From 4147ad82549a7b35c4b6bc99318e930f9e3a544b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 16 Apr 2026 22:17:46 +0200 Subject: [PATCH] Refactoring: Extract utility class WebViewWithTray --- src/serena/dashboard.py | 223 ++++----------------------------- src/serena/util/pywebview.py | 233 +++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 198 deletions(-) create mode 100644 src/serena/util/pywebview.py diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 1799ea17..569b17a2 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -3,17 +3,13 @@ import os import socket import sys import threading -import time import urllib.error import urllib.request from html import escape from pathlib import Path from typing import TYPE_CHECKING, Any, Self -import psutil -import webview from flask import Flask, Response, redirect, request, send_from_directory -from PIL import Image from pydantic import BaseModel from sensai.util import logging from sensai.util.pickle import dump_pickle, load_pickle @@ -23,6 +19,7 @@ from serena.config.serena_config import SerenaConfig, SerenaPaths from serena.constants import SERENA_DASHBOARD_DIR from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler +from serena.util.pywebview import WebViewWithTray if TYPE_CHECKING: from serena.agent import SerenaAgent @@ -783,7 +780,7 @@ class SerenaDashboardAPI: return thread, port -class SerenaDashboardViewer: +class SerenaDashboardViewer(WebViewWithTray): """ Minimal pywebview wrapper with optional system tray. """ @@ -794,24 +791,33 @@ class SerenaDashboardViewer: self, url: str, *, - start_minimized: bool = False, width: int = 1400, height: int = 900, + start_minimized: bool = False, parent_process_id: int | None = None, + tray: bool = True, ): - self.url = url - # Use system tray to allow hiding to tray and intercepting close to hide instead of quit - self.tray = True - self.title = "Serena Dashboard" - self.width = width - self.height = height - self.start_minimized = start_minimized - self.parent_process_id = parent_process_id + dashboard_path = Path(SERENA_DASHBOARD_DIR) - self.window: webview.Window - self._tray_icon: Any - self._quitting = False - self._app_icon_path: str | None = None + # .ico is Windows-only; macOS expects a PNG for the window/dock icon. + app_icon_filename = "serena.ico" if sys.platform == "win32" else "serena-icon-1024-mac.png" + app_icon_path = str(dashboard_path / app_icon_filename) + + tray_icon_filename = "serena-icon-tray-mac.png" if sys.platform == "darwin" else "serena-icon-48.png" + tray_icon_path = str(dashboard_path / tray_icon_filename) + + super().__init__( + url, + title="Serena Dashboard", + tray=tray, + width=width, + height=height, + start_minimized=start_minimized, + parent_process_id=parent_process_id, + app_id="oraios.serena", + app_icon_path=app_icon_path, + tray_icon_path=tray_icon_path, + ) @staticmethod def is_current_platform_supported() -> bool: @@ -829,187 +835,8 @@ class SerenaDashboardViewer: return sys.platform in supported_platforms def run(self) -> None: - # set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) - if sys.platform == "win32": - import ctypes - - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena") - if self.DEBUG: logging.configure(level=logging.DEBUG) logging.add_file_logger(SerenaPaths().get_next_log_file_path("dashboard-viewer")) - dashboard_path = Path(SERENA_DASHBOARD_DIR) - # .ico is Windows-only; macOS expects a PNG for the window/dock icon. - icon_filename = "serena.ico" if sys.platform == "win32" else "serena-icon-1024-mac.png" - icon_path = str(dashboard_path / icon_filename) - self._app_icon_path = icon_path - - # Create hidden to avoid flash; show/restore/minimize in start callback. - window = webview.create_window( - self.title, - self.url, - width=self.width, - height=self.height, - hidden=self.start_minimized, - text_select=True, - zoomable=True, - ) - assert window is not None - self.window = window - - if self.tray: - self.window.events.closing += self._on_closing - self._start_tray() - - def _start_callback() -> None: - if self.start_minimized: - self._hide_window() - else: - self._show_window() - - # start a thread to monitor the parent process - if self.parent_process_id is not None: - threading.Thread(target=self._monitor_parent_process, daemon=True).start() - - webview.start(_start_callback, icon=icon_path) - - def _monitor_parent_process(self) -> None: - """ - Monitors the parent process and shuts down the dashboard viewer if the parent process exits. - """ - pid = self.parent_process_id - log.info("Starting parent process monitor thread (parent_pid=%d, pid=%s)", pid, os.getpid()) - try: - parent_process = psutil.Process(pid) - while parent_process.is_running(): - time.sleep(1) - except psutil.NoSuchProcess: - pass # Parent process already exited - log.info("Parent process (pid=%d) has exited, shutting down dashboard viewer", pid) - self._terminate() - - def _show_window(self) -> None: - if not self.window: - return - - if sys.platform == "darwin": - from PyObjCTools.AppHelper import callAfter - - callAfter(self._show_window_on_macos) - else: - self.window.show() - self.window.restore() - - def _hide_window(self) -> None: - if not self.window: - return - - if sys.platform == "darwin": - from PyObjCTools.AppHelper import callAfter - - callAfter(self._hide_window_on_macos) - else: - self.window.hide() - - def _show_window_on_macos(self) -> None: - from AppKit import ( - NSApplication, - NSApplicationActivationPolicyRegular, - ) - from PyObjCTools.AppHelper import callLater - - ns_app = NSApplication.sharedApplication() - ns_app.setActivationPolicy_(NSApplicationActivationPolicyRegular) - self._set_macos_app_icon(ns_app) - ns_app.unhide_(None) - ns_app.activateIgnoringOtherApps_(True) - # Give the status item menu a beat to close before restoring the window. - callLater(0.1, self._restore_window_on_macos) - - def _restore_window_on_macos(self) -> None: - self.window.show() - self.window.restore() - - def _hide_window_on_macos(self) -> None: - from AppKit import ( - NSApplication, - NSApplicationActivationPolicyAccessory, - ) - - self.window.hide() - NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory) - - def _set_macos_app_icon(self, ns_app: Any) -> None: - if not self._app_icon_path: - return - - from AppKit import NSImage - - ns_image = NSImage.alloc().initByReferencingFile_(self._app_icon_path) - if ns_image is not None: - ns_app.setApplicationIconImage_(ns_image) - - 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 _terminate(self) -> None: - """ - Terminates the viewer application and the tray icon - """ - self._quitting = True - try: - if self._tray_icon: - self._tray_icon.stop() - finally: - if self.window: - self.window.destroy() - - def _start_tray(self) -> None: - # import pystray locally, because the import fails when there is no display! - import pystray - from pystray import MenuItem as Item - from pystray._base import Icon as TrayIcon - - dashboard_path = Path(SERENA_DASHBOARD_DIR) - - # macOS menu bar icons are displayed at 16pt; 32px covers Retina (@2x). - # Windows/Linux tray icons are larger, so 48px is the better fit there. - icon_filename = "serena-icon-tray-mac.png" if sys.platform == "darwin" else "serena-icon-48.png" - icon_img = Image.open(dashboard_path / icon_filename) - - def show(_icon: TrayIcon, _item: Item) -> None: - self._show_window() - - def hide(_icon: TrayIcon, _item: Item) -> None: - self._hide_window() - - def quit_app(_icon: TrayIcon, _item: Item) -> None: - self._terminate() - - menu = pystray.Menu( - Item("Open", show, default=True), - Item("Hide", hide), - Item("Quit", quit_app), - ) - - kwargs: dict[str, Any] = {} - if sys.platform == "darwin": - # Passing darwin_nsapplication integrates pystray with the NSApplication - # run loop that webview.start() is about to enter. sharedApplication() - # is idempotent; pywebview will reuse the same singleton. - from AppKit import NSApplication - - kwargs["darwin_nsapplication"] = NSApplication.sharedApplication() - - self._tray_icon = pystray.Icon("dashboard_viewer", icon_img, self.title, menu, **kwargs) - - # On Windows/Linux, run_detached spawns pystray's own internal thread and - # returns immediately. On macOS it hooks into the NSApplication run loop - # that webview.start() is about to enter (run_detached is always called - # before webview.start() on macOS — see run()). - self._tray_icon.run_detached() + super().run() diff --git a/src/serena/util/pywebview.py b/src/serena/util/pywebview.py new file mode 100644 index 00000000..6489437a --- /dev/null +++ b/src/serena/util/pywebview.py @@ -0,0 +1,233 @@ +import logging +import os +import sys +import threading +import time +from typing import Any + +import psutil +import webview +from PIL import Image + +log = logging.getLogger(__name__) + + +class WebViewWithTray: + """ + Web view window with optional system tray. + """ + + DEBUG = False + + def __init__( + self, + url: str, + *, + title: str, + start_minimized: bool = False, + width: int = 1400, + height: int = 900, + tray: bool = False, + parent_process_id: int | None = None, + app_id: str | None = None, + app_icon_path: str | None = None, + tray_icon_path: str | None = None, + ): + """ + :param url: the URL to be show in the web view + :param title: the title of the window + :param start_minimized: whether to show the window minimised on startup (if `tray` is enabled, minimised to tray) + :param width: width of the window in pixels + :param height: height of the window in pixels + :param tray: whether to enable the system tray icon and functionality. + If this is enabled, closing the window will minimise it to tray instead of quitting the application. + :param parent_process_id: the PID of the parent process to monitor; if specified, the viewer will automatically + shut itself down when the parent process exits. + :param app_id: the application ID to use for the viewer to ensure that the window is grouped separately (with separate + icon) in the taskbar + """ + self._url = url + self._use_tray = tray + self._title = title + self._start_minimized = start_minimized + self._parent_process_id = parent_process_id + self._app_id = app_id + + self.window: webview.Window + self._tray_icon: Any + self._quitting = False + self._app_icon_path = app_icon_path + self._tray_icon_path = tray_icon_path + + # Create hidden to avoid flash; show/restore/minimize in start callback. + window = webview.create_window( + self._title, + self._url, + width=width, + height=height, + hidden=self._start_minimized, + text_select=True, + zoomable=True, + ) + assert window is not None + self.window = window + + def run(self) -> None: + # set app id (avoid app being lumped together with other Python-based apps in Windows taskbar) + if self._app_id is not None and sys.platform == "win32": + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("oraios.serena") + + if self._use_tray: + self.window.events.closing += self._on_closing + self._start_tray() + + def _start_callback() -> None: + if self._start_minimized: + self._hide_window() + else: + self._show_window() + + # start a thread to monitor the parent process + if self._parent_process_id is not None: + threading.Thread(target=self._monitor_parent_process, daemon=True).start() + + webview.start(_start_callback, icon=self._app_icon_path) + + def _monitor_parent_process(self) -> None: + """ + Monitors the parent process and shuts down the dashboard viewer if the parent process exits. + """ + pid = self._parent_process_id + log.info("Starting parent process monitor thread (parent_pid=%d, pid=%s)", pid, os.getpid()) + try: + parent_process = psutil.Process(pid) + while parent_process.is_running(): + time.sleep(1) + except psutil.NoSuchProcess: + pass # Parent process already exited + log.info("Parent process (pid=%d) has exited, shutting down dashboard viewer", pid) + self._terminate() + + def _show_window(self) -> None: + if not self.window: + return + + if sys.platform == "darwin": + from PyObjCTools.AppHelper import callAfter + + callAfter(self._show_window_on_macos) + else: + self.window.show() + self.window.restore() + + def _hide_window(self) -> None: + if not self.window: + return + + if sys.platform == "darwin": + from PyObjCTools.AppHelper import callAfter + + callAfter(self._hide_window_on_macos) + else: + self.window.hide() + + def _show_window_on_macos(self) -> None: + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyRegular, + ) + from PyObjCTools.AppHelper import callLater + + ns_app = NSApplication.sharedApplication() + ns_app.setActivationPolicy_(NSApplicationActivationPolicyRegular) + self._set_macos_app_icon(ns_app) + ns_app.unhide_(None) + ns_app.activateIgnoringOtherApps_(True) + # Give the status item menu a beat to close before restoring the window. + callLater(0.1, self._restore_window_on_macos) + + def _restore_window_on_macos(self) -> None: + self.window.show() + self.window.restore() + + def _hide_window_on_macos(self) -> None: + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyAccessory, + ) + + self.window.hide() + NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory) + + def _set_macos_app_icon(self, ns_app: Any) -> None: + if not self._app_icon_path: + return + + from AppKit import NSImage + + ns_image = NSImage.alloc().initByReferencingFile_(self._app_icon_path) + if ns_image is not None: + ns_app.setApplicationIconImage_(ns_image) + + 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 _terminate(self) -> None: + """ + Terminates the viewer application and the tray icon + """ + self._quitting = True + try: + if self._tray_icon: + self._tray_icon.stop() + finally: + if self.window: + self.window.destroy() + + def _start_tray(self) -> None: + # import pystray locally, because the import fails when there is no display! + import pystray + from pystray import MenuItem as Item + from pystray._base import Icon as TrayIcon + + icon_img = None + if self._tray_icon_path is not None: + icon_img = Image.open(self._tray_icon_path) + + def show(_icon: TrayIcon, _item: Item) -> None: + self._show_window() + + def hide(_icon: TrayIcon, _item: Item) -> None: + self._hide_window() + + def quit_app(_icon: TrayIcon, _item: Item) -> None: + self._terminate() + + menu = pystray.Menu( + Item("Open", show, default=True), + Item("Hide", hide), + Item("Quit", quit_app), + ) + + kwargs: dict[str, Any] = {} + if sys.platform == "darwin": + # Passing darwin_nsapplication integrates pystray with the NSApplication + # run loop that webview.start() is about to enter. sharedApplication() + # is idempotent; pywebview will reuse the same singleton. + from AppKit import NSApplication + + kwargs["darwin_nsapplication"] = NSApplication.sharedApplication() + + self._tray_icon = pystray.Icon("dashboard_viewer", icon_img, self._title, menu, **kwargs) + + # On Windows/Linux, run_detached spawns pystray's own internal thread and + # returns immediately. On macOS it hooks into the NSApplication run loop + # that webview.start() is about to enter (run_detached is always called + # before webview.start() on macOS — see run()). + self._tray_icon.run_detached()