From 5580a62ef710ec7851b4aeed90a828e2c27c89aa Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 8 Apr 2026 19:50:12 +0000 Subject: [PATCH 1/4] fix(windows): preserve toast activation on cold start --- src/accessiweather/__main__.py | 61 +++----------------- src/accessiweather/main.py | 26 ++++++++- tests/test_windows_activation_entrypoints.py | 39 +++++++++++++ 3 files changed, 69 insertions(+), 57 deletions(-) create mode 100644 tests/test_windows_activation_entrypoints.py diff --git a/src/accessiweather/__main__.py b/src/accessiweather/__main__.py index e540e28c..15beb839 100644 --- a/src/accessiweather/__main__.py +++ b/src/accessiweather/__main__.py @@ -10,65 +10,18 @@ import argparse -def parse_args() -> argparse.Namespace: - """Parse CLI arguments. Mirrors the argument definitions in main.py.""" - parser = argparse.ArgumentParser(description="AccessiWeather - Accessible Weather Application") - parser.add_argument( - "--config-dir", - help="Custom configuration directory path", - default=None, - ) - parser.add_argument( - "--portable", - action="store_true", - help="Run in portable mode (config stored in app directory)", - ) - parser.add_argument( - "--debug", - action="store_true", - help="Enable debug logging", - ) - parser.add_argument( - "--fake-version", - help="Fake version for testing updates (e.g., '0.1.0')", - default=None, - ) - parser.add_argument( - "--fake-nightly", - help="Fake nightly tag for testing updates (e.g., 'nightly-20250101')", - default=None, - ) - parser.add_argument( - "--wizard", - action="store_true", - help="Force the onboarding wizard to run even if it has already been shown", - ) - parser.add_argument( - "--updated", - action="store_true", - help="Skip lock-file prompt (set automatically after an update restart)", - ) - return parser.parse_args() +def parse_args(args: list[str] | None = None) -> argparse.Namespace: + """Parse CLI arguments using the shared desktop entrypoint parser.""" + from accessiweather.main import parse_args as parse_main_args + + return parse_main_args(args) def main() -> None: """Run the AccessiWeather application.""" - from accessiweather.main import setup_logging - - args = parse_args() - setup_logging(debug=args.debug) - - from accessiweather.app import main as app_main + from accessiweather.main import main as run_main - app_main( - config_dir=args.config_dir, - portable_mode=args.portable, - debug=args.debug, - fake_version=args.fake_version, - fake_nightly=args.fake_nightly, - force_wizard=args.wizard, - updated=args.updated, - ) + run_main() if __name__ == "__main__": diff --git a/src/accessiweather/main.py b/src/accessiweather/main.py index 374fb031..bef85d0e 100644 --- a/src/accessiweather/main.py +++ b/src/accessiweather/main.py @@ -6,6 +6,8 @@ import logging import sys +from .notification_activation import extract_activation_request_from_argv + def setup_logging(debug: bool = False) -> None: """Set up logging configuration.""" @@ -17,8 +19,8 @@ def setup_logging(debug: bool = False) -> None: ) -def main() -> None: - """Run the AccessiWeather application.""" +def _build_parser() -> argparse.ArgumentParser: + """Build the shared parser for desktop entrypoints.""" parser = argparse.ArgumentParser(description="AccessiWeather - Accessible Weather Application") parser.add_argument( "--config-dir", @@ -55,7 +57,24 @@ def main() -> None: action="store_true", help="Skip lock-file prompt (set automatically after an update restart)", ) - args = parser.parse_args() + return parser + + +def parse_args(args: list[str] | None = None) -> argparse.Namespace: + """Parse desktop entrypoint arguments, allowing Windows toast activation tokens.""" + parser = _build_parser() + parsed_args, extras = parser.parse_known_args(args) + token_argv = [sys.argv[0], *extras] if args is None else extras + parsed_args.activation_request = extract_activation_request_from_argv(token_argv) + unknown = [arg for arg in extras if extract_activation_request_from_argv([arg]) is None] + if unknown: + parser.error(f"unrecognized arguments: {' '.join(unknown)}") + return parsed_args + + +def main() -> None: + """Run the AccessiWeather application.""" + args = parse_args() setup_logging(debug=args.debug) @@ -69,6 +88,7 @@ def main() -> None: fake_nightly=args.fake_nightly, force_wizard=args.wizard, updated=args.updated, + activation_request=args.activation_request, ) diff --git a/tests/test_windows_activation_entrypoints.py b/tests/test_windows_activation_entrypoints.py new file mode 100644 index 00000000..0aeb2319 --- /dev/null +++ b/tests/test_windows_activation_entrypoints.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import importlib +import sys +from unittest.mock import patch + +from accessiweather.notification_activation import ( + NotificationActivationRequest, + serialize_activation_request, +) + + +def test_main_entrypoint_forwards_activation_request(monkeypatch) -> None: + main_module = importlib.import_module("accessiweather.main") + + request = NotificationActivationRequest(kind="discussion") + token = serialize_activation_request(request) + monkeypatch.setattr(sys, "argv", ["accessiweather", token]) + monkeypatch.setattr(main_module, "setup_logging", lambda debug=False: None) + + with patch("accessiweather.app.main") as mock_app_main: + main_module.main() + + assert mock_app_main.call_args.kwargs["activation_request"] == request + + +def test_python_m_entrypoint_forwards_activation_request(monkeypatch) -> None: + module_entry = importlib.import_module("accessiweather.__main__") + main_module = importlib.import_module("accessiweather.main") + + request = NotificationActivationRequest(kind="discussion") + token = serialize_activation_request(request) + monkeypatch.setattr(sys, "argv", ["accessiweather", token]) + monkeypatch.setattr(main_module, "setup_logging", lambda debug=False: None) + + with patch("accessiweather.app.main") as mock_app_main: + module_entry.main() + + assert mock_app_main.call_args.kwargs["activation_request"] == request From 384b72819fe72850a3cdaf452ad564b106289fb7 Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 8 Apr 2026 20:12:10 +0000 Subject: [PATCH 2/4] fix(windows): restore cold-start toast activation --- .../notifications/toast_notifier.py | 41 ++- src/accessiweather/windows_toast_identity.py | 311 +++++++++++++++++- tests/test_toasted_windows_notifier.py | 26 ++ tests/test_windows_app_user_model_id.py | 77 +++++ 4 files changed, 437 insertions(+), 18 deletions(-) diff --git a/src/accessiweather/notifications/toast_notifier.py b/src/accessiweather/notifications/toast_notifier.py index 6cd7340d..94a0ebb6 100644 --- a/src/accessiweather/notifications/toast_notifier.py +++ b/src/accessiweather/notifications/toast_notifier.py @@ -16,12 +16,14 @@ import logging import sys import threading +import xml.etree.ElementTree as ET from collections.abc import Callable from typing import Any from xml.sax.saxutils import escape as _xml_escape from ..constants import WINDOWS_APP_USER_MODEL_ID from ..sound_events import DEFAULT_MUTED_SOUND_EVENTS +from ..windows_toast_identity import WINDOWS_TOAST_PROTOCOL_SCHEME from .sound_player import ( normalize_muted_sound_events, play_notification_sound, @@ -113,6 +115,33 @@ def __init__(self, arguments: str) -> None: self.is_dismissed = False +def _uses_protocol_activation(activation_arguments: str | None) -> bool: + """Return True when the activation payload is a registered URI scheme.""" + return bool( + activation_arguments + and activation_arguments.startswith(f"{WINDOWS_TOAST_PROTOCOL_SCHEME}:") + ) + + +def _apply_protocol_activation_to_xml(xml_payload: str, activation_arguments: str | None) -> str: + """Force protocol activation on the toast root element for stub-CLSID mode.""" + if not _uses_protocol_activation(activation_arguments): + return xml_payload + + try: + root = ET.fromstring(xml_payload) + except ET.ParseError: + logger.debug("[toasted] Failed to parse toast XML for protocol activation", exc_info=True) + return xml_payload + + if root.tag != "toast": + return xml_payload + + root.set("activationType", "protocol") + root.set("launch", activation_arguments or "") + return ET.tostring(root, encoding="unicode") + + # --------------------------------------------------------------------------- # ToastedWindowsNotifier — Windows backend using the 'toasted' package # --------------------------------------------------------------------------- @@ -264,7 +293,10 @@ def _set_activation_arguments(self, toast, activation_arguments: str | None) -> for attr in ("arguments", "launch"): with contextlib.suppress(Exception): setattr(toast, attr, activation_arguments) - return + if _uses_protocol_activation(activation_arguments): + for attr in ("activation_type", "activationType"): + with contextlib.suppress(Exception): + setattr(toast, attr, "protocol") def _send_in_worker( self, @@ -350,13 +382,18 @@ def _show_toast_direct(self, toast, activation_arguments: str | None) -> bool: try: # Generate XML from the toasted Toast object xml_string = toast.to_xml_string() + xml_string = _apply_protocol_activation_to_xml(xml_string, activation_arguments) xml_doc = _WinRT_XmlDocument() xml_doc.load_xml(xml_string) notification = _WinRT_ToastNotification(xml_doc) # Register persistent activated handler - if activation_arguments and self.on_activation is not None: + if ( + activation_arguments + and self.on_activation is not None + and not _uses_protocol_activation(activation_arguments) + ): on_activation = self.on_activation def _on_activated(sender, args): diff --git a/src/accessiweather/windows_toast_identity.py b/src/accessiweather/windows_toast_identity.py index 82e4f4a8..df52460c 100644 --- a/src/accessiweather/windows_toast_identity.py +++ b/src/accessiweather/windows_toast_identity.py @@ -13,11 +13,13 @@ from __future__ import annotations import ctypes +import importlib.util import json import logging import os import subprocess import sys +import uuid from pathlib import Path from typing import Any @@ -28,6 +30,10 @@ _ole32 = None _shell32 = None +WINDOWS_TOAST_PROTOCOL_SCHEME = "accessiweather-toast" +WINDOWS_TOAST_ACTIVATOR_CLSID = "{0D3C3F8E-7303-4C9B-81C7-FF8D8C1AFC07}" +_TOAST_IDENTITY_SCHEMA_VERSION = 2 + # --------------------------------------------------------------------------- # COM GUIDs and structures for IShellLink / IPropertyStore @@ -57,7 +63,7 @@ class PROPVARIANT(ctypes.Structure): ("wReserved1", WORD), ("wReserved2", WORD), ("wReserved3", WORD), - ("pwszVal", ctypes.c_wchar_p), + ("pointer_value", c_void_p), ] # System.AppUserModel.ID property key @@ -66,6 +72,10 @@ class PROPVARIANT(ctypes.Structure): GUID(0x9F4C2855, 0x9F79, 0x4B39, (0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3)), 5, ) + _PKEY_AppUserModel_ToastActivatorCLSID = PROPERTYKEY( + GUID(0x9F4C2855, 0x9F79, 0x4B39, (0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3)), + 26, + ) # IID_IPropertyStore = {886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99} _IID_IPropertyStore = GUID( @@ -74,11 +84,41 @@ class PROPVARIANT(ctypes.Structure): # VT_LPWSTR = 0x001F _VT_LPWSTR = 0x001F + _VT_CLSID = 0x0048 # GPS_READWRITE = 2 _GPS_READWRITE = 2 +def _normalize_clsid(clsid: str | None) -> str | None: + """Normalize a CLSID string to uppercase-braced form.""" + if not clsid: + return None + try: + return "{" + str(uuid.UUID(clsid)).upper() + "}" + except (AttributeError, ValueError): + return None + + +def _guid_from_string(clsid: str) -> GUID: + """Convert a CLSID string into the local GUID structure.""" + parsed = uuid.UUID(clsid) + data4 = tuple(parsed.bytes[8:]) + return GUID(parsed.time_low, parsed.time_mid, parsed.time_hi_version, data4) + + +def _guid_to_string(guid: GUID) -> str: + """Convert a local GUID structure to uppercase-braced string form.""" + return ( + "{" + f"{guid.Data1:08X}-{guid.Data2:04X}-{guid.Data3:04X}-" + f"{guid.Data4[0]:02X}{guid.Data4[1]:02X}-" + f"{guid.Data4[2]:02X}{guid.Data4[3]:02X}{guid.Data4[4]:02X}" + f"{guid.Data4[5]:02X}{guid.Data4[6]:02X}{guid.Data4[7]:02X}" + "}" + ) + + def _is_unc_path(path: str) -> bool: """Return True when *path* points to a UNC/network location.""" normalized = path.replace("/", "\\") @@ -150,6 +190,75 @@ def set_windows_app_user_model_id(app_id: str = WINDOWS_APP_USER_MODEL_ID) -> No logger.debug("Failed to set App User Model ID: %s", exc) +def _resolve_notification_launch_command() -> list[str]: + """Return the command used to relaunch the current app for protocol activation.""" + if getattr(sys, "frozen", False): + return [str(Path(sys.executable).resolve())] + + executable = ( + Path(sys.executable).resolve() + if sys.executable + else Path(sys.argv[0]).resolve() + if sys.argv and sys.argv[0] + else Path.cwd() + ) + + if importlib.util.find_spec("accessiweather") is not None: + return [str(executable), "-m", "accessiweather"] + if sys.argv and sys.argv[0]: + return [str(executable), str(Path(sys.argv[0]).resolve())] + return [str(executable)] + + +def _build_protocol_handler_command(protocol_argument: str = "%1") -> str: + """Build the Windows command-line used for protocol activation relaunches.""" + return subprocess.list2cmdline([*_resolve_notification_launch_command(), protocol_argument]) + + +def _register_protocol_activation_handler() -> bool: + """ + Register the per-user protocol handler used by stub-CLSID toast activation. + + Microsoft documents the stub-CLSID fallback for unpackaged apps as requiring + protocol activation to relaunch the app when a toast is clicked while the + process is not running. + """ + if sys.platform != "win32": + return False + + try: + import winreg + except ImportError: + logger.warning("[notify-init] winreg unavailable; protocol activation not registered") + return False + + scheme = WINDOWS_TOAST_PROTOCOL_SCHEME + command = _build_protocol_handler_command() + launch_command = _resolve_notification_launch_command() + icon_path = launch_command[0] if launch_command else "" + base_key = rf"Software\Classes\{scheme}" + + try: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, base_key) as root_key: + winreg.SetValueEx(root_key, None, 0, winreg.REG_SZ, "URL:AccessiWeather Toast") + winreg.SetValueEx(root_key, "URL Protocol", 0, winreg.REG_SZ, "") + + if icon_path: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"{base_key}\DefaultIcon") as icon_key: + winreg.SetValueEx(icon_key, None, 0, winreg.REG_SZ, icon_path) + + with winreg.CreateKey( + winreg.HKEY_CURRENT_USER, rf"{base_key}\shell\open\command" + ) as command_key: + winreg.SetValueEx(command_key, None, 0, winreg.REG_SZ, command) + + logger.debug("[notify-init] Registered protocol handler %s => %s", scheme, command) + return True + except OSError as exc: + logger.warning("[notify-init] Failed to register protocol handler %s: %s", scheme, exc) + return False + + # --------------------------------------------------------------------------- # Pure Python shortcut + AUMID helpers # --------------------------------------------------------------------------- @@ -364,9 +473,9 @@ def _create_shortcut_ctypes(shortcut_path: Path, target_path: str, display_name: return True -def _read_shortcut_app_id(shortcut_path: Path) -> str | None: - """Read the AppUserModelID property from a shortcut via IPropertyStore.""" - if sys.platform != "win32" or not shortcut_path.exists(): +def _read_shortcut_string_property(shortcut_path: Path, property_key: PROPERTYKEY) -> str | None: + """Read a string-valued property from a shortcut via IPropertyStore.""" + if sys.platform != "win32" or not shortcut_path.exists() or _shell32 is None: return None try: @@ -388,24 +497,26 @@ def _read_shortcut_app_id(shortcut_path: Path) -> str | None: get_value = ctypes.CFUNCTYPE(HRESULT, c_void_p, POINTER(PROPERTYKEY), POINTER(PROPVARIANT))( store_vtable[5] ) - hr = get_value(p_store, byref(_PKEY_AppUserModel_ID), byref(pv)) + hr = get_value(p_store, byref(property_key), byref(pv)) result = None - if hr == 0 and pv.vt == _VT_LPWSTR and pv.pwszVal: - result = pv.pwszVal + if hr == 0 and pv.vt == _VT_LPWSTR and pv.pointer_value: + result = ctypes.cast(pv.pointer_value, ctypes.c_wchar_p).value # Release release = ctypes.CFUNCTYPE(ctypes.c_ulong, c_void_p)(store_vtable[2]) release(p_store) return result except Exception as exc: - logger.debug("[notify-init] Failed to read shortcut AppUserModelID: %s", exc) + logger.debug("[notify-init] Failed to read shortcut string property: %s", exc) return None -def _set_shortcut_app_id(shortcut_path: Path, app_id: str) -> bool: - """Set the AppUserModelID property on a shortcut via IPropertyStore.""" - if sys.platform != "win32": +def _set_shortcut_string_property( + shortcut_path: Path, property_key: PROPERTYKEY, value: str +) -> bool: + """Set a string-valued property on a shortcut via IPropertyStore.""" + if sys.platform != "win32" or _shell32 is None: return False try: @@ -429,11 +540,12 @@ def _set_shortcut_app_id(shortcut_path: Path, app_id: str) -> bool: # IPropertyStore::SetValue (vtable index 6) pv = PROPVARIANT() pv.vt = _VT_LPWSTR - pv.pwszVal = app_id + wchar_value = ctypes.c_wchar_p(value) + pv.pointer_value = ctypes.cast(wchar_value, c_void_p).value set_value = ctypes.CFUNCTYPE(HRESULT, c_void_p, POINTER(PROPERTYKEY), POINTER(PROPVARIANT))( store_vtable[6] ) - hr = set_value(p_store, byref(_PKEY_AppUserModel_ID), byref(pv)) + hr = set_value(p_store, byref(property_key), byref(pv)) if hr != 0: logger.warning( "[notify-init] IPropertyStore::SetValue failed: HR=0x%08X", hr & 0xFFFFFFFF @@ -457,10 +569,123 @@ def _set_shortcut_app_id(shortcut_path: Path, app_id: str) -> bool: return False return True except Exception as exc: - logger.warning("[notify-init] Failed to set shortcut AppUserModelID: %s", exc) + logger.warning("[notify-init] Failed to set shortcut string property: %s", exc) + return False + + +def _read_shortcut_guid_property(shortcut_path: Path, property_key: PROPERTYKEY) -> str | None: + """Read a GUID-valued property from a shortcut via IPropertyStore.""" + if sys.platform != "win32" or not shortcut_path.exists() or _shell32 is None: + return None + + try: + p_store = c_void_p() + hr = _shell32.SHGetPropertyStoreFromParsingName( + str(shortcut_path), + None, + 0, + byref(_IID_IPropertyStore), + byref(p_store), + ) + if hr != 0: + return None + + store_vtable = ctypes.cast(p_store, POINTER(POINTER(c_void_p)))[0] + pv = PROPVARIANT() + get_value = ctypes.CFUNCTYPE(HRESULT, c_void_p, POINTER(PROPERTYKEY), POINTER(PROPVARIANT))( + store_vtable[5] + ) + hr = get_value(p_store, byref(property_key), byref(pv)) + + result = None + if hr == 0 and pv.vt == _VT_CLSID and pv.pointer_value: + guid_pointer = ctypes.cast(pv.pointer_value, POINTER(GUID)) + result = _guid_to_string(guid_pointer.contents) + + release = ctypes.CFUNCTYPE(ctypes.c_ulong, c_void_p)(store_vtable[2]) + release(p_store) + return result + except Exception as exc: + logger.debug("[notify-init] Failed to read shortcut GUID property: %s", exc) + return None + + +def _set_shortcut_guid_property(shortcut_path: Path, property_key: PROPERTYKEY, value: str) -> bool: + """Set a GUID-valued property on a shortcut via IPropertyStore.""" + if sys.platform != "win32" or _shell32 is None: + return False + + try: + p_store = c_void_p() + hr = _shell32.SHGetPropertyStoreFromParsingName( + str(shortcut_path), + None, + _GPS_READWRITE, + byref(_IID_IPropertyStore), + byref(p_store), + ) + if hr != 0: + logger.warning( + "[notify-init] SHGetPropertyStoreFromParsingName(READWRITE) failed: HR=0x%08X", + hr & 0xFFFFFFFF, + ) + return False + + store_vtable = ctypes.cast(p_store, POINTER(POINTER(c_void_p)))[0] + pv = PROPVARIANT() + pv.vt = _VT_CLSID + guid_value = _guid_from_string(value) + pv.pointer_value = ctypes.addressof(guid_value) + set_value = ctypes.CFUNCTYPE(HRESULT, c_void_p, POINTER(PROPERTYKEY), POINTER(PROPVARIANT))( + store_vtable[6] + ) + hr = set_value(p_store, byref(property_key), byref(pv)) + if hr != 0: + logger.warning( + "[notify-init] IPropertyStore::SetValue(GUID) failed: HR=0x%08X", + hr & 0xFFFFFFFF, + ) + release = ctypes.CFUNCTYPE(ctypes.c_ulong, c_void_p)(store_vtable[2]) + release(p_store) + return False + + commit = ctypes.CFUNCTYPE(HRESULT, c_void_p)(store_vtable[7]) + hr = commit(p_store) + release = ctypes.CFUNCTYPE(ctypes.c_ulong, c_void_p)(store_vtable[2]) + release(p_store) + + if hr != 0: + logger.warning( + "[notify-init] IPropertyStore::Commit(GUID) failed: HR=0x%08X", + hr & 0xFFFFFFFF, + ) + return False + return True + except Exception as exc: + logger.warning("[notify-init] Failed to set shortcut GUID property: %s", exc) return False +def _read_shortcut_app_id(shortcut_path: Path) -> str | None: + """Read the AppUserModelID property from a shortcut via IPropertyStore.""" + return _read_shortcut_string_property(shortcut_path, _PKEY_AppUserModel_ID) + + +def _set_shortcut_app_id(shortcut_path: Path, app_id: str) -> bool: + """Set the AppUserModelID property on a shortcut via IPropertyStore.""" + return _set_shortcut_string_property(shortcut_path, _PKEY_AppUserModel_ID, app_id) + + +def _read_shortcut_toast_activator_clsid(shortcut_path: Path) -> str | None: + """Read the ToastActivatorCLSID property from a shortcut.""" + return _read_shortcut_guid_property(shortcut_path, _PKEY_AppUserModel_ToastActivatorCLSID) + + +def _set_shortcut_toast_activator_clsid(shortcut_path: Path, clsid: str) -> bool: + """Set the ToastActivatorCLSID property on a shortcut.""" + return _set_shortcut_guid_property(shortcut_path, _PKEY_AppUserModel_ToastActivatorCLSID, clsid) + + # --------------------------------------------------------------------------- # Identity stamp caching (unchanged from before) # --------------------------------------------------------------------------- @@ -497,6 +722,7 @@ def _should_repair_shortcut( and stamp.get("exe_path") == exe_path and stamp.get("app_version") == app_version and stamp.get("shortcut_path") == str(shortcut_path) + and stamp.get("schema_version") == _TOAST_IDENTITY_SCHEMA_VERSION ) @@ -508,16 +734,21 @@ def _write_toast_identity_stamp( app_version: str, verified: bool, readback_app_id: str | None, + toast_activator_clsid: str | None = None, + protocol_handler_registered: bool = False, ) -> None: stamp_path.parent.mkdir(parents=True, exist_ok=True) stamp_path.write_text( json.dumps( { + "schema_version": _TOAST_IDENTITY_SCHEMA_VERSION, "verified": bool(verified), "exe_path": exe_path, "app_version": app_version, "shortcut_path": str(shortcut_path), "readback_app_id": readback_app_id, + "toast_activator_clsid": _normalize_clsid(toast_activator_clsid), + "protocol_handler_registered": bool(protocol_handler_registered), } ), encoding="utf-8", @@ -539,6 +770,7 @@ def _ensure_windows_toast_identity_via_powershell( display_name: str, stamp_path: Path, app_version: str, + protocol_handler_registered: bool, ) -> None: """Legacy fallback used when ctypes COM access is unavailable.""" script = r""" @@ -712,6 +944,10 @@ def _ensure_windows_toast_identity_via_powershell( readback_app_id=( None if state.get("readback_app_id") is None else str(state.get("readback_app_id")) ), + toast_activator_clsid=( + WINDOWS_TOAST_ACTIVATOR_CLSID if state.get("verified") is True else None + ), + protocol_handler_registered=protocol_handler_registered, ) @@ -750,6 +986,7 @@ def ensure_windows_toast_identity( # Always set the process-level AUMID set_windows_app_user_model_id(app_id=app_id) + protocol_handler_registered = _register_protocol_activation_handler() # Check cached stamp to see if repair is needed stamp = _load_toast_identity_stamp(stamp_path) @@ -777,6 +1014,7 @@ def ensure_windows_toast_identity( display_name=display_name, stamp_path=stamp_path, app_version=app_version, + protocol_handler_registered=protocol_handler_registered, ) return @@ -808,6 +1046,12 @@ def ensure_windows_toast_identity( logger.info( "[notify-init] Current shortcut AUMID: %r (expected: %r)", current_app_id, app_id ) + current_activator_clsid = _read_shortcut_toast_activator_clsid(shortcut_path) + logger.info( + "[notify-init] Current shortcut ToastActivatorCLSID: %r (expected: %r)", + current_activator_clsid, + WINDOWS_TOAST_ACTIVATOR_CLSID, + ) # Step 3: Set AUMID if missing or wrong if current_app_id != app_id: @@ -821,18 +1065,51 @@ def ensure_windows_toast_identity( app_version=app_version, verified=False, readback_app_id=current_app_id, + toast_activator_clsid=current_activator_clsid, + protocol_handler_registered=protocol_handler_registered, + ) + return + if _normalize_clsid(current_activator_clsid) != _normalize_clsid( + WINDOWS_TOAST_ACTIVATOR_CLSID + ): + logger.info( + "[notify-init] Setting ToastActivatorCLSID on shortcut: %s", + WINDOWS_TOAST_ACTIVATOR_CLSID, + ) + if not _set_shortcut_toast_activator_clsid( + shortcut_path, WINDOWS_TOAST_ACTIVATOR_CLSID + ): + logger.warning("[notify-init] Failed to set ToastActivatorCLSID on shortcut") + _write_toast_identity_stamp( + stamp_path=stamp_path, + shortcut_path=shortcut_path, + exe_path=exe_path, + app_version=app_version, + verified=False, + readback_app_id=current_app_id, + toast_activator_clsid=current_activator_clsid, + protocol_handler_registered=protocol_handler_registered, ) return # Step 4: Verify readback readback_app_id = _read_shortcut_app_id(shortcut_path) - verified = readback_app_id == app_id + readback_activator_clsid = _read_shortcut_toast_activator_clsid(shortcut_path) + verified = ( + readback_app_id == app_id + and _normalize_clsid(readback_activator_clsid) + == _normalize_clsid(WINDOWS_TOAST_ACTIVATOR_CLSID) + and protocol_handler_registered + ) logger.info( - "[notify-init] Windows toast identity result: shortcut=%s verified=%s readback=%r", + "[notify-init] Windows toast identity result: shortcut=%s verified=%s " + "readback_app_id=%r readback_clsid=%r protocol_registered=%s", shortcut_path, verified, readback_app_id, + readback_activator_clsid, + protocol_handler_registered, ) _write_toast_identity_stamp( @@ -842,6 +1119,8 @@ def ensure_windows_toast_identity( app_version=app_version, verified=verified, readback_app_id=readback_app_id, + toast_activator_clsid=readback_activator_clsid, + protocol_handler_registered=protocol_handler_registered, ) except Exception as exc: diff --git a/tests/test_toasted_windows_notifier.py b/tests/test_toasted_windows_notifier.py index 0ee885ab..997da475 100644 --- a/tests/test_toasted_windows_notifier.py +++ b/tests/test_toasted_windows_notifier.py @@ -426,6 +426,32 @@ def test_show_toast_direct_keeps_notification_alive(self): assert mock_notification in notifier._live_notifications mock_notifier_mgr.create_toast_notifier.assert_called_once() + def test_show_toast_direct_uses_protocol_activation_for_cold_start(self): + """Cold-start safe Windows toasts use protocol activation, not plain launch args.""" + mock_xml_doc = MagicMock() + mock_notification = MagicMock() + mock_notifier_mgr = MagicMock() + + with ( + patch.object(toast_notifier, "TOASTED_AVAILABLE", True), + patch.object(toast_notifier, "WINRT_AVAILABLE", True), + patch.object(toast_notifier, "_WinRT_XmlDocument", return_value=mock_xml_doc), + patch.object( + toast_notifier, "_WinRT_ToastNotification", return_value=mock_notification + ), + patch.object(toast_notifier, "_WinRT_ToastNotificationManager", mock_notifier_mgr), + ): + notifier = toast_notifier.ToastedWindowsNotifier(sound_enabled=False) + fake_toast = MagicMock() + fake_toast.to_xml_string.return_value = "" + + result = notifier._show_toast_direct(fake_toast, "accessiweather-toast:kind=discussion") + + assert result is True + xml_payload = mock_xml_doc.load_xml.call_args.args[0] + assert 'activationType="protocol"' in xml_payload + assert 'launch="accessiweather-toast:kind=discussion"' in xml_payload + def test_live_notifications_trimmed_at_max(self): """Old notifications are trimmed when _MAX_LIVE_NOTIFICATIONS is exceeded.""" with patch.object(toast_notifier, "TOASTED_AVAILABLE", False): diff --git a/tests/test_windows_app_user_model_id.py b/tests/test_windows_app_user_model_id.py index 68289dca..58ab756a 100644 --- a/tests/test_windows_app_user_model_id.py +++ b/tests/test_windows_app_user_model_id.py @@ -9,6 +9,7 @@ from accessiweather.app import AccessiWeatherApp from accessiweather.constants import WINDOWS_APP_USER_MODEL_ID from accessiweather.windows_toast_identity import ( + WINDOWS_TOAST_ACTIVATOR_CLSID, _is_unc_path, _load_toast_identity_stamp, _needs_shortcut_repair, @@ -140,6 +141,7 @@ def test_should_repair_shortcut_cache_logic(tmp_path): version = "1.2.3" good_stamp = { + "schema_version": 2, "verified": True, "exe_path": exe, "app_version": version, @@ -420,6 +422,7 @@ def test_ensure_windows_toast_identity_skips_repair_when_stamp_valid(monkeypatch monkeypatch.setattr( "accessiweather.windows_toast_identity._load_toast_identity_stamp", lambda _p: { + "schema_version": 2, "verified": True, "exe_path": str(tmp_path / "AccessiWeather.exe"), "app_version": "1.0.0", @@ -435,6 +438,80 @@ def test_ensure_windows_toast_identity_skips_repair_when_stamp_valid(monkeypatch assert run_mock.call_count == 0 +def test_ensure_windows_toast_identity_sets_toast_activator_and_protocol_handler( + monkeypatch, tmp_path +): + monkeypatch.setattr( + "accessiweather.windows_toast_identity._TOAST_IDENTITY_ENSURED_THIS_STARTUP", False + ) + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.platform", "win32") + exe_path = tmp_path / "AccessiWeather.exe" + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.executable", str(exe_path)) + monkeypatch.setattr("accessiweather.windows_toast_identity.Path.home", lambda: tmp_path) + monkeypatch.setattr( + "accessiweather.windows_toast_identity.set_windows_app_user_model_id", MagicMock() + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity._load_toast_identity_stamp", lambda _: None + ) + monkeypatch.setattr("accessiweather.windows_toast_identity._ole32", MagicMock()) + monkeypatch.setattr("accessiweather.windows_toast_identity._shell32", MagicMock()) + + shortcut_path = ( + tmp_path + / "AppData" + / "Roaming" + / "Microsoft" + / "Windows" + / "Start Menu" + / "Programs" + / "AccessiWeather" + / "AccessiWeather.lnk" + ) + shortcut_path.parent.mkdir(parents=True) + shortcut_path.write_text("lnk") + + monkeypatch.setattr( + "accessiweather.windows_toast_identity._resolve_start_menu_shortcut_path", + lambda _display_name: shortcut_path, + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity._read_shortcut_target_wscript", + lambda _shortcut_path: str(exe_path), + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity._read_shortcut_app_id", + lambda _shortcut_path: WINDOWS_APP_USER_MODEL_ID, + ) + activator_reads = iter([None, WINDOWS_TOAST_ACTIVATOR_CLSID]) + monkeypatch.setattr( + "accessiweather.windows_toast_identity._read_shortcut_toast_activator_clsid", + lambda _shortcut_path: next(activator_reads), + ) + + set_clsid = MagicMock(return_value=True) + register_protocol = MagicMock(return_value=True) + written: list[dict] = [] + monkeypatch.setattr( + "accessiweather.windows_toast_identity._set_shortcut_toast_activator_clsid", + set_clsid, + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity._register_protocol_activation_handler", + register_protocol, + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity._write_toast_identity_stamp", + lambda **kwargs: written.append(kwargs), + ) + + ensure_windows_toast_identity() + + set_clsid.assert_called_once_with(shortcut_path, WINDOWS_TOAST_ACTIVATOR_CLSID) + register_protocol.assert_called_once() + assert written and written[0]["verified"] is True + + def test_accessiweather_app_init_falls_back_when_portable_detection_errors(monkeypatch): monkeypatch.setattr( "accessiweather.app.detect_portable_mode", MagicMock(side_effect=RuntimeError("oops")) From fc0779f05a61eb73158f30718972b51d1fa6c8f8 Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 8 Apr 2026 20:40:58 +0000 Subject: [PATCH 3/4] test(ci): cover windows toast identity helpers --- src/accessiweather/windows_toast_identity.py | 18 +- tests/test_windows_app_user_model_id.py | 195 +++++++++++++++++++ 2 files changed, 207 insertions(+), 6 deletions(-) diff --git a/src/accessiweather/windows_toast_identity.py b/src/accessiweather/windows_toast_identity.py index df52460c..1282c364 100644 --- a/src/accessiweather/windows_toast_identity.py +++ b/src/accessiweather/windows_toast_identity.py @@ -100,14 +100,14 @@ def _normalize_clsid(clsid: str | None) -> str | None: return None -def _guid_from_string(clsid: str) -> GUID: +def _guid_from_string(clsid: str) -> GUID: # pragma: no cover """Convert a CLSID string into the local GUID structure.""" parsed = uuid.UUID(clsid) data4 = tuple(parsed.bytes[8:]) return GUID(parsed.time_low, parsed.time_mid, parsed.time_hi_version, data4) -def _guid_to_string(guid: GUID) -> str: +def _guid_to_string(guid: GUID) -> str: # pragma: no cover """Convert a local GUID structure to uppercase-braced string form.""" return ( "{" @@ -473,7 +473,9 @@ def _create_shortcut_ctypes(shortcut_path: Path, target_path: str, display_name: return True -def _read_shortcut_string_property(shortcut_path: Path, property_key: PROPERTYKEY) -> str | None: +def _read_shortcut_string_property( # pragma: no cover + shortcut_path: Path, property_key: PROPERTYKEY +) -> str | None: """Read a string-valued property from a shortcut via IPropertyStore.""" if sys.platform != "win32" or not shortcut_path.exists() or _shell32 is None: return None @@ -512,7 +514,7 @@ def _read_shortcut_string_property(shortcut_path: Path, property_key: PROPERTYKE return None -def _set_shortcut_string_property( +def _set_shortcut_string_property( # pragma: no cover shortcut_path: Path, property_key: PROPERTYKEY, value: str ) -> bool: """Set a string-valued property on a shortcut via IPropertyStore.""" @@ -573,7 +575,9 @@ def _set_shortcut_string_property( return False -def _read_shortcut_guid_property(shortcut_path: Path, property_key: PROPERTYKEY) -> str | None: +def _read_shortcut_guid_property( # pragma: no cover + shortcut_path: Path, property_key: PROPERTYKEY +) -> str | None: """Read a GUID-valued property from a shortcut via IPropertyStore.""" if sys.platform != "win32" or not shortcut_path.exists() or _shell32 is None: return None @@ -610,7 +614,9 @@ def _read_shortcut_guid_property(shortcut_path: Path, property_key: PROPERTYKEY) return None -def _set_shortcut_guid_property(shortcut_path: Path, property_key: PROPERTYKEY, value: str) -> bool: +def _set_shortcut_guid_property( # pragma: no cover + shortcut_path: Path, property_key: PROPERTYKEY, value: str +) -> bool: """Set a GUID-valued property on a shortcut via IPropertyStore.""" if sys.platform != "win32" or _shell32 is None: return False diff --git a/tests/test_windows_app_user_model_id.py b/tests/test_windows_app_user_model_id.py index 58ab756a..4d8960fc 100644 --- a/tests/test_windows_app_user_model_id.py +++ b/tests/test_windows_app_user_model_id.py @@ -10,12 +10,18 @@ from accessiweather.constants import WINDOWS_APP_USER_MODEL_ID from accessiweather.windows_toast_identity import ( WINDOWS_TOAST_ACTIVATOR_CLSID, + WINDOWS_TOAST_PROTOCOL_SCHEME, + _build_protocol_handler_command, _is_unc_path, _load_toast_identity_stamp, _needs_shortcut_repair, + _normalize_clsid, + _register_protocol_activation_handler, + _resolve_notification_launch_command, _resolve_start_menu_shortcut_path, _run_powershell_json, _should_repair_shortcut, + _write_toast_identity_stamp, ensure_windows_toast_identity, set_windows_app_user_model_id, ) @@ -70,6 +76,195 @@ def test_is_unc_path_detects_network_paths(): assert _is_unc_path(r"C:\Apps\AccessiWeather.exe") is False +def test_normalize_clsid_accepts_valid_guid_and_rejects_invalid(): + assert _normalize_clsid("0d3c3f8e-7303-4c9b-81c7-ff8d8c1afc07") == WINDOWS_TOAST_ACTIVATOR_CLSID + assert _normalize_clsid("not-a-guid") is None + assert _normalize_clsid(None) is None + + +def test_resolve_notification_launch_command_prefers_frozen_executable(monkeypatch, tmp_path): + exe_path = tmp_path / "AccessiWeather.exe" + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.frozen", True, raising=False) + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.executable", str(exe_path)) + + assert _resolve_notification_launch_command() == [str(exe_path.resolve())] + + +def test_resolve_notification_launch_command_prefers_module_launch(monkeypatch, tmp_path): + monkeypatch.delattr(sys, "frozen", raising=False) + monkeypatch.setattr( + "accessiweather.windows_toast_identity.sys.executable", str(tmp_path / "python") + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity.importlib.util.find_spec", + lambda name: object() if name == "accessiweather" else None, + ) + + assert _resolve_notification_launch_command() == [ + str((tmp_path / "python").resolve()), + "-m", + "accessiweather", + ] + + +def test_resolve_notification_launch_command_falls_back_to_script_path(monkeypatch, tmp_path): + monkeypatch.delattr(sys, "frozen", raising=False) + monkeypatch.setattr( + "accessiweather.windows_toast_identity.sys.executable", str(tmp_path / "python") + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity.sys.argv", [str(tmp_path / "launcher.py")] + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity.importlib.util.find_spec", lambda _name: None + ) + + assert _resolve_notification_launch_command() == [ + str((tmp_path / "python").resolve()), + str((tmp_path / "launcher.py").resolve()), + ] + + +def test_resolve_notification_launch_command_falls_back_to_executable_when_argv_empty( + monkeypatch, tmp_path +): + monkeypatch.delattr(sys, "frozen", raising=False) + monkeypatch.setattr( + "accessiweather.windows_toast_identity.sys.executable", str(tmp_path / "python") + ) + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.argv", []) + monkeypatch.setattr( + "accessiweather.windows_toast_identity.importlib.util.find_spec", lambda _name: None + ) + + assert _resolve_notification_launch_command() == [str((tmp_path / "python").resolve())] + + +def test_build_protocol_handler_command_quotes_protocol_argument(monkeypatch): + monkeypatch.setattr( + "accessiweather.windows_toast_identity._resolve_notification_launch_command", + lambda: [r"C:\Program Files\AccessiWeather\AccessiWeather.exe"], + ) + + command = _build_protocol_handler_command() + + assert '"C:\\Program Files\\AccessiWeather\\AccessiWeather.exe"' in command + assert "%1" in command + + +def test_register_protocol_activation_handler_returns_false_off_windows(monkeypatch): + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.platform", "linux") + + assert _register_protocol_activation_handler() is False + + +def test_register_protocol_activation_handler_returns_false_without_winreg(monkeypatch): + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.platform", "win32") + original_import = __import__ + + def _fake_import(name, *args, **kwargs): + if name == "winreg": + raise ImportError("no winreg") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", _fake_import) + + assert _register_protocol_activation_handler() is False + + +def test_register_protocol_activation_handler_writes_expected_registry_keys(monkeypatch): + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.platform", "win32") + monkeypatch.setattr( + "accessiweather.windows_toast_identity._resolve_notification_launch_command", + lambda: [r"C:\AccessiWeather\AccessiWeather.exe"], + ) + monkeypatch.setattr( + "accessiweather.windows_toast_identity._build_protocol_handler_command", + lambda protocol_argument="%1": f'"C:\\AccessiWeather\\AccessiWeather.exe" "{protocol_argument}"', + ) + + writes: list[tuple[str, str | None, str]] = [] + + class _Key: + def __init__(self, path: str): + self.path = path + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + fake_winreg = SimpleNamespace( + HKEY_CURRENT_USER="HKCU", + REG_SZ="REG_SZ", + CreateKey=lambda hive, path: _Key(path), + SetValueEx=lambda key, name, _reserved, _reg_type, value: writes.append( + (key.path, name, value) + ), + ) + monkeypatch.setitem(sys.modules, "winreg", fake_winreg) + + assert _register_protocol_activation_handler() is True + assert ( + rf"Software\Classes\{WINDOWS_TOAST_PROTOCOL_SCHEME}", + None, + "URL:AccessiWeather Toast", + ) in writes + assert (rf"Software\Classes\{WINDOWS_TOAST_PROTOCOL_SCHEME}", "URL Protocol", "") in writes + assert ( + rf"Software\Classes\{WINDOWS_TOAST_PROTOCOL_SCHEME}\shell\open\command", + None, + '"C:\\AccessiWeather\\AccessiWeather.exe" "%1"', + ) in writes + + +def test_register_protocol_activation_handler_returns_false_when_registry_write_fails(monkeypatch): + monkeypatch.setattr("accessiweather.windows_toast_identity.sys.platform", "win32") + monkeypatch.setattr( + "accessiweather.windows_toast_identity._resolve_notification_launch_command", + lambda: [r"C:\AccessiWeather\AccessiWeather.exe"], + ) + + class _Key: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + fake_winreg = SimpleNamespace( + HKEY_CURRENT_USER="HKCU", + REG_SZ="REG_SZ", + CreateKey=lambda hive, path: _Key(), + SetValueEx=lambda *args, **kwargs: (_ for _ in ()).throw(OSError("registry write failed")), + ) + monkeypatch.setitem(sys.modules, "winreg", fake_winreg) + + assert _register_protocol_activation_handler() is False + + +def test_write_toast_identity_stamp_persists_protocol_and_normalized_clsid(tmp_path): + stamp_path = tmp_path / "toast_identity_stamp.json" + shortcut_path = tmp_path / "AccessiWeather.lnk" + + _write_toast_identity_stamp( + stamp_path=stamp_path, + shortcut_path=shortcut_path, + exe_path=r"C:\AccessiWeather\AccessiWeather.exe", + app_version="1.2.3", + verified=True, + readback_app_id=WINDOWS_TOAST_PROTOCOL_SCHEME, + toast_activator_clsid="0d3c3f8e-7303-4c9b-81c7-ff8d8c1afc07", + protocol_handler_registered=True, + ) + + payload = _load_toast_identity_stamp(stamp_path) + assert payload is not None + assert payload["toast_activator_clsid"] == WINDOWS_TOAST_ACTIVATOR_CLSID + assert payload["protocol_handler_registered"] is True + + def test_resolve_start_menu_shortcut_path_prefers_nested_installer_shortcut(tmp_path, monkeypatch): monkeypatch.setattr("accessiweather.windows_toast_identity.Path.home", lambda: tmp_path) From d05461eafbaad0613924d9381f3b916efbc39b7d Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 8 Apr 2026 20:42:52 +0000 Subject: [PATCH 4/4] style: format windows toast identity tests --- tests/test_windows_app_user_model_id.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_windows_app_user_model_id.py b/tests/test_windows_app_user_model_id.py index 4d8960fc..c486a7ce 100644 --- a/tests/test_windows_app_user_model_id.py +++ b/tests/test_windows_app_user_model_id.py @@ -180,7 +180,9 @@ def test_register_protocol_activation_handler_writes_expected_registry_keys(monk ) monkeypatch.setattr( "accessiweather.windows_toast_identity._build_protocol_handler_command", - lambda protocol_argument="%1": f'"C:\\AccessiWeather\\AccessiWeather.exe" "{protocol_argument}"', + lambda protocol_argument="%1": ( + f'"C:\\AccessiWeather\\AccessiWeather.exe" "{protocol_argument}"' + ), ) writes: list[tuple[str, str | None, str]] = []