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