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