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