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 -d, --default <profile> make profile <profile> the default profile
64 -l, --load <profile> load profile <profile>
65 -s, --save <profile> save your current setup to profile <profile>
66 -r, --remove <profile> remove profile <profile>
67 --batch run autorandr for all users with active X11 sessions
68 --config dump your current xrandr setup
69 --debug enable verbose output
70 --dry-run don't change anything, only print the xrandr commands
71 --fingerprint fingerprint your current hardware setup
72 --force force (re)loading of a profile
73 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
74 to skip both in detecting changes and applying a profile
76 If no suitable profile can be identified, the current configuration is kept.
77 To change this behaviour and switch to a fallback configuration, specify
80 autorandr supports a set of per-profile and global hooks. See the documentation
83 The following virtual configurations are available:
87 class AutorandrException(Exception):
88 def __init__(self, message, original_exception=None, report_bug=False):
89 self.message = message
90 self.report_bug = report_bug
91 if original_exception:
92 self.original_exception = original_exception
93 trace = sys.exc_info()[2]
96 self.line = trace.tb_lineno
97 self.file_name = trace.tb_frame.f_code.co_filename
101 frame = inspect.currentframe().f_back
102 self.line = frame.f_lineno
103 self.file_name = frame.f_code.co_filename
106 self.file_name = None
107 self.original_exception = None
109 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
110 self.file_name = None
113 retval = [self.message]
115 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
116 if self.original_exception:
117 retval.append(":\n ")
118 retval.append(str(self.original_exception).replace("\n", "\n "))
120 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
121 "\nhttps://github.com/phillipberndt/autorandr/issues"
122 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
123 return "".join(retval)
126 class XrandrOutput(object):
127 "Represents an XRandR output"
129 # This regular expression is used to parse an output in `xrandr --verbose'
130 XRANDR_OUTPUT_REGEXP = """(?x)
131 ^(?P<output>[^ ]+)\s+ # Line starts with output name
132 (?: # Differentiate disconnected and connected
133 disconnected | # in first line
134 unknown\ connection |
135 (?P<connected>connected)
138 (?P<primary>primary\ )? # Might be primary screen
140 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
141 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
142 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
143 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
144 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
145 )? # .. but only if the screen is in use.
146 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
147 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
148 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
149 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
150 (?:\s*(?: # Properties of the output
151 Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) | # Gamma value
152 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
153 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
154 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
158 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution:
159 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+ # Extract rate
160 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
161 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
165 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
166 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
167 h:\s+width\s+(?P<width>[0-9]+).+\s+
168 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
171 XRANDR_13_DEFAULTS = {
172 "transform": "1,0,0,0,1,0,0,0,1",
176 XRANDR_12_DEFAULTS = {
179 "gamma": "1.0:1.0:1.0",
182 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
184 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
187 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
190 def short_edid(self):
191 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
194 def options_with_defaults(self):
195 "Return the options dictionary, augmented with the default values that weren't set"
196 if "off" in self.options:
199 if xrandr_version() >= Version("1.3"):
200 options.update(self.XRANDR_13_DEFAULTS)
201 if xrandr_version() >= Version("1.2"):
202 options.update(self.XRANDR_12_DEFAULTS)
203 options.update(self.options)
204 return {a: b for a, b in options.items() if a not in self.ignored_options}
207 def filtered_options(self):
208 "Return a dictionary of options without ignored options"
209 return {a: b for a, b in self.options.items() if a not in self.ignored_options}
212 def option_vector(self):
213 "Return the command line parameters for XRandR for this instance"
214 args = ["--output", self.output]
215 for option, arg in sorted(self.options_with_defaults.items()):
216 args.append("--%s" % option)
222 def option_string(self):
223 "Return the command line parameters in the configuration file format"
224 options = ["output %s" % self.output]
225 for option, arg in sorted(self.filtered_options.items()):
227 options.append("%s %s" % (option, arg))
229 options.append(option)
230 return "\n".join(options)
234 "Return a key to sort the outputs for xrandr invocation"
237 if "off" in self.options:
239 if "pos" in self.options:
240 x, y = map(float, self.options["pos"].split("x"))
245 def __init__(self, output, edid, options):
246 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
249 self.options = options
250 self.ignored_options = []
251 self.remove_default_option_values()
253 def set_ignored_options(self, options):
254 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
255 self.ignored_options = list(options)
257 def remove_default_option_values(self):
258 "Remove values from the options dictionary that are superflous"
259 if "off" in self.options and len(self.options.keys()) > 1:
260 self.options = {"off": None}
262 for option, default_value in self.XRANDR_DEFAULTS.items():
263 if option in self.options and self.options[option] == default_value:
264 del self.options[option]
267 def from_xrandr_output(cls, xrandr_output):
268 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
270 This method also returns a list of modes supported by the output.
273 xrandr_output = xrandr_output.replace("\r\n", "\n")
274 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
276 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
279 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
280 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
282 remainder = xrandr_output[len(match_object.group(0)):]
284 raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
285 "regular expression, starting at byte %d with ..'%s'." %
286 (len(remainder), len(match_object.group(0)), remainder[:10]),
289 match = match_object.groupdict()
294 for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
295 if mode_match.group("name"):
296 modes.append(mode_match.groupdict())
298 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
301 if not match["connected"]:
304 edid = "".join(match["edid"].strip().split())
306 edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
308 if not match["width"]:
309 options["off"] = None
311 if match["mode_name"]:
312 options["mode"] = match["mode_name"]
313 elif match["mode_width"]:
314 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
316 if match["rotate"] not in ("left", "right"):
317 options["mode"] = "%sx%s" % (match["width"], match["height"])
319 options["mode"] = "%sx%s" % (match["height"], match["width"])
320 options["rotate"] = match["rotate"]
322 options["primary"] = None
323 if match["reflect"] == "X":
324 options["reflect"] = "x"
325 elif match["reflect"] == "Y":
326 options["reflect"] = "y"
327 elif match["reflect"] == "X and Y":
328 options["reflect"] = "xy"
329 options["pos"] = "%sx%s" % (match["x"], match["y"])
331 panning = [match["panning"]]
332 if match["tracking"]:
333 panning += ["/", match["tracking"]]
335 panning += ["/", match["border"]]
336 options["panning"] = "".join(panning)
337 if match["transform"]:
338 transformation = ",".join(match["transform"].strip().split())
339 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
340 options["transform"] = transformation
341 if not match["mode_name"]:
342 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
343 # I doubt that this special case is actually required.
344 print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
345 "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
347 gamma = match["gamma"].strip()
348 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
349 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
350 # so we approximate by 1e-10.
351 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
352 options["gamma"] = gamma
354 options["rate"] = match["rate"]
356 return XrandrOutput(match["output"], edid, options), modes
359 def from_config_file(cls, edid_map, configuration):
360 "Instanciate an XrandrOutput from the contents of a configuration file"
362 for line in configuration.split("\n"):
364 line = line.split(None, 1)
365 if line and line[0].startswith("#"):
367 options[line[0]] = line[1] if len(line) > 1 else None
371 if options["output"] in edid_map:
372 edid = edid_map[options["output"]]
374 # This fuzzy matching is for legacy autorandr that used sysfs output names
375 fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
376 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
377 if fuzzy_output in fuzzy_edid_map:
378 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
379 elif "off" not in options:
380 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
381 "is not off in config file." % (options["output"], options["output"]))
382 output = options["output"]
383 del options["output"]
385 return XrandrOutput(output, edid, options)
387 def edid_equals(self, other):
388 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
389 if self.edid and other.edid:
390 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
391 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
392 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
393 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
394 return self.edid == other.edid
396 def __ne__(self, other):
397 return not (self == other)
399 def __eq__(self, other):
400 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
402 def verbose_diff(self, other):
403 "Compare to another XrandrOutput and return a list of human readable differences"
405 if not self.edid_equals(other):
406 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
407 if self.output != other.output:
408 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
409 if "off" in self.options and "off" not in other.options:
410 diffs.append("The output is disabled currently, but active in the new configuration")
411 elif "off" in other.options and "off" not in self.options:
412 diffs.append("The output is currently enabled, but inactive in the new configuration")
414 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
415 if name not in other.options:
416 diffs.append("Option --%s %sis not present in the new configuration" %
417 (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
418 elif name not in self.options:
419 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
420 (name, other.options[name]))
421 elif self.options[name] != other.options[name]:
422 diffs.append("Option --%s %sis `%s' in the new configuration" %
423 (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
427 def xrandr_version():
428 "Return the version of XRandR that this system uses"
429 if getattr(xrandr_version, "version", False) is False:
430 version_string = os.popen("xrandr -v").read()
432 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
433 xrandr_version.version = Version(version)
434 except AttributeError:
435 xrandr_version.version = Version("1.3.0")
437 return xrandr_version.version
440 def debug_regexp(pattern, string):
441 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
444 bounds = (0, len(string))
445 while bounds[0] != bounds[1]:
446 half = int((bounds[0] + bounds[1]) / 2)
447 if half == bounds[0]:
449 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
450 partial_length = bounds[0]
451 return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
452 (partial_length, string[max(0, partial_length - 20):partial_length],
453 string[partial_length:partial_length + 10]))
456 return "Debug information would be available if the `regex' module was installed."
459 def parse_xrandr_output():
460 "Parse the output of `xrandr --verbose' into a list of outputs"
461 xrandr_output = os.popen("xrandr -q --verbose").read()
462 if not xrandr_output:
463 raise AutorandrException("Failed to run xrandr")
465 # We are not interested in screens
466 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
468 # Split at output boundaries and instanciate an XrandrOutput per output
469 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
470 if len(split_xrandr_output) < 2:
471 raise AutorandrException("No output boundaries found", report_bug=True)
472 outputs = OrderedDict()
473 modes = OrderedDict()
474 for i in range(1, len(split_xrandr_output), 2):
475 output_name = split_xrandr_output[i].split()[0]
476 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
477 outputs[output_name] = output
479 modes[output_name] = output_modes
481 return outputs, modes
484 def load_profiles(profile_path):
485 "Load the stored profiles"
488 for profile in os.listdir(profile_path):
489 config_name = os.path.join(profile_path, profile, "config")
490 setup_name = os.path.join(profile_path, profile, "setup")
491 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
494 edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
498 for line in chain(open(config_name).readlines(), ["output"]):
499 if line[:6] == "output" and buffer:
500 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
505 for output_name in list(config.keys()):
506 if config[output_name].edid is None:
507 del config[output_name]
509 profiles[profile] = {
511 "path": os.path.join(profile_path, profile),
512 "config-mtime": os.stat(config_name).st_mtime,
518 def get_symlinks(profile_path):
519 "Load all symlinks from a directory"
522 for link in os.listdir(profile_path):
523 file_name = os.path.join(profile_path, link)
524 if os.path.islink(file_name):
525 symlinks[link] = os.readlink(file_name)
530 def find_profiles(current_config, profiles):
531 "Find profiles matching the currently connected outputs"
532 detected_profiles = []
533 for profile_name, profile in profiles.items():
534 config = profile["config"]
536 for name, output in config.items():
539 if name not in current_config or not output.edid_equals(current_config[name]):
542 if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
545 detected_profiles.append(profile_name)
546 return detected_profiles
549 def profile_blocked(profile_path, meta_information=None):
550 """Check if a profile is blocked.
552 meta_information is expected to be an dictionary. It will be passed to the block scripts
553 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
555 return not exec_scripts(profile_path, "block", meta_information)
558 def output_configuration(configuration, config):
559 "Write a configuration file"
560 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
561 for output in outputs:
562 print(configuration[output].option_string, file=config)
565 def output_setup(configuration, setup):
566 "Write a setup (fingerprint) file"
567 outputs = sorted(configuration.keys())
568 for output in outputs:
569 if configuration[output].edid:
570 print(output, configuration[output].edid, file=setup)
573 def save_configuration(profile_path, configuration):
574 "Save a configuration into a profile"
575 if not os.path.isdir(profile_path):
576 os.makedirs(profile_path)
577 with open(os.path.join(profile_path, "config"), "w") as config:
578 output_configuration(configuration, config)
579 with open(os.path.join(profile_path, "setup"), "w") as setup:
580 output_setup(configuration, setup)
583 def update_mtime(filename):
584 "Update a file's mtime"
586 os.utime(filename, None)
592 def call_and_retry(*args, **kwargs):
593 """Wrapper around subprocess.call that retries failed calls.
595 This function calls subprocess.call and on non-zero exit states,
596 waits a second and then retries once. This mitigates #47,
597 a timing issue with some drivers.
599 if "dry_run" in kwargs:
600 dry_run = kwargs["dry_run"]
601 del kwargs["dry_run"]
604 kwargs_redirected = dict(kwargs)
606 if hasattr(subprocess, "DEVNULL"):
607 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
609 kwargs_redirected["stdout"] = open(os.devnull, "w")
610 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
611 retval = subprocess.call(*args, **kwargs_redirected)
614 retval = subprocess.call(*args, **kwargs)
618 def apply_configuration(new_configuration, current_configuration, dry_run=False):
619 "Apply a configuration"
620 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
622 base_argv = ["echo", "xrandr"]
624 base_argv = ["xrandr"]
626 # There are several xrandr / driver bugs we need to take care of here:
627 # - We cannot enable more than two screens at the same time
628 # See https://github.com/phillipberndt/autorandr/pull/6
629 # and commits f4cce4d and 8429886.
630 # - We cannot disable all screens
631 # See https://github.com/phillipberndt/autorandr/pull/20
632 # - We should disable screens before enabling others, because there's
633 # a limit on the number of enabled screens
634 # - We must make sure that the screen at 0x0 is activated first,
635 # or the other (first) screen to be activated would be moved there.
636 # - If an active screen already has a transformation and remains active,
637 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
638 # Update the configuration in 3 passes in that case. (On Haswell graphics,
640 # - Some implementations can not handle --transform at all, so avoid it unless
641 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
642 # - Some implementations can not handle --panning without specifying --fb
643 # explicitly, so avoid it unless necessary.
644 # (See https://github.com/phillipberndt/autorandr/issues/72)
646 auxiliary_changes_pre = []
649 remain_active_count = 0
650 for output in outputs:
651 if not new_configuration[output].edid or "off" in new_configuration[output].options:
652 disable_outputs.append(new_configuration[output].option_vector)
654 if "off" not in current_configuration[output].options:
655 remain_active_count += 1
657 option_vector = new_configuration[output].option_vector
658 if xrandr_version() >= Version("1.3.0"):
659 for option, off_value in (("transform", "none"), ("panning", "0x0")):
660 if option in current_configuration[output].options:
661 auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
664 option_index = option_vector.index("--%s" % option)
665 if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
666 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
670 enable_outputs.append(option_vector)
672 # Perform pe-change auxiliary changes
673 if auxiliary_changes_pre:
674 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
675 if call_and_retry(argv, dry_run=dry_run) != 0:
676 raise AutorandrException("Command failed: %s" % " ".join(argv))
678 # Disable unused outputs, but make sure that there always is at least one active screen
679 disable_keep = 0 if remain_active_count else 1
680 if len(disable_outputs) > disable_keep:
681 argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
682 if call_and_retry(argv, dry_run=dry_run) != 0:
683 # Disabling the outputs failed. Retry with the next command:
684 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
685 # This does not occur if simultaneously the primary screen is reset.
688 disable_outputs = disable_outputs[-1:] if disable_keep else []
690 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
691 # disable the last two screens. This is a problem, so if this would happen, instead disable only
692 # one screen in the first call below.
693 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
694 # In the context of a xrandr call that changes the display state, `--query' should do nothing
695 disable_outputs.insert(0, ['--query'])
697 # Enable the remaining outputs in pairs of two operations
698 operations = disable_outputs + enable_outputs
699 for index in range(0, len(operations), 2):
700 argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
701 if call_and_retry(argv, dry_run=dry_run) != 0:
702 raise AutorandrException("Command failed: %s" % " ".join(argv))
705 def is_equal_configuration(source_configuration, target_configuration):
706 "Check if all outputs from target are already configured correctly in source"
707 for output in target_configuration.keys():
708 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
713 def add_unused_outputs(source_configuration, target_configuration):
714 "Add outputs that are missing in target to target, in 'off' state"
715 for output_name, output in source_configuration.items():
716 if output_name not in target_configuration:
717 target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
720 def remove_irrelevant_outputs(source_configuration, target_configuration):
721 "Remove outputs from target that ought to be 'off' and already are"
722 for output_name, output in source_configuration.items():
723 if "off" in output.options:
724 if output_name in target_configuration:
725 if "off" in target_configuration[output_name].options:
726 del target_configuration[output_name]
729 def generate_virtual_profile(configuration, modes, profile_name):
730 "Generate one of the virtual profiles"
731 configuration = copy.deepcopy(configuration)
732 if profile_name == "common":
734 for output, output_modes in modes.items():
736 if configuration[output].edid:
737 for mode in output_modes:
738 mode_set.add((mode["width"], mode["height"]))
739 mode_sets.append(mode_set)
740 common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
741 common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
742 if common_resolution:
743 for output in configuration:
744 configuration[output].options = {}
745 if output in modes and configuration[output].edid:
746 modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
747 modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
748 mode = modes_filtered[0]
749 configuration[output].options["mode"] = mode['name']
750 configuration[output].options["pos"] = "0x0"
752 configuration[output].options["off"] = None
753 elif profile_name in ("horizontal", "vertical"):
755 if profile_name == "horizontal":
756 shift_index = "width"
757 pos_specifier = "%sx0"
759 shift_index = "height"
760 pos_specifier = "0x%s"
762 for output in configuration:
763 configuration[output].options = {}
764 if output in modes and configuration[output].edid:
766 score = int(a["width"]) * int(a["height"])
770 modes = sorted(modes[output], key=key)
772 configuration[output].options["mode"] = mode["name"]
773 configuration[output].options["rate"] = mode["rate"]
774 configuration[output].options["pos"] = pos_specifier % shift
775 shift += int(mode[shift_index])
777 configuration[output].options["off"] = None
778 elif profile_name == "clone-largest":
779 modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
780 modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
781 biggest_resolution = modes_sorted[0]
782 for output in configuration:
783 configuration[output].options = {}
784 if output in modes and configuration[output].edid:
786 score = int(a["width"]) * int(a["height"])
790 modes = sorted(modes[output], key=key)
792 configuration[output].options["mode"] = mode["name"]
793 configuration[output].options["rate"] = mode["rate"]
794 configuration[output].options["pos"] = "0x0"
795 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
796 float(biggest_resolution["height"]) / float(mode["height"]))
797 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
798 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
799 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
801 configuration[output].options["off"] = None
805 def print_profile_differences(one, another):
806 "Print the differences between two profiles for debugging"
809 print("| Differences between the two profiles:", file=sys.stderr)
810 for output in set(chain.from_iterable((one.keys(), another.keys()))):
811 if output not in one:
812 if "off" not in another[output].options:
813 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
814 elif output not in another:
815 if "off" not in one[output].options:
816 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
818 for line in one[output].verbose_diff(another[output]):
819 print("| [Output %s] %s" % (output, line), file=sys.stderr)
820 print("\\-", file=sys.stderr)
824 "Print help and exit"
826 for profile in virtual_profiles:
827 name, description = profile[:2]
828 description = [description]
830 while len(description[0]) > max_width + 1:
831 left_over = description[0][max_width:]
832 description[0] = description[0][:max_width] + "-"
833 description.insert(1, " %-15s %s" % ("", left_over))
834 description = "\n".join(description)
835 print(" %-15s %s" % (name, description))
839 def exec_scripts(profile_path, script_name, meta_information=None):
842 This will run all executables from the profile folder, and global per-user
843 and system-wide configuration folders, named script_name or residing in
844 subdirectories named script_name.d.
846 If profile_path is None, only global scripts will be invoked.
848 meta_information is expected to be an dictionary. It will be passed to the block scripts
849 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
851 Returns True unless any of the scripts exited with non-zero exit status.
854 env = os.environ.copy()
856 for key, value in meta_information.items():
857 env["AUTORANDR_{}".format(key.upper())] = str(value)
859 # If there are multiple candidates, the XDG spec tells to only use the first one.
862 user_profile_path = os.path.expanduser("~/.autorandr")
863 if not os.path.isdir(user_profile_path):
864 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
866 candidate_directories = [user_profile_path]
867 for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
868 candidate_directories.append(os.path.join(config_dir, "autorandr"))
870 candidate_directories.append(profile_path)
872 for folder in candidate_directories:
873 if script_name not in ran_scripts:
874 script = os.path.join(folder, script_name)
875 if os.access(script, os.X_OK | os.F_OK):
877 all_ok &= subprocess.call(script, env=env) != 0
879 raise AutorandrException("Failed to execute user command: %s" % (script,))
880 ran_scripts.add(script_name)
882 script_folder = os.path.join(folder, "%s.d" % script_name)
883 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
884 for file_name in os.listdir(script_folder):
885 check_name = "d/%s" % (file_name,)
886 if check_name not in ran_scripts:
887 script = os.path.join(script_folder, file_name)
888 if os.access(script, os.X_OK | os.F_OK):
890 all_ok &= subprocess.call(script, env=env) != 0
892 raise AutorandrException("Failed to execute user command: %s" % (script,))
893 ran_scripts.add(check_name)
898 def dispatch_call_to_sessions(argv):
899 """Invoke autorandr for each open local X11 session with the given options.
901 The function iterates over all processes not owned by root and checks
902 whether they have DISPLAY and XAUTHORITY variables set. It strips the
903 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
904 this display has been handled already. If it has not, it forks, changes
905 uid/gid to the user owning the process, reuses the process's environment
906 and runs autorandr with the parameters from argv.
908 This function requires root permissions. It only works for X11 servers that
909 have at least one non-root process running. It is susceptible for attacks
910 where one user runs a process with another user's DISPLAY variable - in
911 this case, it might happen that autorandr is invoked for the other user,
912 which won't work. Since no other harm than prevention of automated
913 execution of autorandr can be done this way, the assumption is that in this
914 situation, the local administrator will handle the situation."""
916 X11_displays_done = set()
918 autorandr_binary = os.path.abspath(argv[0])
919 backup_candidates = {}
921 def fork_child_autorandr(pwent, process_environ):
922 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
923 child_pid = os.fork()
925 # This will throw an exception if any of the privilege changes fails,
926 # so it should be safe. Also, note that since the environment
927 # is taken from a process owned by the user, reusing it should
928 # not leak any information.
930 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
931 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
932 os.chdir(pwent.pw_dir)
934 os.environ.update(process_environ)
935 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
937 os.waitpid(child_pid, 0)
939 for directory in os.listdir("/proc"):
940 directory = os.path.join("/proc/", directory)
941 if not os.path.isdir(directory):
943 environ_file = os.path.join(directory, "environ")
944 if not os.path.isfile(environ_file):
946 uid = os.stat(environ_file).st_uid
948 # The following line assumes that user accounts start at 1000 and that
949 # no one works using the root or another system account. This is rather
950 # restrictive, but de facto default. Alternatives would be to use the
951 # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
952 # but effectively, both values aren't binding in any way.
953 # If this breaks your use case, please file a bug on Github.
958 for environ_entry in open(environ_file).read().split("\0"):
959 name, sep, value = environ_entry.partition("=")
961 if name == "DISPLAY" and "." in value:
962 value = value[:value.find(".")]
963 process_environ[name] = value
965 if "DISPLAY" not in process_environ:
966 # Cannot work with this environment, skip.
969 # To allow scripts to detect batch invocation (especially useful for predetect)
970 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
971 process_environ["UID"] = str(uid)
973 display = process_environ["DISPLAY"]
975 if "XAUTHORITY" not in process_environ:
976 # It's very likely that we cannot work with this environment either,
977 # but keep it as a backup just in case we don't find anything else.
978 backup_candidates[display] = process_environ
981 if display not in X11_displays_done:
983 pwent = pwd.getpwuid(uid)
985 # User has no pwd entry
988 fork_child_autorandr(pwent, process_environ)
989 X11_displays_done.add(display)
991 # Run autorandr for any users/displays which didn't have a process with
993 for display, process_environ in backup_candidates.items():
994 if display not in X11_displays_done:
996 pwent = pwd.getpwuid(int(process_environ["UID"]))
998 # User has no pwd entry
1001 fork_child_autorandr(pwent, process_environ)
1002 X11_displays_done.add(display)
1007 opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1008 ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
1009 "force", "fingerprint", "config", "debug", "skip-options=", "help"])
1010 except getopt.GetoptError as e:
1011 print("Failed to parse options: {0}.\n"
1012 "Use --help to get usage information.".format(str(e)),
1014 sys.exit(posix.EX_USAGE)
1016 options = dict(opts)
1018 if "-h" in options or "--help" in options:
1022 if "--batch" in options:
1023 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1024 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1026 print("--batch mode can only be used by root and if $DISPLAY is unset")
1028 if "AUTORANDR_BATCH_PID" in os.environ:
1029 user = pwd.getpwuid(os.getuid())
1030 user = user.pw_name if user else "#%d" % os.getuid()
1031 print("autorandr running as user %s (started from batch instance)" % user)
1034 profile_symlinks = {}
1036 # Load profiles from each XDG config directory
1037 # The XDG spec says that earlier entries should take precedence, so reverse the order
1038 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1039 system_profile_path = os.path.join(directory, "autorandr")
1040 if os.path.isdir(system_profile_path):
1041 profiles.update(load_profiles(system_profile_path))
1042 profile_symlinks.update(get_symlinks(system_profile_path))
1043 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1044 # profile_path is also used later on to store configurations
1045 profile_path = os.path.expanduser("~/.autorandr")
1046 if not os.path.isdir(profile_path):
1047 # Elsewise, follow the XDG specification
1048 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1049 if os.path.isdir(profile_path):
1050 profiles.update(load_profiles(profile_path))
1051 profile_symlinks.update(get_symlinks(profile_path))
1052 # Sort by descending mtime
1053 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
1054 except Exception as e:
1055 raise AutorandrException("Failed to load profiles", e)
1057 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}
1059 exec_scripts(None, "predetect")
1060 config, modes = parse_xrandr_output()
1062 if "--fingerprint" in options:
1063 output_setup(config, sys.stdout)
1066 if "--config" in options:
1067 output_configuration(config, sys.stdout)
1070 if "--skip-options" in options:
1071 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1072 for profile in profiles.values():
1073 for output in profile["config"].values():
1074 output.set_ignored_options(skip_options)
1075 for output in config.values():
1076 output.set_ignored_options(skip_options)
1079 options["--save"] = options["-s"]
1080 if "--save" in options:
1081 if options["--save"] in (x[0] for x in virtual_profiles):
1082 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1083 "This configuration name is a reserved virtual configuration." % options["--save"])
1085 profile_folder = os.path.join(profile_path, options["--save"])
1086 save_configuration(profile_folder, config)
1087 exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
1088 except Exception as e:
1089 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1090 print("Saved current configuration as profile '%s'" % options["--save"])
1094 options["--remove"] = options["-r"]
1095 if "--remove" in options:
1096 if options["--remove"] in (x[0] for x in virtual_profiles):
1097 raise AutorandrException("Cannot remove profile '%s':\n"
1098 "This configuration name is a reserved virtual configuration." % options["--remove"])
1099 if options["--remove"] not in profiles.keys():
1100 raise AutorandrException("Cannot remove profile '%s':\n"
1101 "This profile does not exist." % options["--remove"])
1104 profile_folder = os.path.join(profile_path, options["--remove"])
1105 profile_dirlist = os.listdir(profile_folder)
1106 profile_dirlist.remove("config")
1107 profile_dirlist.remove("setup")
1109 print("Profile folder '%s' contains the following additional files:\n"
1110 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1111 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1112 if response != "yes":
1115 shutil.rmtree(profile_folder)
1116 print("Removed profile '%s'" % options["--remove"])
1118 print("Profile '%s' was not removed" % options["--remove"])
1119 except Exception as e:
1120 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1123 detected_profiles = find_profiles(config, profiles)
1124 load_profile = False
1127 options["--load"] = options["-l"]
1128 if "--load" in options:
1129 load_profile = options["--load"]
1130 elif len(args) == 1:
1131 load_profile = args[0]
1133 # Find the active profile(s) first, for the block script (See #42)
1134 current_profiles = []
1135 for profile_name in profiles.keys():
1136 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1137 if configs_are_equal:
1138 current_profiles.append(profile_name)
1139 block_script_metadata = {
1140 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1141 "CURRENT_PROFILES": ":".join(current_profiles)
1144 for profile_name in profiles.keys():
1145 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1146 print("%s (blocked)" % profile_name, file=sys.stderr)
1149 if profile_name in detected_profiles:
1150 props.append("(detected)")
1151 if ("-c" in options or "--change" in options) and not load_profile:
1152 load_profile = profile_name
1153 if profile_name in current_profiles:
1154 props.append("(current)")
1155 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
1156 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1157 print_profile_differences(config, profiles[profile_name]["config"])
1160 options["--default"] = options["-d"]
1161 if not load_profile and "--default" in options:
1162 load_profile = options["--default"]
1165 if load_profile in profile_symlinks:
1166 if "--debug" in options:
1167 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1168 load_profile = profile_symlinks[load_profile]
1170 if load_profile in (x[0] for x in virtual_profiles):
1171 load_config = generate_virtual_profile(config, modes, load_profile)
1172 scripts_path = os.path.join(profile_path, load_profile)
1175 profile = profiles[load_profile]
1176 load_config = profile["config"]
1177 scripts_path = profile["path"]
1179 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1180 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1181 update_mtime(os.path.join(scripts_path, "config"))
1182 add_unused_outputs(config, load_config)
1183 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1184 print("Config already loaded", file=sys.stderr)
1186 if "--debug" in options and load_config != dict(config):
1187 print("Loading profile '%s'" % load_profile)
1188 print_profile_differences(config, load_config)
1190 remove_irrelevant_outputs(config, load_config)
1193 if "--dry-run" in options:
1194 apply_configuration(load_config, config, True)
1197 "CURRENT_PROFILE": load_profile,
1198 "PROFILE_FOLDER": scripts_path,
1200 exec_scripts(scripts_path, "preswitch", script_metadata)
1201 if "--debug" in options:
1202 print("Going to run:")
1203 apply_configuration(load_config, config, True)
1204 apply_configuration(load_config, config, False)
1205 exec_scripts(scripts_path, "postswitch", script_metadata)
1206 except AutorandrException as e:
1207 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1208 except Exception as e:
1209 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1211 if "--dry-run" not in options and "--debug" in options:
1212 new_config, _ = parse_xrandr_output()
1213 if not is_equal_configuration(new_config, load_config):
1214 print("The configuration change did not go as expected:")
1215 print_profile_differences(new_config, load_config)
1220 def exception_handled_main(argv=sys.argv):
1223 except AutorandrException as e:
1224 print(e, file=sys.stderr)
1226 except Exception as e:
1227 if not len(str(e)): # BdbQuit
1228 print("Exception: {0}".format(e.__class__.__name__))
1231 print("Unhandled exception ({0}). Please report this as a bug at "
1232 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1237 if __name__ == '__main__':
1238 exception_handled_main()