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