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 called "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 get_symlinks(profile_path):
480 "Load all symlinks from a directory"
483 for link in os.listdir(profile_path):
484 file_name = os.path.join(profile_path, link)
485 if os.path.islink(file_name):
486 symlinks[link] = os.readlink(file_name)
490 def find_profiles(current_config, profiles):
491 "Find profiles matching the currently connected outputs"
492 detected_profiles = []
493 for profile_name, profile in profiles.items():
494 config = profile["config"]
496 for name, output in config.items():
499 if name not in current_config or not output.edid_equals(current_config[name]):
502 if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
505 detected_profiles.append(profile_name)
506 return detected_profiles
508 def profile_blocked(profile_path, meta_information=None):
509 """Check if a profile is blocked.
511 meta_information is expected to be an dictionary. It will be passed to the block scripts
512 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
514 return not exec_scripts(profile_path, "block", meta_information)
516 def output_configuration(configuration, config):
517 "Write a configuration file"
518 outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
519 for output in outputs:
520 print(configuration[output].option_string, file=config)
522 def output_setup(configuration, setup):
523 "Write a setup (fingerprint) file"
524 outputs = sorted(configuration.keys())
525 for output in outputs:
526 if configuration[output].edid:
527 print(output, configuration[output].edid, file=setup)
529 def save_configuration(profile_path, configuration):
530 "Save a configuration into a profile"
531 if not os.path.isdir(profile_path):
532 os.makedirs(profile_path)
533 with open(os.path.join(profile_path, "config"), "w") as config:
534 output_configuration(configuration, config)
535 with open(os.path.join(profile_path, "setup"), "w") as setup:
536 output_setup(configuration, setup)
538 def update_mtime(filename):
539 "Update a file's mtime"
541 os.utime(filename, None)
546 def call_and_retry(*args, **kwargs):
547 """Wrapper around subprocess.call that retries failed calls.
549 This function calls subprocess.call and on non-zero exit states,
550 waits a second and then retries once. This mitigates #47,
551 a timing issue with some drivers.
553 kwargs_redirected = dict(kwargs)
554 if hasattr(subprocess, "DEVNULL"):
555 kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
557 kwargs_redirected["stdout"] = open(os.devnull, "w")
558 kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
559 retval = subprocess.call(*args, **kwargs_redirected)
562 retval = subprocess.call(*args, **kwargs)
565 def apply_configuration(new_configuration, current_configuration, dry_run=False):
566 "Apply a configuration"
567 outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
569 base_argv = [ "echo", "xrandr" ]
571 base_argv = [ "xrandr" ]
573 # There are several xrandr / driver bugs we need to take care of here:
574 # - We cannot enable more than two screens at the same time
575 # See https://github.com/phillipberndt/autorandr/pull/6
576 # and commits f4cce4d and 8429886.
577 # - We cannot disable all screens
578 # See https://github.com/phillipberndt/autorandr/pull/20
579 # - We should disable screens before enabling others, because there's
580 # a limit on the number of enabled screens
581 # - We must make sure that the screen at 0x0 is activated first,
582 # or the other (first) screen to be activated would be moved there.
583 # - If an active screen already has a transformation and remains active,
584 # the xrandr call fails with an invalid RRSetScreenSize parameter error.
585 # Update the configuration in 3 passes in that case. (On Haswell graphics,
587 # - Some implementations can not handle --transform at all, so avoid it unless
588 # necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
590 auxiliary_changes_pre = []
593 remain_active_count = 0
594 for output in outputs:
595 if not new_configuration[output].edid or "off" in new_configuration[output].options:
596 disable_outputs.append(new_configuration[output].option_vector)
598 if "off" not in current_configuration[output].options:
599 remain_active_count += 1
601 option_vector = new_configuration[output].option_vector
602 if xrandr_version() >= Version("1.3.0"):
603 if "transform" in current_configuration[output].options:
604 auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
607 transform_index = option_vector.index("--transform")
608 if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
609 option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
613 enable_outputs.append(option_vector)
615 # Perform pe-change auxiliary changes
616 if auxiliary_changes_pre:
617 argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
618 if call_and_retry(argv) != 0:
619 raise AutorandrException("Command failed: %s" % " ".join(argv))
621 # Disable unused outputs, but make sure that there always is at least one active screen
622 disable_keep = 0 if remain_active_count else 1
623 if len(disable_outputs) > disable_keep:
624 if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
625 # Disabling the outputs failed. Retry with the next command:
626 # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
627 # This does not occur if simultaneously the primary screen is reset.
630 disable_outputs = disable_outputs[-1:] if disable_keep else []
632 # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
633 # disable the last two screens. This is a problem, so if this would happen, instead disable only
634 # one screen in the first call below.
635 if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
636 # In the context of a xrandr call that changes the display state, `--query' should do nothing
637 disable_outputs.insert(0, ['--query'])
639 # Enable the remaining outputs in pairs of two operations
640 operations = disable_outputs + enable_outputs
641 for index in range(0, len(operations), 2):
642 argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
643 if call_and_retry(argv) != 0:
644 raise AutorandrException("Command failed: %s" % " ".join(argv))
646 def is_equal_configuration(source_configuration, target_configuration):
647 "Check if all outputs from target are already configured correctly in source"
648 for output in target_configuration.keys():
649 if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
653 def add_unused_outputs(source_configuration, target_configuration):
654 "Add outputs that are missing in target to target, in 'off' state"
655 for output_name, output in source_configuration.items():
656 if output_name not in target_configuration:
657 target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
659 def remove_irrelevant_outputs(source_configuration, target_configuration):
660 "Remove outputs from target that ought to be 'off' and already are"
661 for output_name, output in source_configuration.items():
662 if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
663 del target_configuration[output_name]
665 def generate_virtual_profile(configuration, modes, profile_name):
666 "Generate one of the virtual profiles"
667 configuration = copy.deepcopy(configuration)
668 if profile_name == "common":
669 common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
670 common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
671 common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
672 if common_resolution:
673 for output in configuration:
674 configuration[output].options = {}
675 if output in modes and configuration[output].edid:
676 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]
677 configuration[output].options["pos"] = "0x0"
679 configuration[output].options["off"] = None
680 elif profile_name in ("horizontal", "vertical"):
682 if profile_name == "horizontal":
683 shift_index = "width"
684 pos_specifier = "%sx0"
686 shift_index = "height"
687 pos_specifier = "0x%s"
689 for output in configuration:
690 configuration[output].options = {}
691 if output in modes and configuration[output].edid:
692 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
693 configuration[output].options["mode"] = mode["name"]
694 configuration[output].options["rate"] = mode["rate"]
695 configuration[output].options["pos"] = pos_specifier % shift
696 shift += int(mode[shift_index])
698 configuration[output].options["off"] = None
701 def print_profile_differences(one, another):
702 "Print the differences between two profiles for debugging"
705 print("| Differences between the two profiles:", file=sys.stderr)
706 for output in set(chain.from_iterable((one.keys(), another.keys()))):
707 if output not in one:
708 if "off" not in another[output].options:
709 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
710 elif output not in another:
711 if "off" not in one[output].options:
712 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
714 for line in one[output].verbose_diff(another[output]):
715 print("| [Output %s] %s" % (output, line), file=sys.stderr)
716 print ("\\-", file=sys.stderr)
719 "Print help and exit"
721 for profile in virtual_profiles:
722 print(" %-10s %s" % profile[:2])
725 def exec_scripts(profile_path, script_name, meta_information=None):
728 This will run all executables from the profile folder, and global per-user
729 and system-wide configuration folders, named script_name or residing in
730 subdirectories named script_name.d.
732 meta_information is expected to be an dictionary. It will be passed to the block scripts
733 in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
735 Returns True unless any of the scripts exited with non-zero exit status.
739 env = os.environ.copy()
740 env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
742 env = os.environ.copy()
744 # If there are multiple candidates, the XDG spec tells to only use the first one.
747 user_profile_path = os.path.expanduser("~/.autorandr")
748 if not os.path.isdir(user_profile_path):
749 user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
751 for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
752 (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"))):
754 if script_name not in ran_scripts:
755 script = os.path.join(folder, script_name)
756 if os.access(script, os.X_OK | os.F_OK):
757 all_ok &= subprocess.call(script, env=env) != 0
758 ran_scripts.add(script_name)
760 script_folder = os.path.join(folder, "%s.d" % script_name)
761 if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
762 for file_name in os.listdir(script_folder):
763 check_name = "d/%s" % (file_name,)
764 if check_name not in ran_scripts:
765 script = os.path.join(script_folder, file_name)
766 if os.access(script, os.X_OK | os.F_OK):
767 all_ok &= subprocess.call(script, env=env) != 0
768 ran_scripts.add(check_name)
772 def dispatch_call_to_sessions(argv):
773 """Invoke autorandr for each open local X11 session with the given options.
775 The function iterates over all processes not owned by root and checks
776 whether they have a DISPLAY variable set. It strips the screen from any
777 variable it finds (i.e. :0.0 becomes :0) and checks whether this display
778 has been handled already. If it has not, it forks, changes uid/gid to
779 the user owning the process, reuses the process's environment and runs
780 autorandr with the parameters from argv.
782 This function requires root permissions. It only works for X11 servers that
783 have at least one non-root process running. It is susceptible for attacks
784 where one user runs a process with another user's DISPLAY variable - in
785 this case, it might happen that autorandr is invoked for the other user,
786 which won't work. Since no other harm than prevention of automated
787 execution of autorandr can be done this way, the assumption is that in this
788 situation, the local administrator will handle the situation."""
789 X11_displays_done = set()
791 autorandr_binary = os.path.abspath(argv[0])
793 for directory in os.listdir("/proc"):
794 directory = os.path.join("/proc/", directory)
795 if not os.path.isdir(directory):
797 environ_file = os.path.join(directory, "environ")
798 if not os.path.isfile(environ_file):
800 uid = os.stat(environ_file).st_uid
802 # The following line assumes that user accounts start at 1000 and that
803 # no one works using the root or another system account. This is rather
804 # restrictive, but de facto default. Alternatives would be to use the
805 # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
806 # but effectively, both values aren't binding in any way.
807 # If this breaks your use case, please file a bug on Github.
812 for environ_entry in open(environ_file).read().split("\0"):
813 if "=" in environ_entry:
814 name, value = environ_entry.split("=", 1)
815 if name == "DISPLAY" and "." in value:
816 value = value[:value.find(".")]
817 process_environ[name] = value
818 display = process_environ["DISPLAY"] if "DISPLAY" in process_environ else None
820 if display and display not in X11_displays_done:
822 pwent = pwd.getpwuid(uid)
824 # User has no pwd entry
827 print("Running autorandr as %s for display %s" % (pwent.pw_name, display))
828 child_pid = os.fork()
830 # This will throw an exception if any of the privilege changes fails,
831 # so it should be safe. Also, note that since the environment
832 # is taken from a process owned by the user, reusing it should
833 # not leak any information.
835 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
836 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
837 os.chdir(pwent.pw_dir)
839 os.environ.update(process_environ)
840 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
842 os.waitpid(child_pid, 0)
844 X11_displays_done.add(display)
848 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])
849 except getopt.GetoptError as e:
850 print("Failed to parse options: {0}.\n"
851 "Use --help to get usage information.".format(str(e)),
853 sys.exit(posix.EX_USAGE)
856 if "--batch" in options:
857 if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
858 dispatch_call_to_sessions([ x for x in argv if x != "--batch" ])
860 print("--batch mode can only be used by root and if $DISPLAY is unset")
864 profile_symlinks = {}
866 # Load profiles from each XDG config directory
867 # The XDG spec says that earlier entries should take precedence, so reverse the order
868 for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
869 system_profile_path = os.path.join(directory, "autorandr")
870 if os.path.isdir(system_profile_path):
871 profiles.update(load_profiles(system_profile_path))
872 profile_symlinks.update(get_symlinks(system_profile_path))
873 # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
874 # profile_path is also used later on to store configurations
875 profile_path = os.path.expanduser("~/.autorandr")
876 if not os.path.isdir(profile_path):
877 # Elsewise, follow the XDG specification
878 profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
879 if os.path.isdir(profile_path):
880 profiles.update(load_profiles(profile_path))
881 profile_symlinks.update(get_symlinks(profile_path))
882 # Sort by descending mtime
883 profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
884 except Exception as e:
885 raise AutorandrException("Failed to load profiles", e)
887 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 }
889 config, modes = parse_xrandr_output()
891 if "--fingerprint" in options:
892 output_setup(config, sys.stdout)
895 if "--config" in options:
896 output_configuration(config, sys.stdout)
899 if "--skip-options" in options:
900 skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
901 for profile in profiles.values():
902 for output in profile["config"].values():
903 output.set_ignored_options(skip_options)
904 for output in config.values():
905 output.set_ignored_options(skip_options)
908 options["--save"] = options["-s"]
909 if "--save" in options:
910 if options["--save"] in ( x[0] for x in virtual_profiles ):
911 raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
913 profile_folder = os.path.join(profile_path, options["--save"])
914 save_configuration(profile_folder, config)
915 exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
916 except Exception as e:
917 raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
918 print("Saved current configuration as profile '%s'" % options["--save"])
922 options["--remove"] = options["-r"]
923 if "--remove" in options:
924 if options["--remove"] in ( x[0] for x in virtual_profiles ):
925 raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
926 if options["--remove"] not in profiles.keys():
927 raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
930 profile_folder = os.path.join(profile_path, options["--remove"])
931 profile_dirlist = os.listdir(profile_folder)
932 profile_dirlist.remove("config")
933 profile_dirlist.remove("setup")
935 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
936 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
937 if response != "yes":
940 shutil.rmtree(profile_folder)
941 print("Removed profile '%s'" % options["--remove"])
943 print("Profile '%s' was not removed" % options["--remove"])
944 except Exception as e:
945 raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
948 if "-h" in options or "--help" in options:
951 detected_profiles = find_profiles(config, profiles)
955 options["--load"] = options["-l"]
956 if "--load" in options:
957 load_profile = options["--load"]
959 # Find the active profile(s) first, for the block script (See #42)
960 current_profiles = []
961 for profile_name in profiles.keys():
962 configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
963 if configs_are_equal:
964 current_profiles.append(profile_name)
965 block_script_metadata = {
966 "CURRENT_PROFILE": "".join(current_profiles[:1]),
967 "CURRENT_PROFILES": ":".join(current_profiles)
970 for profile_name in profiles.keys():
971 if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
972 print("%s (blocked)" % profile_name, file=sys.stderr)
975 if profile_name in detected_profiles:
976 props.append("(detected)")
977 if ("-c" in options or "--change" in options) and not load_profile:
978 load_profile = profile_name
979 if profile_name in current_profiles:
980 props.append("(current)")
981 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
982 if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
983 print_profile_differences(config, profiles[profile_name]["config"])
986 options["--default"] = options["-d"]
987 if not load_profile and "--default" in options:
988 load_profile = options["--default"]
991 if load_profile in profile_symlinks:
992 if "--debug" in options:
993 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
994 load_profile = profile_symlinks[load_profile]
996 if load_profile in ( x[0] for x in virtual_profiles ):
997 load_config = generate_virtual_profile(config, modes, load_profile)
998 scripts_path = os.path.join(profile_path, load_profile)
1001 profile = profiles[load_profile]
1002 load_config = profile["config"]
1003 scripts_path = profile["path"]
1005 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1006 if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1007 update_mtime(os.path.join(scripts_path, "config"))
1008 add_unused_outputs(config, load_config)
1009 if load_config == dict(config) and not "-f" in options and not "--force" in options:
1010 print("Config already loaded", file=sys.stderr)
1012 if "--debug" in options and load_config != dict(config):
1013 print("Loading profile '%s'" % load_profile)
1014 print_profile_differences(config, load_config)
1016 remove_irrelevant_outputs(config, load_config)
1019 if "--dry-run" in options:
1020 apply_configuration(load_config, config, True)
1023 "CURRENT_PROFILE": load_profile,
1024 "PROFILE_FOLDER": scripts_path,
1026 exec_scripts(scripts_path, "preswitch", script_metadata)
1027 if "--debug" in options:
1028 print("Going to run:")
1029 apply_configuration(load_config, config, True)
1030 apply_configuration(load_config, config, False)
1031 exec_scripts(scripts_path, "postswitch", script_metadata)
1032 except Exception as e:
1033 raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1035 if "--dry-run" not in options and "--debug" in options:
1036 new_config, _ = parse_xrandr_output()
1037 if not is_equal_configuration(new_config, load_config):
1038 print("The configuration change did not go as expected:")
1039 print_profile_differences(new_config, load_config)
1043 if __name__ == '__main__':
1046 except AutorandrException as e:
1047 print(e, file=sys.stderr)
1049 except Exception as e:
1050 if not len(str(e)): # BdbQuit
1051 print("Exception: {0}".format(e.__class__.__name__))
1054 print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)