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. 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() 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: 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()