From afa41b555d35f7035651ebc9d8b54047ed12eaa4 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sat, 9 May 2026 00:04:22 -0400 Subject: [PATCH 1/5] feat(tray): keep Portkey Drop available from notification area Add a cross-platform wx TaskBarIcon integration so users can restore the app, open the transfer queue, check for updates, or exit from the notification area while keeping all actions available from the main UI and settings dialog. Constraint: PortkeyDrop PRs target dev and desktop UI controls must remain keyboard and screen-reader accessible. Rejected: Tray-only minimize behavior by default | would make close semantics surprising and could strand users who do not use notification area affordances. Confidence: high Scope-risk: moderate Directive: Keep notification-area actions mirrored by normal menus or dialogs so the tray never becomes the only accessible path. Tested: .\\.venv\\Scripts\\python.exe -m pytest; .\\.venv\\Scripts\\python.exe -m ruff check .; .\\.venv\\Scripts\\python.exe -m ruff format --check .; git diff --check Not-tested: Manual tray interaction on macOS and Linux desktops. Co-authored-by: OmX --- src/portkeydrop/app.py | 59 +++++++++++- src/portkeydrop/dialogs/settings.py | 18 ++++ src/portkeydrop/settings.py | 2 + src/portkeydrop/system_tray.py | 138 ++++++++++++++++++++++++++++ tests/_wx_stub.py | 34 +++++++ tests/test_settings.py | 6 ++ tests/test_settings_dialog_a11y.py | 6 ++ tests/test_system_tray.py | 135 +++++++++++++++++++++++++++ 8 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 src/portkeydrop/system_tray.py create mode 100644 tests/test_system_tray.py diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index 8b0983d..eae2b76 100644 --- a/src/portkeydrop/app.py +++ b/src/portkeydrop/app.py @@ -29,6 +29,7 @@ save_queue, ) from portkeydrop.services.transfer_service import TransferService, format_transfer_detail +from portkeydrop.system_tray import SystemTrayIcon from portkeydrop.local_files import ( delete_local, list_local_dir, @@ -124,6 +125,8 @@ def __init__(self) -> None: except ImportError: self.build_tag = os.environ.get("PORTKEYDROP_BUILD_TAG") self._auto_update_check_timer: wx.Timer | None = None + self._tray_icon: SystemTrayIcon | None = None + self._force_exit = False self._site_manager = SiteManager() self._transfer_service = TransferService( notify_window=self, @@ -146,6 +149,7 @@ def __init__(self) -> None: self._bind_events() self._update_title() self._refresh_local_files() + self._sync_tray_icon() wx.CallAfter(self._set_initial_focus) self._start_auto_update_checks() wx.CallAfter(self._check_for_updates_on_startup) @@ -757,7 +761,12 @@ def _on_disconnect(self, event) -> None: self.tb_host.SetFocus() def _on_exit(self, event: wx.CommandEvent) -> None: - self.Close() + self.request_exit() + + def request_exit(self) -> None: + """Close the application even when close-to-tray is enabled.""" + self._force_exit = True + self.Close(True) # --- Path bar events --- @@ -1816,6 +1825,7 @@ def _on_settings(self, event: wx.CommandEvent) -> None: ) self.update_check_updates_menu_label() self._start_auto_update_checks() + self._sync_tray_icon() self._populate_file_list( self.remote_file_list, self._get_visible_files(self._remote_files, self._remote_filter_text), @@ -2127,11 +2137,58 @@ def _restore_transfer_queue(self) -> None: msg = f"Restored {count} pending transfer{'s' if count != 1 else ''} from last session" wx.CallAfter(self._announce, msg) + def _sync_tray_icon(self) -> None: + """Create or remove the notification area icon to match settings.""" + app_settings = getattr(getattr(self, "_settings", None), "app", None) + enabled = bool(getattr(app_settings, "show_notification_area_icon", True)) + if not hasattr(self, "_tray_icon"): + self._tray_icon = None + if enabled and self._tray_icon is None: + try: + self._tray_icon = SystemTrayIcon(self) + except Exception: + logger.warning("Failed to initialize notification area icon", exc_info=True) + self._tray_icon = None + return + + if not enabled and self._tray_icon is not None: + self._destroy_tray_icon() + + def _destroy_tray_icon(self) -> None: + tray_icon = getattr(self, "_tray_icon", None) + if tray_icon is None: + return + try: + tray_icon.RemoveIcon() + except Exception: + logger.debug("Failed to remove notification area icon", exc_info=True) + try: + tray_icon.Destroy() + except Exception: + logger.debug("Failed to destroy notification area icon", exc_info=True) + self._tray_icon = None + + def _should_minimize_to_tray_on_close(self) -> bool: + app_settings = getattr(getattr(self, "_settings", None), "app", None) + return ( + not getattr(self, "_force_exit", False) + and getattr(self, "_tray_icon", None) is not None + and bool(getattr(app_settings, "show_notification_area_icon", True)) + and bool(getattr(app_settings, "minimize_to_notification_area_on_close", False)) + ) + def _on_close(self, event) -> None: """Save transfer queue and stop timers before closing the window.""" save_queue(self._transfer_service, get_config_dir()) + if self._should_minimize_to_tray_on_close(): + self.Hide() + self._announce("Portkey Drop is still running in the notification area.") + if event is not None and hasattr(event, "Veto"): + event.Veto() + return if self._auto_update_check_timer: self._auto_update_check_timer.Stop() + self._destroy_tray_icon() if event is not None and hasattr(event, "Skip"): event.Skip() diff --git a/src/portkeydrop/dialogs/settings.py b/src/portkeydrop/dialogs/settings.py index ce9b6ed..4a51762 100644 --- a/src/portkeydrop/dialogs/settings.py +++ b/src/portkeydrop/dialogs/settings.py @@ -289,6 +289,18 @@ def _build_display_tab(self) -> None: control_name="Date format", ) + self.show_tray_icon_check = self._add_checkbox_row( + sizer, + wx.CheckBox(panel, label="Show ¬ification area icon"), + name="Show notification area icon", + ) + + self.minimize_to_tray_check = self._add_checkbox_row( + sizer, + wx.CheckBox(panel, label="Minimize to notification area when &closing"), + name="Minimize to notification area on close", + ) + sizer.AddStretchSpacer(1) self.notebook.AddPage(panel, "Display") @@ -453,6 +465,10 @@ def _populate(self) -> None: self.sort_asc_check.SetValue(s.display.sort_ascending) idx = ["relative", "absolute"].index(s.display.date_format) self.date_format_choice.SetSelection(idx) + self.show_tray_icon_check.SetValue(getattr(s.app, "show_notification_area_icon", True)) + self.minimize_to_tray_check.SetValue( + getattr(s.app, "minimize_to_notification_area_on_close", False) + ) # Connection idx = list(SUPPORTED_PROTOCOL_VALUES).index(s.connection.protocol) self.default_proto_choice.SetSelection(idx) @@ -492,6 +508,8 @@ def get_settings(self) -> Settings: s.display.sort_by = self.sort_by_choice.GetStringSelection() s.display.sort_ascending = self.sort_asc_check.GetValue() s.display.date_format = self.date_format_choice.GetStringSelection() + s.app.show_notification_area_icon = self.show_tray_icon_check.GetValue() + s.app.minimize_to_notification_area_on_close = self.minimize_to_tray_check.GetValue() s.connection.protocol = self.default_proto_choice.GetStringSelection() s.connection.timeout = self.timeout_spin.GetValue() diff --git a/src/portkeydrop/settings.py b/src/portkeydrop/settings.py index 5d6dd11..6a659cc 100644 --- a/src/portkeydrop/settings.py +++ b/src/portkeydrop/settings.py @@ -59,6 +59,8 @@ class AppSettings: auto_update_enabled: bool = True update_check_interval_hours: int = 24 update_channel: str = "stable" + show_notification_area_icon: bool = True + minimize_to_notification_area_on_close: bool = False @dataclass diff --git a/src/portkeydrop/system_tray.py b/src/portkeydrop/system_tray.py new file mode 100644 index 0000000..b0190fb --- /dev/null +++ b/src/portkeydrop/system_tray.py @@ -0,0 +1,138 @@ +"""Notification area icon for Portkey Drop.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import wx +import wx.adv + +if TYPE_CHECKING: + from portkeydrop.app import MainFrame + +logger = logging.getLogger(__name__) + + +class SystemTrayIcon(wx.adv.TaskBarIcon): + """Cross-platform system tray / notification area icon.""" + + def __init__(self, frame: MainFrame) -> None: + super().__init__() + self.frame = frame + self._icon_set = False + self._cached_icon: wx.Icon | None = None + + self._setup_icon() + self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self._on_left_click) + self.Bind(wx.adv.EVT_TASKBAR_LEFT_DCLICK, self._on_left_click) + self.Bind(wx.adv.EVT_TASKBAR_RIGHT_DOWN, self._on_right_click) + + def _setup_icon(self) -> None: + icon = self._load_icon() + if icon is not None and icon.IsOk(): + self._cached_icon = icon + self.SetIcon(icon, "Portkey Drop") + self._icon_set = True + logger.debug("Notification area icon set") + else: + logger.warning("Notification area icon could not be loaded") + + def _load_icon(self) -> wx.Icon | None: + for icon_path in self._get_icon_paths(): + if not icon_path.exists(): + continue + try: + bitmap_type = ( + wx.BITMAP_TYPE_ICO if icon_path.suffix.lower() == ".ico" else wx.BITMAP_TYPE_PNG + ) + icon = wx.Icon(str(icon_path), bitmap_type) + if icon.IsOk(): + return icon + except Exception: + logger.debug("Failed to load tray icon from %s", icon_path, exc_info=True) + return self._create_default_icon() + + def _get_icon_paths(self) -> list[Path]: + if getattr(sys, "frozen", False): + base_path = Path(sys.executable).parent + return [ + base_path / "app.ico", + base_path / "resources" / "app.ico", + base_path / "resources" / "app_32.png", + base_path / "resources" / "app_16.png", + ] + + package_path = Path(__file__).parent + return [ + package_path / "resources" / "app.ico", + package_path / "resources" / "app_32.png", + package_path / "resources" / "app_16.png", + ] + + def _create_default_icon(self) -> wx.Icon: + bitmap = wx.Bitmap(16, 16) + dc = wx.MemoryDC(bitmap) + dc.SetBackground(wx.Brush(wx.Colour(42, 92, 170))) + dc.Clear() + dc.SetPen(wx.Pen(wx.WHITE, 1)) + dc.SetTextForeground(wx.WHITE) + dc.DrawText("P", 4, 1) + dc.SelectObject(wx.NullBitmap) + + icon = wx.Icon() + icon.CopyFromBitmap(bitmap) + return icon + + def _on_left_click(self, event) -> None: + self.show_main_window() + + def _on_right_click(self, event) -> None: + menu = self._create_popup_menu() + try: + self.PopupMenu(menu) + finally: + menu.Destroy() + + def _create_popup_menu(self) -> wx.Menu: + menu = wx.Menu() + show_item = menu.Append(wx.ID_ANY, "&Show Portkey Drop") + queue_item = menu.Append(wx.ID_ANY, "Transfer &Queue...") + updates_item = menu.Append(wx.ID_ANY, "Check for &Updates...") + menu.AppendSeparator() + exit_item = menu.Append(wx.ID_EXIT, "E&xit") + + self.Bind(wx.EVT_MENU, self._on_show_menu, show_item) + self.Bind(wx.EVT_MENU, self._on_transfer_queue_menu, queue_item) + self.Bind(wx.EVT_MENU, self._on_check_updates_menu, updates_item) + self.Bind(wx.EVT_MENU, self._on_exit_menu, exit_item) + return menu + + def _on_show_menu(self, event) -> None: + self.show_main_window() + + def _on_transfer_queue_menu(self, event) -> None: + self.show_main_window() + self.frame._on_transfer_queue(event) + + def _on_check_updates_menu(self, event) -> None: + self.show_main_window() + self.frame._on_check_updates(event) + + def _on_exit_menu(self, event) -> None: + self.frame.request_exit() + + def show_main_window(self) -> None: + self.frame.Show(True) + self.frame.Iconize(False) + if sys.platform == "darwin": + self.frame.RequestUserAttention() + else: + self.frame.Raise() + self.frame.SetFocus() + + def update_tooltip(self, text: str) -> None: + if self._icon_set and self._cached_icon is not None and self._cached_icon.IsOk(): + self.SetIcon(self._cached_icon, text) diff --git a/tests/_wx_stub.py b/tests/_wx_stub.py index 425b1d7..f16f975 100644 --- a/tests/_wx_stub.py +++ b/tests/_wx_stub.py @@ -95,6 +95,13 @@ def _new_id_ref(*_args, **_kwargs): fake_wx.ListCtrl = lambda *args, **kwargs: _SimpleWidget() fake_wx.Timer = lambda *args, **kwargs: _SimpleWidget() fake_wx.FileDataObject = MagicMock() + fake_wx.Icon = MagicMock + fake_wx.Bitmap = MagicMock + fake_wx.MemoryDC = MagicMock + fake_wx.Brush = MagicMock + fake_wx.Colour = MagicMock + fake_wx.Pen = MagicMock + fake_wx.NullBitmap = None class _Clipboard: @staticmethod @@ -145,6 +152,9 @@ def Close() -> None: fake_wx.ICON_INFORMATION = 105 fake_wx.ID_EXIT = 200 fake_wx.ID_ABOUT = 201 + fake_wx.ID_ANY = -1 + fake_wx.BITMAP_TYPE_ICO = 1 + fake_wx.BITMAP_TYPE_PNG = 2 fake_wx.StaticBox = lambda *args, **kwargs: _SimpleWidget() fake_wx.StaticBoxSizer = lambda *args, **kwargs: _SimpleWidget() @@ -201,6 +211,30 @@ def SetDescription(self, value: str) -> None: fake_adv.AboutDialogInfo = _AboutDialogInfo fake_adv.AboutBox = lambda info: None + fake_adv.EVT_TASKBAR_LEFT_DOWN = object() + fake_adv.EVT_TASKBAR_LEFT_DCLICK = object() + fake_adv.EVT_TASKBAR_RIGHT_DOWN = object() + + class _TaskBarIcon: + def __init__(self, *args, **kwargs) -> None: + self._bindings: list[tuple] = [] + + def Bind(self, *args, **kwargs) -> None: + self._bindings.append((args, kwargs)) + + def SetIcon(self, *args, **kwargs) -> bool: + return True + + def PopupMenu(self, *_args, **_kwargs) -> None: + return None + + def RemoveIcon(self) -> bool: + return True + + def Destroy(self) -> None: + return None + + fake_adv.TaskBarIcon = _TaskBarIcon fake_wx.adv = fake_adv return fake_wx, fake_adv diff --git a/tests/test_settings.py b/tests/test_settings.py index 3f3f9fd..b32127a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -70,6 +70,8 @@ def test_defaults(self): assert s.auto_update_enabled is True assert s.update_check_interval_hours == 24 assert s.update_channel == "stable" + assert s.show_notification_area_icon is True + assert s.minimize_to_notification_area_on_close is False class TestSettings: @@ -97,6 +99,8 @@ def test_save_and_load(self, tmp_path): settings.app.auto_update_enabled = False settings.app.update_check_interval_hours = 12 settings.app.update_channel = "nightly" + settings.app.show_notification_area_icon = False + settings.app.minimize_to_notification_area_on_close = True save_settings(settings, tmp_path) loaded = load_settings(tmp_path) @@ -108,6 +112,8 @@ def test_save_and_load(self, tmp_path): assert loaded.app.auto_update_enabled is False assert loaded.app.update_check_interval_hours == 12 assert loaded.app.update_channel == "nightly" + assert loaded.app.show_notification_area_icon is False + assert loaded.app.minimize_to_notification_area_on_close is True def test_load_corrupt_file(self, tmp_path): (tmp_path / "settings.json").write_text("not json", encoding="utf-8") diff --git a/tests/test_settings_dialog_a11y.py b/tests/test_settings_dialog_a11y.py index 3881c8d..b4c3155 100644 --- a/tests/test_settings_dialog_a11y.py +++ b/tests/test_settings_dialog_a11y.py @@ -217,6 +217,8 @@ def test_all_controls_have_unambiguous_accessible_names(monkeypatch): "passive_check": "Passive mode", "verify_keys_choice": "Verify host keys", "remember_local_folder_check": "Remember last local folder on startup", + "show_tray_icon_check": "Show notification area icon", + "minimize_to_tray_check": "Minimize to notification area on close", "auto_update_check": "Automatic update checks", "update_interval_spin": "Update check interval", "update_channel_choice": "Update channel", @@ -362,6 +364,8 @@ def _on_check_updates(channel: str, parent) -> None: def test_get_settings_persists_updater_fields(monkeypatch): dlg = _load_dialog(monkeypatch) dlg.auto_update_check.SetValue(False) + dlg.show_tray_icon_check.SetValue(False) + dlg.minimize_to_tray_check.SetValue(True) dlg.update_interval_spin.SetValue(12) dlg.update_channel_choice.SetSelection(1) dlg.remember_local_folder_check.SetValue(False) @@ -370,6 +374,8 @@ def test_get_settings_persists_updater_fields(monkeypatch): settings = dlg.get_settings() assert settings.app.auto_update_enabled is False + assert settings.app.show_notification_area_icon is False + assert settings.app.minimize_to_notification_area_on_close is True assert settings.app.update_check_interval_hours == 12 assert settings.app.update_channel == "nightly" assert settings.app.remember_last_local_folder_on_startup is False diff --git a/tests/test_system_tray.py b/tests/test_system_tray.py new file mode 100644 index 0000000..4fd060e --- /dev/null +++ b/tests/test_system_tray.py @@ -0,0 +1,135 @@ +"""Tests for notification area / system tray support.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from tests._wx_stub import load_module_with_fake_wx + + +@pytest.fixture +def app_module(monkeypatch): + return load_module_with_fake_wx("portkeydrop.app", monkeypatch) + + +def test_system_tray_icon_creates_portkeydrop_menu(monkeypatch): + module, fake_wx = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + + appended: list[str] = [] + + class FakeMenu: + def Append(self, item_id, label, *_args): + appended.append(label) + return SimpleNamespace(id=item_id, label=label) + + def AppendSeparator(self): + appended.append("separator") + + def Destroy(self): + pass + + fake_wx.Menu = FakeMenu + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray.Bind = MagicMock() + tray.frame = MagicMock() + + tray._create_popup_menu() + + assert "&Show Portkey Drop" in appended + assert "Transfer &Queue..." in appended + assert "Check for &Updates..." in appended + assert "E&xit" in appended + + +def test_system_tray_show_main_window_restores_frame(monkeypatch): + module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + frame = MagicMock() + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray.frame = frame + + tray.show_main_window() + + frame.Show.assert_called_once_with(True) + frame.Iconize.assert_called_once_with(False) + frame.Raise.assert_called_once() + frame.SetFocus.assert_called_once() + + +def test_system_tray_quit_delegates_to_frame(monkeypatch): + module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + frame = MagicMock() + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray.frame = frame + + tray._on_exit_menu(None) + + frame.request_exit.assert_called_once() + + +def test_main_frame_creates_tray_icon_when_setting_enabled(app_module, monkeypatch): + app, _ = app_module + frame = object.__new__(app.MainFrame) + frame._settings = SimpleNamespace(app=SimpleNamespace(show_notification_area_icon=True)) + fake_tray = MagicMock() + monkeypatch.setattr(app, "SystemTrayIcon", MagicMock(return_value=fake_tray)) + + frame._tray_icon = None + frame._sync_tray_icon() + + app.SystemTrayIcon.assert_called_once_with(frame) + assert frame._tray_icon is fake_tray + + +def test_main_frame_removes_tray_icon_when_setting_disabled(app_module): + app, _ = app_module + frame = object.__new__(app.MainFrame) + tray = MagicMock() + frame._tray_icon = tray + frame._settings = SimpleNamespace(app=SimpleNamespace(show_notification_area_icon=False)) + + frame._sync_tray_icon() + + tray.RemoveIcon.assert_called_once() + tray.Destroy.assert_called_once() + assert frame._tray_icon is None + + +def test_close_minimizes_to_tray_when_enabled(app_module): + app, _ = app_module + frame = object.__new__(app.MainFrame) + frame._settings = SimpleNamespace( + app=SimpleNamespace( + show_notification_area_icon=True, + minimize_to_notification_area_on_close=True, + ) + ) + frame._tray_icon = MagicMock() + frame._transfer_service = MagicMock() + frame._auto_update_check_timer = MagicMock() + frame.Hide = MagicMock() + frame._announce = MagicMock() + event = MagicMock() + + frame._on_close(event) + + frame.Hide.assert_called_once() + event.Veto.assert_called_once() + frame._auto_update_check_timer.Stop.assert_not_called() + frame._announce.assert_called_once_with( + "Portkey Drop is still running in the notification area." + ) + + +def test_request_exit_closes_without_minimizing(app_module): + app, _ = app_module + frame = object.__new__(app.MainFrame) + frame._force_exit = False + frame.Close = MagicMock() + + frame.request_exit() + + assert frame._force_exit is True + frame.Close.assert_called_once_with(True) From 04f9f8a5c1ec3a77637496ed2c5d040006cfb4a5 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sat, 9 May 2026 00:13:32 -0400 Subject: [PATCH 2/5] fix(tray): let Alt+F4 honor close-to-tray Remove the Windows Exit menu Alt+F4 accelerator so Alt+F4 reaches the frame close event instead of the explicit exit command. Constraint: File > Exit must remain a real exit while window close gestures honor the minimize-to-notification-area setting. Rejected: Treating Alt+F4 as File > Exit | bypassed the saved close-to-tray preference. Confidence: high Scope-risk: narrow Directive: Keep close gestures separate from explicit exit commands when adding tray lifecycle behavior. Tested: .\\.venv\\Scripts\\python.exe -m pytest tests/test_app.py::test_windows_menu_does_not_override_alt_f4_close tests/test_app.py::test_macos_menu_uses_command_q_for_exit_not_disconnect tests/test_system_tray.py::test_close_minimizes_to_tray_when_enabled; .\\.venv\\Scripts\\python.exe -m ruff check src\\portkeydrop\\app.py tests\\test_app.py; .\\.venv\\Scripts\\python.exe -m ruff format --check src\\portkeydrop\\app.py tests\\test_app.py; git diff --check Not-tested: Manual Alt+F4 retest after pushing. Co-authored-by: OmX --- src/portkeydrop/app.py | 4 ++-- tests/test_app.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index eae2b76..1650a06 100644 --- a/src/portkeydrop/app.py +++ b/src/portkeydrop/app.py @@ -164,8 +164,8 @@ def _build_menu(self) -> None: file_menu.AppendSeparator() file_menu.Append(ID_SETTINGS, "Se&ttings...", "Application settings") file_menu.AppendSeparator() - exit_shortcut = "Ctrl+Q" if wx.Platform == "__WXMAC__" else "Alt+F4" - file_menu.Append(wx.ID_EXIT, f"E&xit\t{exit_shortcut}", "Exit application") + exit_label = "E&xit\tCtrl+Q" if wx.Platform == "__WXMAC__" else "E&xit" + file_menu.Append(wx.ID_EXIT, exit_label, "Exit application") menubar.Append(file_menu, "&File") # Edit menu (for file operations) diff --git a/tests/test_app.py b/tests/test_app.py index 1c71fe6..bebbabe 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -225,6 +225,54 @@ def Append(self, *_args): assert "E&xit\tCtrl+Q" in appended_labels +def test_windows_menu_does_not_override_alt_f4_close(app_module): + app, fake_wx = app_module + fake_wx.Platform = "__WXMSW__" + + appended_labels: list[str] = [] + + class FakeMenu: + def Append(self, *_args): + if len(_args) >= 2: + appended_labels.append(_args[1]) + return MagicMock(Enable=MagicMock()) + + def AppendSeparator(self): + pass + + def AppendCheckItem(self, *_args): + if len(_args) >= 2: + appended_labels.append(_args[1]) + return MagicMock() + + def AppendRadioItem(self, *_args): + if len(_args) >= 2: + appended_labels.append(_args[1]) + return MagicMock() + + def AppendSubMenu(self, *_args): + pass + + def Check(self, *_args): + pass + + class FakeMenuBar: + def Append(self, *_args): + pass + + fake_wx.Menu = FakeMenu + fake_wx.MenuBar = FakeMenuBar + frame = object.__new__(app.MainFrame) + frame._settings = SimpleNamespace(display=SimpleNamespace(show_hidden_files=False)) + frame._get_update_channel = MagicMock(return_value="stable") + frame.SetMenuBar = MagicMock() + + frame._build_menu() + + assert "E&xit" in appended_labels + assert "E&xit\tAlt+F4" not in appended_labels + + def test_switch_pane_focus_local_to_remote(app_module): app, _ = app_module frame = _hydrate_frame(app_module) From 0dfdd16566355a4ef3c16f5aa26d04bfd52282df Mon Sep 17 00:00:00 2001 From: Orinks Date: Sat, 9 May 2026 00:14:22 -0400 Subject: [PATCH 3/5] fix(mac): label quit shortcut as Cmd+Q Use the macOS command-key label for the Exit menu while preserving the Windows fix that leaves Alt+F4 to the close event. Constraint: macOS menus should expose Command shortcuts, not Control shortcuts. Rejected: Keeping Ctrl+Q in the macOS menu | it is the wrong platform convention and screen-reader-visible text. Confidence: high Scope-risk: narrow Directive: Keep platform shortcut labels aligned with native desktop conventions. Tested: .\\.venv\\Scripts\\python.exe -m pytest tests/test_app.py::test_macos_menu_uses_command_q_for_exit_not_disconnect tests/test_app.py::test_windows_menu_does_not_override_alt_f4_close; .\\.venv\\Scripts\\python.exe -m ruff check src\\portkeydrop\\app.py tests\\test_app.py; .\\.venv\\Scripts\\python.exe -m ruff format --check src\\portkeydrop\\app.py tests\\test_app.py; git diff --check Not-tested: Manual macOS menu rendering. Co-authored-by: OmX --- src/portkeydrop/app.py | 2 +- tests/test_app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index 1650a06..036b203 100644 --- a/src/portkeydrop/app.py +++ b/src/portkeydrop/app.py @@ -164,7 +164,7 @@ def _build_menu(self) -> None: file_menu.AppendSeparator() file_menu.Append(ID_SETTINGS, "Se&ttings...", "Application settings") file_menu.AppendSeparator() - exit_label = "E&xit\tCtrl+Q" if wx.Platform == "__WXMAC__" else "E&xit" + exit_label = "E&xit\tCmd+Q" if wx.Platform == "__WXMAC__" else "E&xit" file_menu.Append(wx.ID_EXIT, exit_label, "Exit application") menubar.Append(file_menu, "&File") diff --git a/tests/test_app.py b/tests/test_app.py index bebbabe..7a717dc 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -222,7 +222,7 @@ def Append(self, *_args): assert "&Disconnect" in appended_labels assert "&Disconnect\tCtrl+Q" not in appended_labels - assert "E&xit\tCtrl+Q" in appended_labels + assert "E&xit\tCmd+Q" in appended_labels def test_windows_menu_does_not_override_alt_f4_close(app_module): From fe66b4102a580865c944ba2e3f15ff140eccad1d Mon Sep 17 00:00:00 2001 From: Orinks Date: Sat, 9 May 2026 00:16:06 -0400 Subject: [PATCH 4/5] fix(mac): use wx command-key accelerator convention Keep the macOS quit menu accelerator as Ctrl+Q in source because wx maps Ctrl accelerators to the Command key on macOS. Constraint: Command+Q must remain the native macOS quit gesture; wx accelerator syntax expresses that as Ctrl+Q, not a literal Cmd token. Rejected: Literal Cmd+Q accelerator text | may not be parsed consistently by wx and fights the documented Ctrl-to-Command mapping. Confidence: high Scope-risk: narrow Directive: Use RawCtrl only for the physical Control key on macOS; use Ctrl when the user-facing shortcut should be Command. Tested: .\\.venv\\Scripts\\python.exe -m pytest tests/test_app.py::test_macos_menu_uses_command_q_for_exit_not_disconnect tests/test_app.py::test_windows_menu_does_not_override_alt_f4_close; .\\.venv\\Scripts\\python.exe -m ruff check src\\portkeydrop\\app.py tests\\test_app.py; .\\.venv\\Scripts\\python.exe -m ruff format --check src\\portkeydrop\\app.py tests\\test_app.py; git diff --check Not-tested: Manual macOS Command+Q runtime test. Co-authored-by: OmX --- src/portkeydrop/app.py | 3 ++- tests/test_app.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index 036b203..2e708a7 100644 --- a/src/portkeydrop/app.py +++ b/src/portkeydrop/app.py @@ -164,7 +164,8 @@ def _build_menu(self) -> None: file_menu.AppendSeparator() file_menu.Append(ID_SETTINGS, "Se&ttings...", "Application settings") file_menu.AppendSeparator() - exit_label = "E&xit\tCmd+Q" if wx.Platform == "__WXMAC__" else "E&xit" + # wx maps Ctrl accelerators to Command on macOS; RawCtrl is the physical Ctrl key. + exit_label = "E&xit\tCtrl+Q" if wx.Platform == "__WXMAC__" else "E&xit" file_menu.Append(wx.ID_EXIT, exit_label, "Exit application") menubar.Append(file_menu, "&File") diff --git a/tests/test_app.py b/tests/test_app.py index 7a717dc..731bc0c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -222,7 +222,8 @@ def Append(self, *_args): assert "&Disconnect" in appended_labels assert "&Disconnect\tCtrl+Q" not in appended_labels - assert "E&xit\tCmd+Q" in appended_labels + # wx maps Ctrl accelerators to Command on macOS, so this is Command+Q to users. + assert "E&xit\tCtrl+Q" in appended_labels def test_windows_menu_does_not_override_alt_f4_close(app_module): From b6b7fea9a7a291e776746e5d124b3be3ba4b8578 Mon Sep 17 00:00:00 2001 From: Orinks Date: Sat, 9 May 2026 00:22:29 -0400 Subject: [PATCH 5/5] test(tray): cover notification area icon behavior Increase changed-line coverage for the tray icon paths so the PR can satisfy the coverage gate before merging. Constraint: PR #128 coverage gate requires at least 80 percent diff coverage against origin/dev. Confidence: high Scope-risk: narrow Directive: Keep tray behavior covered through icon setup, menu actions, tooltip updates, and platform-specific restore behavior. Tested: full pytest suite with coverage XML, local diff-cover gate at 96 percent, and Ruff. Not-tested: Manual tray verification after this test-only change. Co-authored-by: OmX --- tests/test_system_tray.py | 210 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/tests/test_system_tray.py b/tests/test_system_tray.py index 4fd060e..a5113a3 100644 --- a/tests/test_system_tray.py +++ b/tests/test_system_tray.py @@ -44,6 +44,164 @@ def Destroy(self): assert "E&xit" in appended +def test_system_tray_icon_initializes_icon_and_taskbar_bindings(monkeypatch): + module, fake_wx = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + frame = MagicMock() + icon = MagicMock() + icon.IsOk.return_value = True + monkeypatch.setattr(module.SystemTrayIcon, "_load_icon", MagicMock(return_value=icon)) + + tray = module.SystemTrayIcon(frame) + + assert tray.frame is frame + assert tray._cached_icon is icon + assert tray._icon_set is True + assert (fake_wx.adv.EVT_TASKBAR_LEFT_DOWN, tray._on_left_click) in [ + args for args, _kwargs in tray._bindings + ] + assert (fake_wx.adv.EVT_TASKBAR_LEFT_DCLICK, tray._on_left_click) in [ + args for args, _kwargs in tray._bindings + ] + assert (fake_wx.adv.EVT_TASKBAR_RIGHT_DOWN, tray._on_right_click) in [ + args for args, _kwargs in tray._bindings + ] + + +def test_system_tray_setup_icon_warns_when_icon_cannot_load(monkeypatch): + module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray._icon_set = False + tray._cached_icon = None + tray.SetIcon = MagicMock() + tray._load_icon = MagicMock(return_value=None) + + tray._setup_icon() + + tray.SetIcon.assert_not_called() + assert tray._cached_icon is None + assert tray._icon_set is False + + +def test_system_tray_load_icon_uses_first_valid_icon_file(monkeypatch, tmp_path): + module, fake_wx = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + ico_path = tmp_path / "app.ico" + ico_path.write_bytes(b"icon") + png_path = tmp_path / "app_32.png" + icon = MagicMock() + icon.IsOk.return_value = True + fake_wx.Icon = MagicMock(return_value=icon) + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray._get_icon_paths = MagicMock(return_value=[ico_path, png_path]) + tray._create_default_icon = MagicMock() + + loaded = tray._load_icon() + + assert loaded is icon + fake_wx.Icon.assert_called_once_with(str(ico_path), fake_wx.BITMAP_TYPE_ICO) + tray._create_default_icon.assert_not_called() + + +def test_system_tray_load_icon_falls_back_after_bad_or_missing_files(monkeypatch, tmp_path): + module, fake_wx = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + bad_path = tmp_path / "app.png" + bad_path.write_bytes(b"bad icon") + missing_path = tmp_path / "missing.ico" + fallback = MagicMock() + invalid_icon = MagicMock() + invalid_icon.IsOk.return_value = False + fake_wx.Icon = MagicMock(return_value=invalid_icon) + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray._get_icon_paths = MagicMock(return_value=[missing_path, bad_path]) + tray._create_default_icon = MagicMock(return_value=fallback) + + loaded = tray._load_icon() + + assert loaded is fallback + fake_wx.Icon.assert_called_once_with(str(bad_path), fake_wx.BITMAP_TYPE_PNG) + tray._create_default_icon.assert_called_once() + + +def test_system_tray_load_icon_ignores_icon_constructor_errors(monkeypatch, tmp_path): + module, fake_wx = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + icon_path = tmp_path / "app.ico" + icon_path.write_bytes(b"bad icon") + fallback = MagicMock() + fake_wx.Icon = MagicMock(side_effect=RuntimeError("bad icon")) + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray._get_icon_paths = MagicMock(return_value=[icon_path]) + tray._create_default_icon = MagicMock(return_value=fallback) + + assert tray._load_icon() is fallback + + +def test_system_tray_get_icon_paths_handles_source_and_frozen_layouts(monkeypatch, tmp_path): + module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + + source_paths = tray._get_icon_paths() + + assert source_paths == [ + module.Path(module.__file__).parent / "resources" / "app.ico", + module.Path(module.__file__).parent / "resources" / "app_32.png", + module.Path(module.__file__).parent / "resources" / "app_16.png", + ] + + executable = tmp_path / "PortkeyDrop.exe" + monkeypatch.setattr(module.sys, "frozen", True, raising=False) + monkeypatch.setattr(module.sys, "executable", str(executable)) + + assert tray._get_icon_paths() == [ + tmp_path / "app.ico", + tmp_path / "resources" / "app.ico", + tmp_path / "resources" / "app_32.png", + tmp_path / "resources" / "app_16.png", + ] + + +def test_system_tray_creates_default_icon(monkeypatch): + module, fake_wx = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + bitmap = object() + dc = MagicMock() + icon = MagicMock() + fake_wx.Bitmap = MagicMock(return_value=bitmap) + fake_wx.MemoryDC = MagicMock(return_value=dc) + fake_wx.Brush = MagicMock(return_value=object()) + fake_wx.Colour = MagicMock(return_value=object()) + fake_wx.Pen = MagicMock(return_value=object()) + fake_wx.WHITE = object() + fake_wx.Icon = MagicMock(return_value=icon) + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + + assert tray._create_default_icon() is icon + + fake_wx.Bitmap.assert_called_once_with(16, 16) + icon.CopyFromBitmap.assert_called_once() + + +def test_system_tray_pointer_and_menu_events_open_expected_actions(monkeypatch): + module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + frame = MagicMock() + event = object() + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray.frame = frame + tray.show_main_window = MagicMock() + tray.PopupMenu = MagicMock() + menu = MagicMock() + tray._create_popup_menu = MagicMock(return_value=menu) + + tray._on_left_click(event) + tray._on_right_click(event) + tray._on_show_menu(event) + tray._on_transfer_queue_menu(event) + tray._on_check_updates_menu(event) + + assert tray.show_main_window.call_count == 4 + tray.PopupMenu.assert_called_once_with(menu) + menu.Destroy.assert_called_once() + frame._on_transfer_queue.assert_called_once_with(event) + frame._on_check_updates.assert_called_once_with(event) + + def test_system_tray_show_main_window_restores_frame(monkeypatch): module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) frame = MagicMock() @@ -58,6 +216,58 @@ def test_system_tray_show_main_window_restores_frame(monkeypatch): frame.SetFocus.assert_called_once() +def test_system_tray_show_main_window_requests_attention_on_macos(monkeypatch): + module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + frame = MagicMock() + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray.frame = frame + monkeypatch.setattr(module.sys, "platform", "darwin") + + tray.show_main_window() + + frame.Show.assert_called_once_with(True) + frame.Iconize.assert_called_once_with(False) + frame.RequestUserAttention.assert_called_once() + frame.Raise.assert_not_called() + frame.SetFocus.assert_not_called() + + +def test_system_tray_update_tooltip_reuses_cached_icon(monkeypatch): + module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + icon = MagicMock() + icon.IsOk.return_value = True + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray._icon_set = True + tray._cached_icon = icon + tray.SetIcon = MagicMock() + + tray.update_tooltip("Transfers active") + + tray.SetIcon.assert_called_once_with(icon, "Transfers active") + + +def test_system_tray_update_tooltip_ignores_missing_or_invalid_icon(monkeypatch): + module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) + invalid_icon = MagicMock() + invalid_icon.IsOk.return_value = False + tray = module.SystemTrayIcon.__new__(module.SystemTrayIcon) + tray.SetIcon = MagicMock() + + tray._icon_set = False + tray._cached_icon = invalid_icon + tray.update_tooltip("Transfers active") + tray.SetIcon.assert_not_called() + + tray._icon_set = True + tray._cached_icon = None + tray.update_tooltip("Transfers active") + tray.SetIcon.assert_not_called() + + tray._cached_icon = invalid_icon + tray.update_tooltip("Transfers active") + tray.SetIcon.assert_not_called() + + def test_system_tray_quit_delegates_to_frame(monkeypatch): module, _ = load_module_with_fake_wx("portkeydrop.system_tray", monkeypatch) frame = MagicMock()