Merge remote-tracking branch 'origin/webview-util'

This commit is contained in:
Dominik Jain committed 2026-04-17 16:11:13 +02:00
commit 37d40d6659
2 files changed
+258 -198

No files matched your search

+25 -198
View File
@@ -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()
+233
View File
@@ -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()