]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Bump version to 1.14
[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.14"
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 displays 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         "Instantiate 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 superfluous"
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         """Instantiate 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         "Instantiate 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 instantiate 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]), file=sys.stderr)
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 split, 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     # Adjust the frame buffer to match (see #319)
1020     if fb_args:
1021         argv = base_argv
1022         if call_and_retry(argv, dry_run=dry_run) != 0:
1023             raise AutorandrException("Command failed: %s" % " ".join(map(shlex.quote, argv)))
1024
1025
1026
1027 def is_equal_configuration(source_configuration, target_configuration):
1028     """
1029         Check if all outputs from target are already configured correctly in source and
1030         that no other outputs are active.
1031     """
1032     for output in target_configuration.keys():
1033         if "off" in target_configuration[output].options:
1034             if (output in source_configuration and "off" not in source_configuration[output].options):
1035                 return False
1036         else:
1037             if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
1038                 return False
1039     for output in source_configuration.keys():
1040         if "off" in source_configuration[output].options:
1041             if output in target_configuration and "off" not in target_configuration[output].options:
1042                 return False
1043         else:
1044             if output not in target_configuration:
1045                 return False
1046     return True
1047
1048
1049 def add_unused_outputs(source_configuration, target_configuration):
1050     "Add outputs that are missing in target to target, in 'off' state"
1051     for output_name, output in source_configuration.items():
1052         if output_name not in target_configuration:
1053             target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
1054
1055
1056 def remove_irrelevant_outputs(source_configuration, target_configuration):
1057     "Remove outputs from target that ought to be 'off' and already are"
1058     for output_name, output in source_configuration.items():
1059         if "off" in output.options:
1060             if output_name in target_configuration:
1061                 if "off" in target_configuration[output_name].options:
1062                     del target_configuration[output_name]
1063
1064
1065 def generate_virtual_profile(configuration, modes, profile_name):
1066     "Generate one of the virtual profiles"
1067     configuration = copy.deepcopy(configuration)
1068     if profile_name == "common":
1069         mode_sets = []
1070         for output, output_modes in modes.items():
1071             mode_set = set()
1072             if configuration[output].edid:
1073                 for mode in output_modes:
1074                     mode_set.add((mode["width"], mode["height"]))
1075             mode_sets.append(mode_set)
1076         common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
1077         common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
1078         if common_resolution:
1079             for output in configuration:
1080                 configuration[output].options = {}
1081                 if output in modes and configuration[output].edid:
1082                     modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
1083                     modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
1084                     mode = modes_filtered[0]
1085                     configuration[output].options["mode"] = mode['name']
1086                     configuration[output].options["pos"] = "0x0"
1087                 else:
1088                     configuration[output].options["off"] = None
1089     elif profile_name in ("horizontal", "vertical", "horizontal-reverse", "vertical-reverse"):
1090         shift = 0
1091         if profile_name.startswith("horizontal"):
1092             shift_index = "width"
1093             pos_specifier = "%sx0"
1094         else:
1095             shift_index = "height"
1096             pos_specifier = "0x%s"
1097             
1098         config_iter = reversed(configuration) if "reverse" in profile_name else iter(configuration)
1099             
1100         for output in config_iter:
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"] = pos_specifier % shift
1113                 shift += int(mode[shift_index])
1114             else:
1115                 configuration[output].options["off"] = None
1116     elif profile_name == "clone-largest":
1117         modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
1118         modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
1119         biggest_resolution = modes_sorted[0]
1120         for output in configuration:
1121             configuration[output].options = {}
1122             if output in modes and configuration[output].edid:
1123                 def key(a):
1124                     score = int(a["width"]) * int(a["height"])
1125                     if a["preferred"]:
1126                         score += 10**6
1127                     return score
1128                 output_modes = sorted(modes[output], key=key)
1129                 mode = output_modes[-1]
1130                 configuration[output].options["mode"] = mode["name"]
1131                 configuration[output].options["rate"] = mode["rate"]
1132                 configuration[output].options["pos"] = "0x0"
1133                 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
1134                             float(biggest_resolution["height"]) / float(mode["height"]))
1135                 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
1136                 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
1137                 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
1138             else:
1139                 configuration[output].options["off"] = None
1140     elif profile_name == "off":
1141         for output in configuration:
1142             for key in list(configuration[output].options.keys()):
1143                 del configuration[output].options[key]
1144             configuration[output].options["off"] = None
1145     return configuration
1146
1147
1148 def print_profile_differences(one, another):
1149     "Print the differences between two profiles for debugging"
1150     if one == another:
1151         return
1152     print("| Differences between the two profiles:")
1153     for output in set(chain.from_iterable((one.keys(), another.keys()))):
1154         if output not in one:
1155             if "off" not in another[output].options:
1156                 print("| Output `%s' is missing from the active configuration" % output)
1157         elif output not in another:
1158             if "off" not in one[output].options:
1159                 print("| Output `%s' is missing from the new configuration" % output)
1160         else:
1161             for line in one[output].verbose_diff(another[output]):
1162                 print("| [Output %s] %s" % (output, line))
1163     print("\\-")
1164
1165
1166 def exit_help():
1167     "Print help and exit"
1168     print(help_text)
1169     for profile in virtual_profiles:
1170         name, description = profile[:2]
1171         description = [description]
1172         max_width = 78 - 18
1173         while len(description[0]) > max_width + 1:
1174             left_over = description[0][max_width:]
1175             description[0] = description[0][:max_width] + "-"
1176             description.insert(1, "  %-15s %s" % ("", left_over))
1177         description = "\n".join(description)
1178         print("  %-15s %s" % (name, description))
1179     sys.exit(0)
1180
1181
1182 def exec_scripts(profile_path, script_name, meta_information=None):
1183     """"Run userscripts
1184
1185     This will run all executables from the profile folder, and global per-user
1186     and system-wide configuration folders, named script_name or residing in
1187     subdirectories named script_name.d.
1188
1189     If profile_path is None, only global scripts will be invoked.
1190
1191     meta_information is expected to be an dictionary. It will be passed to the block scripts
1192     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1193
1194     Returns True unless any of the scripts exited with non-zero exit status.
1195     """
1196     all_ok = True
1197     env = os.environ.copy()
1198     if meta_information:
1199         for key, value in meta_information.items():
1200             env["AUTORANDR_{}".format(key.upper())] = str(value)
1201
1202     # If there are multiple candidates, the XDG spec tells to only use the first one.
1203     ran_scripts = set()
1204
1205     user_profile_path = os.path.expanduser("~/.autorandr")
1206     if not os.path.isdir(user_profile_path):
1207         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1208
1209     candidate_directories = []
1210     if profile_path:
1211         candidate_directories.append(profile_path)
1212     candidate_directories.append(user_profile_path)
1213     for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1214         candidate_directories.append(os.path.join(config_dir, "autorandr"))
1215
1216     for folder in candidate_directories:
1217         if script_name not in ran_scripts:
1218             script = os.path.join(folder, script_name)
1219             if os.access(script, os.X_OK | os.F_OK):
1220                 try:
1221                     all_ok &= subprocess.call(script, env=env) != 0
1222                 except Exception as e:
1223                     raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
1224                 ran_scripts.add(script_name)
1225
1226         script_folder = os.path.join(folder, "%s.d" % script_name)
1227         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1228             for file_name in sorted(os.listdir(script_folder)):
1229                 check_name = "d/%s" % (file_name,)
1230                 if check_name not in ran_scripts:
1231                     script = os.path.join(script_folder, file_name)
1232                     if os.access(script, os.X_OK | os.F_OK):
1233                         try:
1234                             all_ok &= subprocess.call(script, env=env) != 0
1235                         except Exception as e:
1236                             raise AutorandrException("Failed to execute user command: %s. Error: %s" % (script, str(e)))
1237                         ran_scripts.add(check_name)
1238
1239     return all_ok
1240
1241
1242 def dispatch_call_to_sessions(argv):
1243     """Invoke autorandr for each open local X11 session with the given options.
1244
1245     The function iterates over all processes not owned by root and checks
1246     whether they have DISPLAY and XAUTHORITY variables set. It strips the
1247     screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1248     this display has been handled already. If it has not, it forks, changes
1249     uid/gid to the user owning the process, reuses the process's environment
1250     and runs autorandr with the parameters from argv.
1251
1252     This function requires root permissions. It only works for X11 servers that
1253     have at least one non-root process running. It is susceptible for attacks
1254     where one user runs a process with another user's DISPLAY variable - in
1255     this case, it might happen that autorandr is invoked for the other user,
1256     which won't work. Since no other harm than prevention of automated
1257     execution of autorandr can be done this way, the assumption is that in this
1258     situation, the local administrator will handle the situation."""
1259
1260     X11_displays_done = set()
1261
1262     autorandr_binary = os.path.abspath(argv[0])
1263     backup_candidates = {}
1264
1265     def fork_child_autorandr(pwent, process_environ):
1266         print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1267         child_pid = os.fork()
1268         if child_pid == 0:
1269             # This will throw an exception if any of the privilege changes fails,
1270             # so it should be safe. Also, note that since the environment
1271             # is taken from a process owned by the user, reusing it should
1272             # not leak any information.
1273             try:
1274                 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1275             except AttributeError:
1276                 # Python 2 doesn't have getgrouplist
1277                 os.setgroups([])
1278             os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1279             os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1280             os.chdir(pwent.pw_dir)
1281             os.environ.clear()
1282             os.environ.update(process_environ)
1283             if sys.executable != "" and sys.executable != None:
1284                 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1285             else:
1286                 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1287             sys.exit(1)
1288         os.waitpid(child_pid, 0)
1289
1290     # The following line assumes that user accounts start at 1000 and that no
1291     # one works using the root or another system account. This is rather
1292     # restrictive, but de facto default. If this breaks your use case, set the
1293     # env var AUTORANDR_UID_MIN as appropriate. (Alternatives would be to use
1294     # the UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf; but
1295     # effectively, both values aren't binding in any way.)
1296     uid_min = 1000
1297     if 'AUTORANDR_UID_MIN' in os.environ:
1298       uid_min = int(os.environ['AUTORANDR_UID_MIN'])
1299
1300     for directory in os.listdir("/proc"):
1301         directory = os.path.join("/proc/", directory)
1302         if not os.path.isdir(directory):
1303             continue
1304         environ_file = os.path.join(directory, "environ")
1305         if not os.path.isfile(environ_file):
1306             continue
1307         uid = os.stat(environ_file).st_uid
1308
1309         if uid < uid_min:
1310             continue
1311
1312         process_environ = {}
1313         for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1314             try:
1315                 environ_entry = environ_entry.decode("ascii")
1316             except UnicodeDecodeError:
1317                 continue
1318             name, sep, value = environ_entry.partition("=")
1319             if name and sep:
1320                 if name == "DISPLAY" and "." in value:
1321                     value = value[:value.find(".")]
1322                 process_environ[name] = value
1323
1324         if "DISPLAY" not in process_environ:
1325             # Cannot work with this environment, skip.
1326             continue
1327
1328         if "WAYLAND_DISPLAY" in process_environ and process_environ["WAYLAND_DISPLAY"]:
1329             if "--debug" in argv:
1330                 print("Detected Wayland session '{0}'. Skipping.".format(process_environ["WAYLAND_DISPLAY"]))
1331             continue
1332
1333         # To allow scripts to detect batch invocation (especially useful for predetect)
1334         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1335         process_environ["UID"] = str(uid)
1336
1337         display = process_environ["DISPLAY"]
1338
1339         if "XAUTHORITY" not in process_environ:
1340             # It's very likely that we cannot work with this environment either,
1341             # but keep it as a backup just in case we don't find anything else.
1342             backup_candidates[display] = process_environ
1343             continue
1344
1345         if display not in X11_displays_done:
1346             try:
1347                 pwent = pwd.getpwuid(uid)
1348             except KeyError:
1349                 # User has no pwd entry
1350                 continue
1351
1352             fork_child_autorandr(pwent, process_environ)
1353             X11_displays_done.add(display)
1354
1355     # Run autorandr for any users/displays which didn't have a process with
1356     # XAUTHORITY set.
1357     for display, process_environ in backup_candidates.items():
1358         if display not in X11_displays_done:
1359             try:
1360                 pwent = pwd.getpwuid(int(process_environ["UID"]))
1361             except KeyError:
1362                 # User has no pwd entry
1363                 continue
1364
1365             fork_child_autorandr(pwent, process_environ)
1366             X11_displays_done.add(display)
1367
1368
1369 def enabled_monitors(config):
1370     monitors = []
1371     for monitor in config:
1372         if "--off" in config[monitor].option_vector:
1373             continue
1374         monitors.append(monitor)
1375     return monitors
1376
1377
1378 def read_config(options, directory):
1379     """Parse a configuration config.ini from directory and merge it into
1380     the options dictionary"""
1381     config = configparser.ConfigParser()
1382     config.read(os.path.join(directory, "settings.ini"))
1383     if config.has_section("config"):
1384         for key, value in config.items("config"):
1385             options.setdefault("--%s" % key, value)
1386
1387 def main(argv):
1388     try:
1389         opts, args = getopt.getopt(
1390             argv[1:],
1391             "s:r:l:d:cfh",
1392             [
1393                 "batch",
1394                 "dry-run",
1395                 "change",
1396                 "cycle",
1397                 "default=",
1398                 "save=",
1399                 "remove=",
1400                 "load=",
1401                 "force",
1402                 "fingerprint",
1403                 "config",
1404                 "debug",
1405                 "skip-options=",
1406                 "help",
1407                 "list",
1408                 "current",
1409                 "detected",
1410                 "version",
1411                 "match-edid",
1412                 "ignore-lid"
1413             ]
1414         )
1415     except getopt.GetoptError as e:
1416         print("Failed to parse options: {0}.\n"
1417               "Use --help to get usage information.".format(str(e)),
1418               file=sys.stderr)
1419         sys.exit(posix.EX_USAGE)
1420
1421     options = dict(opts)
1422
1423     if "-h" in options or "--help" in options:
1424         exit_help()
1425
1426     if "--version" in options:
1427         print("autorandr " + __version__)
1428         sys.exit(0)
1429
1430     if "--current" in options and "--detected" in options:
1431         print("--current and --detected are mutually exclusive.", file=sys.stderr)
1432         sys.exit(posix.EX_USAGE)
1433
1434     # Batch mode
1435     if "--batch" in options:
1436         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1437             dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1438         else:
1439             print("--batch mode can only be used by root and if $DISPLAY is unset")
1440         return
1441     if "AUTORANDR_BATCH_PID" in os.environ:
1442         user = pwd.getpwuid(os.getuid())
1443         user = user.pw_name if user else "#%d" % os.getuid()
1444         print("autorandr running as user %s (started from batch instance)" % user)
1445     if ("WAYLAND_DISPLAY" in os.environ and os.environ["WAYLAND_DISPLAY"]):
1446         print("Detected Wayland session '{0}'. Exiting.".format(os.environ["WAYLAND_DISPLAY"]), file=sys.stderr)
1447         sys.exit(1)
1448
1449     profiles = {}
1450     profile_symlinks = {}
1451     try:
1452         # Load profiles from each XDG config directory
1453         # The XDG spec says that earlier entries should take precedence, so reverse the order
1454         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1455             system_profile_path = os.path.join(directory, "autorandr")
1456             if os.path.isdir(system_profile_path):
1457                 profiles.update(load_profiles(system_profile_path))
1458                 profile_symlinks.update(get_symlinks(system_profile_path))
1459                 read_config(options, system_profile_path)
1460         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1461         # profile_path is also used later on to store configurations
1462         profile_path = os.path.expanduser("~/.autorandr")
1463         if not os.path.isdir(profile_path):
1464             # Elsewise, follow the XDG specification
1465             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1466         if os.path.isdir(profile_path):
1467             profiles.update(load_profiles(profile_path))
1468             profile_symlinks.update(get_symlinks(profile_path))
1469             read_config(options, profile_path)
1470     except Exception as e:
1471         raise AutorandrException("Failed to load profiles", e)
1472
1473     exec_scripts(None, "predetect")
1474
1475     ignore_lid = "--ignore-lid" in options
1476
1477     config, modes = parse_xrandr_output(
1478         ignore_lid=ignore_lid,
1479     )
1480
1481     if "--match-edid" in options:
1482         update_profiles_edid(profiles, config)
1483
1484     # Sort by mtime
1485     sort_direction = -1
1486     if "--cycle" in options:
1487         # When cycling through profiles, put the profile least recently used to the top of the list
1488         sort_direction = 1
1489     profiles = OrderedDict(sorted(profiles.items(), key=lambda x: sort_direction * x[1]["config-mtime"]))
1490     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}
1491
1492     if "--fingerprint" in options:
1493         output_setup(config, sys.stdout)
1494         sys.exit(0)
1495
1496     if "--config" in options:
1497         output_configuration(config, sys.stdout)
1498         sys.exit(0)
1499
1500     if "--skip-options" in options:
1501         skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1502         for profile in profiles.values():
1503             for output in profile["config"].values():
1504                 output.set_ignored_options(skip_options)
1505         for output in config.values():
1506             output.set_ignored_options(skip_options)
1507
1508     if "-s" in options:
1509         options["--save"] = options["-s"]
1510     if "--save" in options:
1511         if options["--save"] in (x[0] for x in virtual_profiles):
1512             raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1513                                      "This configuration name is a reserved virtual configuration." % options["--save"])
1514         error = check_configuration_pre_save(config)
1515         if error:
1516             print("Cannot save current configuration as profile '%s':" % options["--save"])
1517             print(error)
1518             sys.exit(1)
1519         try:
1520             profile_folder = os.path.join(profile_path, options["--save"])
1521             save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1522             exec_scripts(profile_folder, "postsave", {
1523                 "CURRENT_PROFILE": options["--save"],
1524                 "PROFILE_FOLDER": profile_folder,
1525                 "MONITORS": ":".join(enabled_monitors(config)),
1526             })
1527         except AutorandrException as e:
1528             raise e
1529         except Exception as e:
1530             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1531         print("Saved current configuration as profile '%s'" % options["--save"])
1532         sys.exit(0)
1533
1534     if "-r" in options:
1535         options["--remove"] = options["-r"]
1536     if "--remove" in options:
1537         if options["--remove"] in (x[0] for x in virtual_profiles):
1538             raise AutorandrException("Cannot remove profile '%s':\n"
1539                                      "This configuration name is a reserved virtual configuration." % options["--remove"])
1540         if options["--remove"] not in profiles.keys():
1541             raise AutorandrException("Cannot remove profile '%s':\n"
1542                                      "This profile does not exist." % options["--remove"])
1543         try:
1544             remove = True
1545             profile_folder = os.path.join(profile_path, options["--remove"])
1546             profile_dirlist = os.listdir(profile_folder)
1547             profile_dirlist.remove("config")
1548             profile_dirlist.remove("setup")
1549             if profile_dirlist:
1550                 print("Profile folder '%s' contains the following additional files:\n"
1551                       "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1552                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1553                 if response != "yes":
1554                     remove = False
1555             if remove is True:
1556                 shutil.rmtree(profile_folder)
1557                 print("Removed profile '%s'" % options["--remove"])
1558             else:
1559                 print("Profile '%s' was not removed" % options["--remove"])
1560         except Exception as e:
1561             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1562         sys.exit(0)
1563
1564     detected_profiles = find_profiles(config, profiles)
1565     load_profile = False
1566
1567     if "-l" in options:
1568         options["--load"] = options["-l"]
1569     if "--load" in options:
1570         load_profile = options["--load"]
1571     elif len(args) == 1:
1572         load_profile = args[0]
1573     else:
1574         # Find the active profile(s) first, for the block script (See #42)
1575         current_profiles = []
1576         for profile_name in profiles.keys():
1577             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1578             if configs_are_equal:
1579                 current_profiles.append(profile_name)
1580         block_script_metadata = {
1581             "CURRENT_PROFILE": "".join(current_profiles[:1]),
1582             "CURRENT_PROFILES": ":".join(current_profiles)
1583         }
1584
1585         best_index = 9999
1586         for profile_name in profiles.keys():
1587             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1588                 if not any(opt in options for opt in ("--current", "--detected", "--list")):
1589                     print("%s (blocked)" % profile_name)
1590                 continue
1591             props = []
1592             is_current_profile = profile_name in current_profiles
1593             if profile_name in detected_profiles:
1594                 if len(detected_profiles) == 1:
1595                     index = 1
1596                     props.append("(detected)")
1597                 else:
1598                     index = detected_profiles.index(profile_name) + 1
1599                     props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1600                 if index < best_index:
1601                     if "-c" in options or "--change" in options or ("--cycle" in options and not is_current_profile):
1602                         load_profile = profile_name
1603                         best_index = index
1604             elif "--detected" in options:
1605                 continue
1606             if is_current_profile:
1607                 props.append("(current)")
1608             elif "--current" in options:
1609                 continue
1610             if any(opt in options for opt in ("--current", "--detected", "--list")):
1611                 print("%s" % (profile_name, ))
1612             else:
1613                 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1614             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1615                 print_profile_differences(config, profiles[profile_name]["config"])
1616
1617     if "-d" in options:
1618         options["--default"] = options["-d"]
1619     if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1620         load_profile = options["--default"]
1621
1622     if load_profile:
1623         if load_profile in profile_symlinks:
1624             if "--debug" in options:
1625                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1626             load_profile = profile_symlinks[load_profile]
1627
1628         if load_profile in (x[0] for x in virtual_profiles):
1629             load_config = generate_virtual_profile(config, modes, load_profile)
1630             scripts_path = os.path.join(profile_path, load_profile)
1631         else:
1632             try:
1633                 profile = profiles[load_profile]
1634                 load_config = profile["config"]
1635                 scripts_path = profile["path"]
1636             except KeyError:
1637                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1638             if "--dry-run" not in options:
1639                 update_mtime(os.path.join(scripts_path, "config"))
1640         add_unused_outputs(config, load_config)
1641         if load_config == dict(config) and "-f" not in options and "--force" not in options:
1642             print("Config already loaded", file=sys.stderr)
1643             sys.exit(0)
1644         if "--debug" in options and load_config != dict(config):
1645             print("Loading profile '%s'" % load_profile)
1646             print_profile_differences(config, load_config)
1647
1648         remove_irrelevant_outputs(config, load_config)
1649
1650         try:
1651             if "--dry-run" in options:
1652                 apply_configuration(load_config, config, True)
1653             else:
1654                 script_metadata = {
1655                     "CURRENT_PROFILE": load_profile,
1656                     "PROFILE_FOLDER": scripts_path,
1657                     "MONITORS": ":".join(enabled_monitors(load_config)),
1658                 }
1659                 exec_scripts(scripts_path, "preswitch", script_metadata)
1660                 if "--debug" in options:
1661                     print("Going to run:")
1662                     apply_configuration(load_config, config, True)
1663                 apply_configuration(load_config, config, False)
1664                 exec_scripts(scripts_path, "postswitch", script_metadata)
1665         except AutorandrException as e:
1666             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1667         except Exception as e:
1668             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1669
1670         if "--dry-run" not in options and "--debug" in options:
1671             new_config, _ = parse_xrandr_output(
1672                 ignore_lid=ignore_lid,
1673             )
1674             if "--skip-options" in options:
1675                 for output in new_config.values():
1676                     output.set_ignored_options(skip_options)
1677             if not is_equal_configuration(new_config, load_config):
1678                 print("The configuration change did not go as expected:")
1679                 print_profile_differences(new_config, load_config)
1680
1681     sys.exit(0)
1682
1683
1684 def exception_handled_main(argv=sys.argv):
1685     try:
1686         main(sys.argv)
1687     except AutorandrException as e:
1688         print(e, file=sys.stderr)
1689         sys.exit(1)
1690     except Exception as e:
1691         if not len(str(e)):  # BdbQuit
1692             print("Exception: {0}".format(e.__class__.__name__))
1693             sys.exit(2)
1694
1695         print("Unhandled exception ({0}). Please report this as a bug at "
1696               "https://github.com/phillipberndt/autorandr/issues.".format(e),
1697               file=sys.stderr)
1698         raise
1699
1700
1701 if __name__ == '__main__':
1702     exception_handled_main()