]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Accept comments in config/setup files
[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     meta_information is expected to be an dictionary. It will be passed to the block scripts
748     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
749
750     Returns True unless any of the scripts exited with non-zero exit status.
751     """
752     all_ok = True
753     if meta_information:
754         env = os.environ.copy()
755         env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
756     else:
757         env = os.environ.copy()
758
759     # If there are multiple candidates, the XDG spec tells to only use the first one.
760     ran_scripts = set()
761
762     user_profile_path = os.path.expanduser("~/.autorandr")
763     if not os.path.isdir(user_profile_path):
764         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
765
766     for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
767                         (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"))):
768
769         if script_name not in ran_scripts:
770             script = os.path.join(folder, script_name)
771             if os.access(script, os.X_OK | os.F_OK):
772                 try:
773                     all_ok &= subprocess.call(script, env=env) != 0
774                 except:
775                     raise AutorandrException("Failed to execute user command: %s" % (script,))
776                 ran_scripts.add(script_name)
777
778         script_folder = os.path.join(folder, "%s.d" % script_name)
779         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
780             for file_name in os.listdir(script_folder):
781                 check_name = "d/%s" % (file_name,)
782                 if check_name not in ran_scripts:
783                     script = os.path.join(script_folder, file_name)
784                     if os.access(script, os.X_OK | os.F_OK):
785                         try:
786                             all_ok &= subprocess.call(script, env=env) != 0
787                         except:
788                             raise AutorandrException("Failed to execute user command: %s" % (script,))
789                         ran_scripts.add(check_name)
790
791     return all_ok
792
793 def dispatch_call_to_sessions(argv):
794     """Invoke autorandr for each open local X11 session with the given options.
795
796     The function iterates over all processes not owned by root and checks
797     whether they have a DISPLAY variable set. It strips the screen from any
798     variable it finds (i.e. :0.0 becomes :0) and checks whether this display
799     has been handled already. If it has not, it forks, changes uid/gid to
800     the user owning the process, reuses the process's environment and runs
801     autorandr with the parameters from argv.
802
803     This function requires root permissions. It only works for X11 servers that
804     have at least one non-root process running. It is susceptible for attacks
805     where one user runs a process with another user's DISPLAY variable - in
806     this case, it might happen that autorandr is invoked for the other user,
807     which won't work. Since no other harm than prevention of automated
808     execution of autorandr can be done this way, the assumption is that in this
809     situation, the local administrator will handle the situation."""
810     X11_displays_done = set()
811
812     autorandr_binary = os.path.abspath(argv[0])
813
814     for directory in os.listdir("/proc"):
815         directory = os.path.join("/proc/", directory)
816         if not os.path.isdir(directory):
817             continue
818         environ_file = os.path.join(directory, "environ")
819         if not os.path.isfile(environ_file):
820             continue
821         uid = os.stat(environ_file).st_uid
822
823         # The following line assumes that user accounts start at 1000 and that
824         # no one works using the root or another system account. This is rather
825         # restrictive, but de facto default. Alternatives would be to use the
826         # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
827         # but effectively, both values aren't binding in any way.
828         # If this breaks your use case, please file a bug on Github.
829         if uid < 1000:
830             continue
831
832         process_environ = {}
833         for environ_entry in open(environ_file).read().split("\0"):
834             if "=" in environ_entry:
835                 name, value = environ_entry.split("=", 1)
836                 if name == "DISPLAY" and "." in value:
837                     value = value[:value.find(".")]
838                 process_environ[name] = value
839         display = process_environ["DISPLAY"] if "DISPLAY" in process_environ else None
840
841         if display and display not in X11_displays_done:
842             try:
843                 pwent = pwd.getpwuid(uid)
844             except KeyError:
845                 # User has no pwd entry
846                 continue
847
848             print("Running autorandr as %s for display %s" % (pwent.pw_name, display))
849             child_pid = os.fork()
850             if child_pid == 0:
851                 # This will throw an exception if any of the privilege changes fails,
852                 # so it should be safe. Also, note that since the environment
853                 # is taken from a process owned by the user, reusing it should
854                 # not leak any information.
855                 os.setgroups([])
856                 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
857                 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
858                 os.chdir(pwent.pw_dir)
859                 os.environ.clear()
860                 os.environ.update(process_environ)
861                 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
862                 os.exit(1)
863             os.waitpid(child_pid, 0)
864
865             X11_displays_done.add(display)
866
867 def main(argv):
868     try:
869         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])
870     except getopt.GetoptError as e:
871         print("Failed to parse options: {0}.\n"
872               "Use --help to get usage information.".format(str(e)),
873               file=sys.stderr)
874         sys.exit(posix.EX_USAGE)
875
876     # Batch mode
877     if "--batch" in options:
878         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
879             dispatch_call_to_sessions([ x for x in argv if x != "--batch" ])
880         else:
881             print("--batch mode can only be used by root and if $DISPLAY is unset")
882         return
883
884     profiles = {}
885     profile_symlinks = {}
886     try:
887         # Load profiles from each XDG config directory
888         # The XDG spec says that earlier entries should take precedence, so reverse the order
889         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
890             system_profile_path = os.path.join(directory, "autorandr")
891             if os.path.isdir(system_profile_path):
892                 profiles.update(load_profiles(system_profile_path))
893                 profile_symlinks.update(get_symlinks(system_profile_path))
894         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
895         # profile_path is also used later on to store configurations
896         profile_path = os.path.expanduser("~/.autorandr")
897         if not os.path.isdir(profile_path):
898             # Elsewise, follow the XDG specification
899             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
900         if os.path.isdir(profile_path):
901             profiles.update(load_profiles(profile_path))
902             profile_symlinks.update(get_symlinks(profile_path))
903         # Sort by descending mtime
904         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
905     except Exception as e:
906         raise AutorandrException("Failed to load profiles", e)
907
908     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 }
909
910     config, modes = parse_xrandr_output()
911
912     if "--fingerprint" in options:
913         output_setup(config, sys.stdout)
914         sys.exit(0)
915
916     if "--config" in options:
917         output_configuration(config, sys.stdout)
918         sys.exit(0)
919
920     if "--skip-options" in options:
921         skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
922         for profile in profiles.values():
923             for output in profile["config"].values():
924                 output.set_ignored_options(skip_options)
925         for output in config.values():
926             output.set_ignored_options(skip_options)
927
928     if "-s" in options:
929         options["--save"] = options["-s"]
930     if "--save" in options:
931         if options["--save"] in ( x[0] for x in virtual_profiles ):
932             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
933         try:
934             profile_folder = os.path.join(profile_path, options["--save"])
935             save_configuration(profile_folder, config)
936             exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
937         except Exception as e:
938             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
939         print("Saved current configuration as profile '%s'" % options["--save"])
940         sys.exit(0)
941
942     if "-r" in options:
943         options["--remove"] = options["-r"]
944     if "--remove" in options:
945         if options["--remove"] in ( x[0] for x in virtual_profiles ):
946             raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
947         if options["--remove"] not in profiles.keys():
948             raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
949         try:
950             remove = True
951             profile_folder = os.path.join(profile_path, options["--remove"])
952             profile_dirlist = os.listdir(profile_folder)
953             profile_dirlist.remove("config")
954             profile_dirlist.remove("setup")
955             if profile_dirlist:
956                 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
957                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
958                 if response != "yes":
959                     remove = False
960             if remove is True:
961                 shutil.rmtree(profile_folder)
962                 print("Removed profile '%s'" % options["--remove"])
963             else:
964                 print("Profile '%s' was not removed" % options["--remove"])
965         except Exception as e:
966             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
967         sys.exit(0)
968
969     if "-h" in options or "--help" in options:
970         exit_help()
971
972     detected_profiles = find_profiles(config, profiles)
973     load_profile = False
974
975     if "-l" in options:
976         options["--load"] = options["-l"]
977     if "--load" in options:
978         load_profile = options["--load"]
979     else:
980         # Find the active profile(s) first, for the block script (See #42)
981         current_profiles = []
982         for profile_name in profiles.keys():
983             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
984             if configs_are_equal:
985                 current_profiles.append(profile_name)
986         block_script_metadata = {
987             "CURRENT_PROFILE":  "".join(current_profiles[:1]),
988             "CURRENT_PROFILES": ":".join(current_profiles)
989         }
990
991         for profile_name in profiles.keys():
992             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
993                 print("%s (blocked)" % profile_name, file=sys.stderr)
994                 continue
995             props = []
996             if profile_name in detected_profiles:
997                 props.append("(detected)")
998                 if ("-c" in options or "--change" in options) and not load_profile:
999                     load_profile = profile_name
1000             if profile_name in current_profiles:
1001                 props.append("(current)")
1002             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
1003             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1004                 print_profile_differences(config, profiles[profile_name]["config"])
1005
1006     if "-d" in options:
1007         options["--default"] = options["-d"]
1008     if not load_profile and "--default" in options:
1009         load_profile = options["--default"]
1010
1011     if load_profile:
1012         if load_profile in profile_symlinks:
1013             if "--debug" in options:
1014                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1015             load_profile = profile_symlinks[load_profile]
1016
1017         if load_profile in ( x[0] for x in virtual_profiles ):
1018             load_config = generate_virtual_profile(config, modes, load_profile)
1019             scripts_path = os.path.join(profile_path, load_profile)
1020         else:
1021             try:
1022                 profile = profiles[load_profile]
1023                 load_config = profile["config"]
1024                 scripts_path = profile["path"]
1025             except KeyError:
1026                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1027             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1028                 update_mtime(os.path.join(scripts_path, "config"))
1029         add_unused_outputs(config, load_config)
1030         if load_config == dict(config) and not "-f" in options and not "--force" in options:
1031             print("Config already loaded", file=sys.stderr)
1032             sys.exit(0)
1033         if "--debug" in options and load_config != dict(config):
1034             print("Loading profile '%s'" % load_profile)
1035             print_profile_differences(config, load_config)
1036
1037         remove_irrelevant_outputs(config, load_config)
1038
1039         try:
1040             if "--dry-run" in options:
1041                 apply_configuration(load_config, config, True)
1042             else:
1043                 script_metadata = {
1044                     "CURRENT_PROFILE": load_profile,
1045                     "PROFILE_FOLDER": scripts_path,
1046                 }
1047                 exec_scripts(scripts_path, "preswitch", script_metadata)
1048                 if "--debug" in options:
1049                     print("Going to run:")
1050                     apply_configuration(load_config, config, True)
1051                 apply_configuration(load_config, config, False)
1052                 exec_scripts(scripts_path, "postswitch", script_metadata)
1053         except AutorandrException as e:
1054             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1055         except Exception as e:
1056             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1057
1058         if "--dry-run" not in options and "--debug" in options:
1059             new_config, _ = parse_xrandr_output()
1060             if not is_equal_configuration(new_config, load_config):
1061                 print("The configuration change did not go as expected:")
1062                 print_profile_differences(new_config, load_config)
1063
1064     sys.exit(0)
1065
1066 def exception_handled_main(argv=sys.argv):
1067     try:
1068         main(sys.argv)
1069     except AutorandrException as e:
1070         print(e, file=sys.stderr)
1071         sys.exit(1)
1072     except Exception as e:
1073         if not len(str(e)):  # BdbQuit
1074             print("Exception: {0}".format(e.__class__.__name__))
1075             sys.exit(2)
1076
1077         print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)
1078         raise
1079
1080 if __name__ == '__main__':
1081     exception_handled_main()