]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Don't fail hard in get_fb_dimensions if a mode isn't WWWxHHH
[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
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, configuration):
612     "Save a configuration into a profile"
613     if not os.path.isdir(profile_path):
614         os.makedirs(profile_path)
615     with open(os.path.join(profile_path, "config"), "w") as config:
616         output_configuration(configuration, config)
617     with open(os.path.join(profile_path, "setup"), "w") as setup:
618         output_setup(configuration, setup)
619
620
621 def update_mtime(filename):
622     "Update a file's mtime"
623     try:
624         os.utime(filename, None)
625         return True
626     except:
627         return False
628
629
630 def call_and_retry(*args, **kwargs):
631     """Wrapper around subprocess.call that retries failed calls.
632
633     This function calls subprocess.call and on non-zero exit states,
634     waits a second and then retries once. This mitigates #47,
635     a timing issue with some drivers.
636     """
637     if "dry_run" in kwargs:
638         dry_run = kwargs["dry_run"]
639         del kwargs["dry_run"]
640     else:
641         dry_run = False
642     kwargs_redirected = dict(kwargs)
643     if not dry_run:
644         if hasattr(subprocess, "DEVNULL"):
645             kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
646         else:
647             kwargs_redirected["stdout"] = open(os.devnull, "w")
648         kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
649     retval = subprocess.call(*args, **kwargs_redirected)
650     if retval != 0:
651         time.sleep(1)
652         retval = subprocess.call(*args, **kwargs)
653     return retval
654
655
656 def get_fb_dimensions(configuration):
657     width = 0
658     height = 0
659     for output in configuration.values():
660         if "off" in output.options or not output.edid:
661             continue
662         # This won't work with all modes -- but it's a best effort.
663         match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
664         if not match:
665             return None
666         o_mode = match.group(0)
667         o_width, o_height = map(int, o_mode.split("x"))
668         if "transform" in output.options:
669             a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
670             w = (g * o_width + h * o_height + i)
671             x = (a * o_width + b * o_height + c) / w
672             y = (d * o_width + e * o_height + f) / w
673             o_width, o_height = x, y
674         if "rotate" in output.options:
675             if output.options["rotate"] in ("left", "right"):
676                 o_width, o_height = o_height, o_width
677         if "pos" in output.options:
678             o_left, o_top = map(int, output.options["pos"].split("x"))
679             o_width += o_left
680             o_height += o_top
681         if "panning" in output.options:
682             match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
683             if match:
684                 detail = match.groupdict(default="0")
685                 o_width = int(detail.get("w")) + int(detail.get("x"))
686                 o_height = int(detail.get("h")) + int(detail.get("y"))
687         width = max(width, o_width)
688         height = max(height, o_height)
689     return int(width), int(height)
690
691
692 def apply_configuration(new_configuration, current_configuration, dry_run=False):
693     "Apply a configuration"
694     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
695     if dry_run:
696         base_argv = ["echo", "xrandr"]
697     else:
698         base_argv = ["xrandr"]
699
700     # There are several xrandr / driver bugs we need to take care of here:
701     # - We cannot enable more than two screens at the same time
702     #   See https://github.com/phillipberndt/autorandr/pull/6
703     #   and commits f4cce4d and 8429886.
704     # - We cannot disable all screens
705     #   See https://github.com/phillipberndt/autorandr/pull/20
706     # - We should disable screens before enabling others, because there's
707     #   a limit on the number of enabled screens
708     # - We must make sure that the screen at 0x0 is activated first,
709     #   or the other (first) screen to be activated would be moved there.
710     # - If an active screen already has a transformation and remains active,
711     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
712     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
713     #   at least.)
714     # - Some implementations can not handle --transform at all, so avoid it unless
715     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
716     # - Some implementations can not handle --panning without specifying --fb
717     #   explicitly, so avoid it unless necessary.
718     #   (See https://github.com/phillipberndt/autorandr/issues/72)
719
720     fb_dimensions = get_fb_dimensions(new_configuration)
721     try:
722         base_argv += ["--fb", "%dx%d" % fb_dimensions]
723     except:
724         # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
725         pass
726
727     auxiliary_changes_pre = []
728     disable_outputs = []
729     enable_outputs = []
730     remain_active_count = 0
731     for output in outputs:
732         if not new_configuration[output].edid or "off" in new_configuration[output].options:
733             disable_outputs.append(new_configuration[output].option_vector)
734         else:
735             if output not in current_configuration:
736                 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
737                                          "Don't know how to proceed." % output)
738             if "off" not in current_configuration[output].options:
739                 remain_active_count += 1
740
741             option_vector = new_configuration[output].option_vector
742             if xrandr_version() >= Version("1.3.0"):
743                 for option, off_value in (("transform", "none"), ("panning", "0x0")):
744                     if option in current_configuration[output].options:
745                         auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
746                     else:
747                         try:
748                             option_index = option_vector.index("--%s" % option)
749                             if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
750                                 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
751                         except ValueError:
752                             pass
753
754             enable_outputs.append(option_vector)
755
756     # Perform pe-change auxiliary changes
757     if auxiliary_changes_pre:
758         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
759         if call_and_retry(argv, dry_run=dry_run) != 0:
760             raise AutorandrException("Command failed: %s" % " ".join(argv))
761
762     # Disable unused outputs, but make sure that there always is at least one active screen
763     disable_keep = 0 if remain_active_count else 1
764     if len(disable_outputs) > disable_keep:
765         argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
766         if call_and_retry(argv, dry_run=dry_run) != 0:
767             # Disabling the outputs failed. Retry with the next command:
768             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
769             # This does not occur if simultaneously the primary screen is reset.
770             pass
771         else:
772             disable_outputs = disable_outputs[-1:] if disable_keep else []
773
774     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
775     # disable the last two screens. This is a problem, so if this would happen, instead disable only
776     # one screen in the first call below.
777     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
778         # In the context of a xrandr call that changes the display state, `--query' should do nothing
779         disable_outputs.insert(0, ['--query'])
780
781     # Enable the remaining outputs in pairs of two operations
782     operations = disable_outputs + enable_outputs
783     for index in range(0, len(operations), 2):
784         argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
785         if call_and_retry(argv, dry_run=dry_run) != 0:
786             raise AutorandrException("Command failed: %s" % " ".join(argv))
787
788
789 def is_equal_configuration(source_configuration, target_configuration):
790     """
791         Check if all outputs from target are already configured correctly in source and
792         that no other outputs are active.
793     """
794     for output in target_configuration.keys():
795         if "off" in target_configuration[output].options:
796             if (output in source_configuration and "off" not in source_configuration[output].options):
797                 return False
798         else:
799             if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
800                 return False
801     for output in source_configuration.keys():
802         if "off" in source_configuration[output].options:
803             if output in target_configuration and "off" not in target_configuration[output].options:
804                 return False
805         else:
806             if output not in target_configuration:
807                 return False
808     return True
809
810
811 def add_unused_outputs(source_configuration, target_configuration):
812     "Add outputs that are missing in target to target, in 'off' state"
813     for output_name, output in source_configuration.items():
814         if output_name not in target_configuration:
815             target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
816
817
818 def remove_irrelevant_outputs(source_configuration, target_configuration):
819     "Remove outputs from target that ought to be 'off' and already are"
820     for output_name, output in source_configuration.items():
821         if "off" in output.options:
822             if output_name in target_configuration:
823                 if "off" in target_configuration[output_name].options:
824                     del target_configuration[output_name]
825
826
827 def generate_virtual_profile(configuration, modes, profile_name):
828     "Generate one of the virtual profiles"
829     configuration = copy.deepcopy(configuration)
830     if profile_name == "common":
831         mode_sets = []
832         for output, output_modes in modes.items():
833             mode_set = set()
834             if configuration[output].edid:
835                 for mode in output_modes:
836                     mode_set.add((mode["width"], mode["height"]))
837             mode_sets.append(mode_set)
838         common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
839         common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
840         if common_resolution:
841             for output in configuration:
842                 configuration[output].options = {}
843                 if output in modes and configuration[output].edid:
844                     modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
845                     modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
846                     mode = modes_filtered[0]
847                     configuration[output].options["mode"] = mode['name']
848                     configuration[output].options["pos"] = "0x0"
849                 else:
850                     configuration[output].options["off"] = None
851     elif profile_name in ("horizontal", "vertical"):
852         shift = 0
853         if profile_name == "horizontal":
854             shift_index = "width"
855             pos_specifier = "%sx0"
856         else:
857             shift_index = "height"
858             pos_specifier = "0x%s"
859
860         for output in configuration:
861             configuration[output].options = {}
862             if output in modes and configuration[output].edid:
863                 def key(a):
864                     score = int(a["width"]) * int(a["height"])
865                     if a["preferred"]:
866                         score += 10**6
867                     return score
868                 output_modes = sorted(modes[output], key=key)
869                 mode = output_modes[-1]
870                 configuration[output].options["mode"] = mode["name"]
871                 configuration[output].options["rate"] = mode["rate"]
872                 configuration[output].options["pos"] = pos_specifier % shift
873                 shift += int(mode[shift_index])
874             else:
875                 configuration[output].options["off"] = None
876     elif profile_name == "clone-largest":
877         modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
878         modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
879         biggest_resolution = modes_sorted[0]
880         for output in configuration:
881             configuration[output].options = {}
882             if output in modes and configuration[output].edid:
883                 def key(a):
884                     score = int(a["width"]) * int(a["height"])
885                     if a["preferred"]:
886                         score += 10**6
887                     return score
888                 output_modes = sorted(modes[output], key=key)
889                 mode = output_modes[-1]
890                 configuration[output].options["mode"] = mode["name"]
891                 configuration[output].options["rate"] = mode["rate"]
892                 configuration[output].options["pos"] = "0x0"
893                 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
894                             float(biggest_resolution["height"]) / float(mode["height"]))
895                 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
896                 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
897                 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
898             else:
899                 configuration[output].options["off"] = None
900     elif profile_name == "off":
901         for output in configuration:
902             for key in list(configuration[output].options.keys()):
903                 del configuration[output].options[key]
904             configuration[output].options["off"] = None
905     return configuration
906
907
908 def print_profile_differences(one, another):
909     "Print the differences between two profiles for debugging"
910     if one == another:
911         return
912     print("| Differences between the two profiles:")
913     for output in set(chain.from_iterable((one.keys(), another.keys()))):
914         if output not in one:
915             if "off" not in another[output].options:
916                 print("| Output `%s' is missing from the active configuration" % output)
917         elif output not in another:
918             if "off" not in one[output].options:
919                 print("| Output `%s' is missing from the new configuration" % output)
920         else:
921             for line in one[output].verbose_diff(another[output]):
922                 print("| [Output %s] %s" % (output, line))
923     print("\\-")
924
925
926 def exit_help():
927     "Print help and exit"
928     print(help_text)
929     for profile in virtual_profiles:
930         name, description = profile[:2]
931         description = [description]
932         max_width = 78 - 18
933         while len(description[0]) > max_width + 1:
934             left_over = description[0][max_width:]
935             description[0] = description[0][:max_width] + "-"
936             description.insert(1, "  %-15s %s" % ("", left_over))
937         description = "\n".join(description)
938         print("  %-15s %s" % (name, description))
939     sys.exit(0)
940
941
942 def exec_scripts(profile_path, script_name, meta_information=None):
943     """"Run userscripts
944
945     This will run all executables from the profile folder, and global per-user
946     and system-wide configuration folders, named script_name or residing in
947     subdirectories named script_name.d.
948
949     If profile_path is None, only global scripts will be invoked.
950
951     meta_information is expected to be an dictionary. It will be passed to the block scripts
952     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
953
954     Returns True unless any of the scripts exited with non-zero exit status.
955     """
956     all_ok = True
957     env = os.environ.copy()
958     if meta_information:
959         for key, value in meta_information.items():
960             env["AUTORANDR_{}".format(key.upper())] = str(value)
961
962     # If there are multiple candidates, the XDG spec tells to only use the first one.
963     ran_scripts = set()
964
965     user_profile_path = os.path.expanduser("~/.autorandr")
966     if not os.path.isdir(user_profile_path):
967         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
968
969     candidate_directories = [user_profile_path]
970     for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
971         candidate_directories.append(os.path.join(config_dir, "autorandr"))
972     if profile_path:
973         candidate_directories.append(profile_path)
974
975     for folder in candidate_directories:
976         if script_name not in ran_scripts:
977             script = os.path.join(folder, script_name)
978             if os.access(script, os.X_OK | os.F_OK):
979                 try:
980                     all_ok &= subprocess.call(script, env=env) != 0
981                 except:
982                     raise AutorandrException("Failed to execute user command: %s" % (script,))
983                 ran_scripts.add(script_name)
984
985         script_folder = os.path.join(folder, "%s.d" % script_name)
986         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
987             for file_name in os.listdir(script_folder):
988                 check_name = "d/%s" % (file_name,)
989                 if check_name not in ran_scripts:
990                     script = os.path.join(script_folder, file_name)
991                     if os.access(script, os.X_OK | os.F_OK):
992                         try:
993                             all_ok &= subprocess.call(script, env=env) != 0
994                         except:
995                             raise AutorandrException("Failed to execute user command: %s" % (script,))
996                         ran_scripts.add(check_name)
997
998     return all_ok
999
1000
1001 def dispatch_call_to_sessions(argv):
1002     """Invoke autorandr for each open local X11 session with the given options.
1003
1004     The function iterates over all processes not owned by root and checks
1005     whether they have DISPLAY and XAUTHORITY variables set. It strips the
1006     screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1007     this display has been handled already. If it has not, it forks, changes
1008     uid/gid to the user owning the process, reuses the process's environment
1009     and runs autorandr with the parameters from argv.
1010
1011     This function requires root permissions. It only works for X11 servers that
1012     have at least one non-root process running. It is susceptible for attacks
1013     where one user runs a process with another user's DISPLAY variable - in
1014     this case, it might happen that autorandr is invoked for the other user,
1015     which won't work. Since no other harm than prevention of automated
1016     execution of autorandr can be done this way, the assumption is that in this
1017     situation, the local administrator will handle the situation."""
1018
1019     X11_displays_done = set()
1020
1021     autorandr_binary = os.path.abspath(argv[0])
1022     backup_candidates = {}
1023
1024     def fork_child_autorandr(pwent, process_environ):
1025         print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1026         child_pid = os.fork()
1027         if child_pid == 0:
1028             # This will throw an exception if any of the privilege changes fails,
1029             # so it should be safe. Also, note that since the environment
1030             # is taken from a process owned by the user, reusing it should
1031             # not leak any information.
1032             os.setgroups([])
1033             os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1034             os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1035             os.chdir(pwent.pw_dir)
1036             os.environ.clear()
1037             os.environ.update(process_environ)
1038             os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1039             os.exit(1)
1040         os.waitpid(child_pid, 0)
1041
1042     for directory in os.listdir("/proc"):
1043         directory = os.path.join("/proc/", directory)
1044         if not os.path.isdir(directory):
1045             continue
1046         environ_file = os.path.join(directory, "environ")
1047         if not os.path.isfile(environ_file):
1048             continue
1049         uid = os.stat(environ_file).st_uid
1050
1051         # The following line assumes that user accounts start at 1000 and that
1052         # no one works using the root or another system account. This is rather
1053         # restrictive, but de facto default. Alternatives would be to use the
1054         # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
1055         # but effectively, both values aren't binding in any way.
1056         # If this breaks your use case, please file a bug on Github.
1057         if uid < 1000:
1058             continue
1059
1060         process_environ = {}
1061         for environ_entry in open(environ_file).read().split("\0"):
1062             name, sep, value = environ_entry.partition("=")
1063             if name and sep:
1064                 if name == "DISPLAY" and "." in value:
1065                     value = value[:value.find(".")]
1066                 process_environ[name] = value
1067
1068         if "DISPLAY" not in process_environ:
1069             # Cannot work with this environment, skip.
1070             continue
1071
1072         # To allow scripts to detect batch invocation (especially useful for predetect)
1073         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1074         process_environ["UID"] = str(uid)
1075
1076         display = process_environ["DISPLAY"]
1077
1078         if "XAUTHORITY" not in process_environ:
1079             # It's very likely that we cannot work with this environment either,
1080             # but keep it as a backup just in case we don't find anything else.
1081             backup_candidates[display] = process_environ
1082             continue
1083
1084         if display not in X11_displays_done:
1085             try:
1086                 pwent = pwd.getpwuid(uid)
1087             except KeyError:
1088                 # User has no pwd entry
1089                 continue
1090
1091             fork_child_autorandr(pwent, process_environ)
1092             X11_displays_done.add(display)
1093
1094     # Run autorandr for any users/displays which didn't have a process with
1095     # XAUTHORITY set.
1096     for display, process_environ in backup_candidates.items():
1097         if display not in X11_displays_done:
1098             try:
1099                 pwent = pwd.getpwuid(int(process_environ["UID"]))
1100             except KeyError:
1101                 # User has no pwd entry
1102                 continue
1103
1104             fork_child_autorandr(pwent, process_environ)
1105             X11_displays_done.add(display)
1106
1107
1108 def enabled_monitors(config):
1109     monitors = []
1110     for monitor in config:
1111         if "--off" in config[monitor].option_vector:
1112             continue
1113         monitors.append(monitor)
1114     return monitors
1115
1116
1117 def read_config(options, directory):
1118     """Parse a configuration config.ini from directory and merge it into
1119     the options dictionary"""
1120     config = configparser.ConfigParser()
1121     config.read(os.path.join(directory, "settings.ini"))
1122     if config.has_section("config"):
1123         for key, value in config.items("config"):
1124             options.setdefault("--%s" % key, value)
1125
1126 def main(argv):
1127     try:
1128         opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1129                                    ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
1130                                     "force", "fingerprint", "config", "debug", "skip-options=", "help",
1131                                     "current", "detected", "version"])
1132     except getopt.GetoptError as e:
1133         print("Failed to parse options: {0}.\n"
1134               "Use --help to get usage information.".format(str(e)),
1135               file=sys.stderr)
1136         sys.exit(posix.EX_USAGE)
1137
1138     options = dict(opts)
1139
1140     if "-h" in options or "--help" in options:
1141         exit_help()
1142
1143     if "--version" in options:
1144         print("autorandr " + __version__)
1145         sys.exit(0)
1146
1147     if "--current" in options and "--detected" in options:
1148         print("--current and --detected are mutually exclusive.", file=sys.stderr)
1149         sys.exit(posix.EX_USAGE)
1150
1151     # Batch mode
1152     if "--batch" in options:
1153         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1154             dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1155         else:
1156             print("--batch mode can only be used by root and if $DISPLAY is unset")
1157         return
1158     if "AUTORANDR_BATCH_PID" in os.environ:
1159         user = pwd.getpwuid(os.getuid())
1160         user = user.pw_name if user else "#%d" % os.getuid()
1161         print("autorandr running as user %s (started from batch instance)" % user)
1162
1163     profiles = {}
1164     profile_symlinks = {}
1165     try:
1166         # Load profiles from each XDG config directory
1167         # The XDG spec says that earlier entries should take precedence, so reverse the order
1168         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1169             system_profile_path = os.path.join(directory, "autorandr")
1170             if os.path.isdir(system_profile_path):
1171                 profiles.update(load_profiles(system_profile_path))
1172                 profile_symlinks.update(get_symlinks(system_profile_path))
1173                 read_config(options, system_profile_path)
1174         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1175         # profile_path is also used later on to store configurations
1176         profile_path = os.path.expanduser("~/.autorandr")
1177         if not os.path.isdir(profile_path):
1178             # Elsewise, follow the XDG specification
1179             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1180         if os.path.isdir(profile_path):
1181             profiles.update(load_profiles(profile_path))
1182             profile_symlinks.update(get_symlinks(profile_path))
1183             read_config(options, profile_path)
1184         # Sort by descending mtime
1185         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
1186     except Exception as e:
1187         raise AutorandrException("Failed to load profiles", e)
1188
1189     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}
1190
1191     exec_scripts(None, "predetect")
1192     config, modes = parse_xrandr_output()
1193
1194     if "--fingerprint" in options:
1195         output_setup(config, sys.stdout)
1196         sys.exit(0)
1197
1198     if "--config" in options:
1199         output_configuration(config, sys.stdout)
1200         sys.exit(0)
1201
1202     if "--skip-options" in options:
1203         skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1204         for profile in profiles.values():
1205             for output in profile["config"].values():
1206                 output.set_ignored_options(skip_options)
1207         for output in config.values():
1208             output.set_ignored_options(skip_options)
1209
1210     if "-s" in options:
1211         options["--save"] = options["-s"]
1212     if "--save" in options:
1213         if options["--save"] in (x[0] for x in virtual_profiles):
1214             raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1215                                      "This configuration name is a reserved virtual configuration." % options["--save"])
1216         error = check_configuration_pre_save(config)
1217         if error:
1218             print("Cannot save current configuration as profile '%s':" % options["--save"])
1219             print(error)
1220             sys.exit(1)
1221         try:
1222             profile_folder = os.path.join(profile_path, options["--save"])
1223             save_configuration(profile_folder, config)
1224             exec_scripts(profile_folder, "postsave", {
1225                 "CURRENT_PROFILE": options["--save"],
1226                 "PROFILE_FOLDER": profile_folder,
1227                 "MONITORS": ":".join(enabled_monitors(config)),
1228             })
1229         except Exception as e:
1230             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1231         print("Saved current configuration as profile '%s'" % options["--save"])
1232         sys.exit(0)
1233
1234     if "-r" in options:
1235         options["--remove"] = options["-r"]
1236     if "--remove" in options:
1237         if options["--remove"] in (x[0] for x in virtual_profiles):
1238             raise AutorandrException("Cannot remove profile '%s':\n"
1239                                      "This configuration name is a reserved virtual configuration." % options["--remove"])
1240         if options["--remove"] not in profiles.keys():
1241             raise AutorandrException("Cannot remove profile '%s':\n"
1242                                      "This profile does not exist." % options["--remove"])
1243         try:
1244             remove = True
1245             profile_folder = os.path.join(profile_path, options["--remove"])
1246             profile_dirlist = os.listdir(profile_folder)
1247             profile_dirlist.remove("config")
1248             profile_dirlist.remove("setup")
1249             if profile_dirlist:
1250                 print("Profile folder '%s' contains the following additional files:\n"
1251                       "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1252                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1253                 if response != "yes":
1254                     remove = False
1255             if remove is True:
1256                 shutil.rmtree(profile_folder)
1257                 print("Removed profile '%s'" % options["--remove"])
1258             else:
1259                 print("Profile '%s' was not removed" % options["--remove"])
1260         except Exception as e:
1261             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1262         sys.exit(0)
1263
1264     detected_profiles = find_profiles(config, profiles)
1265     load_profile = False
1266
1267     if "-l" in options:
1268         options["--load"] = options["-l"]
1269     if "--load" in options:
1270         load_profile = options["--load"]
1271     elif len(args) == 1:
1272         load_profile = args[0]
1273     else:
1274         # Find the active profile(s) first, for the block script (See #42)
1275         current_profiles = []
1276         for profile_name in profiles.keys():
1277             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1278             if configs_are_equal:
1279                 current_profiles.append(profile_name)
1280         block_script_metadata = {
1281             "CURRENT_PROFILE": "".join(current_profiles[:1]),
1282             "CURRENT_PROFILES": ":".join(current_profiles)
1283         }
1284
1285         for profile_name in profiles.keys():
1286             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1287                 if "--current" not in options and "--detected" not in options:
1288                     print("%s (blocked)" % profile_name)
1289                 continue
1290             props = []
1291             if profile_name in detected_profiles:
1292                 props.append("(detected)")
1293                 if ("-c" in options or "--change" in options) and not load_profile:
1294                     load_profile = profile_name
1295             elif "--detected" in options:
1296                 continue
1297             if profile_name in current_profiles:
1298                 props.append("(current)")
1299             elif "--current" in options:
1300                 continue
1301             if "--current" in options or "--detected" in options:
1302                 print("%s" % (profile_name, ))
1303             else:
1304                 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1305             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1306                 print_profile_differences(config, profiles[profile_name]["config"])
1307
1308     if "-d" in options:
1309         options["--default"] = options["-d"]
1310     if not load_profile and "--default" in options and ("-c" in options or "--change" in options):
1311         load_profile = options["--default"]
1312
1313     if load_profile:
1314         if load_profile in profile_symlinks:
1315             if "--debug" in options:
1316                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1317             load_profile = profile_symlinks[load_profile]
1318
1319         if load_profile in (x[0] for x in virtual_profiles):
1320             load_config = generate_virtual_profile(config, modes, load_profile)
1321             scripts_path = os.path.join(profile_path, load_profile)
1322         else:
1323             try:
1324                 profile = profiles[load_profile]
1325                 load_config = profile["config"]
1326                 scripts_path = profile["path"]
1327             except KeyError:
1328                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1329             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1330                 update_mtime(os.path.join(scripts_path, "config"))
1331         add_unused_outputs(config, load_config)
1332         if load_config == dict(config) and "-f" not in options and "--force" not in options:
1333             print("Config already loaded", file=sys.stderr)
1334             sys.exit(0)
1335         if "--debug" in options and load_config != dict(config):
1336             print("Loading profile '%s'" % load_profile)
1337             print_profile_differences(config, load_config)
1338
1339         remove_irrelevant_outputs(config, load_config)
1340
1341         try:
1342             if "--dry-run" in options:
1343                 apply_configuration(load_config, config, True)
1344             else:
1345                 script_metadata = {
1346                     "CURRENT_PROFILE": load_profile,
1347                     "PROFILE_FOLDER": scripts_path,
1348                     "MONITORS": ":".join(enabled_monitors(load_config)),
1349                 }
1350                 exec_scripts(scripts_path, "preswitch", script_metadata)
1351                 if "--debug" in options:
1352                     print("Going to run:")
1353                     apply_configuration(load_config, config, True)
1354                 apply_configuration(load_config, config, False)
1355                 exec_scripts(scripts_path, "postswitch", script_metadata)
1356         except AutorandrException as e:
1357             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1358         except Exception as e:
1359             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1360
1361         if "--dry-run" not in options and "--debug" in options:
1362             new_config, _ = parse_xrandr_output()
1363             if not is_equal_configuration(new_config, load_config):
1364                 print("The configuration change did not go as expected:")
1365                 print_profile_differences(new_config, load_config)
1366
1367     sys.exit(0)
1368
1369
1370 def exception_handled_main(argv=sys.argv):
1371     try:
1372         main(sys.argv)
1373     except AutorandrException as e:
1374         print(e, file=sys.stderr)
1375         sys.exit(1)
1376     except Exception as e:
1377         if not len(str(e)):  # BdbQuit
1378             print("Exception: {0}".format(e.__class__.__name__))
1379             sys.exit(2)
1380
1381         print("Unhandled exception ({0}). Please report this as a bug at "
1382               "https://github.com/phillipberndt/autorandr/issues.".format(e),
1383               file=sys.stderr)
1384         raise
1385
1386
1387 if __name__ == '__main__':
1388     exception_handled_main()