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