diff --git a/src/portkeydrop/app.py b/src/portkeydrop/app.py index 8b0983d..2e708a7 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) @@ -160,8 +164,9 @@ 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") + # 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") # Edit menu (for file operations) @@ -757,7 +762,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 +1826,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 +2138,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_app.py b/tests/test_app.py index 1c71fe6..731bc0c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -222,9 +222,58 @@ def Append(self, *_args): assert "&Disconnect" in appended_labels assert "&Disconnect\tCtrl+Q" not 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): + 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) 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..a5113a3 --- /dev/null +++ b/tests/test_system_tray.py @@ -0,0 +1,345 @@ +"""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_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() + 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_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() + 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)