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 in ("transform", "panning"):
660 if option in current_configuration[output].options:
661 auxiliary_changes_pre.append(["--output", output, "--%s" % option, "none"])
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 += os.path.join(config_dir, "autorandr")
870 candidate_directories += profile_path
872 for folder in candidate_directories:
874 if script_name not in ran_scripts:
875 script = os.path.join(folder, script_name)
876 if os.access(script, os.X_OK | os.F_OK):
878 all_ok &= subprocess.call(script, env=env) != 0
880 raise AutorandrException("Failed to execute user command: %s" % (script,))
881 ran_scripts.add(script_name)
883 script_folder = os.path.join(folder, "%s.d" % script_name)
884 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
885 for file_name in os.listdir(script_folder):
886 check_name = "d/%s" % (file_name,)
887 if check_name not in ran_scripts:
888 script = os.path.join(script_folder, file_name)
889 if os.access(script, os.X_OK | os.F_OK):
891 all_ok &= subprocess.call(script, env=env) != 0
893 raise AutorandrException("Failed to execute user command: %s" % (script,))
894 ran_scripts.add(check_name)
899 def dispatch_call_to_sessions(argv):
900 """Invoke autorandr for each open local X11 session with the given options.
902 The function iterates over all processes not owned by root and checks
903 whether they have DISPLAY and XAUTHORITY variables set. It strips the
904 screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
905 this display has been handled already. If it has not, it forks, changes
906 uid/gid to the user owning the process, reuses the process's environment
907 and runs autorandr with the parameters from argv.
909 This function requires root permissions. It only works for X11 servers that
910 have at least one non-root process running. It is susceptible for attacks
911 where one user runs a process with another user's DISPLAY variable - in
912 this case, it might happen that autorandr is invoked for the other user,
913 which won't work. Since no other harm than prevention of automated
914 execution of autorandr can be done this way, the assumption is that in this
915 situation, the local administrator will handle the situation."""
917 X11_displays_done = set()
919 autorandr_binary = os.path.abspath(argv[0])
920 backup_candidates = {}
922 def fork_child_autorandr(pwent, process_environ):
923 print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
924 child_pid = os.fork()
926 # This will throw an exception if any of the privilege changes fails,
927 # so it should be safe. Also, note that since the environment
928 # is taken from a process owned by the user, reusing it should
929 # not leak any information.
931 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
932 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
933 os.chdir(pwent.pw_dir)
935 os.environ.update(process_environ)
936 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
938 os.waitpid(child_pid, 0)
940 for directory in os.listdir("/proc"):
941 directory = os.path.join("/proc/", directory)
942 if not os.path.isdir(directory):
944 environ_file = os.path.join(directory, "environ")
945 if not os.path.isfile(environ_file):
947 uid = os.stat(environ_file).st_uid
949 # The following line assumes that user accounts start at 1000 and that
950 # no one works using the root or another system account. This is rather
951 # restrictive, but de facto default. Alternatives would be to use the
952 # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
953 # but effectively, both values aren't binding in any way.
954 # If this breaks your use case, please file a bug on Github.
959 for environ_entry in open(environ_file).read().split("\0"):
960 if "=" in environ_entry:
961 name, value = environ_entry.split("=", 1)
962 if name == "DISPLAY" and "." in value:
963 value = value[:value.find(".")]
964 process_environ[name] = value
966 if "DISPLAY" not in process_environ:
967 # Cannot work with this environment, skip.
970 # To allow scripts to detect batch invocation (especially useful for predetect)
971 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
972 process_environ["UID"] = str(uid)
974 display = process_environ["DISPLAY"]
976 if "XAUTHORITY" not in process_environ:
977 # It's very likely that we cannot work with this environment either,
978 # but keep it as a backup just in case we don't find anything else.
979 backup_candidates[display] = process_environ
982 if display not in X11_displays_done:
984 pwent = pwd.getpwuid(uid)
986 # User has no pwd entry
989 fork_child_autorandr(pwent, process_environ)
990 X11_displays_done.add(display)
992 # Run autorandr for any users/displays which didn't have a process with
994 for display, process_environ in backup_candidates.items():
995 if display not in X11_displays_done:
997 pwent = pwd.getpwuid(int(process_environ["UID"]))
999 # User has no pwd entry
1002 fork_child_autorandr(pwent, process_environ)
1003 X11_displays_done.add(display)
1008 opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1009 ["batch", "dry-run", "change", "default=", "save=", "remove=", "load=",
1010 "force", "fingerprint", "config", "debug", "skip-options=", "help"])
1011 except getopt.GetoptError as e:
1012 print("Failed to parse options: {0}.\n"
1013 "Use --help to get usage information.".format(str(e)),
1015 sys.exit(posix.EX_USAGE)
1017 options = dict(opts)
1019 if "-h" in options or "--help" in options:
1023 if "--batch" in options:
1024 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1025 dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1027 print("--batch mode can only be used by root and if $DISPLAY is unset")
1029 if "AUTORANDR_BATCH_PID" in os.environ:
1030 user = pwd.getpwuid(os.getuid())
1031 user = user.pw_name if user else "#%d" % os.getuid()
1032 print("autorandr running as user %s (started from batch instance)" % user)
1035 profile_symlinks = {}
1037 # Load profiles from each XDG config directory
1038 # The XDG spec says that earlier entries should take precedence, so reverse the order
1039 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1040 system_profile_path = os.path.join(directory, "autorandr")
1041 if os.path.isdir(system_profile_path):
1042 profiles.update(load_profiles(system_profile_path))
1043 profile_symlinks.update(get_symlinks(system_profile_path))
1044 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1045 # profile_path is also used later on to store configurations
1046 profile_path = os.path.expanduser("~/.autorandr")
1047 if not os.path.isdir(profile_path):
1048 # Elsewise, follow the XDG specification
1049 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1050 if os.path.isdir(profile_path):
1051 profiles.update(load_profiles(profile_path))
1052 profile_symlinks.update(get_symlinks(profile_path))
1053 # Sort by descending mtime
1054 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
1055 except Exception as e:
1056 raise AutorandrException("Failed to load profiles", e)
1058 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}
1060 exec_scripts(None, "predetect")
1061 config, modes = parse_xrandr_output()
1063 if "--fingerprint" in options:
1064 output_setup(config, sys.stdout)
1067 if "--config" in options:
1068 output_configuration(config, sys.stdout)
1071 if "--skip-options" in options:
1072 skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1073 for profile in profiles.values():
1074 for output in profile["config"].values():
1075 output.set_ignored_options(skip_options)
1076 for output in config.values():
1077 output.set_ignored_options(skip_options)
1080 options["--save"] = options["-s"]
1081 if "--save" in options:
1082 if options["--save"] in (x[0] for x in virtual_profiles):
1083 raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1084 "This configuration name is a reserved virtual configuration." % options["--save"])
1086 profile_folder = os.path.join(profile_path, options["--save"])
1087 save_configuration(profile_folder, config)
1088 exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
1089 except Exception as e:
1090 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1091 print("Saved current configuration as profile '%s'" % options["--save"])
1095 options["--remove"] = options["-r"]
1096 if "--remove" in options:
1097 if options["--remove"] in (x[0] for x in virtual_profiles):
1098 raise AutorandrException("Cannot remove profile '%s':\n"
1099 "This configuration name is a reserved virtual configuration." % options["--remove"])
1100 if options["--remove"] not in profiles.keys():
1101 raise AutorandrException("Cannot remove profile '%s':\n"
1102 "This profile does not exist." % options["--remove"])
1105 profile_folder = os.path.join(profile_path, options["--remove"])
1106 profile_dirlist = os.listdir(profile_folder)
1107 profile_dirlist.remove("config")
1108 profile_dirlist.remove("setup")
1110 print("Profile folder '%s' contains the following additional files:\n"
1111 "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1112 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1113 if response != "yes":
1116 shutil.rmtree(profile_folder)
1117 print("Removed profile '%s'" % options["--remove"])
1119 print("Profile '%s' was not removed" % options["--remove"])
1120 except Exception as e:
1121 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1124 detected_profiles = find_profiles(config, profiles)
1125 load_profile = False
1128 options["--load"] = options["-l"]
1129 if "--load" in options:
1130 load_profile = options["--load"]
1131 elif len(args) == 1:
1132 load_profile = args[0]
1134 # Find the active profile(s) first, for the block script (See #42)
1135 current_profiles = []
1136 for profile_name in profiles.keys():
1137 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1138 if configs_are_equal:
1139 current_profiles.append(profile_name)
1140 block_script_metadata = {
1141 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1142 "CURRENT_PROFILES": ":".join(current_profiles)
1145 for profile_name in profiles.keys():
1146 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1147 print("%s (blocked)" % profile_name, file=sys.stderr)
1150 if profile_name in detected_profiles:
1151 props.append("(detected)")
1152 if ("-c" in options or "--change" in options) and not load_profile:
1153 load_profile = profile_name
1154 if profile_name in current_profiles:
1155 props.append("(current)")
1156 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
1157 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1158 print_profile_differences(config, profiles[profile_name]["config"])
1161 options["--default"] = options["-d"]
1162 if not load_profile and "--default" in options:
1163 load_profile = options["--default"]
1166 if load_profile in profile_symlinks:
1167 if "--debug" in options:
1168 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1169 load_profile = profile_symlinks[load_profile]
1171 if load_profile in (x[0] for x in virtual_profiles):
1172 load_config = generate_virtual_profile(config, modes, load_profile)
1173 scripts_path = os.path.join(profile_path, load_profile)
1176 profile = profiles[load_profile]
1177 load_config = profile["config"]
1178 scripts_path = profile["path"]
1180 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1181 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1182 update_mtime(os.path.join(scripts_path, "config"))
1183 add_unused_outputs(config, load_config)
1184 if load_config == dict(config) and "-f" not in options and "--force" not in options:
1185 print("Config already loaded", file=sys.stderr)
1187 if "--debug" in options and load_config != dict(config):
1188 print("Loading profile '%s'" % load_profile)
1189 print_profile_differences(config, load_config)
1191 remove_irrelevant_outputs(config, load_config)
1194 if "--dry-run" in options:
1195 apply_configuration(load_config, config, True)
1198 "CURRENT_PROFILE": load_profile,
1199 "PROFILE_FOLDER": scripts_path,
1201 exec_scripts(scripts_path, "preswitch", script_metadata)
1202 if "--debug" in options:
1203 print("Going to run:")
1204 apply_configuration(load_config, config, True)
1205 apply_configuration(load_config, config, False)
1206 exec_scripts(scripts_path, "postswitch", script_metadata)
1207 except AutorandrException as e:
1208 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1209 except Exception as e:
1210 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1212 if "--dry-run" not in options and "--debug" in options:
1213 new_config, _ = parse_xrandr_output()
1214 if not is_equal_configuration(new_config, load_config):
1215 print("The configuration change did not go as expected:")
1216 print_profile_differences(new_config, load_config)
1221 def exception_handled_main(argv=sys.argv):
1224 except AutorandrException as e:
1225 print(e, file=sys.stderr)
1227 except Exception as e:
1228 if not len(str(e)): # BdbQuit
1229 print("Exception: {0}".format(e.__class__.__name__))
1232 print("Unhandled exception ({0}). Please report this as a bug at "
1233 "https://github.com/phillipberndt/autorandr/issues.".format(e),
1238 if __name__ == '__main__':
1239 exception_handled_main()