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