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 @@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file.
- 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.
- Relaunching AccessiWeather no longer briefly activates the already-running window twice in the rare case its primary relaunch signal can't be delivered.
- 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
7 changes: 6 additions & 1 deletion src/accessiweather/app_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@

logger = logging.getLogger(__name__)

# The handoff file is only a fallback for when the named-pipe IPC channel is
# unavailable, so it doesn't need sub-second latency. Poll on a relaxed
# interval to avoid a perpetual high-frequency wakeup for the whole session.
_ACTIVATION_HANDOFF_POLL_MS = 2000


def show_alert_dialog(parent, alert, settings=None) -> None:
"""Lazy wrapper for the single-alert details dialog."""
Expand Down Expand Up @@ -90,7 +95,7 @@ def _start_activation_handoff_polling(self) -> None:
self._on_activation_handoff_timer,
self._activation_handoff_timer,
)
self._activation_handoff_timer.Start(750)
self._activation_handoff_timer.Start(_ACTIVATION_HANDOFF_POLL_MS)

def _start_activation_ipc_server(self) -> None:
"""Start duplicate-launch IPC and route requests onto the UI thread."""
Expand Down
19 changes: 18 additions & 1 deletion src/accessiweather/single_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ def try_acquire_lock(self) -> bool:
self._lock_acquired = True
return True

# Policy: on any unexpected failure of the mutex check we return True
# ("allow startup"). A guard that occasionally permits a second instance
# is far better than one that can wedge the app shut, so every error
# path below intentionally falls through to launching.
try:
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_configure_kernel32_signatures(kernel32)
Expand Down Expand Up @@ -140,10 +144,23 @@ def request_existing_instance_show(
if sys.platform == "win32" and _send_activation_request_ipc(handoff_request):
return True

self.write_activation_handoff(handoff_request)
if sys.platform != "win32":
self.write_activation_handoff(handoff_request)
return False

shown = self._show_existing_window()
# The direct window restore only raises the window; it cannot act on a
# specific request. Write the handoff so the primary instance still
# routes discussion/alert_details intents — but skip it for a plain
# generic_fallback that a successful restore already satisfied, so the
# primary doesn't handle the same generic request a second time when it
# polls for handoffs.
if not shown or handoff_request.kind != "generic_fallback":
self.write_activation_handoff(handoff_request)
return shown

def _show_existing_window(self) -> bool:
"""Restore and foreground the existing main window; True if one was shown."""
try:
user32 = ctypes.WinDLL("user32", use_last_error=True)
_configure_user32_signatures(user32)
Expand Down
16 changes: 16 additions & 0 deletions tests/test_app_notification_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,19 @@ def fake_windll(name, use_last_error=True):
user32.ShowWindow.assert_called_once_with(0x1_0000_4242, 9)
user32.SetForegroundWindow.assert_called_once_with(0x1_0000_4242)
frame.Raise.assert_not_called()


def test_start_activation_handoff_polling_uses_relaxed_interval() -> None:
"""Handoff polling arms the timer at the relaxed fallback interval."""
from accessiweather import app_activation

app = AccessiWeatherApp.__new__(AccessiWeatherApp)
app._activation_handoff_timer = None
app.Bind = MagicMock()

with patch("accessiweather.app_activation.wx") as mock_wx:
app._start_activation_handoff_polling()
timer = mock_wx.Timer.return_value

assert app_activation._ACTIVATION_HANDOFF_POLL_MS == 2000
timer.Start.assert_called_once_with(app_activation._ACTIVATION_HANDOFF_POLL_MS)
46 changes: 46 additions & 0 deletions tests/test_single_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,52 @@ def test_second_windows_launch_restores_window_with_location_title(monkeypatch,
assert user32.foreground_calls == [2468]


def test_generic_fallback_skips_handoff_when_window_restore_succeeds(monkeypatch, tmp_path):
runtime_paths = RuntimeStoragePaths(config_root=tmp_path / "config")
app = SimpleNamespace(runtime_paths=runtime_paths, formal_name="AccessiWeather")
kernel32 = _FakeKernel32(last_error=ERROR_ALREADY_EXISTS)
user32 = _FakeUser32(hwnd=2468)

import accessiweather.single_instance as single_instance

monkeypatch.setattr(single_instance.sys, "platform", "win32")
monkeypatch.setattr(single_instance, "ctypes", _FakeCtypes(kernel32, user32))
monkeypatch.setattr(single_instance, "_send_activation_request_ipc", lambda request: False)

manager = SingleInstanceManager(app, runtime_paths=runtime_paths)

assert manager.try_acquire_lock() is False
assert manager.request_existing_instance_show() is True
assert user32.foreground_calls == [2468]
# A plain generic restore that succeeded must not also leave a handoff for
# the primary to consume and act on a second time.
assert manager.consume_activation_handoff() is None


def test_discussion_request_still_writes_handoff_when_window_restore_succeeds(
monkeypatch, tmp_path
):
runtime_paths = RuntimeStoragePaths(config_root=tmp_path / "config")
app = SimpleNamespace(runtime_paths=runtime_paths, formal_name="AccessiWeather")
kernel32 = _FakeKernel32(last_error=ERROR_ALREADY_EXISTS)
user32 = _FakeUser32(hwnd=2468)

import accessiweather.single_instance as single_instance

monkeypatch.setattr(single_instance.sys, "platform", "win32")
monkeypatch.setattr(single_instance, "ctypes", _FakeCtypes(kernel32, user32))
monkeypatch.setattr(single_instance, "_send_activation_request_ipc", lambda request: False)

manager = SingleInstanceManager(app, runtime_paths=runtime_paths)

assert manager.try_acquire_lock() is False
assert manager.request_existing_instance_show(NotificationActivationRequest(kind="discussion"))
assert user32.foreground_calls == [2468]
# The window poke can't open the discussion dialog, so the intent must still
# be handed off for the primary to route.
assert manager.consume_activation_handoff() == NotificationActivationRequest(kind="discussion")


def test_second_windows_launch_writes_generic_handoff_when_window_lookup_fails(
monkeypatch, tmp_path
):
Expand Down