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