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