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