From aed80b67a3a6ae0ed5826fb996e27ac8e8ac7888 Mon Sep 17 00:00:00 2001 From: jaimergp Date: Thu, 4 Jul 2024 17:21:18 +0200 Subject: [PATCH] Display a friendlier name for "Open With" menus in Windows (#225) * Specify FriendlyTypeName on Windows file type association * allow errors on cleanup * try indexed icon * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * expose app_name and AUMI too * Enable some code cleanups * Debug on Windows * do not quote icons * pre-commit * add news * add docstrings * consistent types --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- menuinst/platforms/win.py | 54 ++++-- menuinst/platforms/win_utils/registry.py | 202 ++++++++++++++++------- news/225-friendly-open-with | 19 +++ 3 files changed, 199 insertions(+), 76 deletions(-) create mode 100644 news/225-friendly-open-with diff --git a/menuinst/platforms/win.py b/menuinst/platforms/win.py index e001e7d9..c80f7076 100644 --- a/menuinst/platforms/win.py +++ b/menuinst/platforms/win.py @@ -16,6 +16,7 @@ from .win_utils.knownfolders import folder_path as windows_folder_path from .win_utils.knownfolders import windows_terminal_settings_files from .win_utils.registry import ( + notify_shell_changes, register_file_extension, register_url_protocol, unregister_file_extension, @@ -198,14 +199,19 @@ def create(self) -> Tuple[Path, ...]: for location in self.menu.terminal_profile_locations: self._add_remove_windows_terminal_profile(location, remove=False) - self._register_file_extensions() - self._register_url_protocols() + changed_extensions = self._register_file_extensions() + changed_protocols = self._register_url_protocols() + if changed_extensions or changed_protocols: + notify_shell_changes() return paths def remove(self) -> Tuple[Path, ...]: - self._unregister_file_extensions() - self._unregister_url_protocols() + changed_extensions = self._unregister_file_extensions() + changed_protocols = self._unregister_url_protocols() + if changed_extensions or changed_protocols: + notify_shell_changes() + for location in self.menu.terminal_profile_locations: self._add_remove_windows_terminal_profile(location, remove=True) @@ -433,47 +439,67 @@ def _cmd_ftype(identifier, command=None, query=False, remove=False) -> Completed arg = f"{identifier}=" return logged_run(["cmd", "/D", "/C", f"assoc {arg}"], check=True) - def _register_file_extensions(self): + def _register_file_extensions(self) -> bool: """WIP""" extensions = self.metadata["file_extensions"] if not extensions: - return + return False command = " ".join(self._process_command(with_arg1=True)) icon = self.render_key("icon") exts = list(dict.fromkeys([ext.lower() for ext in extensions])) for ext in exts: identifier = self._ftype_identifier(ext) - register_file_extension(ext, identifier, command, icon=icon, mode=self.menu.mode) + register_file_extension( + ext, + identifier, + command, + icon=icon, + app_name=self.render_key("name"), + app_user_model_id=self._app_user_model_id(), + mode=self.menu.mode, + ) + return True - def _unregister_file_extensions(self): + def _unregister_file_extensions(self) -> bool: extensions = self.metadata["file_extensions"] if not extensions: - return + return False exts = list(dict.fromkeys([ext.lower() for ext in extensions])) for ext in exts: identifier = self._ftype_identifier(ext) unregister_file_extension(ext, identifier, mode=self.menu.mode) + return True - def _register_url_protocols(self): + def _register_url_protocols(self) -> bool: "See https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85)" # noqa protocols = self.metadata["url_protocols"] if not protocols: - return + return False command = " ".join(self._process_command(with_arg1=True)) icon = self.render_key("icon") for protocol in protocols: identifier = self._ftype_identifier(protocol) - register_url_protocol(protocol, command, identifier, icon=icon, mode=self.menu.mode) + register_url_protocol( + protocol, + command, + identifier, + icon=icon, + app_name=self.render_key("name"), + app_user_model_id=self._app_user_model_id(), + mode=self.menu.mode, + ) + return True - def _unregister_url_protocols(self): + def _unregister_url_protocols(self) -> bool: protocols = self.metadata["url_protocols"] if not protocols: - return + return False for protocol in protocols: identifier = self._ftype_identifier(protocol) unregister_url_protocol(protocol, identifier, mode=self.menu.mode) + return True def _app_user_model_id(self): aumi = self.render_key("app_user_model_id") diff --git a/menuinst/platforms/win_utils/registry.py b/menuinst/platforms/win_utils/registry.py index ac3d320b..550ef9f5 100644 --- a/menuinst/platforms/win_utils/registry.py +++ b/menuinst/platforms/win_utils/registry.py @@ -14,6 +14,7 @@ Mnemonic: SetValueEx for "excalars" (scalars, named values) """ +import ctypes import winreg from logging import getLogger @@ -23,10 +24,19 @@ def _reg_exe(*args, check=True): - return logged_run(["reg.exe", *args, "/f"], check=True) - - -def register_file_extension(extension, identifier, command, icon=None, mode="user"): + return logged_run(["reg.exe", *args, "/f"], check=check) + + +def register_file_extension( + extension, + identifier, + command, + icon=None, + app_name=None, + friendly_type_name=None, + app_user_model_id=None, + mode="user", +): """ We want to achieve this. Entries ending in / denote keys; no trailing / means named value. If the item has a value attached to it, it's written after the : symbol. @@ -39,45 +49,50 @@ def register_file_extension(extension, identifier, command, icon=None, mode="use ... /: "a description of the file being handled" - DefaultIcon: "path to the app icon" + DefaultIcon/: "path to the app icon" + FriendlyAppName/: "Name of the program" + AppUserModelID: "AUMI string" shell/ - open/ + open/: "Name of the program" + icon: "path to the app icon" + FriendlyAppName: "name of the program" command/: "the command to be executed when opening a file with this extension" """ - with winreg.OpenKeyEx( - ( - winreg.HKEY_LOCAL_MACHINE if mode == "system" else winreg.HKEY_CURRENT_USER # HKLM - ), # HKCU - r"Software\Classes", - ) as key: - # First we associate an extension with a handler - winreg.SetValueEx( - winreg.CreateKey(key, fr"{extension}\OpenWithProgids"), - identifier, - 0, - winreg.REG_SZ, - "", # presence of the key is enough - ) - log.debug("Created registry entry for extension '%s'", extension) - - # Now we register the handler - handler_desc = f"{extension} {identifier} handler" - winreg.SetValue(key, identifier, winreg.REG_SZ, handler_desc) - log.debug("Created registry entry for handler '%s'", identifier) - - # and set the 'open' command - subkey = rf"{identifier}\shell\open\command" - # Use SetValue to create subkeys as necessary - winreg.SetValue(key, subkey, winreg.REG_SZ, command) - log.debug("Created registry entry for command '%s'", command) - - if icon: - subkey = winreg.OpenKey(key, identifier, access=winreg.KEY_SET_VALUE) - winreg.SetValueEx(subkey, "DefaultIcon", 0, winreg.REG_SZ, icon) - log.debug("Created registry entry for icon '%s'", icon) - - # TODO: We can add contextual menu items too - # via f"{handler_key}\shell\\command" + if mode == "system": + key = "HKEY_LOCAL_MACHINE/Software/Classes" # HKLM + else: + key = "HKEY_CURRENT_USER/Software/Classes" # HKCU + + # First we associate an extension with a handler (presence of key is enough) + regvalue(f"{key}/{extension}/OpenWithProgids/{identifier}", "") + + # Now we register the handler + regvalue(f"{key}/{identifier}/@", f"{extension} {identifier} file") + + # Set the 'open' command + regvalue(f"{key}/{identifier}/shell/open/command/@", command) + if app_name: + regvalue(f"{key}/{identifier}/shell/open/@", app_name) + regvalue(f"{key}/{identifier}/FriendlyAppName/@", app_name) + regvalue(f"{key}/{identifier}/shell/open/FriendlyAppName", app_name) + + if app_user_model_id: + regvalue(f"{key}/{identifier}/AppUserModelID", app_user_model_id) + + if icon: + # NOTE: This doesn't change the icon next in the Open With menu + # This defaults to whatever the command executable is shipping + regvalue(f"{key}/{identifier}/DefaultIcon/@", icon) + regvalue(f"{key}/{identifier}/shell/open/Icon", icon) + + if friendly_type_name: + # NOTE: Windows <10 requires the string in a PE file, but that's too + # much work. We can just put the raw string here even if the docs say + # otherwise. + regvalue(f"{key}/{identifier}/FriendlyTypeName", friendly_type_name) + + # TODO: We can add contextual menu items too + # via f"{handler_key}\shell\\command" def unregister_file_extension(extension, identifier, mode="user"): @@ -86,11 +101,11 @@ def unregister_file_extension(extension, identifier, mode="user"): if mode == "system" else (winreg.HKEY_CURRENT_USER, "HKCU") ) - _reg_exe("delete", fr"{root_str}\Software\Classes\{identifier}") + _reg_exe("delete", rf"{root_str}\Software\Classes\{identifier}", check=False) try: with winreg.OpenKey( - root, fr"Software\Classes\{extension}\OpenWithProgids", 0, winreg.KEY_ALL_ACCESS + root, rf"Software\Classes\{extension}\OpenWithProgids", 0, winreg.KEY_ALL_ACCESS ) as key: try: winreg.QueryValueEx(key, identifier) @@ -102,34 +117,46 @@ def unregister_file_extension(extension, identifier, mode="user"): winreg.DeleteValue(key, identifier) except Exception as exc: log.exception("Could not check key '%s' for deletion", extension, exc_info=exc) - raise - - -def register_url_protocol(protocol, command, identifier=None, icon=None, mode="user"): + return False + + +def register_url_protocol( + protocol, + command, + identifier=None, + icon=None, + app_name=None, + app_user_model_id=None, + mode="user", +): if mode == "system": - key = winreg.CreateKey(winreg.HKEY_CLASSES_ROOT, protocol) + key = f"HKEY_CLASSES_ROOT/{protocol}" else: - key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, fr"Software\Classes\{protocol}") - with key: - winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f"URL:{protocol.title()}") - winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "") - # SetValue creates sub keys when slashes are present; - # SetValueEx creates a value with backslashes - we don't want that here - winreg.SetValue(key, r"shell\open\command", winreg.REG_SZ, command) - if icon: - winreg.SetValueEx(key, "DefaultIcon", 0, winreg.REG_SZ, icon) - if identifier: - # We add this one value for traceability; not required - winreg.SetValueEx(key, "_menuinst", 0, winreg.REG_SZ, identifier) + key = f"HKEY_CURRENT_USER/Software/Classes/{protocol}" + regvalue(f"{key}/@", f"URL:{protocol.title()}") + regvalue(f"{key}/URL Protocol", "") + regvalue(f"{key}/shell/open/command/@", command) + if app_name: + regvalue(f"{key}/shell/open/@", app_name) + regvalue(f"{key}/FriendlyAppName/@", app_name) + regvalue(f"{key}/shell/open/FriendlyAppName", app_name) + if icon: + regvalue(f"{key}/DefaultIcon/@", icon) + regvalue(f"{key}/shell/open/Icon", icon) + if app_user_model_id: + regvalue(f"{key}/AppUserModelId", app_user_model_id) + if identifier: + # We add this one value for traceability; not required + regvalue(f"{key}/_menuinst", identifier) def unregister_url_protocol(protocol, identifier=None, mode="user"): if mode == "system": key_tuple = winreg.HKEY_CLASSES_ROOT, protocol - key_str = fr"HKCR\{protocol}" + key_str = rf"HKCR\{protocol}" else: - key_tuple = winreg.HKEY_CURRENT_USER, fr"Software\Classes\{protocol}" - key_str = fr"HKCU\Software\Classes\{protocol}" + key_tuple = winreg.HKEY_CURRENT_USER, rf"Software\Classes\{protocol}" + key_str = rf"HKCU\Software\Classes\{protocol}" try: with winreg.OpenKey(*key_tuple) as key: value, _ = winreg.QueryValueEx(key, "_menuinst") @@ -140,3 +167,54 @@ def unregister_url_protocol(protocol, identifier=None, mode="user"): if delete: _reg_exe("delete", key_str, check=False) + + +def regvalue(key, value, value_type=winreg.REG_SZ, raise_on_errors=True): + """ + Convenience wrapper to set different types of registry values. + + For practical purposes we distinguish between three cases: + + - A key with no value (think of a directory with no contents). + Use value = "". + - A key with an unnamed value (think of a directory with a file 'index.html') + Use a key with '@' as the last component. + - A key with named values (think of non-index.html files in the directory) + + The first component of the key is the root, and must be one of the winreg.HKEY_* + variable _names_ (their actual value will be fetched from winreg). + + Key must be at least three components long (root key, *key, @ or named value). + """ + log.debug("Setting registry value %s = '%s'", key, value) + key = original_key = key.replace("\\", "/").strip("/") + root, *midkey, subkey, named_value = key.split("/") + rootkey = getattr(winreg, root) + access = winreg.KEY_SET_VALUE + try: + if named_value == "@": + if midkey: + winreg.CreateKey(rootkey, "\\".join(midkey)) # ensure it exists + with winreg.OpenKey(rootkey, "\\".join(midkey), access=access) as key: + winreg.SetValue(key, subkey, value_type, value) + else: + winreg.CreateKey(rootkey, "\\".join([*midkey, subkey])) # ensure it exists + with winreg.OpenKey(rootkey, "\\".join([*midkey, subkey]), access=access) as key: + winreg.SetValueEx(key, named_value, 0, value_type, value) + except OSError as exc: + if raise_on_errors: + raise + log.warning("Could not set %s to %s", original_key, value, exc_info=exc) + + +def notify_shell_changes(): + """ + Needed to propagate registry changes without having to reboot. + + https://discuss.python.org/t/is-there-a-library-to-change-windows-10-default-program-icon/5846/2 + """ + shell32 = ctypes.OleDLL('shell32') + shell32.SHChangeNotify.restype = None + event = 0x08000000 # SHCNE_ASSOCCHANGED + flags = 0x0000 # SHCNF_IDLIST + shell32.SHChangeNotify(event, flags, None, None) diff --git a/news/225-friendly-open-with b/news/225-friendly-open-with new file mode 100644 index 00000000..14ef86c2 --- /dev/null +++ b/news/225-friendly-open-with @@ -0,0 +1,19 @@ +### Enhancements + +* + +### Bug fixes + +* Display shortcut name in Windows' "Open with" menu entries. (#225) + +### Deprecations + +* + +### Docs + +* + +### Other + +*