]> git.donarmstrong.com Git - deb_pkgs/autorandr.git/blobdiff - autorandr.py
Merge pull request #56 from nazar-pc/patch-1
[deb_pkgs/autorandr.git] / autorandr.py
index 315f62c86119c4c7f04ddd8dbb2a63682cdd8fb1..548f3129767ab32b52b561dc4f0f2fd6ab4bfd79 100755 (executable)
@@ -30,15 +30,22 @@ import getopt
 import hashlib
 import os
 import posix
 import hashlib
 import os
 import posix
+import pwd
 import re
 import subprocess
 import sys
 import re
 import subprocess
 import sys
+import shutil
+import time
 
 from collections import OrderedDict
 from distutils.version import LooseVersion as Version
 from functools import reduce
 from itertools import chain
 
 
 from collections import OrderedDict
 from distutils.version import LooseVersion as Version
 from functools import reduce
 from itertools import chain
 
+try:
+    input = raw_input
+except NameError:
+    pass
 
 virtual_profiles = [
     # (name, description, callback)
 
 virtual_profiles = [
     # (name, description, callback)
@@ -53,6 +60,7 @@ Usage: autorandr [options]
 -h, --help              get this small help
 -c, --change            reload current setup
 -s, --save <profile>    save your current setup to profile <profile>
 -h, --help              get this small help
 -c, --change            reload current setup
 -s, --save <profile>    save your current setup to profile <profile>
+-r, --remove <profile>  remove profile <profile>
 -l, --load <profile>    load profile <profile>
 -d, --default <profile> make profile <profile> the default profile
 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
 -l, --load <profile>    load profile <profile>
 -d, --default <profile> make profile <profile> the default profile
 --skip-options <option> comma separated list of xrandr arguments (e.g. "gamma")
@@ -62,6 +70,7 @@ Usage: autorandr [options]
 --config                dump your current xrandr setup
 --dry-run               don't change anything, only print the xrandr commands
 --debug                 enable verbose output
 --config                dump your current xrandr setup
 --dry-run               don't change anything, only print the xrandr commands
 --debug                 enable verbose output
+--batch                 run autorandr for all users with active X11 sessions
 
  To prevent a profile from being loaded, place a script call "block" in its
  directory. The script is evaluated before the screen setup is inspected, and
 
  To prevent a profile from being loaded, place a script call "block" in its
  directory. The script is evaluated before the screen setup is inspected, and
@@ -106,7 +115,8 @@ class AutorandrException(Exception):
             retval.append(":\n  ")
             retval.append(str(self.original_exception).replace("\n", "\n  "))
         if self.report_bug:
             retval.append(":\n  ")
             retval.append(str(self.original_exception).replace("\n", "\n  "))
         if self.report_bug:
-            retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream."
+            retval.append("\nThis appears to be a bug. Please help improving autorandr by reporting it upstream:"
+                          "\nhttps://github.com/phillipberndt/autorandr/issues"
                          "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
         return "".join(retval)
 
                          "\nPlease attach the output of `xrandr --verbose` to your bug report if appropriate.")
         return "".join(retval)
 
@@ -484,12 +494,13 @@ def find_profiles(current_config, profiles):
             detected_profiles.append(profile_name)
     return detected_profiles
 
             detected_profiles.append(profile_name)
     return detected_profiles
 
-def profile_blocked(profile_path):
-    "Check if a profile is blocked"
-    script = os.path.join(profile_path, "block")
-    if not os.access(script, os.X_OK | os.F_OK):
-        return False
-    return subprocess.call(script) == 0
+def profile_blocked(profile_path, meta_information=None):
+    """Check if a profile is blocked.
+
+    meta_information is expected to be an dictionary. It will be passed to the block scripts
+    in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
+    """
+    return not exec_scripts(profile_path, "block", meta_information)
 
 def output_configuration(configuration, config):
     "Write a configuration file"
 
 def output_configuration(configuration, config):
     "Write a configuration file"
@@ -521,6 +532,25 @@ def update_mtime(filename):
     except:
         return False
 
     except:
         return False
 
+def call_and_retry(*args, **kwargs):
+    """Wrapper around subprocess.call that retries failed calls.
+
+    This function calls subprocess.call and on non-zero exit states,
+    waits a second and then retries once. This mitigates #47,
+    a timing issue with some drivers.
+    """
+    kwargs_redirected = dict(kwargs)
+    if hasattr(subprocess, "DEVNULL"):
+        kwargs_redirected["stdout"] = getattr(subprocess, "DEVNULL")
+    else:
+        kwargs_redirected["stdout"] = open(os.devnull, "w")
+    kwargs_redirected["stderr"] = kwargs_redirected["stdout"]
+    retval = subprocess.call(*args, **kwargs_redirected)
+    if retval != 0:
+        time.sleep(1)
+        retval = subprocess.call(*args, **kwargs)
+    return retval
+
 def apply_configuration(new_configuration, current_configuration, dry_run=False):
     "Apply a configuration"
     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
 def apply_configuration(new_configuration, current_configuration, dry_run=False):
     "Apply a configuration"
     outputs = sorted(new_configuration.keys(), key=lambda x: new_configuration[x].sort_key)
@@ -543,6 +573,8 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
     #   at least.)
     #   the xrandr call fails with an invalid RRSetScreenSize parameter error.
     #   Update the configuration in 3 passes in that case.  (On Haswell graphics,
     #   at least.)
+    # - Some implementations can not handle --transform at all, so avoid it unless
+    #   necessary. (See https://github.com/phillipberndt/autorandr/issues/37)
 
     auxiliary_changes_pre = []
     disable_outputs = []
 
     auxiliary_changes_pre = []
     disable_outputs = []
@@ -554,20 +586,31 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
         else:
             if "off" not in current_configuration[output].options:
                 remain_active_count += 1
         else:
             if "off" not in current_configuration[output].options:
                 remain_active_count += 1
-            enable_outputs.append(new_configuration[output].option_vector)
-            if xrandr_version() >= Version("1.3.0") and "transform" in current_configuration[output].options:
-                auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
+
+            option_vector = new_configuration[output].option_vector
+            if xrandr_version() >= Version("1.3.0"):
+                if "transform" in current_configuration[output].options:
+                    auxiliary_changes_pre.append(["--output", output, "--transform", "none"])
+                else:
+                    try:
+                        transform_index = option_vector.index("--transform")
+                        if option_vector[transform_index+1] == XrandrOutput.XRANDR_DEFAULTS["transform"]:
+                            option_vector = option_vector[:transform_index] + option_vector[transform_index+2:]
+                    except ValueError:
+                        pass
+
+            enable_outputs.append(option_vector)
 
     # Perform pe-change auxiliary changes
     if auxiliary_changes_pre:
         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
 
     # Perform pe-change auxiliary changes
     if auxiliary_changes_pre:
         argv = base_argv + list(chain.from_iterable(auxiliary_changes_pre))
-        if subprocess.call(argv) != 0:
+        if call_and_retry(argv) != 0:
             raise AutorandrException("Command failed: %s" % " ".join(argv))
 
     # Disable unused outputs, but make sure that there always is at least one active screen
     disable_keep = 0 if remain_active_count else 1
     if len(disable_outputs) > disable_keep:
             raise AutorandrException("Command failed: %s" % " ".join(argv))
 
     # Disable unused outputs, but make sure that there always is at least one active screen
     disable_keep = 0 if remain_active_count else 1
     if len(disable_outputs) > disable_keep:
-        if subprocess.call(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
+        if call_and_retry(base_argv + list(chain.from_iterable(disable_outputs[:-1] if disable_keep else disable_outputs))) != 0:
             # Disabling the outputs failed. Retry with the next command:
             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
             # This does not occur if simultaneously the primary screen is reset.
             # Disabling the outputs failed. Retry with the next command:
             # Sometimes disabling of outputs fails due to an invalid RRSetScreenSize.
             # This does not occur if simultaneously the primary screen is reset.
@@ -586,7 +629,7 @@ def apply_configuration(new_configuration, current_configuration, dry_run=False)
     operations = disable_outputs + enable_outputs
     for index in range(0, len(operations), 2):
         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
     operations = disable_outputs + enable_outputs
     for index in range(0, len(operations), 2):
         argv = base_argv + list(chain.from_iterable(operations[index:index+2]))
-        if subprocess.call(argv) != 0:
+        if call_and_retry(argv) != 0:
             raise AutorandrException("Command failed: %s" % " ".join(argv))
 
 def is_equal_configuration(source_configuration, target_configuration):
             raise AutorandrException("Command failed: %s" % " ".join(argv))
 
 def is_equal_configuration(source_configuration, target_configuration):
@@ -668,25 +711,142 @@ def exit_help():
         print("  %-10s %s" % profile[:2])
     sys.exit(0)
 
         print("  %-10s %s" % profile[:2])
     sys.exit(0)
 
-def exec_scripts(profile_path, script_name):
-    "Run userscripts"
-    for script in (os.path.join(profile_path, script_name), os.path.join(os.path.dirname(profile_path), script_name)):
-        if os.access(script, os.X_OK | os.F_OK):
-            subprocess.call(script)
+def exec_scripts(profile_path, script_name, meta_information=None):
+    """"Run userscripts
+
+    This will run all executables from the profile folder, and global per-user
+    and system-wide configuration folders, named script_name or residing in
+    subdirectories named script_name.d.
+
+    meta_information is expected to be an dictionary. It will be passed to the block scripts
+    in the environment, as variables called AUTORANDR_<CAPITALIZED_KEY_HERE>.
+
+    Returns True unless any of the scripts exited with non-zero exit status.
+    """
+    all_ok = True
+    if meta_information:
+        env = os.environ.copy()
+        env.update({ "AUTORANDR_%s" % str(key).upper(): str(value) for (key, value) in meta_information.items() })
+    else:
+        env = os.environ.copy()
+
+    # If there are multiple candidates, the XDG spec tells to only use the first one.
+    ran_scripts = set()
+
+    user_profile_path = os.path.expanduser("~/.autorandr")
+    if not os.path.isdir(user_profile_path):
+        user_profile_path = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), "autorandr")
+
+    for folder in chain((profile_path, os.path.dirname(profile_path), user_profile_path),
+                        (os.path.join(x, "autorandr") for x in os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":"))):
+
+        if script_name not in ran_scripts:
+            script = os.path.join(folder, script_name)
+            if os.access(script, os.X_OK | os.F_OK):
+                all_ok &= subprocess.call(script, env=env) != 0
+                ran_scripts.add(script_name)
+
+        script_folder = os.path.join(folder, "%s.d" % script_name)
+        if os.access(script_folder, os.R_OK | os.X_OK) and os.path.isdir(script_folder):
+            for file_name in os.listdir(script_folder):
+                check_name = "d/%s" % (file_name,)
+                if check_name not in ran_scripts:
+                    script = os.path.join(script_folder, file_name)
+                    if os.access(script, os.X_OK | os.F_OK):
+                        all_ok &= subprocess.call(script, env=env) != 0
+                        ran_scripts.add(check_name)
+
+    return all_ok
+
+def dispatch_call_to_sessions(argv):
+    """Invoke autorandr for each open local X11 session with the given options.
+
+    The function iterates over all processes not owned by root and checks
+    whether they have a DISPLAY variable set. It strips the screen from any
+    variable it finds (i.e. :0.0 becomes :0) and checks whether this display
+    has been handled already. If it has not, it forks, changes uid/gid to
+    the user owning the process, reuses the process's environment and runs
+    autorandr with the parameters from argv.
+
+    This function requires root permissions. It only works for X11 servers that
+    have at least one non-root process running. It is susceptible for attacks
+    where one user runs a process with another user's DISPLAY variable - in
+    this case, it might happen that autorandr is invoked for the other user,
+    which won't work. Since no other harm than prevention of automated
+    execution of autorandr can be done this way, the assumption is that in this
+    situation, the local administrator will handle the situation."""
+    X11_displays_done = set()
+
+    autorandr_binary = os.path.abspath(argv[0])
+
+    for directory in os.listdir("/proc"):
+        directory = os.path.join("/proc/", directory)
+        if not os.path.isdir(directory):
+            continue
+        environ_file = os.path.join(directory, "environ")
+        if not os.path.isfile(environ_file):
+            continue
+        uid = os.stat(environ_file).st_uid
+        if uid == 0:
+            continue
+
+        process_environ = {}
+        for environ_entry in open(environ_file).read().split("\0"):
+            if "=" in environ_entry:
+                name, value = environ_entry.split("=", 1)
+                if name == "DISPLAY" and "." in value:
+                    value = value[:value.find(".")]
+                process_environ[name] = value
+        display = process_environ["DISPLAY"] if "DISPLAY" in process_environ else None
+
+        if display and display not in X11_displays_done:
+            try:
+                pwent = pwd.getpwuid(uid)
+            except KeyError:
+                # User has no pwd entry
+                continue
+
+            print("Running autorandr as %s for display %s" % (pwent.pw_name, display))
+            child_pid = os.fork()
+            if child_pid == 0:
+                # This will throw an exception if any of the privilege changes fails,
+                # so it should be safe. Also, note that since the environment
+                # is taken from a process owned by the user, reusing it should
+                # not leak any information.
+                os.setgroups([])
+                os.setresgid(pwent.pw_gid, pwent.pw_gid, pwent.pw_gid)
+                os.setresuid(pwent.pw_uid, pwent.pw_uid, pwent.pw_uid)
+                os.chdir(pwent.pw_dir)
+                os.environ.clear()
+                os.environ.update(process_environ)
+                os.execl(autorandr_binary, autorandr_binary, *argv[1:])
+                os.exit(1)
+            os.waitpid(child_pid, 0)
+
+            X11_displays_done.add(display)
 
 def main(argv):
     try:
 
 def main(argv):
     try:
-       options = dict(getopt.getopt(argv[1:], "s:l:d:cfh", [ "dry-run", "change", "default=", "save=", "load=", "force", "fingerprint", "config", "debug", "skip-options=", "help" ])[0])
+        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])
     except getopt.GetoptError as e:
         print("Failed to parse options: {0}.\n"
               "Use --help to get usage information.".format(str(e)),
               file=sys.stderr)
         sys.exit(posix.EX_USAGE)
 
     except getopt.GetoptError as e:
         print("Failed to parse options: {0}.\n"
               "Use --help to get usage information.".format(str(e)),
               file=sys.stderr)
         sys.exit(posix.EX_USAGE)
 
+    # Batch mode
+    if "--batch" in options:
+        if ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]) and os.getuid() == 0:
+            dispatch_call_to_sessions([ x for x in argv if x != "--batch" ])
+        else:
+            print("--batch mode can only be used by root and if $DISPLAY is unset")
+        return
+
     profiles = {}
     try:
         # Load profiles from each XDG config directory
     profiles = {}
     try:
         # Load profiles from each XDG config directory
-        for directory in os.environ.get("XDG_CONFIG_DIRS", "").split(":"):
+        # The XDG spec says that earlier entries should take precedence, so reverse the order
+        for directory in reversed(os.environ.get("XDG_CONFIG_DIRS", "/etc/xdg").split(":")):
             system_profile_path = os.path.join(directory, "autorandr")
             if os.path.isdir(system_profile_path):
                 profiles.update(load_profiles(system_profile_path))
             system_profile_path = os.path.join(directory, "autorandr")
             if os.path.isdir(system_profile_path):
                 profiles.update(load_profiles(system_profile_path))
@@ -727,12 +887,41 @@ def main(argv):
         if options["--save"] in ( x[0] for x in virtual_profiles ):
             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
         try:
         if options["--save"] in ( x[0] for x in virtual_profiles ):
             raise AutorandrException("Cannot save current configuration as profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--save"])
         try:
-            save_configuration(os.path.join(profile_path, options["--save"]), config)
+            profile_folder = os.path.join(profile_path, options["--save"])
+            save_configuration(profile_folder, config)
+            exec_scripts(profile_folder, "postsave", {"CURRENT_PROFILE": options["--save"], "PROFILE_FOLDER": profile_folder})
         except Exception as e:
             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
         print("Saved current configuration as profile '%s'" % options["--save"])
         sys.exit(0)
 
         except Exception as e:
             raise AutorandrException("Failed to save current configuration as profile '%s'" % (options["--save"],), e)
         print("Saved current configuration as profile '%s'" % options["--save"])
         sys.exit(0)
 
+    if "-r" in options:
+        options["--remove"] = options["-r"]
+    if "--remove" in options:
+        if options["--remove"] in ( x[0] for x in virtual_profiles ):
+            raise AutorandrException("Cannot remove profile '%s':\nThis configuration name is a reserved virtual configuration." % options["--remove"])
+        if options["--remove"] not in profiles.keys():
+            raise AutorandrException("Cannot remove profile '%s':\nThis profile does not exist." % options["--remove"])
+        try:
+            remove = True
+            profile_folder = os.path.join(profile_path, options["--remove"])
+            profile_dirlist = os.listdir(profile_folder)
+            profile_dirlist.remove("config")
+            profile_dirlist.remove("setup")
+            if profile_dirlist:
+                print("Profile folder '%s' contains the following additional files:\n---\n%s\n---" % (options["--remove"], "\n".join(profile_dirlist)))
+                response = input("Do you really want to remove profile '%s'? If so, type 'yes': " % options["--remove"]).strip()
+                if response != "yes":
+                    remove = False
+            if remove is True:
+                shutil.rmtree(profile_folder)
+                print("Removed profile '%s'" % options["--remove"])
+            else:
+                print("Profile '%s' was not removed" % options["--remove"])
+        except Exception as e:
+            raise AutorandrException("Failed to remove profile '%s'" % (options["--remove"],), e)
+        sys.exit(0)
+
     if "-h" in options or "--help" in options:
         exit_help()
 
     if "-h" in options or "--help" in options:
         exit_help()
 
@@ -744,8 +933,19 @@ def main(argv):
     if "--load" in options:
         load_profile = options["--load"]
     else:
     if "--load" in options:
         load_profile = options["--load"]
     else:
+        # Find the active profile(s) first, for the block script (See #42)
+        current_profiles = []
         for profile_name in profiles.keys():
         for profile_name in profiles.keys():
-            if profile_blocked(os.path.join(profile_path, profile_name)):
+            configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
+            if configs_are_equal:
+                current_profiles.append(profile_name)
+        block_script_metadata = {
+            "CURRENT_PROFILE":  "".join(current_profiles[:1]),
+            "CURRENT_PROFILES": ":".join(current_profiles)
+        }
+
+        for profile_name in profiles.keys():
+            if profile_blocked(os.path.join(profile_path, profile_name), block_script_metadata):
                 print("%s (blocked)" % profile_name, file=sys.stderr)
                 continue
             props = []
                 print("%s (blocked)" % profile_name, file=sys.stderr)
                 continue
             props = []
@@ -753,8 +953,7 @@ def main(argv):
                 props.append("(detected)")
                 if ("-c" in options or "--change" in options) and not load_profile:
                     load_profile = profile_name
                 props.append("(detected)")
                 if ("-c" in options or "--change" in options) and not load_profile:
                     load_profile = profile_name
-            configs_are_equal = is_equal_configuration(config, profiles[profile_name]["config"])
-            if configs_are_equal:
+            if profile_name in current_profiles:
                 props.append("(current)")
             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
                 props.append("(current)")
             print("%s%s%s" % (profile_name, " " if props else "", " ".join(props)), file=sys.stderr)
             if not configs_are_equal and "--debug" in options and profile_name in detected_profiles:
@@ -792,9 +991,16 @@ def main(argv):
             if "--dry-run" in options:
                 apply_configuration(load_config, config, True)
             else:
             if "--dry-run" in options:
                 apply_configuration(load_config, config, True)
             else:
-                exec_scripts(scripts_path, "preswitch")
+                script_metadata = {
+                    "CURRENT_PROFILE": load_profile,
+                    "PROFILE_FOLDER": scripts_path,
+                }
+                exec_scripts(scripts_path, "preswitch", script_metadata)
+                if "--debug" in options:
+                    print("Going to run:")
+                    apply_configuration(load_config, config, True)
                 apply_configuration(load_config, config, False)
                 apply_configuration(load_config, config, False)
-                exec_scripts(scripts_path, "postswitch")
+                exec_scripts(scripts_path, "postswitch", script_metadata)
         except Exception as e:
             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
 
         except Exception as e:
             raise AutorandrException("Failed to apply profile '%s'" % load_profile, e, True)
 
@@ -817,5 +1023,5 @@ if __name__ == '__main__':
             print("Exception: {0}".format(e.__class__.__name__))
             sys.exit(2)
 
             print("Exception: {0}".format(e.__class__.__name__))
             sys.exit(2)
 
-        print("Unhandled exception ({0}). Please report this as a bug.".format(e), file=sys.stderr)
+        print("Unhandled exception ({0}). Please report this as a bug at https://github.com/phillipberndt/autorandr/issues.".format(e), file=sys.stderr)
         raise
         raise