]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blob - autorandr.py
Minor cleanup in virtual profile description output
[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
40 from collections import OrderedDict
41 from distutils.version import LooseVersion as Version
42 from functools import reduce
43 from itertools import chain
44
45 try:
46     input = raw_input
47 except NameError:
48     pass
49
50 virtual_profiles = [
51     # (name, description, callback)
52     ("common", "Clone all connected outputs at the largest common resolution", None),
53     ("clone-largest", "Clone all connected outputs with the largest resolution (scaled down if necessary)", None),
54     ("horizontal", "Stack all connected outputs horizontally at their largest resolution", None),
55     ("vertical", "Stack all connected outputs vertically at their largest resolution", None),
56 ]
57
58 help_text = """
59 Usage: autorandr [options]
60
61 -h, --help              get this small help
62 -c, --change            reload current setup
63 -s, --save <profile>    save your current setup to profile <profile>
64 -r, --remove <profile>  remove profile <profile>
65 -l, --load <profile>    load profile <profile>
66 -d, --default <profile> make profile <profile> the default profile
67 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
68                         to skip both in detecting changes and applying a profile
69 --force                 force (re)loading of a profile
70 --fingerprint           fingerprint your current hardware setup
71 --config                dump your current xrandr setup
72 --dry-run               don't change anything, only print the xrandr commands
73 --debug                 enable verbose output
74 --batch                 run autorandr for all users with active X11 sessions
75
76  To prevent a profile from being loaded, place a script called "block" in its
77  directory. The script is evaluated before the screen setup is inspected, and
78  in case of it returning a value of 0 the profile is skipped. This can be used
79  to query the status of a docking station you are about to leave.
80
81  If no suitable profile can be identified, the current configuration is kept.
82  To change this behaviour and switch to a fallback configuration, specify
83  --default <profile>.
84
85  Another script called "postswitch" can be placed in the directory
86  ~/.config/autorandr (or ~/.autorandr if you have an old installation) as well
87  as in any profile directories: The scripts are executed after a mode switch
88  has taken place and can notify window managers.
89
90  The following virtual configurations are available:
91 """.strip()
92
93 class AutorandrException(Exception):
94     def __init__(self, message, original_exception=None, report_bug=False):
95         self.message = message
96         self.report_bug = report_bug
97         if original_exception:
98             self.original_exception = original_exception
99             trace = sys.exc_info()[2]
100             while trace.tb_next:
101                 trace = trace.tb_next
102             self.line = trace.tb_lineno
103             self.file_name = trace.tb_frame.f_code.co_filename
104         else:
105             try:
106                 import inspect
107                 frame = inspect.currentframe().f_back
108                 self.line = frame.f_lineno
109                 self.file_name = frame.f_code.co_filename
110             except:
111                 self.line = None
112                 self.file_name = None
113             self.original_exception = None
114
115         if os.path.abspath(self.file_name) == os.path.abspath(sys.argv[0]):
116             self.file_name = None
117
118     def __str__(self):
119         retval = [ self.message ]
120         if self.line:
121             retval.append(" (line %d%s)" % (self.line, ("; %s" % self.file_name) if self.file_name else ""))
122         if self.original_exception:
123             retval.append(":\n  ")
124             retval.append(str(self.original_exception).replace("\n", "\n  "))
125         if self.report_bug:
126             retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
127                           "\nhttps://github.com/phillipberndt/autorandr/issues"
128                          "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
129         return "".join(retval)
130
131 class XrandrOutput(object):
132     "Represents an XRandR output"
133
134     # This regular expression is used to parse an output in `xrandr --verbose'
135     XRANDR_OUTPUT_REGEXP = """(?x)
136         ^(?P<output>[^ ]+)\s+                                                           # Line starts with output name
137         (?:                                                                             # Differentiate disconnected and connected in first line
138             disconnected |
139             unknown\ connection |
140             (?P<connected>connected)
141         )
142         \s*
143         (?P<primary>primary\ )?                                                         # Might be primary screen
144         (?:\s*
145             (?P<width>[0-9]+)x(?P<height>[0-9]+)                                        # Resolution (might be overridden below!)
146             \+(?P<x>-?[0-9]+)\+(?P<y>-?[0-9]+)\s+                                       # Position
147             (?:\(0x[0-9a-fA-F]+\)\s+)?                                                  # XID
148             (?P<rotate>(?:normal|left|right|inverted))\s+                               # Rotation
149             (?:(?P<reflect>X\ and\ Y|X|Y)\ axis)?                                       # Reflection
150         )?                                                                              # .. but everything of the above only if the screen is in use.
151         (?:[\ \t]*\([^\)]+\))(?:\s*[0-9]+mm\sx\s[0-9]+mm)?
152         (?:[\ \t]*panning\ (?P<panning>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?                 # Panning information
153         (?:[\ \t]*tracking\ (?P<tracking>[0-9]+x[0-9]+\+[0-9]+\+[0-9]+))?               # Tracking information
154         (?:[\ \t]*border\ (?P<border>(?:[0-9]+/){3}[0-9]+))?                            # Border information
155         (?:\s*(?:                                                                       # Properties of the output
156             Gamma: (?P<gamma>(?:inf|[0-9\.: e])+) |                                     # Gamma value
157             Transform: (?P<transform>(?:[\-0-9\. ]+\s+){3}) |                           # Transformation matrix
158             EDID: (?P<edid>\s*?(?:\\n\\t\\t[0-9a-f]+)+) |                               # EDID of the output
159             (?![0-9])[^:\s][^:\n]+:.*(?:\s\\t[\\t ].+)*                                 # Other properties
160         ))+
161         \s*
162         (?P<modes>(?:
163             (?P<mode_name>\S+).+?\*current.*\s+                                         # Interesting (current) resolution: Extract rate
164              h:\s+width\s+(?P<mode_width>[0-9]+).+\s+
165              v:\s+height\s+(?P<mode_height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
166             \S+(?:(?!\*current).)+\s+h:.+\s+v:.+\s*                                     # Other resolutions
167         )*)
168     """
169
170     XRANDR_OUTPUT_MODES_REGEXP = """(?x)
171         (?P<name>\S+).+?(?P<preferred>\+preferred)?\s+
172          h:\s+width\s+(?P<width>[0-9]+).+\s+
173          v:\s+height\s+(?P<height>[0-9]+).+clock\s+(?P<rate>[0-9\.]+)Hz\s* |
174     """
175
176     XRANDR_13_DEFAULTS = {
177         "transform": "1,0,0,0,1,0,0,0,1",
178         "panning": "0x0",
179     }
180
181     XRANDR_12_DEFAULTS = {
182         "reflect": "normal",
183         "rotate": "normal",
184         "gamma": "1.0:1.0:1.0",
185     }
186
187     XRANDR_DEFAULTS = dict(list(XRANDR_13_DEFAULTS.items()) + list(XRANDR_12_DEFAULTS.items()))
188
189     EDID_UNAVAILABLE = "--CONNECTED-BUT-EDID-UNAVAILABLE-"
190
191     def __repr__(self):
192         return "<%s%s %s>" % (self.output, self.short_edid, " ".join(self.option_vector))
193
194     @property
195     def short_edid(self):
196         return ("%s..%s" % (self.edid[:5], self.edid[-5:])) if self.edid else ""
197
198     @property
199     def options_with_defaults(self):
200         "Return the options dictionary, augmented with the default values that weren't set"
201         if "off" in self.options:
202             return self.options
203         options = {}
204         if xrandr_version() >= Version("1.3"):
205             options.update(self.XRANDR_13_DEFAULTS)
206         if xrandr_version() >= Version("1.2"):
207             options.update(self.XRANDR_12_DEFAULTS)
208         options.update(self.options)
209         return { a: b for a, b in options.items() if a not in self.ignored_options }
210
211     @property
212     def filtered_options(self):
213         "Return a dictionary of options without ignored options"
214         return { a: b for a, b in self.options.items() if a not in self.ignored_options }
215
216     @property
217     def option_vector(self):
218         "Return the command line parameters for XRandR for this instance"
219         return sum([["--%s" % option[0], option[1]] if option[1] else ["--%s" % option[0]] for option in chain((("output", self.output),), sorted(self.options_with_defaults.items()))], [])
220
221     @property
222     def option_string(self):
223         "Return the command line parameters in the configuration file format"
224         return "\n".join([ " ".join(option) if option[1] else option[0] for option in chain((("output", self.output),), sorted(self.filtered_options.items()))])
225
226     @property
227     def sort_key(self):
228         "Return a key to sort the outputs for xrandr invocation"
229         if not self.edid:
230             return -2
231         if "off" in self.options:
232             return -1
233         if "pos" in self.options:
234             x, y = map(float, self.options["pos"].split("x"))
235         else:
236             x, y = 0, 0
237         return x + 10000 * y
238
239     def __init__(self, output, edid, options):
240         "Instanciate using output name, edid and a dictionary of XRandR command line parameters"
241         self.output = output
242         self.edid = edid
243         self.options = options
244         self.ignored_options = []
245         self.remove_default_option_values()
246
247     def set_ignored_options(self, options):
248         "Set a list of xrandr options that are never used (neither when comparing configurations nor when applying them)"
249         self.ignored_options = list(options)
250
251     def remove_default_option_values(self):
252         "Remove values from the options dictionary that are superflous"
253         if "off" in self.options and len(self.options.keys()) > 1:
254             self.options = { "off": None }
255             return
256         for option, default_value in self.XRANDR_DEFAULTS.items():
257             if option in self.options and self.options[option] == default_value:
258                 del self.options[option]
259
260     @classmethod
261     def from_xrandr_output(cls, xrandr_output):
262         """Instanciate an XrandrOutput from the output of `xrandr --verbose'
263
264         This method also returns a list of modes supported by the output.
265         """
266         try:
267             xrandr_output = xrandr_output.replace("\r\n", "\n")
268             match_object = re.search(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
269         except:
270             raise AutorandrException("Parsing XRandR output failed, there is an error in the regular expression.", report_bug = True)
271         if not match_object:
272             debug = debug_regexp(XrandrOutput.XRANDR_OUTPUT_REGEXP, xrandr_output)
273             raise AutorandrException("Parsing XRandR output failed, the regular expression did not match: %s" % debug, report_bug = True)
274         remainder = xrandr_output[len(match_object.group(0)):]
275         if remainder:
276             raise AutorandrException(("Parsing XRandR output failed, %d bytes left unmatched after regular expression, "
277                                 "starting at byte %d with ..'%s'.") % (len(remainder), len(match_object.group(0)), remainder[:10]), report_bug=True)
278
279         match = match_object.groupdict()
280
281         modes = []
282         if match["modes"]:
283             modes = [ x.groupdict() for x in re.finditer(XrandrOutput.XRANDR_OUTPUT_MODES_REGEXP, match["modes"]) if x.group("name") ]
284             if not modes:
285                 raise AutorandrException("Parsing XRandR output failed, couldn't find any display modes", report_bug=True)
286
287         options = {}
288         if not match["connected"]:
289             edid = None
290         else:
291             edid = "".join(match["edid"].strip().split()) if match["edid"] else "%s-%s" % (XrandrOutput.EDID_UNAVAILABLE, match["output"])
292
293         if not match["width"]:
294             options["off"] = None
295         else:
296             if match["mode_name"]:
297                 options["mode"] = match["mode_name"]
298             elif match["mode_width"]:
299                 options["mode"] = "%sx%s" % (match["mode_width"], match["mode_height"])
300             else:
301                 if match["rotate"] not in ("left", "right"):
302                     options["mode"] = "%sx%s" % (match["width"], match["height"])
303                 else:
304                     options["mode"] = "%sx%s" % (match["height"], match["width"])
305             options["rotate"] = match["rotate"]
306             if match["primary"]:
307                 options["primary"] = None
308             if match["reflect"] == "X":
309                 options["reflect"] = "x"
310             elif match["reflect"] == "Y":
311                 options["reflect"] = "y"
312             elif match["reflect"] == "X and Y":
313                 options["reflect"] = "xy"
314             options["pos"] = "%sx%s" % (match["x"], match["y"])
315             if match["panning"]:
316                 panning = [ match["panning"] ]
317                 if match["tracking"]:
318                     panning += [ "/", match["tracking"] ]
319                     if match["border"]:
320                         panning += [ "/", match["border"] ]
321                 options["panning"] = "".join(panning)
322             if match["transform"]:
323                 transformation = ",".join(match["transform"].strip().split())
324                 if transformation != "1.000000,0.000000,0.000000,0.000000,1.000000,0.000000,0.000000,0.000000,1.000000":
325                     options["transform"] = transformation
326                     if not match["mode_name"]:
327                         # TODO We'd need to apply the reverse transformation here. Let's see if someone complains, I doubt that this
328                         # special case is actually required.
329                         print("Warning: Output %s has a transformation applied. Could not determine correct mode! Using `%s'." % (match["output"], options["mode"]), file=sys.stderr)
330             if match["gamma"]:
331                 gamma = match["gamma"].strip()
332                 # xrandr prints different values in --verbose than it accepts as a parameter value for --gamma
333                 # Also, it is not able to work with non-standard gamma ramps. Finally, it auto-corrects 0 to 1,
334                 # so we approximate by 1e-10.
335                 gamma = ":".join([ str(max(1e-10, round(1./float(x), 3))) for x in gamma.split(":") ])
336                 options["gamma"] = gamma
337             if match["rate"]:
338                 options["rate"] = match["rate"]
339
340         return XrandrOutput(match["output"], edid, options), modes
341
342     @classmethod
343     def from_config_file(cls, edid_map, configuration):
344         "Instanciate an XrandrOutput from the contents of a configuration file"
345         options = {}
346         for line in configuration.split("\n"):
347             if line:
348                 line = line.split(None, 1)
349                 if line and line[0].startswith("#"):
350                     continue
351                 options[line[0]] = line[1] if len(line) > 1 else None
352
353         edid = None
354
355         if options["output"] in edid_map:
356             edid = edid_map[options["output"]]
357         else:
358             # This fuzzy matching is for legacy autorandr that used sysfs output names
359             fuzzy_edid_map = [ re.sub("(card[0-9]+|-)", "", x) for x in edid_map.keys() ]
360             fuzzy_output = re.sub("(card[0-9]+|-)", "", options["output"])
361             if fuzzy_output in fuzzy_edid_map:
362                 edid = edid_map[list(edid_map.keys())[fuzzy_edid_map.index(fuzzy_output)]]
363             elif "off" not in options:
364                 raise AutorandrException("Failed to find an EDID for output `%s' in setup file, required as `%s' is not off in config file." % (options["output"], options["output"]))
365         output = options["output"]
366         del options["output"]
367
368         return XrandrOutput(output, edid, options)
369
370     def edid_equals(self, other):
371         "Compare to another XrandrOutput's edid and on/off-state, taking legacy autorandr behaviour (md5sum'ing) into account"
372         if self.edid and other.edid:
373             if len(self.edid) == 32 and len(other.edid) != 32 and not other.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
374                 return hashlib.md5(binascii.unhexlify(other.edid)).hexdigest() == self.edid
375             if len(self.edid) != 32 and len(other.edid) == 32 and not self.edid.startswith(XrandrOutput.EDID_UNAVAILABLE):
376                 return hashlib.md5(binascii.unhexlify(self.edid)).hexdigest() == other.edid
377         return self.edid == other.edid
378
379     def __ne__(self, other):
380         return not (self == other)
381
382     def __eq__(self, other):
383         return self.edid_equals(other) and self.output == other.output and self.filtered_options == other.filtered_options
384
385     def verbose_diff(self, other):
386         "Compare to another XrandrOutput and return a list of human readable differences"
387         diffs = []
388         if not self.edid_equals(other):
389             diffs.append("EDID `%s' differs from `%s'" % (self.short_edid, other.short_edid))
390         if self.output != other.output:
391             diffs.append("Output name `%s' differs from `%s'" % (self.output, other.output))
392         if "off" in self.options and "off" not in other.options:
393             diffs.append("The output is disabled currently, but active in the new configuration")
394         elif "off" in other.options and "off" not in self.options:
395             diffs.append("The output is currently enabled, but inactive in the new configuration")
396         else:
397             for name in set(chain.from_iterable((self.options.keys(), other.options.keys()))):
398                 if name not in other.options:
399                     diffs.append("Option --%s %sis not present in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else ""))
400                 elif name not in self.options:
401                     diffs.append("Option --%s (`%s' in the new configuration) is not present currently" % (name, other.options[name]))
402                 elif self.options[name] != other.options[name]:
403                     diffs.append("Option --%s %sis `%s' in the new configuration" % (name, "(= `%s') " % self.options[name] if self.options[name] else "", other.options[name]))
404         return diffs
405
406 def xrandr_version():
407     "Return the version of XRandR that this system uses"
408     if getattr(xrandr_version, "version", False) is False:
409         version_string = os.popen("xrandr -v").read()
410         try:
411             version = re.search("xrandr program version\s+([0-9\.]+)", version_string).group(1)
412             xrandr_version.version = Version(version)
413         except AttributeError:
414             xrandr_version.version = Version("1.3.0")
415
416     return xrandr_version.version
417
418 def debug_regexp(pattern, string):
419     "Use the partial matching functionality of the regex module to display debug info on a non-matching regular expression"
420     try:
421         import regex
422         bounds = ( 0, len(string) )
423         while bounds[0] != bounds[1]:
424             half = int((bounds[0] + bounds[1]) / 2)
425             if half == bounds[0]:
426                 break
427             bounds = (half, bounds[1]) if regex.search(pattern, string[:half], partial=True) else (bounds[0], half - 1)
428         partial_length = bounds[0]
429         return ("Regular expression matched until position "
430               "%d, ..'%s', and did not match from '%s'.." % (partial_length, string[max(0, partial_length-20):partial_length],
431                                                              string[partial_length:partial_length+10]))
432     except ImportError:
433         pass
434     return "Debug information would be available if the `regex' module was installed."
435
436 def parse_xrandr_output():
437     "Parse the output of `xrandr --verbose' into a list of outputs"
438     xrandr_output = os.popen("xrandr -q --verbose").read()
439     if not xrandr_output:
440         raise AutorandrException("Failed to run xrandr")
441
442     # We are not interested in screens
443     xrandr_output = re.sub("(?m)^Screen [0-9].+", "", xrandr_output).strip()
444
445     # Split at output boundaries and instanciate an XrandrOutput per output
446     split_xrandr_output = re.split("(?m)^([^ ]+ (?:(?:dis)?connected|unknown connection).*)$", xrandr_output)
447     if len(split_xrandr_output) < 2:
448         raise AutorandrException("No output boundaries found", report_bug=True)
449     outputs = OrderedDict()
450     modes = OrderedDict()
451     for i in range(1, len(split_xrandr_output), 2):
452         output_name = split_xrandr_output[i].split()[0]
453         output, output_modes = XrandrOutput.from_xrandr_output("".join(split_xrandr_output[i:i+2]))
454         outputs[output_name] = output
455         if output_modes:
456             modes[output_name] = output_modes
457
458     return outputs, modes
459
460 def load_profiles(profile_path):
461     "Load the stored profiles"
462
463     profiles = {}
464     for profile in os.listdir(profile_path):
465         config_name = os.path.join(profile_path, profile, "config")
466         setup_name  = os.path.join(profile_path, profile, "setup")
467         if not os.path.isfile(config_name) or not os.path.isfile(setup_name):
468             continue
469
470         edids = dict([ x.split() for x in (y.strip() for y in open(setup_name).readlines()) if x and x[0] != "#" ])
471
472         config = {}
473         buffer = []
474         for line in chain(open(config_name).readlines(), ["output"]):
475             if line[:6] == "output" and buffer:
476                 config[buffer[0].strip().split()[-1]] = XrandrOutput.from_config_file(edids, "".join(buffer))
477                 buffer = [ line ]
478             else:
479                 buffer.append(line)
480
481         for output_name in list(config.keys()):
482             if config[output_name].edid is None:
483                 del config[output_name]
484
485         profiles[profile] = { "config": config, "path": os.path.join(profile_path, profile), "config-mtime": os.stat(config_name).st_mtime }
486
487     return profiles
488
489 def get_symlinks(profile_path):
490     "Load all symlinks from a directory"
491
492     symlinks = {}
493     for link in os.listdir(profile_path):
494         file_name = os.path.join(profile_path, link)
495         if os.path.islink(file_name):
496             symlinks[link] = os.readlink(file_name)
497
498     return symlinks
499
500 def find_profiles(current_config, profiles):
501     "Find profiles matching the currently connected outputs"
502     detected_profiles = []
503     for profile_name, profile in profiles.items():
504         config = profile["config"]
505         matches = True
506         for name, output in config.items():
507             if not output.edid:
508                 continue
509             if name not in current_config or not output.edid_equals(current_config[name]):
510                 matches = False
511                 break
512         if not matches or any(( name not in config.keys() for name in current_config.keys() if current_config[name].edid )):
513             continue
514         if matches:
515             detected_profiles.append(profile_name)
516     return detected_profiles
517
518 def profile_blocked(profile_path, meta_information=None):
519     """Check if a profile is blocked.
520
521     meta_information is expected to be an dictionary. It will be passed to the block scripts
522     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
523     """
524     return not exec_scripts(profile_path, "block", meta_information)
525
526 def output_configuration(configuration, config):
527     "Write a configuration file"
528     outputs = sorted(configuration.keys(), key=lambda x: configuration[x].sort_key)
529     for output in outputs:
530         print(configuration[output].option_string, file=config)
531
532 def output_setup(configuration, setup):
533     "Write a setup (fingerprint) file"
534     outputs = sorted(configuration.keys())
535     for output in outputs:
536         if configuration[output].edid:
537             print(output, configuration[output].edid, file=setup)
538
539 def save_configuration(profile_path, configuration):
540     "Save a configuration into a profile"
541     if not os.path.isdir(profile_path):
542         os.makedirs(profile_path)
543     with open(os.path.join(profile_path, "config"), "w") as config:
544         output_configuration(configuration, config)
545     with open(os.path.join(profile_path, "setup"), "w") as setup:
546         output_setup(configuration, setup)
547
548 def update_mtime(filename):
549     "Update a file's mtime"
550     try:
551         os.utime(filename, None)
552         return True
553     except:
554         return False
555
556 def call_and_retry(*args, **kwargs):
557     """Wrapper around subprocess.call that retries failed calls.
558
559     This function calls subprocess.call and on non-zero exit states,
560     waits a second and then retries once. This mitigates #47,
561     a timing issue with some drivers.
562     """
563     if "dry_run" in kwargs:
564         dry_run = kwargs["dry_run"]
565         del kwargs["dry_run"]
566     else:
567         dry_run = False
568     kwargs_redirected = dict(kwargs)
569     if not dry_run:
570         if hasattr(subprocess, "DEVNULL"):
571             kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
572         else:
573             kwargs_redirected["stdout"] = open(os.devnull, "w")
574         kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
575     retval = subprocess.call(*args, **kwargs_redirected)
576     if retval != 0:
577         time.sleep(1)
578         retval = subprocess.call(*args, **kwargs)
579     return retval
580
581 def apply_configuration(new_configuration, current_configuration, dry_run=False):
582     "Apply a configuration"
583     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
584     if dry_run:
585         base_argv = [ "echo", "xrandr" ]
586     else:
587         base_argv = [ "xrandr" ]
588
589     # There are several xrandr / driver bugs we need to take care of here:
590     # - We cannot enable more than two screens at the same time
591     #   See https://github.com/phillipberndt/autorandr/pull/6
592     #   and commits f4cce4d and 8429886.
593     # - We cannot disable all screens
594     #   See https://github.com/phillipberndt/autorandr/pull/20
595     # - We should disable screens before enabling others, because there's
596     #   a limit on the number of enabled screens
597     # - We must make sure that the screen at 0x0 is activated first,
598     #   or the other (first) screen to be activated would be moved there.
599     # - If an active screen already has a transformation and remains active,
600     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
601     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
602     #   at least.)
603     # - Some implementations can not handle --transform at all, so avoid it unless
604     #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
605     # - Some implementations can not handle --panning without specifying --fb
606     #   explicitly, so avoid it unless necessary.
607     #   (See https://github.com/phillipberndt/autorandr/issues/72)
608
609     auxiliary_changes_pre = []
610     disable_outputs = []
611     enable_outputs = []
612     remain_active_count = 0
613     for output in outputs:
614         if not new_configuration[output].edid or "off" in new_configuration[output].options:
615             disable_outputs.append(new_configuration[output].option_vector)
616         else:
617             if "off" not in current_configuration[output].options:
618                 remain_active_count += 1
619
620             option_vector = new_configuration[output].option_vector
621             if xrandr_version() >= Version("1.3.0"):
622                 for option in ("transform", "panning"):
623                     if option in current_configuration[output].options:
624                         auxiliary_changes_pre.append(["--output", output, "--%s" % option, "none"])
625                     else:
626                         try:
627                             option_index = option_vector.index("--%s" % option)
628                             if option_vector[option_index+1] == XrandrOutput.XRANDR_DEFAULTS[option]:
629                                 option_vector = option_vector[:option_index] + option_vector[option_index+2:]
630                         except ValueError:
631                             pass
632
633             enable_outputs.append(option_vector)
634
635     # Perform pe-change auxiliary changes
636     if auxiliary_changes_pre:
637         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
638         if call_and_retry(argv, dry_run=dry_run) != 0:
639             raise AutorandrException("Command failed: %s" % " ".join(argv))
640
641     # Disable unused outputs, but make sure that there always is at least one active screen
642     disable_keep = 0 if remain_active_count else 1
643     if len(disable_outputs) > disable_keep:
644         if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs)), dry_run=dry_run) != 0:
645             # Disabling the outputs failed. Retry with the next command:
646             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
647             # This does not occur if simultaneously the primary screen is reset.
648             pass
649         else:
650             disable_outputs = disable_outputs[-1:] if disable_keep else []
651
652     # If disable_outputs still has more than one output in it, one of the xrandr-calls below would
653     # disable the last two screens. This is a problem, so if this would happen, instead disable only
654     # one screen in the first call below.
655     if len(disable_outputs) > 0 and len(disable_outputs) % 2 == 0:
656         # In the context of a xrandr call that changes the display state, `--query' should do nothing
657         disable_outputs.insert(0, ['--query'])
658
659     # Enable the remaining outputs in pairs of two operations
660     operations = disable_outputs + enable_outputs
661     for index in range(0, len(operations), 2):
662         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
663         if call_and_retry(argv, dry_run=dry_run) != 0:
664             raise AutorandrException("Command failed: %s" % " ".join(argv))
665
666 def is_equal_configuration(source_configuration, target_configuration):
667     "Check if all outputs from target are already configured correctly in source"
668     for output in target_configuration.keys():
669         if (output not in source_configuration) or (source_configuration[output] != target_configuration[output]):
670             return False
671     return True
672
673 def add_unused_outputs(source_configuration, target_configuration):
674     "Add outputs that are missing in target to target, in 'off' state"
675     for output_name, output in source_configuration.items():
676         if output_name not in target_configuration:
677             target_configuration[output_name] = XrandrOutput(output_name, output.edid, { "off": None })
678
679 def remove_irrelevant_outputs(source_configuration, target_configuration):
680     "Remove outputs from target that ought to be 'off' and already are"
681     for output_name, output in source_configuration.items():
682         if "off" in output.options and output_name in target_configuration and "off" in target_configuration[output_name].options:
683             del target_configuration[output_name]
684
685 def generate_virtual_profile(configuration, modes, profile_name):
686     "Generate one of the virtual profiles"
687     configuration = copy.deepcopy(configuration)
688     if profile_name == "common":
689         common_resolution = [ set(( ( mode["width"], mode["height"] ) for mode in output_modes )) for output, output_modes in modes.items() if configuration[output].edid ]
690         common_resolution = reduce(lambda a, b: a & b, common_resolution[1:], common_resolution[0])
691         common_resolution = sorted(common_resolution, key=lambda a: int(a[0])*int(a[1]))
692         if common_resolution:
693             for output in configuration:
694                 configuration[output].options = {}
695                 if output in modes and configuration[output].edid:
696                     configuration[output].options["mode"] = [ x["name"] for x in sorted(modes[output], key=lambda x: 0 if x["preferred"] else 1) if x["width"] == common_resolution[-1][0] and x["height"] == common_resolution[-1][1] ][0]
697                     configuration[output].options["pos"] = "0x0"
698                 else:
699                     configuration[output].options["off"] = None
700     elif profile_name in ("horizontal", "vertical"):
701         shift = 0
702         if profile_name == "horizontal":
703             shift_index = "width"
704             pos_specifier = "%sx0"
705         else:
706             shift_index = "height"
707             pos_specifier = "0x%s"
708
709         for output in configuration:
710             configuration[output].options = {}
711             if output in modes and configuration[output].edid:
712                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
713                 configuration[output].options["mode"] = mode["name"]
714                 configuration[output].options["rate"] = mode["rate"]
715                 configuration[output].options["pos"] = pos_specifier % shift
716                 shift += int(mode[shift_index])
717             else:
718                 configuration[output].options["off"] = None
719     elif profile_name == "clone-largest":
720         biggest_resolution = sorted([output_modes[0] for output, output_modes in modes.items()], key=lambda x: int(x["width"])*int(x["height"]), reverse=True)[0]
721         for output in configuration:
722             configuration[output].options = {}
723             if output in modes and configuration[output].edid:
724                 mode = sorted(modes[output], key=lambda a: int(a["width"])*int(a["height"]) + (10**6 if a["preferred"] else 0))[-1]
725                 configuration[output].options["mode"] = mode["name"]
726                 configuration[output].options["rate"] = mode["rate"]
727                 configuration[output].options["pos"] = "0x0"
728                 scale = max(float(biggest_resolution["width"]) / float(mode["width"]) ,float(biggest_resolution["height"]) / float(mode["height"]))
729                 mov_x = (float(mode["width"])*scale-float(biggest_resolution["width"]))/-2
730                 mov_y = (float(mode["height"])*scale-float(biggest_resolution["height"]))/-2
731                 configuration[output].options["transform"] = "{},0,{},0,{},{},0,0,1".format(scale, mov_x, scale, mov_y)
732             else:
733                 configuration[output].options["off"] = None
734     return configuration
735
736 def print_profile_differences(one, another):
737     "Print the differences between two profiles for debugging"
738     if one == another:
739         return
740     print("| Differences between the two profiles:", file=sys.stderr)
741     for output in set(chain.from_iterable((one.keys(), another.keys()))):
742         if output not in one:
743             if "off" not in another[output].options:
744                 print("| Output `%s' is missing from the active configuration" % output, file=sys.stderr)
745         elif output not in another:
746             if "off" not in one[output].options:
747                 print("| Output `%s' is missing from the new configuration" % output, file=sys.stderr)
748         else:
749             for line in one[output].verbose_diff(another[output]):
750                 print("| [Output %s] %s" % (output, line), file=sys.stderr)
751     print ("\\-", file=sys.stderr)
752
753 def exit_help():
754     "Print help and exit"
755     print(help_text)
756     for profile in virtual_profiles:
757         name, description = profile[:2]
758         description = [ description ]
759         max_width = 78-18
760         while len(description[0]) > max_width + 1:
761             left_over = description[0][max_width:]
762             description[0] = description[0][:max_width] + "-"
763             description.insert(1, "  %-15s %s" % ("", left_over))
764         description = "\n".join(description)
765         print("  %-15s %s" % (name, description))
766     sys.exit(0)
767
768 def exec_scripts(profile_path, script_name, meta_information=None):
769     """"Run userscripts
770
771     This will run all executables from the profile folder, and global per-user
772     and system-wide configuration folders, named script_name or residing in
773     subdirectories named script_name.d.
774
775     If profile_path is None, only global scripts will be invoked.
776
777     meta_information is expected to be an dictionary. It will be passed to the block scripts
778     in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
779
780     Returns True unless any of the scripts exited with non-zero exit status.
781     """
782     all_ok = True
783     if meta_information:
784         env = os.environ.copy()
785         env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
786     else:
787         env = os.environ.copy()
788
789     # If there are multiple candidates, the XDG spec tells to only use the first one.
790     ran_scripts = set()
791
792     user_profile_path = os.path.expanduser("~/.autorandr")
793     if not os.path.isdir(user_profile_path):
794         user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
795
796     candidate_directories = chain((user_profile_path,), (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")))
797     if profile_path:
798         candidate_directories = chain((profile_path,), candidate_directories)
799
800     for folder in candidate_directories:
801
802         if script_name not in ran_scripts:
803             script = os.path.join(folder, script_name)
804             if os.access(script, os.X_OK | os.F_OK):
805                 try:
806                     all_ok &= subprocess.call(script, env=env) != 0
807                 except:
808                     raise AutorandrException("Failed to execute user command: %s" % (script,))
809                 ran_scripts.add(script_name)
810
811         script_folder = os.path.join(folder, "%s.d" % script_name)
812         if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
813             for file_name in os.listdir(script_folder):
814                 check_name = "d/%s" % (file_name,)
815                 if check_name not in ran_scripts:
816                     script = os.path.join(script_folder, file_name)
817                     if os.access(script, os.X_OK | os.F_OK):
818                         try:
819                             all_ok &= subprocess.call(script, env=env) != 0
820                         except:
821                             raise AutorandrException("Failed to execute user command: %s" % (script,))
822                         ran_scripts.add(check_name)
823
824     return all_ok
825
826 def dispatch_call_to_sessions(argv):
827     """Invoke autorandr for each open local X11 session with the given options.
828
829     The function iterates over all processes not owned by root and checks
830     whether they have a DISPLAY variable set. It strips the screen from any
831     variable it finds (i.e. :0.0 becomes :0) and checks whether this display
832     has been handled already. If it has not, it forks, changes uid/gid to
833     the user owning the process, reuses the process's environment and runs
834     autorandr with the parameters from argv.
835
836     This function requires root permissions. It only works for X11 servers that
837     have at least one non-root process running. It is susceptible for attacks
838     where one user runs a process with another user's DISPLAY variable - in
839     this case, it might happen that autorandr is invoked for the other user,
840     which won't work. Since no other harm than prevention of automated
841     execution of autorandr can be done this way, the assumption is that in this
842     situation, the local administrator will handle the situation."""
843     X11_displays_done = set()
844
845     autorandr_binary = os.path.abspath(argv[0])
846
847     for directory in os.listdir("/proc"):
848         directory = os.path.join("/proc/", directory)
849         if not os.path.isdir(directory):
850             continue
851         environ_file = os.path.join(directory, "environ")
852         if not os.path.isfile(environ_file):
853             continue
854         uid = os.stat(environ_file).st_uid
855
856         # The following line assumes that user accounts start at 1000 and that
857         # no one works using the root or another system account. This is rather
858         # restrictive, but de facto default. Alternatives would be to use the
859         # UID_MIN from /etc/login.defs or FIRST_UID from /etc/adduser.conf;
860         # but effectively, both values aren't binding in any way.
861         # If this breaks your use case, please file a bug on Github.
862         if uid < 1000:
863             continue
864
865         process_environ = {}
866         for environ_entry in open(environ_file).read().split("\0"):
867             if "=" in environ_entry:
868                 name, value = environ_entry.split("=", 1)
869                 if name == "DISPLAY" and "." in value:
870                     value = value[:value.find(".")]
871                 process_environ[name] = value
872         display = process_environ["DISPLAY"] if "DISPLAY" in process_environ else None
873
874         # To allow scripts to detect batch invocation (especially useful for predetect)
875         process_environ["AUTORANDR_BATCH_PID"] = str(os.getpid())
876
877         if display and display not in X11_displays_done:
878             try:
879                 pwent = pwd.getpwuid(uid)
880             except KeyError:
881                 # User has no pwd entry
882                 continue
883
884             print("Running autorandr as %s for display %s" % (pwent.pw_name, display))
885             child_pid = os.fork()
886             if child_pid == 0:
887                 # This will throw an exception if any of the privilege changes fails,
888                 # so it should be safe. Also, note that since the environment
889                 # is taken from a process owned by the user, reusing it should
890                 # not leak any information.
891                 os.setgroups([])
892                 os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
893                 os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
894                 os.chdir(pwent.pw_dir)
895                 os.environ.clear()
896                 os.environ.update(process_environ)
897                 os.execl(autorandr_binary, autorandr_binary, *argv[1:])
898                 os.exit(1)
899             os.waitpid(child_pid, 0)
900
901             X11_displays_done.add(display)
902
903 def main(argv):
904     try:
905         options = dict(getopt.getopt(argv[1:], "s:r:l:d:cfh", [ "batch", "dry-run", "change", "default=", "save=", "remove=", "load=", "force", "fingerprint", "config", "debug", "skip-options=", "help" ])[0])
906     except getopt.GetoptError as e:
907         print("Failed to parse options: {0}.\n"
908               "Use --help to get usage information.".format(str(e)),
909               file=sys.stderr)
910         sys.exit(posix.EX_USAGE)
911
912     if "-h" in options or "--help" in options:
913         exit_help()
914
915     # Batch mode
916     if "--batch" in options:
917         if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
918             dispatch_call_to_sessions([ x for x in argv if x != "--batch" ])
919         else:
920             print("--batch mode can only be used by root and if $DISPLAY is unset")
921         return
922
923     profiles = {}
924     profile_symlinks = {}
925     try:
926         # Load profiles from each XDG config directory
927         # The XDG spec says that earlier entries should take precedence, so reverse the order
928         for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
929             system_profile_path = os.path.join(directory, "autorandr")
930             if os.path.isdir(system_profile_path):
931                 profiles.update(load_profiles(system_profile_path))
932                 profile_symlinks.update(get_symlinks(system_profile_path))
933         # For the user's profiles, prefer the legacy ~/.autorandr if it already exists
934         # profile_path is also used later on to store configurations
935         profile_path = os.path.expanduser("~/.autorandr")
936         if not os.path.isdir(profile_path):
937             # Elsewise, follow the XDG specification
938             profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
939         if os.path.isdir(profile_path):
940             profiles.update(load_profiles(profile_path))
941             profile_symlinks.update(get_symlinks(profile_path))
942         # Sort by descending mtime
943         profiles = OrderedDict(sorted(profiles.items(), key=lambda x: -x[1]["config-mtime"]))
944     except Exception as e:
945         raise AutorandrException("Failed to load profiles", e)
946
947     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 }
948
949     exec_scripts(None, "predetect")
950     config, modes = parse_xrandr_output()
951
952     if "--fingerprint" in options:
953         output_setup(config, sys.stdout)
954         sys.exit(0)
955
956     if "--config" in options:
957         output_configuration(config, sys.stdout)
958         sys.exit(0)
959
960     if "--skip-options" in options:
961         skip_options = [ y[2:] if y[:2] == "--" else y for y in ( x.strip() for x in options["--skip-options"].split(",") ) ]
962         for profile in profiles.values():
963             for output in profile["config"].values():
964                 output.set_ignored_options(skip_options)
965         for output in config.values():
966             output.set_ignored_options(skip_options)
967
968     if "-s" in options:
969         options["--save"] = options["-s"]
970     if "--save" in options:
971         if options["--save"] in ( x[0] for x in virtual_profiles ):
972             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
973         try:
974             profile_folder = os.path.join(profile_path, options["--save"])
975             save_configuration(profile_folder, config)
976             exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
977         except Exception as e:
978             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
979         print("Saved current configuration as profile '%s'" % options["--save"])
980         sys.exit(0)
981
982     if "-r" in options:
983         options["--remove"] = options["-r"]
984     if "--remove" in options:
985         if options["--remove"] in ( x[0] for x in virtual_profiles ):
986             raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
987         if options["--remove"] not in profiles.keys():
988             raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
989         try:
990             remove = True
991             profile_folder = os.path.join(profile_path, options["--remove"])
992             profile_dirlist = os.listdir(profile_folder)
993             profile_dirlist.remove("config")
994             profile_dirlist.remove("setup")
995             if profile_dirlist:
996                 print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
997                 response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
998                 if response != "yes":
999                     remove = False
1000             if remove is True:
1001                 shutil.rmtree(profile_folder)
1002                 print("Removed profile '%s'" % options["--remove"])
1003             else:
1004                 print("Profile '%s' was not removed" % options["--remove"])
1005         except Exception as e:
1006             raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
1007         sys.exit(0)
1008
1009     detected_profiles = find_profiles(config, profiles)
1010     load_profile = False
1011
1012     if "-l" in options:
1013         options["--load"] = options["-l"]
1014     if "--load" in options:
1015         load_profile = options["--load"]
1016     else:
1017         # Find the active profile(s) first, for the block script (See #42)
1018         current_profiles = []
1019         for profile_name in profiles.keys():
1020             configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
1021             if configs_are_equal:
1022                 current_profiles.append(profile_name)
1023         block_script_metadata = {
1024             "CURRENT_PROFILE":  "".join(current_profiles[:1]),
1025             "CURRENT_PROFILES": ":".join(current_profiles)
1026         }
1027
1028         for profile_name in profiles.keys():
1029             if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
1030                 print("%s (blocked)" % profile_name, file=sys.stderr)
1031                 continue
1032             props = []
1033             if profile_name in detected_profiles:
1034                 props.append("(detected)")
1035                 if ("-c" in options or "--change" in options) and not load_profile:
1036                     load_profile = profile_name
1037             if profile_name in current_profiles:
1038                 props.append("(current)")
1039             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
1040             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
1041                 print_profile_differences(config, profiles[profile_name]["config"])
1042
1043     if "-d" in options:
1044         options["--default"] = options["-d"]
1045     if not load_profile and "--default" in options:
1046         load_profile = options["--default"]
1047
1048     if load_profile:
1049         if load_profile in profile_symlinks:
1050             if "--debug" in options:
1051                 print("'%s' symlinked to '%s'" % (load_profile, profile_symlinks[load_profile]))
1052             load_profile = profile_symlinks[load_profile]
1053
1054         if load_profile in ( x[0] for x in virtual_profiles ):
1055             load_config = generate_virtual_profile(config, modes, load_profile)
1056             scripts_path = os.path.join(profile_path, load_profile)
1057         else:
1058             try:
1059                 profile = profiles[load_profile]
1060                 load_config = profile["config"]
1061                 scripts_path = profile["path"]
1062             except KeyError:
1063                 raise AutorandrException("Failed to load profile '%s': Profile not found" % load_profile)
1064             if load_profile in detected_profiles and detected_profiles[0] != load_profile:
1065                 update_mtime(os.path.join(scripts_path, "config"))
1066         add_unused_outputs(config, load_config)
1067         if load_config == dict(config) and not "-f" in options and not "--force" in options:
1068             print("Config already loaded", file=sys.stderr)
1069             sys.exit(0)
1070         if "--debug" in options and load_config != dict(config):
1071             print("Loading profile '%s'" % load_profile)
1072             print_profile_differences(config, load_config)
1073
1074         remove_irrelevant_outputs(config, load_config)
1075
1076         try:
1077             if "--dry-run" in options:
1078                 apply_configuration(load_config, config, True)
1079             else:
1080                 script_metadata = {
1081                     "CURRENT_PROFILE": load_profile,
1082                     "PROFILE_FOLDER": scripts_path,
1083                 }
1084                 exec_scripts(scripts_path, "preswitch", script_metadata)
1085                 if "--debug" in options:
1086                     print("Going to run:")
1087                     apply_configuration(load_config, config, True)
1088                 apply_configuration(load_config, config, False)
1089                 exec_scripts(scripts_path, "postswitch", script_metadata)
1090         except AutorandrException as e:
1091             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, e.report_bug)
1092         except Exception as e:
1093             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
1094
1095         if "--dry-run" not in options and "--debug" in options:
1096             new_config, _ = parse_xrandr_output()
1097             if not is_equal_configuration(new_config, load_config):
1098                 print("The configuration change did not go as expected:")
1099                 print_profile_differences(new_config, load_config)
1100
1101     sys.exit(0)
1102
1103 def exception_handled_main(argv=sys.argv):
1104     try:
1105         main(sys.argv)
1106     except AutorandrException as e:
1107         print(e, file=sys.stderr)
1108         sys.exit(1)
1109     except Exception as e:
1110         if not len(str(e)):  # BdbQuit
1111             print("Exception: {0}".format(e.__class__.__name__))
1112             sys.exit(2)
1113
1114         print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)
1115         raise
1116
1117 if __name__ == '__main__':
1118     exception_handled_main()