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