]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Add --cycle, cycles between detected profiles
[deb_pkgs/autorandr.git] / autorandr.py
1 #!/usr/bin/env python
2 # encoding: utf-8
3 #
4 # autorandr.py
5 # Copyright (c) 2015, Phillip Berndt
6 #
7 # Autorandr rewrite in Python
8 #
9 # This script aims to be fully compatible with the original autorandr.
10 #
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.
15 #
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.
20 #
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/>.
23 #
24
25 from __future__ import print_function
26
27 import binascii
28 import copy
29 import getopt
30 import hashlib
31 import os
32 import posix
33 import pwd
34 import re
35 import subprocess
36 import sys
37 import shutil
38 import time
39 import glob
40
41 from collections import OrderedDict
42 from distutils.version import LooseVersion as Version
43 from functools import reduce
44 from itertools import chain
45
46 if sys.version_info.major == 2:
47     import ConfigParser as configparser
48 else:
49     import configparser
50
51 __version__ = "1.11"
52
53 try:
54     input = raw_input
55 except NameError:
56     pass
57
58 virtual_profiles = [
59     # (name, description, callback)
60     ("off", "Disable all outputs", None),
61     ("common", "Clone all connected outputs at the largest common resolution", None),
62     ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
63     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
64     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
65 ]
66
67 help_text = """
68 Usage: autorandr [options]
69
70 -h, --help              get this small help
71 -c, --change            automatically load the first detected profile
72 -d, --default <profile> make profile <profile> the default profile
73 -l, --load <profile>    load profile <profile>
74 -s, --save <profile>    save your current setup to profile <profile>
75 -r, --remove <profile>  remove profile <profile>
76 --batch                 run autorandr for all users with active X11 sessions
77 --current               only list current (active) configuration(s)
78 --config                dump your current xrandr setup
79 --cycle                 automatically load the next detected profile
80 --debug                 enable verbose output
81 --detected              only list detected (available) configuration(s)
82 --dry-run               don't change anything, only print the xrandr commands
83 --fingerprint           fingerprint your current hardware setup
84 --force                 force (re)loading of a profile / overwrite exiting files
85 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
86                         to skip both in detecting changes and applying a profile
87 --version               show version information and exit
88
89  If no suitable profile can be identified, the current configuration is kept.
90  To change this behaviour and switch to a fallback configuration, specify
91  --default <profile>.
92
93  autorandr supports a set of per-profile and global hooks. See the documentation
94  for details.
95
96  The following virtual configurations are available:
97 """.strip()
98
99
100 def is_closed_lid(output):
101     if not re.match(r'(eDP(-?[0-9]\+)*|LVDS(-?[0-9]\+)*)', output):
102         return False
103     lids = glob.glob("/proc/acpi/button/lid/*/state")
104     if len(lids) == 1:
105         state_file = lids[0]
106         with open(state_file) as f:
107             content = f.read()
108             return "close" in content
109     return False
110
111
112 class AutorandrException(Exception):
113     def __init__(self, message, original_exception=None, report_bug=False):
114         self.message = message
115         self.report_bug = report_bug
116         if original_exception:
117             self.original_exception = original_exception
118             trace = sys.exc_info()[2]
119             while trace.tb_next:
120                 trace = trace.tb_next
121             self.line = trace.tb_lineno
122             self.file_name = trace.tb_frame.f_code.co_filename
123         else:
124             try:
125                 import inspect
126                 frame = inspect.currentframe().f_back
127                 self.line = frame.f_lineno
128                 self.file_name = frame.f_code.co_filename
129             except:
130                 self.line = None
131                 self.file_name = None
132             self.original_exception = None
133
134         if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
135             self.file_name = None
136
137     def __str__(self):
138         retval = [self.message]
139         if self.line:
140             retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
141         if self.original_exception:
142             retval.append(":\n  ")
143             retval.append(str(self.original_exception).replace("\n", "\n  "))
144         if self.report_bug:
145             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
146                           "\nhttps://github.com/phillipberndt/autorandr/issues"
147                           "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
148         return "".join(retval)
149
150
151 class XrandrOutput(object):
152     "Represents an XRandR output"
153
154     # This regular expression is used to parse an output in `xrandr --verbose'
155     XRANDR_OUTPUT_REGEXP = """(?x)
156         ^\s*(?P<output>\S[^ ]*)\s+                                                      # Line starts with output name
157         (?:                                                                             # Differentiate disconnected and connected
158             disconnected |                                                              # in first line
159             unknown\ connection |
160             (?P<connected>connected)
161         )
162         \s*
163         (?P<primary>primary\ )?                                                         # Might be primary screen
164         (?:\s*
165             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
166             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
167             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
168             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
169             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
170         )?                                                                              # .. but only if the screen is in use.
171         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
172         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
173         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
174         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
175         (?:\s*(?:                                                                       # Properties of the output
176             Gamma: (?P<gamma>(?:inf|-?[0-9\.\-: e])+) |                                 # Gamma value
177             CRTC:\s*(?P<crtc>[0-9]) |                                                   # CRTC value
178             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
179             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
180             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
181         ))+
182         \s*
183         (?P<modes>(?:
184             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution:
185              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+                                   # Extract rate
186              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
187             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
188         )*)
189     """
190
191     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
192         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
193          h:\s+width\s+(?P<width>[0-9]+).+\s+
194          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
195     """
196
197     XRANDR_13_DEFAULTS = {
198         "transform": "1,0,0,0,1,0,0,0,1",
199         "panning": "0x0",
200     }
201
202     XRANDR_12_DEFAULTS = {
203         "reflect": "normal",
204         "rotate": "normal",
205         "gamma": "1.0:1.0:1.0",
206     }
207
208     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
209
210     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
211
212     def __repr__(self):
213         return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
214
215     @property
216     def short_edid(self):
217         return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
218
219     @property
220     def options_with_defaults(self):
221         "Return the options dictionary, augmented with the default values that weren't set"
222         if "off" in self.options:
223             return self.options
224         options = {}
225         if xrandr_version() >= Version("1.3"):
226             options.update(self.XRANDR_13_DEFAULTS)
227         if xrandr_version() >= Version("1.2"):
228             options.update(self.XRANDR_12_DEFAULTS)
229         options.update(self.options)
230         return {a: b for a, b in options.items() if a not in self.ignored_options}
231
232     @property
233     def filtered_options(self):
234         "Return a dictionary of options without ignored options"
235         return {a: b for a, b in self.options.items() if a not in self.ignored_options}
236
237     @property
238     def option_vector(self):
239         "Return the command line parameters for XRandR for this instance"
240         args = ["--output", self.output]
241         for option, arg in sorted(self.options_with_defaults.items()):
242             args.append("--%s" % option)
243             if arg:
244                 args.append(arg)
245         return args
246
247     @property
248     def option_string(self):
249         "Return the command line parameters in the configuration file format"
250         options = ["output %s" % self.output]
251         for option, arg in sorted(self.filtered_options.items()):
252             if arg:
253                 options.append("%s %s" % (option, arg))
254             else:
255                 options.append(option)
256         return "\n".join(options)
257
258     @property
259     def sort_key(self):
260         "Return a key to sort the outputs for xrandr invocation"
261         if not self.edid:
262             return -2
263         if "off" in self.options:
264             return -1
265         if "pos" in self.options:
266             x, y = map(float, self.options["pos"].split("x"))
267         else:
268             x, y = 0, 0
269         return x + 10000 * y
270
271     def __init__(self, output, edid, options):
272         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
273         self.output = output
274         self.edid = edid
275         self.options = options
276         self.ignored_options = []
277         self.remove_default_option_values()
278
279     def set_ignored_options(self, options):
280         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
281         self.ignored_options = list(options)
282
283     def remove_default_option_values(self):
284         "Remove values from the options dictionary that are superflous"
285         if "off" in self.options and len(self.options.keys()) > 1:
286             self.options = {"off": None}
287             return
288         for option, default_value in self.XRANDR_DEFAULTS.items():
289             if option in self.options and self.options[option] == default_value:
290                 del self.options[option]
291
292     @classmethod
293     def from_xrandr_output(cls, xrandr_output):
294         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
295
296         This method also returns a list of modes supported by the output.
297         """
298         try:
299             xrandr_output = xrandr_output.replace("\r\n", "\n")
300             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
301         except:
302             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.",
303                                      report_bug=True)
304         if not match_object:
305             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
306             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug,
307                                      report_bug=True)
308         remainder = xrandr_output[len(match_object.group(0)):]
309         if remainder:
310             raise AutorandrException("Parsing XRandR output failed, %d bytes left unmatched after "
311                                      "regular expression, starting at byte %d with ..'%s'." %
312                                      (len(remainder), len(match_object.group(0)), remainder[:10]),
313                                      report_bug=True)
314
315         match = match_object.groupdict()
316
317         modes = []
318         if match["modes"]:
319             modes = []
320             for mode_match in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]):
321                 if mode_match.group("name"):
322                     modes.append(mode_match.groupdict())
323             if not modes:
324                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
325
326         options = {}
327         if not match["connected"]:
328             edid = None
329         elif match["edid"]:
330             edid = "".join(match["edid"].strip().split())
331         else:
332             edid = "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
333
334         # An output can be disconnected but still have a mode configured. This can only happen
335         # as a residual situation after a disconnect, you cannot associate a mode with an disconnected
336         # output.
337         #
338         # This code needs to be careful not to mix the two. An output should only be configured to
339         # "off" if it doesn't have a mode associated with it, which is modelled as "not a width" here.
340         if not match["width"]:
341             options["off"] = None
342         else:
343             if match["mode_name"]:
344                 options["mode"] = match["mode_name"]
345             elif match["mode_width"]:
346                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
347             else:
348                 if match["rotate"] not in ("left", "right"):
349                     options["mode"] = "%sx%s" % (match["width"] or 0, match["height"] or 0)
350                 else:
351                     options["mode"] = "%sx%s" % (match["height"] or 0, match["width"] or 0)
352             if match["rotate"]:
353                 options["rotate"] = match["rotate"]
354             if match["primary"]:
355                 options["primary"] = None
356             if match["reflect"] == "X":
357                 options["reflect"] = "x"
358             elif match["reflect"] == "Y":
359                 options["reflect"] = "y"
360             elif match["reflect"] == "X and Y":
361                 options["reflect"] = "xy"
362             if match["x"] or match["y"]:
363                 options["pos"] = "%sx%s" % (match["x"] or "0", match["y"] or "0")
364             if match["panning"]:
365                 panning = [match["panning"]]
366                 if match["tracking"]:
367                     panning += ["/", match["tracking"]]
368                     if match["border"]:
369                         panning += ["/", match["border"]]
370                 options["panning"] = "".join(panning)
371             if match["transform"]:
372                 transformation = ",".join(match["transform"].strip().split())
373                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
374                     options["transform"] = transformation
375                     if not match["mode_name"]:
376                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains,
377                         # I doubt that this special case is actually required.
378                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! "
379                               "Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
380             if match["gamma"]:
381                 gamma = match["gamma"].strip()
382                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
383                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
384                 # so we approximate by 1e-10.
385                 gamma = ":".join([str(max(1e-10, round(1. / float(x), 3))) for x in gamma.split(":")])
386                 options["gamma"] = gamma
387             if match["crtc"]:
388                 options["crtc"] = match["crtc"]
389             if match["rate"]:
390                 options["rate"] = match["rate"]
391
392         return XrandrOutput(match["output"], edid, options), modes
393
394     @classmethod
395     def from_config_file(cls, edid_map, configuration):
396         "Instanciate an XrandrOutput from the contents of a configuration file"
397         options = {}
398         for line in configuration.split("\n"):
399             if line:
400                 line = line.split(None, 1)
401                 if line and line[0].startswith("#"):
402                     continue
403                 options[line[0]] = line[1] if len(line) > 1 else None
404
405         edid = None
406
407         if options["output"] in edid_map:
408             edid = edid_map[options["output"]]
409         else:
410             # This fuzzy matching is for legacy autorandr that used sysfs output names
411             fuzzy_edid_map = [re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys()]
412             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
413             if fuzzy_output in fuzzy_edid_map:
414                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
415             elif "off" not in options:
416                 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' "
417                                          "is not off in config file." % (options["output"], options["output"]))
418         output = options["output"]
419         del options["output"]
420
421         return XrandrOutput(output, edid, options)
422
423     def edid_equals(self, other):
424         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
425         if self.edid and other.edid:
426             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
427                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
428             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
429                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
430             if "*" in self.edid:
431                 return match_asterisk(self.edid, other.edid) > 0
432             elif "*" in other.edid:
433                 return match_asterisk(other.edid, self.edid) > 0
434         return self.edid == other.edid
435
436     def __ne__(self, other):
437         return not (self == other)
438
439     def __eq__(self, other):
440         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
441
442     def verbose_diff(self, other):
443         "Compare to another XrandrOutput and return a list of human readable differences"
444         diffs = []
445         if not self.edid_equals(other):
446             diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
447         if self.output != other.output:
448             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
449         if "off" in self.options and "off" not in other.options:
450             diffs.append("The output is disabled currently, but active in the new configuration")
451         elif "off" in other.options and "off" not in self.options:
452             diffs.append("The output is currently enabled, but inactive in the new configuration")
453         else:
454             for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
455                 if name not in other.options:
456                     diffs.append("Option --%s %sis not present in the new configuration" %
457                                  (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
458                 elif name not in self.options:
459                     diffs.append("Option --%s (`%s' in the new configuration) is not present currently" %
460                                  (name, other.options[name]))
461                 elif self.options[name] != other.options[name]:
462                     diffs.append("Option --%s %sis `%s' in the new configuration" %
463                                  (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
464         return diffs
465
466
467 def xrandr_version():
468     "Return the version of XRandR that this system uses"
469     if getattr(xrandr_version, "version", False) is False:
470         version_string = os.popen("xrandr -v").read()
471         try:
472             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
473             xrandr_version.version = Version(version)
474         except AttributeError:
475             xrandr_version.version = Version("1.3.0")
476
477     return xrandr_version.version
478
479
480 def debug_regexp(pattern, string):
481     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
482     try:
483         import regex
484         bounds = (0, len(string))
485         while bounds[0] != bounds[1]:
486             half = int((bounds[0] + bounds[1]) / 2)
487             if half == bounds[0]:
488                 break
489             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
490         partial_length = bounds[0]
491         return ("Regular expression matched until position %d, ..'%s', and did not match from '%s'.." %
492                 (partial_length, string[max(0, partial_length - 20):partial_length],
493                  string[partial_length:partial_length + 10]))
494     except ImportError:
495         pass
496     return "Debug information would be available if the `regex' module was installed."
497
498
499 def parse_xrandr_output():
500     "Parse the output of `xrandr --verbose' into a list of outputs"
501     xrandr_output = os.popen("xrandr -q --verbose").read()
502     if not xrandr_output:
503         raise AutorandrException("Failed to run xrandr")
504
505     # We are not interested in screens
506     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
507
508     # Split at output boundaries and instanciate an XrandrOutput per output
509     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
510     if len(split_xrandr_output) < 2:
511         raise AutorandrException("No output boundaries found", report_bug=True)
512     outputs = OrderedDict()
513     modes = OrderedDict()
514     for i in range(1, len(split_xrandr_output), 2):
515         output_name = split_xrandr_output[i].split()[0]
516         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i + 2]))
517         outputs[output_name] = output
518         if output_modes:
519             modes[output_name] = output_modes
520
521     # consider a closed lid as disconnected if other outputs are connected
522     if sum(o.edid != None for o in outputs.values()) > 1:
523         for output_name in outputs.keys():
524             if is_closed_lid(output_name):
525                 outputs[output_name].edid = None
526
527     return outputs, modes
528
529
530 def load_profiles(profile_path):
531     "Load the stored profiles"
532
533     profiles = {}
534     for profile in os.listdir(profile_path):
535         config_name = os.path.join(profile_path, profile, "config")
536         setup_name = os.path.join(profile_path, profile, "setup")
537         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
538             continue
539
540         edids = dict([x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#"])
541
542         config = {}
543         buffer = []
544         for line in chain(open(config_name).readlines(), ["output"]):
545             if line[:6] == "output" and buffer:
546                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
547                 buffer = [line]
548             else:
549                 buffer.append(line)
550
551         for output_name in list(config.keys()):
552             if config[output_name].edid is None:
553                 del config[output_name]
554
555         profiles[profile] = {
556             "config": config,
557             "path": os.path.join(profile_path, profile),
558             "config-mtime": os.stat(config_name).st_mtime,
559         }
560
561     return profiles
562
563
564 def get_symlinks(profile_path):
565     "Load all symlinks from a directory"
566
567     symlinks = {}
568     for link in os.listdir(profile_path):
569         file_name = os.path.join(profile_path, link)
570         if os.path.islink(file_name):
571             symlinks[link] = os.readlink(file_name)
572
573     return symlinks
574
575
576 def match_asterisk(pattern, data):
577     """Match data against a pattern
578
579     The difference to fnmatch is that this function only accepts patterns with a single
580     asterisk and that it returns a "closeness" number, which is larger the better the match.
581     Zero indicates no match at all.
582     """
583     if "*" not in pattern:
584         return 1 if pattern == data else 0
585     parts = pattern.split("*")
586     if len(parts) > 2:
587         raise ValueError("Only patterns with a single asterisk are supported, %s is invalid" % pattern)
588     if not data.startswith(parts[0]):
589         return 0
590     if not data.endswith(parts[1]):
591         return 0
592     matched = len(pattern)
593     total = len(data) + 1
594     return matched * 1. / total
595
596
597 def find_profiles(current_config, profiles):
598     "Find profiles matching the currently connected outputs, sorting asterisk matches to the back"
599     detected_profiles = []
600     for profile_name, profile in profiles.items():
601         config = profile["config"]
602         matches = True
603         for name, output in config.items():
604             if not output.edid:
605                 continue
606             if name not in current_config or not output.edid_equals(current_config[name]):
607                 matches = False
608                 break
609         if not matches or any((name not in config.keys() for name in current_config.keys() if current_config[name].edid)):
610             continue
611         if matches:
612             closeness = max(match_asterisk(output.edid, current_config[name].edid), match_asterisk(current_config[name].edid, output.edid))
613             detected_profiles.append((closeness, profile_name))
614     detected_profiles = [o[1] for o in sorted(detected_profiles, key=lambda x: -x[0])]
615     return detected_profiles
616
617
618 def profile_blocked(profile_path, meta_information=None):
619     """Check if a profile is blocked.
620
621     meta_information is expected to be an dictionary. It will be passed to the block scripts
622     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
623     """
624     return not exec_scripts(profile_path, "block", meta_information)
625
626
627 def check_configuration_pre_save(configuration):
628     "Check that a configuration is safe for saving."
629     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
630     for output in outputs:
631         if "off" not in configuration[output].options and not configuration[output].edid:
632             return ("`%(o)s' is not off (has a mode configured) but is disconnected (does not have an EDID).\n"
633                     "This typically means that it has been recently unplugged and then not properly disabled\n"
634                     "by the user. Please disable it (e.g. using `xrandr --output %(o)s --off`) and then rerun\n"
635                     "this command.") % {"o": output}
636
637
638 def output_configuration(configuration, config):
639     "Write a configuration file"
640     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
641     for output in outputs:
642         print(configuration[output].option_string, file=config)
643
644
645 def output_setup(configuration, setup):
646     "Write a setup (fingerprint) file"
647     outputs = sorted(configuration.keys())
648     for output in outputs:
649         if configuration[output].edid:
650             print(output, configuration[output].edid, file=setup)
651
652
653 def save_configuration(profile_path, profile_name, configuration, forced=False):
654     "Save a configuration into a profile"
655     if not os.path.isdir(profile_path):
656         os.makedirs(profile_path)
657     config_path = os.path.join(profile_path, "config")
658     setup_path = os.path.join(profile_path, "setup")
659     if os.path.isfile(config_path) and not forced:
660         raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
661     if os.path.isfile(setup_path) and not forced:
662         raise AutorandrException('Refusing to overwrite config "{}" without passing "--force"!'.format(profile_name))
663
664     with open(config_path, "w") as config:
665         output_configuration(configuration, config)
666     with open(setup_path, "w") as setup:
667         output_setup(configuration, setup)
668
669
670 def update_mtime(filename):
671     "Update a file's mtime"
672     try:
673         os.utime(filename, None)
674         return True
675     except:
676         return False
677
678
679 def call_and_retry(*args, **kwargs):
680     """Wrapper around subprocess.call that retries failed calls.
681
682     This function calls subprocess.call and on non-zero exit states,
683     waits a second and then retries once. This mitigates #47,
684     a timing issue with some drivers.
685     """
686     if "dry_run" in kwargs:
687         dry_run = kwargs["dry_run"]
688         del kwargs["dry_run"]
689     else:
690         dry_run = False
691     kwargs_redirected = dict(kwargs)
692     if not dry_run:
693         if hasattr(subprocess, "DEVNULL"):
694             kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
695         else:
696             kwargs_redirected["stdout"] = open(os.devnull, "w")
697         kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
698     retval = subprocess.call(*args, **kwargs_redirected)
699     if retval != 0:
700         time.sleep(1)
701         retval = subprocess.call(*args, **kwargs)
702     return retval
703
704
705 def get_fb_dimensions(configuration):
706     width = 0
707     height = 0
708     for output in configuration.values():
709         if "off" in output.options or not output.edid:
710             continue
711         # This won't work with all modes -- but it's a best effort.
712         match = re.search("[0-9]{3,}x[0-9]{3,}", output.options["mode"])
713         if not match:
714             return None
715         o_mode = match.group(0)
716         o_width, o_height = map(int, o_mode.split("x"))
717         if "transform" in output.options:
718             a, b, c, d, e, f, g, h, i = map(float, output.options["transform"].split(","))
719             w = (g * o_width + h * o_height + i)
720             x = (a * o_width + b * o_height + c) / w
721             y = (d * o_width + e * o_height + f) / w
722             o_width, o_height = x, y
723         if "rotate" in output.options:
724             if output.options["rotate"] in ("left", "right"):
725                 o_width, o_height = o_height, o_width
726         if "pos" in output.options:
727             o_left, o_top = map(int, output.options["pos"].split("x"))
728             o_width += o_left
729             o_height += o_top
730         if "panning" in output.options:
731             match = re.match("(?P<w>[0-9]+)x(?P<h>[0-9]+)(?:\+(?P<x>[0-9]+))?(?:\+(?P<y>[0-9]+))?.*", output.options["panning"])
732             if match:
733                 detail = match.groupdict(default="0")
734                 o_width = int(detail.get("w")) + int(detail.get("x"))
735                 o_height = int(detail.get("h")) + int(detail.get("y"))
736         width = max(width, o_width)
737         height = max(height, o_height)
738     return int(width), int(height)
739
740
741 def apply_configuration(new_configuration, current_configuration, dry_run=False):
742     "Apply a configuration"
743     found_top_left_monitor = False
744     found_left_monitor = False
745     found_top_monitor = False
746     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
747     if dry_run:
748         base_argv = ["echo", "xrandr"]
749     else:
750         base_argv = ["xrandr"]
751
752     # There are several xrandr / driver bugs we need to take care of here:
753     # - We cannot enable more than two screens at the same time
754     #   See https://github.com/phillipberndt/autorandr/pull/6
755     #   and commits f4cce4d and 8429886.
756     # - We cannot disable all screens
757     #   See https://github.com/phillipberndt/autorandr/pull/20
758     # - We should disable screens before enabling others, because there's
759     #   a limit on the number of enabled screens
760     # - We must make sure that the screen at 0x0 is activated first,
761     #   or the other (first) screen to be activated would be moved there.
762     # - If an active screen already has a transformation and remains active,
763     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
764     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
765     #   at least.)
766     # - Some implementations can not handle --transform at all, so avoid it unless
767     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
768     # - Some implementations can not handle --panning without specifying --fb
769     #   explicitly, so avoid it unless necessary.
770     #   (See https://github.com/phillipberndt/autorandr/issues/72)
771
772     fb_dimensions = get_fb_dimensions(new_configuration)
773     try:
774         base_argv += ["--fb", "%dx%d" % fb_dimensions]
775     except:
776         # Failed to obtain frame-buffer size. Doesn't matter, xrandr will choose for the user.
777         pass
778
779     auxiliary_changes_pre = []
780     disable_outputs = []
781     enable_outputs = []
782     remain_active_count = 0
783     for output in outputs:
784         if not new_configuration[output].edid or "off" in new_configuration[output].options:
785             disable_outputs.append(new_configuration[output].option_vector)
786         else:
787             if output not in current_configuration:
788                 raise AutorandrException("New profile configures output %s which does not exist in current xrandr --verbose output. "
789                                          "Don't know how to proceed." % output)
790             if "off" not in current_configuration[output].options:
791                 remain_active_count += 1
792
793             option_vector = new_configuration[output].option_vector
794             if xrandr_version() >= Version("1.3.0"):
795                 for option, off_value in (("transform", "none"), ("panning", "0x0")):
796                     if option in current_configuration[output].options:
797                         auxiliary_changes_pre.append(["--output", output, "--%s" % option, off_value])
798                     else:
799                         try:
800                             option_index = option_vector.index("--%s" % option)
801                             if option_vector[option_index + 1] == XrandrOutput.XRANDR_DEFAULTS[option]:
802                                 option_vector = option_vector[:option_index] + option_vector[option_index + 2:]
803                         except ValueError:
804                             pass
805             if not found_top_left_monitor:
806                 position = new_configuration[output].options.get("pos", "0x0")
807                 if position == "0x0":
808                     found_top_left_monitor = True
809                     enable_outputs.insert(0, option_vector)
810                 elif not found_left_monitor and position.startswith("0x"):
811                     found_left_monitor = True
812                     enable_outputs.insert(0, option_vector)
813                 elif not found_top_monitor and position.endswith("x0"):
814                     found_top_monitor = True
815                     enable_outputs.insert(0, option_vector)
816                 else:
817                     enable_outputs.append(option_vector)
818             else:
819                 enable_outputs.append(option_vector)
820
821     # Perform pe-change auxiliary changes
822     if auxiliary_changes_pre:
823         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
824         if call_and_retry(argv, dry_run=dry_run) != 0:
825             raise AutorandrException("Command failed: %s" % " ".join(argv))
826
827     # Disable unused outputs, but make sure that there always is at least one active screen
828     disable_keep = 0 if remain_active_count else 1
829     if len(disable_outputs) > disable_keep:
830         argv = base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))
831         if call_and_retry(argv, dry_run=dry_run) != 0:
832             # Disabling the outputs failed. Retry with the next command:
833             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
834             # This does not occur if simultaneously the primary screen is reset.
835             pass
836         else:
837             disable_outputs = disable_outputs[-1:] if disable_keep else []
838
839     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
840     # disable the last two screens. This is a problem, so if this would happen, instead disable only
841     # one screen in the first call below.
842     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
843         # In the context of a xrandr call that changes the display state, `--query' should do nothing
844         disable_outputs.insert(0, ['--query'])
845
846     # If we did not find a candidate, we might need to inject a call
847     # If there is no output to disable, we will enable 0x and x0 at the same time
848     if not found_top_left_monitor and len(disable_outputs) > 0:
849         # If the call to 0x and x0 is splitted, inject one of them
850         if found_top_monitor and found_left_monitor:
851             enable_outputs.insert(0, enable_outputs[0])
852
853     # Enable the remaining outputs in pairs of two operations
854     operations = disable_outputs + enable_outputs
855     for index in range(0, len(operations), 2):
856         argv = base_argv + list(chain.from_iterable(operations[index:index + 2]))
857         if call_and_retry(argv, dry_run=dry_run) != 0:
858             raise AutorandrException("Command failed: %s" % " ".join(argv))
859
860
861 def is_equal_configuration(source_configuration, target_configuration):
862     """
863         Check if all outputs from target are already configured correctly in source and
864         that no other outputs are active.
865     """
866     for output in target_configuration.keys():
867         if "off" in target_configuration[output].options:
868             if (output in source_configuration and "off" not in source_configuration[output].options):
869                 return False
870         else:
871             if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
872                 return False
873     for output in source_configuration.keys():
874         if "off" in source_configuration[output].options:
875             if output in target_configuration and "off" not in target_configuration[output].options:
876                 return False
877         else:
878             if output not in target_configuration:
879                 return False
880     return True
881
882
883 def add_unused_outputs(source_configuration, target_configuration):
884     "Add outputs that are missing in target to target, in 'off' state"
885     for output_name, output in source_configuration.items():
886         if output_name not in target_configuration:
887             target_configuration[output_name] = XrandrOutput(output_name, output.edid, {"off": None})
888
889
890 def remove_irrelevant_outputs(source_configuration, target_configuration):
891     "Remove outputs from target that ought to be 'off' and already are"
892     for output_name, output in source_configuration.items():
893         if "off" in output.options:
894             if output_name in target_configuration:
895                 if "off" in target_configuration[output_name].options:
896                     del target_configuration[output_name]
897
898
899 def generate_virtual_profile(configuration, modes, profile_name):
900     "Generate one of the virtual profiles"
901     configuration = copy.deepcopy(configuration)
902     if profile_name == "common":
903         mode_sets = []
904         for output, output_modes in modes.items():
905             mode_set = set()
906             if configuration[output].edid:
907                 for mode in output_modes:
908                     mode_set.add((mode["width"], mode["height"]))
909             mode_sets.append(mode_set)
910         common_resolution = reduce(lambda a, b: a & b, mode_sets[1:], mode_sets[0])
911         common_resolution = sorted(common_resolution, key=lambda a: int(a[0]) * int(a[1]))
912         if common_resolution:
913             for output in configuration:
914                 configuration[output].options = {}
915                 if output in modes and configuration[output].edid:
916                     modes_sorted = sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1)
917                     modes_filtered = [x for x in modes_sorted if (x["width"], x["height"]) == common_resolution[-1]]
918                     mode = modes_filtered[0]
919                     configuration[output].options["mode"] = mode['name']
920                     configuration[output].options["pos"] = "0x0"
921                 else:
922                     configuration[output].options["off"] = None
923     elif profile_name in ("horizontal", "vertical"):
924         shift = 0
925         if profile_name == "horizontal":
926             shift_index = "width"
927             pos_specifier = "%sx0"
928         else:
929             shift_index = "height"
930             pos_specifier = "0x%s"
931
932         for output in configuration:
933             configuration[output].options = {}
934             if output in modes and configuration[output].edid:
935                 def key(a):
936                     score = int(a["width"]) * int(a["height"])
937                     if a["preferred"]:
938                         score += 10**6
939                     return score
940                 output_modes = sorted(modes[output], key=key)
941                 mode = output_modes[-1]
942                 configuration[output].options["mode"] = mode["name"]
943                 configuration[output].options["rate"] = mode["rate"]
944                 configuration[output].options["pos"] = pos_specifier % shift
945                 shift += int(mode[shift_index])
946             else:
947                 configuration[output].options["off"] = None
948     elif profile_name == "clone-largest":
949         modes_unsorted = [output_modes[0] for output, output_modes in modes.items()]
950         modes_sorted = sorted(modes_unsorted, key=lambda x: int(x["width"]) * int(x["height"]), reverse=True)
951         biggest_resolution = modes_sorted[0]
952         for output in configuration:
953             configuration[output].options = {}
954             if output in modes and configuration[output].edid:
955                 def key(a):
956                     score = int(a["width"]) * int(a["height"])
957                     if a["preferred"]:
958                         score += 10**6
959                     return score
960                 output_modes = sorted(modes[output], key=key)
961                 mode = output_modes[-1]
962                 configuration[output].options["mode"] = mode["name"]
963                 configuration[output].options["rate"] = mode["rate"]
964                 configuration[output].options["pos"] = "0x0"
965                 scale = max(float(biggest_resolution["width"]) / float(mode["width"]),
966                             float(biggest_resolution["height"]) / float(mode["height"]))
967                 mov_x = (float(mode["width"]) * scale - float(biggest_resolution["width"])) / -2
968                 mov_y = (float(mode["height"]) * scale - float(biggest_resolution["height"])) / -2
969                 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
970             else:
971                 configuration[output].options["off"] = None
972     elif profile_name == "off":
973         for output in configuration:
974             for key in list(configuration[output].options.keys()):
975                 del configuration[output].options[key]
976             configuration[output].options["off"] = None
977     return configuration
978
979
980 def print_profile_differences(one, another):
981     "Print the differences between two profiles for debugging"
982     if one == another:
983         return
984     print("| Differences between the two profiles:")
985     for output in set(chain.from_iterable((one.keys(), another.keys()))):
986         if output not in one:
987             if "off" not in another[output].options:
988                 print("| Output `%s' is missing from the active configuration" % output)
989         elif output not in another:
990             if "off" not in one[output].options:
991                 print("| Output `%s' is missing from the new configuration" % output)
992         else:
993             for line in one[output].verbose_diff(another[output]):
994                 print("| [Output %s] %s" % (output, line))
995     print("\\-")
996
997
998 def exit_help():
999     "Print help and exit"
1000     print(help_text)
1001     for profile in virtual_profiles:
1002         name, description = profile[:2]
1003         description = [description]
1004         max_width = 78 - 18
1005         while len(description[0]) > max_width + 1:
1006             left_over = description[0][max_width:]
1007             description[0] = description[0][:max_width] + "-"
1008             description.insert(1, "  %-15s %s" % ("", left_over))
1009         description = "\n".join(description)
1010         print("  %-15s %s" % (name, description))
1011     sys.exit(0)
1012
1013
1014 def exec_scripts(profile_path, script_name, meta_information=None):
1015     """"Run userscripts
1016
1017     This will run all executables from the profile folder, and global per-user
1018     and system-wide configuration folders, named script_name or residing in
1019     subdirectories named script_name.d.
1020
1021     If profile_path is None, only global scripts will be invoked.
1022
1023     meta_information is expected to be an dictionary. It will be passed to the block scripts
1024     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
1025
1026     Returns True unless any of the scripts exited with non-zero exit status.
1027     """
1028     all_ok = True
1029     env = os.environ.copy()
1030     if meta_information:
1031         for key, value in meta_information.items():
1032             env["AUTORANDR_{}".format(key.upper())] = str(value)
1033
1034     # If there are multiple candidates, the XDG spec tells to only use the first one.
1035     ran_scripts = set()
1036
1037     user_profile_path = os.path.expanduser("~/.autorandr")
1038     if not os.path.isdir(user_profile_path):
1039         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1040
1041     candidate_directories = []
1042     if profile_path:
1043         candidate_directories.append(profile_path)
1044     candidate_directories.append(user_profile_path)
1045     for config_dir in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"):
1046         candidate_directories.append(os.path.join(config_dir, "autorandr"))
1047
1048     for folder in candidate_directories:
1049         if script_name not in ran_scripts:
1050             script = os.path.join(folder, script_name)
1051             if os.access(script, os.X_OK | os.F_OK):
1052                 try:
1053                     all_ok &= subprocess.call(script, env=env) != 0
1054                 except:
1055                     raise AutorandrException("Failed to execute user command: %s" % (script,))
1056                 ran_scripts.add(script_name)
1057
1058         script_folder = os.path.join(folder, "%s.d" % script_name)
1059         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
1060             for file_name in os.listdir(script_folder):
1061                 check_name = "d/%s" % (file_name,)
1062                 if check_name not in ran_scripts:
1063                     script = os.path.join(script_folder, file_name)
1064                     if os.access(script, os.X_OK | os.F_OK):
1065                         try:
1066                             all_ok &= subprocess.call(script, env=env) != 0
1067                         except:
1068                             raise AutorandrException("Failed to execute user command: %s" % (script,))
1069                         ran_scripts.add(check_name)
1070
1071     return all_ok
1072
1073
1074 def dispatch_call_to_sessions(argv):
1075     """Invoke autorandr for each open local X11 session with the given options.
1076
1077     The function iterates over all processes not owned by root and checks
1078     whether they have DISPLAY and XAUTHORITY variables set. It strips the
1079     screen from any variable it finds (i.e. :0.0 becomes :0) and checks whether
1080     this display has been handled already. If it has not, it forks, changes
1081     uid/gid to the user owning the process, reuses the process's environment
1082     and runs autorandr with the parameters from argv.
1083
1084     This function requires root permissions. It only works for X11 servers that
1085     have at least one non-root process running. It is susceptible for attacks
1086     where one user runs a process with another user's DISPLAY variable - in
1087     this case, it might happen that autorandr is invoked for the other user,
1088     which won't work. Since no other harm than prevention of automated
1089     execution of autorandr can be done this way, the assumption is that in this
1090     situation, the local administrator will handle the situation."""
1091
1092     X11_displays_done = set()
1093
1094     autorandr_binary = os.path.abspath(argv[0])
1095     backup_candidates = {}
1096
1097     def fork_child_autorandr(pwent, process_environ):
1098         print("Running autorandr as %s for display %s" % (pwent.pw_name, process_environ["DISPLAY"]))
1099         child_pid = os.fork()
1100         if child_pid == 0:
1101             # This will throw an exception if any of the privilege changes fails,
1102             # so it should be safe. Also, note that since the environment
1103             # is taken from a process owned by the user, reusing it should
1104             # not leak any information.
1105             try:
1106                 os.setgroups(os.getgrouplist(pwent.pw_name, pwent.pw_gid))
1107             except AttributeError:
1108                 # Python 2 doesn't have getgrouplist
1109                 os.setgroups([])
1110             os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
1111             os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
1112             os.chdir(pwent.pw_dir)
1113             os.environ.clear()
1114             os.environ.update(process_environ)
1115             if sys.executable != "" and sys.executable != None:
1116                 os.execl(sys.executable, sys.executable, autorandr_binary, *argv[1:])
1117             else:
1118                 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
1119             sys.exit(1)
1120         os.waitpid(child_pid, 0)
1121
1122     for directory in os.listdir("/proc"):
1123         directory = os.path.join("/proc/", directory)
1124         if not os.path.isdir(directory):
1125             continue
1126         environ_file = os.path.join(directory, "environ")
1127         if not os.path.isfile(environ_file):
1128             continue
1129         uid = os.stat(environ_file).st_uid
1130
1131         # The following line assumes that user accounts start at 1000 and that
1132         # no one works using the root or another system account. This is rather
1133         # restrictive, but de facto default. Alternatives would be to use the
1134         # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
1135         # but effectively, both values aren't binding in any way.
1136         # If this breaks your use case, please file a bug on Github.
1137         if uid < 1000:
1138             continue
1139
1140         process_environ = {}
1141         for environ_entry in open(environ_file, 'rb').read().split(b"\0"):
1142             try:
1143                 environ_entry = environ_entry.decode("ascii")
1144             except UnicodeDecodeError:
1145                 continue
1146             name, sep, value = environ_entry.partition("=")
1147             if name and sep:
1148                 if name == "DISPLAY" and "." in value:
1149                     value = value[:value.find(".")]
1150                 process_environ[name] = value
1151
1152         if "DISPLAY" not in process_environ:
1153             # Cannot work with this environment, skip.
1154             continue
1155
1156         # To allow scripts to detect batch invocation (especially useful for predetect)
1157         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
1158         process_environ["UID"] = str(uid)
1159
1160         display = process_environ["DISPLAY"]
1161
1162         if "XAUTHORITY" not in process_environ:
1163             # It's very likely that we cannot work with this environment either,
1164             # but keep it as a backup just in case we don't find anything else.
1165             backup_candidates[display] = process_environ
1166             continue
1167
1168         if display not in X11_displays_done:
1169             try:
1170                 pwent = pwd.getpwuid(uid)
1171             except KeyError:
1172                 # User has no pwd entry
1173                 continue
1174
1175             fork_child_autorandr(pwent, process_environ)
1176             X11_displays_done.add(display)
1177
1178     # Run autorandr for any users/displays which didn't have a process with
1179     # XAUTHORITY set.
1180     for display, process_environ in backup_candidates.items():
1181         if display not in X11_displays_done:
1182             try:
1183                 pwent = pwd.getpwuid(int(process_environ["UID"]))
1184             except KeyError:
1185                 # User has no pwd entry
1186                 continue
1187
1188             fork_child_autorandr(pwent, process_environ)
1189             X11_displays_done.add(display)
1190
1191
1192 def enabled_monitors(config):
1193     monitors = []
1194     for monitor in config:
1195         if "--off" in config[monitor].option_vector:
1196             continue
1197         monitors.append(monitor)
1198     return monitors
1199
1200
1201 def read_config(options, directory):
1202     """Parse a configuration config.ini from directory and merge it into
1203     the options dictionary"""
1204     config = configparser.ConfigParser()
1205     config.read(os.path.join(directory, "settings.ini"))
1206     if config.has_section("config"):
1207         for key, value in config.items("config"):
1208             options.setdefault("--%s" % key, value)
1209
1210 def main(argv):
1211     try:
1212         opts, args = getopt.getopt(argv[1:], "s:r:l:d:cfh",
1213                                    ["batch", "dry-run", "change", "cycle", "default=", "save=", "remove=", "load=",
1214                                     "force", "fingerprint", "config", "debug", "skip-options=", "help",
1215                                     "current", "detected", "version"])
1216     except getopt.GetoptError as e:
1217         print("Failed to parse options: {0}.\n"
1218               "Use --help to get usage information.".format(str(e)),
1219               file=sys.stderr)
1220         sys.exit(posix.EX_USAGE)
1221
1222     options = dict(opts)
1223
1224     if "-h" in options or "--help" in options:
1225         exit_help()
1226
1227     if "--version" in options:
1228         print("autorandr " + __version__)
1229         sys.exit(0)
1230
1231     if "--current" in options and "--detected" in options:
1232         print("--current and --detected are mutually exclusive.", file=sys.stderr)
1233         sys.exit(posix.EX_USAGE)
1234
1235     # Batch mode
1236     if "--batch" in options:
1237         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
1238             dispatch_call_to_sessions([x for x in argv if x != "--batch"])
1239         else:
1240             print("--batch mode can only be used by root and if $DISPLAY is unset")
1241         return
1242     if "AUTORANDR_BATCH_PID" in os.environ:
1243         user = pwd.getpwuid(os.getuid())
1244         user = user.pw_name if user else "#%d" % os.getuid()
1245         print("autorandr running as user %s (started from batch instance)" % user)
1246
1247     profiles = {}
1248     profile_symlinks = {}
1249     try:
1250         # Load profiles from each XDG config directory
1251         # The XDG spec says that earlier entries should take precedence, so reverse the order
1252         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
1253             system_profile_path = os.path.join(directory, "autorandr")
1254             if os.path.isdir(system_profile_path):
1255                 profiles.update(load_profiles(system_profile_path))
1256                 profile_symlinks.update(get_symlinks(system_profile_path))
1257                 read_config(options, system_profile_path)
1258         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
1259         # profile_path is also used later on to store configurations
1260         profile_path = os.path.expanduser("~/.autorandr")
1261         if not os.path.isdir(profile_path):
1262             # Elsewise, follow the XDG specification
1263             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
1264         if os.path.isdir(profile_path):
1265             profiles.update(load_profiles(profile_path))
1266             profile_symlinks.update(get_symlinks(profile_path))
1267             read_config(options, profile_path)
1268         # Sort by descending mtime
1269         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
1270     except Exception as e:
1271         raise AutorandrException("Failed to load profiles", e)
1272
1273     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}
1274
1275     exec_scripts(None, "predetect")
1276     config, modes = parse_xrandr_output()
1277
1278     if "--fingerprint" in options:
1279         output_setup(config, sys.stdout)
1280         sys.exit(0)
1281
1282     if "--config" in options:
1283         output_configuration(config, sys.stdout)
1284         sys.exit(0)
1285
1286     if "--skip-options" in options:
1287         skip_options = [y[2:] if y[:2] == "--" else y for y in (x.strip() for x in options["--skip-options"].split(","))]
1288         for profile in profiles.values():
1289             for output in profile["config"].values():
1290                 output.set_ignored_options(skip_options)
1291         for output in config.values():
1292             output.set_ignored_options(skip_options)
1293
1294     if "-s" in options:
1295         options["--save"] = options["-s"]
1296     if "--save" in options:
1297         if options["--save"] in (x[0] for x in virtual_profiles):
1298             raise AutorandrException("Cannot save current configuration as profile '%s':\n"
1299                                      "This configuration name is a reserved virtual configuration." % options["--save"])
1300         error = check_configuration_pre_save(config)
1301         if error:
1302             print("Cannot save current configuration as profile '%s':" % options["--save"])
1303             print(error)
1304             sys.exit(1)
1305         try:
1306             profile_folder = os.path.join(profile_path, options["--save"])
1307             save_configuration(profile_folder, options['--save'], config, forced="--force" in options)
1308             exec_scripts(profile_folder, "postsave", {
1309                 "CURRENT_PROFILE": options["--save"],
1310                 "PROFILE_FOLDER": profile_folder,
1311                 "MONITORS": ":".join(enabled_monitors(config)),
1312             })
1313         except AutorandrException as e:
1314             raise e
1315         except Exception as e:
1316             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
1317         print("Saved current configuration as profile '%s'" % options["--save"])
1318         sys.exit(0)
1319
1320     if "-r" in options:
1321         options["--remove"] = options["-r"]
1322     if "--remove" in options:
1323         if options["--remove"] in (x[0] for x in virtual_profiles):
1324             raise AutorandrException("Cannot remove profile '%s':\n"
1325                                      "This configuration name is a reserved virtual configuration." % options["--remove"])
1326         if options["--remove"] not in profiles.keys():
1327             raise AutorandrException("Cannot remove profile '%s':\n"
1328                                      "This profile does not exist." % options["--remove"])
1329         try:
1330             remove = True
1331             profile_folder = os.path.join(profile_path, options["--remove"])
1332             profile_dirlist = os.listdir(profile_folder)
1333             profile_dirlist.remove("config")
1334             profile_dirlist.remove("setup")
1335             if profile_dirlist:
1336                 print("Profile folder '%s' contains the following additional files:\n"
1337                       "---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
1338                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
1339                 if response != "yes":
1340                     remove = False
1341             if remove is True:
1342                 shutil.rmtree(profile_folder)
1343                 print("Removed profile '%s'" % options["--remove"])
1344             else:
1345                 print("Profile '%s' was not removed" % options["--remove"])
1346         except Exception as e:
1347             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1348         sys.exit(0)
1349
1350     detected_profiles = find_profiles(config, profiles)
1351     load_profile = False
1352
1353     if "-l" in options:
1354         options["--load"] = options["-l"]
1355     if "--load" in options:
1356         load_profile = options["--load"]
1357     elif len(args) == 1:
1358         load_profile = args[0]
1359     else:
1360         # Find the active profile(s) first, for the block script (See #42)
1361         current_profiles = []
1362         for profile_name in profiles.keys():
1363             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1364             if configs_are_equal:
1365                 current_profiles.append(profile_name)
1366         block_script_metadata = {
1367             "CURRENT_PROFILE": "".join(current_profiles[:1]),
1368             "CURRENT_PROFILES": ":".join(current_profiles)
1369         }
1370
1371         best_index = 9999
1372         for profile_name in profiles.keys():
1373             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1374                 if "--current" not in options and "--detected" not in options:
1375                     print("%s (blocked)" % profile_name)
1376                 continue
1377             props = []
1378             if profile_name in detected_profiles:
1379                 if len(detected_profiles) == 1:
1380                     index = 1
1381                     props.append("(detected)")
1382                 else:
1383                     index = detected_profiles.index(profile_name) + 1
1384                     props.append("(detected) (%d%s match)" % (index, ["st", "nd", "rd"][index - 1] if index < 4 else "th"))
1385                 if ("-c" in options or "--change" in options) and index < best_index:
1386                     load_profile = profile_name
1387                     best_index = index
1388                 if "--cycle" in options and index < best_index:
1389                     if profile_name not in current_profiles:
1390                         load_profile = profile_name
1391                         best_index = index
1392             elif "--detected" in options:
1393                 continue
1394             if profile_name in current_profiles:
1395                 props.append("(current)")
1396             elif "--current" in options:
1397                 continue
1398             if "--current" in options or "--detected" in options:
1399                 print("%s" % (profile_name, ))
1400             else:
1401                 print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)))
1402             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1403                 print_profile_differences(config, profiles[profile_name]["config"])
1404
1405     if "-d" in options:
1406         options["--default"] = options["-d"]
1407     if not load_profile and "--default" in options and ("-c" in options or "--change" in options or "--cycle" in options):
1408         load_profile = options["--default"]
1409
1410     if load_profile:
1411         if load_profile in profile_symlinks:
1412             if "--debug" in options:
1413                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1414             load_profile = profile_symlinks[load_profile]
1415
1416         if load_profile in (x[0] for x in virtual_profiles):
1417             load_config = generate_virtual_profile(config, modes, load_profile)
1418             scripts_path = os.path.join(profile_path, load_profile)
1419         else:
1420             try:
1421                 profile = profiles[load_profile]
1422                 load_config = profile["config"]
1423                 scripts_path = profile["path"]
1424             except KeyError:
1425                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1426             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1427                 update_mtime(os.path.join(scripts_path, "config"))
1428         add_unused_outputs(config, load_config)
1429         if load_config == dict(config) and "-f" not in options and "--force" not in options:
1430             print("Config already loaded", file=sys.stderr)
1431             sys.exit(0)
1432         if "--debug" in options and load_config != dict(config):
1433             print("Loading profile '%s'" % load_profile)
1434             print_profile_differences(config, load_config)
1435
1436         remove_irrelevant_outputs(config, load_config)
1437
1438         try:
1439             if "--dry-run" in options:
1440                 apply_configuration(load_config, config, True)
1441             else:
1442                 script_metadata = {
1443                     "CURRENT_PROFILE": load_profile,
1444                     "PROFILE_FOLDER": scripts_path,
1445                     "MONITORS": ":".join(enabled_monitors(load_config)),
1446                 }
1447                 exec_scripts(scripts_path, "preswitch", script_metadata)
1448                 if "--debug" in options:
1449                     print("Going to run:")
1450                     apply_configuration(load_config, config, True)
1451                 apply_configuration(load_config, config, False)
1452                 exec_scripts(scripts_path, "postswitch", script_metadata)
1453         except AutorandrException as e:
1454             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1455         except Exception as e:
1456             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1457
1458         if "--dry-run" not in options and "--debug" in options:
1459             new_config, _ = parse_xrandr_output()
1460             if not is_equal_configuration(new_config, load_config):
1461                 print("The configuration change did not go as expected:")
1462                 print_profile_differences(new_config, load_config)
1463
1464     sys.exit(0)
1465
1466
1467 def exception_handled_main(argv=sys.argv):
1468     try:
1469         main(sys.argv)
1470     except AutorandrException as e:
1471         print(e, file=sys.stderr)
1472         sys.exit(1)
1473     except Exception as e:
1474         if not len(str(e)):  # BdbQuit
1475             print("Exception: {0}".format(e.__class__.__name__))
1476             sys.exit(2)
1477
1478         print("Unhandled exception ({0}). Please report this as a bug at "
1479               "https://github.com/phillipberndt/autorandr/issues.".format(e),
1480               file=sys.stderr)
1481         raise
1482
1483
1484 if __name__ == '__main__':
1485     exception_handled_main()