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