Inspired by BugSETI architecture — system tray with WebView2 windows, Docker Compose stack (Forgejo + InfluxDB + inference proxy), and scoring agent integration. Builds as signed native binary on macOS. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
277 lines
7 KiB
Go
277 lines
7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os/exec"
|
|
"runtime"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
)
|
|
|
|
// TrayService provides system tray bindings for the LEM desktop.
|
|
// Exposes status to the frontend and controls the tray menu.
|
|
type TrayService struct {
|
|
app *application.App
|
|
dashboard *DashboardService
|
|
docker *DockerService
|
|
agent *AgentRunner
|
|
}
|
|
|
|
// NewTrayService creates a new TrayService.
|
|
func NewTrayService(app *application.App) *TrayService {
|
|
return &TrayService{app: app}
|
|
}
|
|
|
|
// SetServices wires up service references after app creation.
|
|
func (t *TrayService) SetServices(dashboard *DashboardService, docker *DockerService, agent *AgentRunner) {
|
|
t.dashboard = dashboard
|
|
t.docker = docker
|
|
t.agent = agent
|
|
}
|
|
|
|
// ServiceName returns the Wails service name.
|
|
func (t *TrayService) ServiceName() string {
|
|
return "TrayService"
|
|
}
|
|
|
|
// ServiceStartup is called when the Wails app starts.
|
|
func (t *TrayService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
|
log.Println("TrayService started")
|
|
return nil
|
|
}
|
|
|
|
// ServiceShutdown is called on app exit.
|
|
func (t *TrayService) ServiceShutdown() error {
|
|
log.Println("TrayService shutdown")
|
|
return nil
|
|
}
|
|
|
|
// TraySnapshot is the complete tray state for the frontend.
|
|
type TraySnapshot struct {
|
|
StackRunning bool `json:"stackRunning"`
|
|
AgentRunning bool `json:"agentRunning"`
|
|
AgentTask string `json:"agentTask"`
|
|
Training []TrainingRow `json:"training"`
|
|
Generation GenerationStats `json:"generation"`
|
|
Models []ModelInfo `json:"models"`
|
|
DockerServices int `json:"dockerServices"`
|
|
}
|
|
|
|
// GetSnapshot returns the full tray state.
|
|
func (t *TrayService) GetSnapshot() TraySnapshot {
|
|
snap := TraySnapshot{}
|
|
|
|
if t.dashboard != nil {
|
|
ds := t.dashboard.GetSnapshot()
|
|
snap.Training = ds.Training
|
|
snap.Generation = ds.Generation
|
|
snap.Models = ds.Models
|
|
}
|
|
|
|
if t.docker != nil {
|
|
status := t.docker.GetStatus()
|
|
snap.StackRunning = status.Running
|
|
snap.DockerServices = len(status.Services)
|
|
}
|
|
|
|
if t.agent != nil {
|
|
snap.AgentRunning = t.agent.IsRunning()
|
|
snap.AgentTask = t.agent.CurrentTask()
|
|
}
|
|
|
|
return snap
|
|
}
|
|
|
|
// StartStack starts the Docker compose stack.
|
|
func (t *TrayService) StartStack() error {
|
|
if t.docker == nil {
|
|
return fmt.Errorf("docker service not available")
|
|
}
|
|
return t.docker.Start()
|
|
}
|
|
|
|
// StopStack stops the Docker compose stack.
|
|
func (t *TrayService) StopStack() error {
|
|
if t.docker == nil {
|
|
return fmt.Errorf("docker service not available")
|
|
}
|
|
return t.docker.Stop()
|
|
}
|
|
|
|
// StartAgent starts the scoring agent.
|
|
func (t *TrayService) StartAgent() error {
|
|
if t.agent == nil {
|
|
return fmt.Errorf("agent service not available")
|
|
}
|
|
return t.agent.Start()
|
|
}
|
|
|
|
// StopAgent stops the scoring agent.
|
|
func (t *TrayService) StopAgent() {
|
|
if t.agent != nil {
|
|
t.agent.Stop()
|
|
}
|
|
}
|
|
|
|
// setupSystemTray configures the system tray icon and menu.
|
|
func setupSystemTray(app *application.App, tray *TrayService, dashboard *DashboardService, docker *DockerService) {
|
|
systray := app.SystemTray.New()
|
|
systray.SetTooltip("LEM — Lethean Ethics Model")
|
|
|
|
// Platform-specific icon.
|
|
if runtime.GOOS == "darwin" {
|
|
systray.SetTemplateIcon(trayIconTemplate)
|
|
} else {
|
|
systray.SetDarkModeIcon(trayIconDark)
|
|
systray.SetIcon(trayIconLight)
|
|
}
|
|
|
|
// ── Tray Panel (frameless dropdown) ──
|
|
trayWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
|
Name: "tray-panel",
|
|
Title: "LEM",
|
|
Width: 420,
|
|
Height: 520,
|
|
URL: "/tray",
|
|
Hidden: true,
|
|
Frameless: true,
|
|
BackgroundColour: application.NewRGB(15, 23, 42),
|
|
})
|
|
systray.AttachWindow(trayWindow).WindowOffset(5)
|
|
|
|
// ── Dashboard Window ──
|
|
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
|
Name: "dashboard",
|
|
Title: "LEM Dashboard",
|
|
Width: 1400,
|
|
Height: 900,
|
|
URL: "/dashboard",
|
|
Hidden: true,
|
|
BackgroundColour: application.NewRGB(15, 23, 42),
|
|
})
|
|
|
|
// ── Workbench Window (model scoring, probes) ──
|
|
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
|
Name: "workbench",
|
|
Title: "LEM Workbench",
|
|
Width: 1200,
|
|
Height: 800,
|
|
URL: "/workbench",
|
|
Hidden: true,
|
|
BackgroundColour: application.NewRGB(15, 23, 42),
|
|
})
|
|
|
|
// ── Settings Window ──
|
|
app.Window.NewWithOptions(application.WebviewWindowOptions{
|
|
Name: "settings",
|
|
Title: "LEM Settings",
|
|
Width: 600,
|
|
Height: 500,
|
|
URL: "/settings",
|
|
Hidden: true,
|
|
BackgroundColour: application.NewRGB(15, 23, 42),
|
|
})
|
|
|
|
// ── Build Tray Menu ──
|
|
trayMenu := app.Menu.New()
|
|
|
|
// Status (dynamic).
|
|
statusItem := trayMenu.Add("LEM: Idle")
|
|
statusItem.SetEnabled(false)
|
|
|
|
trayMenu.AddSeparator()
|
|
|
|
// Stack control.
|
|
stackItem := trayMenu.Add("Start Services")
|
|
stackItem.OnClick(func(ctx *application.Context) {
|
|
if docker.IsRunning() {
|
|
docker.Stop()
|
|
stackItem.SetLabel("Start Services")
|
|
statusItem.SetLabel("LEM: Stopped")
|
|
} else {
|
|
docker.Start()
|
|
stackItem.SetLabel("Stop Services")
|
|
statusItem.SetLabel("LEM: Running")
|
|
}
|
|
})
|
|
|
|
// Agent control.
|
|
agentItem := trayMenu.Add("Start Scoring Agent")
|
|
agentItem.OnClick(func(ctx *application.Context) {
|
|
if tray.agent != nil && tray.agent.IsRunning() {
|
|
tray.agent.Stop()
|
|
agentItem.SetLabel("Start Scoring Agent")
|
|
} else if tray.agent != nil {
|
|
tray.agent.Start()
|
|
agentItem.SetLabel("Stop Scoring Agent")
|
|
}
|
|
})
|
|
|
|
trayMenu.AddSeparator()
|
|
|
|
// Windows.
|
|
trayMenu.Add("Open Dashboard").OnClick(func(ctx *application.Context) {
|
|
if w, ok := app.Window.Get("dashboard"); ok {
|
|
w.Show()
|
|
w.Focus()
|
|
}
|
|
})
|
|
|
|
trayMenu.Add("Open Workbench").OnClick(func(ctx *application.Context) {
|
|
if w, ok := app.Window.Get("workbench"); ok {
|
|
w.Show()
|
|
w.Focus()
|
|
}
|
|
})
|
|
|
|
trayMenu.Add("Open Forge").OnClick(func(ctx *application.Context) {
|
|
// Open the local Forgejo in the default browser.
|
|
openBrowser("http://localhost:3000")
|
|
})
|
|
|
|
trayMenu.AddSeparator()
|
|
|
|
// Stats submenu.
|
|
statsMenu := trayMenu.AddSubmenu("Training")
|
|
statsMenu.Add("Golden Set: loading...").SetEnabled(false)
|
|
statsMenu.Add("Expansion: loading...").SetEnabled(false)
|
|
statsMenu.Add("Models Scored: loading...").SetEnabled(false)
|
|
|
|
trayMenu.AddSeparator()
|
|
|
|
// Settings.
|
|
trayMenu.Add("Settings...").OnClick(func(ctx *application.Context) {
|
|
if w, ok := app.Window.Get("settings"); ok {
|
|
w.Show()
|
|
w.Focus()
|
|
}
|
|
})
|
|
|
|
trayMenu.AddSeparator()
|
|
|
|
// Quit.
|
|
trayMenu.Add("Quit LEM").OnClick(func(ctx *application.Context) {
|
|
app.Quit()
|
|
})
|
|
|
|
systray.SetMenu(trayMenu)
|
|
}
|
|
|
|
// openBrowser launches the default browser.
|
|
func openBrowser(url string) {
|
|
var cmd string
|
|
var args []string
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
cmd = "open"
|
|
case "linux":
|
|
cmd = "xdg-open"
|
|
case "windows":
|
|
cmd = "rundll32"
|
|
args = []string{"url.dll,FileProtocolHandler"}
|
|
}
|
|
args = append(args, url)
|
|
go exec.Command(cmd, args...).Start()
|
|
}
|