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