Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions src/accessiweather/app_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
38 changes: 38 additions & 0 deletions src/accessiweather/single_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import contextlib
import ctypes
import logging
import sys
Expand All @@ -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."""

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions tests/test_app_notification_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()