diff --git a/desktop/src/main/__tests__/app-lifecycle.test.ts b/desktop/src/main/__tests__/app-lifecycle.test.ts new file mode 100644 index 000000000..ca407cfdf --- /dev/null +++ b/desktop/src/main/__tests__/app-lifecycle.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest" +import { + clearAppQuitting, + isAppQuitting, + markAppQuitting, +} from "../app-lifecycle.js" + +describe("app lifecycle", () => { + it("starts as not quitting", () => { + expect(isAppQuitting()).toBe(false) + }) + + it("marks the application as quitting", () => { + markAppQuitting() + expect(isAppQuitting()).toBe(true) + clearAppQuitting() + expect(isAppQuitting()).toBe(false) + }) +}) diff --git a/desktop/src/main/__tests__/state.test.ts b/desktop/src/main/__tests__/state.test.ts index b241dd019..94ebfdddc 100644 --- a/desktop/src/main/__tests__/state.test.ts +++ b/desktop/src/main/__tests__/state.test.ts @@ -22,6 +22,23 @@ describe("DaemonState", () => { expect(state.updateWorkspaces(ws)).toBe(false) }) + it("updates workspace status and notifies listeners only for changes", () => { + const state = new DaemonState() + const listener = vi.fn() + const unsubscribe = state.onWorkspacesChange(listener) + state.updateWorkspaces([makeWorkspace("ws1", "2024-01-01")]) + listener.mockClear() + + expect(state.updateWorkspaceStatus("ws1", "running")).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + expect(state.updateWorkspaceStatus("ws1", "running")).toBe(false) + expect(listener).toHaveBeenCalledTimes(1) + expect(state.updateWorkspaceStatus("missing", "running")).toBe(false) + unsubscribe() + expect(state.updateWorkspaceStatus("ws1", "stopped")).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + }) + it("detects workspace removal", () => { const state = new DaemonState() expect( diff --git a/desktop/src/main/__tests__/tray.test.ts b/desktop/src/main/__tests__/tray.test.ts index ae571c474..3e3311cb6 100644 --- a/desktop/src/main/__tests__/tray.test.ts +++ b/desktop/src/main/__tests__/tray.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from "vitest" -import { buildUpdateMenuItems } from "../tray.js" +import { buildTrayMenuTemplate, buildUpdateMenuItems } from "../tray.js" vi.mock("electron", () => ({})) vi.mock("../updater.js", () => ({ getLastStatus: () => ({ state: "idle" }), installUpdate: vi.fn(), + onUpdateStatusChanged: vi.fn(() => () => {}), })) describe("buildUpdateMenuItems", () => { @@ -15,6 +16,12 @@ describe("buildUpdateMenuItems", () => { expect(buildUpdateMenuItems({ state: "downloading", version: "1" }, () => {})).toEqual([]) expect(buildUpdateMenuItems({ state: "not-available" }, () => {})).toEqual([]) expect(buildUpdateMenuItems({ state: "error", error: "x" }, () => {})).toEqual([]) + expect( + buildUpdateMenuItems( + { state: "error", code: "install-failed", version: "1" }, + () => {}, + ), + ).toHaveLength(2) }) it("adds Install Update item + separator when downloaded", () => { @@ -33,4 +40,76 @@ describe("buildUpdateMenuItems", () => { const items = buildUpdateMenuItems({ state: "downloaded" }, () => {}) expect(items[0]).toMatchObject({ label: "Install Update v" }) }) + + it("offers retry after installation fails", () => { + const onInstall = vi.fn() + const items = buildUpdateMenuItems( + { state: "error", code: "install-failed", version: "9.9.9" }, + onInstall, + ) + expect(items[0]).toMatchObject({ label: "Retry Install Update v9.9.9" }) + ;(items[0] as { click?: () => void }).click?.() + expect(onInstall).toHaveBeenCalledTimes(1) + }) +}) + +describe("buildTrayMenuTemplate", () => { + const actions = { + showDevsy: vi.fn(), + showWorkspace: vi.fn(), + showAllWorkspaces: vi.fn(), + stopWorkspace: vi.fn(), + installUpdate: vi.fn(), + quit: vi.fn(), + } + + it("shows only active workspaces with open and stop actions", () => { + const items = buildTrayMenuTemplate( + { + activeWorkspaces: [ + { id: "running", status: "running" }, + { id: "busy", status: "busy" }, + ], + pendingStops: new Set(), + updateStatus: { state: "idle" }, + }, + actions, + ) + expect(items[0]).toMatchObject({ label: "Show Devsy" }) + const submenu = items[2].submenu as Array> + expect(submenu[0]).toMatchObject({ label: "running" }) + expect(submenu[1]).toMatchObject({ label: "busy — Busy" }) + expect(submenu[0].submenu).toEqual([ + expect.objectContaining({ label: "Open in Devsy" }), + expect.objectContaining({ label: "Stop Workspace" }), + ]) + expect(items.at(-1)).toMatchObject({ label: "Quit Devsy" }) + }) + + it("shows an empty state and disables a pending stop", () => { + const empty = buildTrayMenuTemplate( + { + activeWorkspaces: [], + pendingStops: new Set(), + updateStatus: { state: "idle" }, + }, + actions, + ) + expect((empty[2].submenu as Array>)[0]).toMatchObject({ + label: "No Active Workspaces", + enabled: false, + }) + + const pending = buildTrayMenuTemplate( + { + activeWorkspaces: [{ id: "ws-1", status: "running" }], + pendingStops: new Set(["ws-1"]), + updateStatus: { state: "idle" }, + }, + actions, + ) + const stop = ((pending[2].submenu as Array>)[0] + .submenu as Array>)[1] + expect(stop).toMatchObject({ label: "Stopping…", enabled: false }) + }) }) diff --git a/desktop/src/main/__tests__/updater.test.ts b/desktop/src/main/__tests__/updater.test.ts index 2698bab4b..96627619c 100644 --- a/desktop/src/main/__tests__/updater.test.ts +++ b/desktop/src/main/__tests__/updater.test.ts @@ -27,7 +27,6 @@ vi.mock("electron-updater", () => ({ vi.mock("electron", () => ({ app: { isPackaged: true, - isQuitting: false, getPath: () => "/tmp/devsy-test", getVersion: () => "1.0.0", }, @@ -102,25 +101,63 @@ describe("updater", () => { ).not.toHaveBeenCalled() }) - it("sets app.isQuitting before quitAndInstall so the window can close", async () => { - const electron = await import("electron") - ;( - electron.app as typeof electron.app & { isQuitting?: boolean } - ).isQuitting = false - let quittingWhenInstalled: boolean | undefined - electronUpdaterMock.autoUpdater.quitAndInstall.mockImplementation(() => { - quittingWhenInstalled = ( - electron.app as typeof electron.app & { isQuitting?: boolean } - ).isQuitting - }) + it("marks the app as quitting before quitAndInstall", async () => { const { installUpdate } = await import("../updater.js") + const { isAppQuitting } = await import("../app-lifecycle.js") await installUpdate() - expect(quittingWhenInstalled).toBe(true) + expect(isAppQuitting()).toBe(true) expect( electronUpdaterMock.autoUpdater.quitAndInstall, ).toHaveBeenCalledTimes(1) }) + it("restores lifecycle state when quitAndInstall fails", async () => { + electronUpdaterMock.autoUpdater.quitAndInstall.mockImplementation(() => { + throw new Error("install failed") + }) + const { getLastStatus, initAutoUpdater, installUpdate } = await import( + "../updater.js" + ) + const { isAppQuitting } = await import("../app-lifecycle.js") + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + electronUpdaterMock.autoUpdater.emit("update-downloaded", { + version: "9.9.9", + }) + + await expect(installUpdate()).rejects.toThrow("install failed") + expect(isAppQuitting()).toBe(false) + expect(getLastStatus()).toMatchObject({ + state: "error", + code: "install-failed", + version: "9.9.9", + }) + }) + + it("continues notifying update listeners after one throws", async () => { + const { initAutoUpdater, onUpdateStatusChanged } = await import( + "../updater.js" + ) + const send = vi.fn() + const win = { isDestroyed: () => false, webContents: { send } } as never + await initAutoUpdater(() => win) + const first = vi.fn(() => { + throw new Error("listener failed") + }) + const second = vi.fn() + onUpdateStatusChanged(first) + onUpdateStatusChanged(second) + + expect(() => + electronUpdaterMock.autoUpdater.emit("update-available", { + version: "9.9.9", + }), + ).not.toThrow() + expect(first).toHaveBeenCalled() + expect(second).toHaveBeenCalled() + }) + it("swallows a channel-missing rejection from check_for_updates", async () => { electronUpdaterMock.autoUpdater.checkForUpdates.mockRejectedValueOnce( new Error( diff --git a/desktop/src/main/__tests__/workspace-status.test.ts b/desktop/src/main/__tests__/workspace-status.test.ts new file mode 100644 index 000000000..7a705aebf --- /dev/null +++ b/desktop/src/main/__tests__/workspace-status.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" +import { + isActiveWorkspaceStatus, + normalizeWorkspaceStatus, +} from "../workspace-status.js" + +describe("workspace status", () => { + it.each([ + ['{"state":"running"}', "running"], + ['{"state":"busy"}', "busy"], + [" stopped ", "stopped"], + ["not-json", "not-json"], + ])("normalizes %j", (raw, expected) => { + expect(normalizeWorkspaceStatus(raw)).toBe(expected) + }) + + it("ignores empty or JSON without a state", () => { + expect(normalizeWorkspaceStatus(" ")).toBeUndefined() + expect(normalizeWorkspaceStatus("{}")).toBeUndefined() + }) + + it.each(["running", "RUNNING", "busy", "Busy"])( + "classifies %s as active", + (status) => expect(isActiveWorkspaceStatus(status)).toBe(true), + ) + + it.each([undefined, "stopped", "notfound", "unknown"])( + "classifies %s as inactive", + (status) => expect(isActiveWorkspaceStatus(status)).toBe(false), + ) +}) diff --git a/desktop/src/main/app-lifecycle.ts b/desktop/src/main/app-lifecycle.ts new file mode 100644 index 000000000..9102dda1b --- /dev/null +++ b/desktop/src/main/app-lifecycle.ts @@ -0,0 +1,13 @@ +let quitting = false + +export function markAppQuitting(): void { + quitting = true +} + +export function clearAppQuitting(): void { + quitting = false +} + +export function isAppQuitting(): boolean { + return quitting +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 0906a51c6..ff625be9b 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -2,6 +2,7 @@ import { homedir } from "node:os" import { join } from "node:path" import { app, BrowserWindow, session } from "electron" import { initAnalytics, shutdownAnalytics, trackEvent } from "./analytics.js" +import { isAppQuitting, markAppQuitting } from "./app-lifecycle.js" import { CliRunner } from "./cli.js" import { DaemonManager } from "./daemon-manager.js" import { registerIpcHandlers } from "./ipc.js" @@ -18,10 +19,13 @@ const PROTOCOL = "devsy" let mainWindow: BrowserWindow | null = null let pendingDeepLink: string | null = null +let pendingRoute: string | null = null +let appTray: AppTray | null = null +let watcher: Watcher | null = null const state = new DaemonState() function handleDeepLink(url: string): void { - if (mainWindow) { + if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.show() mainWindow.focus() @@ -31,6 +35,18 @@ function handleDeepLink(url: string): void { } } +function showDevsy(route?: string): void { + if (!mainWindow || mainWindow.isDestroyed()) { + pendingRoute = route ?? null + createWindow() + return + } + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.show() + mainWindow.focus() + if (route) mainWindow.webContents.send("navigate", route) +} + // Enforce single instance; forward deep links from second instances to the first. const gotLock = app.requestSingleInstanceLock() if (!gotLock) { @@ -41,7 +57,7 @@ app.on("second-instance", (_event, argv) => { const url = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)) if (url) { handleDeepLink(url) - } else if (mainWindow) { + } else if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.show() mainWindow.focus() @@ -72,7 +88,7 @@ function createWindow(): void { mainWindow.on("close", (event) => { if ( mainWindow && - !(app as typeof app & { isQuitting?: boolean }).isQuitting + !isAppQuitting() ) { event.preventDefault() mainWindow.hide() @@ -87,6 +103,10 @@ function createWindow(): void { mainWindow.once("ready-to-show", () => { mainWindow?.show() + if (pendingRoute) { + mainWindow?.webContents.send("navigate", pendingRoute) + pendingRoute = null + } if (pendingDeepLink) { mainWindow?.webContents.send("deep-link", pendingDeepLink) pendingDeepLink = null @@ -150,7 +170,7 @@ app.whenReady().then(() => { daemonManager.start() app.on("before-quit", () => { - ;(app as typeof app & { isQuitting?: boolean }).isQuitting = true + markAppQuitting() trackEvent("app_close") shutdownAnalytics().catch(() => {}) cli.killAll() @@ -160,6 +180,8 @@ app.whenReady().then(() => { daemonManager.stop() ptyManager.destroyAll() stopAutoUpdater() + watcher?.stop() + appTray?.destroy() }) const providerJobs = new ProviderJobs() @@ -170,6 +192,7 @@ app.whenReady().then(() => { tunnelProcesses, scheduleProviderUpdateCheck, runInitialProviderUpdateCheck, + workspaceActions, } = registerIpcHandlers({ cli, state, @@ -181,7 +204,7 @@ app.whenReady().then(() => { }) // Start state watcher - const watcher = new Watcher({ + watcher = new Watcher({ cli, daemon: daemonManager.daemonClient, state, @@ -189,18 +212,27 @@ app.whenReady().then(() => { providerJobs, workspaceJobs, }) - providerJobs.onChange(() => watcher.broadcastProviders()) - providerJobs.setRefresh(() => watcher.refreshProviders()) - workspaceJobs.onChange(() => watcher.broadcastWorkspaces()) - workspaceJobs.setRefresh(() => watcher.refreshWorkspaces()) + providerJobs.onChange(() => watcher?.broadcastProviders()) + providerJobs.setRefresh(() => + watcher ? watcher.refreshProviders() : Promise.resolve(), + ) + workspaceJobs.onChange(() => watcher?.broadcastWorkspaces()) + workspaceJobs.setRefresh(() => + watcher ? watcher.refreshWorkspaces() : Promise.resolve(), + ) void watcher.start().then(runInitialProviderUpdateCheck) scheduleProviderUpdateCheck() // Set up system tray - const appTray = new AppTray({ + appTray = new AppTray({ state, - getMainWindow: () => mainWindow, + showDevsy, + stopWorkspace: workspaceActions.stop, + refreshWorkspace: (id) => + watcher ? watcher.refreshWorkspaceStatus(id) : Promise.resolve(), + refreshWorkspaces: () => + watcher ? watcher.refreshWorkspaces() : Promise.resolve(), }) appTray.setup() diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 3cd27d01a..094a00bb5 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -32,6 +32,7 @@ import { setReleaseChannel, } from "./updater.js" import { type ProviderEntry, parseProviderEntries } from "./watcher.js" +import { normalizeWorkspaceStatus } from "./workspace-status.js" import type { WorkspaceJobs } from "./workspace-jobs.js" const execFileAsync = promisify(execFile) @@ -173,6 +174,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: Map scheduleProviderUpdateCheck: () => void runInitialProviderUpdateCheck: () => void + workspaceActions: { stop: (workspaceId: string) => Promise } } { const { cli, state, logStore, pty, providerJobs, workspaceJobs } = deps const tunnelProcesses = new Map< @@ -431,7 +433,15 @@ export function registerIpcHandlers(deps: IpcDependencies): { "15s", ] if (args.recovery) cliArgs.push("--recovery") - return cli.runRaw(cliArgs) + const raw = await cli.runRaw(cliArgs) + if (!args.recovery) { + const status = normalizeWorkspaceStatus(raw) + if (status && state.updateWorkspaceStatus(args.workspaceId, status)) { + // The watcher owns renderer broadcasts; this update still keeps + // main-process consumers current for explicit status requests. + } + } + return raw }, ) @@ -1113,45 +1123,76 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) - ipcMain.handle( - "workspace_stop", - async (_event, args: { workspaceId: string; debug?: boolean; commandId?: string }) => { - trackEvent("workspace_stop", { - workspace_ref: hashWorkspaceRef(args.workspaceId), - }) - await quiesceWorkspace(args.workspaceId) - const cmdId = args.commandId ?? crypto.randomUUID() - const logPath = logStore.createLogFile( - state.workspaceContext(args.workspaceId), - args.workspaceId, - ) - const sink = createLogSink( - deps.getMainWindow, - cmdId, - (line) => logStore.appendLog(logPath, line), - () => logStore.closeLog(logPath), - ) + type WorkspaceActionSource = "renderer" | "tray" + type StopWorkspaceArgs = { + workspaceId: string + debug?: boolean + commandId?: string + } + async function startWorkspaceStop( + args: StopWorkspaceArgs, + source: WorkspaceActionSource, + ): Promise<{ commandId: string; completion: Promise }> { + trackEvent("workspace_stop", { + workspace_ref: hashWorkspaceRef(args.workspaceId), + source, + }) + await quiesceWorkspace(args.workspaceId) + const commandId = args.commandId ?? crypto.randomUUID() + const logPath = logStore.createLogFile( + state.workspaceContext(args.workspaceId), + args.workspaceId, + ) + const sink = createLogSink( + deps.getMainWindow, + commandId, + (line) => logStore.appendLog(logPath, line), + () => logStore.closeLog(logPath), + ) + const completion = new Promise((resolve, reject) => { const cliArgs = ["workspace", "stop", args.workspaceId] if (args.debug) cliArgs.push("--debug") - cli.runStreaming( - cliArgs, - (line) => { - if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath) - }, - (code) => { - void sink.done( - formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), - { success: code === 0 }, - ) - }, - args.workspaceId, - ) + cli + .runStreaming( + cliArgs, + (line) => { + if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath) + }, + (code, cliError) => { + void sink + .done( + formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), + code === 0 + ? { success: true } + : { level: "error", success: false, cliError }, + ) + .then(() => { + if (code === 0) resolve() + else reject(new Error(`workspace stop exited with ${code}`)) + }) + .catch(reject) + }, + args.workspaceId, + ) + .catch((error: unknown) => { + void sink + .done(formatLogLine(errorMessage(error), "ERROR"), { + success: false, + }) + .catch(() => {}) + .finally(() => reject(error)) + }) + }) + return { commandId, completion } + } - return cmdId - }, - ) + ipcMain.handle("workspace_stop", async (_event, args: StopWorkspaceArgs) => { + const { commandId, completion } = await startWorkspaceStop(args, "renderer") + void completion.catch(() => {}) + return commandId + }) ipcMain.handle( "workspace_delete", @@ -1590,6 +1631,15 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses, scheduleProviderUpdateCheck: scheduleUpdates, runInitialProviderUpdateCheck: runUpdateCheck, + workspaceActions: { + async stop(workspaceId: string): Promise { + const { completion } = await startWorkspaceStop( + { workspaceId }, + "tray", + ) + await completion + }, + }, } } diff --git a/desktop/src/main/state.ts b/desktop/src/main/state.ts index 3baeeb66f..bb6a2ba9a 100644 --- a/desktop/src/main/state.ts +++ b/desktop/src/main/state.ts @@ -1,6 +1,7 @@ export interface Workspace { id: string lastUsed?: string + status?: string [key: string]: unknown } @@ -25,6 +26,7 @@ export class DaemonState { private machines = new Map() private contexts: Context[] = [] private activeContext = "" + private workspaceListeners = new Set<() => void>() updateWorkspaces(list: Workspace[]): boolean { const merged = list.map((w) => { @@ -41,9 +43,23 @@ export class DaemonState { const newMap = new Map(merged.map((w) => [w.id, w])) if (this.mapsEqual(this.workspaces, newMap)) return false this.workspaces = newMap + this.notifyWorkspaceListeners() return true } + updateWorkspaceStatus(id: string, status: string): boolean { + const workspace = this.workspaces.get(id) + if (!workspace || workspace.status === status) return false + this.workspaces.set(id, { ...workspace, status }) + this.notifyWorkspaceListeners() + return true + } + + onWorkspacesChange(listener: () => void): () => void { + this.workspaceListeners.add(listener) + return () => this.workspaceListeners.delete(listener) + } + updateProviders(list: Provider[]): boolean { const newMap = new Map(list.map((p) => [p.name, p])) if (this.mapsEqual(this.providers, newMap)) return false @@ -129,4 +145,14 @@ export class DaemonState { } return true } + + private notifyWorkspaceListeners(): void { + for (const listener of this.workspaceListeners) { + try { + listener() + } catch (error) { + console.error("[state] workspace listener failed:", error) + } + } + } } diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts index 859c5eae1..5cbd45edc 100644 --- a/desktop/src/main/tray.ts +++ b/desktop/src/main/tray.ts @@ -1,160 +1,210 @@ import { join } from "node:path" -import type { BrowserWindow } from "electron" import { app, Menu, nativeImage, nativeTheme, Tray } from "electron" -import type { DaemonState } from "./state.js" -import { getLastStatus, installUpdate, type UpdateStatus } from "./updater.js" +import type { DaemonState, Workspace } from "./state.js" +import { + getLastStatus, + installUpdate, + onUpdateStatusChanged, + type UpdateStatus, +} from "./updater.js" +import { isActiveWorkspaceStatus } from "./workspace-status.js" -/** - * Builds the menu items shown when an update is ready. Returns an empty - * array when no install action should be offered. Exported for unit testing. - */ export function buildUpdateMenuItems( status: UpdateStatus, onInstall: () => void, ): Electron.MenuItemConstructorOptions[] { - if (status.state !== "downloaded") return [] + const installationFailed = + status.state === "error" && status.code === "install-failed" + if (status.state !== "downloaded" && !installationFailed) return [] + const label = installationFailed + ? `Retry Install Update v${status.version ?? ""}` + : `Install Update v${status.version ?? ""}` return [ - { label: `Install Update v${status.version ?? ""}`, click: onInstall }, + { label, click: onInstall }, { type: "separator" }, ] } +export interface TrayMenuModel { + activeWorkspaces: Workspace[] + pendingStops: ReadonlySet + updateStatus: UpdateStatus +} + +export interface TrayMenuActions { + showDevsy: () => void + showWorkspace: (id: string) => void + showAllWorkspaces: () => void + stopWorkspace: (id: string) => void + installUpdate: () => void + quit: () => void +} + +export function buildTrayMenuTemplate( + model: TrayMenuModel, + actions: TrayMenuActions, +): Electron.MenuItemConstructorOptions[] { + const active = model.activeWorkspaces + const workspaceItems: Electron.MenuItemConstructorOptions[] = active + .slice(0, 10) + .map((workspace) => { + const pending = model.pendingStops.has(workspace.id) + const busy = workspace.status?.trim().toLowerCase() === "busy" + return { + label: `${workspace.id}${busy && !pending ? " — Busy" : ""}`, + submenu: [ + { + label: "Open in Devsy", + click: () => actions.showWorkspace(workspace.id), + }, + { + label: pending ? "Stopping…" : "Stop Workspace", + enabled: !pending, + click: pending + ? undefined + : () => actions.stopWorkspace(workspace.id), + }, + ], + } + }) + + const activeSubmenu: Electron.MenuItemConstructorOptions[] = + workspaceItems.length > 0 + ? [ + ...workspaceItems, + ...(active.length > 10 ? [{ type: "separator" as const }] : []), + { + label: "Show All Workspaces…", + click: actions.showAllWorkspaces, + }, + ] + : [ + { label: "No Active Workspaces", enabled: false }, + { type: "separator" }, + { + label: "Open Workspaces in Devsy…", + click: actions.showAllWorkspaces, + }, + ] + + return [ + { label: "Show Devsy", click: actions.showDevsy }, + { type: "separator" }, + { label: `Active Workspaces (${active.length})`, submenu: activeSubmenu }, + ...buildUpdateMenuItems(model.updateStatus, actions.installUpdate), + { type: "separator" }, + { label: "Quit Devsy", click: actions.quit }, + ] +} + interface TrayDeps { state: DaemonState - getMainWindow: () => BrowserWindow | null + showDevsy: (route?: string) => void + stopWorkspace: (workspaceId: string) => Promise + refreshWorkspace: (workspaceId: string) => Promise + refreshWorkspaces: () => Promise } export class AppTray { private tray: Tray | null = null - private rebuildTimer: ReturnType | null = null + private pendingStops = new Set() + private unsubscribeWorkspaceState: (() => void) | null = null + private unsubscribeUpdateStatus: (() => void) | null = null + private readonly onThemeUpdated = (): void => { + this.tray?.setImage(this.createTrayIcon()) + } constructor(private deps: TrayDeps) {} setup(): void { - const icon = this.createTrayIcon() - this.tray = new Tray(icon) - this.tray.setToolTip("Devsy") - this.rebuildMenu() - + if (this.tray) return + this.tray = new Tray(this.createTrayIcon()) + this.tray.setToolTip("Devsy — No active workspaces") + this.unsubscribeWorkspaceState = this.deps.state.onWorkspacesChange(() => + this.rebuildMenu(), + ) + this.unsubscribeUpdateStatus = onUpdateStatusChanged(() => + this.rebuildMenu(), + ) if (process.platform !== "darwin") { - nativeTheme.on("updated", () => { - if (this.tray) { - this.tray.setImage(this.createTrayIcon()) - } - }) + nativeTheme.on("updated", this.onThemeUpdated) } - - this.tray.on("click", () => { - const win = this.deps.getMainWindow() - if (win) { - win.show() - win.focus() - } - }) - - // Rebuild menu every 5 seconds - this.rebuildTimer = setInterval(() => this.rebuildMenu(), 5000) + this.rebuildMenu() } destroy(): void { - if (this.rebuildTimer) { - clearInterval(this.rebuildTimer) - this.rebuildTimer = null - } - if (this.tray) { - this.tray.destroy() - this.tray = null + this.unsubscribeWorkspaceState?.() + this.unsubscribeWorkspaceState = null + this.unsubscribeUpdateStatus?.() + this.unsubscribeUpdateStatus = null + if (process.platform !== "darwin") { + nativeTheme.off("updated", this.onThemeUpdated) } + this.pendingStops.clear() + this.tray?.destroy() + this.tray = null } private createTrayIcon(): Electron.NativeImage { const trayDir = join(__dirname, "../../resources/tray") if (process.platform === "darwin") { - // macOS: use Template images — Electron auto-adapts to menu bar theme - const iconPath = join(trayDir, "icon-trayTemplate.png") - try { - const icon = nativeImage.createFromPath(iconPath) - icon.setTemplateImage(true) - return icon - } catch { - return nativeImage.createEmpty() - } + const icon = nativeImage.createFromPath( + join(trayDir, "icon-trayTemplate.png"), + ) + icon.setTemplateImage(true) + return icon } - - // Windows/Linux: pick light or dark variant based on system theme const variant = nativeTheme.shouldUseDarkColors ? "dark" : "light" - const iconPath = join(trayDir, `icon-tray-${variant}.png`) - try { - return nativeImage.createFromPath(iconPath) - } catch { - return nativeImage.createEmpty() - } + return nativeImage.createFromPath( + join(trayDir, `icon-tray-${variant}.png`), + ) } private rebuildMenu(): void { if (!this.tray) return + const activeWorkspaces = this.deps.state + .workspaceList() + .filter((workspace) => isActiveWorkspaceStatus(workspace.status)) - const workspaces = this.deps.state.workspaceList() - const count = workspaces.length - const statusLabel = - count === 0 - ? "No workspaces" - : `${count} workspace${count === 1 ? "" : "s"}` - - const template: Electron.MenuItemConstructorOptions[] = [ - ...buildUpdateMenuItems(getLastStatus(), () => { - installUpdate().catch(() => {}) - }), - { label: statusLabel, enabled: false }, - ] - - if (workspaces.length > 0) { - template.push({ type: "separator" }) - for (const ws of workspaces.slice(0, 10)) { - template.push({ - label: ` ${ws.id}`, - click: () => { - const win = this.deps.getMainWindow() - if (win) { - win.show() - win.focus() - win.webContents.send("navigate", `/workspaces/${ws.id}`) - } - }, - }) - } - if (count > 10) { - template.push({ label: ` ... and ${count - 10} more`, enabled: false }) - } - } - - template.push( - { type: "separator" }, - { - label: "Show Devsy", - click: () => { - const win = this.deps.getMainWindow() - if (win) { - win.show() - win.focus() - } - }, - }, + const template = buildTrayMenuTemplate( { - label: "Hide", - click: () => { - this.deps.getMainWindow()?.hide() - }, + activeWorkspaces, + pendingStops: this.pendingStops, + updateStatus: getLastStatus(), }, - { type: "separator" }, { - label: "Quit Devsy", - click: () => app.quit(), + showDevsy: () => this.deps.showDevsy(), + showWorkspace: (id) => + this.deps.showDevsy(`/workspaces/${encodeURIComponent(id)}`), + showAllWorkspaces: () => this.deps.showDevsy("/workspaces"), + stopWorkspace: (id) => void this.stopFromTray(id), + installUpdate: () => + void installUpdate().catch((error) => + console.warn("[tray] failed to install update:", error), + ), + quit: () => app.quit(), }, ) + this.tray.setContextMenu(Menu.buildFromTemplate(template)) + const count = activeWorkspaces.length + this.tray.setToolTip( + `Devsy — ${count} active workspace${count === 1 ? "" : "s"}`, + ) + } - const menu = Menu.buildFromTemplate(template) - this.tray.setContextMenu(menu) - this.tray.setToolTip(`Devsy — ${statusLabel}`) + private async stopFromTray(workspaceId: string): Promise { + if (this.pendingStops.has(workspaceId)) return + this.pendingStops.add(workspaceId) + this.rebuildMenu() + try { + await this.deps.stopWorkspace(workspaceId) + await this.deps.refreshWorkspace(workspaceId) + await this.deps.refreshWorkspaces() + } catch (error) { + console.warn(`[tray] failed to stop workspace ${workspaceId}:`, error) + } finally { + this.pendingStops.delete(workspaceId) + this.rebuildMenu() + } } } diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index b0e137858..6f6b579d7 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -2,6 +2,7 @@ import { readFileSync, renameSync, writeFileSync } from "node:fs" import { join } from "node:path" import { app, type BrowserWindow } from "electron" import { trackEvent } from "./analytics.js" +import { clearAppQuitting, markAppQuitting } from "./app-lifecycle.js" export type ReleaseChannel = "stable" | "beta" @@ -21,6 +22,7 @@ export type UpdateErrorCode = | "feed-error" | "verification" | "channel-missing" + | "install-failed" export interface UpdateProgress { percent: number @@ -80,6 +82,7 @@ let getMainWindowFn: (() => BrowserWindow | null) | null = null let lastStatus: UpdateStatus = { state: "idle" } let initialCheckTimer: ReturnType | null = null let recheckTimer: ReturnType | null = null +const statusListeners = new Set<(status: UpdateStatus) => void>() function sendUpdateStatus(status: UpdateStatus): void { const win = getMainWindowFn?.() @@ -91,6 +94,20 @@ function sendUpdateStatus(status: UpdateStatus): void { function setStatus(status: UpdateStatus): void { lastStatus = status sendUpdateStatus(status) + for (const listener of statusListeners) { + try { + listener(status) + } catch (error) { + console.error("[updater] status listener failed:", error) + } + } +} + +export function onUpdateStatusChanged( + listener: (status: UpdateStatus) => void, +): () => void { + statusListeners.add(listener) + return () => statusListeners.delete(listener) } export function getLastStatus(): UpdateStatus { @@ -333,8 +350,23 @@ export async function downloadUpdate(): Promise { } export async function installUpdate(): Promise { - const autoUpdater = await getUpdater() - if (!autoUpdater || typeof autoUpdater.quitAndInstall !== "function") return - ;(app as typeof app & { isQuitting?: boolean }).isQuitting = true - autoUpdater.quitAndInstall() + let markedQuitting = false + try { + const autoUpdater = await getUpdater() + if (!autoUpdater || typeof autoUpdater.quitAndInstall !== "function") return + markAppQuitting() + markedQuitting = true + autoUpdater.quitAndInstall() + } catch (error) { + if (markedQuitting) clearAppQuitting() + const message = error instanceof Error ? error.message : String(error) + setStatus({ + ...lastStatus, + state: "error", + code: "install-failed", + error: message, + }) + console.error("Update installation failed:", message) + throw error + } } diff --git a/desktop/src/main/watcher.ts b/desktop/src/main/watcher.ts index 4431fdcdc..6dca4e36e 100644 --- a/desktop/src/main/watcher.ts +++ b/desktop/src/main/watcher.ts @@ -8,6 +8,7 @@ import type { DaemonClient } from "./daemon-client.js" import type { ProviderJobs } from "./provider-jobs.js" import type { DaemonState } from "./state.js" import type { WorkspaceJobs } from "./workspace-jobs.js" +import { normalizeWorkspaceStatus } from "./workspace-status.js" interface WatcherDeps { cli: CliRunner @@ -58,6 +59,7 @@ export function parseProviderEntries(raw: Record) { export class Watcher { private pollTimer: ReturnType | null = null + private workspaceStatusTimer: ReturnType | null = null private fsWatcher: ReturnType | null = null private polling = false private pollQueued = false @@ -68,11 +70,18 @@ export class Watcher { // Same serialization for pollWorkspaces, so a manual refreshWorkspaces() // (e.g. after a delete finishes) can't overlap a scheduled poll. private workspacePollChain: Promise = Promise.resolve() + private workspaceStatusChain: Promise = Promise.resolve() + private workspaceStatusPolling = false + private workspaceStatusQueued = false constructor(private deps: WatcherDeps) {} start(): Promise { this.pollTimer = setInterval(() => this.schedulePoll(), 3000) + this.workspaceStatusTimer = setInterval( + () => this.scheduleWorkspaceStatusPoll(), + 10_000, + ) const devsyDir = join(homedir(), ".devsy") if (existsSync(devsyDir)) { @@ -84,7 +93,9 @@ export class Watcher { this.fsWatcher.on("all", () => this.schedulePoll()) } - return this.pollOnce() + return this.pollOnce().then(() => { + void this.refreshWorkspaceStatuses() + }) } stop(): void { @@ -92,6 +103,11 @@ export class Watcher { clearInterval(this.pollTimer) this.pollTimer = null } + if (this.workspaceStatusTimer) { + clearInterval(this.workspaceStatusTimer) + this.workspaceStatusTimer = null + } + this.workspaceStatusQueued = false if (this.fsWatcher) { this.fsWatcher.close() this.fsWatcher = null @@ -143,6 +159,72 @@ export class Watcher { await this.queueWorkspacePoll() } + async refreshWorkspaceStatus(workspaceId: string): Promise { + await this.queueWorkspaceStatusPoll([workspaceId]) + } + + async refreshWorkspaceStatuses(): Promise { + await this.queueWorkspaceStatusPoll() + } + + private scheduleWorkspaceStatusPoll(): void { + if (this.workspaceStatusPolling) { + this.workspaceStatusQueued = true + return + } + void this.refreshWorkspaceStatuses() + } + + private queueWorkspaceStatusPoll(workspaceIds?: string[]): Promise { + const run = this.workspaceStatusChain.then(() => + this.pollWorkspaceStatuses(workspaceIds), + ) + this.workspaceStatusChain = run + return run + } + + private async pollWorkspaceStatuses(workspaceIds?: string[]): Promise { + this.workspaceStatusPolling = true + let changed = false + try { + const ids = workspaceIds ?? this.deps.state.workspaceList().map((ws) => ws.id) + const concurrency = 6 + let next = 0 + const worker = async (): Promise => { + while (next < ids.length) { + const id = ids[next++] + try { + const raw = await this.deps.cli.runRaw([ + "workspace", + "status", + id, + "--result-format", + "json", + "--timeout", + "15s", + ]) + const status = normalizeWorkspaceStatus(raw) + if (status && this.deps.state.updateWorkspaceStatus(id, status)) { + changed = true + } + } catch { + // Preserve the last known status and retry on the next sweep. + } + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, ids.length) }, worker), + ) + if (changed) this.broadcastWorkspaces() + } finally { + this.workspaceStatusPolling = false + if (this.workspaceStatusQueued) { + this.workspaceStatusQueued = false + void this.refreshWorkspaceStatuses() + } + } + } + private queueWorkspacePoll(): Promise { const run = this.workspacePollChain.then(() => this.pollWorkspaces()) this.workspacePollChain = run diff --git a/desktop/src/main/workspace-status.ts b/desktop/src/main/workspace-status.ts new file mode 100644 index 000000000..dc0b3139c --- /dev/null +++ b/desktop/src/main/workspace-status.ts @@ -0,0 +1,21 @@ +export function normalizeWorkspaceStatus(raw: string): string | undefined { + const text = raw.trim() + if (!text) return undefined + + try { + const parsed = JSON.parse(text) as { state?: unknown } + if (typeof parsed.state === "string" && parsed.state.trim()) { + return parsed.state.trim() + } + return undefined + } catch { + // The CLI may return a plain-text status. + } + + return text +} + +export function isActiveWorkspaceStatus(status: string | undefined): boolean { + const normalized = status?.trim().toLowerCase() + return normalized === "running" || normalized === "busy" +} diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index 3302f43c7..997284151 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -27,6 +27,7 @@ export type UpdateErrorCode = | "feed-error" | "verification" | "channel-missing" + | "install-failed" export interface UpdateProgress { percent: number diff --git a/desktop/src/renderer/src/lib/stores/workspaces.test.ts b/desktop/src/renderer/src/lib/stores/workspaces.test.ts index 2506ffd13..395db984e 100644 --- a/desktop/src/renderer/src/lib/stores/workspaces.test.ts +++ b/desktop/src/renderer/src/lib/stores/workspaces.test.ts @@ -72,16 +72,14 @@ describe("workspaces store", () => { ) }) - it("fetches status for each workspace after loading", async () => { - const mockWorkspaces = [{ id: "ws-1" }, { id: "ws-2" }] + it("uses status supplied by the main process", async () => { + const mockWorkspaces = [ + { id: "ws-1", status: "Running" }, + { id: "ws-2", status: "Stopped" }, + ] mockInvoke.mockImplementation( (cmd: string, args?: Record) => { if (cmd === "workspace_list") return Promise.resolve(mockWorkspaces) - if (cmd === "workspace_status") { - const wsId = args?.workspaceId as string - if (wsId === "ws-1") return Promise.resolve('{"state":"Running"}') - if (wsId === "ws-2") return Promise.resolve('{"state":"Stopped"}') - } return Promise.resolve(undefined) }, ) @@ -152,8 +150,8 @@ describe("workspaces store", () => { expect(mockUnlisten).toHaveBeenCalled() }) - it("polls statuses every 10 seconds", async () => { - const mockWorkspaces = [{ id: "ws-1" }] + it("does not poll statuses in the renderer", async () => { + const mockWorkspaces = [{ id: "ws-1", status: "Running" }] let statusCallCount = 0 mockInvoke.mockImplementation((cmd: string) => { if (cmd === "workspace_list") return Promise.resolve(mockWorkspaces) @@ -165,21 +163,13 @@ describe("workspaces store", () => { }) await initWorkspaces() - // Initial fetch - const initialCalls = statusCallCount - - // Advance past one poll interval - vi.advanceTimersByTime(10_000) - await vi.waitFor(() => { - expect(statusCallCount).toBeGreaterThan(initialCalls) - }) + vi.advanceTimersByTime(30_000) + expect(statusCallCount).toBe(0) }) it("destroyWorkspaces stops polling", async () => { mockInvoke.mockImplementation((cmd: string) => { if (cmd === "workspace_list") return Promise.resolve([{ id: "ws-1" }]) - if (cmd === "workspace_status") - return Promise.resolve('{"state":"Stopped"}') return Promise.resolve(undefined) }) diff --git a/desktop/src/renderer/src/lib/stores/workspaces.ts b/desktop/src/renderer/src/lib/stores/workspaces.ts index 010a7f996..8796bd95b 100644 --- a/desktop/src/renderer/src/lib/stores/workspaces.ts +++ b/desktop/src/renderer/src/lib/stores/workspaces.ts @@ -1,5 +1,5 @@ import { get, writable } from "svelte/store" -import { workspaceList, workspaceStatus } from "$lib/ipc/commands.js" +import { workspaceList } from "$lib/ipc/commands.js" import { onWorkspacesChanged } from "$lib/ipc/events.js" import type { UnlistenFn } from "$lib/ipc/types.js" import type { Workspace, WorkspaceJob } from "$lib/types/index.js" @@ -12,9 +12,6 @@ export const workspacesLoading = writable(true) export const workspaceJobs = writable>({}) let unlisten: UnlistenFn | null = null -let pollInterval: ReturnType | null = null - -const STATUS_POLL_MS = 10_000 function mergeWorkspaceStatuses(current: Workspace[], updated: Workspace[]) { const statusMap = new Map(current.map((ws) => [ws.id, ws.status])) @@ -29,7 +26,6 @@ export async function initWorkspaces() { try { const list = await workspaceList() workspaces.set(mergeWorkspaceStatuses(get(workspaces), list)) - fetchStatuses(list) } catch { // IPC not available (e.g. during browser preview) } finally { @@ -40,19 +36,10 @@ export async function initWorkspaces() { unlisten = await onWorkspacesChanged((updated, jobs) => { workspaces.update((current) => mergeWorkspaceStatuses(current, updated)) workspaceJobs.set(jobs) - fetchStatuses(updated) }) } catch { // Event listener setup failed } - - // Poll statuses periodically to keep dashboard and badges fresh - pollInterval = setInterval(() => { - const current = get(workspaces) - if (current.length > 0) { - fetchStatuses(current) - } - }, STATUS_POLL_MS) } export function destroyWorkspaces() { @@ -60,38 +47,4 @@ export function destroyWorkspaces() { unlisten() unlisten = null } - if (pollInterval) { - clearInterval(pollInterval) - pollInterval = null - } -} - -/** Fetch status for each workspace and merge into store */ -function fetchStatuses(list: Workspace[]) { - for (const ws of list) { - workspaceStatus(ws.id) - .then((raw) => { - try { - const parsed = JSON.parse(raw) as { state?: string } - if (parsed.state) { - workspaces.update((current) => - current.map((w) => - w.id === ws.id ? { ...w, status: parsed.state } : w, - ), - ) - } - } catch { - // Status response wasn't valid JSON — use raw as status - const status = raw.trim() - if (status) { - workspaces.update((current) => - current.map((w) => (w.id === ws.id ? { ...w, status } : w)), - ) - } - } - }) - .catch(() => { - // Status fetch failed — leave as-is - }) - } }