Compare commits
3 Commits
fix/cloud-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2457519b1f | ||
|
|
417f55029a | ||
|
|
129395e73b |
BIN
apps/desktop/resources/tray/tray-error-Template.png
Normal file
|
After Width: | Height: | Size: 120 B |
BIN
apps/desktop/resources/tray/tray-error-Template@2x.png
Normal file
|
After Width: | Height: | Size: 154 B |
BIN
apps/desktop/resources/tray/tray-error.png
Normal file
|
After Width: | Height: | Size: 130 B |
BIN
apps/desktop/resources/tray/tray-error@2x.png
Normal file
|
After Width: | Height: | Size: 173 B |
BIN
apps/desktop/resources/tray/tray-running-Template.png
Normal file
|
After Width: | Height: | Size: 90 B |
BIN
apps/desktop/resources/tray/tray-running-Template@2x.png
Normal file
|
After Width: | Height: | Size: 111 B |
BIN
apps/desktop/resources/tray/tray-running.png
Normal file
|
After Width: | Height: | Size: 99 B |
BIN
apps/desktop/resources/tray/tray-running@2x.png
Normal file
|
After Width: | Height: | Size: 118 B |
BIN
apps/desktop/resources/tray/tray-starting-Template.png
Normal file
|
After Width: | Height: | Size: 111 B |
BIN
apps/desktop/resources/tray/tray-starting-Template@2x.png
Normal file
|
After Width: | Height: | Size: 152 B |
BIN
apps/desktop/resources/tray/tray-starting.png
Normal file
|
After Width: | Height: | Size: 120 B |
BIN
apps/desktop/resources/tray/tray-starting@2x.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
apps/desktop/resources/tray/tray-stopped-Template.png
Normal file
|
After Width: | Height: | Size: 96 B |
BIN
apps/desktop/resources/tray/tray-stopped-Template@2x.png
Normal file
|
After Width: | Height: | Size: 122 B |
BIN
apps/desktop/resources/tray/tray-stopped.png
Normal file
|
After Width: | Height: | Size: 105 B |
BIN
apps/desktop/resources/tray/tray-stopped@2x.png
Normal file
|
After Width: | Height: | Size: 131 B |
@@ -1,5 +1,6 @@
|
||||
import { app, ipcMain, BrowserWindow, shell } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
readFile,
|
||||
writeFile,
|
||||
@@ -126,9 +127,64 @@ function urlsMatch(a: string, b: string): boolean {
|
||||
return na.length > 0 && na === nb;
|
||||
}
|
||||
|
||||
// In-process fan-out so other main-process modules (e.g. tray-manager) can
|
||||
// observe daemon status changes without round-tripping through IPC. The
|
||||
// renderer still receives `daemon:status` events via webContents below.
|
||||
const daemonEvents = new EventEmitter();
|
||||
let lastStatus: DaemonStatus = { state: "installing_cli" };
|
||||
|
||||
function sendStatus(status: DaemonStatus): void {
|
||||
lastStatus = status;
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("daemon:status", status);
|
||||
daemonEvents.emit("status", status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to daemon status changes from within the main process. The
|
||||
* listener is invoked once synchronously with the current status (replay),
|
||||
* then on every subsequent status change. Returns an unsubscribe function.
|
||||
*/
|
||||
export function subscribeDaemonStatus(
|
||||
listener: (status: DaemonStatus) => void,
|
||||
): () => void {
|
||||
listener(lastStatus);
|
||||
daemonEvents.on("status", listener);
|
||||
return () => {
|
||||
daemonEvents.off("status", listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDaemonPrefs(): Promise<DaemonPrefs> {
|
||||
return loadPrefs();
|
||||
}
|
||||
|
||||
export async function setDaemonPrefs(
|
||||
partial: Partial<DaemonPrefs>,
|
||||
): Promise<DaemonPrefs> {
|
||||
const cur = await loadPrefs();
|
||||
const merged = { ...cur, ...partial };
|
||||
await savePrefs(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export const daemonOps = {
|
||||
start: () => withGuard(() => startDaemon()),
|
||||
stop: () => withGuard(() => stopDaemon()),
|
||||
restart: () => withGuard(() => restartDaemon()),
|
||||
};
|
||||
|
||||
export async function openDaemonLogFile(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const active = await ensureActiveProfile();
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
return { success: false, error: "Log file not found yet" };
|
||||
}
|
||||
const error = await shell.openPath(logPath);
|
||||
return error === "" ? { success: true } : { success: false, error };
|
||||
}
|
||||
|
||||
interface HealthPayload {
|
||||
@@ -917,16 +973,7 @@ export function setupDaemonManager(
|
||||
// Reveal the daemon's log file in the user's default editor / Console
|
||||
// app. Acts as the escape hatch when the in-app log viewer isn't enough
|
||||
// (full history, complex search, copy-to-clipboard at scale).
|
||||
ipcMain.handle("daemon:open-log-file", async () => {
|
||||
const active = await ensureActiveProfile();
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
return { success: false, error: "Log file not found yet" };
|
||||
}
|
||||
// shell.openPath returns "" on success, error string on failure.
|
||||
const error = await shell.openPath(logPath);
|
||||
return error === "" ? { success: true } : { success: false, error };
|
||||
});
|
||||
ipcMain.handle("daemon:open-log-file", () => openDaemonLogFile());
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
|
||||
@@ -5,6 +5,7 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { setupTray } from "./tray-manager";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
@@ -173,6 +174,14 @@ function createWindow(): void {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Without this, closing the window on macOS (where the app stays alive)
|
||||
// leaves a destroyed BrowserWindow in the `mainWindow` slot. Tray clicks
|
||||
// and deep-link dispatch would then call methods on a dead reference
|
||||
// instead of recreating the window via showOrCreateMainWindow().
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Detect OS language changes while the app is running. Electron has no
|
||||
// dedicated event for this on any platform, so we poll on focus regain —
|
||||
// catches the common case where users switch System Settings → Language
|
||||
@@ -209,6 +218,20 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Single path for "bring the main window forward" used by the tray, the
|
||||
// macOS `activate` event, and any future deep-link / notification handlers.
|
||||
// If the window was closed (mainWindow === null after the `closed` event)
|
||||
// or somehow destroyed, recreate it; otherwise restore + show + focus.
|
||||
function showOrCreateMainWindow(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
createWindow();
|
||||
return;
|
||||
}
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
// --- Dev / production isolation -------------------------------------------
|
||||
// Give dev mode a separate app name and userData path so it gets its own
|
||||
// single-instance lock file and doesn't conflict with the packaged production
|
||||
@@ -407,6 +430,10 @@ if (!gotTheLock) {
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
setupDaemonManager(() => mainWindow);
|
||||
setupTray({
|
||||
getWindow: () => mainWindow,
|
||||
showOrCreateWindow: showOrCreateMainWindow,
|
||||
});
|
||||
|
||||
// macOS: deep link arrives via open-url event
|
||||
app.on("open-url", (_event, url) => {
|
||||
|
||||
366
apps/desktop/src/main/tray-manager.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { DaemonStatus } from "../shared/daemon-types";
|
||||
|
||||
// `vi.mock(...)` factories are hoisted above all `import` and `const`
|
||||
// declarations in the file, so anything they reference must be hoisted with
|
||||
// them. `vi.hoisted` is the supported escape hatch.
|
||||
const h = vi.hoisted(() => {
|
||||
class MockTray {
|
||||
setImageMock = (globalThis as unknown as { vi: typeof vi }).vi.fn();
|
||||
setToolTipMock = (globalThis as unknown as { vi: typeof vi }).vi.fn();
|
||||
setContextMenuMock = (globalThis as unknown as { vi: typeof vi }).vi.fn();
|
||||
destroyMock = (globalThis as unknown as { vi: typeof vi }).vi.fn();
|
||||
clickListeners: Array<() => void> = [];
|
||||
setImage = this.setImageMock;
|
||||
setToolTip = this.setToolTipMock;
|
||||
setContextMenu = this.setContextMenuMock;
|
||||
destroy = this.destroyMock;
|
||||
on(event: string, listener: () => void): this {
|
||||
if (event === "click") this.clickListeners.push(listener);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
return {
|
||||
MockTray,
|
||||
trayInstances: [] as InstanceType<typeof MockTray>[],
|
||||
subscribeListeners: [] as Array<(s: DaemonStatus) => void>,
|
||||
lastEmit: { value: { state: "stopped" } as DaemonStatus },
|
||||
appListeners: {} as Record<string, Array<(...a: unknown[]) => void>>,
|
||||
};
|
||||
});
|
||||
|
||||
// Make `vi` available on globalThis so the hoisted factory above can reach
|
||||
// the test runner's `vi.fn` without re-importing it (the factory runs before
|
||||
// the top-level imports execute).
|
||||
(globalThis as unknown as { vi: typeof vi }).vi = vi;
|
||||
|
||||
vi.mock("electron", () => {
|
||||
class TrayWrapper {
|
||||
constructor() {
|
||||
const inner = new h.MockTray();
|
||||
h.trayInstances.push(inner);
|
||||
return inner as unknown as TrayWrapper;
|
||||
}
|
||||
}
|
||||
return {
|
||||
app: {
|
||||
getAppPath: () => "/app",
|
||||
quit: vi.fn(),
|
||||
on: (event: string, listener: (...args: unknown[]) => void) => {
|
||||
(h.appListeners[event] ??= []).push(listener);
|
||||
},
|
||||
},
|
||||
BrowserWindow: class {},
|
||||
Tray: TrayWrapper,
|
||||
Menu: {
|
||||
buildFromTemplate: vi.fn((template) => ({ __template: template })),
|
||||
},
|
||||
nativeImage: {
|
||||
createFromPath: vi.fn((path: string) => ({ __path: path })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./daemon-manager", () => ({
|
||||
subscribeDaemonStatus: (listener: (s: DaemonStatus) => void) => {
|
||||
h.subscribeListeners.push(listener);
|
||||
listener(h.lastEmit.value);
|
||||
return () => {
|
||||
const i = h.subscribeListeners.indexOf(listener);
|
||||
if (i >= 0) h.subscribeListeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
daemonOps: {
|
||||
start: vi.fn().mockResolvedValue({ success: true }),
|
||||
stop: vi.fn().mockResolvedValue({ success: true }),
|
||||
restart: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
openDaemonLogFile: vi.fn().mockResolvedValue({ success: true }),
|
||||
}));
|
||||
|
||||
import {
|
||||
__resetTrayForTests,
|
||||
buildMenuTemplate,
|
||||
formatStatusLabel,
|
||||
setupTray,
|
||||
} from "./tray-manager";
|
||||
import { app, Menu, nativeImage } from "electron";
|
||||
import { daemonOps, openDaemonLogFile } from "./daemon-manager";
|
||||
|
||||
function emit(status: DaemonStatus): void {
|
||||
h.lastEmit.value = status;
|
||||
for (const l of h.subscribeListeners) l(status);
|
||||
}
|
||||
|
||||
function getLastMenu(): Electron.MenuItemConstructorOptions[] {
|
||||
const calls = (Menu.buildFromTemplate as unknown as { mock: { calls: unknown[][] } }).mock.calls;
|
||||
const last = calls[calls.length - 1]?.[0];
|
||||
return last as Electron.MenuItemConstructorOptions[];
|
||||
}
|
||||
|
||||
function lastImagePath(): string | undefined {
|
||||
const calls = (nativeImage.createFromPath as unknown as {
|
||||
mock: { calls: [string][] };
|
||||
}).mock.calls;
|
||||
return calls.at(-1)?.[0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.trayInstances.length = 0;
|
||||
h.subscribeListeners.length = 0;
|
||||
h.lastEmit.value = { state: "stopped" };
|
||||
for (const k of Object.keys(h.appListeners)) delete h.appListeners[k];
|
||||
vi.mocked(app.quit).mockClear();
|
||||
vi.mocked(daemonOps.start).mockClear();
|
||||
vi.mocked(daemonOps.stop).mockClear();
|
||||
vi.mocked(daemonOps.restart).mockClear();
|
||||
vi.mocked(openDaemonLogFile).mockClear();
|
||||
(Menu.buildFromTemplate as unknown as { mock: { calls: unknown[][] } }).mock.calls.length = 0;
|
||||
(nativeImage.createFromPath as unknown as { mock: { calls: unknown[][] } }).mock.calls.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetTrayForTests();
|
||||
});
|
||||
|
||||
describe("formatStatusLabel", () => {
|
||||
it("includes pid and agent count when running", () => {
|
||||
expect(
|
||||
formatStatusLabel({ state: "running", pid: 1234, agents: ["a", "b", "c"] }),
|
||||
).toBe("Running · pid 1234 · 3 agents");
|
||||
});
|
||||
|
||||
it("uses singular when one agent is registered", () => {
|
||||
expect(
|
||||
formatStatusLabel({ state: "running", pid: 7, agents: ["a"] }),
|
||||
).toBe("Running · pid 7 · 1 agent");
|
||||
});
|
||||
|
||||
it("omits agent count when zero", () => {
|
||||
expect(formatStatusLabel({ state: "running", pid: 1, agents: [] })).toBe(
|
||||
"Running · pid 1",
|
||||
);
|
||||
});
|
||||
|
||||
it("covers transient states", () => {
|
||||
expect(formatStatusLabel({ state: "stopped" })).toBe("Stopped");
|
||||
expect(formatStatusLabel({ state: "starting" })).toBe("Starting…");
|
||||
expect(formatStatusLabel({ state: "stopping" })).toBe("Stopping…");
|
||||
expect(formatStatusLabel({ state: "installing_cli" })).toBe("Setting up…");
|
||||
expect(formatStatusLabel({ state: "cli_not_found" })).toBe("Setup failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMenuTemplate", () => {
|
||||
const noop = () => {};
|
||||
const actions = {
|
||||
showWindow: noop,
|
||||
openLog: noop,
|
||||
start: noop,
|
||||
stop: noop,
|
||||
restart: noop,
|
||||
quit: noop,
|
||||
};
|
||||
|
||||
it("disables Start and enables Stop/Restart when running", () => {
|
||||
const t = buildMenuTemplate({ state: "running", pid: 1 }, actions);
|
||||
const byLabel = Object.fromEntries(
|
||||
t.filter((i) => i.label).map((i) => [i.label, i]),
|
||||
);
|
||||
expect(byLabel["Start Daemon"]?.enabled).toBe(false);
|
||||
expect(byLabel["Stop Daemon"]?.enabled).toBe(true);
|
||||
expect(byLabel["Restart Daemon"]?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("enables Start only when stopped or cli_not_found", () => {
|
||||
for (const state of ["stopped", "cli_not_found"] as const) {
|
||||
const t = buildMenuTemplate({ state }, actions);
|
||||
const start = t.find((i) => i.label === "Start Daemon");
|
||||
expect(start?.enabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("disables every daemon action while transitioning", () => {
|
||||
for (const state of ["starting", "stopping", "installing_cli"] as const) {
|
||||
const t = buildMenuTemplate({ state }, actions);
|
||||
const byLabel = Object.fromEntries(
|
||||
t.filter((i) => i.label).map((i) => [i.label, i]),
|
||||
);
|
||||
expect(byLabel["Start Daemon"]?.enabled).toBe(false);
|
||||
expect(byLabel["Stop Daemon"]?.enabled).toBe(false);
|
||||
expect(byLabel["Restart Daemon"]?.enabled).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("places the status label as a disabled first row", () => {
|
||||
const t = buildMenuTemplate({ state: "stopped" }, actions);
|
||||
expect(t[0]).toMatchObject({ label: "Stopped", enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupTray", () => {
|
||||
it("creates a Tray once and ignores duplicate setupTray calls", () => {
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
expect(h.trayInstances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("Show Multica menu item invokes showOrCreateWindow", () => {
|
||||
const showOrCreateWindow = vi.fn();
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow });
|
||||
const menu = getLastMenu();
|
||||
const item = menu.find((i) => i.label === "Show Multica");
|
||||
(item?.click as () => void)?.();
|
||||
expect(showOrCreateWindow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tray click on darwin recreates the window when getWindow returns null", () => {
|
||||
const orig = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
const showOrCreateWindow = vi.fn();
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow });
|
||||
const click = h.trayInstances[0]!.clickListeners[0]!;
|
||||
click();
|
||||
expect(showOrCreateWindow).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
if (orig) Object.defineProperty(process, "platform", orig);
|
||||
}
|
||||
});
|
||||
|
||||
it("tray click on darwin treats a destroyed BrowserWindow as missing", () => {
|
||||
const orig = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
const showOrCreateWindow = vi.fn();
|
||||
const destroyedWindow = {
|
||||
isDestroyed: () => true,
|
||||
isVisible: () => true,
|
||||
isMinimized: () => false,
|
||||
hide: vi.fn(),
|
||||
show: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
setupTray({ getWindow: () => destroyedWindow, showOrCreateWindow });
|
||||
const click = h.trayInstances[0]!.clickListeners[0]!;
|
||||
click();
|
||||
expect(showOrCreateWindow).toHaveBeenCalledTimes(1);
|
||||
expect(destroyedWindow.hide).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (orig) Object.defineProperty(process, "platform", orig);
|
||||
}
|
||||
});
|
||||
|
||||
it("tray click on darwin always shows + focuses a visible window, never hides it", () => {
|
||||
const orig = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
const showOrCreateWindow = vi.fn();
|
||||
const visibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
isMinimized: () => false,
|
||||
hide: vi.fn(),
|
||||
show: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
} as unknown as Electron.BrowserWindow;
|
||||
setupTray({ getWindow: () => visibleWindow, showOrCreateWindow });
|
||||
const click = h.trayInstances[0]!.clickListeners[0]!;
|
||||
click();
|
||||
click();
|
||||
click();
|
||||
expect(showOrCreateWindow).toHaveBeenCalledTimes(3);
|
||||
expect(visibleWindow.hide).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (orig) Object.defineProperty(process, "platform", orig);
|
||||
}
|
||||
});
|
||||
|
||||
it("replays current status on subscribe and renders an initial menu", () => {
|
||||
h.lastEmit.value = { state: "running", pid: 99, agents: ["a"] };
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
const menu = getLastMenu();
|
||||
expect(menu[0]).toMatchObject({
|
||||
label: "Running · pid 99 · 1 agent",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("swaps the tray image and rebuilds the menu on status change", () => {
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
const tray = h.trayInstances[0]!;
|
||||
tray.setImageMock.mockClear();
|
||||
(Menu.buildFromTemplate as unknown as { mock: { calls: unknown[][] } }).mock.calls.length = 0;
|
||||
|
||||
emit({ state: "running", pid: 12 });
|
||||
|
||||
expect(tray.setImageMock).toHaveBeenCalledTimes(1);
|
||||
expect(lastImagePath()).toMatch(/tray-running(-Template)?\.png$/);
|
||||
|
||||
const menu = getLastMenu();
|
||||
expect(menu[0]).toMatchObject({ label: "Running · pid 12", enabled: false });
|
||||
});
|
||||
|
||||
it("maps installing_cli and stopping to the starting silhouette", () => {
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
const tray = h.trayInstances[0]!;
|
||||
|
||||
emit({ state: "installing_cli" });
|
||||
expect(lastImagePath()).toMatch(/tray-starting(-Template)?\.png$/);
|
||||
|
||||
emit({ state: "stopping" });
|
||||
expect(lastImagePath()).toMatch(/tray-starting(-Template)?\.png$/);
|
||||
|
||||
expect(tray.setImageMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps cli_not_found to the error silhouette", () => {
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
emit({ state: "cli_not_found" });
|
||||
expect(lastImagePath()).toMatch(/tray-error(-Template)?\.png$/);
|
||||
});
|
||||
|
||||
it("wires menu clicks to daemonOps and openDaemonLogFile", () => {
|
||||
h.lastEmit.value = { state: "running", pid: 1 };
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
const menu = getLastMenu();
|
||||
const click = (label: string): void => {
|
||||
const item = menu.find((i) => i.label === label);
|
||||
(item?.click as () => void)?.();
|
||||
};
|
||||
|
||||
click("Stop Daemon");
|
||||
click("Restart Daemon");
|
||||
click("Open Log File");
|
||||
click("Quit Multica");
|
||||
|
||||
expect(daemonOps.stop).toHaveBeenCalledTimes(1);
|
||||
expect(daemonOps.restart).toHaveBeenCalledTimes(1);
|
||||
expect(openDaemonLogFile).toHaveBeenCalledTimes(1);
|
||||
expect(app.quit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not register a click listener on Linux", () => {
|
||||
const orig = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
try {
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
expect(h.trayInstances[0]!.clickListeners).toHaveLength(0);
|
||||
} finally {
|
||||
if (orig) Object.defineProperty(process, "platform", orig);
|
||||
}
|
||||
});
|
||||
|
||||
it("registers a click listener on darwin", () => {
|
||||
const orig = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { value: "darwin" });
|
||||
try {
|
||||
setupTray({ getWindow: () => null, showOrCreateWindow: vi.fn() });
|
||||
expect(h.trayInstances[0]!.clickListeners).toHaveLength(1);
|
||||
} finally {
|
||||
if (orig) Object.defineProperty(process, "platform", orig);
|
||||
}
|
||||
});
|
||||
});
|
||||
181
apps/desktop/src/main/tray-manager.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { app, BrowserWindow, Menu, Tray, nativeImage } from "electron";
|
||||
import { join } from "path";
|
||||
import type { DaemonState, DaemonStatus } from "../shared/daemon-types";
|
||||
import {
|
||||
daemonOps,
|
||||
openDaemonLogFile,
|
||||
subscribeDaemonStatus,
|
||||
} from "./daemon-manager";
|
||||
|
||||
type IconVariant = "running" | "stopped" | "starting" | "error";
|
||||
|
||||
// State → icon variant. macOS uses template images (see resolveIconPath
|
||||
// below); "starting" / "stopping" / "installing_cli" all fall back to the
|
||||
// stopped silhouette there because template images can't animate — the
|
||||
// transient state is communicated via the menu's disabled title row.
|
||||
const TRAY_ICON_BY_STATE: Record<DaemonState, IconVariant> = {
|
||||
installing_cli: "starting",
|
||||
cli_not_found: "error",
|
||||
starting: "starting",
|
||||
stopping: "starting",
|
||||
running: "running",
|
||||
stopped: "stopped",
|
||||
};
|
||||
|
||||
// Same path-swap trick as bundledCliPath() in daemon-manager.ts: in dev
|
||||
// `app.getAppPath()` points at apps/desktop, and electron-builder's
|
||||
// `asarUnpack: resources/**` extracts these PNGs to app.asar.unpacked/ in
|
||||
// packaged builds. macOS picks up the `Template` filename suffix and
|
||||
// recolors the image for the menu bar theme automatically.
|
||||
function resolveIconPath(state: DaemonState): string {
|
||||
const variant = TRAY_ICON_BY_STATE[state];
|
||||
const file =
|
||||
process.platform === "darwin"
|
||||
? `tray-${variant}-Template.png`
|
||||
: `tray-${variant}.png`;
|
||||
return join(app.getAppPath(), "resources", "tray", file).replace(
|
||||
"app.asar",
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
}
|
||||
|
||||
// Title row of the context menu — disabled, used purely as a status read-out
|
||||
// since macOS (per design decision) keeps the menu bar icon text-free.
|
||||
export function formatStatusLabel(status: DaemonStatus): string {
|
||||
switch (status.state) {
|
||||
case "running": {
|
||||
const parts = ["Running"];
|
||||
if (typeof status.pid === "number") parts.push(`pid ${status.pid}`);
|
||||
const agentCount = status.agents?.length ?? 0;
|
||||
if (agentCount > 0) {
|
||||
parts.push(`${agentCount} ${agentCount === 1 ? "agent" : "agents"}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
case "stopped":
|
||||
return "Stopped";
|
||||
case "starting":
|
||||
return "Starting…";
|
||||
case "stopping":
|
||||
return "Stopping…";
|
||||
case "installing_cli":
|
||||
return "Setting up…";
|
||||
case "cli_not_found":
|
||||
return "Setup failed";
|
||||
}
|
||||
}
|
||||
|
||||
// Pure menu template builder — exported for unit tests so they can inspect
|
||||
// label / enabled / type fields without going near a real Tray instance.
|
||||
export function buildMenuTemplate(
|
||||
status: DaemonStatus,
|
||||
actions: {
|
||||
showWindow: () => void;
|
||||
openLog: () => void;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
restart: () => void;
|
||||
quit: () => void;
|
||||
},
|
||||
): Electron.MenuItemConstructorOptions[] {
|
||||
const state = status.state;
|
||||
const canStart = state === "stopped" || state === "cli_not_found";
|
||||
const canStop = state === "running";
|
||||
const canRestart = state === "running";
|
||||
|
||||
return [
|
||||
{ label: formatStatusLabel(status), enabled: false },
|
||||
{ type: "separator" },
|
||||
{ label: "Show Multica", click: actions.showWindow },
|
||||
{ label: "Open Log File", click: actions.openLog },
|
||||
{ type: "separator" },
|
||||
{ label: "Start Daemon", enabled: canStart, click: actions.start },
|
||||
{ label: "Stop Daemon", enabled: canStop, click: actions.stop },
|
||||
{ label: "Restart Daemon", enabled: canRestart, click: actions.restart },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit Multica", click: actions.quit },
|
||||
];
|
||||
}
|
||||
|
||||
export interface TrayOptions {
|
||||
// Peek at the current main window without forcing a recreation. Used by
|
||||
// the left-click handler to decide whether the window is already visible
|
||||
// (hide) or needs to be brought forward (show-or-create).
|
||||
getWindow: () => BrowserWindow | null;
|
||||
// Single entry point for "bring the main window forward, creating one if
|
||||
// it was previously closed/destroyed". The caller owns BrowserWindow
|
||||
// lifecycle so tray-manager never holds a stale reference.
|
||||
showOrCreateWindow: () => void;
|
||||
}
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
function rebuildMenu(status: DaemonStatus, opts: TrayOptions): void {
|
||||
if (!tray) return;
|
||||
const template = buildMenuTemplate(status, {
|
||||
showWindow: opts.showOrCreateWindow,
|
||||
openLog: () => {
|
||||
void openDaemonLogFile();
|
||||
},
|
||||
start: () => {
|
||||
void daemonOps.start();
|
||||
},
|
||||
stop: () => {
|
||||
void daemonOps.stop();
|
||||
},
|
||||
restart: () => {
|
||||
void daemonOps.restart();
|
||||
},
|
||||
quit: () => {
|
||||
app.quit();
|
||||
},
|
||||
});
|
||||
tray.setContextMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the tray icon and wire it to the live daemon status. Idempotent —
|
||||
* a second call is a no-op so HMR / re-entry can't accumulate Tray instances.
|
||||
*/
|
||||
export function setupTray(opts: TrayOptions): void {
|
||||
if (tray) return;
|
||||
|
||||
const initialImage = nativeImage.createFromPath(resolveIconPath("stopped"));
|
||||
tray = new Tray(initialImage);
|
||||
tray.setToolTip("Multica");
|
||||
|
||||
unsubscribe = subscribeDaemonStatus((status) => {
|
||||
if (!tray) return;
|
||||
tray.setImage(nativeImage.createFromPath(resolveIconPath(status.state)));
|
||||
rebuildMenu(status, opts);
|
||||
});
|
||||
|
||||
// Left-click handler is a macOS/Windows nice-to-have only. Linux's
|
||||
// AppIndicator surface doesn't fire `click`, so all actions must remain
|
||||
// reachable via the context menu — which they are (see buildMenuTemplate).
|
||||
// Click always shows + focuses the main window; hiding is reserved for
|
||||
// the closeToTray pref (PR2). showOrCreateWindow restores from minimized,
|
||||
// shows if hidden, and focuses an already-visible window.
|
||||
if (process.platform !== "linux") {
|
||||
tray.on("click", () => {
|
||||
opts.showOrCreateWindow();
|
||||
});
|
||||
}
|
||||
|
||||
app.on("before-quit", () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
tray?.destroy();
|
||||
tray = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Test-only escape hatch: lets the suite reset module state between cases
|
||||
// without exporting the live `tray` / `unsubscribe` bindings.
|
||||
export function __resetTrayForTests(): void {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
tray?.destroy();
|
||||
tray = null;
|
||||
}
|
||||