diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 34927596..f0cb7c82 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -706,7 +706,10 @@ class SerenaDashboardViewer: self.window: webview.Window self._tray_icon: Any + self._app_delegate: Any = None self._quitting = False + self._window_hidden_to_tray = start_minimized + self._suppress_next_activate_reopen = start_minimized @staticmethod def is_current_platform_supported() -> bool: @@ -747,53 +750,92 @@ class SerenaDashboardViewer: if self.tray: self._start_tray() - self._set_dock_icon_visible(not self.start_minimized) def _start_callback() -> None: + # pywebview installs its own NSApplication delegate during startup, + # so on macOS we need to wrap that final delegate here instead of + # earlier in run(), or the dock reopen handler gets overwritten. + if sys.platform == "darwin" and self.tray: + from PyObjCTools.AppHelper import callAfter + + callAfter(self._setup_macos_dock_handler) if self.start_minimized: self.window.minimize() else: - self.window.show() - self.window.restore() + self._show_window() webview.start(_start_callback, icon=icon_path) - def _on_closing(self) -> bool: - """Intercept window close: hide to tray instead of quitting.""" - if self._quitting: - return True + def _show_window(self) -> None: + if self.window: + self.window.show() + self.window.restore() + self._window_hidden_to_tray = False + self._suppress_next_activate_reopen = False + + def _hide_window(self) -> None: if self.window: self.window.hide() - if sys.platform == "darwin": - # setActivationPolicy_ must run on the main thread; _on_closing - # may be called from a pywebview background thread. - from PyObjCTools.AppHelper import callAfter + self._window_hidden_to_tray = True - callAfter(self._set_dock_icon_visible, False) + def _setup_macos_dock_handler(self) -> None: + """Set up handler for dock icon clicks on macOS.""" + from AppKit import NSApplication, NSObject + from objc import python_method # type: ignore[import-untyped] + + viewer = self # capture reference for the delegate + + # Use a unique class name to avoid conflict with pywebview's AppDelegate + class SerenaDockDelegate(NSObject): + @python_method + def initWithOriginalDelegate_(self, original: Any) -> Any: + obj = self.init() + if obj is not None: + obj._original_delegate = original + return obj + + def applicationShouldHandleReopen_hasVisibleWindows_(self, sender: Any, has_visible_windows: bool) -> bool: + """Called when dock icon is clicked.""" + if not has_visible_windows and viewer.window: + viewer._show_window() + # Forward to original delegate if it exists and has this method + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if hasattr(self._original_delegate, "applicationShouldHandleReopen_hasVisibleWindows_"): + return self._original_delegate.applicationShouldHandleReopen_hasVisibleWindows_(sender, has_visible_windows) + return True + + def applicationDidBecomeActive_(self, notification: Any) -> None: + """Fallback for dock activation paths that do not invoke reopen.""" + if viewer._suppress_next_activate_reopen: + viewer._suppress_next_activate_reopen = False + elif viewer._window_hidden_to_tray: + viewer._show_window() + # Forward to original delegate if it exists and has this method + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if hasattr(self._original_delegate, "applicationDidBecomeActive_"): + self._original_delegate.applicationDidBecomeActive_(notification) + + def forwardingTargetForSelector_(self, selector: Any) -> Any: + """Forward unknown selectors to original delegate.""" + if hasattr(self, "_original_delegate") and self._original_delegate is not None: + if self._original_delegate.respondsToSelector_(selector): + return self._original_delegate + return None + + ns_app = NSApplication.sharedApplication() + original_delegate = ns_app.delegate() + if original_delegate is getattr(self, "_app_delegate", None): + return + self._app_delegate = SerenaDockDelegate.alloc().initWithOriginalDelegate_(original_delegate) + ns_app.setDelegate_(self._app_delegate) + + def _on_closing(self) -> bool: + """Intercept window close: hide window instead of quitting (macOS standard behavior).""" + if self._quitting: + return True + self._hide_window() return False # prevent the window from actually closing - def _set_dock_icon_visible(self, visible: bool) -> None: - """macOS-only; toggles the dock icon. - - Sets the NSApplication activation policy to Regular (shows in dock) or - Accessory (hides from dock). No-op on other platforms. - """ - if sys.platform == "darwin": - from AppKit import ( - NSApplication, - NSApplicationActivationPolicyAccessory, - NSApplicationActivationPolicyRegular, - ) - - ns_app = NSApplication.sharedApplication() - policy = NSApplicationActivationPolicyRegular if visible else NSApplicationActivationPolicyAccessory - ns_app.setActivationPolicy_(policy) - if visible: - # setActivationPolicy_ alone is not enough at runtime; unhide_ fires - # the full applicationWillUnhide:/applicationDidUnhide: cycle, which - # is what actually restores the dock icon and brings windows forward. - ns_app.unhide_(None) - def _start_tray(self) -> None: # import pystray locally, because the import fails when there is no display! import pystray @@ -808,15 +850,10 @@ class SerenaDashboardViewer: icon_img = Image.open(dashboard_path / icon_filename) def show(_icon: TrayIcon, _item: Item) -> None: - if self.window: - self._set_dock_icon_visible(True) - self.window.show() - self.window.restore() + self._show_window() def hide(_icon: TrayIcon, _item: Item) -> None: - if self.window: - self.window.hide() - self._set_dock_icon_visible(False) + self._hide_window() def quit_app(_icon: TrayIcon, _item: Item) -> None: self._quitting = True diff --git a/test/serena/test_dashboard.py b/test/serena/test_dashboard.py index 8de6e8eb..578c06cd 100644 --- a/test/serena/test_dashboard.py +++ b/test/serena/test_dashboard.py @@ -1,6 +1,8 @@ +import sys from types import SimpleNamespace -from serena.dashboard import SerenaDashboardAPI +import serena.dashboard as dashboard_module +from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer from solidlsp.ls_config import Language @@ -53,3 +55,106 @@ def test_available_languages_exclude_project_languages(): assert Language.MARKDOWN.value not in available # ensure experimental languages remain available for selection assert Language.ANSIBLE.value in available + + +def test_dashboard_viewer_initializes_macos_delegate_slot(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") + assert viewer._app_delegate is None + assert viewer._window_hidden_to_tray is False + assert viewer._suppress_next_activate_reopen is False + + +def test_dashboard_viewer_initializes_hidden_state_for_minimized_start(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) + assert viewer._window_hidden_to_tray is True + assert viewer._suppress_next_activate_reopen is True + + +def test_dashboard_viewer_show_and_hide_helpers_update_state(): + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html", start_minimized=True) + + class _FakeWindow: + def __init__(self) -> None: + self.calls: list[str] = [] + + def show(self) -> None: + self.calls.append("show") + + def restore(self) -> None: + self.calls.append("restore") + + def hide(self) -> None: + self.calls.append("hide") + + viewer.window = _FakeWindow() + + viewer._show_window() + assert viewer.window.calls == ["show", "restore"] + assert viewer._window_hidden_to_tray is False + assert viewer._suppress_next_activate_reopen is False + + viewer._hide_window() + assert viewer.window.calls == ["show", "restore", "hide"] + assert viewer._window_hidden_to_tray is True + + +def test_dashboard_viewer_installs_macos_dock_handler_after_pywebview_start(monkeypatch): + order: list[str] = [] + + class _ClosingEvent: + def __iadd__(self, _handler): + order.append("bind-closing") + return self + + class _FakeWindow: + def __init__(self) -> None: + self.events = SimpleNamespace(closing=_ClosingEvent()) + + def minimize(self) -> None: + order.append("minimize") + + def show(self) -> None: + order.append("show") + + def restore(self) -> None: + order.append("restore") + + fake_window = _FakeWindow() + + def fake_create_window(*args, **kwargs): + order.append("create-window") + return fake_window + + def fake_start(callback, **kwargs): + del kwargs + order.append("start") + callback() + + class _FakeAppHelperModule: + @staticmethod + def callAfter(func, *args, **kwargs): + del args, kwargs + order.append("schedule-dock-handler") + func() + + monkeypatch.setattr(dashboard_module.sys, "platform", "darwin") + monkeypatch.setattr(dashboard_module.webview, "create_window", fake_create_window) + monkeypatch.setattr(dashboard_module.webview, "start", fake_start) + monkeypatch.setitem(sys.modules, "PyObjCTools.AppHelper", _FakeAppHelperModule) + + viewer = SerenaDashboardViewer("http://localhost:24282/dashboard/index.html") + monkeypatch.setattr(viewer, "_start_tray", lambda: order.append("start-tray")) + monkeypatch.setattr(viewer, "_setup_macos_dock_handler", lambda: order.append("setup-dock-handler")) + + viewer.run() + + assert order == [ + "create-window", + "bind-closing", + "start-tray", + "start", + "schedule-dock-handler", + "setup-dock-handler", + "show", + "restore", + ]