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