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