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