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