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