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