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