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