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