Compare commits

...

3 Commits

Author SHA1 Message Date
Lambda
2457519b1f fix(desktop): tray left-click always shows + focuses, never hides
The previous toggle behavior hid the main window when it was already
visible, surprising users who expected the menu bar icon to bring the
app forward. Hiding belongs to the closeToTray pref (PR2); the tray
icon itself should be unambiguously a "bring window to front" affordance.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-20 10:59:21 +08:00
Jiayuan Zhang
417f55029a fix(desktop): recreate main window from tray when closed on macOS
The tray held methods bound to mainWindow even after the user closed it
on macOS (where the app stays alive). Tray click / Show Multica would
then act on a destroyed BrowserWindow instead of bringing the app back.

- Null out mainWindow on its `closed` event so the reference can't go
  stale.
- Centralize a `showOrCreateMainWindow` path that recreates the window
  when none is alive and otherwise restores/shows/focuses.
- Tray now takes a `{ getWindow, showOrCreateWindow }` option pair:
  Show Multica + the macOS/Windows click handler route through
  `showOrCreateWindow`, and the click handler treats a destroyed
  reference the same as a missing one.

Tests cover Show Multica → showOrCreateWindow, click → recreate on
null window, and click → recreate on destroyed window.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 21:30:48 +08:00
Jiayuan Zhang
129395e73b feat(desktop): add system tray with daemon status (PR1/S)
Adds a native Electron Tray that mirrors the local daemon state and exposes
quick actions (start/stop/restart, show window, open log, quit) — covering
the S-phase scope of MUL-2388.

- daemon-manager: expose subscribeDaemonStatus, getDaemonPrefs, setDaemonPrefs,
  daemonOps, and openDaemonLogFile so the tray can drive the existing state
  machine without round-tripping through IPC. The renderer's daemon:status
  channel is untouched.
- tray-manager: builds a context menu whose first row is a disabled status
  read-out (Running · pid 1234 · 3 agents), swaps the tray icon on every
  state change, and gates Start/Stop/Restart on the current state. Linux
  skips the click handler since AppIndicator does not fire it.
- Placeholder icons (black/white geometric shapes) for the four variants —
  real assets land in a separate design PR.
- Unit tests cover label formatting, menu enabled-state per daemon state,
  icon resolution per state, click wiring, and the Linux/darwin branching.

closeToTray pref + close→hide behavior are deferred to PR2 (M phase).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 21:21:22 +08:00
20 changed files with 631 additions and 10 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

View File

@@ -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).

View File

@@ -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) => {

View 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);
}
});
});

View 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;
}