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