]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Introduce --batch option to autorandr: Run autorandr for each user with an X11 session
[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 call "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 find_profiles(current_config, profiles):
480     "Find profiles matching the currently connected outputs"
481     detected_profiles = []
482     for profile_name, profile in profiles.items():
483         config = profile["config"]
484         matches = True
485         for name, output in config.items():
486             if not output.edid:
487                 continue
488             if name not in current_config or not output.edid_equals(current_config[name]):
489                 matches = False
490                 break
491         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
492             continue
493         if matches:
494             detected_profiles.append(profile_name)
495     return detected_profiles
496
497 def profile_blocked(profile_path, meta_information=None):
498     """Check if a profile is blocked.
499
500     meta_information is expected to be an dictionary. It will be passed to the block scripts
501     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
502     """
503     return not exec_scripts(profile_path, "block", meta_information)
504
505 def output_configuration(configuration, config):
506     "Write a configuration file"
507     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
508     for output in outputs:
509         print(configuration[output].option_string, file=config)
510
511 def output_setup(configuration, setup):
512     "Write a setup (fingerprint) file"
513     outputs = sorted(configuration.keys())
514     for output in outputs:
515         if configuration[output].edid:
516             print(output, configuration[output].edid, file=setup)
517
518 def save_configuration(profile_path, configuration):
519     "Save a configuration into a profile"
520     if not os.path.isdir(profile_path):
521         os.makedirs(profile_path)
522     with open(os.path.join(profile_path, "config"), "w") as config:
523         output_configuration(configuration, config)
524     with open(os.path.join(profile_path, "setup"), "w") as setup:
525         output_setup(configuration, setup)
526
527 def update_mtime(filename):
528     "Update a file's mtime"
529     try:
530         os.utime(filename, None)
531         return True
532     except:
533         return False
534
535 def call_and_retry(*args, **kwargs):
536     """Wrapper around subprocess.call that retries failed calls.
537
538     This function calls subprocess.call and on non-zero exit states,
539     waits a second and then retries once. This mitigates #47,
540     a timing issue with some drivers.
541     """
542     kwargs_redirected = dict(kwargs)
543     if hasattr(subprocess, "DEVNULL"):
544         kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
545     else:
546         kwargs_redirected["stdout"] = open(os.devnull, "w")
547     kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
548     retval = subprocess.call(*args, **kwargs_redirected)
549     if retval != 0:
550         time.sleep(1)
551         retval = subprocess.call(*args, **kwargs)
552     return retval
553
554 def apply_configuration(new_configuration, current_configuration, dry_run=False):
555     "Apply a configuration"
556     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
557     if dry_run:
558         base_argv = [ "echo", "xrandr" ]
559     else:
560         base_argv = [ "xrandr" ]
561
562     # There are several xrandr / driver bugs we need to take care of here:
563     # - We cannot enable more than two screens at the same time
564     #   See https://github.com/phillipberndt/autorandr/pull/6
565     #   and commits f4cce4d and 8429886.
566     # - We cannot disable all screens
567     #   See https://github.com/phillipberndt/autorandr/pull/20
568     # - We should disable screens before enabling others, because there's
569     #   a limit on the number of enabled screens
570     # - We must make sure that the screen at 0x0 is activated first,
571     #   or the other (first) screen to be activated would be moved there.
572     # - If an active screen already has a transformation and remains active,
573     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
574     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
575     #   at least.)
576     # - Some implementations can not handle --transform at all, so avoid it unless
577     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
578
579     auxiliary_changes_pre = []
580     disable_outputs = []
581     enable_outputs = []
582     remain_active_count = 0
583     for output in outputs:
584         if not new_configuration[output].edid or "off" in new_configuration[output].options:
585             disable_outputs.append(new_configuration[output].option_vector)
586         else:
587             if "off" not in current_configuration[output].options:
588                 remain_active_count += 1
589
590             option_vector = new_configuration[output].option_vector
591             if xrandr_version() >= Version("1.3.0"):
592                 if "transform" in current_configuration[output].options:
593                     auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
594                 else:
595                     try:
596                         transform_index = option_vector.index("--transform")
597                         if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
598                             option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
599                     except ValueError:
600                         pass
601
602             enable_outputs.append(option_vector)
603
604     # Perform pe-change auxiliary changes
605     if auxiliary_changes_pre:
606         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
607         if call_and_retry(argv) != 0:
608             raise AutorandrException("Command failed: %s" % " ".join(argv))
609
610     # Disable unused outputs, but make sure that there always is at least one active screen
611     disable_keep = 0 if remain_active_count else 1
612     if len(disable_outputs) > disable_keep:
613         if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
614             # Disabling the outputs failed. Retry with the next command:
615             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
616             # This does not occur if simultaneously the primary screen is reset.
617             pass
618         else:
619             disable_outputs = disable_outputs[-1:] if disable_keep else []
620
621     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
622     # disable the last two screens. This is a problem, so if this would happen, instead disable only
623     # one screen in the first call below.
624     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
625         # In the context of a xrandr call that changes the display state, `--query' should do nothing
626         disable_outputs.insert(0, ['--query'])
627
628     # Enable the remaining outputs in pairs of two operations
629     operations = disable_outputs + enable_outputs
630     for index in range(0, len(operations), 2):
631         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
632         if call_and_retry(argv) != 0:
633             raise AutorandrException("Command failed: %s" % " ".join(argv))
634
635 def is_equal_configuration(source_configuration, target_configuration):
636     "Check if all outputs from target are already configured correctly in source"
637     for output in target_configuration.keys():
638         if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
639             return False
640     return True
641
642 def add_unused_outputs(source_configuration, target_configuration):
643     "Add outputs that are missing in target to target, in 'off' state"
644     for output_name, output in source_configuration.items():
645         if output_name not in target_configuration:
646             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
647
648 def remove_irrelevant_outputs(source_configuration, target_configuration):
649     "Remove outputs from target that ought to be 'off' and already are"
650     for output_name, output in source_configuration.items():
651         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
652             del target_configuration[output_name]
653
654 def generate_virtual_profile(configuration, modes, profile_name):
655     "Generate one of the virtual profiles"
656     configuration = copy.deepcopy(configuration)
657     if profile_name == "common":
658         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
659         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
660         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
661         if common_resolution:
662             for output in configuration:
663                 configuration[output].options = {}
664                 if output in modes and configuration[output].edid:
665                     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]
666                     configuration[output].options["pos"] = "0x0"
667                 else:
668                     configuration[output].options["off"] = None
669     elif profile_name in ("horizontal", "vertical"):
670         shift = 0
671         if profile_name == "horizontal":
672             shift_index = "width"
673             pos_specifier = "%sx0"
674         else:
675             shift_index = "height"
676             pos_specifier = "0x%s"
677
678         for output in configuration:
679             configuration[output].options = {}
680             if output in modes and configuration[output].edid:
681                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
682                 configuration[output].options["mode"] = mode["name"]
683                 configuration[output].options["rate"] = mode["rate"]
684                 configuration[output].options["pos"] = pos_specifier % shift
685                 shift += int(mode[shift_index])
686             else:
687                 configuration[output].options["off"] = None
688     return configuration
689
690 def print_profile_differences(one, another):
691     "Print the differences between two profiles for debugging"
692     if one == another:
693         return
694     print("| Differences between the two profiles:", file=sys.stderr)
695     for output in set(chain.from_iterable((one.keys(), another.keys()))):
696         if output not in one:
697             if "off" not in another[output].options:
698                 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
699         elif output not in another:
700             if "off" not in one[output].options:
701                 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
702         else:
703             for line in one[output].verbose_diff(another[output]):
704                 print("| [Output %s] %s" % (output, line), file=sys.stderr)
705     print ("\\-", file=sys.stderr)
706
707 def exit_help():
708     "Print help and exit"
709     print(help_text)
710     for profile in virtual_profiles:
711         print("  %-10s %s" % profile[:2])
712     sys.exit(0)
713
714 def exec_scripts(profile_path, script_name, meta_information=None):
715     """"Run userscripts
716
717     This will run all executables from the profile folder, and global per-user
718     and system-wide configuration folders, named script_name or residing in
719     subdirectories named script_name.d.
720
721     meta_information is expected to be an dictionary. It will be passed to the block scripts
722     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
723
724     Returns True unless any of the scripts exited with non-zero exit status.
725     """
726     all_ok = True
727     if meta_information:
728         env = os.environ.copy()
729         env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
730     else:
731         env = os.environ.copy()
732
733     # If there are multiple candidates, the XDG spec tells to only use the first one.
734     ran_scripts = set()
735
736     user_profile_path = os.path.expanduser("~/.autorandr")
737     if not os.path.isdir(user_profile_path):
738         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
739
740     for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
741                         (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"))):
742
743         if script_name not in ran_scripts:
744             script = os.path.join(folder, script_name)
745             if os.access(script, os.X_OK | os.F_OK):
746                 all_ok &= subprocess.call(script, env=env) != 0
747                 ran_scripts.add(script_name)
748
749         script_folder = os.path.join(folder, "%s.d" % script_name)
750         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
751             for file_name in os.listdir(script_folder):
752                 check_name = "d/%s" % (file_name,)
753                 if check_name not in ran_scripts:
754                     script = os.path.join(script_folder, file_name)
755                     if os.access(script, os.X_OK | os.F_OK):
756                         all_ok &= subprocess.call(script, env=env) != 0
757                         ran_scripts.add(check_name)
758
759     return all_ok
760
761 def dispatch_call_to_sessions(argv):
762     """Invoke autorandr for each open local X11 session with the given options.
763
764     The function iterates over all processes not owned by root and checks
765     whether they have a DISPLAY variable set. It strips the screen from any
766     variable it finds (i.e. :0.0 becomes :0) and checks whether this display
767     has been handled already. If it has not, it forks, changes uid/gid to
768     the user owning the process, reuses the process's environment and runs
769     autorandr with the parameters from argv.
770
771     This function requires root permissions. It only works for X11 servers that
772     have at least one non-root process running. It is susceptible for attacks
773     where one user runs a process with another user's DISPLAY variable - in
774     this case, it might happen that autorandr is invoked for the other user,
775     which won't work. Since no other harm than prevention of automated
776     execution of autorandr can be done this way, the assumption is that in this
777     situation, the local administrator will handle the situation."""
778     X11_displays_done = set()
779
780     autorandr_binary = os.path.abspath(argv[0])
781
782     for directory in os.listdir("/proc"):
783         directory = os.path.join("/proc/", directory)
784         if not os.path.isdir(directory):
785             continue
786         environ_file = os.path.join(directory, "environ")
787         if not os.path.isfile(environ_file):
788             continue
789         uid = os.stat(environ_file).st_uid
790         if uid == 0:
791             continue
792
793         process_environ = {}
794         for environ_entry in open(environ_file).read().split("\0"):
795             if "=" in environ_entry:
796                 name, value = environ_entry.split("=", 1)
797                 if name == "DISPLAY" and "." in value:
798                     value = value[:value.find(".")]
799                 process_environ[name] = value
800         display = process_environ["DISPLAY"] if "DISPLAY" in process_environ else None
801
802         if display and display not in X11_displays_done:
803             try:
804                 pwent = pwd.getpwuid(uid)
805             except KeyError:
806                 # User has no pwd entry
807                 continue
808
809             print("Running autorandr as %s for display %s" % (pwent.pw_name, display))
810             child_pid = os.fork()
811             if child_pid == 0:
812                 # This will throw an exception if any of the privilege changes fails,
813                 # so it should be safe. Also, note that since the environment
814                 # is taken from a process owned by the user, reusing it should
815                 # not leak any information.
816                 os.setgroups([])
817                 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
818                 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
819                 os.chdir(pwent.pw_dir)
820                 os.environ.clear()
821                 os.environ.update(process_environ)
822                 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
823                 os.exit(1)
824             os.waitpid(child_pid, 0)
825
826             X11_displays_done.add(display)
827
828 def main(argv):
829     try:
830         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])
831     except getopt.GetoptError as e:
832         print("Failed to parse options: {0}.\n"
833               "Use --help to get usage information.".format(str(e)),
834               file=sys.stderr)
835         sys.exit(posix.EX_USAGE)
836
837     # Batch mode
838     if "--batch" in options:
839         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
840             dispatch_call_to_sessions([ x for x in argv if x != "--batch" ])
841         else:
842             print("--batch mode can only be used by root and if $DISPLAY is unset")
843         return
844
845     profiles = {}
846     try:
847         # Load profiles from each XDG config directory
848         # The XDG spec says that earlier entries should take precedence, so reverse the order
849         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
850             system_profile_path = os.path.join(directory, "autorandr")
851             if os.path.isdir(system_profile_path):
852                 profiles.update(load_profiles(system_profile_path))
853         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
854         # profile_path is also used later on to store configurations
855         profile_path = os.path.expanduser("~/.autorandr")
856         if not os.path.isdir(profile_path):
857             # Elsewise, follow the XDG specification
858             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
859         if os.path.isdir(profile_path):
860             profiles.update(load_profiles(profile_path))
861         # Sort by descending mtime
862         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
863     except Exception as e:
864         raise AutorandrException("Failed to load profiles", e)
865
866     config, modes = parse_xrandr_output()
867
868     if "--fingerprint" in options:
869         output_setup(config, sys.stdout)
870         sys.exit(0)
871
872     if "--config" in options:
873         output_configuration(config, sys.stdout)
874         sys.exit(0)
875
876     if "--skip-options" in options:
877         skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
878         for profile in profiles.values():
879             for output in profile["config"].values():
880                 output.set_ignored_options(skip_options)
881         for output in config.values():
882             output.set_ignored_options(skip_options)
883
884     if "-s" in options:
885         options["--save"] = options["-s"]
886     if "--save" in options:
887         if options["--save"] in ( x[0] for x in virtual_profiles ):
888             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
889         try:
890             profile_folder = os.path.join(profile_path, options["--save"])
891             save_configuration(profile_folder, config)
892             exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
893         except Exception as e:
894             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
895         print("Saved current configuration as profile '%s'" % options["--save"])
896         sys.exit(0)
897
898     if "-r" in options:
899         options["--remove"] = options["-r"]
900     if "--remove" in options:
901         if options["--remove"] in ( x[0] for x in virtual_profiles ):
902             raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
903         if options["--remove"] not in profiles.keys():
904             raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
905         try:
906             remove = True
907             profile_folder = os.path.join(profile_path, options["--remove"])
908             profile_dirlist = os.listdir(profile_folder)
909             profile_dirlist.remove("config")
910             profile_dirlist.remove("setup")
911             if profile_dirlist:
912                 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
913                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
914                 if response != "yes":
915                     remove = False
916             if remove is True:
917                 shutil.rmtree(profile_folder)
918                 print("Removed profile '%s'" % options["--remove"])
919             else:
920                 print("Profile '%s' was not removed" % options["--remove"])
921         except Exception as e:
922             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
923         sys.exit(0)
924
925     if "-h" in options or "--help" in options:
926         exit_help()
927
928     detected_profiles = find_profiles(config, profiles)
929     load_profile = False
930
931     if "-l" in options:
932         options["--load"] = options["-l"]
933     if "--load" in options:
934         load_profile = options["--load"]
935     else:
936         # Find the active profile(s) first, for the block script (See #42)
937         current_profiles = []
938         for profile_name in profiles.keys():
939             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
940             if configs_are_equal:
941                 current_profiles.append(profile_name)
942         block_script_metadata = {
943             "CURRENT_PROFILE":  "".join(current_profiles[:1]),
944             "CURRENT_PROFILES": ":".join(current_profiles)
945         }
946
947         for profile_name in profiles.keys():
948             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
949                 print("%s (blocked)" % profile_name, file=sys.stderr)
950                 continue
951             props = []
952             if profile_name in detected_profiles:
953                 props.append("(detected)")
954                 if ("-c" in options or "--change" in options) and not load_profile:
955                     load_profile = profile_name
956             if profile_name in current_profiles:
957                 props.append("(current)")
958             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
959             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
960                 print_profile_differences(config, profiles[profile_name]["config"])
961
962     if "-d" in options:
963         options["--default"] = options["-d"]
964     if not load_profile and "--default" in options:
965         load_profile = options["--default"]
966
967     if load_profile:
968         if load_profile in ( x[0] for x in virtual_profiles ):
969             load_config = generate_virtual_profile(config, modes, load_profile)
970             scripts_path = os.path.join(profile_path, load_profile)
971         else:
972             try:
973                 profile = profiles[load_profile]
974                 load_config = profile["config"]
975                 scripts_path = profile["path"]
976             except KeyError:
977                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
978             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
979                 update_mtime(os.path.join(scripts_path, "config"))
980         add_unused_outputs(config, load_config)
981         if load_config == dict(config) and not "-f" in options and not "--force" in options:
982             print("Config already loaded", file=sys.stderr)
983             sys.exit(0)
984         if "--debug" in options and load_config != dict(config):
985             print("Loading profile '%s'" % load_profile)
986             print_profile_differences(config, load_config)
987
988         remove_irrelevant_outputs(config, load_config)
989
990         try:
991             if "--dry-run" in options:
992                 apply_configuration(load_config, config, True)
993             else:
994                 script_metadata = {
995                     "CURRENT_PROFILE": load_profile,
996                     "PROFILE_FOLDER": scripts_path,
997                 }
998                 exec_scripts(scripts_path, "preswitch", script_metadata)
999                 if "--debug" in options:
1000                     print("Going to run:")
1001                     apply_configuration(load_config, config, True)
1002                 apply_configuration(load_config, config, False)
1003                 exec_scripts(scripts_path, "postswitch", script_metadata)
1004         except Exception as e:
1005             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1006
1007         if "--dry-run" not in options and "--debug" in options:
1008             new_config, _ = parse_xrandr_output()
1009             if not is_equal_configuration(new_config, load_config):
1010                 print("The configuration change did not go as expected:")
1011                 print_profile_differences(new_config, load_config)
1012
1013     sys.exit(0)
1014
1015 if __name__ == '__main__':
1016     try:
1017         main(sys.argv)
1018     except AutorandrException as e:
1019         print(e, file=sys.stderr)
1020         sys.exit(1)
1021     except Exception as e:
1022         if not len(str(e)):  # BdbQuit
1023             print("Exception: {0}".format(e.__class__.__name__))
1024             sys.exit(2)
1025
1026         print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)
1027         raise