]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Merge broadcast_rgb feature (#204)
[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 subprocess
36 import sys
37 import shutil
38 import time
39 import glob
40
41 from collections import OrderedDict
42 from functools import reduce
43 from itertools import chain
44
45 try:
46     from packaging.version import Version
47 except ModuleNotFoundError:
48     from distutils.version import LooseVersion as Version
49
50 if sys.version_info.major == 2:
51     import ConfigParser as configparser
52 else:
53     import configparser
54
55 __version__ = "1.11"
56
57 try:
58     input = raw_input
59 except NameError:
60     pass
61
62 virtual_profiles = [
63     # (name, description, callback)
64     ("off", "Disable all outputs", None),
65     ("common", "Clone all connected outputs at the largest common resolution", None),
66     ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
67     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
68     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
69 ]
70
71 properties = [
72     "Colorspace",
73     "max bpc",
74     "aspect ratio",
75     "Broadcast RGB",
76     "audio",
77     "non-desktop",
78     "TearFree",
79     "underscan vborder",
80     "underscan hborder",
81     "underscan",
82     "scaling mode",
83 ]
84
85 help_text = """
86 Usage: autorandr [options]
87
88 -h, --help              get this small help
89 -c, --change            automatically load the first detected profile
90 -d, --default <profile> make profile <profile> the default profile
91 -l, --load <profile>    load profile <profile>
92 -s, --save <profile>    save your current setup to profile <profile>
93 -r, --remove <profile>  remove profile <profile>
94 --batch                 run autorandr for all users with active X11 sessions
95 --current               only list current (active) configuration(s)
96 --config                dump your current xrandr setup
97 --cycle                 automatically load the next detected profile
98 --debug                 enable verbose output
99 --detected              only list detected (available) configuration(s)
100 --dry-run               don't change anything, only print the xrandr commands
101 --fingerprint           fingerprint your current hardware setup
102 --match-edid            match diplays based on edid instead of name
103 --force                 force (re)loading of a profile / overwrite exiting files
104 --list                  list configurations
105 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
106                         to skip both in detecting changes and applying a profile
107 --version               show version information and exit
108
109  If no suitable profile can be identified, the current configuration is kept.
110  To change this behaviour and switch to a fallback configuration, specify
111  --default <profile>.
112
113  autorandr supports a set of per-profile and global hooks. See the documentation
114  for details.
115
116  The following virtual configurations are available:
117 """.strip()
118
119
120 def is_closed_lid(output):
121     if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
122         return False
123     lids = glob.glob("/proc/acpi/button/lid/*/state")
124     if len(lids) == 1:
125         state_file = lids[0]
126         with open(state_file) as f:
127             content = f.read()
128             return "close" in content
129     return False
130
131
132 class AutorandrException(Exception):
133     def __init__(self, message, original_exception=None, report_bug=False):
134         self.message = message
135         self.report_bug = report_bug
136         if original_exception:
137             self.original_exception = original_exception
138             trace = sys.exc_info()[2]
139             while trace.tb_next:
140                 trace = trace.tb_next
141             self.line = trace.tb_lineno
142             self.file_name = trace.tb_frame.f_code.co_filename
143         else:
144             try:
145                 import inspect
146                 frame = inspect.currentframe().f_back
147                 self.line = frame.f_lineno
148                 self.file_name = frame.f_code.co_filename
149             except:
150                 self.line = None
151                 self.file_name = None
152             self.original_exception = None
153
154         if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
155             self.file_name = None
156
157     def __str__(self):
158         retval = [self.message]
159         if self.line:
160             retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
161         if self.original_exception:
162             retval.append(":\n  ")
163             retval.append(str(self.original_exception).replace("\n", "\n  "))
164         if self.report_bug:
165             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
166                           "\nhttps://github.com/phillipberndt/autorandr/issues"
167                           "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
168         return "".join(retval)
169
170
171 class XrandrOutput(object):
172     "Represents an XRandR output"
173
174     XRANDR_PROPERTIES_REGEXP = "|".join(
175         [r"{}:\s*(?P<{}>[\S ]*\S+)"
176          .format(re.sub(r"\s", r"\\\g<0>", p), re.sub(r"\W+", "_", p.lower()))
177             for p in properties])
178
179     # This regular expression is used to parse an output in `xrandr --verbose'
180     XRANDR_OUTPUT_REGEXP = """(?x)
181         ^\s*(?P<output>\S[^ ]*)\s+                                                      # Line starts with output name
182         (?:                                                                             # Differentiate disconnected and connected
183             disconnected |                                                              # in first line
184             unknown\ connection |
185             (?P<connected>connected)
186         )
187         \s*
188         (?P<primary>primary\ )?                                                         # Might be primary screen
189         (?:\s*
190             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
191             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
192             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
193             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
194             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
195         )?                                                                              # .. but only if the screen is in use.
196         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
197         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
198         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
199         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
200         (?:\s*(?:                                                                       # Properties of the output
201             Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) |                                 # Gamma value
202             CRTC:\s*(?P<crtc>[0-9]) |                                                   # CRTC value
203             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
204             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
205             """ + XRANDR_PROPERTIES_REGEXP + """ |                                      # Properties to include in the profile
206             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
207         ))+
208         \s*
209         (?P<modes>(?:
210             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution:
211              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+                                   # Extract rate
212              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
213             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
214         )*)
215     """
216
217     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
218         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
219          h:\s+width\s+(?P<width>[0-9]+).+\s+
220          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
221     """
222
223     XRANDR_13_DEFAULTS = {
224         "transform": "1,0,0,0,1,0,0,0,1",
225         "panning": "0x0",
226     }
227
228     XRANDR_12_DEFAULTS = {
229         "reflect": "normal",
230         "rotate": "normal",
231         "gamma": "1.0:1.0:1.0",
232     }
233
234     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
235
236     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
237
238     def __repr__(self):
239         return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
240
241     @property
242     def short_edid(self):
243         return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
244
245     @property
246     def options_with_defaults(self):
247         "Return the options dictionary, augmented with the default values that weren't set"
248         if "off" in self.options:
249             return self.options
250         options = {}
251         if xrandr_version() >= Version("1.3"):
252             options.update(self.XRANDR_13_DEFAULTS)
253         if xrandr_version() >= Version("1.2"):
254             options.update(self.XRANDR_12_DEFAULTS)
255         options.update(self.options)
256         return {a: b for a, b in options.items() if a not in self.ignored_options}
257
258     @property
259     def filtered_options(self):
260         "Return a dictionary of options without ignored options"
261         return {a: b for a, b in self.options.items() if a not in self.ignored_options}
262
263     @property
264     def option_vector(self):
265         "Return the command line parameters for XRandR for this instance"
266         args = ["--output", self.output]
267         for option, arg in sorted(self.options_with_defaults.items()):
268             if option[:5] == "prop-":
269                 prop_found = False
270                 for prop, xrandr_prop in [(re.sub(r"\W+", "_", p.lower()), p) for p in properties]:
271                     if prop == option[5:]:
272                         args.append("--set")
273                         args.append(xrandr_prop)
274                         prop_found = True
275                         break
276                 if not prop_found:
277                     print("Warning: Unknown property `%s' in config file. Skipping." % option[5:], file=sys.stderr)
278                     continue
279             else:
280                 args.append("--%s" % option)
281             if arg:
282                 args.append(arg)
283         return args
284
285     @property
286     def option_string(self):
287         "Return the command line parameters in the configuration file format"
288         options = ["output %s" % self.output]
289         for option, arg in sorted(self.filtered_options.items()):
290             if arg:
291                 options.append("%s %s" % (option, arg))
292             else:
293                 options.append(option)
294         return "\n".join(options)
295
296     @property
297     def sort_key(self):
298         "Return a key to sort the outputs for xrandr invocation"
299         if not self.edid:
300             return -2
301         if "off" in self.options:
302             return -1
303         if "pos" in self.options:
304             x, y = map(float, self.options["pos"].split("x"))
305         else:
306             x, y = 0, 0
307         return x + 10000 * y
308
309     def __init__(self, output, edid, options):
310         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
311         self.output = output
312         self.edid = edid
313         self.options = options
314         self.ignored_options = []
315         self.remove_default_option_values()
316
317     def set_ignored_options(self, options):
318         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
319         self.ignored_options = list(options)
320
321     def remove_default_option_values(self):
322         "Remove values from the options dictionary that are superflous"
323         if "off" in self.options and len(self.options.keys()) > 1:
324             self.options = {"off": None}
325             return
326         for option, default_value in self.XRANDR_DEFAULTS.items():
327             if option in self.options and self.options[option] == default_value:
328                 del self.options[option]
329
330     @classmethod
331     def from_xrandr_output(cls, xrandr_output):
332         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
333
334         This method also returns a list of modes supported by the output.
335         """
336         try:
337             xrandr_output = xrandr_output.replace("\r\n", "\n")
338             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
339         except:
340             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
341                                      report_bug=True)
342         if not match_object:
343             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
344             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
345                                      report_bug=True)
346         remainder = xrandr_output[len(match_object.group(0)):]
347         if remainder:
348             raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
349                                      "regular expression, starting at byte %d with ..'%s'." %
350                                      (len(remainder), len(match_object.group(0)), remainder[:10]),
351                                      report_bug=True)
352
353         match = match_object.groupdict()
354
355         modes = []
356         if match["modes"]:
357             modes = []
358             for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
359                 if mode_match.group("name"):
360                     modes.append(mode_match.groupdict())
361             if not modes:
362                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
363
364         options = {}
365         if not match["connected"]:
366             edid = None
367         elif match["edid"]:
368             edid = "".join(match["edid"].strip().split())
369         else:
370             edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
371
372         # An output can be disconnected but still have a mode configured. This can only happen
373         # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
374         # output.
375         #
376         # This code needs to be careful not to mix the two. An output should only be configured to
377         # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
378         if not match["width"]:
379             options["off"] = None
380         else:
381             if match["mode_name"]:
382                 options["mode"] = match["mode_name"]
383             elif match["mode_width"]:
384                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
385             else:
386                 if match["rotate"] not in ("left", "right"):
387                     options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
388                 else:
389                     options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
390             if match["rotate"]:
391                 options["rotate"] = match["rotate"]
392             if match["primary"]:
393                 options["primary"] = None
394             if match["reflect"] == "X":
395                 options["reflect"] = "x"
396             elif match["reflect"] == "Y":
397                 options["reflect"] = "y"
398             elif match["reflect"] == "X and Y":
399                 options["reflect"] = "xy"
400             if match["x"] or match["y"]:
401                 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
402             if match["panning"]:
403                 panning = [match["panning"]]
404                 if match["tracking"]:
405                     panning += ["/", match["tracking"]]
406                     if match["border"]:
407                         panning += ["/", match["border"]]
408                 options["panning"] = "".join(panning)
409             if match["transform"]:
410                 transformation = ",".join(match["transform"].strip().split())
411                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
412                     options["transform"] = transformation
413                     if not match["mode_name"]:
414                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
415                         # I doubt that this special case is actually required.
416                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
417                               "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
418             if match["gamma"]:
419                 gamma = match["gamma"].strip()
420                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
421                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
422                 # so we approximate by 1e-10.
423                 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
424                 options["gamma"] = gamma
425             if match["crtc"]:
426                 options["crtc"] = match["crtc"]
427             if match["rate"]:
428                 options["rate"] = match["rate"]
429             for prop in [re.sub(r"\W+", "_", p.lower()) for p in properties]:
430                 if match[prop]:
431                     options["prop-" + prop] = match[prop]
432
433         return XrandrOutput(match["output"], edid, options), modes
434
435     @classmethod
436     def from_config_file(cls, edid_map, configuration):
437         "Instanciate an XrandrOutput from the contents of a configuration file"
438         options = {}
439         for line in configuration.split("\n"):
440             if line:
441                 line = line.split(None, 1)
442                 if line and line[0].startswith("#"):
443                     continue
444                 options[line[0]] = line[1] if len(line) > 1 else None
445
446         edid = None
447
448         if options["output"] in edid_map:
449             edid = edid_map[options["output"]]
450         else:
451             # This fuzzy matching is for legacy autorandr that used sysfs output names
452             fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
453             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
454             if fuzzy_output in fuzzy_edid_map:
455                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
456             elif "off" not in options:
457                 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
458                                          "is not off in config file." % (options["output"], options["output"]))
459         output = options["output"]
460         del options["output"]
461
462         return XrandrOutput(output, edid, options)
463
464     def edid_equals(self, other):
465         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
466         if self.edid and other.edid:
467             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
468                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
469             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
470                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
471             if "*" in self.edid:
472                 return match_asterisk(self.edid, other.edid) > 0
473             elif "*" in other.edid:
474                 return match_asterisk(other.edid, self.edid) > 0
475         return self.edid == other.edid
476
477     def __ne__(self, other):
478         return not (self == other)
479
480     def __eq__(self, other):
481         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
482
483     def verbose_diff(self, other):
484         "Compare to another XrandrOutput and return a list of human readable differences"
485         diffs = []
486         if not self.edid_equals(other):
487             diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
488         if self.output != other.output:
489             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
490         if "off" in self.options and "off" not in other.options:
491             diffs.append("The output is disabled currently, but active in the new configuration")
492         elif "off" in other.options and "off" not in self.options:
493             diffs.append("The output is currently enabled, but inactive in the new configuration")
494         else:
495             for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
496                 if name not in other.options:
497                     diffs.append("Option --%s %sis not present in the new configuration" %
498                                  (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
499                 elif name not in self.options:
500                     diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
501                                  (name, other.options[name]))
502                 elif self.options[name] != other.options[name]:
503                     diffs.append("Option --%s %sis `%s' in the new configuration" %
504                                  (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
505         return diffs
506
507
508 def xrandr_version():
509     "Return the version of XRandR that this system uses"
510     if getattr(xrandr_version, "version", False) is False:
511         version_string = os.popen("xrandr -v").read()
512         try:
513             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
514             xrandr_version.version = Version(version)
515         except AttributeError:
516             xrandr_version.version = Version("1.3.0")
517
518     return xrandr_version.version
519
520
521 def debug_regexp(pattern, string):
522     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
523     try:
524         import regex
525         bounds = (0, len(string))
526         while bounds[0] != bounds[1]:
527             half = int((bounds[0] + bounds[1]) / 2)
528             if half == bounds[0]:
529                 break
530             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
531         partial_length = bounds[0]
532         return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
533                 (partial_length, string[max(0, partial_length - 20):partial_length],
534                  string[partial_length:partial_length + 10]))
535     except ImportError:
536         pass
537     return "Debug information would be available if the `regex' module was installed."
538
539
540 def parse_xrandr_output():
541     "Parse the output of `xrandr --verbose' into a list of outputs"
542     xrandr_output = os.popen("xrandr -q --verbose").read()
543     if not xrandr_output:
544         raise AutorandrException("Failed to run xrandr")
545
546     # We are not interested in screens
547     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
548
549     # Split at output boundaries and instanciate an XrandrOutput per output
550     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
551     if len(split_xrandr_output) < 2:
552         raise AutorandrException("No output boundaries found", report_bug=True)
553     outputs = OrderedDict()
554     modes = OrderedDict()
555     for i in range(1, len(split_xrandr_output), 2):
556         output_name = split_xrandr_output[i].split()[0]
557         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
558         outputs[output_name] = output
559         if output_modes:
560             modes[output_name] = output_modes
561
562     # consider a closed lid as disconnected if other outputs are connected
563     if sum(o.edid != None for o in outputs.values()) > 1:
564         for output_name in outputs.keys():
565             if is_closed_lid(output_name):
566                 outputs[output_name].edid = None
567
568     return outputs, modes
569
570
571 def load_profiles(profile_path):
572     "Load the stored profiles"
573
574     profiles = {}
575     for profile in os.listdir(profile_path):
576         config_name = os.path.join(profile_path, profile, "config")
577         setup_name = os.path.join(profile_path, profile, "setup")
578         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
579             continue
580
581         edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
582
583         config = {}
584         buffer = []
585         for line in chain(open(config_name).readlines(), ["output"]):
586             if line[:6] == "output" and buffer:
587                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
588                 buffer = [line]
589             else:
590                 buffer.append(line)
591
592         for output_name in list(config.keys()):
593             if config[output_name].edid is None:
594                 del config[output_name]
595
596         profiles[profile] = {
597             "config": config,
598             "path": os.path.join(profile_path, profile),
599             "config-mtime": os.stat(config_name).st_mtime,
600         }
601
602     return profiles
603
604
605 def get_symlinks(profile_path):
606     "Load all symlinks from a directory"
607
608     symlinks = {}
609     for link in os.listdir(profile_path):
610         file_name = os.path.join(profile_path, link)
611         if os.path.islink(file_name):
612             symlinks[link] = os.readlink(file_name)
613
614     return symlinks
615
616
617 def match_asterisk(pattern, data):
618     """Match data against a pattern
619
620     The difference to fnmatch is that this function only accepts patterns with a single
621     asterisk and that it returns a "closeness" number, which is larger the better the match.
622     Zero indicates no match at all.
623     """
624     if "*" not in pattern:
625         return 1 if pattern == data else 0
626     parts = pattern.split("*")
627     if len(parts) > 2:
628         raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
629     if not data.startswith(parts[0]):
630         return 0
631     if not data.endswith(parts[1]):
632         return 0
633     matched = len(pattern)
634     total = len(data) + 1
635     return matched * 1. / total
636
637
638 def update_profiles_edid(profiles, config):
639     edid_map = {}
640     for c in config:
641         if config[c].edid is not None:
642             edid_map[config[c].edid] = c
643
644     for p in profiles:
645         profile_config = profiles[p]["config"]
646
647         for edid in edid_map:
648             for c in profile_config.keys():
649                 if profile_config[c].edid != edid or c == edid_map[edid]:
650                     continue
651
652                 print("%s: renaming display %s to %s" % (p, c, edid_map[edid]))
653
654                 tmp_disp = profile_config[c]
655
656                 if edid_map[edid] in profile_config:
657                     # Swap the two entries
658                     profile_config[c] = profile_config[edid_map[edid]]
659                     profile_config[c].output = c
660                 else:
661                     # Object is reassigned to another key, drop this one
662                     del profile_config[c]
663
664                 profile_config[edid_map[edid]] = tmp_disp
665                 profile_config[edid_map[edid]].output = edid_map[edid]
666
667
668 def find_profiles(current_config, profiles):
669     "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
670     detected_profiles = []
671     for profile_name, profile in profiles.items():
672         config = profile["config"]
673         matches = True
674         for name, output in config.items():
675             if not output.edid:
676                 continue
677             if name not in current_config or not output.edid_equals(current_config[name]):
678                 matches = False
679                 break
680         if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
681             continue
682         if matches:
683             closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(
684                 current_config[name].edid, output.edid))
685             detected_profiles.append((closeness, profile_name))
686     detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
687     return detected_profiles
688
689
690 def profile_blocked(profile_path, meta_information=None):
691     """Check if a profile is blocked.
692
693     meta_information is expected to be an dictionary. It will be passed to the block scripts
694     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
695     """
696     return not exec_scripts(profile_path, "block", meta_information)
697
698
699 def check_configuration_pre_save(configuration):
700     "Check that a configuration is safe for saving."
701     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
702     for output in outputs:
703         if "off" not in configuration[output].options and not configuration[output].edid:
704             return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
705                     "This typically means that it has been recently unplugged and then not properly disabled\n"
706                     "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
707                     "this command.") % {"o": output}
708
709
710 def output_configuration(configuration, config):
711     "Write a configuration file"
712     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
713     for output in outputs:
714         print(configuration[output].option_string, file=config)
715
716
717 def output_setup(configuration, setup):
718     "Write a setup (fingerprint) file"
719     outputs = sorted(configuration.keys())
720     for output in outputs:
721         if configuration[output].edid:
722             print(output, configuration[output].edid, file=setup)
723
724
725 def save_configuration(profile_path, profile_name, configuration, forced=False):
726     "Save a configuration into a profile"
727     if not os.path.isdir(profile_path):
728         os.makedirs(profile_path)
729     config_path = os.path.join(profile_path, "config")
730     setup_path = os.path.join(profile_path, "setup")
731     if os.path.isfile(config_path) and not forced:
732         raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
733     if os.path.isfile(setup_path) and not forced:
734         raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
735
736     with open(config_path, "w") as config:
737         output_configuration(configuration, config)
738     with open(setup_path, "w") as setup:
739         output_setup(configuration, setup)
740
741
742 def update_mtime(filename):
743     "Update a file's mtime"
744     try:
745         os.utime(filename, None)
746         return True
747     except:
748         return False
749
750
751 def call_and_retry(*args, **kwargs):
752     """Wrapper around subprocess.call that retries failed calls.
753
754     This function calls subprocess.call and on non-zero exit states,
755     waits a second and then retries once. This mitigates #47,
756     a timing issue with some drivers.
757     """
758     if "dry_run" in kwargs:
759         dry_run = kwargs["dry_run"]
760         del kwargs["dry_run"]
761     else:
762         dry_run = False
763     kwargs_redirected = dict(kwargs)
764     if not dry_run:
765         if hasattr(subprocess, "DEVNULL"):
766             kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
767         else:
768             kwargs_redirected["stdout"] = open(os.devnull, "w")
769         kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
770     retval = subprocess.call(*args, **kwargs_redirected)
771     if retval != 0:
772         time.sleep(1)
773         retval = subprocess.call(*args, **kwargs)
774     return retval
775
776
777 def get_fb_dimensions(configuration):
778     width = 0
779     height = 0
780     for output in configuration.values():
781         if "off" in output.options or not output.edid:
782             continue
783         # This won't work with all modes -- but it's a best effort.
784         match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
785         if not match:
786             return None
787         o_mode = match.group(0)
788         o_width, o_height = map(int, o_mode.split("x"))
789         if "transform" in output.options:
790             a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
791             w = (g * o_width + h * o_height + i)
792             x = (a * o_width + b * o_height + c) / w
793             y = (d * o_width + e * o_height + f) / w
794             o_width, o_height = x, y
795         if "rotate" in output.options:
796             if output.options["rotate"] in ("left", "right"):
797                 o_width, o_height = o_height, o_width
798         if "pos" in output.options:
799             o_left, o_top = map(int, output.options["pos"].split("x"))
800             o_width += o_left
801             o_height += o_top
802         if "panning" in output.options:
803             match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
804             if match:
805                 detail = match.groupdict(default="0")
806                 o_width = int(detail.get("w")) + int(detail.get("x"))
807                 o_height = int(detail.get("h")) + int(detail.get("y"))
808         width = max(width, o_width)
809         height = max(height, o_height)
810     return int(width), int(height)
811
812
813 def apply_configuration(new_configuration, current_configuration, dry_run=False):
814     "Apply a configuration"
815     found_top_left_monitor = False
816     found_left_monitor = False
817     found_top_monitor = False
818     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
819     if dry_run:
820         base_argv = ["echo", "xrandr"]
821     else:
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()