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