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