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:
93 class AutorandrException(Exception):
94 def __init__(self, message, original_exception=None, report_bug=False):
95 self.message = message
96 self.report_bug = report_bug
97 if original_exception:
98 self.original_exception = original_exception
99 trace = sys.exc_info()[2]
101 trace = trace.tb_next
102 self.line = trace.tb_lineno
103 self.file_name = trace.tb_frame.f_code.co_filename
107 frame = inspect.currentframe().f_back
108 self.line = frame.f_lineno
109 self.file_name = frame.f_code.co_filename
112 self.file_name = None
113 self.original_exception = None
115 if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
116 self.file_name = None
119 retval = [ self.message ]
121 retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
122 if self.original_exception:
123 retval.append(":\n ")
124 retval.append(str(self.original_exception).replace("\n", "\n "))
126 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
127 "\nhttps://github.com/phillipberndt/autorandr/issues"
128 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
129 return "".join(retval)
131 class XrandrOutput(object):
132 "Represents an XRandR output"
134 # This regular expression is used to parse an output in `xrandr --verbose'
135 XRANDR_OUTPUT_REGEXP = """(?x)
136 ^(?P<output>[^ ]+)\s+ # Line starts with output name
137 (?: # Differentiate disconnected and connected in first line
139 unknown\ connection |
140 (?P<connected>connected)
143 (?P<primary>primary\ )? # Might be primary screen
145 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
146 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
147 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
148 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
149 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
150 )? # .. but everything of the above only if the screen is in use.
151 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
152 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
153 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
154 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
155 (?:\s*(?: # Properties of the output
156 Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) | # Gamma value
157 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
158 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
159 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
163 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution: Extract rate
164 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
165 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
166 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
170 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
171 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
172 h:\s+width\s+(?P<width>[0-9]+).+\s+
173 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
176 XRANDR_13_DEFAULTS = {
177 "transform": "1,0,0,0,1,0,0,0,1",
181 XRANDR_12_DEFAULTS = {
184 "gamma": "1.0:1.0:1.0",
187 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
189 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
192 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
195 def short_edid(self):
196 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
199 def options_with_defaults(self):
200 "Return the options dictionary, augmented with the default values that weren't set"
201 if "off" in self.options:
204 if xrandr_version() >= Version("1.3"):
205 options.update(self.XRANDR_13_DEFAULTS)
206 if xrandr_version() >= Version("1.2"):
207 options.update(self.XRANDR_12_DEFAULTS)
208 options.update(self.options)
209 return { a: b for a, b in options.items() if a not in self.ignored_options }
212 def filtered_options(self):
213 "Return a dictionary of options without ignored options"
214 return { a: b for a, b in self.options.items() if a not in self.ignored_options }
217 def option_vector(self):
218 "Return the command line parameters for XRandR for this instance"
219 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()))], [])
222 def option_string(self):
223 "Return the command line parameters in the configuration file format"
224 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
228 "Return a key to sort the outputs for xrandr invocation"
231 if "off" in self.options:
233 if "pos" in self.options:
234 x, y = map(float, self.options["pos"].split("x"))
239 def __init__(self, output, edid, options):
240 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
243 self.options = options
244 self.ignored_options = []
245 self.remove_default_option_values()
247 def set_ignored_options(self, options):
248 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
249 self.ignored_options = list(options)
251 def remove_default_option_values(self):
252 "Remove values from the options dictionary that are superflous"
253 if "off" in self.options and len(self.options.keys()) > 1:
254 self.options = { "off": None }
256 for option, default_value in self.XRANDR_DEFAULTS.items():
257 if option in self.options and self.options[option] == default_value:
258 del self.options[option]
261 def from_xrandr_output(cls, xrandr_output):
262 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
264 This method also returns a list of modes supported by the output.
267 xrandr_output = xrandr_output.replace("\r\n", "\n")
268 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
270 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
272 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
273 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
274 remainder = xrandr_output[len(match_object.group(0)):]
276 raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
277 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
279 match = match_object.groupdict()
283 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
285 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
288 if not match["connected"]:
291 edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
293 if not match["width"]:
294 options["off"] = None
296 if match["mode_name"]:
297 options["mode"] = match["mode_name"]
298 elif match["mode_width"]:
299 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
301 if match["rotate"] not in ("left", "right"):
302 options["mode"] = "%sx%s" % (match["width"], match["height"])
304 options["mode"] = "%sx%s" % (match["height"], match["width"])
305 options["rotate"] = match["rotate"]
307 options["primary"] = None
308 if match["reflect"] == "X":
309 options["reflect"] = "x"
310 elif match["reflect"] == "Y":
311 options["reflect"] = "y"
312 elif match["reflect"] == "X and Y":
313 options["reflect"] = "xy"
314 options["pos"] = "%sx%s" % (match["x"], match["y"])
316 panning = [ match["panning"] ]
317 if match["tracking"]:
318 panning += [ "/", match["tracking"] ]
320 panning += [ "/", match["border"] ]
321 options["panning"] = "".join(panning)
322 if match["transform"]:
323 transformation = ",".join(match["transform"].strip().split())
324 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
325 options["transform"] = transformation
326 if not match["mode_name"]:
327 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
328 # special case is actually required.
329 print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
331 gamma = match["gamma"].strip()
332 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
333 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
334 # so we approximate by 1e-10.
335 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
336 options["gamma"] = gamma
338 options["rate"] = match["rate"]
340 return XrandrOutput(match["output"], edid, options), modes
343 def from_config_file(cls, edid_map, configuration):
344 "Instanciate an XrandrOutput from the contents of a configuration file"
346 for line in configuration.split("\n"):
348 line = line.split(None, 1)
349 if line and line[0].startswith("#"):
351 options[line[0]] = line[1] if len(line) > 1 else None
355 if options["output"] in edid_map:
356 edid = edid_map[options["output"]]
358 # This fuzzy matching is for legacy autorandr that used sysfs output names
359 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
360 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
361 if fuzzy_output in fuzzy_edid_map:
362 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
363 elif "off" not in options:
364 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' is not off in config file." % (options["output"], options["output"]))
365 output = options["output"]
366 del options["output"]
368 return XrandrOutput(output, edid, options)
370 def edid_equals(self, other):
371 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
372 if self.edid and other.edid:
373 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
374 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
375 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
376 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
377 return self.edid == other.edid
379 def __ne__(self, other):
380 return not (self == other)
382 def __eq__(self, other):
383 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
385 def verbose_diff(self, other):
386 "Compare to another XrandrOutput and return a list of human readable differences"
388 if not self.edid_equals(other):
389 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
390 if self.output != other.output:
391 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
392 if "off" in self.options and "off" not in other.options:
393 diffs.append("The output is disabled currently, but active in the new configuration")
394 elif "off" in other.options and "off" not in self.options:
395 diffs.append("The output is currently enabled, but inactive in the new configuration")
397 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
398 if name not in other.options:
399 diffs.append("Option --%s %sis not present in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
400 elif name not in self.options:
401 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" % (name, other.options[name]))
402 elif self.options[name] != other.options[name]:
403 diffs.append("Option --%s %sis `%s' in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
406 def xrandr_version():
407 "Return the version of XRandR that this system uses"
408 if getattr(xrandr_version, "version", False) is False:
409 version_string = os.popen("xrandr -v").read()
411 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
412 xrandr_version.version = Version(version)
413 except AttributeError:
414 xrandr_version.version = Version("1.3.0")
416 return xrandr_version.version
418 def debug_regexp(pattern, string):
419 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
422 bounds = ( 0, len(string) )
423 while bounds[0] != bounds[1]:
424 half = int((bounds[0] + bounds[1]) / 2)
425 if half == bounds[0]:
427 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
428 partial_length = bounds[0]
429 return ("Regular expression matched until position "
430 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
431 string[partial_length:partial_length+10]))
434 return "Debug information would be available if the `regex' module was installed."
436 def parse_xrandr_output():
437 "Parse the output of `xrandr --verbose' into a list of outputs"
438 xrandr_output = os.popen("xrandr -q --verbose").read()
439 if not xrandr_output:
440 raise AutorandrException("Failed to run xrandr")
442 # We are not interested in screens
443 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
445 # Split at output boundaries and instanciate an XrandrOutput per output
446 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
447 if len(split_xrandr_output) < 2:
448 raise AutorandrException("No output boundaries found", report_bug=True)
449 outputs = OrderedDict()
450 modes = OrderedDict()
451 for i in range(1, len(split_xrandr_output), 2):
452 output_name = split_xrandr_output[i].split()[0]
453 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
454 outputs[output_name] = output
456 modes[output_name] = output_modes
458 return outputs, modes
460 def load_profiles(profile_path):
461 "Load the stored profiles"
464 for profile in os.listdir(profile_path):
465 config_name = os.path.join(profile_path, profile, "config")
466 setup_name = os.path.join(profile_path, profile, "setup")
467 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
470 edids = dict([ x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#" ])
474 for line in chain(open(config_name).readlines(), ["output"]):
475 if line[:6] == "output" and buffer:
476 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
481 for output_name in list(config.keys()):
482 if config[output_name].edid is None:
483 del config[output_name]
485 profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
489 def get_symlinks(profile_path):
490 "Load all symlinks from a directory"
493 for link in os.listdir(profile_path):
494 file_name = os.path.join(profile_path, link)
495 if os.path.islink(file_name):
496 symlinks[link] = os.readlink(file_name)
500 def find_profiles(current_config, profiles):
501 "Find profiles matching the currently connected outputs"
502 detected_profiles = []
503 for profile_name, profile in profiles.items():
504 config = profile["config"]
506 for name, output in config.items():
509 if name not in current_config or not output.edid_equals(current_config[name]):
512 if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
515 detected_profiles.append(profile_name)
516 return detected_profiles
518 def profile_blocked(profile_path, meta_information=None):
519 """Check if a profile is blocked.
521 meta_information is expected to be an dictionary. It will be passed to the block scripts
522 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
524 return not exec_scripts(profile_path, "block", meta_information)
526 def output_configuration(configuration, config):
527 "Write a configuration file"
528 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
529 for output in outputs:
530 print(configuration[output].option_string, file=config)
532 def output_setup(configuration, setup):
533 "Write a setup (fingerprint) file"
534 outputs = sorted(configuration.keys())
535 for output in outputs:
536 if configuration[output].edid:
537 print(output, configuration[output].edid, file=setup)
539 def save_configuration(profile_path, configuration):
540 "Save a configuration into a profile"
541 if not os.path.isdir(profile_path):
542 os.makedirs(profile_path)
543 with open(os.path.join(profile_path, "config"), "w") as config:
544 output_configuration(configuration, config)
545 with open(os.path.join(profile_path, "setup"), "w") as setup:
546 output_setup(configuration, setup)
548 def update_mtime(filename):
549 "Update a file's mtime"
551 os.utime(filename, None)
556 def call_and_retry(*args, **kwargs):
557 """Wrapper around subprocess.call that retries failed calls.
559 This function calls subprocess.call and on non-zero exit states,
560 waits a second and then retries once. This mitigates #47,
561 a timing issue with some drivers.
563 if "dry_run" in kwargs:
564 dry_run = kwargs["dry_run"]
565 del kwargs["dry_run"]
568 kwargs_redirected = dict(kwargs)
570 if hasattr(subprocess, "DEVNULL"):
571 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
573 kwargs_redirected["stdout"] = open(os.devnull, "w")
574 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
575 retval = subprocess.call(*args, **kwargs_redirected)
578 retval = subprocess.call(*args, **kwargs)
581 def apply_configuration(new_configuration, current_configuration, dry_run=False):
582 "Apply a configuration"
583 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
585 base_argv = [ "echo", "xrandr" ]
587 base_argv = [ "xrandr" ]
589 # There are several xrandr / driver bugs we need to take care of here:
590 # - We cannot enable more than two screens at the same time
591 # See https://github.com/phillipberndt/autorandr/pull/6
592 # and commits f4cce4d and 8429886.
593 # - We cannot disable all screens
594 # See https://github.com/phillipberndt/autorandr/pull/20
595 # - We should disable screens before enabling others, because there's
596 # a limit on the number of enabled screens
597 # - We must make sure that the screen at 0x0 is activated first,
598 # or the other (first) screen to be activated would be moved there.
599 # - If an active screen already has a transformation and remains active,
600 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
601 # Update the configuration in 3 passes in that case. (On Haswell graphics,
603 # - Some implementations can not handle --transform at all, so avoid it unless
604 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
605 # - Some implementations can not handle --panning without specifying --fb
606 # explicitly, so avoid it unless necessary.
607 # (See https://github.com/phillipberndt/autorandr/issues/72)
609 auxiliary_changes_pre = []
612 remain_active_count = 0
613 for output in outputs:
614 if not new_configuration[output].edid or "off" in new_configuration[output].options:
615 disable_outputs.append(new_configuration[output].option_vector)
617 if "off" not in current_configuration[output].options:
618 remain_active_count += 1
620 option_vector = new_configuration[output].option_vector
621 if xrandr_version() >= Version("1.3.0"):
622 for option in ("transform", "panning"):
623 if option in current_configuration[output].options:
624 auxiliary_changes_pre.append(["--output", output, "--%s" % option, "none"])
627 option_index = option_vector.index("--%s" % option)
628 if option_vector[option_index+1] == XrandrOutput.XRANDR_DEFAULTS[option]:
629 option_vector = option_vector[:option_index] + option_vector[option_index+2:]
633 enable_outputs.append(option_vector)
635 # Perform pe-change auxiliary changes
636 if auxiliary_changes_pre:
637 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
638 if call_and_retry(argv, dry_run=dry_run) != 0:
639 raise AutorandrException("Command failed: %s" % " ".join(argv))
641 # Disable unused outputs, but make sure that there always is at least one active screen
642 disable_keep = 0 if remain_active_count else 1
643 if len(disable_outputs) > disable_keep:
644 if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs)), dry_run=dry_run) != 0:
645 # Disabling the outputs failed. Retry with the next command:
646 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
647 # This does not occur if simultaneously the primary screen is reset.
650 disable_outputs = disable_outputs[-1:] if disable_keep else []
652 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
653 # disable the last two screens. This is a problem, so if this would happen, instead disable only
654 # one screen in the first call below.
655 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
656 # In the context of a xrandr call that changes the display state, `--query' should do nothing
657 disable_outputs.insert(0, ['--query'])
659 # Enable the remaining outputs in pairs of two operations
660 operations = disable_outputs + enable_outputs
661 for index in range(0, len(operations), 2):
662 argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
663 if call_and_retry(argv, dry_run=dry_run) != 0:
664 raise AutorandrException("Command failed: %s" % " ".join(argv))
666 def is_equal_configuration(source_configuration, target_configuration):
667 "Check if all outputs from target are already configured correctly in source"
668 for output in target_configuration.keys():
669 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
673 def add_unused_outputs(source_configuration, target_configuration):
674 "Add outputs that are missing in target to target, in 'off' state"
675 for output_name, output in source_configuration.items():
676 if output_name not in target_configuration:
677 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
679 def remove_irrelevant_outputs(source_configuration, target_configuration):
680 "Remove outputs from target that ought to be 'off' and already are"
681 for output_name, output in source_configuration.items():
682 if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
683 del target_configuration[output_name]
685 def generate_virtual_profile(configuration, modes, profile_name):
686 "Generate one of the virtual profiles"
687 configuration = copy.deepcopy(configuration)
688 if profile_name == "common":
689 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
690 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
691 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
692 if common_resolution:
693 for output in configuration:
694 configuration[output].options = {}
695 if output in modes and configuration[output].edid:
696 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]
697 configuration[output].options["pos"] = "0x0"
699 configuration[output].options["off"] = None
700 elif profile_name in ("horizontal", "vertical"):
702 if profile_name == "horizontal":
703 shift_index = "width"
704 pos_specifier = "%sx0"
706 shift_index = "height"
707 pos_specifier = "0x%s"
709 for output in configuration:
710 configuration[output].options = {}
711 if output in modes and configuration[output].edid:
712 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
713 configuration[output].options["mode"] = mode["name"]
714 configuration[output].options["rate"] = mode["rate"]
715 configuration[output].options["pos"] = pos_specifier % shift
716 shift += int(mode[shift_index])
718 configuration[output].options["off"] = None
719 elif profile_name == "clone-largest":
720 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]
721 for output in configuration:
722 configuration[output].options = {}
723 if output in modes and configuration[output].edid:
724 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
725 configuration[output].options["mode"] = mode["name"]
726 configuration[output].options["rate"] = mode["rate"]
727 configuration[output].options["pos"] = "0x0"
728 scale = max(float(biggest_resolution["width"]) / float(mode["width"]) ,float(biggest_resolution["height"]) / float(mode["height"]))
729 mov_x = (float(mode["width"])*scale-float(biggest_resolution["width"]))/-2
730 mov_y = (float(mode["height"])*scale-float(biggest_resolution["height"]))/-2
731 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
733 configuration[output].options["off"] = None
736 def print_profile_differences(one, another):
737 "Print the differences between two profiles for debugging"
740 print("| Differences between the two profiles:", file=sys.stderr)
741 for output in set(chain.from_iterable((one.keys(), another.keys()))):
742 if output not in one:
743 if "off" not in another[output].options:
744 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
745 elif output not in another:
746 if "off" not in one[output].options:
747 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
749 for line in one[output].verbose_diff(another[output]):
750 print("| [Output %s] %s" % (output, line), file=sys.stderr)
751 print ("\\-", file=sys.stderr)
754 "Print help and exit"
756 for profile in virtual_profiles:
757 name, description = profile[:2]
758 description = [ description ]
760 while len(description[0]) > max_width + 1:
761 left_over = description[0][max_width:]
762 description[0] = description[0][:max_width] + "-"
763 description.insert(1, " %-15s %s" % ("", left_over))
764 description = "\n".join(description)
765 print(" %-15s %s" % (name, description))
768 def exec_scripts(profile_path, script_name, meta_information=None):
771 This will run all executables from the profile folder, and global per-user
772 and system-wide configuration folders, named script_name or residing in
773 subdirectories named script_name.d.
775 If profile_path is None, only global scripts will be invoked.
777 meta_information is expected to be an dictionary. It will be passed to the block scripts
778 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
780 Returns True unless any of the scripts exited with non-zero exit status.
784 env = os.environ.copy()
785 env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
787 env = os.environ.copy()
789 # If there are multiple candidates, the XDG spec tells to only use the first one.
792 user_profile_path = os.path.expanduser("~/.autorandr")
793 if not os.path.isdir(user_profile_path):
794 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
796 candidate_directories = chain((user_profile_path,), (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")))
798 candidate_directories = chain((profile_path,), candidate_directories)
800 for folder in candidate_directories:
802 if script_name not in ran_scripts:
803 script = os.path.join(folder, script_name)
804 if os.access(script, os.X_OK | os.F_OK):
806 all_ok &= subprocess.call(script, env=env) != 0
808 raise AutorandrException("Failed to execute user command: %s" % (script,))
809 ran_scripts.add(script_name)
811 script_folder = os.path.join(folder, "%s.d" % script_name)
812 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
813 for file_name in os.listdir(script_folder):
814 check_name = "d/%s" % (file_name,)
815 if check_name not in ran_scripts:
816 script = os.path.join(script_folder, file_name)
817 if os.access(script, os.X_OK | os.F_OK):
819 all_ok &= subprocess.call(script, env=env) != 0
821 raise AutorandrException("Failed to execute user command: %s" % (script,))
822 ran_scripts.add(check_name)
826 def dispatch_call_to_sessions(argv):
827 """Invoke autorandr for each open local X11 session with the given options.
829 The function iterates over all processes not owned by root and checks
830 whether they have a DISPLAY variable set. It strips the screen from any
831 variable it finds (i.e. :0.0 becomes :0) and checks whether this display
832 has been handled already. If it has not, it forks, changes uid/gid to
833 the user owning the process, reuses the process's environment and runs
834 autorandr with the parameters from argv.
836 This function requires root permissions. It only works for X11 servers that
837 have at least one non-root process running. It is susceptible for attacks
838 where one user runs a process with another user's DISPLAY variable - in
839 this case, it might happen that autorandr is invoked for the other user,
840 which won't work. Since no other harm than prevention of automated
841 execution of autorandr can be done this way, the assumption is that in this
842 situation, the local administrator will handle the situation."""
843 X11_displays_done = set()
845 autorandr_binary = os.path.abspath(argv[0])
847 for directory in os.listdir("/proc"):
848 directory = os.path.join("/proc/", directory)
849 if not os.path.isdir(directory):
851 environ_file = os.path.join(directory, "environ")
852 if not os.path.isfile(environ_file):
854 uid = os.stat(environ_file).st_uid
856 # The following line assumes that user accounts start at 1000 and that
857 # no one works using the root or another system account. This is rather
858 # restrictive, but de facto default. Alternatives would be to use the
859 # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
860 # but effectively, both values aren't binding in any way.
861 # If this breaks your use case, please file a bug on Github.
866 for environ_entry in open(environ_file).read().split("\0"):
867 if "=" in environ_entry:
868 name, value = environ_entry.split("=", 1)
869 if name == "DISPLAY" and "." in value:
870 value = value[:value.find(".")]
871 process_environ[name] = value
872 display = process_environ["DISPLAY"] if "DISPLAY" in process_environ else None
874 # To allow scripts to detect batch invocation (especially useful for predetect)
875 process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
877 if display and display not in X11_displays_done:
879 pwent = pwd.getpwuid(uid)
881 # User has no pwd entry
884 print("Running autorandr as %s for display %s" % (pwent.pw_name, 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 X11_displays_done.add(display)
905 options = dict(getopt.getopt(argv[1:], "s:r:l:d:cfh", [ "batch", "dry-run", "change", "default=", "save=", "remove=", "load=", "force", "fingerprint", "config", "debug", "skip-options=", "help" ])[0])
906 except getopt.GetoptError as e:
907 print("Failed to parse options: {0}.\n"
908 "Use --help to get usage information.".format(str(e)),
910 sys.exit(posix.EX_USAGE)
912 if "-h" in options or "--help" in options:
916 if "--batch" in options:
917 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
918 dispatch_call_to_sessions([ x for x in argv if x != "--batch" ])
920 print("--batch mode can only be used by root and if $DISPLAY is unset")
924 profile_symlinks = {}
926 # Load profiles from each XDG config directory
927 # The XDG spec says that earlier entries should take precedence, so reverse the order
928 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
929 system_profile_path = os.path.join(directory, "autorandr")
930 if os.path.isdir(system_profile_path):
931 profiles.update(load_profiles(system_profile_path))
932 profile_symlinks.update(get_symlinks(system_profile_path))
933 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
934 # profile_path is also used later on to store configurations
935 profile_path = os.path.expanduser("~/.autorandr")
936 if not os.path.isdir(profile_path):
937 # Elsewise, follow the XDG specification
938 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
939 if os.path.isdir(profile_path):
940 profiles.update(load_profiles(profile_path))
941 profile_symlinks.update(get_symlinks(profile_path))
942 # Sort by descending mtime
943 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
944 except Exception as e:
945 raise AutorandrException("Failed to load profiles", e)
947 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 }
949 exec_scripts(None, "predetect")
950 config, modes = parse_xrandr_output()
952 if "--fingerprint" in options:
953 output_setup(config, sys.stdout)
956 if "--config" in options:
957 output_configuration(config, sys.stdout)
960 if "--skip-options" in options:
961 skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
962 for profile in profiles.values():
963 for output in profile["config"].values():
964 output.set_ignored_options(skip_options)
965 for output in config.values():
966 output.set_ignored_options(skip_options)
969 options["--save"] = options["-s"]
970 if "--save" in options:
971 if options["--save"] in ( x[0] for x in virtual_profiles ):
972 raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
974 profile_folder = os.path.join(profile_path, options["--save"])
975 save_configuration(profile_folder, config)
976 exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
977 except Exception as e:
978 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
979 print("Saved current configuration as profile '%s'" % options["--save"])
983 options["--remove"] = options["-r"]
984 if "--remove" in options:
985 if options["--remove"] in ( x[0] for x in virtual_profiles ):
986 raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
987 if options["--remove"] not in profiles.keys():
988 raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
991 profile_folder = os.path.join(profile_path, options["--remove"])
992 profile_dirlist = os.listdir(profile_folder)
993 profile_dirlist.remove("config")
994 profile_dirlist.remove("setup")
996 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
997 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
998 if response != "yes":
1001 shutil.rmtree(profile_folder)
1002 print("Removed profile '%s'" % options["--remove"])
1004 print("Profile '%s' was not removed" % options["--remove"])
1005 except Exception as e:
1006 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1009 detected_profiles = find_profiles(config, profiles)
1010 load_profile = False
1013 options["--load"] = options["-l"]
1014 if "--load" in options:
1015 load_profile = options["--load"]
1017 # Find the active profile(s) first, for the block script (See #42)
1018 current_profiles = []
1019 for profile_name in profiles.keys():
1020 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1021 if configs_are_equal:
1022 current_profiles.append(profile_name)
1023 block_script_metadata = {
1024 "CURRENT_PROFILE": "".join(current_profiles[:1]),
1025 "CURRENT_PROFILES": ":".join(current_profiles)
1028 for profile_name in profiles.keys():
1029 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1030 print("%s (blocked)" % profile_name, file=sys.stderr)
1033 if profile_name in detected_profiles:
1034 props.append("(detected)")
1035 if ("-c" in options or "--change" in options) and not load_profile:
1036 load_profile = profile_name
1037 if profile_name in current_profiles:
1038 props.append("(current)")
1039 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
1040 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1041 print_profile_differences(config, profiles[profile_name]["config"])
1044 options["--default"] = options["-d"]
1045 if not load_profile and "--default" in options:
1046 load_profile = options["--default"]
1049 if load_profile in profile_symlinks:
1050 if "--debug" in options:
1051 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1052 load_profile = profile_symlinks[load_profile]
1054 if load_profile in ( x[0] for x in virtual_profiles ):
1055 load_config = generate_virtual_profile(config, modes, load_profile)
1056 scripts_path = os.path.join(profile_path, load_profile)
1059 profile = profiles[load_profile]
1060 load_config = profile["config"]
1061 scripts_path = profile["path"]
1063 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1064 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1065 update_mtime(os.path.join(scripts_path, "config"))
1066 add_unused_outputs(config, load_config)
1067 if load_config == dict(config) and not "-f" in options and not "--force" in options:
1068 print("Config already loaded", file=sys.stderr)
1070 if "--debug" in options and load_config != dict(config):
1071 print("Loading profile '%s'" % load_profile)
1072 print_profile_differences(config, load_config)
1074 remove_irrelevant_outputs(config, load_config)
1077 if "--dry-run" in options:
1078 apply_configuration(load_config, config, True)
1081 "CURRENT_PROFILE": load_profile,
1082 "PROFILE_FOLDER": scripts_path,
1084 exec_scripts(scripts_path, "preswitch", script_metadata)
1085 if "--debug" in options:
1086 print("Going to run:")
1087 apply_configuration(load_config, config, True)
1088 apply_configuration(load_config, config, False)
1089 exec_scripts(scripts_path, "postswitch", script_metadata)
1090 except AutorandrException as e:
1091 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1092 except Exception as e:
1093 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1095 if "--dry-run" not in options and "--debug" in options:
1096 new_config, _ = parse_xrandr_output()
1097 if not is_equal_configuration(new_config, load_config):
1098 print("The configuration change did not go as expected:")
1099 print_profile_differences(new_config, load_config)
1103 def exception_handled_main(argv=sys.argv):
1106 except AutorandrException as e:
1107 print(e, file=sys.stderr)
1109 except Exception as e:
1110 if not len(str(e)): # BdbQuit
1111 print("Exception: {0}".format(e.__class__.__name__))
1114 print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)
1117 if __name__ == '__main__':
1118 exception_handled_main()