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