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