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