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