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