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