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 ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
54 ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
58 Usage: autorandr [options]
60 -h, --help get this small help
61 -c, --change reload current setup
62 -s, --save <profile> save your current setup to profile <profile>
63 -r, --remove <profile> remove profile <profile>
64 -l, --load <profile> load profile <profile>
65 -d, --default <profile> make profile <profile> the default profile
66 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
67 to skip both in detecting changes and applying a profile
68 --force force (re)loading of a profile
69 --fingerprint fingerprint your current hardware setup
70 --config dump your current xrandr setup
71 --dry-run don't change anything, only print the xrandr commands
72 --debug enable verbose output
73 --batch run autorandr for all users with active X11 sessions
75 To prevent a profile from being loaded, place a script call "block" in its
76 directory. The script is evaluated before the screen setup is inspected, and
77 in case of it returning a value of 0 the profile is skipped. This can be used
78 to query the status of a docking station you are about to leave.
80 If no suitable profile can be identified, the current configuration is kept.
81 To change this behaviour and switch to a fallback configuration, specify
84 Another script called "postswitch" can be placed in the directory
85 ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
86 as in any profile directories: The scripts are executed after a mode switch
87 has taken place and can notify window managers.
89 The following virtual configurations are available:
92 class AutorandrException(Exception):
93 def __init__(self, message, original_exception=None, report_bug=False):
94 self.message = message
95 self.report_bug = report_bug
96 if original_exception:
97 self.original_exception = original_exception
98 trace = sys.exc_info()[2]
100 trace = trace.tb_next
101 self.line = trace.tb_lineno
105 self.line = inspect.currentframe().f_back.f_lineno
108 self.original_exception = None
111 retval = [ self.message ]
113 retval.append(" (line %d)" % self.line)
114 if self.original_exception:
115 retval.append(":\n ")
116 retval.append(str(self.original_exception).replace("\n", "\n "))
118 retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
119 "\nhttps://github.com/phillipberndt/autorandr/issues"
120 "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
121 return "".join(retval)
123 class XrandrOutput(object):
124 "Represents an XRandR output"
126 # This regular expression is used to parse an output in `xrandr --verbose'
127 XRANDR_OUTPUT_REGEXP = """(?x)
128 ^(?P<output>[^ ]+)\s+ # Line starts with output name
129 (?: # Differentiate disconnected and connected in first line
131 unknown\ connection |
132 (?P<connected>connected)
135 (?P<primary>primary\ )? # Might be primary screen
137 (?P<width>[0-9]+)x(?P<height>[0-9]+) # Resolution (might be overridden below!)
138 \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+ # Position
139 (?:\(0x[0-9a-fA-F]+\)\s+)? # XID
140 (?P<rotate>(?:normal|left|right|inverted))\s+ # Rotation
141 (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)? # Reflection
142 )? # .. but everything of the above only if the screen is in use.
143 (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
144 (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Panning information
145 (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))? # Tracking information
146 (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))? # Border information
147 (?:\s*(?: # Properties of the output
148 Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) | # Gamma value
149 Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) | # Transformation matrix
150 EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) | # EDID of the output
151 (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)* # Other properties
155 (?P<mode_name>\S+).+?\*current.*\s+ # Interesting (current) resolution: Extract rate
156 h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
157 v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
158 \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s* # Other resolutions
162 XRANDR_OUTPUT_MODES_REGEXP = """(?x)
163 (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
164 h:\s+width\s+(?P<width>[0-9]+).+\s+
165 v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
168 XRANDR_13_DEFAULTS = {
169 "transform": "1,0,0,0,1,0,0,0,1",
173 XRANDR_12_DEFAULTS = {
176 "gamma": "1.0:1.0:1.0",
179 XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
181 EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
184 return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
187 def short_edid(self):
188 return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
191 def options_with_defaults(self):
192 "Return the options dictionary, augmented with the default values that weren't set"
193 if "off" in self.options:
196 if xrandr_version() >= Version("1.3"):
197 options.update(self.XRANDR_13_DEFAULTS)
198 if xrandr_version() >= Version("1.2"):
199 options.update(self.XRANDR_12_DEFAULTS)
200 options.update(self.options)
201 return { a: b for a, b in options.items() if a not in self.ignored_options }
204 def filtered_options(self):
205 "Return a dictionary of options without ignored options"
206 return { a: b for a, b in self.options.items() if a not in self.ignored_options }
209 def option_vector(self):
210 "Return the command line parameters for XRandR for this instance"
211 return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), sorted(self.options_with_defaults.items()))], [])
214 def option_string(self):
215 "Return the command line parameters in the configuration file format"
216 return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
220 "Return a key to sort the outputs for xrandr invocation"
223 if "off" in self.options:
225 if "pos" in self.options:
226 x, y = map(float, self.options["pos"].split("x"))
231 def __init__(self, output, edid, options):
232 "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
235 self.options = options
236 self.ignored_options = []
237 self.remove_default_option_values()
239 def set_ignored_options(self, options):
240 "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
241 self.ignored_options = list(options)
243 def remove_default_option_values(self):
244 "Remove values from the options dictionary that are superflous"
245 if "off" in self.options and len(self.options.keys()) > 1:
246 self.options = { "off": None }
248 for option, default_value in self.XRANDR_DEFAULTS.items():
249 if option in self.options and self.options[option] == default_value:
250 del self.options[option]
253 def from_xrandr_output(cls, xrandr_output):
254 """Instanciate an XrandrOutput from the output of `xrandr --verbose'
256 This method also returns a list of modes supported by the output.
259 xrandr_output = xrandr_output.replace("\r\n", "\n")
260 match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
262 raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
264 debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
265 raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
266 remainder = xrandr_output[len(match_object.group(0)):]
268 raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
269 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
271 match = match_object.groupdict()
275 modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
277 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
280 if not match["connected"]:
283 edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
285 if not match["width"]:
286 options["off"] = None
288 if match["mode_name"]:
289 options["mode"] = match["mode_name"]
290 elif match["mode_width"]:
291 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
293 if match["rotate"] not in ("left", "right"):
294 options["mode"] = "%sx%s" % (match["width"], match["height"])
296 options["mode"] = "%sx%s" % (match["height"], match["width"])
297 options["rotate"] = match["rotate"]
299 options["primary"] = None
300 if match["reflect"] == "X":
301 options["reflect"] = "x"
302 elif match["reflect"] == "Y":
303 options["reflect"] = "y"
304 elif match["reflect"] == "X and Y":
305 options["reflect"] = "xy"
306 options["pos"] = "%sx%s" % (match["x"], match["y"])
308 panning = [ match["panning"] ]
309 if match["tracking"]:
310 panning += [ "/", match["tracking"] ]
312 panning += [ "/", match["border"] ]
313 options["panning"] = "".join(panning)
314 if match["transform"]:
315 transformation = ",".join(match["transform"].strip().split())
316 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
317 options["transform"] = transformation
318 if not match["mode_name"]:
319 # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
320 # special case is actually required.
321 print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
323 gamma = match["gamma"].strip()
324 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
325 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
326 # so we approximate by 1e-10.
327 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
328 options["gamma"] = gamma
330 options["rate"] = match["rate"]
332 return XrandrOutput(match["output"], edid, options), modes
335 def from_config_file(cls, edid_map, configuration):
336 "Instanciate an XrandrOutput from the contents of a configuration file"
338 for line in configuration.split("\n"):
340 line = line.split(None, 1)
341 options[line[0]] = line[1] if len(line) > 1 else None
345 if options["output"] in edid_map:
346 edid = edid_map[options["output"]]
348 # This fuzzy matching is for legacy autorandr that used sysfs output names
349 fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
350 fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
351 if fuzzy_output in fuzzy_edid_map:
352 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
353 elif "off" not in options:
354 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' is not off in config file." % (options["output"], options["output"]))
355 output = options["output"]
356 del options["output"]
358 return XrandrOutput(output, edid, options)
360 def edid_equals(self, other):
361 "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
362 if self.edid and other.edid:
363 if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
364 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
365 if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
366 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
367 return self.edid == other.edid
369 def __ne__(self, other):
370 return not (self == other)
372 def __eq__(self, other):
373 return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
375 def verbose_diff(self, other):
376 "Compare to another XrandrOutput and return a list of human readable differences"
378 if not self.edid_equals(other):
379 diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
380 if self.output != other.output:
381 diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
382 if "off" in self.options and "off" not in other.options:
383 diffs.append("The output is disabled currently, but active in the new configuration")
384 elif "off" in other.options and "off" not in self.options:
385 diffs.append("The output is currently enabled, but inactive in the new configuration")
387 for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
388 if name not in other.options:
389 diffs.append("Option --%s %sis not present in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
390 elif name not in self.options:
391 diffs.append("Option --%s (`%s' in the new configuration) is not present currently" % (name, other.options[name]))
392 elif self.options[name] != other.options[name]:
393 diffs.append("Option --%s %sis `%s' in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
396 def xrandr_version():
397 "Return the version of XRandR that this system uses"
398 if getattr(xrandr_version, "version", False) is False:
399 version_string = os.popen("xrandr -v").read()
401 version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
402 xrandr_version.version = Version(version)
403 except AttributeError:
404 xrandr_version.version = Version("1.3.0")
406 return xrandr_version.version
408 def debug_regexp(pattern, string):
409 "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
412 bounds = ( 0, len(string) )
413 while bounds[0] != bounds[1]:
414 half = int((bounds[0] + bounds[1]) / 2)
415 if half == bounds[0]:
417 bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
418 partial_length = bounds[0]
419 return ("Regular expression matched until position "
420 "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
421 string[partial_length:partial_length+10]))
424 return "Debug information would be available if the `regex' module was installed."
426 def parse_xrandr_output():
427 "Parse the output of `xrandr --verbose' into a list of outputs"
428 xrandr_output = os.popen("xrandr -q --verbose").read()
429 if not xrandr_output:
430 raise AutorandrException("Failed to run xrandr")
432 # We are not interested in screens
433 xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
435 # Split at output boundaries and instanciate an XrandrOutput per output
436 split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
437 if len(split_xrandr_output) < 2:
438 raise AutorandrException("No output boundaries found", report_bug=True)
439 outputs = OrderedDict()
440 modes = OrderedDict()
441 for i in range(1, len(split_xrandr_output), 2):
442 output_name = split_xrandr_output[i].split()[0]
443 output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
444 outputs[output_name] = output
446 modes[output_name] = output_modes
448 return outputs, modes
450 def load_profiles(profile_path):
451 "Load the stored profiles"
454 for profile in os.listdir(profile_path):
455 config_name = os.path.join(profile_path, profile, "config")
456 setup_name = os.path.join(profile_path, profile, "setup")
457 if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
460 edids = dict([ x.strip().split() for x in open(setup_name).readlines() if x.strip() ])
464 for line in chain(open(config_name).readlines(), ["output"]):
465 if line[:6] == "output" and buffer:
466 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
471 for output_name in list(config.keys()):
472 if config[output_name].edid is None:
473 del config[output_name]
475 profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
479 def find_profiles(current_config, profiles):
480 "Find profiles matching the currently connected outputs"
481 detected_profiles = []
482 for profile_name, profile in profiles.items():
483 config = profile["config"]
485 for name, output in config.items():
488 if name not in current_config or not output.edid_equals(current_config[name]):
491 if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
494 detected_profiles.append(profile_name)
495 return detected_profiles
497 def profile_blocked(profile_path, meta_information=None):
498 """Check if a profile is blocked.
500 meta_information is expected to be an dictionary. It will be passed to the block scripts
501 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
503 return not exec_scripts(profile_path, "block", meta_information)
505 def output_configuration(configuration, config):
506 "Write a configuration file"
507 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
508 for output in outputs:
509 print(configuration[output].option_string, file=config)
511 def output_setup(configuration, setup):
512 "Write a setup (fingerprint) file"
513 outputs = sorted(configuration.keys())
514 for output in outputs:
515 if configuration[output].edid:
516 print(output, configuration[output].edid, file=setup)
518 def save_configuration(profile_path, configuration):
519 "Save a configuration into a profile"
520 if not os.path.isdir(profile_path):
521 os.makedirs(profile_path)
522 with open(os.path.join(profile_path, "config"), "w") as config:
523 output_configuration(configuration, config)
524 with open(os.path.join(profile_path, "setup"), "w") as setup:
525 output_setup(configuration, setup)
527 def update_mtime(filename):
528 "Update a file's mtime"
530 os.utime(filename, None)
535 def call_and_retry(*args, **kwargs):
536 """Wrapper around subprocess.call that retries failed calls.
538 This function calls subprocess.call and on non-zero exit states,
539 waits a second and then retries once. This mitigates #47,
540 a timing issue with some drivers.
542 kwargs_redirected = dict(kwargs)
543 if hasattr(subprocess, "DEVNULL"):
544 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
546 kwargs_redirected["stdout"] = open(os.devnull, "w")
547 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
548 retval = subprocess.call(*args, **kwargs_redirected)
551 retval = subprocess.call(*args, **kwargs)
554 def apply_configuration(new_configuration, current_configuration, dry_run=False):
555 "Apply a configuration"
556 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
558 base_argv = [ "echo", "xrandr" ]
560 base_argv = [ "xrandr" ]
562 # There are several xrandr / driver bugs we need to take care of here:
563 # - We cannot enable more than two screens at the same time
564 # See https://github.com/phillipberndt/autorandr/pull/6
565 # and commits f4cce4d and 8429886.
566 # - We cannot disable all screens
567 # See https://github.com/phillipberndt/autorandr/pull/20
568 # - We should disable screens before enabling others, because there's
569 # a limit on the number of enabled screens
570 # - We must make sure that the screen at 0x0 is activated first,
571 # or the other (first) screen to be activated would be moved there.
572 # - If an active screen already has a transformation and remains active,
573 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
574 # Update the configuration in 3 passes in that case. (On Haswell graphics,
576 # - Some implementations can not handle --transform at all, so avoid it unless
577 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
579 auxiliary_changes_pre = []
582 remain_active_count = 0
583 for output in outputs:
584 if not new_configuration[output].edid or "off" in new_configuration[output].options:
585 disable_outputs.append(new_configuration[output].option_vector)
587 if "off" not in current_configuration[output].options:
588 remain_active_count += 1
590 option_vector = new_configuration[output].option_vector
591 if xrandr_version() >= Version("1.3.0"):
592 if "transform" in current_configuration[output].options:
593 auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
596 transform_index = option_vector.index("--transform")
597 if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
598 option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
602 enable_outputs.append(option_vector)
604 # Perform pe-change auxiliary changes
605 if auxiliary_changes_pre:
606 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
607 if call_and_retry(argv) != 0:
608 raise AutorandrException("Command failed: %s" % " ".join(argv))
610 # Disable unused outputs, but make sure that there always is at least one active screen
611 disable_keep = 0 if remain_active_count else 1
612 if len(disable_outputs) > disable_keep:
613 if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
614 # Disabling the outputs failed. Retry with the next command:
615 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
616 # This does not occur if simultaneously the primary screen is reset.
619 disable_outputs = disable_outputs[-1:] if disable_keep else []
621 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
622 # disable the last two screens. This is a problem, so if this would happen, instead disable only
623 # one screen in the first call below.
624 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
625 # In the context of a xrandr call that changes the display state, `--query' should do nothing
626 disable_outputs.insert(0, ['--query'])
628 # Enable the remaining outputs in pairs of two operations
629 operations = disable_outputs + enable_outputs
630 for index in range(0, len(operations), 2):
631 argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
632 if call_and_retry(argv) != 0:
633 raise AutorandrException("Command failed: %s" % " ".join(argv))
635 def is_equal_configuration(source_configuration, target_configuration):
636 "Check if all outputs from target are already configured correctly in source"
637 for output in target_configuration.keys():
638 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
642 def add_unused_outputs(source_configuration, target_configuration):
643 "Add outputs that are missing in target to target, in 'off' state"
644 for output_name, output in source_configuration.items():
645 if output_name not in target_configuration:
646 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
648 def remove_irrelevant_outputs(source_configuration, target_configuration):
649 "Remove outputs from target that ought to be 'off' and already are"
650 for output_name, output in source_configuration.items():
651 if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
652 del target_configuration[output_name]
654 def generate_virtual_profile(configuration, modes, profile_name):
655 "Generate one of the virtual profiles"
656 configuration = copy.deepcopy(configuration)
657 if profile_name == "common":
658 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
659 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
660 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
661 if common_resolution:
662 for output in configuration:
663 configuration[output].options = {}
664 if output in modes and configuration[output].edid:
665 configuration[output].options["mode"] = [ x["name"] for x in sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1) if x["width"] == common_resolution[-1][0] and x["height"] == common_resolution[-1][1] ][0]
666 configuration[output].options["pos"] = "0x0"
668 configuration[output].options["off"] = None
669 elif profile_name in ("horizontal", "vertical"):
671 if profile_name == "horizontal":
672 shift_index = "width"
673 pos_specifier = "%sx0"
675 shift_index = "height"
676 pos_specifier = "0x%s"
678 for output in configuration:
679 configuration[output].options = {}
680 if output in modes and configuration[output].edid:
681 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
682 configuration[output].options["mode"] = mode["name"]
683 configuration[output].options["rate"] = mode["rate"]
684 configuration[output].options["pos"] = pos_specifier % shift
685 shift += int(mode[shift_index])
687 configuration[output].options["off"] = None
690 def print_profile_differences(one, another):
691 "Print the differences between two profiles for debugging"
694 print("| Differences between the two profiles:", file=sys.stderr)
695 for output in set(chain.from_iterable((one.keys(), another.keys()))):
696 if output not in one:
697 if "off" not in another[output].options:
698 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
699 elif output not in another:
700 if "off" not in one[output].options:
701 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
703 for line in one[output].verbose_diff(another[output]):
704 print("| [Output %s] %s" % (output, line), file=sys.stderr)
705 print ("\\-", file=sys.stderr)
708 "Print help and exit"
710 for profile in virtual_profiles:
711 print(" %-10s %s" % profile[:2])
714 def exec_scripts(profile_path, script_name, meta_information=None):
717 This will run all executables from the profile folder, and global per-user
718 and system-wide configuration folders, named script_name or residing in
719 subdirectories named script_name.d.
721 meta_information is expected to be an dictionary. It will be passed to the block scripts
722 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
724 Returns True unless any of the scripts exited with non-zero exit status.
728 env = os.environ.copy()
729 env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
731 env = os.environ.copy()
733 # If there are multiple candidates, the XDG spec tells to only use the first one.
736 user_profile_path = os.path.expanduser("~/.autorandr")
737 if not os.path.isdir(user_profile_path):
738 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
740 for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
741 (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"))):
743 if script_name not in ran_scripts:
744 script = os.path.join(folder, script_name)
745 if os.access(script, os.X_OK | os.F_OK):
746 all_ok &= subprocess.call(script, env=env) != 0
747 ran_scripts.add(script_name)
749 script_folder = os.path.join(folder, "%s.d" % script_name)
750 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
751 for file_name in os.listdir(script_folder):
752 check_name = "d/%s" % (file_name,)
753 if check_name not in ran_scripts:
754 script = os.path.join(script_folder, file_name)
755 if os.access(script, os.X_OK | os.F_OK):
756 all_ok &= subprocess.call(script, env=env) != 0
757 ran_scripts.add(check_name)
761 def dispatch_call_to_sessions(argv):
762 """Invoke autorandr for each open local X11 session with the given options.
764 The function iterates over all processes not owned by root and checks
765 whether they have a DISPLAY variable set. It strips the screen from any
766 variable it finds (i.e. :0.0 becomes :0) and checks whether this display
767 has been handled already. If it has not, it forks, changes uid/gid to
768 the user owning the process, reuses the process's environment and runs
769 autorandr with the parameters from argv.
771 This function requires root permissions. It only works for X11 servers that
772 have at least one non-root process running. It is susceptible for attacks
773 where one user runs a process with another user's DISPLAY variable - in
774 this case, it might happen that autorandr is invoked for the other user,
775 which won't work. Since no other harm than prevention of automated
776 execution of autorandr can be done this way, the assumption is that in this
777 situation, the local administrator will handle the situation."""
778 X11_displays_done = set()
780 autorandr_binary = os.path.abspath(argv[0])
782 for directory in os.listdir("/proc"):
783 directory = os.path.join("/proc/", directory)
784 if not os.path.isdir(directory):
786 environ_file = os.path.join(directory, "environ")
787 if not os.path.isfile(environ_file):
789 uid = os.stat(environ_file).st_uid
794 for environ_entry in open(environ_file).read().split("\0"):
795 if "=" in environ_entry:
796 name, value = environ_entry.split("=", 1)
797 if name == "DISPLAY" and "." in value:
798 value = value[:value.find(".")]
799 process_environ[name] = value
800 display = process_environ["DISPLAY"] if "DISPLAY" in process_environ else None
802 if display and display not in X11_displays_done:
804 pwent = pwd.getpwuid(uid)
806 # User has no pwd entry
809 print("Running autorandr as %s for display %s" % (pwent.pw_name, display))
810 child_pid = os.fork()
812 # This will throw an exception if any of the privilege changes fails,
813 # so it should be safe. Also, note that since the environment
814 # is taken from a process owned by the user, reusing it should
815 # not leak any information.
817 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
818 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
819 os.chdir(pwent.pw_dir)
821 os.environ.update(process_environ)
822 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
824 os.waitpid(child_pid, 0)
826 X11_displays_done.add(display)
830 options = dict(getopt.getopt(argv[1:], "s:r:l:d:cfh", [ "batch", "dry-run", "change", "default=", "save=", "remove=", "load=", "force", "fingerprint", "config", "debug", "skip-options=", "help" ])[0])
831 except getopt.GetoptError as e:
832 print("Failed to parse options: {0}.\n"
833 "Use --help to get usage information.".format(str(e)),
835 sys.exit(posix.EX_USAGE)
838 if "--batch" in options:
839 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
840 dispatch_call_to_sessions([ x for x in argv if x != "--batch" ])
842 print("--batch mode can only be used by root and if $DISPLAY is unset")
847 # Load profiles from each XDG config directory
848 # The XDG spec says that earlier entries should take precedence, so reverse the order
849 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
850 system_profile_path = os.path.join(directory, "autorandr")
851 if os.path.isdir(system_profile_path):
852 profiles.update(load_profiles(system_profile_path))
853 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
854 # profile_path is also used later on to store configurations
855 profile_path = os.path.expanduser("~/.autorandr")
856 if not os.path.isdir(profile_path):
857 # Elsewise, follow the XDG specification
858 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
859 if os.path.isdir(profile_path):
860 profiles.update(load_profiles(profile_path))
861 # Sort by descending mtime
862 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
863 except Exception as e:
864 raise AutorandrException("Failed to load profiles", e)
866 config, modes = parse_xrandr_output()
868 if "--fingerprint" in options:
869 output_setup(config, sys.stdout)
872 if "--config" in options:
873 output_configuration(config, sys.stdout)
876 if "--skip-options" in options:
877 skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
878 for profile in profiles.values():
879 for output in profile["config"].values():
880 output.set_ignored_options(skip_options)
881 for output in config.values():
882 output.set_ignored_options(skip_options)
885 options["--save"] = options["-s"]
886 if "--save" in options:
887 if options["--save"] in ( x[0] for x in virtual_profiles ):
888 raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
890 profile_folder = os.path.join(profile_path, options["--save"])
891 save_configuration(profile_folder, config)
892 exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
893 except Exception as e:
894 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
895 print("Saved current configuration as profile '%s'" % options["--save"])
899 options["--remove"] = options["-r"]
900 if "--remove" in options:
901 if options["--remove"] in ( x[0] for x in virtual_profiles ):
902 raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
903 if options["--remove"] not in profiles.keys():
904 raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
907 profile_folder = os.path.join(profile_path, options["--remove"])
908 profile_dirlist = os.listdir(profile_folder)
909 profile_dirlist.remove("config")
910 profile_dirlist.remove("setup")
912 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
913 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
914 if response != "yes":
917 shutil.rmtree(profile_folder)
918 print("Removed profile '%s'" % options["--remove"])
920 print("Profile '%s' was not removed" % options["--remove"])
921 except Exception as e:
922 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
925 if "-h" in options or "--help" in options:
928 detected_profiles = find_profiles(config, profiles)
932 options["--load"] = options["-l"]
933 if "--load" in options:
934 load_profile = options["--load"]
936 # Find the active profile(s) first, for the block script (See #42)
937 current_profiles = []
938 for profile_name in profiles.keys():
939 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
940 if configs_are_equal:
941 current_profiles.append(profile_name)
942 block_script_metadata = {
943 "CURRENT_PROFILE": "".join(current_profiles[:1]),
944 "CURRENT_PROFILES": ":".join(current_profiles)
947 for profile_name in profiles.keys():
948 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
949 print("%s (blocked)" % profile_name, file=sys.stderr)
952 if profile_name in detected_profiles:
953 props.append("(detected)")
954 if ("-c" in options or "--change" in options) and not load_profile:
955 load_profile = profile_name
956 if profile_name in current_profiles:
957 props.append("(current)")
958 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
959 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
960 print_profile_differences(config, profiles[profile_name]["config"])
963 options["--default"] = options["-d"]
964 if not load_profile and "--default" in options:
965 load_profile = options["--default"]
968 if load_profile in ( x[0] for x in virtual_profiles ):
969 load_config = generate_virtual_profile(config, modes, load_profile)
970 scripts_path = os.path.join(profile_path, load_profile)
973 profile = profiles[load_profile]
974 load_config = profile["config"]
975 scripts_path = profile["path"]
977 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
978 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
979 update_mtime(os.path.join(scripts_path, "config"))
980 add_unused_outputs(config, load_config)
981 if load_config == dict(config) and not "-f" in options and not "--force" in options:
982 print("Config already loaded", file=sys.stderr)
984 if "--debug" in options and load_config != dict(config):
985 print("Loading profile '%s'" % load_profile)
986 print_profile_differences(config, load_config)
988 remove_irrelevant_outputs(config, load_config)
991 if "--dry-run" in options:
992 apply_configuration(load_config, config, True)
995 "CURRENT_PROFILE": load_profile,
996 "PROFILE_FOLDER": scripts_path,
998 exec_scripts(scripts_path, "preswitch", script_metadata)
999 if "--debug" in options:
1000 print("Going to run:")
1001 apply_configuration(load_config, config, True)
1002 apply_configuration(load_config, config, False)
1003 exec_scripts(scripts_path, "postswitch", script_metadata)
1004 except Exception as e:
1005 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1007 if "--dry-run" not in options and "--debug" in options:
1008 new_config, _ = parse_xrandr_output()
1009 if not is_equal_configuration(new_config, load_config):
1010 print("The configuration change did not go as expected:")
1011 print_profile_differences(new_config, load_config)
1015 if __name__ == '__main__':
1018 except AutorandrException as e:
1019 print(e, file=sys.stderr)
1021 except Exception as e:
1022 if not len(str(e)): # BdbQuit
1023 print("Exception: {0}".format(e.__class__.__name__))
1026 print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)