]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Version bump: autorandr-1.8
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
11 # This program is free software: you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation, either version 3 of the License, or
14 # (at your option) any later version.
15 #
16 # This program is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
20 #
21 # You should have received a copy of the GNU General Public License
22 # along with this program. If not, see <http://www.gnu.org/licenses/>.
23 #
24
25 from __future__ import print_function
26
27 import binascii
28 import copy
29 import fnmatch
30 import getopt
31 import hashlib
32 import os
33 import posix
34 import pwd
35 import re
36 import subprocess
37 import sys
38 import shutil
39 import time
40
41 from collections import OrderedDict
42 from distutils.version import LooseVersion as Version
43 from functools import reduce
44 from itertools import chain
45
46 if sys.version_info.major == 2:
47     import ConfigParser as configparser
48 else:
49     import configparser
50
51 __version__ = "1.8"
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 output not in current_configuration:
712                 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
713                                          "Don't know how to proceed." % output)
714             if "off" not in current_configuration[output].options:
715                 remain_active_count += 1
716
717             option_vector = new_configuration[output].option_vector
718             if xrandr_version() >= Version("1.3.0"):
719                 for option, off_value in (("transform", "none"), ("panning", "0x0")):
720                     if option in current_configuration[output].options:
721                         auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
722                     else:
723                         try:
724                             option_index = option_vector.index("--%s" % option)
725                             if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
726                                 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
727                         except ValueError:
728                             pass
729
730             enable_outputs.append(option_vector)
731
732     # Perform pe-change auxiliary changes
733     if auxiliary_changes_pre:
734         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
735         if call_and_retry(argv, dry_run=dry_run) != 0:
736             raise AutorandrException("Command failed: %s" % " ".join(argv))
737
738     # Disable unused outputs, but make sure that there always is at least one active screen
739     disable_keep = 0 if remain_active_count else 1
740     if len(disable_outputs) > disable_keep:
741         argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
742         if call_and_retry(argv, dry_run=dry_run) != 0:
743             # Disabling the outputs failed. Retry with the next command:
744             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
745             # This does not occur if simultaneously the primary screen is reset.
746             pass
747         else:
748             disable_outputs = disable_outputs[-1:] if disable_keep else []
749
750     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
751     # disable the last two screens. This is a problem, so if this would happen, instead disable only
752     # one screen in the first call below.
753     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
754         # In the context of a xrandr call that changes the display state, `--query' should do nothing
755         disable_outputs.insert(0, ['--query'])
756
757     # Enable the remaining outputs in pairs of two operations
758     operations = disable_outputs + enable_outputs
759     for index in range(0, len(operations), 2):
760         argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
761         if call_and_retry(argv, dry_run=dry_run) != 0:
762             raise AutorandrException("Command failed: %s" % " ".join(argv))
763
764
765 def is_equal_configuration(source_configuration, target_configuration):
766     """
767         Check if all outputs from target are already configured correctly in source and
768         that no other outputs are active.
769     """
770     for output in target_configuration.keys():
771         if "off" in target_configuration[output].options:
772             if (output in source_configuration and "off" not in source_configuration[output].options):
773                 return False
774         else:
775             if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
776                 return False
777     for output in source_configuration.keys():
778         if "off" in source_configuration[output].options:
779             if output in target_configuration and "off" not in target_configuration[output].options:
780                 return False
781         else:
782             if output not in target_configuration:
783                 return False
784     return True
785
786
787 def add_unused_outputs(source_configuration, target_configuration):
788     "Add outputs that are missing in target to target, in 'off' state"
789     for output_name, output in source_configuration.items():
790         if output_name not in target_configuration:
791             target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
792
793
794 def remove_irrelevant_outputs(source_configuration, target_configuration):
795     "Remove outputs from target that ought to be 'off' and already are"
796     for output_name, output in source_configuration.items():
797         if "off" in output.options:
798             if output_name in target_configuration:
799                 if "off" in target_configuration[output_name].options:
800                     del target_configuration[output_name]
801
802
803 def generate_virtual_profile(configuration, modes, profile_name):
804     "Generate one of the virtual profiles"
805     configuration = copy.deepcopy(configuration)
806     if profile_name == "common":
807         mode_sets = []
808         for output, output_modes in modes.items():
809             mode_set = set()
810             if configuration[output].edid:
811                 for mode in output_modes:
812                     mode_set.add((mode["width"], mode["height"]))
813             mode_sets.append(mode_set)
814         common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
815         common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
816         if common_resolution:
817             for output in configuration:
818                 configuration[output].options = {}
819                 if output in modes and configuration[output].edid:
820                     modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
821                     modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
822                     mode = modes_filtered[0]
823                     configuration[output].options["mode"] = mode['name']
824                     configuration[output].options["pos"] = "0x0"
825                 else:
826                     configuration[output].options["off"] = None
827     elif profile_name in ("horizontal", "vertical"):
828         shift = 0
829         if profile_name == "horizontal":
830             shift_index = "width"
831             pos_specifier = "%sx0"
832         else:
833             shift_index = "height"
834             pos_specifier = "0x%s"
835
836         for output in configuration:
837             configuration[output].options = {}
838             if output in modes and configuration[output].edid:
839                 def key(a):
840                     score = int(a["width"]) * int(a["height"])
841                     if a["preferred"]:
842                         score += 10**6
843                     return score
844                 output_modes = sorted(modes[output], key=key)
845                 mode = output_modes[-1]
846                 configuration[output].options["mode"] = mode["name"]
847                 configuration[output].options["rate"] = mode["rate"]
848                 configuration[output].options["pos"] = pos_specifier % shift
849                 shift += int(mode[shift_index])
850             else:
851                 configuration[output].options["off"] = None
852     elif profile_name == "clone-largest":
853         modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
854         modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
855         biggest_resolution = modes_sorted[0]
856         for output in configuration:
857             configuration[output].options = {}
858             if output in modes and configuration[output].edid:
859                 def key(a):
860                     score = int(a["width"]) * int(a["height"])
861                     if a["preferred"]:
862                         score += 10**6
863                     return score
864                 output_modes = sorted(modes[output], key=key)
865                 mode = output_modes[-1]
866                 configuration[output].options["mode"] = mode["name"]
867                 configuration[output].options["rate"] = mode["rate"]
868                 configuration[output].options["pos"] = "0x0"
869                 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
870                             float(biggest_resolution["height"]) / float(mode["height"]))
871                 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
872                 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
873                 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
874             else:
875                 configuration[output].options["off"] = None
876     elif profile_name == "off":
877         for output in configuration:
878             for key in list(configuration[output].options.keys()):
879                 del configuration[output].options[key]
880             configuration[output].options["off"] = None
881     return configuration
882
883
884 def print_profile_differences(one, another):
885     "Print the differences between two profiles for debugging"
886     if one == another:
887         return
888     print("| Differences between the two profiles:")
889     for output in set(chain.from_iterable((one.keys(), another.keys()))):
890         if output not in one:
891             if "off" not in another[output].options:
892                 print("| Output `%s' is missing from the active configuration" % output)
893         elif output not in another:
894             if "off" not in one[output].options:
895                 print("| Output `%s' is missing from the new configuration" % output)
896         else:
897             for line in one[output].verbose_diff(another[output]):
898                 print("| [Output %s] %s" % (output, line))
899     print("\\-")
900
901
902 def exit_help():
903     "Print help and exit"
904     print(help_text)
905     for profile in virtual_profiles:
906         name, description = profile[:2]
907         description = [description]
908         max_width = 78 - 18
909         while len(description[0]) > max_width + 1:
910             left_over = description[0][max_width:]
911             description[0] = description[0][:max_width] + "-"
912             description.insert(1, "  %-15s %s" % ("", left_over))
913         description = "\n".join(description)
914         print("  %-15s %s" % (name, description))
915     sys.exit(0)
916
917
918 def exec_scripts(profile_path, script_name, meta_information=None):
919     """"Run userscripts
920
921     This will run all executables from the profile folder, and global per-user
922     and system-wide configuration folders, named script_name or residing in
923     subdirectories named script_name.d.
924
925     If profile_path is None, only global scripts will be invoked.
926
927     meta_information is expected to be an dictionary. It will be passed to the block scripts
928     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
929
930     Returns True unless any of the scripts exited with non-zero exit status.
931     """
932     all_ok = True
933     env = os.environ.copy()
934     if meta_information:
935         for key, value in meta_information.items():
936             env["AUTORANDR_{}".format(key.upper())] = str(value)
937
938     # If there are multiple candidates, the XDG spec tells to only use the first one.
939     ran_scripts = set()
940
941     user_profile_path = os.path.expanduser("~/.autorandr")
942     if not os.path.isdir(user_profile_path):
943         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
944
945     candidate_directories = [user_profile_path]
946     for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
947         candidate_directories.append(os.path.join(config_dir, "autorandr"))
948     if profile_path:
949         candidate_directories.append(profile_path)
950
951     for folder in candidate_directories:
952         if script_name not in ran_scripts:
953             script = os.path.join(folder, script_name)
954             if os.access(script, os.X_OK | os.F_OK):
955                 try:
956                     all_ok &= subprocess.call(script, env=env) != 0
957                 except:
958                     raise AutorandrException("Failed to execute user command: %s" % (script,))
959                 ran_scripts.add(script_name)
960
961         script_folder = os.path.join(folder, "%s.d" % script_name)
962         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
963             for file_name in os.listdir(script_folder):
964                 check_name = "d/%s" % (file_name,)
965                 if check_name not in ran_scripts:
966                     script = os.path.join(script_folder, file_name)
967                     if os.access(script, os.X_OK | os.F_OK):
968                         try:
969                             all_ok &= subprocess.call(script, env=env) != 0
970                         except:
971                             raise AutorandrException("Failed to execute user command: %s" % (script,))
972                         ran_scripts.add(check_name)
973
974     return all_ok
975
976
977 def dispatch_call_to_sessions(argv):
978     """Invoke autorandr for each open local X11 session with the given options.
979
980     The function iterates over all processes not owned by root and checks
981     whether they have DISPLAY and XAUTHORITY variables set. It strips the
982     screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
983     this display has been handled already. If it has not, it forks, changes
984     uid/gid to the user owning the process, reuses the process's environment
985     and runs autorandr with the parameters from argv.
986
987     This function requires root permissions. It only works for X11 servers that
988     have at least one non-root process running. It is susceptible for attacks
989     where one user runs a process with another user's DISPLAY variable - in
990     this case, it might happen that autorandr is invoked for the other user,
991     which won't work. Since no other harm than prevention of automated
992     execution of autorandr can be done this way, the assumption is that in this
993     situation, the local administrator will handle the situation."""
994
995     X11_displays_done = set()
996
997     autorandr_binary = os.path.abspath(argv[0])
998     backup_candidates = {}
999
1000     def fork_child_autorandr(pwent, process_environ):
1001         print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1002         child_pid = os.fork()
1003         if child_pid == 0:
1004             # This will throw an exception if any of the privilege changes fails,
1005             # so it should be safe. Also, note that since the environment
1006             # is taken from a process owned by the user, reusing it should
1007             # not leak any information.
1008             os.setgroups([])
1009             os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1010             os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1011             os.chdir(pwent.pw_dir)
1012             os.environ.clear()
1013             os.environ.update(process_environ)
1014             os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1015             os.exit(1)
1016         os.waitpid(child_pid, 0)
1017
1018     for directory in os.listdir("/proc"):
1019         directory = os.path.join("/proc/", directory)
1020         if not os.path.isdir(directory):
1021             continue
1022         environ_file = os.path.join(directory, "environ")
1023         if not os.path.isfile(environ_file):
1024             continue
1025         uid = os.stat(environ_file).st_uid
1026
1027         # The following line assumes that user accounts start at 1000 and that
1028         # no one works using the root or another system account. This is rather
1029         # restrictive, but de facto default. Alternatives would be to use the
1030         # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
1031         # but effectively, both values aren't binding in any way.
1032         # If this breaks your use case, please file a bug on Github.
1033         if uid < 1000:
1034             continue
1035
1036         process_environ = {}
1037         for environ_entry in open(environ_file).read().split("\0"):
1038             name, sep, value = environ_entry.partition("=")
1039             if name and sep:
1040                 if name == "DISPLAY" and "." in value:
1041                     value = value[:value.find(".")]
1042                 process_environ[name] = value
1043
1044         if "DISPLAY" not in process_environ:
1045             # Cannot work with this environment, skip.
1046             continue
1047
1048         # To allow scripts to detect batch invocation (especially useful for predetect)
1049         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1050         process_environ["UID"] = str(uid)
1051
1052         display = process_environ["DISPLAY"]
1053
1054         if "XAUTHORITY" not in process_environ:
1055             # It's very likely that we cannot work with this environment either,
1056             # but keep it as a backup just in case we don't find anything else.
1057             backup_candidates[display] = process_environ
1058             continue
1059
1060         if display not in X11_displays_done:
1061             try:
1062                 pwent = pwd.getpwuid(uid)
1063             except KeyError:
1064                 # User has no pwd entry
1065                 continue
1066
1067             fork_child_autorandr(pwent, process_environ)
1068             X11_displays_done.add(display)
1069
1070     # Run autorandr for any users/displays which didn't have a process with
1071     # XAUTHORITY set.
1072     for display, process_environ in backup_candidates.items():
1073         if display not in X11_displays_done:
1074             try:
1075                 pwent = pwd.getpwuid(int(process_environ["UID"]))
1076             except KeyError:
1077                 # User has no pwd entry
1078                 continue
1079
1080             fork_child_autorandr(pwent, process_environ)
1081             X11_displays_done.add(display)
1082
1083
1084 def enabled_monitors(config):
1085     monitors = []
1086     for monitor in config:
1087         if "--off" in config[monitor].option_vector:
1088             continue
1089         monitors.append(monitor)
1090     return monitors
1091
1092
1093 def read_config(options, directory):
1094     """Parse a configuration config.ini from directory and merge it into
1095     the options dictionary"""
1096     config = configparser.ConfigParser()
1097     config.read(os.path.join(directory, "settings.ini"))
1098     if config.has_section("config"):
1099         for key, value in config.items("config"):
1100             options.setdefault("--%s" % key, value)
1101
1102 def main(argv):
1103     try:
1104         opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1105                                    ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
1106                                     "force", "fingerprint", "config", "debug", "skip-options=", "help",
1107                                     "current", "detected", "version"])
1108     except getopt.GetoptError as e:
1109         print("Failed to parse options: {0}.\n"
1110               "Use --help to get usage information.".format(str(e)),
1111               file=sys.stderr)
1112         sys.exit(posix.EX_USAGE)
1113
1114     options = dict(opts)
1115
1116     if "-h" in options or "--help" in options:
1117         exit_help()
1118
1119     if "--version" in options:
1120         print("autorandr " + __version__)
1121         sys.exit(0)
1122
1123     if "--current" in options and "--detected" in options:
1124         print("--current and --detected are mutually exclusive.", file=sys.stderr)
1125         sys.exit(posix.EX_USAGE)
1126
1127     # Batch mode
1128     if "--batch" in options:
1129         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1130             dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1131         else:
1132             print("--batch mode can only be used by root and if $DISPLAY is unset")
1133         return
1134     if "AUTORANDR_BATCH_PID" in os.environ:
1135         user = pwd.getpwuid(os.getuid())
1136         user = user.pw_name if user else "#%d" % os.getuid()
1137         print("autorandr running as user %s (started from batch instance)" % user)
1138
1139     profiles = {}
1140     profile_symlinks = {}
1141     try:
1142         # Load profiles from each XDG config directory
1143         # The XDG spec says that earlier entries should take precedence, so reverse the order
1144         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1145             system_profile_path = os.path.join(directory, "autorandr")
1146             if os.path.isdir(system_profile_path):
1147                 profiles.update(load_profiles(system_profile_path))
1148                 profile_symlinks.update(get_symlinks(system_profile_path))
1149                 read_config(options, system_profile_path)
1150         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1151         # profile_path is also used later on to store configurations
1152         profile_path = os.path.expanduser("~/.autorandr")
1153         if not os.path.isdir(profile_path):
1154             # Elsewise, follow the XDG specification
1155             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1156         if os.path.isdir(profile_path):
1157             profiles.update(load_profiles(profile_path))
1158             profile_symlinks.update(get_symlinks(profile_path))
1159             read_config(options, profile_path)
1160         # Sort by descending mtime
1161         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
1162     except Exception as e:
1163         raise AutorandrException("Failed to load profiles", e)
1164
1165     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}
1166
1167     exec_scripts(None, "predetect")
1168     config, modes = parse_xrandr_output()
1169
1170     if "--fingerprint" in options:
1171         output_setup(config, sys.stdout)
1172         sys.exit(0)
1173
1174     if "--config" in options:
1175         output_configuration(config, sys.stdout)
1176         sys.exit(0)
1177
1178     if "--skip-options" in options:
1179         skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1180         for profile in profiles.values():
1181             for output in profile["config"].values():
1182                 output.set_ignored_options(skip_options)
1183         for output in config.values():
1184             output.set_ignored_options(skip_options)
1185
1186     if "-s" in options:
1187         options["--save"] = options["-s"]
1188     if "--save" in options:
1189         if options["--save"] in (x[0] for x in virtual_profiles):
1190             raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1191                                      "This configuration name is a reserved virtual configuration." % options["--save"])
1192         try:
1193             profile_folder = os.path.join(profile_path, options["--save"])
1194             save_configuration(profile_folder, config)
1195             exec_scripts(profile_folder, "postsave", {
1196                 "CURRENT_PROFILE": options["--save"],
1197                 "PROFILE_FOLDER": profile_folder,
1198                 "MONITORS": ":".join(enabled_monitors(config)),
1199             })
1200         except Exception as e:
1201             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1202         print("Saved current configuration as profile '%s'" % options["--save"])
1203         sys.exit(0)
1204
1205     if "-r" in options:
1206         options["--remove"] = options["-r"]
1207     if "--remove" in options:
1208         if options["--remove"] in (x[0] for x in virtual_profiles):
1209             raise AutorandrException("Cannot remove profile '%s':\n"
1210                                      "This configuration name is a reserved virtual configuration." % options["--remove"])
1211         if options["--remove"] not in profiles.keys():
1212             raise AutorandrException("Cannot remove profile '%s':\n"
1213                                      "This profile does not exist." % options["--remove"])
1214         try:
1215             remove = True
1216             profile_folder = os.path.join(profile_path, options["--remove"])
1217             profile_dirlist = os.listdir(profile_folder)
1218             profile_dirlist.remove("config")
1219             profile_dirlist.remove("setup")
1220             if profile_dirlist:
1221                 print("Profile folder '%s' contains the following additional files:\n"
1222                       "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1223                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1224                 if response != "yes":
1225                     remove = False
1226             if remove is True:
1227                 shutil.rmtree(profile_folder)
1228                 print("Removed profile '%s'" % options["--remove"])
1229             else:
1230                 print("Profile '%s' was not removed" % options["--remove"])
1231         except Exception as e:
1232             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1233         sys.exit(0)
1234
1235     detected_profiles = find_profiles(config, profiles)
1236     load_profile = False
1237
1238     if "-l" in options:
1239         options["--load"] = options["-l"]
1240     if "--load" in options:
1241         load_profile = options["--load"]
1242     elif len(args) == 1:
1243         load_profile = args[0]
1244     else:
1245         # Find the active profile(s) first, for the block script (See #42)
1246         current_profiles = []
1247         for profile_name in profiles.keys():
1248             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1249             if configs_are_equal:
1250                 current_profiles.append(profile_name)
1251         block_script_metadata = {
1252             "CURRENT_PROFILE": "".join(current_profiles[:1]),
1253             "CURRENT_PROFILES": ":".join(current_profiles)
1254         }
1255
1256         for profile_name in profiles.keys():
1257             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1258                 if "--current" not in options and "--detected" not in options:
1259                     print("%s (blocked)" % profile_name)
1260                 continue
1261             props = []
1262             if profile_name in detected_profiles:
1263                 props.append("(detected)")
1264                 if ("-c" in options or "--change" in options) and not load_profile:
1265                     load_profile = profile_name
1266             elif "--detected" in options:
1267                 continue
1268             if profile_name in current_profiles:
1269                 props.append("(current)")
1270             elif "--current" in options:
1271                 continue
1272             if "--current" in options or "--detected" in options:
1273                 print("%s" % (profile_name, ))
1274             else:
1275                 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1276             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1277                 print_profile_differences(config, profiles[profile_name]["config"])
1278
1279     if "-d" in options:
1280         options["--default"] = options["-d"]
1281     if not load_profile and "--default" in options and ("-c" in options or "--change" in options):
1282         load_profile = options["--default"]
1283
1284     if load_profile:
1285         if load_profile in profile_symlinks:
1286             if "--debug" in options:
1287                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1288             load_profile = profile_symlinks[load_profile]
1289
1290         if load_profile in (x[0] for x in virtual_profiles):
1291             load_config = generate_virtual_profile(config, modes, load_profile)
1292             scripts_path = os.path.join(profile_path, load_profile)
1293         else:
1294             try:
1295                 profile = profiles[load_profile]
1296                 load_config = profile["config"]
1297                 scripts_path = profile["path"]
1298             except KeyError:
1299                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1300             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1301                 update_mtime(os.path.join(scripts_path, "config"))
1302         add_unused_outputs(config, load_config)
1303         if load_config == dict(config) and "-f" not in options and "--force" not in options:
1304             print("Config already loaded", file=sys.stderr)
1305             sys.exit(0)
1306         if "--debug" in options and load_config != dict(config):
1307             print("Loading profile '%s'" % load_profile)
1308             print_profile_differences(config, load_config)
1309
1310         remove_irrelevant_outputs(config, load_config)
1311
1312         try:
1313             if "--dry-run" in options:
1314                 apply_configuration(load_config, config, True)
1315             else:
1316                 script_metadata = {
1317                     "CURRENT_PROFILE": load_profile,
1318                     "PROFILE_FOLDER": scripts_path,
1319                     "MONITORS": ":".join(enabled_monitors(load_config)),
1320                 }
1321                 exec_scripts(scripts_path, "preswitch", script_metadata)
1322                 if "--debug" in options:
1323                     print("Going to run:")
1324                     apply_configuration(load_config, config, True)
1325                 apply_configuration(load_config, config, False)
1326                 exec_scripts(scripts_path, "postswitch", script_metadata)
1327         except AutorandrException as e:
1328             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1329         except Exception as e:
1330             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1331
1332         if "--dry-run" not in options and "--debug" in options:
1333             new_config, _ = parse_xrandr_output()
1334             if not is_equal_configuration(new_config, load_config):
1335                 print("The configuration change did not go as expected:")
1336                 print_profile_differences(new_config, load_config)
1337
1338     sys.exit(0)
1339
1340
1341 def exception_handled_main(argv=sys.argv):
1342     try:
1343         main(sys.argv)
1344     except AutorandrException as e:
1345         print(e, file=sys.stderr)
1346         sys.exit(1)
1347     except Exception as e:
1348         if not len(str(e)):  # BdbQuit
1349             print("Exception: {0}".format(e.__class__.__name__))
1350             sys.exit(2)
1351
1352         print("Unhandled exception ({0}). Please report this as a bug at "
1353               "https://github.com/phillipberndt/autorandr/issues.".format(e),
1354               file=sys.stderr)
1355         raise
1356
1357
1358 if __name__ == '__main__':
1359     exception_handled_main()