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