mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 10:16:00 +00:00
Add centralised Serena dashboard tray manager
Add class SerenaDashboardTrayManager: A single tray app to manage all Serena instances (detached process which is informed of running instances via network requests) Tray icon functionality was consequently removed from SerenaDashboardViewer, which is now a plain viewer.
This commit is contained in:
1 parent
07e1f66eba
commit
a068cb5563
3 files changed
+382
-150
No files matched your search
+49
-32
@@ -2,17 +2,16 @@
|
||||
The Serena Model Context Protocol (MCP) Server
|
||||
"""
|
||||
|
||||
import multiprocessing
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from logging import Logger
|
||||
from typing import TYPE_CHECKING, Optional, TypeVar
|
||||
|
||||
import webview
|
||||
from sensai.util import logging
|
||||
from sensai.util.logging import LogTime
|
||||
from sensai.util.string import dict_string
|
||||
@@ -30,7 +29,7 @@ from serena.config.serena_config import (
|
||||
SerenaPaths,
|
||||
ToolInclusionDefinition,
|
||||
)
|
||||
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer
|
||||
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager
|
||||
from serena.ls_manager import LanguageServerManager
|
||||
from serena.project import MemoriesManager, Project
|
||||
from serena.prompt_factory import SerenaPromptFactory
|
||||
@@ -364,6 +363,10 @@ class SerenaAgent:
|
||||
self._active_tools: AvailableTools
|
||||
self._update_active_tools()
|
||||
|
||||
# record the agent start time (used for tray manager registration)
|
||||
self._agent_started_at = datetime.now().isoformat(timespec="seconds")
|
||||
self._dashboard_port: int | None = None
|
||||
|
||||
# start the dashboard (web frontend), registering its log handler
|
||||
# should be the last thing to happen in the initialization since the dashboard
|
||||
# may access various parts of the agent
|
||||
@@ -371,13 +374,14 @@ class SerenaAgent:
|
||||
self._dashboard_thread, port = SerenaDashboardAPI(
|
||||
get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats
|
||||
).run_in_thread(host=self.serena_config.web_dashboard_listen_address)
|
||||
self._dashboard_port = port
|
||||
dashboard_host = self.serena_config.web_dashboard_listen_address
|
||||
if dashboard_host == "0.0.0.0":
|
||||
dashboard_host = "localhost"
|
||||
dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html"
|
||||
self._dashboard_url = dashboard_url
|
||||
log.info("Serena web dashboard started at %s", dashboard_url)
|
||||
self._start_dashboard_viewer(minimized=not self.serena_config.web_dashboard_open_on_launch)
|
||||
self._ensure_tray_manager_and_register(open_on_launch=self.serena_config.web_dashboard_open_on_launch)
|
||||
# inform the GUI window (if any)
|
||||
if self._gui_log_viewer is not None:
|
||||
self._gui_log_viewer.set_dashboard_url(dashboard_url)
|
||||
@@ -520,39 +524,42 @@ class SerenaAgent:
|
||||
"""
|
||||
return self._dashboard_url
|
||||
|
||||
@staticmethod
|
||||
def _start_dashboard_viewer_process_function(url: str, minimized: bool) -> None:
|
||||
def _ensure_tray_manager_and_register(self, open_on_launch: bool) -> None:
|
||||
"""
|
||||
Main function of the subprocess for starting the dashboard viewer
|
||||
"""
|
||||
try:
|
||||
SerenaDashboardViewer(url, start_minimized=minimized).run()
|
||||
except webview.errors.WebViewException as e:
|
||||
log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}")
|
||||
# Fall back to opening the browser window if the window was supposed to be shown directly
|
||||
if not minimized:
|
||||
SerenaAgent._open_dashboard_in_browser(url)
|
||||
Ensure the tray manager is running and register this dashboard instance with it.
|
||||
|
||||
def _start_dashboard_viewer(self, minimized: bool) -> None:
|
||||
"""
|
||||
Starts the dashboard viewer (in a separate process) or, if the current platform does not support it,
|
||||
opens the dashboard in the default web browser.
|
||||
If the current platform supports the native tray manager, this method starts
|
||||
the manager (if not already running) and registers the instance. Otherwise,
|
||||
it falls back to opening the dashboard in the default web browser.
|
||||
|
||||
:param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform).
|
||||
If the viewer is not supported on the current platform, then this controls whether to open the browser window.
|
||||
:param open_on_launch: whether the dashboard should be opened immediately
|
||||
"""
|
||||
if not system_has_usable_display():
|
||||
log.info("Not starting the Serena dashboard viewer because no usable display was detected.")
|
||||
log.info("Not starting the Serena tray manager because no usable display was detected.")
|
||||
return
|
||||
|
||||
url = self.get_dashboard_url()
|
||||
assert url is not None
|
||||
if SerenaDashboardViewer.is_current_platform_supported():
|
||||
process = multiprocessing.Process(target=self._start_dashboard_viewer_process_function, args=(url, minimized), daemon=True)
|
||||
process.start()
|
||||
assert self._dashboard_port is not None
|
||||
|
||||
if SerenaDashboardTrayManager.is_current_platform_supported():
|
||||
# ensure the singleton tray manager process is running
|
||||
SerenaDashboardTrayManager.ensure_running()
|
||||
|
||||
# determine the current project name (if any)
|
||||
project_name = self._active_project.project_name if self._active_project is not None else None
|
||||
|
||||
# register this instance with the tray manager, optionally opening a viewer
|
||||
SerenaDashboardTrayManager.register_instance(
|
||||
port=self._dashboard_port,
|
||||
dashboard_url=url,
|
||||
project=project_name,
|
||||
started_at=self._agent_started_at,
|
||||
open_viewer=open_on_launch,
|
||||
)
|
||||
else:
|
||||
log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback")
|
||||
if not minimized:
|
||||
log.info("Not starting Serena tray manager because the current platform does not support it; using browser-based fallback")
|
||||
if open_on_launch:
|
||||
self._open_dashboard_in_browser(url)
|
||||
|
||||
def open_dashboard(self) -> bool:
|
||||
@@ -765,6 +772,11 @@ class SerenaAgent:
|
||||
if self._project_activation_callback is not None:
|
||||
self._project_activation_callback()
|
||||
|
||||
# notify the tray manager of the project change (if the dashboard is running)
|
||||
dashboard_port = getattr(self, "_dashboard_port", None)
|
||||
if dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported():
|
||||
SerenaDashboardTrayManager.update_project(dashboard_port, project.project_name)
|
||||
|
||||
def activate_project_from_path_or_name(
|
||||
self, project_root_or_name: str, update_active_modes: bool = True, update_active_tools: bool = True
|
||||
) -> Project:
|
||||
@@ -890,14 +902,19 @@ class SerenaAgent:
|
||||
"""
|
||||
Shuts down the agent, freeing resources and stopping background tasks.
|
||||
"""
|
||||
# guard against __del__ being called on a partially constructed instance
|
||||
if not hasattr(self, "_active_project"):
|
||||
return
|
||||
log.info("SerenaAgent is shutting down ...")
|
||||
if self._active_project is not None:
|
||||
|
||||
# NOTE: This is called from __del__, so the instance may not be fully initialized (exception during __init__)
|
||||
|
||||
if hasattr(self, "_dashboard_port"):
|
||||
if self._dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported():
|
||||
SerenaDashboardTrayManager.unregister_instance(self._dashboard_port)
|
||||
|
||||
if hasattr(self, "_active_project") and self._active_project is not None:
|
||||
self._active_project.shutdown(timeout=timeout)
|
||||
self._active_project = None
|
||||
if self._gui_log_viewer:
|
||||
|
||||
if hasattr(self, "_gui_log_viewer") and self._gui_log_viewer is not None:
|
||||
log.info("Stopping the GUI log window ...")
|
||||
self._gui_log_viewer.stop()
|
||||
self._gui_log_viewer = None
|
||||
|
||||
+2
-3
@@ -415,11 +415,10 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
@click.argument("url", type=str)
|
||||
@click.option("--width", type=int, default=1400, show_default=True, help="Window width.")
|
||||
@click.option("--height", type=int, default=900, show_default=True, help="Window height.")
|
||||
@click.option("--minimized", is_flag=True, default=False, help="Whether to start minimized/in tray.")
|
||||
def dashboard_viewer(url: str, width: int, height: int, minimized: bool) -> None:
|
||||
def dashboard_viewer(url: str, width: int, height: int) -> None:
|
||||
from serena.dashboard import SerenaDashboardViewer
|
||||
|
||||
viewer = SerenaDashboardViewer(url, start_minimized=minimized, width=width, height=height)
|
||||
viewer = SerenaDashboardViewer(url, width=width, height=height)
|
||||
viewer.run()
|
||||
|
||||
|
||||
|
||||
+331
-115
@@ -1,8 +1,13 @@
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
|
||||
@@ -683,42 +688,34 @@ class SerenaDashboardAPI:
|
||||
|
||||
class SerenaDashboardViewer:
|
||||
"""
|
||||
Minimal pywebview wrapper with optional system tray.
|
||||
Minimal pywebview wrapper that opens a dashboard in a native window.
|
||||
|
||||
The viewer is a plain window without a system tray icon. Tray management
|
||||
is handled separately by :class:`SerenaDashboardTrayManager`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
start_minimized: bool = False,
|
||||
width: int = 1400,
|
||||
height: int = 900,
|
||||
):
|
||||
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.window: webview.Window
|
||||
self._tray_icon: Any
|
||||
self._quitting = False
|
||||
self._app_icon_path: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def is_current_platform_supported() -> bool:
|
||||
"""
|
||||
:return: whether the current platform supports the dashboard viewer
|
||||
:return: whether the current platform supports the native dashboard viewer (and tray manager).
|
||||
"""
|
||||
# The dashboard viewer (with system tray) is technically supported only on Windows and macOS.
|
||||
# Linux support is problematic; see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943
|
||||
# supported on Windows and macOS; Linux support is problematic
|
||||
# (see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943)
|
||||
supported_platforms = [
|
||||
"win32",
|
||||
# NOTE: Disabling macOS support for now, because the tray behaviour is suboptimal (too many icons when
|
||||
# subagents are spawned, etc.)
|
||||
# "darwin"
|
||||
"darwin",
|
||||
]
|
||||
return sys.platform in supported_platforms
|
||||
|
||||
@@ -733,145 +730,364 @@ class SerenaDashboardViewer:
|
||||
# .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,
|
||||
)
|
||||
assert window is not None
|
||||
self.window = window
|
||||
|
||||
if self.tray:
|
||||
self.window.events.closing += self._on_closing
|
||||
self._start_tray()
|
||||
webview.start(icon=icon_path)
|
||||
|
||||
def _start_callback() -> None:
|
||||
if self.start_minimized:
|
||||
self._hide_window()
|
||||
else:
|
||||
self._show_window()
|
||||
|
||||
webview.start(_start_callback, icon=icon_path)
|
||||
@dataclass
|
||||
class TrayManagedInstance:
|
||||
"""A registered Serena dashboard instance managed by the tray manager."""
|
||||
|
||||
def _show_window(self) -> None:
|
||||
if not self.window:
|
||||
return
|
||||
port: int
|
||||
"""the port on which the dashboard API is listening"""
|
||||
|
||||
if sys.platform == "darwin":
|
||||
from PyObjCTools.AppHelper import callAfter
|
||||
dashboard_url: str
|
||||
"""the full URL to the dashboard frontend"""
|
||||
|
||||
callAfter(self._show_window_on_macos)
|
||||
else:
|
||||
self.window.show()
|
||||
self.window.restore()
|
||||
project: str | None
|
||||
"""the name of the active project, or None if no project is activated"""
|
||||
|
||||
def _hide_window(self) -> None:
|
||||
if not self.window:
|
||||
return
|
||||
started_at: str
|
||||
"""ISO 8601 timestamp of when the agent instance was started"""
|
||||
|
||||
if sys.platform == "darwin":
|
||||
from PyObjCTools.AppHelper import callAfter
|
||||
|
||||
callAfter(self._hide_window_on_macos)
|
||||
else:
|
||||
self.window.hide()
|
||||
class SerenaDashboardTrayManager:
|
||||
"""
|
||||
Singleton process managing a system tray icon for all Serena dashboard instances.
|
||||
|
||||
def _show_window_on_macos(self) -> None:
|
||||
from AppKit import (
|
||||
NSApplication,
|
||||
NSApplicationActivationPolicyRegular,
|
||||
)
|
||||
from PyObjCTools.AppHelper import callLater
|
||||
Runs a Flask backend on a fixed port and displays a single tray icon that
|
||||
aggregates all running Serena instances. Individual dashboard viewers are
|
||||
spawned on demand when the user clicks a menu item.
|
||||
|
||||
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)
|
||||
The manager is started as a detached process by the first Serena agent that
|
||||
needs it and terminates automatically when no dashboard instances remain.
|
||||
"""
|
||||
|
||||
def _restore_window_on_macos(self) -> None:
|
||||
self.window.show()
|
||||
self.window.restore()
|
||||
PORT = 0x5ED8
|
||||
"""fixed port for the tray manager (dashboard base port 0x5EDA minus 2)"""
|
||||
|
||||
def _hide_window_on_macos(self) -> None:
|
||||
from AppKit import (
|
||||
NSApplication,
|
||||
NSApplicationActivationPolicyAccessory,
|
||||
)
|
||||
HOST = "127.0.0.1"
|
||||
"""listen address (local only)"""
|
||||
|
||||
self.window.hide()
|
||||
NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
||||
ALIVE_CHECK_INTERVAL_SECONDS = 15
|
||||
"""interval in seconds between alive checks of registered instances"""
|
||||
|
||||
def _set_macos_app_icon(self, ns_app: Any) -> None:
|
||||
if not self._app_icon_path:
|
||||
return
|
||||
def __init__(self) -> None:
|
||||
self._instances: dict[int, TrayManagedInstance] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._tray_icon: Any = None
|
||||
self._app = Flask(__name__)
|
||||
self._setup_routes()
|
||||
|
||||
from AppKit import NSImage
|
||||
def _setup_routes(self) -> None:
|
||||
@self._app.route("/health", methods=["GET"])
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "alive"}
|
||||
|
||||
ns_image = NSImage.alloc().initByReferencingFile_(self._app_icon_path)
|
||||
if ns_image is not None:
|
||||
ns_app.setApplicationIconImage_(ns_image)
|
||||
@self._app.route("/register", methods=["POST"])
|
||||
def register() -> dict[str, str]:
|
||||
data = request.get_json()
|
||||
instance = TrayManagedInstance(
|
||||
port=data["port"],
|
||||
dashboard_url=data["dashboard_url"],
|
||||
project=data.get("project"),
|
||||
started_at=data["started_at"],
|
||||
)
|
||||
with self._lock:
|
||||
self._instances[instance.port] = instance
|
||||
log.info("Registered instance on port %d (project=%s)", instance.port, instance.project)
|
||||
|
||||
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
|
||||
# open a viewer immediately if requested
|
||||
if data.get("open_viewer", False):
|
||||
self._open_viewer(instance)
|
||||
|
||||
def _start_tray(self) -> None:
|
||||
# import pystray locally, because the import fails when there is no display!
|
||||
import pystray
|
||||
return {"status": "registered"}
|
||||
|
||||
@self._app.route("/update_project", methods=["POST"])
|
||||
def update_project() -> dict[str, str]:
|
||||
data = request.get_json()
|
||||
port = data["port"]
|
||||
project = data.get("project")
|
||||
with self._lock:
|
||||
if port in self._instances:
|
||||
self._instances[port].project = project
|
||||
log.info("Updated project for instance on port %d to '%s'", port, project)
|
||||
return {"status": "updated"}
|
||||
|
||||
@self._app.route("/unregister", methods=["POST"])
|
||||
def unregister() -> dict[str, str]:
|
||||
data = request.get_json()
|
||||
port = data["port"]
|
||||
with self._lock:
|
||||
self._instances.pop(port, None)
|
||||
log.info("Unregistered instance on port %d", port)
|
||||
return {"status": "unregistered"}
|
||||
|
||||
def _build_menu_items(self) -> tuple[Any, ...]:
|
||||
"""
|
||||
Callable that returns the current tray menu items.
|
||||
|
||||
Invoked dynamically by pystray each time the menu is shown.
|
||||
When there is exactly one instance, it is marked as the default action
|
||||
so that a left-click on the tray icon opens the viewer immediately.
|
||||
When there are multiple instances, a hidden default item forces the menu
|
||||
to appear on left-click (Windows only; macOS always shows the menu).
|
||||
"""
|
||||
from pystray import MenuItem as Item
|
||||
from pystray._base import Icon as TrayIcon
|
||||
|
||||
with self._lock:
|
||||
instances = list(self._instances.values())
|
||||
|
||||
if not instances:
|
||||
return (Item("No instances", None, enabled=False),)
|
||||
|
||||
# determine whether a single-instance shortcut applies
|
||||
is_single = len(instances) == 1
|
||||
|
||||
items: list[Any] = []
|
||||
|
||||
# for multi-instance: add a hidden default item that opens the menu on left-click
|
||||
if not is_single:
|
||||
|
||||
def _force_show_menu(icon: Any, _item: Any) -> None:
|
||||
if hasattr(icon, "_show_menu"):
|
||||
icon._show_menu()
|
||||
|
||||
items.append(Item("Instances", _force_show_menu, default=True, visible=False))
|
||||
|
||||
for inst in sorted(instances, key=lambda i: i.started_at):
|
||||
label = f"{inst.project or 'SerenaAgent'} ({inst.started_at})"
|
||||
|
||||
# closure to capture the current instance
|
||||
def _make_callback(instance: TrayManagedInstance) -> Callable:
|
||||
def _callback(_icon: Any, _item: Any) -> None:
|
||||
self._open_viewer(instance)
|
||||
|
||||
return _callback
|
||||
|
||||
items.append(Item(label, _make_callback(inst), default=is_single))
|
||||
|
||||
return tuple(items)
|
||||
|
||||
def _open_viewer(self, instance: TrayManagedInstance) -> None:
|
||||
"""Spawn a dashboard viewer window for the given instance in a new subprocess."""
|
||||
url = instance.dashboard_url
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
kwargs["creationflags"] = CREATE_NO_WINDOW
|
||||
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", f"from serena.dashboard import SerenaDashboardViewer; SerenaDashboardViewer({url!r}).run()"],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _alive_check_loop(self) -> None:
|
||||
"""Periodically check whether registered instances are still reachable.
|
||||
|
||||
Removes unreachable instances and terminates the manager when none remain.
|
||||
"""
|
||||
while True:
|
||||
time.sleep(self.ALIVE_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
# collect ports to check
|
||||
with self._lock:
|
||||
ports_to_check = list(self._instances.keys())
|
||||
|
||||
# probe each instance
|
||||
dead_ports: list[int] = []
|
||||
for port in ports_to_check:
|
||||
try:
|
||||
url = f"http://127.0.0.1:{port}/heartbeat"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
urllib.request.urlopen(req, timeout=3)
|
||||
except Exception:
|
||||
dead_ports.append(port)
|
||||
|
||||
# remove dead instances
|
||||
if dead_ports:
|
||||
with self._lock:
|
||||
for port in dead_ports:
|
||||
self._instances.pop(port, None)
|
||||
log.info("Removed unreachable instance on port %d", port)
|
||||
|
||||
# terminate if no instances remain
|
||||
with self._lock:
|
||||
remaining = len(self._instances)
|
||||
if remaining == 0:
|
||||
log.info("No dashboard instances remaining; shutting down tray manager")
|
||||
if self._tray_icon is not None:
|
||||
self._tray_icon.stop()
|
||||
return
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the tray manager (blocking). Starts Flask, alive-check thread, and tray icon."""
|
||||
import pystray
|
||||
|
||||
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.
|
||||
# select the appropriate icon for the platform
|
||||
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._quitting = True
|
||||
try:
|
||||
_icon.stop()
|
||||
finally:
|
||||
if self.window:
|
||||
self.window.destroy()
|
||||
|
||||
menu = pystray.Menu(
|
||||
Item("Open", show, default=True),
|
||||
Item("Hide", hide),
|
||||
Item("Quit", quit_app),
|
||||
# start Flask in a background thread
|
||||
flask_thread = threading.Thread(
|
||||
target=lambda: self._app.run(host=self.HOST, port=self.PORT, debug=False, use_reloader=False, threaded=True),
|
||||
daemon=True,
|
||||
)
|
||||
flask_thread.start()
|
||||
|
||||
# start alive-check in a background thread
|
||||
alive_thread = threading.Thread(target=self._alive_check_loop, daemon=True)
|
||||
alive_thread.start()
|
||||
|
||||
# set up tray icon with a dynamic menu (callable returns items on each open)
|
||||
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)
|
||||
self._tray_icon = pystray.Icon(
|
||||
"serena_tray_manager",
|
||||
icon_img,
|
||||
"Serena",
|
||||
menu=pystray.Menu(self._build_menu_items),
|
||||
**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()
|
||||
# blocks until stop() is called
|
||||
self._tray_icon.run()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Class-level helpers (used by agents to interact with the manager)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def is_current_platform_supported(cls) -> bool:
|
||||
"""
|
||||
:return: whether the current platform supports the tray manager
|
||||
"""
|
||||
supported_platforms = ["win32", "darwin"]
|
||||
return sys.platform in supported_platforms
|
||||
|
||||
@classmethod
|
||||
def is_running(cls) -> bool:
|
||||
"""
|
||||
:return: True if a tray manager process is already listening on the fixed port
|
||||
"""
|
||||
try:
|
||||
url = f"http://{cls.HOST}:{cls.PORT}/health"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def ensure_running(cls) -> None:
|
||||
"""Ensure a tray manager process is running, starting one if necessary."""
|
||||
log.info("Ensuring dashboard tray manager availability")
|
||||
if cls.is_running():
|
||||
log.info("Dashboard tray manager is already running")
|
||||
return
|
||||
|
||||
# spawn a detached process
|
||||
log.info("Starting new dashboard tray manager process")
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from serena.dashboard import SerenaDashboardTrayManager; SerenaDashboardTrayManager().run()",
|
||||
]
|
||||
kwargs: dict[str, Any] = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
# CREATE_NO_WINDOW suppresses the console; CREATE_NEW_PROCESS_GROUP
|
||||
# isolates the child from the parent's Ctrl+C group.
|
||||
# Note: DETACHED_PROCESS must NOT be combined with CREATE_NO_WINDOW.
|
||||
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
|
||||
subprocess.Popen(cmd, **kwargs)
|
||||
|
||||
# wait for the manager to become available
|
||||
for _ in range(30):
|
||||
time.sleep(0.1)
|
||||
if cls.is_running():
|
||||
log.info("Dashboard tray manager started successfully")
|
||||
return
|
||||
log.warning("Dashboard tray manager did not start within the expected time")
|
||||
|
||||
@classmethod
|
||||
def register_instance(cls, port: int, dashboard_url: str, project: str | None, started_at: str, open_viewer: bool = False) -> None:
|
||||
"""Register a dashboard instance with the running tray manager.
|
||||
|
||||
:param port: the port of the dashboard API (used for alive checks)
|
||||
:param dashboard_url: the full URL to the dashboard frontend
|
||||
:param project: the currently active project name, or None
|
||||
:param started_at: ISO 8601 timestamp of when the agent was started
|
||||
:param open_viewer: whether the tray manager should immediately open a viewer for this instance
|
||||
"""
|
||||
url = f"http://{cls.HOST}:{cls.PORT}/register"
|
||||
data = json.dumps(
|
||||
{
|
||||
"port": port,
|
||||
"dashboard_url": dashboard_url,
|
||||
"project": project,
|
||||
"started_at": started_at,
|
||||
"open_viewer": open_viewer,
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except Exception as e:
|
||||
log.warning("Failed to register with tray manager: %s", e)
|
||||
|
||||
@classmethod
|
||||
def update_project(cls, port: int, project: str | None) -> None:
|
||||
"""Notify the tray manager of a project change for the given instance.
|
||||
|
||||
:param port: the port of the dashboard API
|
||||
:param project: the new active project name, or None
|
||||
"""
|
||||
url = f"http://{cls.HOST}:{cls.PORT}/update_project"
|
||||
data = json.dumps({"port": port, "project": project}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except Exception as e:
|
||||
log.warning("Failed to update project with tray manager: %s", e)
|
||||
|
||||
@classmethod
|
||||
def unregister_instance(cls, port: int) -> None:
|
||||
"""Unregister a dashboard instance from the tray manager.
|
||||
|
||||
:param port: the port of the dashboard API to unregister
|
||||
"""
|
||||
url = f"http://{cls.HOST}:{cls.PORT}/unregister"
|
||||
data = json.dumps({"port": port}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except Exception as e:
|
||||
log.warning("Failed to unregister from tray manager: %s", e)
|
||||
Reference in new issue
Block a user