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