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