From b0214bb76d062698c101887f0a0ed7a5a28143a9 Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 28 May 2026 17:15:34 -0400 Subject: [PATCH 1/4] fix(single-instance): declare Win32 signatures to avoid 64-bit handle truncation ctypes defaults function restype/argtypes to 32-bit c_int, so the HANDLE returned by CreateMutexW and the HWNDs from FindWindowW were truncated on 64-bit Windows before being passed back to CloseHandle/ShowWindow/ SetForegroundWindow. Declare c_void_p signatures for the kernel32 and user32 calls so handles round-trip intact. Signature setup is suppressed on the test fakes (plain methods reject attribute assignment), so behavior is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/accessiweather/single_instance.py | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/accessiweather/single_instance.py b/src/accessiweather/single_instance.py index d14ddcae..893521a3 100644 --- a/src/accessiweather/single_instance.py +++ b/src/accessiweather/single_instance.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import ctypes import logging import sys @@ -23,6 +24,39 @@ SW_RESTORE = 9 +def _configure_kernel32_signatures(kernel32) -> None: + """ + Declare kernel32 signatures so 64-bit HANDLEs are not truncated. + + ctypes defaults a function's restype/argtypes to 32-bit ``c_int``. On + 64-bit Windows a HANDLE is pointer-width, so without these declarations the + handle returned by CreateMutexW would be truncated before being passed back + to CloseHandle. The suppression keeps the test fakes (plain methods, which + reject attribute assignment) working unchanged. + """ + with contextlib.suppress(Exception): + kernel32.CreateMutexW.restype = ctypes.c_void_p + kernel32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p] + kernel32.CloseHandle.restype = ctypes.c_bool + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + + +def _configure_user32_signatures(user32) -> None: + """Declare user32 signatures so window handles are not truncated to int.""" + with contextlib.suppress(Exception): + user32.FindWindowW.restype = ctypes.c_void_p + user32.FindWindowW.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p] + user32.EnumWindows.restype = ctypes.c_bool + user32.GetWindowTextLengthW.restype = ctypes.c_int + user32.GetWindowTextLengthW.argtypes = [ctypes.c_void_p] + user32.GetWindowTextW.restype = ctypes.c_int + user32.GetWindowTextW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_int] + user32.ShowWindow.restype = ctypes.c_bool + user32.ShowWindow.argtypes = [ctypes.c_void_p, ctypes.c_int] + user32.SetForegroundWindow.restype = ctypes.c_bool + user32.SetForegroundWindow.argtypes = [ctypes.c_void_p] + + class SingleInstanceManager: """Coordinates one running AccessiWeather instance per Windows session.""" @@ -67,6 +101,7 @@ def try_acquire_lock(self) -> bool: try: kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + _configure_kernel32_signatures(kernel32) handle = kernel32.CreateMutexW(None, False, self.mutex_name) if not handle: logger.warning("CreateMutexW failed; allowing startup to continue") @@ -111,6 +146,7 @@ def request_existing_instance_show( try: user32 = ctypes.WinDLL("user32", use_last_error=True) + _configure_user32_signatures(user32) hwnd = self._find_accessiweather_window(user32) if not hwnd: logger.info("No existing AccessiWeather window found to restore") @@ -167,6 +203,7 @@ def _is_accessiweather_window_title(title: str) -> bool: def _existing_window_is_present(self) -> bool: try: user32 = ctypes.WinDLL("user32", use_last_error=True) + _configure_user32_signatures(user32) return bool(self._find_accessiweather_window(user32)) except Exception: return False @@ -192,6 +229,7 @@ def release_lock(self) -> None: try: kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + _configure_kernel32_signatures(kernel32) kernel32.CloseHandle(self._mutex_handle) logger.info("Released AccessiWeather single-instance mutex") except Exception as exc: From 99cc0e10301637c7a0d6e73c2dbac5b6255641c4 Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 28 May 2026 17:22:59 -0400 Subject: [PATCH 2/4] fix(activation): declare Win32 signatures in _force_foreground_window Same 64-bit handle-truncation issue as the single-instance mutex fix: the HWND from frame.GetHandle() was passed to IsIconic/ShowWindow/ SetForegroundWindow through ctypes' default c_int restype/argtypes, truncating the pointer on 64-bit Windows. Use a local WinDLL with declared c_void_p signatures (rather than the shared ctypes.windll cache) so the handle round- trips intact without mutating process-global function prototypes. Co-Authored-By: Claude Opus 4.8 --- src/accessiweather/app_activation.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/accessiweather/app_activation.py b/src/accessiweather/app_activation.py index d6cbfd01..b15135d3 100644 --- a/src/accessiweather/app_activation.py +++ b/src/accessiweather/app_activation.py @@ -159,11 +159,21 @@ def _force_foreground_window(frame) -> None: import ctypes hwnd = frame.GetHandle() - user32 = ctypes.windll.user32 + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + # Declare signatures so the 64-bit HWND is not truncated to c_int. + user32.IsIconic.restype = ctypes.c_bool + user32.IsIconic.argtypes = [ctypes.c_void_p] + user32.ShowWindow.restype = ctypes.c_bool + user32.ShowWindow.argtypes = [ctypes.c_void_p, ctypes.c_int] + user32.AllowSetForegroundWindow.argtypes = [ctypes.c_ulong] + user32.SetForegroundWindow.restype = ctypes.c_bool + user32.SetForegroundWindow.argtypes = [ctypes.c_void_p] + kernel32.GetCurrentProcessId.restype = ctypes.c_ulong SW_RESTORE = 9 if user32.IsIconic(hwnd): user32.ShowWindow(hwnd, SW_RESTORE) - user32.AllowSetForegroundWindow(ctypes.windll.kernel32.GetCurrentProcessId()) + user32.AllowSetForegroundWindow(kernel32.GetCurrentProcessId()) user32.SetForegroundWindow(hwnd) except Exception: frame.Raise() From cb27ddea967dac9d32ebe463cb4e69374f7170bc Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 28 May 2026 17:49:07 -0400 Subject: [PATCH 3/4] docs(changelog): note the 64-bit Windows window-restore fix Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cdaa5aa..b7e89bc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file. - Forecaster Notes AI summaries now use the same default prompt for every text product unless you've set custom AI prompt options. - Default AI summaries now stay grounded in the provided weather text or data, so they are less likely to invent forecast details when summarizing reports. - Relaunching AccessiWeather on Windows now reliably restores the already-running window from desktop shortcuts, direct EXE launches, and portable copies without relying on the window title. +- Restoring the running AccessiWeather window on relaunch, and bringing it to the front from a notification, now work reliably on 64-bit Windows where an internal window-handle bug could previously leave the window in the background. - Alt+F4 now stays routed through the normal close-to-tray behavior after switching between All Locations and saved locations. - Automatic Windows startup now stays in the background when AccessiWeather is already running, and startup shortcuts are recreated with a stable AccessiWeather target for portable copies. - The Windows installer now closes running AccessiWeather copies automatically before installing, then can launch the updated app normally when setup exits. From ff6c5d54efb989086267d3601705319228d81925 Mon Sep 17 00:00:00 2001 From: Orinks Date: Thu, 28 May 2026 17:54:41 -0400 Subject: [PATCH 4/4] test(activation): cover the Win32 branch of _force_foreground_window Drives _force_foreground_window through fake user32/kernel32 DLLs so the handle-signature path is exercised on non-Windows CI, plus a non-Windows case asserting the frame.Raise() fallback. Satisfies the diff-coverage gate for the rewritten ctypes block. Co-Authored-By: Claude Opus 4.8 --- tests/test_app_notification_activation.py | 38 +++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_app_notification_activation.py b/tests/test_app_notification_activation.py index 14ff876b..d5677359 100644 --- a/tests/test_app_notification_activation.py +++ b/tests/test_app_notification_activation.py @@ -313,3 +313,41 @@ def test_handle_activation_uses_force_foreground_on_windows() -> None: request = NotificationActivationRequest(kind="generic_fallback") app._handle_notification_activation_request(request) mock_force.assert_called_once_with(mw) + + +def test_force_foreground_window_non_windows_uses_raise(monkeypatch) -> None: + """On non-Windows, _force_foreground_window just raises the frame.""" + from accessiweather import app_activation + + monkeypatch.setattr(app_activation.sys, "platform", "linux") + frame = SimpleNamespace(Raise=MagicMock()) + + AccessiWeatherApp._force_foreground_window(frame) + + frame.Raise.assert_called_once_with() + + +def test_force_foreground_window_uses_win32_handle_apis(monkeypatch) -> None: + """The Windows branch passes the full window handle to the Win32 calls.""" + import ctypes + + from accessiweather import app_activation + + user32 = MagicMock() + kernel32 = MagicMock() + user32.IsIconic.return_value = True + + def fake_windll(name, use_last_error=True): + return {"user32": user32, "kernel32": kernel32}[name] + + monkeypatch.setattr(app_activation.sys, "platform", "win32") + monkeypatch.setattr(ctypes, "WinDLL", fake_windll, raising=False) + + frame = SimpleNamespace(GetHandle=lambda: 0x1_0000_4242, Raise=MagicMock()) + + AccessiWeatherApp._force_foreground_window(frame) + + user32.IsIconic.assert_called_once_with(0x1_0000_4242) + user32.ShowWindow.assert_called_once_with(0x1_0000_4242, 9) + user32.SetForegroundWindow.assert_called_once_with(0x1_0000_4242) + frame.Raise.assert_not_called()