]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Release 1.13.1
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python3
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
11 # This program is free software: you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation, either version 3 of the License, or
14 # (at your option) any later version.
15 #
16 # This program is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
20 #
21 # You should have received a copy of the GNU General Public License
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23 #
24
25 from __future__ import print_function
26
27 import binascii
28 import copy
29 import getopt
30 import hashlib
31 import math
32 import os
33 import posix
34 import pwd
35 import re
36 import shlex
37 import subprocess
38 import sys
39 import shutil
40 import time
41 import glob
42
43 from collections import OrderedDict
44 from functools import reduce
45 from itertools import chain
46
47
48 if sys.version_info.major == 2:
49     import ConfigParser as configparser
50 else:
51     import configparser
52
53 __version__ = "1.13.1"
54
55 try:
56     input = raw_input
57 except NameError:
58     pass
59
60 virtual_profiles = [
61     # (name, description, callback)
62     ("off", "Disable all outputs", None),
63     ("common", "Clone all connected outputs at the largest common resolution", None),
64     ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
65     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
66     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
67     ("horizontal-reverse", "Stack all connected outputs horizontally at their largest resolution in reverse order", None),
68     ("vertical-reverse", "Stack all connected outputs vertically at their largest resolution in reverse order", None),
69 ]
70
71 properties = [
72     "Colorspace",
73     "max bpc",
74     "aspect ratio",
75     "Broadcast RGB",
76     "audio",
77     "non-desktop",
78     "TearFree",
79     "underscan vborder",
80     "underscan hborder",
81     "underscan",
82     "scaling mode",
83 ]
84
85 help_text = """
86 Usage: autorandr [options]
87
88 -h, --help              get this small help
89 -c, --change            automatically load the first detected profile
90 -d, --default <profile> make profile <profile> the default profile
91 -l, --load <profile>    load profile <profile>
92 -s, --save <profile>    save your current setup to profile <profile>
93 -r, --remove <profile>  remove profile <profile>
94 --batch                 run autorandr for all users with active X11 sessions
95 --current               only list current (active) configuration(s)
96 --config                dump your current xrandr setup
97 --cycle                 automatically load the next detected profile
98 --debug                 enable verbose output
99 --detected              only list detected (available) configuration(s)
100 --dry-run               don't change anything, only print the xrandr commands
101 --fingerprint           fingerprint your current hardware setup
102 --ignore-lid            treat outputs as connected even if their lids are closed
103 --match-edid            match diplays based on edid instead of name
104 --force                 force (re)loading of a profile / overwrite exiting files
105 --list                  list configurations
106 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
107                         to skip both in detecting changes and applying a profile
108 --version               show version information and exit
109
110  If no suitable profile can be identified, the current configuration is kept.
111  To change this behaviour and switch to a fallback configuration, specify
112  --default <profile>.
113
114  autorandr supports a set of per-profile and global hooks. See the documentation
115  for details.
116
117  The following virtual configurations are available:
118 """.strip()
119
120
121 class Version(object):
122     def __init__(self, version):
123         self._version = version
124         self._version_parts = re.split("([0-9]+)", version)
125
126     def __eq__(self, other):
127         return self._version_parts == other._version_parts
128
129     def __lt__(self, other):
130         for my, theirs in zip(self._version_parts, other._version_parts):
131             if my.isnumeric() and theirs.isnumeric():
132                 my = int(my)
133                 theirs = int(theirs)
134             if my < theirs:
135                 return True
136         return len(theirs) > len(my)
137
138     def __ge__(self, other):
139         return not (self < other)
140
141     def __ne__(self, other):
142         return not (self == other)
143
144     def __le__(self, other):
145         return (self < other) or (self == other)
146
147     def __gt__(self, other):
148         return self >= other and not (self == other)
149
150 def is_closed_lid(output):
151     if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
152         return False
153     lids = glob.glob("/proc/acpi/button/lid/*/state")
154     if len(lids) == 1:
155         state_file = lids[0]
156         with open(state_file) as f:
157             content = f.read()
158             return "close" in content
159     return False
160
161
162 class AutorandrException(Exception):
163     def __init__(self, message, original_exception=None, report_bug=False):
164         self.message = message
165         self.report_bug = report_bug
166         if original_exception:
167             self.original_exception = original_exception
168             trace = sys.exc_info()[2]
169             while trace.tb_next:
170                 trace = trace.tb_next
171             self.line = trace.tb_lineno
172             self.file_name = trace.tb_frame.f_code.co_filename
173         else:
174             try:
175                 import inspect
176                 frame = inspect.currentframe().f_back
177                 self.line = frame.f_lineno
178                 self.file_name = frame.f_code.co_filename
179             except:
180                 self.line = None
181                 self.file_name = None
182             self.original_exception = None
183
184         if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
185             self.file_name = None
186
187     def __str__(self):
188         retval = [self.message]
189         if self.line:
190             retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
191         if self.original_exception:
192             retval.append(":\n  ")
193             retval.append(str(self.original_exception).replace("\n", "\n  "))
194         if self.report_bug:
195             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
196                           "\nhttps://github.com/phillipberndt/autorandr/issues"
197                           "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
198         return "".join(retval)
199
200
201 class XrandrOutput(object):
202     "Represents an XRandR output"
203
204     XRANDR_PROPERTIES_REGEXP = "|".join(
205         [r"{}:\s*(?P<{}>[\S ]*\S+)"
206          .format(re.sub(r"\s", r"\\\g<0>", p), re.sub(r"\W+", "_", p.lower()))
207             for p in properties])
208
209     # This regular expression is used to parse an output in `xrandr --verbose'
210     XRANDR_OUTPUT_REGEXP = """(?x)
211         ^\s*(?P<output>\S[^ ]*)\s+                                                      # Line starts with output name
212         (?:                                                                             # Differentiate disconnected and connected
213             disconnected |                                                              # in first line
214             unknown\ connection |
215             (?P<connected>connected)
216         )
217         \s*
218         (?P<primary>primary\ )?                                                         # Might be primary screen
219         (?:\s*
220             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
221             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
222             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
223             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
224             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
225         )?                                                                              # .. but only if the screen is in use.
226         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
227         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
228         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
229         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
230         (?:\s*(?:                                                                       # Properties of the output
231             Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) |                                 # Gamma value
232             CRTC:\s*(?P<crtc>[0-9]) |                                                   # CRTC value
233             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
234                       filter:\s+(?P<filter>bilinear|nearest) |                          # Transformation filter
235             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
236             """ + XRANDR_PROPERTIES_REGEXP + """ |                                      # Properties to include in the profile
237             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
238         ))+
239         \s*
240         (?P<modes>(?:
241             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution:
242              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+                                   # Extract rate
243              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
244             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
245         )*)
246     """
247
248     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
249         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
250          h:\s+width\s+(?P<width>[0-9]+).+\s+
251          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
252     """
253
254     XRANDR_13_DEFAULTS = {
255         "transform": "1,0,0,0,1,0,0,0,1",
256         "panning": "0x0",
257     }
258
259     XRANDR_12_DEFAULTS = {
260         "reflect": "normal",
261         "rotate": "normal",
262         "gamma": "1.0:1.0:1.0",
263     }
264
265     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
266
267     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
268
269     def __repr__(self):
270         return "<%s%s %s>" % (self.output, self.fingerprint, " ".join(self.option_vector))
271
272     @property
273     def short_edid(self):
274         return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
275
276     @property
277     def options_with_defaults(self):
278         "Return the options dictionary, augmented with the default values that weren't set"
279         if "off" in self.options:
280             return self.options
281         options = {}
282         if xrandr_version() >= Version("1.3"):
283             options.update(self.XRANDR_13_DEFAULTS)
284         if xrandr_version() >= Version("1.2"):
285             options.update(self.XRANDR_12_DEFAULTS)
286         options.update(self.options)
287         if "set" in self.ignored_options:
288             options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
289         return {a: b for a, b in options.items() if a not in self.ignored_options}
290
291     @property
292     def filtered_options(self):
293         "Return a dictionary of options without ignored options"
294         options = {a: b for a, b in self.options.items() if a not in self.ignored_options}
295         if "set" in self.ignored_options:
296             options = {a: b for a, b in options.items() if not a.startswith("x-prop")}
297         return options
298
299     @property
300     def option_vector(self):
301         "Return the command line parameters for XRandR for this instance"
302         args = ["--output", self.output]
303         for option, arg in sorted(self.options_with_defaults.items()):
304             if option.startswith("x-prop-"):
305                 prop_found = False
306                 for prop, xrandr_prop in [(re.sub(r"\W+", "_", p.lower()), p) for p in properties]:
307                     if prop == option[7:]:
308                         args.append("--set")
309                         args.append(xrandr_prop)
310                         prop_found = True
311                         break
312                 if not prop_found:
313                     print("Warning: Unknown property `%s' in config file. Skipping." % option[7:], file=sys.stderr)
314                     continue
315             elif option.startswith("x-"):
316                 print("Warning: Unknown option `%s' in config file. Skipping." % option, file=sys.stderr)
317                 continue
318             else:
319                 args.append("--%s" % option)
320             if arg:
321                 args.append(arg)
322         return args
323
324     @property
325     def option_string(self):
326         "Return the command line parameters in the configuration file format"
327         options = ["output %s" % self.output]
328         for option, arg in sorted(self.filtered_options.items()):
329             if arg:
330                 options.append("%s %s" % (option, arg))
331             else:
332                 options.append(option)
333         return "\n".join(options)
334
335     @property
336     def sort_key(self):
337         "Return a key to sort the outputs for xrandr invocation"
338         if not self.edid:
339             return -2
340         if "off" in self.options:
341             return -1
342         if "pos" in self.options:
343             x, y = map(float, self.options["pos"].split("x"))
344         else:
345             x, y = 0, 0
346         return x + 10000 * y
347
348     def __init__(self, output, edid, options):
349         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
350         self.output = output
351         self.edid = edid
352         self.options = options
353         self.ignored_options = []
354         self.parse_serial_from_edid()
355         self.remove_default_option_values()
356
357     def parse_serial_from_edid(self):
358         self.serial = None
359         if self.edid:
360             if self.EDID_UNAVAILABLE in self.edid:
361                 return
362             # Thx to pyedid project, the following code was
363             # copied (and modified) from pyedid/__init__py:21 [parse_edid()]
364             raw = bytes.fromhex(self.edid)
365             # Check EDID header, and checksum
366             if raw[:8] != b'\x00\xff\xff\xff\xff\xff\xff\x00' or sum(raw) % 256 != 0:
367                 return
368             serial_no = int.from_bytes(raw[15:11:-1], byteorder='little')
369
370             serial_text = None
371             # Offsets of standard timing information descriptors 1-4
372             # (see https://en.wikipedia.org/wiki/Extended_Display_Identification_Data#EDID_1.4_data_format)
373             for timing_bytes in (raw[54:72], raw[72:90], raw[90:108], raw[108:126]):
374                 if timing_bytes[0:2] == b'\x00\x00':
375                     timing_type = timing_bytes[3]
376                     if timing_type == 0xFF:
377                         buffer = timing_bytes[5:]
378                         buffer = buffer.partition(b'\x0a')[0]
379                         serial_text = buffer.decode('cp437')
380             self.serial = serial_text if serial_text else "0x{:x}".format(serial_no) if serial_no != 0 else None
381
382     def set_ignored_options(self, options):
383         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
384         self.ignored_options = list(options)
385
386     def remove_default_option_values(self):
387         "Remove values from the options dictionary that are superflous"
388         if "off" in self.options and len(self.options.keys()) > 1:
389             self.options = {"off": None}
390             return
391         for option, default_value in self.XRANDR_DEFAULTS.items():
392             if option in self.options and self.options[option] == default_value:
393                 del self.options[option]
394
395     @classmethod
396     def from_xrandr_output(cls, xrandr_output):
397         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
398
399         This method also returns a list of modes supported by the output.
400         """
401         try:
402             xrandr_output = xrandr_output.replace("\r\n", "\n")
403             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
404         except:
405             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
406                                      report_bug=True)
407         if not match_object:
408             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
409             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
410                                      report_bug=True)
411         remainder = xrandr_output[len(match_object.group(0)):]
412         if remainder:
413             raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
414                                      "regular expression, starting at byte %d with ..'%s'." %
415                                      (len(remainder), len(match_object.group(0)), remainder[:10]),
416                                      report_bug=True)
417
418         match = match_object.groupdict()
419
420         modes = []
421         if match["modes"]:
422             modes = []
423             for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
424                 if mode_match.group("name"):
425                     modes.append(mode_match.groupdict())
426             if not modes:
427                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
428
429         options = {}
430         if not match["connected"]:
431             edid = None
432         elif match["edid"]:
433             edid = "".join(match["edid"].strip().split())
434         else:
435             edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
436
437         # An output can be disconnected but still have a mode configured. This can only happen
438         # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
439         # output.
440         #
441         # This code needs to be careful not to mix the two. An output should only be configured to
442         # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
443         if not match["width"]:
444             options["off"] = None
445         else:
446             if match["mode_name"]:
447                 options["mode"] = match["mode_name"]
448             elif match["mode_width"]:
449                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
450             else:
451                 if match["rotate"] not in ("left", "right"):
452                     options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
453                 else:
454                     options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
455             if match["rotate"]:
456                 options["rotate"] = match["rotate"]
457             if match["primary"]:
458                 options["primary"] = None
459             if match["reflect"] == "X":
460                 options["reflect"] = "x"
461             elif match["reflect"] == "Y":
462                 options["reflect"] = "y"
463             elif match["reflect"] == "X and Y":
464                 options["reflect"] = "xy"
465             if match["x"] or match["y"]:
466                 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
467             if match["panning"]:
468                 panning = [match["panning"]]
469                 if match["tracking"]:
470                     panning += ["/", match["tracking"]]
471                     if match["border"]:
472                         panning += ["/", match["border"]]
473                 options["panning"] = "".join(panning)
474             if match["transform"]:
475                 transformation = ",".join(match["transform"].strip().split())
476                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
477                     options["transform"] = transformation
478                     if not match["mode_name"]:
479                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
480                         # I doubt that this special case is actually required.
481                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
482                               "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
483             if match["filter"]:
484                 options["filter"] = match["filter"]
485             if match["gamma"]:
486                 gamma = match["gamma"].strip()
487                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
488                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
489                 # so we approximate by 1e-10.
490                 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
491                 options["gamma"] = gamma
492             if match["crtc"]:
493                 options["crtc"] = match["crtc"]
494             if match["rate"]:
495                 options["rate"] = match["rate"]
496             for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
497                 if match[prop]:
498                     options["x-prop-" + prop] = match[prop]
499
500         return XrandrOutput(match["output"], edid, options), modes
501
502     @classmethod
503     def from_config_file(cls, profile, edid_map, configuration):
504         "Instanciate an XrandrOutput from the contents of a configuration file"
505         options = {}
506         for line in configuration.split("\n"):
507             if line:
508                 line = line.split(None, 1)
509                 if line and line[0].startswith("#"):
510                     continue
511                 options[line[0]] = line[1] if len(line) > 1 else None
512
513         edid = None
514
515         if options["output"] in edid_map:
516             edid = edid_map[options["output"]]
517         else:
518             # This fuzzy matching is for legacy autorandr that used sysfs output names
519             fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
520             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
521             if fuzzy_output in fuzzy_edid_map:
522                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
523             elif "off" not in options:
524                 raise AutorandrException("Profile `%s': Failed to find an EDID for output `%s' in setup file, required "
525                                          "as `%s' is not off in config file." % (profile, options["output"], options["output"]))
526         output = options["output"]
527         del options["output"]
528
529         return XrandrOutput(output, edid, options)
530
531     @property
532     def fingerprint(self):
533         return str(self.serial) if self.serial else self.short_edid
534
535     def fingerprint_equals(self, other):
536         if self.serial and other.serial:
537            return self.serial == other.serial
538         else:
539            return self.edid_equals(other)
540
541     def edid_equals(self, other):
542         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
543         if self.edid and other.edid:
544             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
545                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
546             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
547                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
548             if "*" in self.edid:
549                 return match_asterisk(self.edid, other.edid) > 0
550             elif "*" in other.edid:
551                 return match_asterisk(other.edid, self.edid) > 0
552         return self.edid == other.edid
553
554     def __ne__(self, other):
555         return not (self == other)
556
557     def __eq__(self, other):
558         return self.fingerprint_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
559
560     def verbose_diff(self, other):
561         "Compare to another XrandrOutput and return a list of human readable differences"
562         diffs = []
563         if not self.fingerprint_equals(other):
564             diffs.append("EDID `%s' differs from `%s'" % (self.fingerprint, other.fingerprint))
565         if self.output != other.output:
566             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
567         if "off" in self.options and "off" not in other.options:
568             diffs.append("The output is disabled currently, but active in the new configuration")
569         elif "off" in other.options and "off" not in self.options:
570             diffs.append("The output is currently enabled, but inactive in the new configuration")
571         else:
572             for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
573                 if name not in other.options:
574                     diffs.append("Option --%s %sis not present in the new configuration" %
575                                  (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
576                 elif name not in self.options:
577                     diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
578                                  (name, other.options[name]))
579                 elif self.options[name] != other.options[name]:
580                     diffs.append("Option --%s %sis `%s' in the new configuration" %
581                                  (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
582         return diffs
583
584
585 def xrandr_version():
586     "Return the version of XRandR that this system uses"
587     if getattr(xrandr_version, "version", False) is False:
588         version_string = os.popen("xrandr -v").read()
589         try:
590             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
591             xrandr_version.version = Version(version)
592         except AttributeError:
593             xrandr_version.version = Version("1.3.0")
594
595     return xrandr_version.version
596
597
598 def debug_regexp(pattern, string):
599     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
600     try:
601         import regex
602         bounds = (0, len(string))
603         while bounds[0] != bounds[1]:
604             half = int((bounds[0] + bounds[1]) / 2)
605             if half == bounds[0]:
606                 break
607             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
608         partial_length = bounds[0]
609         return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
610                 (partial_length, string[max(0, partial_length - 20):partial_length],
611                  string[partial_length:partial_length + 10]))
612     except ImportError:
613         pass
614     return "Debug information would be available if the `regex' module was installed."
615
616
617 def parse_xrandr_output(
618     *,
619     ignore_lid,
620 ):
621     "Parse the output of `xrandr --verbose' into a list of outputs"
622     xrandr_output = os.popen("xrandr -q --verbose").read()
623     if not xrandr_output:
624         raise AutorandrException("Failed to run xrandr")
625
626     # We are not interested in screens
627     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
628
629     # Split at output boundaries and instanciate an XrandrOutput per output
630     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
631     if len(split_xrandr_output) < 2:
632         raise AutorandrException("No output boundaries found", report_bug=True)
633     outputs = OrderedDict()
634     modes = OrderedDict()
635     for i in range(1, len(split_xrandr_output), 2):
636         output_name = split_xrandr_output[i].split()[0]
637         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
638         outputs[output_name] = output
639         if output_modes:
640             modes[output_name] = output_modes
641
642     # consider a closed lid as disconnected if other outputs are connected
643     if not ignore_lid and sum(
644         o.edid != None
645         for o
646         in outputs.values()
647     ) > 1:
648         for output_name in outputs.keys():
649             if is_closed_lid(output_name):
650                 outputs[output_name].edid = None
651
652     return outputs, modes
653
654
655 def load_profiles(profile_path):
656     "Load the stored profiles"
657
658     profiles = {}
659     for profile in os.listdir(profile_path):
660         config_name = os.path.join(profile_path, profile, "config")
661         setup_name = os.path.join(profile_path, profile, "setup")
662         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
663             continue
664
665         edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
666
667         config = {}
668         buffer = []
669         for line in chain(open(config_name).readlines(), ["output"]):
670             if line[:6] == "output" and buffer:
671                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(profile, edids, "".join(buffer))
672                 buffer = [line]
673             else:
674                 buffer.append(line)
675
676         for output_name in list(config.keys()):
677             if config[output_name].edid is None:
678                 del config[output_name]
679
680         profiles[profile] = {
681             "config": config,
682             "path": os.path.join(profile_path, profile),
683             "config-mtime": os.stat(config_name).st_mtime,
684         }
685
686     return profiles
687
688
689 def get_symlinks(profile_path):
690     "Load all symlinks from a directory"
691
692     symlinks = {}
693     for link in os.listdir(profile_path):
694         file_name = os.path.join(profile_path, link)
695         if os.path.islink(file_name):
696             symlinks[link] = os.readlink(file_name)
697
698     return symlinks
699
700
701 def match_asterisk(pattern, data):
702     """Match data against a pattern
703
704     The difference to fnmatch is that this function only accepts patterns with a single
705     asterisk and that it returns a "closeness" number, which is larger the better the match.
706     Zero indicates no match at all.
707     """
708     if "*" not in pattern:
709         return 1 if pattern == data else 0
710     parts = pattern.split("*")
711     if len(parts) > 2:
712         raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
713     if not data.startswith(parts[0]):
714         return 0
715     if not data.endswith(parts[1]):
716         return 0
717     matched = len(pattern)
718     total = len(data) + 1
719     return matched * 1. / total
720
721
722 def update_profiles_edid(profiles, config):
723     fp_map = {}
724     for c in config:
725         if config[c].fingerprint is not None:
726             fp_map[config[c].fingerprint] = c
727
728     for p in profiles:
729         profile_config = profiles[p]["config"]
730
731         for fingerprint in fp_map:
732             for c in list(profile_config.keys()):
733                 if profile_config[c].fingerprint != fingerprint or c == fp_map[fingerprint]:
734                     continue
735
736                 print("%s: renaming display %s to %s" % (p, c, fp_map[fingerprint]))
737
738                 tmp_disp = profile_config[c]
739
740                 if fp_map[fingerprint] in profile_config:
741                     # Swap the two entries
742                     profile_config[c] = profile_config[fp_map[fingerprint]]
743                     profile_config[c].output = c
744                 else:
745                     # Object is reassigned to another key, drop this one
746                     del profile_config[c]
747
748                 profile_config[fp_map[fingerprint]] = tmp_disp
749                 profile_config[fp_map[fingerprint]].output = fp_map[fingerprint]
750
751
752 def find_profiles(current_config, profiles):
753     "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
754     detected_profiles = []
755     for profile_name, profile in profiles.items():
756         config = profile["config"]
757         matches = True
758         for name, output in config.items():
759             if not output.fingerprint:
760                 continue
761             if name not in current_config or not output.fingerprint_equals(current_config[name]):
762                 matches = False
763                 break
764         if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].fingerprint)):
765             continue
766         if matches:
767             closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
768                 current_config[name].edid, output.edid))
769             detected_profiles.append((closeness, profile_name))
770     detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
771     return detected_profiles
772
773
774 def profile_blocked(profile_path, meta_information=None):
775     """Check if a profile is blocked.
776
777     meta_information is expected to be an dictionary. It will be passed to the block scripts
778     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
779     """
780     return not exec_scripts(profile_path, "block", meta_information)
781
782
783 def check_configuration_pre_save(configuration):
784     "Check that a configuration is safe for saving."
785     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
786     for output in outputs:
787         if "off" not in configuration[output].options and not configuration[output].edid:
788             return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
789                     "This typically means that it has been recently unplugged and then not properly disabled\n"
790                     "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
791                     "this command.") % {"o": output}
792
793
794 def output_configuration(configuration, config):
795     "Write a configuration file"
796     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
797     for output in outputs:
798         print(configuration[output].option_string, file=config)
799
800
801 def output_setup(configuration, setup):
802     "Write a setup (fingerprint) file"
803     outputs = sorted(configuration.keys())
804     for output in outputs:
805         if configuration[output].edid:
806             print(output, configuration[output].edid, file=setup)
807
808
809 def save_configuration(profile_path, profile_name, configuration, forced=False):
810     "Save a configuration into a profile"
811     if not os.path.isdir(profile_path):
812         os.makedirs(profile_path)
813     config_path = os.path.join(profile_path, "config")
814     setup_path = os.path.join(profile_path, "setup")
815     if os.path.isfile(config_path) and not forced:
816         raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
817     if os.path.isfile(setup_path) and not forced:
818         raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
819
820     with open(config_path, "w") as config:
821         output_configuration(configuration, config)
822     with open(setup_path, "w") as setup:
823         output_setup(configuration, setup)
824
825
826 def update_mtime(filename):
827     "Update a file's mtime"
828     try:
829         os.utime(filename, None)
830         return True
831     except:
832         return False
833
834
835 def call_and_retry(*args, **kwargs):
836     """Wrapper around subprocess.call that retries failed calls.
837
838     This function calls subprocess.call and on non-zero exit states,
839     waits a second and then retries once. This mitigates #47,
840     a timing issue with some drivers.
841     """
842     if kwargs.pop("dry_run", False):
843         for arg in args[0]:
844             print(shlex.quote(arg), end=" ")
845         print()
846         return 0
847     else:
848         if hasattr(subprocess, "DEVNULL"):
849             kwargs["stdout"] = getattr(subprocess, "DEVNULL")
850         else:
851             kwargs["stdout"] = open(os.devnull, "w")
852         kwargs["stderr"] = kwargs["stdout"]
853         retval = subprocess.call(*args, **kwargs)
854         if retval != 0:
855             time.sleep(1)
856             retval = subprocess.call(*args, **kwargs)
857         return retval
858
859
860 def get_fb_dimensions(configuration):
861     width = 0
862     height = 0
863     for output in configuration.values():
864         if "off" in output.options or not output.edid:
865             continue
866         # This won't work with all modes -- but it's a best effort.
867         match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
868         if not match:
869             return None
870         o_mode = match.group(0)
871         o_width, o_height = map(int, o_mode.split("x"))
872         if "transform" in output.options:
873             a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
874             w = (g * o_width + h * o_height + i)
875             x = (a * o_width + b * o_height + c) / w
876             y = (d * o_width + e * o_height + f) / w
877             o_width, o_height = x, y
878         if "rotate" in output.options:
879             if output.options["rotate"] in ("left", "right"):
880                 o_width, o_height = o_height, o_width
881         if "pos" in output.options:
882             o_left, o_top = map(int, output.options["pos"].split("x"))
883             o_width += o_left
884             o_height += o_top
885         if "panning" in output.options:
886             match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
887             if match:
888                 detail = match.groupdict(default="0")
889                 o_width = int(detail.get("w")) + int(detail.get("x"))
890                 o_height = int(detail.get("h")) + int(detail.get("y"))
891         width = max(width, o_width)
892         height = max(height, o_height)
893     return math.ceil(width), math.ceil(height)
894
895
896 def apply_configuration(new_configuration, current_configuration, dry_run=False):
897     "Apply a configuration"
898     found_top_left_monitor = False
899     found_left_monitor = False
900     found_top_monitor = False
901     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
902     base_argv = ["xrandr"]
903
904     # There are several xrandr / driver bugs we need to take care of here:
905     # - We cannot enable more than two screens at the same time
906     #   See https://github.com/phillipberndt/autorandr/pull/6
907     #   and commits f4cce4d and 8429886.
908     # - We cannot disable all screens
909     #   See https://github.com/phillipberndt/autorandr/pull/20
910     # - We should disable screens before enabling others, because there's
911     #   a limit on the number of enabled screens
912     # - We must make sure that the screen at 0x0 is activated first,
913     #   or the other (first) screen to be activated would be moved there.
914     # - If an active screen already has a transformation and remains active,
915     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
916     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
917     #   at least.)
918     # - Some implementations can not handle --transform at all, so avoid it unless
919     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
920     # - Some implementations can not handle --panning without specifying --fb
921     #   explicitly, so avoid it unless necessary.
922     #   (See https://github.com/phillipberndt/autorandr/issues/72)
923
924     fb_dimensions = get_fb_dimensions(new_configuration)
925     try:
926         fb_args = ["--fb", "%dx%d" % fb_dimensions]
927     except:
928         # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
929         fb_args = []
930
931     auxiliary_changes_pre = []
932     disable_outputs = []
933     enable_outputs = []
934     remain_active_count = 0
935     for output in outputs:
936         if not new_configuration[output].edid or "off" in new_configuration[output].options:
937             disable_outputs.append(new_configuration[output].option_vector)
938         else:
939             if output not in current_configuration:
940                 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
941                                          "Don't know how to proceed." % output)
942             if "off" not in current_configuration[output].options:
943                 remain_active_count += 1
944
945             option_vector = new_configuration[output].option_vector
946             if xrandr_version() >= Version("1.3.0"):
947                 for option, off_value in (("transform", "none"), ("panning", "0x0")):
948                     if option in current_configuration[output].options:
949                         auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
950                     else:
951                         try:
952                             option_index = option_vector.index("--%s" % option)
953                             if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
954                                 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
955                         except ValueError:
956                             pass
957             if not found_top_left_monitor:
958                 position = new_configuration[output].options.get("pos", "0x0")
959                 if position == "0x0":
960                     found_top_left_monitor = True
961                     enable_outputs.insert(0, option_vector)
962                 elif not found_left_monitor and position.startswith("0x"):
963                     found_left_monitor = True
964                     enable_outputs.insert(0, option_vector)
965                 elif not found_top_monitor and position.endswith("x0"):
966                     found_top_monitor = True
967                     enable_outputs.insert(0, option_vector)
968                 else:
969                     enable_outputs.append(option_vector)
970             else:
971                 enable_outputs.append(option_vector)
972
973     # Perform pe-change auxiliary changes
974     if auxiliary_changes_pre:
975         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
976         if call_and_retry(argv, dry_run=dry_run) != 0:
977             raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
978
979     # Starting here, fix the frame buffer size
980     # Do not do this earlier, as disabling scaling might temporarily make the framebuffer
981     # dimensions larger than they will finally be.
982     base_argv += fb_args
983
984     # Disable unused outputs, but make sure that there always is at least one active screen
985     disable_keep = 0 if remain_active_count else 1
986     if len(disable_outputs) > disable_keep:
987         argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
988         if call_and_retry(argv, dry_run=dry_run) != 0:
989             # Disabling the outputs failed. Retry with the next command:
990             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
991             # This does not occur if simultaneously the primary screen is reset.
992             pass
993         else:
994             disable_outputs = disable_outputs[-1:] if disable_keep else []
995
996     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
997     # disable the last two screens. This is a problem, so if this would happen, instead disable only
998     # one screen in the first call below.
999     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
1000         # In the context of a xrandr call that changes the display state, `--query' should do nothing
1001         disable_outputs.insert(0, ['--query'])
1002
1003     # If we did not find a candidate, we might need to inject a call
1004     # If there is no output to disable, we will enable 0x and x0 at the same time
1005     if not found_top_left_monitor and len(disable_outputs) > 0:
1006         # If the call to 0x and x0 is splitted, inject one of them
1007         if found_top_monitor and found_left_monitor:
1008             enable_outputs.insert(0, enable_outputs[0])
1009
1010     # Enable the remaining outputs in pairs of two operations
1011     operations = disable_outputs + enable_outputs
1012     for index in range(0, len(operations), 2):
1013         argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
1014         if call_and_retry(argv, dry_run=dry_run) != 0:
1015             raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
1016
1017
1018 def is_equal_configuration(source_configuration, target_configuration):
1019     """
1020         Check if all outputs from target are already configured correctly in source and
1021         that no other outputs are active.
1022     """
1023     for output in target_configuration.keys():
1024         if "off" in target_configuration[output].options:
1025             if (output in source_configuration and "off" not in source_configuration[output].options):
1026                 return False
1027         else:
1028             if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
1029                 return False
1030     for output in source_configuration.keys():
1031         if "off" in source_configuration[output].options:
1032             if output in target_configuration and "off" not in target_configuration[output].options:
1033                 return False
1034         else:
1035             if output not in target_configuration:
1036                 return False
1037     return True
1038
1039
1040 def add_unused_outputs(source_configuration, target_configuration):
1041     "Add outputs that are missing in target to target, in 'off' state"
1042     for output_name, output in source_configuration.items():
1043         if output_name not in target_configuration:
1044             target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
1045
1046
1047 def remove_irrelevant_outputs(source_configuration, target_configuration):
1048     "Remove outputs from target that ought to be 'off' and already are"
1049     for output_name, output in source_configuration.items():
1050         if "off" in output.options:
1051             if output_name in target_configuration:
1052                 if "off" in target_configuration[output_name].options:
1053                     del target_configuration[output_name]
1054
1055
1056 def generate_virtual_profile(configuration, modes, profile_name):
1057     "Generate one of the virtual profiles"
1058     configuration = copy.deepcopy(configuration)
1059     if profile_name == "common":
1060         mode_sets = []
1061         for output, output_modes in modes.items():
1062             mode_set = set()
1063             if configuration[output].edid:
1064                 for mode in output_modes:
1065                     mode_set.add((mode["width"], mode["height"]))
1066             mode_sets.append(mode_set)
1067         common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
1068         common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
1069         if common_resolution:
1070             for output in configuration:
1071                 configuration[output].options = {}
1072                 if output in modes and configuration[output].edid:
1073                     modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
1074                     modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
1075                     mode = modes_filtered[0]
1076                     configuration[output].options["mode"] = mode['name']
1077                     configuration[output].options["pos"] = "0x0"
1078                 else:
1079                     configuration[output].options["off"] = None
1080     elif profile_name in ("horizontal", "vertical", "horizontal-reverse", "vertical-reverse"):
1081         shift = 0
1082         if profile_name.startswith("horizontal"):
1083             shift_index = "width"
1084             pos_specifier = "%sx0"
1085         else:
1086             shift_index = "height"
1087             pos_specifier = "0x%s"
1088             
1089         config_iter = reversed(configuration) if "reverse" in profile_name else iter(configuration)
1090             
1091         for output in config_iter:
1092             configuration[output].options = {}
1093             if output in modes and configuration[output].edid:
1094                 def key(a):
1095                     score = int(a["width"]) * int(a["height"])
1096                     if a["preferred"]:
1097                         score += 10**6
1098                     return score
1099                 output_modes = sorted(modes[output], key=key)
1100                 mode = output_modes[-1]
1101                 configuration[output].options["mode"] = mode["name"]
1102                 configuration[output].options["rate"] = mode["rate"]
1103                 configuration[output].options["pos"] = pos_specifier % shift
1104                 shift += int(mode[shift_index])
1105             else:
1106                 configuration[output].options["off"] = None
1107     elif profile_name == "clone-largest":
1108         modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
1109         modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
1110         biggest_resolution = modes_sorted[0]
1111         for output in configuration:
1112             configuration[output].options = {}
1113             if output in modes and configuration[output].edid:
1114                 def key(a):
1115                     score = int(a["width"]) * int(a["height"])
1116                     if a["preferred"]:
1117                         score += 10**6
1118                     return score
1119                 output_modes = sorted(modes[output], key=key)
1120                 mode = output_modes[-1]
1121                 configuration[output].options["mode"] = mode["name"]
1122                 configuration[output].options["rate"] = mode["rate"]
1123                 configuration[output].options["pos"] = "0x0"
1124                 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1125                             float(biggest_resolution["height"]) / float(mode["height"]))
1126                 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1127                 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1128                 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1129             else:
1130                 configuration[output].options["off"] = None
1131     elif profile_name == "off":
1132         for output in configuration:
1133             for key in list(configuration[output].options.keys()):
1134                 del configuration[output].options[key]
1135             configuration[output].options["off"] = None
1136     return configuration
1137
1138
1139 def print_profile_differences(one, another):
1140     "Print the differences between two profiles for debugging"
1141     if one == another:
1142         return
1143     print("| Differences between the two profiles:")
1144     for output in set(chain.from_iterable((one.keys(), another.keys()))):
1145         if output not in one:
1146             if "off" not in another[output].options:
1147                 print("| Output `%s' is missing from the active configuration" % output)
1148         elif output not in another:
1149             if "off" not in one[output].options:
1150                 print("| Output `%s' is missing from the new configuration" % output)
1151         else:
1152             for line in one[output].verbose_diff(another[output]):
1153                 print("| [Output %s] %s" % (output, line))
1154     print("\\-")
1155
1156
1157 def exit_help():
1158     "Print help and exit"
1159     print(help_text)
1160     for profile in virtual_profiles:
1161         name, description = profile[:2]
1162         description = [description]
1163         max_width = 78 - 18
1164         while len(description[0]) > max_width + 1:
1165             left_over = description[0][max_width:]
1166             description[0] = description[0][:max_width] + "-"
1167             description.insert(1, "  %-15s %s" % ("", left_over))
1168         description = "\n".join(description)
1169         print("  %-15s %s" % (name, description))
1170     sys.exit(0)
1171
1172
1173 def exec_scripts(profile_path, script_name, meta_information=None):
1174     """"Run userscripts
1175
1176     This will run all executables from the profile folder, and global per-user
1177     and system-wide configuration folders, named script_name or residing in
1178     subdirectories named script_name.d.
1179
1180     If profile_path is None, only global scripts will be invoked.
1181
1182     meta_information is expected to be an dictionary. It will be passed to the block scripts
1183     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1184
1185     Returns True unless any of the scripts exited with non-zero exit status.
1186     """
1187     all_ok = True
1188     env = os.environ.copy()
1189     if meta_information:
1190         for key, value in meta_information.items():
1191             env["AUTORANDR_{}".format(key.upper())] = str(value)
1192
1193     # If there are multiple candidates, the XDG spec tells to only use the first one.
1194     ran_scripts = set()
1195
1196     user_profile_path = os.path.expanduser("~/.autorandr")
1197     if not os.path.isdir(user_profile_path):
1198         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1199
1200     candidate_directories = []
1201     if profile_path:
1202         candidate_directories.append(profile_path)
1203     candidate_directories.append(user_profile_path)
1204     for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1205         candidate_directories.append(os.path.join(config_dir, "autorandr"))
1206
1207     for folder in candidate_directories:
1208         if script_name not in ran_scripts:
1209             script = os.path.join(folder, script_name)
1210             if os.access(script, os.X_OK | os.F_OK):
1211                 try:
1212                     all_ok &= subprocess.call(script, env=env) != 0
1213                 except Exception as e:
1214                     raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
1215                 ran_scripts.add(script_name)
1216
1217         script_folder = os.path.join(folder, "%s.d" % script_name)
1218         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1219             for file_name in sorted(os.listdir(script_folder)):
1220                 check_name = "d/%s" % (file_name,)
1221                 if check_name not in ran_scripts:
1222                     script = os.path.join(script_folder, file_name)
1223                     if os.access(script, os.X_OK | os.F_OK):
1224                         try:
1225                             all_ok &= subprocess.call(script, env=env) != 0
1226                         except Exception as e:
1227                             raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
1228                         ran_scripts.add(check_name)
1229
1230     return all_ok
1231
1232
1233 def dispatch_call_to_sessions(argv):
1234     """Invoke autorandr for each open local X11 session with the given options.
1235
1236     The function iterates over all processes not owned by root and checks
1237     whether they have DISPLAY and XAUTHORITY variables set. It strips the
1238     screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1239     this display has been handled already. If it has not, it forks, changes
1240     uid/gid to the user owning the process, reuses the process's environment
1241     and runs autorandr with the parameters from argv.
1242
1243     This function requires root permissions. It only works for X11 servers that
1244     have at least one non-root process running. It is susceptible for attacks
1245     where one user runs a process with another user's DISPLAY variable - in
1246     this case, it might happen that autorandr is invoked for the other user,
1247     which won't work. Since no other harm than prevention of automated
1248     execution of autorandr can be done this way, the assumption is that in this
1249     situation, the local administrator will handle the situation."""
1250
1251     X11_displays_done = set()
1252
1253     autorandr_binary = os.path.abspath(argv[0])
1254     backup_candidates = {}
1255
1256     def fork_child_autorandr(pwent, process_environ):
1257         print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1258         child_pid = os.fork()
1259         if child_pid == 0:
1260             # This will throw an exception if any of the privilege changes fails,
1261             # so it should be safe. Also, note that since the environment
1262             # is taken from a process owned by the user, reusing it should
1263             # not leak any information.
1264             try:
1265                 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1266             except AttributeError:
1267                 # Python 2 doesn't have getgrouplist
1268                 os.setgroups([])
1269             os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1270             os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1271             os.chdir(pwent.pw_dir)
1272             os.environ.clear()
1273             os.environ.update(process_environ)
1274             if sys.executable != "" and sys.executable != None:
1275                 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1276             else:
1277                 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1278             sys.exit(1)
1279         os.waitpid(child_pid, 0)
1280
1281     # The following line assumes that user accounts start at 1000 and that no
1282     # one works using the root or another system account. This is rather
1283     # restrictive, but de facto default. If this breaks your use case, set the
1284     # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
1285     # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
1286     # effectively, both values aren't binding in any way.)
1287     uid_min = 1000
1288     if 'AUTORANDR_UID_MIN' in os.environ:
1289       uid_min = int(os.environ['AUTORANDR_UID_MIN'])
1290
1291     for directory in os.listdir("/proc"):
1292         directory = os.path.join("/proc/", directory)
1293         if not os.path.isdir(directory):
1294             continue
1295         environ_file = os.path.join(directory, "environ")
1296         if not os.path.isfile(environ_file):
1297             continue
1298         uid = os.stat(environ_file).st_uid
1299
1300         if uid < uid_min:
1301             continue
1302
1303         process_environ = {}
1304         for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1305             try:
1306                 environ_entry = environ_entry.decode("ascii")
1307             except UnicodeDecodeError:
1308                 continue
1309             name, sep, value = environ_entry.partition("=")
1310             if name and sep:
1311                 if name == "DISPLAY" and "." in value:
1312                     value = value[:value.find(".")]
1313                 process_environ[name] = value
1314
1315         if "DISPLAY" not in process_environ:
1316             # Cannot work with this environment, skip.
1317             continue
1318
1319         # To allow scripts to detect batch invocation (especially useful for predetect)
1320         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1321         process_environ["UID"] = str(uid)
1322
1323         display = process_environ["DISPLAY"]
1324
1325         if "XAUTHORITY" not in process_environ:
1326             # It's very likely that we cannot work with this environment either,
1327             # but keep it as a backup just in case we don't find anything else.
1328             backup_candidates[display] = process_environ
1329             continue
1330
1331         if display not in X11_displays_done:
1332             try:
1333                 pwent = pwd.getpwuid(uid)
1334             except KeyError:
1335                 # User has no pwd entry
1336                 continue
1337
1338             fork_child_autorandr(pwent, process_environ)
1339             X11_displays_done.add(display)
1340
1341     # Run autorandr for any users/displays which didn't have a process with
1342     # XAUTHORITY set.
1343     for display, process_environ in backup_candidates.items():
1344         if display not in X11_displays_done:
1345             try:
1346                 pwent = pwd.getpwuid(int(process_environ["UID"]))
1347             except KeyError:
1348                 # User has no pwd entry
1349                 continue
1350
1351             fork_child_autorandr(pwent, process_environ)
1352             X11_displays_done.add(display)
1353
1354
1355 def enabled_monitors(config):
1356     monitors = []
1357     for monitor in config:
1358         if "--off" in config[monitor].option_vector:
1359             continue
1360         monitors.append(monitor)
1361     return monitors
1362
1363
1364 def read_config(options, directory):
1365     """Parse a configuration config.ini from directory and merge it into
1366     the options dictionary"""
1367     config = configparser.ConfigParser()
1368     config.read(os.path.join(directory, "settings.ini"))
1369     if config.has_section("config"):
1370         for key, value in config.items("config"):
1371             options.setdefault("--%s" % key, value)
1372
1373 def main(argv):
1374     try:
1375         opts, args = getopt.getopt(
1376             argv[1:],
1377             "s:r:l:d:cfh",
1378             [
1379                 "batch",
1380                 "dry-run",
1381                 "change",
1382                 "cycle",
1383                 "default=",
1384                 "save=",
1385                 "remove=",
1386                 "load=",
1387                 "force",
1388                 "fingerprint",
1389                 "config",
1390                 "debug",
1391                 "skip-options=",
1392                 "help",
1393                 "list",
1394                 "current",
1395                 "detected",
1396                 "version",
1397                 "match-edid",
1398                 "ignore-lid"
1399             ]
1400         )
1401     except getopt.GetoptError as e:
1402         print("Failed to parse options: {0}.\n"
1403               "Use --help to get usage information.".format(str(e)),
1404               file=sys.stderr)
1405         sys.exit(posix.EX_USAGE)
1406
1407     options = dict(opts)
1408
1409     if "-h" in options or "--help" in options:
1410         exit_help()
1411
1412     if "--version" in options:
1413         print("autorandr " + __version__)
1414         sys.exit(0)
1415
1416     if "--current" in options and "--detected" in options:
1417         print("--current and --detected are mutually exclusive.", file=sys.stderr)
1418         sys.exit(posix.EX_USAGE)
1419
1420     # Batch mode
1421     if "--batch" in options:
1422         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1423             dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1424         else:
1425             print("--batch mode can only be used by root and if $DISPLAY is unset")
1426         return
1427     if "AUTORANDR_BATCH_PID" in os.environ:
1428         user = pwd.getpwuid(os.getuid())
1429         user = user.pw_name if user else "#%d" % os.getuid()
1430         print("autorandr running as user %s (started from batch instance)" % user)
1431
1432     profiles = {}
1433     profile_symlinks = {}
1434     try:
1435         # Load profiles from each XDG config directory
1436         # The XDG spec says that earlier entries should take precedence, so reverse the order
1437         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1438             system_profile_path = os.path.join(directory, "autorandr")
1439             if os.path.isdir(system_profile_path):
1440                 profiles.update(load_profiles(system_profile_path))
1441                 profile_symlinks.update(get_symlinks(system_profile_path))
1442                 read_config(options, system_profile_path)
1443         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1444         # profile_path is also used later on to store configurations
1445         profile_path = os.path.expanduser("~/.autorandr")
1446         if not os.path.isdir(profile_path):
1447             # Elsewise, follow the XDG specification
1448             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1449         if os.path.isdir(profile_path):
1450             profiles.update(load_profiles(profile_path))
1451             profile_symlinks.update(get_symlinks(profile_path))
1452             read_config(options, profile_path)
1453     except Exception as e:
1454         raise AutorandrException("Failed to load profiles", e)
1455
1456     exec_scripts(None, "predetect")
1457
1458     ignore_lid = "--ignore-lid" in options
1459
1460     config, modes = parse_xrandr_output(
1461         ignore_lid=ignore_lid,
1462     )
1463
1464     if "--match-edid" in options:
1465         update_profiles_edid(profiles, config)
1466
1467     # Sort by mtime
1468     sort_direction = -1
1469     if "--cycle" in options:
1470         # When cycling through profiles, put the profile least recently used to the top of the list
1471         sort_direction = 1
1472     profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1473     profile_symlinks = {k: v for k, v in profile_symlinks.items() if v in (x[0] for x in virtual_profiles) or v in profiles}
1474
1475     if "--fingerprint" in options:
1476         output_setup(config, sys.stdout)
1477         sys.exit(0)
1478
1479     if "--config" in options:
1480         output_configuration(config, sys.stdout)
1481         sys.exit(0)
1482
1483     if "--skip-options" in options:
1484         skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1485         for profile in profiles.values():
1486             for output in profile["config"].values():
1487                 output.set_ignored_options(skip_options)
1488         for output in config.values():
1489             output.set_ignored_options(skip_options)
1490
1491     if "-s" in options:
1492         options["--save"] = options["-s"]
1493     if "--save" in options:
1494         if options["--save"] in (x[0] for x in virtual_profiles):
1495             raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1496                                      "This configuration name is a reserved virtual configuration." % options["--save"])
1497         error = check_configuration_pre_save(config)
1498         if error:
1499             print("Cannot save current configuration as profile '%s':" % options["--save"])
1500             print(error)
1501             sys.exit(1)
1502         try:
1503             profile_folder = os.path.join(profile_path, options["--save"])
1504             save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1505             exec_scripts(profile_folder, "postsave", {
1506                 "CURRENT_PROFILE": options["--save"],
1507                 "PROFILE_FOLDER": profile_folder,
1508                 "MONITORS": ":".join(enabled_monitors(config)),
1509             })
1510         except AutorandrException as e:
1511             raise e
1512         except Exception as e:
1513             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1514         print("Saved current configuration as profile '%s'" % options["--save"])
1515         sys.exit(0)
1516
1517     if "-r" in options:
1518         options["--remove"] = options["-r"]
1519     if "--remove" in options:
1520         if options["--remove"] in (x[0] for x in virtual_profiles):
1521             raise AutorandrException("Cannot remove profile '%s':\n"
1522                                      "This configuration name is a reserved virtual configuration." % options["--remove"])
1523         if options["--remove"] not in profiles.keys():
1524             raise AutorandrException("Cannot remove profile '%s':\n"
1525                                      "This profile does not exist." % options["--remove"])
1526         try:
1527             remove = True
1528             profile_folder = os.path.join(profile_path, options["--remove"])
1529             profile_dirlist = os.listdir(profile_folder)
1530             profile_dirlist.remove("config")
1531             profile_dirlist.remove("setup")
1532             if profile_dirlist:
1533                 print("Profile folder '%s' contains the following additional files:\n"
1534                       "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1535                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1536                 if response != "yes":
1537                     remove = False
1538             if remove is True:
1539                 shutil.rmtree(profile_folder)
1540                 print("Removed profile '%s'" % options["--remove"])
1541             else:
1542                 print("Profile '%s' was not removed" % options["--remove"])
1543         except Exception as e:
1544             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1545         sys.exit(0)
1546
1547     detected_profiles = find_profiles(config, profiles)
1548     load_profile = False
1549
1550     if "-l" in options:
1551         options["--load"] = options["-l"]
1552     if "--load" in options:
1553         load_profile = options["--load"]
1554     elif len(args) == 1:
1555         load_profile = args[0]
1556     else:
1557         # Find the active profile(s) first, for the block script (See #42)
1558         current_profiles = []
1559         for profile_name in profiles.keys():
1560             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1561             if configs_are_equal:
1562                 current_profiles.append(profile_name)
1563         block_script_metadata = {
1564             "CURRENT_PROFILE": "".join(current_profiles[:1]),
1565             "CURRENT_PROFILES": ":".join(current_profiles)
1566         }
1567
1568         best_index = 9999
1569         for profile_name in profiles.keys():
1570             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1571                 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1572                     print("%s (blocked)" % profile_name)
1573                 continue
1574             props = []
1575             is_current_profile = profile_name in current_profiles
1576             if profile_name in detected_profiles:
1577                 if len(detected_profiles) == 1:
1578                     index = 1
1579                     props.append("(detected)")
1580                 else:
1581                     index = detected_profiles.index(profile_name) + 1
1582                     props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1583                 if index < best_index:
1584                     if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1585                         load_profile = profile_name
1586                         best_index = index
1587             elif "--detected" in options:
1588                 continue
1589             if is_current_profile:
1590                 props.append("(current)")
1591             elif "--current" in options:
1592                 continue
1593             if any(opt in options for opt in ("--current", "--detected", "--list")):
1594                 print("%s" % (profile_name, ))
1595             else:
1596                 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1597             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1598                 print_profile_differences(config, profiles[profile_name]["config"])
1599
1600     if "-d" in options:
1601         options["--default"] = options["-d"]
1602     if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1603         load_profile = options["--default"]
1604
1605     if load_profile:
1606         if load_profile in profile_symlinks:
1607             if "--debug" in options:
1608                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1609             load_profile = profile_symlinks[load_profile]
1610
1611         if load_profile in (x[0] for x in virtual_profiles):
1612             load_config = generate_virtual_profile(config, modes, load_profile)
1613             scripts_path = os.path.join(profile_path, load_profile)
1614         else:
1615             try:
1616                 profile = profiles[load_profile]
1617                 load_config = profile["config"]
1618                 scripts_path = profile["path"]
1619             except KeyError:
1620                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1621             if "--dry-run" not in options:
1622                 update_mtime(os.path.join(scripts_path, "config"))
1623         add_unused_outputs(config, load_config)
1624         if load_config == dict(config) and "-f" not in options and "--force" not in options:
1625             print("Config already loaded", file=sys.stderr)
1626             sys.exit(0)
1627         if "--debug" in options and load_config != dict(config):
1628             print("Loading profile '%s'" % load_profile)
1629             print_profile_differences(config, load_config)
1630
1631         remove_irrelevant_outputs(config, load_config)
1632
1633         try:
1634             if "--dry-run" in options:
1635                 apply_configuration(load_config, config, True)
1636             else:
1637                 script_metadata = {
1638                     "CURRENT_PROFILE": load_profile,
1639                     "PROFILE_FOLDER": scripts_path,
1640                     "MONITORS": ":".join(enabled_monitors(load_config)),
1641                 }
1642                 exec_scripts(scripts_path, "preswitch", script_metadata)
1643                 if "--debug" in options:
1644                     print("Going to run:")
1645                     apply_configuration(load_config, config, True)
1646                 apply_configuration(load_config, config, False)
1647                 exec_scripts(scripts_path, "postswitch", script_metadata)
1648         except AutorandrException as e:
1649             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1650         except Exception as e:
1651             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1652
1653         if "--dry-run" not in options and "--debug" in options:
1654             new_config, _ = parse_xrandr_output(
1655                 ignore_lid=ignore_lid,
1656             )
1657             if "--skip-options" in options:
1658                 for output in new_config.values():
1659                     output.set_ignored_options(skip_options)
1660             if not is_equal_configuration(new_config, load_config):
1661                 print("The configuration change did not go as expected:")
1662                 print_profile_differences(new_config, load_config)
1663
1664     sys.exit(0)
1665
1666
1667 def exception_handled_main(argv=sys.argv):
1668     try:
1669         main(sys.argv)
1670     except AutorandrException as e:
1671         print(e, file=sys.stderr)
1672         sys.exit(1)
1673     except Exception as e:
1674         if not len(str(e)):  # BdbQuit
1675             print("Exception: {0}".format(e.__class__.__name__))
1676             sys.exit(2)
1677
1678         print("Unhandled exception ({0}). Please report this as a bug at "
1679               "https://github.com/phillipberndt/autorandr/issues.".format(e),
1680               file=sys.stderr)
1681         raise
1682
1683
1684 if __name__ == '__main__':
1685     exception_handled_main()