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