import { Tray, type BrowserWindow } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; import type { WorkspaceSyncInfo } from "../shared/rpc"; import type { TransferEngine } from "./transfers/engine"; const BASE_IMAGE = "views://assets/tray/tray-44-template.png"; const PROGRESS_IMAGE = (pct: number) => `views://assets/tray/progress/p${pct}.png`; const REFRESH_THROTTLE_MS = 500; /** * Menu-bar icon: the app glyph when idle; while transfers run the icon swaps * to pre-rendered frames (faded arrows + fill bar = queue doneBytes/totalBytes). * The menu lists the tabs (synced from the webview) with per-tab job info. */ export class AppTray { private tray: Tray; private tabs: WorkspaceSyncInfo = { workspaces: [], activeId: 0 }; private lastRefresh = 0; private refreshTimer: ReturnType | null = null; private currentImage = BASE_IMAGE; /** Forwards tray menu actions the UI must handle (activate-tab:). */ onAction: (action: string) => void = () => {}; constructor( private win: BrowserWindow, private engine: TransferEngine, ) { this.tray = new Tray({ title: "", image: BASE_IMAGE, template: true, width: 22, height: 22, }); this.tray.on("tray-clicked", (event) => { const { action } = (event as { data: { action: string } }).data; if (!action) return; // icon click with a menu set -> macOS opens the menu if (action === "show-app") { this.win.activate(); } else if (action === "quit") { process.exit(0); // electrobun's clean-quit override } else if (action.startsWith("activate-tab:")) { this.win.activate(); this.onAction(action); } }); this.rebuildMenu(); } updateTabs(sync: WorkspaceSyncInfo): void { this.tabs = sync; this.scheduleRefresh(); } /** Called on every engine update; throttled internally. */ updateProgress(): void { this.scheduleRefresh(); } private scheduleRefresh(): void { const now = Date.now(); const wait = Math.max(0, REFRESH_THROTTLE_MS - (now - this.lastRefresh)); if (this.refreshTimer) return; this.refreshTimer = setTimeout(() => { this.refreshTimer = null; this.lastRefresh = Date.now(); this.refresh(); }, wait); } private refresh(): void { const progress = this.engine.aggregateProgress(); // icon: nearest 5% frame while transferring, base glyph when idle let image = BASE_IMAGE; if (progress.activeCount > 0 && progress.totalBytes > 0) { const pct = Math.round(((progress.doneBytes / progress.totalBytes) * 100) / 5) * 5; image = PROGRESS_IMAGE(Math.max(0, Math.min(100, pct))); } if (image !== this.currentImage) { this.currentImage = image; this.tray.setImage(image); } this.rebuildMenu(progress); } private rebuildMenu(progress = this.engine.aggregateProgress()): void { const items: Parameters[0] = []; for (const ws of this.tabs.workspaces) { const jobs = ws.sessionIds .map((id) => progress.bySession[id]) .filter((v): v is NonNullable => !!v); const count = jobs.reduce((n, j) => n + j.count, 0); const total = jobs.reduce((n, j) => n + j.total, 0); const done = jobs.reduce((n, j) => n + j.done, 0); const suffix = count ? ` — ${count} transfer${count === 1 ? "" : "s"} · ${total ? Math.round((done / total) * 100) : 0}%` : ""; items.push({ type: "normal", label: `${ws.name}${suffix}`, action: `activate-tab:${ws.id}`, checked: ws.id === this.tabs.activeId, }); } if (progress.activeCount > 0) { items.push({ type: "divider" }); const pct = progress.totalBytes ? Math.round((progress.doneBytes / progress.totalBytes) * 100) : 0; items.push({ type: "normal", label: `${progress.activeCount} active transfer${progress.activeCount === 1 ? "" : "s"} · ${pct}%`, action: "noop", enabled: false, }); } items.push({ type: "divider" }); items.push({ type: "normal", label: `Show ${BRANDING.appName}`, action: "show-app" }); items.push({ type: "normal", label: "Quit", action: "quit" }); this.tray.setMenu(items); } }