Skip to content

Commit

Permalink
Display a friendlier name for "Open With" menus in Windows (#225)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
jaimergp and pre-commit-ci[bot] authored Jul 4, 2024
1 parent b925494 commit aed80b6
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 76 deletions.
54 changes: 40 additions & 14 deletions menuinst/platforms/win.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down
202 changes: 140 additions & 62 deletions menuinst/platforms/win_utils/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Mnemonic: SetValueEx for "excalars" (scalars, named values)
"""

import ctypes
import winreg
from logging import getLogger

Expand All @@ -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.
Expand All @@ -39,45 +49,50 @@ def register_file_extension(extension, identifier, command, icon=None, mode="use
<extension-handler>
...
<extension-handler>/: "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 Title>\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 Title>\command"


def unregister_file_extension(extension, identifier, mode="user"):
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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)
19 changes: 19 additions & 0 deletions news/225-friendly-open-with
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
### Enhancements

* <news item>

### Bug fixes

* Display shortcut name in Windows' "Open with" menu entries. (#225)

### Deprecations

* <news item>

### Docs

* <news item>

### Other

* <news item>

0 comments on commit aed80b6

Please sign in to comment.