Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display a friendlier name for "Open With" menus in Windows #225

Merged
merged 12 commits into from
Jul 4, 2024
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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't always return bool. I'd change return to return False

"""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>