5 # Copyright (c) 2015, Phillip Berndt
7 # Autorandr rewrite in Python
9 # This script aims to be fully compatible with the original autorandr.
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.
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.
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/>.
25 from __future__ import print_function
40 from collections import OrderedDict
41 from distutils.version import LooseVersion as Version
42 from functools import reduce
43 from itertools import chain
51 # (name, description, callback)
52 ("common", "Clone all connected outputs at the largest common resolution", None),
53 ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
54 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
55 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
59 Usage: autorandr [options]
61 -h, --help get this small help
62 -c, --change reload current setup
63 -s, --save <profile> save your current setup to profile <profile>
64 -r, --remove <profile> remove profile <profile>
65 -l, --load <profile> load profile <profile>
66 -d, --default <profile> make profile <profile> the default profile
67 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
68 to skip both in detecting changes and applying a profile
69 --force force (re)loading of a profile
70 --fingerprint fingerprint your current hardware setup
71 --config dump your current xrandr setup
72 --dry-run don't change anything, only print the xrandr commands
73 --debug enable verbose output
74 --batch run autorandr for all users with active X11 sessions
76 To prevent a profile from being loaded, place a script called "block" in its
77 directory. The script is evaluated before the screen setup is inspected, and
78 in case of it returning a value of 0 the profile is skipped. This can be used
79 to query the status of a docking station you are about to leave.
81 If no suitable profile can be identified, the current configuration is kept.
82 To change this behaviour and switch to a fallback configuration, specify
85 Another script called "postswitch" can be placed in the directory
86 ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
87 as in any profile directories: The scripts are executed after a mode switch
88 has taken place and can notify window managers.
90 The following virtual configurations are available:
94 class AutorandrException(Exception):
95 def __init__(self, message, original_exception=None, report_bug=False):
96 self.message = message
97 self.report_bug = report_bug
98 if original_exception:
99 self.original_exception = original_exception
100 trace = sys.exc_info()[2]
102 trace = trace.tb_next
103 self.line = trace.tb_lineno
104 self.file_name = trace.tb_frame.f_code.co_filename
108 frame = inspect.currentframe().f_back
109 self.line = frame.f_lineno
110 self.file_name = frame.f_code.co_filename
113 self.file_name = None
114 self.original_exception = None
116 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
117 self.file_name = None
120 retval = [self.message]
122 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
123 if self.original_exception:
124 retval.append(":\n ")
125 retval.append(str(self.original_exception).replace("\n", "\n "))
127 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
128 "\nhttps://github.com/phillipberndt/autorandr/issues"
129 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
130 return "".join(retval)
133 class XrandrOutput(object):
134 "Represents an XRandR output"
136 # This regular expression is used to parse an output in `xrandr --verbose'
137 XRANDR_OUTPUT_REGEXP = """(?x)
138 ^(?P<output>[^ ]+)\s+ # Line starts with output name
139 (?: # Differentiate disconnected and connected
140 disconnected | # in first line
141 unknown\ connection |
142 (?P<connected>connected)
145 (?P<primary>primary\ )? # Might be primary screen
147 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
148 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
149 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
150 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
151 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
152 )? # .. but only if the screen is in use.
153 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
154 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
155 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
156 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
157 (?:\s*(?: # Properties of the output
158 Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) | # Gamma value
159 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
160 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
161 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
165 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
166 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
167 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
168 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
172 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
173 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
174 h:\s+width\s+(?P<width>[0-9]+).+\s+
175 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
178 XRANDR_13_DEFAULTS = {
179 "transform": "1,0,0,0,1,0,0,0,1",
183 XRANDR_12_DEFAULTS = {
186 "gamma": "1.0:1.0:1.0",
189 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
191 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
194 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
197 def short_edid(self):
198 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
201 def options_with_defaults(self):
202 "Return the options dictionary, augmented with the default values that weren't set"
203 if "off" in self.options:
206 if xrandr_version() >= Version("1.3"):
207 options.update(self.XRANDR_13_DEFAULTS)
208 if xrandr_version() >= Version("1.2"):
209 options.update(self.XRANDR_12_DEFAULTS)
210 options.update(self.options)
211 return {a: b for a, b in options.items() if a not in self.ignored_options}
214 def filtered_options(self):
215 "Return a dictionary of options without ignored options"
216 return {a: b for a, b in self.options.items() if a not in self.ignored_options}
219 def option_vector(self):
220 "Return the command line parameters for XRandR for this instance"
221 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()))], [])
224 def option_string(self):
225 "Return the command line parameters in the configuration file format"
226 return "\n".join([" ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
230 "Return a key to sort the outputs for xrandr invocation"
233 if "off" in self.options:
235 if "pos" in self.options:
236 x, y = map(float, self.options["pos"].split("x"))
241 def __init__(self, output, edid, options):
242 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
245 self.options = options
246 self.ignored_options = []
247 self.remove_default_option_values()
249 def set_ignored_options(self, options):
250 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
251 self.ignored_options = list(options)
253 def remove_default_option_values(self):
254 "Remove values from the options dictionary that are superflous"
255 if "off" in self.options and len(self.options.keys()) > 1:
256 self.options = {"off": None}
258 for option, default_value in self.XRANDR_DEFAULTS.items():
259 if option in self.options and self.options[option] == default_value:
260 del self.options[option]
263 def from_xrandr_output(cls, xrandr_output):
264 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
266 This method also returns a list of modes supported by the output.
269 xrandr_output = xrandr_output.replace("\r\n", "\n")
270 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
272 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
275 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
276 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
278 remainder = xrandr_output[len(match_object.group(0)):]
280 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
281 "regular expression, starting at byte %d with ..'%s'." %
282 (len(remainder), len(match_object.group(0)), remainder[:10]),
285 match = match_object.groupdict()
289 modes = [x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name")]
291 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
294 if not match["connected"]:
297 edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
299 if not match["width"]:
300 options["off"] = None
302 if match["mode_name"]:
303 options["mode"] = match["mode_name"]
304 elif match["mode_width"]:
305 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
307 if match["rotate"] not in ("left", "right"):
308 options["mode"] = "%sx%s" % (match["width"], match["height"])
310 options["mode"] = "%sx%s" % (match["height"], match["width"])
311 options["rotate"] = match["rotate"]
313 options["primary"] = None
314 if match["reflect"] == "X":
315 options["reflect"] = "x"
316 elif match["reflect"] == "Y":
317 options["reflect"] = "y"
318 elif match["reflect"] == "X and Y":
319 options["reflect"] = "xy"
320 options["pos"] = "%sx%s" % (match["x"], match["y"])
322 panning = [match["panning"]]
323 if match["tracking"]:
324 panning += ["/", match["tracking"]]
326 panning += ["/", match["border"]]
327 options["panning"] = "".join(panning)
328 if match["transform"]:
329 transformation = ",".join(match["transform"].strip().split())
330 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
331 options["transform"] = transformation
332 if not match["mode_name"]:
333 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
334 # I doubt that this special case is actually required.
335 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
336 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
338 gamma = match["gamma"].strip()
339 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
340 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
341 # so we approximate by 1e-10.
342 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
343 options["gamma"] = gamma
345 options["rate"] = match["rate"]
347 return XrandrOutput(match["output"], edid, options), modes
350 def from_config_file(cls, edid_map, configuration):
351 "Instanciate an XrandrOutput from the contents of a configuration file"
353 for line in configuration.split("\n"):
355 line = line.split(None, 1)
356 if line and line[0].startswith("#"):
358 options[line[0]] = line[1] if len(line) > 1 else None
362 if options["output"] in edid_map:
363 edid = edid_map[options["output"]]
365 # This fuzzy matching is for legacy autorandr that used sysfs output names
366 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
367 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
368 if fuzzy_output in fuzzy_edid_map:
369 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
370 elif "off" not in options:
371 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
372 "is not off in config file." % (options["output"], options["output"]))
373 output = options["output"]
374 del options["output"]
376 return XrandrOutput(output, edid, options)
378 def edid_equals(self, other):
379 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
380 if self.edid and other.edid:
381 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
382 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
383 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
384 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
385 return self.edid == other.edid
387 def __ne__(self, other):
388 return not (self == other)
390 def __eq__(self, other):
391 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
393 def verbose_diff(self, other):
394 "Compare to another XrandrOutput and return a list of human readable differences"
396 if not self.edid_equals(other):
397 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
398 if self.output != other.output:
399 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
400 if "off" in self.options and "off" not in other.options:
401 diffs.append("The output is disabled currently, but active in the new configuration")
402 elif "off" in other.options and "off" not in self.options:
403 diffs.append("The output is currently enabled, but inactive in the new configuration")
405 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
406 if name not in other.options:
407 diffs.append("Option --%s %sis not present in the new configuration" %
408 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
409 elif name not in self.options:
410 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
411 (name, other.options[name]))
412 elif self.options[name] != other.options[name]:
413 diffs.append("Option --%s %sis `%s' in the new configuration" %
414 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
418 def xrandr_version():
419 "Return the version of XRandR that this system uses"
420 if getattr(xrandr_version, "version", False) is False:
421 version_string = os.popen("xrandr -v").read()
423 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
424 xrandr_version.version = Version(version)
425 except AttributeError:
426 xrandr_version.version = Version("1.3.0")
428 return xrandr_version.version
431 def debug_regexp(pattern, string):
432 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
435 bounds = (0, len(string))
436 while bounds[0] != bounds[1]:
437 half = int((bounds[0] + bounds[1]) / 2)
438 if half == bounds[0]:
440 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
441 partial_length = bounds[0]
442 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
443 (partial_length, string[max(0, partial_length - 20):partial_length],
444 string[partial_length:partial_length + 10]))
447 return "Debug information would be available if the `regex' module was installed."
450 def parse_xrandr_output():
451 "Parse the output of `xrandr --verbose' into a list of outputs"
452 xrandr_output = os.popen("xrandr -q --verbose").read()
453 if not xrandr_output:
454 raise AutorandrException("Failed to run xrandr")
456 # We are not interested in screens
457 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
459 # Split at output boundaries and instanciate an XrandrOutput per output
460 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
461 if len(split_xrandr_output) < 2:
462 raise AutorandrException("No output boundaries found", report_bug=True)
463 outputs = OrderedDict()
464 modes = OrderedDict()
465 for i in range(1, len(split_xrandr_output), 2):
466 output_name = split_xrandr_output[i].split()[0]
467 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
468 outputs[output_name] = output
470 modes[output_name] = output_modes
472 return outputs, modes
475 def load_profiles(profile_path):
476 "Load the stored profiles"
479 for profile in os.listdir(profile_path):
480 config_name = os.path.join(profile_path, profile, "config")
481 setup_name = os.path.join(profile_path, profile, "setup")
482 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
485 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
489 for line in chain(open(config_name).readlines(), ["output"]):
490 if line[:6] == "output" and buffer:
491 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
496 for output_name in list(config.keys()):
497 if config[output_name].edid is None:
498 del config[output_name]
500 profiles[profile] = {"config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime}
505 def get_symlinks(profile_path):
506 "Load all symlinks from a directory"
509 for link in os.listdir(profile_path):
510 file_name = os.path.join(profile_path, link)
511 if os.path.islink(file_name):
512 symlinks[link] = os.readlink(file_name)
517 def find_profiles(current_config, profiles):
518 "Find profiles matching the currently connected outputs"
519 detected_profiles = []
520 for profile_name, profile in profiles.items():
521 config = profile["config"]
523 for name, output in config.items():
526 if name not in current_config or not output.edid_equals(current_config[name]):
529 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
532 detected_profiles.append(profile_name)
533 return detected_profiles
536 def profile_blocked(profile_path, meta_information=None):
537 """Check if a profile is blocked.
539 meta_information is expected to be an dictionary. It will be passed to the block scripts
540 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
542 return not exec_scripts(profile_path, "block", meta_information)
545 def output_configuration(configuration, config):
546 "Write a configuration file"
547 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
548 for output in outputs:
549 print(configuration[output].option_string, file=config)
552 def output_setup(configuration, setup):
553 "Write a setup (fingerprint) file"
554 outputs = sorted(configuration.keys())
555 for output in outputs:
556 if configuration[output].edid:
557 print(output, configuration[output].edid, file=setup)
560 def save_configuration(profile_path, configuration):
561 "Save a configuration into a profile"
562 if not os.path.isdir(profile_path):
563 os.makedirs(profile_path)
564 with open(os.path.join(profile_path, "config"), "w") as config:
565 output_configuration(configuration, config)
566 with open(os.path.join(profile_path, "setup"), "w") as setup:
567 output_setup(configuration, setup)
570 def update_mtime(filename):
571 "Update a file's mtime"
573 os.utime(filename, None)
579 def call_and_retry(*args, **kwargs):
580 """Wrapper around subprocess.call that retries failed calls.
582 This function calls subprocess.call and on non-zero exit states,
583 waits a second and then retries once. This mitigates #47,
584 a timing issue with some drivers.
586 if "dry_run" in kwargs:
587 dry_run = kwargs["dry_run"]
588 del kwargs["dry_run"]
591 kwargs_redirected = dict(kwargs)
593 if hasattr(subprocess, "DEVNULL"):
594 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
596 kwargs_redirected["stdout"] = open(os.devnull, "w")
597 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
598 retval = subprocess.call(*args, **kwargs_redirected)
601 retval = subprocess.call(*args, **kwargs)
605 def apply_configuration(new_configuration, current_configuration, dry_run=False):
606 "Apply a configuration"
607 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
609 base_argv = ["echo", "xrandr"]
611 base_argv = ["xrandr"]
613 # There are several xrandr / driver bugs we need to take care of here:
614 # - We cannot enable more than two screens at the same time
615 # See https://github.com/phillipberndt/autorandr/pull/6
616 # and commits f4cce4d and 8429886.
617 # - We cannot disable all screens
618 # See https://github.com/phillipberndt/autorandr/pull/20
619 # - We should disable screens before enabling others, because there's
620 # a limit on the number of enabled screens
621 # - We must make sure that the screen at 0x0 is activated first,
622 # or the other (first) screen to be activated would be moved there.
623 # - If an active screen already has a transformation and remains active,
624 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
625 # Update the configuration in 3 passes in that case. (On Haswell graphics,
627 # - Some implementations can not handle --transform at all, so avoid it unless
628 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
629 # - Some implementations can not handle --panning without specifying --fb
630 # explicitly, so avoid it unless necessary.
631 # (See https://github.com/phillipberndt/autorandr/issues/72)
633 auxiliary_changes_pre = []
636 remain_active_count = 0
637 for output in outputs:
638 if not new_configuration[output].edid or "off" in new_configuration[output].options:
639 disable_outputs.append(new_configuration[output].option_vector)
641 if "off" not in current_configuration[output].options:
642 remain_active_count += 1
644 option_vector = new_configuration[output].option_vector
645 if xrandr_version() >= Version("1.3.0"):
646 for option in ("transform", "panning"):
647 if option in current_configuration[output].options:
648 auxiliary_changes_pre.append(["--output", output, "--%s" % option, "none"])
651 option_index = option_vector.index("--%s" % option)
652 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
653 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
657 enable_outputs.append(option_vector)
659 # Perform pe-change auxiliary changes
660 if auxiliary_changes_pre:
661 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
662 if call_and_retry(argv, dry_run=dry_run) != 0:
663 raise AutorandrException("Command failed: %s" % " ".join(argv))
665 # Disable unused outputs, but make sure that there always is at least one active screen
666 disable_keep = 0 if remain_active_count else 1
667 if len(disable_outputs) > disable_keep:
668 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
669 if call_and_retry(argv, dry_run=dry_run) != 0:
670 # Disabling the outputs failed. Retry with the next command:
671 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
672 # This does not occur if simultaneously the primary screen is reset.
675 disable_outputs = disable_outputs[-1:] if disable_keep else []
677 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
678 # disable the last two screens. This is a problem, so if this would happen, instead disable only
679 # one screen in the first call below.
680 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
681 # In the context of a xrandr call that changes the display state, `--query' should do nothing
682 disable_outputs.insert(0, ['--query'])
684 # Enable the remaining outputs in pairs of two operations
685 operations = disable_outputs + enable_outputs
686 for index in range(0, len(operations), 2):
687 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
688 if call_and_retry(argv, dry_run=dry_run) != 0:
689 raise AutorandrException("Command failed: %s" % " ".join(argv))
692 def is_equal_configuration(source_configuration, target_configuration):
693 "Check if all outputs from target are already configured correctly in source"
694 for output in target_configuration.keys():
695 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
700 def add_unused_outputs(source_configuration, target_configuration):
701 "Add outputs that are missing in target to target, in 'off' state"
702 for output_name, output in source_configuration.items():
703 if output_name not in target_configuration:
704 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
707 def remove_irrelevant_outputs(source_configuration, target_configuration):
708 "Remove outputs from target that ought to be 'off' and already are"
709 for output_name, output in source_configuration.items():
710 if "off" in output.options:
711 if output_name in target_configuration:
712 if "off" in target_configuration[output_name].options:
713 del target_configuration[output_name]
716 def generate_virtual_profile(configuration, modes, profile_name):
717 "Generate one of the virtual profiles"
718 configuration = copy.deepcopy(configuration)
719 if profile_name == "common":
720 common_resolution = [set(((mode["width"], mode["height"]) for mode in output_modes)) for output, output_modes in modes.items() if configuration[output].edid]
721 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
722 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
723 if common_resolution:
724 for output in configuration:
725 configuration[output].options = {}
726 if output in modes and configuration[output].edid:
727 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]
728 configuration[output].options["pos"] = "0x0"
730 configuration[output].options["off"] = None
731 elif profile_name in ("horizontal", "vertical"):
733 if profile_name == "horizontal":
734 shift_index = "width"
735 pos_specifier = "%sx0"
737 shift_index = "height"
738 pos_specifier = "0x%s"
740 for output in configuration:
741 configuration[output].options = {}
742 if output in modes and configuration[output].edid:
743 mode = sorted(modes[output], key=lambda a: int(a["width"]) * int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
744 configuration[output].options["mode"] = mode["name"]
745 configuration[output].options["rate"] = mode["rate"]
746 configuration[output].options["pos"] = pos_specifier % shift
747 shift += int(mode[shift_index])
749 configuration[output].options["off"] = None
750 elif profile_name == "clone-largest":
751 biggest_resolution = sorted([output_modes[0] for output, output_modes in modes.items()], key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)[0]
752 for output in configuration:
753 configuration[output].options = {}
754 if output in modes and configuration[output].edid:
755 mode = sorted(modes[output], key=lambda a: int(a["width"]) * int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
756 configuration[output].options["mode"] = mode["name"]
757 configuration[output].options["rate"] = mode["rate"]
758 configuration[output].options["pos"] = "0x0"
759 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
760 float(biggest_resolution["height"]) / float(mode["height"]))
761 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
762 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
763 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
765 configuration[output].options["off"] = None
769 def print_profile_differences(one, another):
770 "Print the differences between two profiles for debugging"
773 print("| Differences between the two profiles:", file=sys.stderr)
774 for output in set(chain.from_iterable((one.keys(), another.keys()))):
775 if output not in one:
776 if "off" not in another[output].options:
777 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
778 elif output not in another:
779 if "off" not in one[output].options:
780 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
782 for line in one[output].verbose_diff(another[output]):
783 print("| [Output %s] %s" % (output, line), file=sys.stderr)
784 print("\\-", file=sys.stderr)
788 "Print help and exit"
790 for profile in virtual_profiles:
791 name, description = profile[:2]
792 description = [description]
794 while len(description[0]) > max_width + 1:
795 left_over = description[0][max_width:]
796 description[0] = description[0][:max_width] + "-"
797 description.insert(1, " %-15s %s" % ("", left_over))
798 description = "\n".join(description)
799 print(" %-15s %s" % (name, description))
803 def exec_scripts(profile_path, script_name, meta_information=None):
806 This will run all executables from the profile folder, and global per-user
807 and system-wide configuration folders, named script_name or residing in
808 subdirectories named script_name.d.
810 If profile_path is None, only global scripts will be invoked.
812 meta_information is expected to be an dictionary. It will be passed to the block scripts
813 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
815 Returns True unless any of the scripts exited with non-zero exit status.
818 env = os.environ.copy()
820 env.update({"AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items()})
822 # If there are multiple candidates, the XDG spec tells to only use the first one.
825 user_profile_path = os.path.expanduser("~/.autorandr")
826 if not os.path.isdir(user_profile_path):
827 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
829 candidate_directories = chain((user_profile_path,), (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")))
831 candidate_directories = chain((profile_path,), candidate_directories)
833 for folder in candidate_directories:
835 if script_name not in ran_scripts:
836 script = os.path.join(folder, script_name)
837 if os.access(script, os.X_OK | os.F_OK):
839 all_ok &= subprocess.call(script, env=env) != 0
841 raise AutorandrException("Failed to execute user command: %s" % (script,))
842 ran_scripts.add(script_name)
844 script_folder = os.path.join(folder, "%s.d" % script_name)
845 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
846 for file_name in os.listdir(script_folder):
847 check_name = "d/%s" % (file_name,)
848 if check_name not in ran_scripts:
849 script = os.path.join(script_folder, file_name)
850 if os.access(script, os.X_OK | os.F_OK):
852 all_ok &= subprocess.call(script, env=env) != 0
854 raise AutorandrException("Failed to execute user command: %s" % (script,))
855 ran_scripts.add(check_name)
860 def dispatch_call_to_sessions(argv):
861 """Invoke autorandr for each open local X11 session with the given options.
863 The function iterates over all processes not owned by root and checks
864 whether they have DISPLAY and XAUTHORITY variables set. It strips the
865 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
866 this display has been handled already. If it has not, it forks, changes
867 uid/gid to the user owning the process, reuses the process's environment
868 and runs autorandr with the parameters from argv.
870 This function requires root permissions. It only works for X11 servers that
871 have at least one non-root process running. It is susceptible for attacks
872 where one user runs a process with another user's DISPLAY variable - in
873 this case, it might happen that autorandr is invoked for the other user,
874 which won't work. Since no other harm than prevention of automated
875 execution of autorandr can be done this way, the assumption is that in this
876 situation, the local administrator will handle the situation."""
878 X11_displays_done = set()
880 autorandr_binary = os.path.abspath(argv[0])
881 backup_candidates = {}
883 def fork_child_autorandr(pwent, process_environ):
884 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
885 child_pid = os.fork()
887 # This will throw an exception if any of the privilege changes fails,
888 # so it should be safe. Also, note that since the environment
889 # is taken from a process owned by the user, reusing it should
890 # not leak any information.
892 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
893 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
894 os.chdir(pwent.pw_dir)
896 os.environ.update(process_environ)
897 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
899 os.waitpid(child_pid, 0)
901 for directory in os.listdir("/proc"):
902 directory = os.path.join("/proc/", directory)
903 if not os.path.isdir(directory):
905 environ_file = os.path.join(directory, "environ")
906 if not os.path.isfile(environ_file):
908 uid = os.stat(environ_file).st_uid
910 # The following line assumes that user accounts start at 1000 and that
911 # no one works using the root or another system account. This is rather
912 # restrictive, but de facto default. Alternatives would be to use the
913 # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
914 # but effectively, both values aren't binding in any way.
915 # If this breaks your use case, please file a bug on Github.
920 for environ_entry in open(environ_file).read().split("\0"):
921 if "=" in environ_entry:
922 name, value = environ_entry.split("=", 1)
923 if name == "DISPLAY" and "." in value:
924 value = value[:value.find(".")]
925 process_environ[name] = value
927 if "DISPLAY" not in process_environ:
928 # Cannot work with this environment, skip.
931 # To allow scripts to detect batch invocation (especially useful for predetect)
932 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
933 process_environ["UID"] = str(uid)
935 display = process_environ["DISPLAY"]
937 if "XAUTHORITY" not in process_environ:
938 # It's very likely that we cannot work with this environment either,
939 # but keep it as a backup just in case we don't find anything else.
940 backup_candidates[display] = process_environ
943 if display not in X11_displays_done:
945 pwent = pwd.getpwuid(uid)
947 # User has no pwd entry
950 fork_child_autorandr(pwent, process_environ)
951 X11_displays_done.add(display)
953 # Run autorandr for any users/displays which didn't have a process with
955 for display, process_environ in backup_candidates.items():
956 if display not in X11_displays_done:
958 pwent = pwd.getpwuid(int(process_environ["UID"]))
960 # User has no pwd entry
963 fork_child_autorandr(pwent, process_environ)
964 X11_displays_done.add(display)
969 opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
970 ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
971 "force", "fingerprint", "config", "debug", "skip-options=", "help"])
972 except getopt.GetoptError as e:
973 print("Failed to parse options: {0}.\n"
974 "Use --help to get usage information.".format(str(e)),
976 sys.exit(posix.EX_USAGE)
980 if "-h" in options or "--help" in options:
984 if "--batch" in options:
985 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
986 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
988 print("--batch mode can only be used by root and if $DISPLAY is unset")
990 if "AUTORANDR_BATCH_PID" in os.environ:
991 user = pwd.getpwuid(os.getuid())
992 user = user.pw_name if user else "#%d" % os.getuid()
993 print("autorandr running as user %s (started from batch instance)" % user)
996 profile_symlinks = {}
998 # Load profiles from each XDG config directory
999 # The XDG spec says that earlier entries should take precedence, so reverse the order
1000 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1001 system_profile_path = os.path.join(directory, "autorandr")
1002 if os.path.isdir(system_profile_path):
1003 profiles.update(load_profiles(system_profile_path))
1004 profile_symlinks.update(get_symlinks(system_profile_path))
1005 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1006 # profile_path is also used later on to store configurations
1007 profile_path = os.path.expanduser("~/.autorandr")
1008 if not os.path.isdir(profile_path):
1009 # Elsewise, follow the XDG specification
1010 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1011 if os.path.isdir(profile_path):
1012 profiles.update(load_profiles(profile_path))
1013 profile_symlinks.update(get_symlinks(profile_path))
1014 # Sort by descending mtime
1015 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
1016 except Exception as e:
1017 raise AutorandrException("Failed to load profiles", e)
1019 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}
1021 exec_scripts(None, "predetect")
1022 config, modes = parse_xrandr_output()
1024 if "--fingerprint" in options:
1025 output_setup(config, sys.stdout)
1028 if "--config" in options:
1029 output_configuration(config, sys.stdout)
1032 if "--skip-options" in options:
1033 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1034 for profile in profiles.values():
1035 for output in profile["config"].values():
1036 output.set_ignored_options(skip_options)
1037 for output in config.values():
1038 output.set_ignored_options(skip_options)
1041 options["--save"] = options["-s"]
1042 if "--save" in options:
1043 if options["--save"] in (x[0] for x in virtual_profiles):
1044 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1045 "This configuration name is a reserved virtual configuration." % options["--save"])
1047 profile_folder = os.path.join(profile_path, options["--save"])
1048 save_configuration(profile_folder, config)
1049 exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
1050 except Exception as e:
1051 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1052 print("Saved current configuration as profile '%s'" % options["--save"])
1056 options["--remove"] = options["-r"]
1057 if "--remove" in options:
1058 if options["--remove"] in (x[0] for x in virtual_profiles):
1059 raise AutorandrException("Cannot remove profile '%s':\n"
1060 "This configuration name is a reserved virtual configuration." % options["--remove"])
1061 if options["--remove"] not in profiles.keys():
1062 raise AutorandrException("Cannot remove profile '%s':\n"
1063 "This profile does not exist." % options["--remove"])
1066 profile_folder = os.path.join(profile_path, options["--remove"])
1067 profile_dirlist = os.listdir(profile_folder)
1068 profile_dirlist.remove("config")
1069 profile_dirlist.remove("setup")
1071 print("Profile folder '%s' contains the following additional files:\n"
1072 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1073 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1074 if response != "yes":
1077 shutil.rmtree(profile_folder)
1078 print("Removed profile '%s'" % options["--remove"])
1080 print("Profile '%s' was not removed" % options["--remove"])
1081 except Exception as e:
1082 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1085 detected_profiles = find_profiles(config, profiles)
1086 load_profile = False
1089 options["--load"] = options["-l"]
1090 if "--load" in options:
1091 load_profile = options["--load"]
1092 elif len(args) == 1:
1093 load_profile = args[0]
1095 # Find the active profile(s) first, for the block script (See #42)
1096 current_profiles = []
1097 for profile_name in profiles.keys():
1098 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1099 if configs_are_equal:
1100 current_profiles.append(profile_name)
1101 block_script_metadata = {
1102 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1103 "CURRENT_PROFILES": ":".join(current_profiles)
1106 for profile_name in profiles.keys():
1107 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1108 print("%s (blocked)" % profile_name, file=sys.stderr)
1111 if profile_name in detected_profiles:
1112 props.append("(detected)")
1113 if ("-c" in options or "--change" in options) and not load_profile:
1114 load_profile = profile_name
1115 if profile_name in current_profiles:
1116 props.append("(current)")
1117 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
1118 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1119 print_profile_differences(config, profiles[profile_name]["config"])
1122 options["--default"] = options["-d"]
1123 if not load_profile and "--default" in options:
1124 load_profile = options["--default"]
1127 if load_profile in profile_symlinks:
1128 if "--debug" in options:
1129 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1130 load_profile = profile_symlinks[load_profile]
1132 if load_profile in (x[0] for x in virtual_profiles):
1133 load_config = generate_virtual_profile(config, modes, load_profile)
1134 scripts_path = os.path.join(profile_path, load_profile)
1137 profile = profiles[load_profile]
1138 load_config = profile["config"]
1139 scripts_path = profile["path"]
1141 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1142 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1143 update_mtime(os.path.join(scripts_path, "config"))
1144 add_unused_outputs(config, load_config)
1145 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1146 print("Config already loaded", file=sys.stderr)
1148 if "--debug" in options and load_config != dict(config):
1149 print("Loading profile '%s'" % load_profile)
1150 print_profile_differences(config, load_config)
1152 remove_irrelevant_outputs(config, load_config)
1155 if "--dry-run" in options:
1156 apply_configuration(load_config, config, True)
1159 "CURRENT_PROFILE": load_profile,
1160 "PROFILE_FOLDER": scripts_path,
1162 exec_scripts(scripts_path, "preswitch", script_metadata)
1163 if "--debug" in options:
1164 print("Going to run:")
1165 apply_configuration(load_config, config, True)
1166 apply_configuration(load_config, config, False)
1167 exec_scripts(scripts_path, "postswitch", script_metadata)
1168 except AutorandrException as e:
1169 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1170 except Exception as e:
1171 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1173 if "--dry-run" not in options and "--debug" in options:
1174 new_config, _ = parse_xrandr_output()
1175 if not is_equal_configuration(new_config, load_config):
1176 print("The configuration change did not go as expected:")
1177 print_profile_differences(new_config, load_config)
1182 def exception_handled_main(argv=sys.argv):
1185 except AutorandrException as e:
1186 print(e, file=sys.stderr)
1188 except Exception as e:
1189 if not len(str(e)): # BdbQuit
1190 print("Exception: {0}".format(e.__class__.__name__))
1193 print("Unhandled exception ({0}). Please report this as a bug at "
1194 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1199 if __name__ == '__main__':
1200 exception_handled_main()