From 5349dd72b8358c9cc90cb8aa7794fd3c20ce3d3b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 12:34:19 -0700 Subject: [PATCH 01/14] Separate host service --- .../lib/electron-app/factories/app/setup.ts | 14 +- .../routers/host-service-manager/index.ts | 43 ++- apps/desktop/src/main/host-service/index.ts | 16 +- apps/desktop/src/main/lib/auto-updater.ts | 9 + .../src/main/lib/host-service-manager.ts | 265 ++++++++++++++++- apps/desktop/src/main/lib/tray/index.ts | 274 ++++++++---------- .../lib/terminal/terminal-ws-transport.ts | 60 ++++ packages/host-service/src/app.ts | 4 + .../host-service/src/terminal/terminal.ts | 27 ++ .../src/trpc/router/health/health.ts | 8 +- packages/host-service/src/types.ts | 2 + 11 files changed, 548 insertions(+), 174 deletions(-) diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index 0b2a660a2df..f67aa03ad27 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -50,7 +50,19 @@ export async function makeAppSetup( }); }); - app.on("window-all-closed", () => !PLATFORM.IS_MAC && app.quit()); + app.on("window-all-closed", () => { + // On macOS, keep the app alive (standard behavior). + // On other platforms, keep alive only if host-service is running. + if (!PLATFORM.IS_MAC) { + const { + getHostServiceManager, + } = require("main/lib/host-service-manager"); + const manager = getHostServiceManager(); + if (!manager.hasActiveInstances()) { + app.quit(); + } + } + }); app.on("before-quit", () => {}); return window; diff --git a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts index 5ee7b4f1ffe..c96ce97efb7 100644 --- a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts +++ b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts @@ -1,5 +1,9 @@ +import { observable } from "@trpc/server/observable"; import { env } from "main/env.main"; -import { getHostServiceManager } from "main/lib/host-service-manager"; +import { + getHostServiceManager, + type HostServiceStatusEvent, +} from "main/lib/host-service-manager"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { loadToken } from "../auth/utils/auth-functions"; @@ -27,5 +31,42 @@ export const createHostServiceManagerRouter = () => { const status = manager.getStatus(input.organizationId); return { status }; }), + + getServiceInfo: publicProcedure + .input(z.object({ organizationId: z.string() })) + .query(({ input }) => { + const manager = getHostServiceManager(); + return manager.getServiceInfo(input.organizationId); + }), + + restart: publicProcedure + .input(z.object({ organizationId: z.string() })) + .mutation(async ({ input }) => { + const manager = getHostServiceManager(); + const { token } = await loadToken(); + if (token) { + manager.setAuthToken(token); + } + manager.setCloudApiUrl(env.NEXT_PUBLIC_API_URL); + const port = await manager.restart(input.organizationId); + const secret = manager.getSecret(input.organizationId); + return { port, secret }; + }), + + onStatusChange: publicProcedure.subscription(() => { + return observable((emit) => { + const manager = getHostServiceManager(); + + const handler = (event: HostServiceStatusEvent) => { + emit.next(event); + }; + + manager.on("status-changed", handler); + + return () => { + manager.off("status-changed", handler); + }; + }); + }), }); }; diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 484f661fb54..6e9522318d7 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -21,6 +21,10 @@ const dbPath = process.env.HOST_DB_PATH; const deviceClientId = process.env.DEVICE_CLIENT_ID; const deviceName = process.env.DEVICE_NAME; const hostServiceSecret = process.env.HOST_SERVICE_SECRET; +const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null; +const protocolVersion = process.env.HOST_SERVICE_PROTOCOL_VERSION + ? Number(process.env.HOST_SERVICE_PROTOCOL_VERSION) + : null; const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; const auth = @@ -37,16 +41,26 @@ const { app, injectWebSocket } = createApp({ dbPath, deviceClientId, deviceName, + serviceVersion, + protocolVersion, allowedOrigins: [ `http://localhost:${desktopVitePort}`, `http://127.0.0.1:${desktopVitePort}`, ], }); +const startedAt = Date.now(); + const server = serve( { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, (info: { port: number }) => { - process.send?.({ type: "ready", port: info.port }); + process.send?.({ + type: "ready", + port: info.port, + serviceVersion, + protocolVersion, + startedAt, + }); }, ); injectWebSocket(server); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 1d95ca60805..ecd4bfd4071 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -267,6 +267,15 @@ export function setupAutoUpdater(): void { `[auto-updater] Update downloaded: ${app.getVersion()} → ${info.version}. Ready to install.`, ); emitStatus(AUTO_UPDATE_STATUS.READY, info.version); + + // After an app update is ready, check if running host-service instances + // will need a restart once the new version is installed. + try { + const { getHostServiceManager } = require("../host-service-manager"); + getHostServiceManager().checkAllCompatibility(); + } catch { + // Host service manager may not be initialized yet + } }); const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL_MS); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index b70099981fc..d746b3aac33 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -1,13 +1,42 @@ import type { ChildProcess } from "node:child_process"; import * as childProcess from "node:child_process"; import { randomBytes } from "node:crypto"; +import { EventEmitter } from "node:events"; import path from "node:path"; import { app } from "electron"; import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; import { SUPERSET_HOME_DIR } from "./app-environment"; import { getDeviceName, getHashedDeviceId } from "./device-info"; -type HostServiceStatus = "starting" | "running" | "crashed"; +export type HostServiceStatus = + | "starting" + | "running" + | "degraded" + | "restarting" + | "stopped"; + +export type CompatibilityResult = + | { compatible: true; updateAvailable: boolean } + | { compatible: false; reason: string }; + +export interface HostServiceInfo { + organizationId: string; + status: HostServiceStatus; + port: number | null; + serviceVersion: string | null; + protocolVersion: number | null; + startedAt: number | null; + uptime: number | null; + restartCount: number; + pendingRestart: boolean; + compatibility: CompatibilityResult | null; +} + +export interface HostServiceStatusEvent { + organizationId: string; + status: HostServiceStatus; + previousStatus: HostServiceStatus | null; +} interface HostServiceProcess { process: ChildProcess | null; @@ -17,6 +46,10 @@ interface HostServiceProcess { restartCount: number; lastCrash?: number; organizationId: string; + startedAt: number | null; + serviceVersion: string | null; + protocolVersion: number | null; + pendingRestart: boolean; } interface PendingStart { @@ -30,6 +63,11 @@ interface PendingStart { const MAX_RESTART_DELAY = 30_000; const BASE_RESTART_DELAY = 1_000; +/** Protocol version for the IPC contract between ElectronMain and HostService. + * Bump this whenever the ready message shape, env contract, or health API + * changes in a backwards-incompatible way. */ +export const HOST_SERVICE_PROTOCOL_VERSION = 1; + function createPortDeferred(): { promise: Promise; resolve: (port: number) => void; @@ -45,9 +83,10 @@ function createPortDeferred(): { return { promise, resolve, reject }; } -export class HostServiceManager { +export class HostServiceManager extends EventEmitter { private instances = new Map(); private pendingStarts = new Map(); + private scheduledRestarts = new Map>(); private scriptPath = path.join(__dirname, "host-service.js"); private authToken: string | null = null; private cloudApiUrl: string | null = null; @@ -70,17 +109,24 @@ export class HostServiceManager { return pendingStart.promise; } + // Cancel any scheduled restart since we're starting explicitly + this.cancelScheduledRestart(organizationId); + return this.spawn(organizationId); } stop(organizationId: string): void { const instance = this.instances.get(organizationId); + this.cancelScheduledRestart(organizationId); + this.cancelPendingStart(organizationId, new Error("Host service stopped")); + if (!instance) return; - instance.status = "crashed"; // prevent restart - this.cancelPendingStart(organizationId, new Error("Host service stopped")); + const previousStatus = instance.status; + instance.status = "stopped"; instance.process?.kill("SIGTERM"); this.instances.delete(organizationId); + this.emitStatus(organizationId, "stopped", previousStatus); } stopAll(): void { @@ -89,6 +135,25 @@ export class HostServiceManager { } } + async restart(organizationId: string): Promise { + const instance = this.instances.get(organizationId); + if (instance) { + const previousStatus = instance.status; + instance.status = "restarting"; + this.emitStatus(organizationId, "restarting", previousStatus); + + this.cancelScheduledRestart(organizationId); + this.cancelPendingStart( + organizationId, + new Error("Host service restarting"), + ); + instance.process?.kill("SIGTERM"); + this.instances.delete(organizationId); + } + + return this.spawn(organizationId); + } + getPort(organizationId: string): number | null { return this.instances.get(organizationId)?.port ?? null; } @@ -97,26 +162,134 @@ export class HostServiceManager { return this.instances.get(organizationId)?.secret ?? null; } - getStatus(organizationId: string): HostServiceStatus | null { + getStatus(organizationId: string): HostServiceStatus { if (this.pendingStarts.has(organizationId)) { return "starting"; } - return this.instances.get(organizationId)?.status ?? null; + return this.instances.get(organizationId)?.status ?? "stopped"; + } + + getServiceInfo(organizationId: string): HostServiceInfo { + const instance = this.instances.get(organizationId); + if (!instance) { + return { + organizationId, + status: this.pendingStarts.has(organizationId) ? "starting" : "stopped", + port: null, + serviceVersion: null, + protocolVersion: null, + startedAt: null, + uptime: null, + restartCount: 0, + pendingRestart: false, + compatibility: null, + }; + } + + return { + organizationId, + status: instance.status, + port: instance.port, + serviceVersion: instance.serviceVersion, + protocolVersion: instance.protocolVersion, + startedAt: instance.startedAt, + uptime: instance.startedAt + ? Math.floor((Date.now() - instance.startedAt) / 1000) + : null, + restartCount: instance.restartCount, + pendingRestart: instance.pendingRestart, + compatibility: this.checkCompatibility(instance), + }; + } + + /** Returns true if any instance is in running or starting state */ + hasActiveInstances(): boolean { + for (const instance of this.instances.values()) { + if (instance.status === "running" || instance.status === "starting") { + return true; + } + } + return this.pendingStarts.size > 0; + } + + /** Returns all organization IDs with active host-service instances */ + getActiveOrganizationIds(): string[] { + const ids: string[] = []; + for (const [id, instance] of this.instances) { + if (instance.status !== "stopped") { + ids.push(id); + } + } + return ids; + } + + /** Check whether a running host-service is compatible with this app version. + * - protocol match + same version = compatible, no update + * - protocol match + older service = compatible, update available + * - protocol mismatch = incompatible, restart required */ + checkCompatibility( + instance: Pick, + ): CompatibilityResult | null { + if (instance.protocolVersion === null) return null; + + if (instance.protocolVersion !== HOST_SERVICE_PROTOCOL_VERSION) { + return { + compatible: false, + reason: `Protocol mismatch: service=${instance.protocolVersion}, app=${HOST_SERVICE_PROTOCOL_VERSION}`, + }; + } + + const currentVersion = app.getVersion(); + const updateAvailable = + instance.serviceVersion !== null && + instance.serviceVersion !== currentVersion; + + return { compatible: true, updateAvailable }; + } + + /** Mark a host-service instance for restart when it becomes idle. */ + markPendingRestart(organizationId: string): void { + const instance = this.instances.get(organizationId); + if (!instance) return; + instance.pendingRestart = true; + this.emitStatus(organizationId, instance.status, instance.status); + } + + /** Check all instances for compatibility and mark incompatible ones for restart. */ + checkAllCompatibility(): void { + for (const [orgId, instance] of this.instances) { + if (instance.status !== "running") continue; + const result = this.checkCompatibility(instance); + if (result && !result.compatible) { + console.log(`[host-service:${orgId}] Incompatible: ${result.reason}`); + instance.pendingRestart = true; + this.emitStatus(orgId, instance.status, instance.status); + } + } } private async spawn(organizationId: string): Promise { const pendingStart = createPortDeferred(); const secret = randomBytes(32).toString("hex"); + + const previousInstance = this.instances.get(organizationId); + const restartCount = previousInstance?.restartCount ?? 0; + const instance: HostServiceProcess = { process: null, port: null, secret, status: "starting", - restartCount: 0, + restartCount, organizationId, + startedAt: null, + serviceVersion: null, + protocolVersion: null, + pendingRestart: false, }; this.instances.set(organizationId, instance); this.pendingStarts.set(organizationId, pendingStart); + this.emitStatus(organizationId, "starting", null); try { const env = await this.buildHostServiceEnv(organizationId, secret); @@ -169,6 +342,8 @@ export class HostServiceManager { DEVICE_CLIENT_ID: getHashedDeviceId(), DEVICE_NAME: getDeviceName(), HOST_SERVICE_SECRET: secret, + HOST_SERVICE_VERSION: app.getVersion(), + HOST_SERVICE_PROTOCOL_VERSION: String(HOST_SERVICE_PROTOCOL_VERSION), HOST_DB_PATH: path.join( SUPERSET_HOME_DIR, "host", @@ -203,7 +378,7 @@ export class HostServiceManager { if ( !current || current.process !== child || - current.status === "crashed" + current.status === "stopped" ) { return; } @@ -214,8 +389,17 @@ export class HostServiceManager { new Error("Host service exited before reporting port"), ); } - current.status = "crashed"; + + const previousStatus = current.status; + // If we were restarting, a new spawn is already in flight — don't + // schedule another restart or overwrite the status. + if (previousStatus === "restarting") { + return; + } + + current.status = "degraded"; current.lastCrash = Date.now(); + this.emitStatus(organizationId, "degraded", previousStatus); this.scheduleRestart(organizationId); }); } @@ -226,10 +410,12 @@ export class HostServiceManager { error: Error, ): void { this.clearPendingStart(instance.organizationId, pendingStart); - instance.status = "crashed"; + const previousStatus = instance.status; + instance.status = "degraded"; pendingStart.reject(error); instance.process?.kill("SIGTERM"); instance.lastCrash = Date.now(); + this.emitStatus(instance.organizationId, "degraded", previousStatus); this.scheduleRestart(instance.organizationId); } @@ -252,9 +438,37 @@ export class HostServiceManager { this.clearPendingStart(instance.organizationId, pendingStart); instance.port = message.port; instance.status = "running"; + instance.startedAt = Date.now(); + instance.restartCount = 0; + + // Pick up version info from the ready message if available + if ( + "serviceVersion" in message && + typeof message.serviceVersion === "string" + ) { + instance.serviceVersion = message.serviceVersion; + } + if ( + "protocolVersion" in message && + typeof message.protocolVersion === "number" + ) { + instance.protocolVersion = message.protocolVersion; + } + console.log( - `[host-service:${instance.organizationId}] listening on port ${message.port}`, + `[host-service:${instance.organizationId}] listening on port ${message.port} (v${instance.serviceVersion}, protocol=${instance.protocolVersion})`, ); + + // Check compatibility on connect + const compat = this.checkCompatibility(instance); + if (compat && !compat.compatible) { + console.warn( + `[host-service:${instance.organizationId}] ${compat.reason} — marking for restart`, + ); + instance.pendingRestart = true; + } + + this.emitStatus(instance.organizationId, "running", "starting"); pendingStart.resolve(message.port); }; @@ -296,10 +510,20 @@ export class HostServiceManager { } } + private cancelScheduledRestart(organizationId: string): void { + const timer = this.scheduledRestarts.get(organizationId); + if (timer) { + clearTimeout(timer); + this.scheduledRestarts.delete(organizationId); + } + } + private scheduleRestart(organizationId: string): void { const instance = this.instances.get(organizationId); if (!instance) return; + this.cancelScheduledRestart(organizationId); + const delay = Math.min( BASE_RESTART_DELAY * 2 ** instance.restartCount, MAX_RESTART_DELAY, @@ -310,9 +534,10 @@ export class HostServiceManager { `[host-service:${organizationId}] restarting in ${delay}ms (attempt ${instance.restartCount})`, ); - setTimeout(() => { + const timer = setTimeout(() => { + this.scheduledRestarts.delete(organizationId); const current = this.instances.get(organizationId); - if (current?.status === "crashed") { + if (current?.status === "degraded") { this.instances.delete(organizationId); this.spawn(organizationId).catch((err) => { console.error( @@ -322,6 +547,20 @@ export class HostServiceManager { }); } }, delay); + this.scheduledRestarts.set(organizationId, timer); + } + + private emitStatus( + organizationId: string, + status: HostServiceStatus, + previousStatus: HostServiceStatus | null, + ): void { + const event: HostServiceStatusEvent = { + organizationId, + status, + previousStatus, + }; + this.emit("status-changed", event); } } diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 0ab352c1fc2..8bcb280285a 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -1,24 +1,19 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { workspaces } from "@superset/local-db"; -import { eq } from "drizzle-orm"; import { app, BrowserWindow, - dialog, Menu, type MenuItemConstructorOptions, nativeImage, Tray, } from "electron"; -import { localDb } from "main/lib/local-db"; -import { menuEmitter } from "main/lib/menu-events"; import { - restartDaemon as restartDaemonShared, - tryListExistingDaemonSessions, -} from "main/lib/terminal"; -import { getTerminalHostClient } from "main/lib/terminal-host/client"; -import type { ListSessionsResponse } from "main/lib/terminal-host/types"; + getHostServiceManager, + type HostServiceStatus, + type HostServiceStatusEvent, +} from "main/lib/host-service-manager"; +import { menuEmitter } from "main/lib/menu-events"; const POLL_INTERVAL_MS = 5000; @@ -106,180 +101,133 @@ function openSettings(): void { menuEmitter.emit("open-settings"); } -function openTerminalSettings(): void { - showWindow(); - menuEmitter.emit("open-settings", "terminal"); -} - -function openSessionInSuperset(workspaceId: string): void { - showWindow(); - menuEmitter.emit("open-workspace", workspaceId); -} - -async function killSession(paneId: string): Promise { - try { - const client = getTerminalHostClient(); - const connected = await client.tryConnectAndAuthenticate(); - if (connected) { - await client.kill({ sessionId: paneId }); - console.log(`[Tray] Killed session: ${paneId}`); - } - } catch (error) { - console.error(`[Tray] Failed to kill session ${paneId}:`, error); +function formatStatusLabel(status: HostServiceStatus): string { + switch (status) { + case "running": + return "Running"; + case "starting": + return "Starting..."; + case "degraded": + return "Degraded"; + case "restarting": + return "Restarting..."; + case "stopped": + return "Stopped"; } - - await updateTrayMenu(); } -function getWorkspaceName(workspaceId: string): string { - try { - const workspace = localDb - .select({ name: workspaces.name }) - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .get(); - return workspace?.name || workspaceId.slice(0, 8); - } catch { - return workspaceId.slice(0, 8); - } -} - -function formatSessionLabel( - session: ListSessionsResponse["sessions"][0], -): string { - const attached = session.attachedClients > 0 ? " (attached)" : ""; - const shellName = session.shell?.split("/").pop() || "shell"; - return `${shellName}${attached}`; -} - -function buildSessionsSubmenu( - sessions: ListSessionsResponse["sessions"], -): MenuItemConstructorOptions[] { - const aliveSessions = sessions.filter((s) => s.isAlive); +function buildHostServiceSubmenu(): MenuItemConstructorOptions[] { + const manager = getHostServiceManager(); + const orgIds = manager.getActiveOrganizationIds(); const menuItems: MenuItemConstructorOptions[] = []; - if (aliveSessions.length === 0) { - menuItems.push({ label: "No active sessions", enabled: false }); + if (orgIds.length === 0) { + menuItems.push({ label: "No active services", enabled: false }); } else { - const byWorkspace = new Map(); - for (const session of aliveSessions) { - const existing = byWorkspace.get(session.workspaceId) || []; - existing.push(session); - byWorkspace.set(session.workspaceId, existing); - } + for (const orgId of orgIds) { + const info = manager.getServiceInfo(orgId); + const statusLabel = formatStatusLabel(info.status); + const versionSuffix = info.serviceVersion + ? ` (v${info.serviceVersion})` + : ""; - let isFirst = true; - for (const [workspaceId, workspaceSessions] of byWorkspace) { - const workspaceName = getWorkspaceName(workspaceId); - - if (!isFirst) { - menuItems.push({ type: "separator" }); - } menuItems.push({ - label: workspaceName, + label: `${statusLabel}${versionSuffix}`, enabled: false, }); - for (const session of workspaceSessions) { + if (info.uptime !== null) { + const uptimeStr = formatUptime(info.uptime); + menuItems.push({ + label: ` Uptime: ${uptimeStr}`, + enabled: false, + }); + } + + if (info.restartCount > 0) { menuItems.push({ - label: formatSessionLabel(session), - submenu: [ - { - label: "Open in Superset", - click: () => openSessionInSuperset(session.workspaceId), - }, - { - label: "Kill", - click: () => killSession(session.paneId), - }, - ], + label: ` Restarts: ${info.restartCount}`, + enabled: false, }); } - isFirst = false; + if (info.pendingRestart) { + menuItems.push({ + label: " Update required — restart to apply", + enabled: false, + }); + } else if ( + info.compatibility && + "updateAvailable" in info.compatibility && + info.compatibility.updateAvailable + ) { + menuItems.push({ + label: " Update available", + enabled: false, + }); + } } } menuItems.push({ type: "separator" }); - menuItems.push({ - label: "Terminal Settings", - click: openTerminalSettings, - }); - - return menuItems; -} -async function quitApp(): Promise { - const { sessions } = await tryListExistingDaemonSessions(); - const hasActiveSessions = sessions.some((s) => s.isAlive); + const hasRunning = orgIds.some((id) => manager.getStatus(id) === "running"); - if (!hasActiveSessions) { - app.quit(); - return; - } - - const { response } = await dialog.showMessageBox({ - type: "question", - buttons: ["Cancel", "Keep Sessions", "Kill Sessions"], - defaultId: 1, - cancelId: 0, - title: "Quit Superset?", - message: "Quit Superset?", - detail: - "Keep sessions running in the background, or kill all sessions and shut down the daemon?", + menuItems.push({ + label: "Restart Host Service", + enabled: hasRunning, + click: () => { + for (const orgId of orgIds) { + if (manager.getStatus(orgId) === "running") { + manager.restart(orgId).catch((err) => { + console.error( + `[Tray] Failed to restart host-service for ${orgId}:`, + err, + ); + }); + } + } + updateTrayMenu(); + }, }); - if (response === 0) { - return; - } + menuItems.push({ + label: "Stop Host Service", + enabled: hasRunning, + click: () => { + manager.stopAll(); + updateTrayMenu(); + }, + }); - if (response === 2) { - try { - await restartDaemonShared(); - } catch (error) { - console.warn( - "[Tray] Failed to restart terminal daemon during quit:", - error, - ); - await dialog - .showMessageBox({ - type: "error", - buttons: ["OK"], - defaultId: 0, - title: "Failed to kill sessions", - message: "Superset could not kill terminal sessions.", - detail: - "The app will stay open so you can retry or quit while keeping sessions running in the background.", - }) - .catch((dialogError) => { - console.warn( - "[Tray] Failed to show terminal quit error dialog:", - dialogError, - ); - }); - return; - } - } + return menuItems; +} - app.quit(); +function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; } -async function updateTrayMenu(): Promise { +function updateTrayMenu(): void { if (!tray) return; - const { sessions } = await tryListExistingDaemonSessions(); - const sessionCount = sessions.filter((s) => s.isAlive).length; + const manager = getHostServiceManager(); + const orgIds = manager.getActiveOrganizationIds(); + + const hasActive = orgIds.length > 0; + const hostServiceLabel = hasActive + ? `Host Service (${orgIds.length})` + : "Host Service"; - const sessionsSubmenu = buildSessionsSubmenu(sessions); - const sessionsLabel = - sessionCount > 0 - ? `Background Sessions (${sessionCount})` - : "Background Sessions"; + const hostServiceSubmenu = buildHostServiceSubmenu(); const menu = Menu.buildFromTemplate([ { - label: sessionsLabel, - submenu: sessionsSubmenu, + label: hostServiceLabel, + submenu: hostServiceSubmenu, }, { type: "separator" }, { @@ -290,9 +238,18 @@ async function updateTrayMenu(): Promise { label: "Settings", click: openSettings, }, + { + label: "Check for Updates", + click: () => { + // Imported lazily to avoid circular dependency + const { checkForUpdatesInteractive } = require("../auto-updater"); + checkForUpdatesInteractive(); + }, + }, + { type: "separator" }, { label: "Quit", - click: quitApp, + click: () => app.quit(), }, ]); @@ -320,14 +277,17 @@ export function initTray(): void { tray = new Tray(icon); tray.setToolTip("Superset"); - updateTrayMenu().catch((error) => { - console.error("[Tray] Failed to build initial menu:", error); + updateTrayMenu(); + + // Rebuild menu on host-service status changes + const manager = getHostServiceManager(); + manager.on("status-changed", (_event: HostServiceStatusEvent) => { + updateTrayMenu(); }); + // Periodic refresh as a fallback pollIntervalId = setInterval(() => { - updateTrayMenu().catch((error) => { - console.error("[Tray] Failed to update menu:", error); - }); + updateTrayMenu(); }, POLL_INTERVAL_MS); // Don't keep Electron alive just for tray updates pollIntervalId.unref(); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index e522a0b8304..1e8925fb55a 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -15,6 +15,14 @@ export interface TerminalTransport { currentUrl: string | null; onDataDisposable: { dispose(): void } | null; stateListeners: Set<() => void>; + /** Internal: auto-reconnect timer. */ + _reconnectTimer: ReturnType | null; + /** Internal: reconnect attempt count for backoff. */ + _reconnectAttempt: number; + /** The xterm instance used for reconnection. */ + _terminal: XTerm | null; + /** Set when the server sends an exit message — no reconnect after this. */ + _exited: boolean; } function setConnectionState( @@ -27,6 +35,10 @@ function setConnectionState( } } +const MAX_RECONNECT_DELAY = 10_000; +const BASE_RECONNECT_DELAY = 500; +const MAX_RECONNECT_ATTEMPTS = 10; + export function createTransport(): TerminalTransport { return { socket: null, @@ -34,9 +46,44 @@ export function createTransport(): TerminalTransport { currentUrl: null, onDataDisposable: null, stateListeners: new Set(), + _reconnectTimer: null, + _reconnectAttempt: 0, + _terminal: null, + _exited: false, }; } +function scheduleReconnect(transport: TerminalTransport) { + if (transport._reconnectTimer) return; + if (transport._exited) return; + if (!transport.currentUrl || !transport._terminal) return; + if (transport._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) return; + + const delay = Math.min( + BASE_RECONNECT_DELAY * 2 ** transport._reconnectAttempt, + MAX_RECONNECT_DELAY, + ); + transport._reconnectAttempt++; + + transport._reconnectTimer = setTimeout(() => { + transport._reconnectTimer = null; + if ( + transport.connectionState === "closed" && + transport.currentUrl && + transport._terminal + ) { + connect(transport, transport._terminal, transport.currentUrl); + } + }, delay); +} + +function cancelReconnect(transport: TerminalTransport) { + if (transport._reconnectTimer) { + clearTimeout(transport._reconnectTimer); + transport._reconnectTimer = null; + } +} + export function connect( transport: TerminalTransport, terminal: XTerm, @@ -53,13 +100,16 @@ export function connect( transport.socket = null; } + cancelReconnect(transport); transport.currentUrl = wsUrl; + transport._terminal = terminal; setConnectionState(transport, "connecting"); const socket = new WebSocket(wsUrl); transport.socket = socket; socket.addEventListener("open", () => { if (transport.socket !== socket) return; + transport._reconnectAttempt = 0; setConnectionState(transport, "open"); sendResize(transport, terminal.cols, terminal.rows); }); @@ -85,6 +135,8 @@ export function connect( } if (message.type === "exit") { + transport._exited = true; + cancelReconnect(transport); terminal.writeln( `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, ); @@ -95,6 +147,8 @@ export function connect( if (transport.socket !== socket) return; setConnectionState(transport, "closed"); transport.socket = null; + // Auto-reconnect on unexpected close (host-service restart, network blip) + scheduleReconnect(transport); }); socket.addEventListener("error", () => { @@ -110,11 +164,14 @@ export function connect( } export function disconnect(transport: TerminalTransport) { + cancelReconnect(transport); if (transport.socket) { transport.socket.close(); transport.socket = null; } transport.currentUrl = null; + transport._terminal = null; + transport._reconnectAttempt = 0; setConnectionState(transport, "disconnected"); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; @@ -137,11 +194,14 @@ export function sendDispose(transport: TerminalTransport) { } export function disposeTransport(transport: TerminalTransport) { + cancelReconnect(transport); if (transport.socket) { transport.socket.close(); transport.socket = null; } transport.currentUrl = null; + transport._terminal = null; + transport._reconnectAttempt = 0; transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; transport.stateListeners.clear(); diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 89da8f230f1..05aaedf05a1 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -33,6 +33,8 @@ export interface CreateAppOptions { dbPath?: string; deviceClientId?: string; deviceName?: string; + serviceVersion?: string | null; + protocolVersion?: number | null; allowedOrigins?: string[]; } @@ -133,6 +135,8 @@ export function createApp(options?: CreateAppOptions): CreateAppResult { runtime, deviceClientId: options?.deviceClientId ?? null, deviceName: options?.deviceName ?? null, + serviceVersion: options?.serviceVersion ?? null, + protocolVersion: options?.protocolVersion ?? null, isAuthenticated, } as Record; }, diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index bf97a5e5583..bb52ab16c12 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -229,6 +229,33 @@ export function registerWorkspaceTerminalRoute({ return c.json({ terminalId: result.terminalId, status: "active" }); }); + // REST dispose — does not require an open WebSocket + app.delete("/terminal/sessions/:terminalId", (c) => { + const terminalId = c.req.param("terminalId"); + if (!terminalId) { + return c.json({ error: "Missing terminalId" }, 400); + } + + const session = sessions.get(terminalId); + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + disposeSession(terminalId, db); + return c.json({ terminalId, status: "disposed" }); + }); + + // REST list — enumerate live terminal sessions + app.get("/terminal/sessions", (c) => { + const result = Array.from(sessions.values()).map((s) => ({ + terminalId: s.terminalId, + exited: s.exited, + exitCode: s.exitCode, + attached: s.socket !== null, + })); + return c.json({ sessions: result }); + }); + app.get( "/terminal/:terminalId", upgradeWebSocket((c) => { diff --git a/packages/host-service/src/trpc/router/health/health.ts b/packages/host-service/src/trpc/router/health/health.ts index a1a1d3d3c36..65353be7aa2 100644 --- a/packages/host-service/src/trpc/router/health/health.ts +++ b/packages/host-service/src/trpc/router/health/health.ts @@ -1,17 +1,23 @@ import os from "node:os"; import { publicProcedure, router } from "../../index"; +const processStartedAt = Date.now(); + export const healthRouter = router({ check: publicProcedure.query(() => { return { status: "ok" as const }; }), - info: publicProcedure.query(() => { + info: publicProcedure.query(({ ctx }) => { return { platform: os.platform(), arch: os.arch(), nodeVersion: process.version, uptime: process.uptime(), + serviceVersion: ctx.serviceVersion ?? null, + protocolVersion: ctx.protocolVersion ?? null, + organizationId: process.env.ORGANIZATION_ID ?? null, + startedAt: processStartedAt, }; }), }); diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index b988590a598..7088867583f 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -23,5 +23,7 @@ export interface HostServiceContext { runtime: HostServiceRuntime; deviceClientId: string | null; deviceName: string | null; + serviceVersion: string | null; + protocolVersion: number | null; isAuthenticated: boolean; } From 835b516bf208662d903740143eacba21a1374957 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 12:51:38 -0700 Subject: [PATCH 02/14] Read host service --- .../trpc/routers/host-service-manager/index.ts | 13 ++++++++++++- .../desktop/src/main/lib/host-service-manager.ts | 13 +++++++++++++ apps/desktop/src/main/lib/tray/index.ts | 14 +++++++++++++- .../HostServiceProvider/HostServiceProvider.tsx | 16 +++++++++++++--- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts index c96ce97efb7..005a5100837 100644 --- a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts +++ b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts @@ -11,7 +11,12 @@ import { loadToken } from "../auth/utils/auth-functions"; export const createHostServiceManagerRouter = () => { return router({ getLocalPort: publicProcedure - .input(z.object({ organizationId: z.string() })) + .input( + z.object({ + organizationId: z.string(), + organizationName: z.string().optional(), + }), + ) .query(async ({ input }) => { const manager = getHostServiceManager(); const { token } = await loadToken(); @@ -19,6 +24,12 @@ export const createHostServiceManagerRouter = () => { manager.setAuthToken(token); } manager.setCloudApiUrl(env.NEXT_PUBLIC_API_URL); + if (input.organizationName) { + manager.setOrganizationName( + input.organizationId, + input.organizationName, + ); + } const port = await manager.start(input.organizationId); const secret = manager.getSecret(input.organizationId); return { port, secret }; diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index d746b3aac33..1a3496cc791 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -21,6 +21,7 @@ export type CompatibilityResult = export interface HostServiceInfo { organizationId: string; + organizationName: string | null; status: HostServiceStatus; port: number | null; serviceVersion: string | null; @@ -87,6 +88,7 @@ export class HostServiceManager extends EventEmitter { private instances = new Map(); private pendingStarts = new Map(); private scheduledRestarts = new Map>(); + private organizationNames = new Map(); private scriptPath = path.join(__dirname, "host-service.js"); private authToken: string | null = null; private cloudApiUrl: string | null = null; @@ -99,6 +101,14 @@ export class HostServiceManager extends EventEmitter { this.cloudApiUrl = url; } + setOrganizationName(organizationId: string, name: string): void { + this.organizationNames.set(organizationId, name); + } + + getOrganizationName(organizationId: string): string | null { + return this.organizationNames.get(organizationId) ?? null; + } + async start(organizationId: string): Promise { const existing = this.instances.get(organizationId); if (existing?.status === "running" && existing.port !== null) { @@ -170,10 +180,12 @@ export class HostServiceManager extends EventEmitter { } getServiceInfo(organizationId: string): HostServiceInfo { + const organizationName = this.getOrganizationName(organizationId); const instance = this.instances.get(organizationId); if (!instance) { return { organizationId, + organizationName, status: this.pendingStarts.has(organizationId) ? "starting" : "stopped", port: null, serviceVersion: null, @@ -188,6 +200,7 @@ export class HostServiceManager extends EventEmitter { return { organizationId, + organizationName, status: instance.status, port: instance.port, serviceVersion: instance.serviceVersion, diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 8bcb280285a..45daf6652b6 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -124,15 +124,27 @@ function buildHostServiceSubmenu(): MenuItemConstructorOptions[] { if (orgIds.length === 0) { menuItems.push({ label: "No active services", enabled: false }); } else { + let isFirst = true; for (const orgId of orgIds) { + if (!isFirst) { + menuItems.push({ type: "separator" }); + } + isFirst = false; + const info = manager.getServiceInfo(orgId); + const orgName = info.organizationName ?? orgId.slice(0, 8); const statusLabel = formatStatusLabel(info.status); const versionSuffix = info.serviceVersion ? ` (v${info.serviceVersion})` : ""; menuItems.push({ - label: `${statusLabel}${versionSuffix}`, + label: orgName, + enabled: false, + }); + + menuItems.push({ + label: ` ${statusLabel}${versionSuffix}`, enabled: false, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx index e368a46a88c..395f9febcd2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx @@ -52,8 +52,12 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { // Start a host service for every org useEffect(() => { for (const orgId of orgIds) { + const org = organizations?.find((o) => o.id === orgId); utils.hostServiceManager.getLocalPort - .ensureData({ organizationId: orgId }) + .ensureData({ + organizationId: orgId, + organizationName: org?.name ?? undefined, + }) .catch((err) => { console.error( `[host-service] Failed to start for org ${orgId}:`, @@ -61,12 +65,18 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { ); }); } - }, [orgIds, utils]); + }, [orgIds, organizations, utils]); // Query the active org's port reactively + const activeOrgName = organizations?.find( + (o) => o.id === activeOrganizationId, + )?.name; const { data: activePortData } = electronTrpc.hostServiceManager.getLocalPort.useQuery( - { organizationId: activeOrganizationId as string }, + { + organizationId: activeOrganizationId as string, + organizationName: activeOrgName ?? undefined, + }, { enabled: !!activeOrganizationId }, ); From 6c504afcfc0f3cd1c0cf4d581b83fad08a9c40a4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 23:03:40 -0700 Subject: [PATCH 03/14] Working long living host service --- apps/desktop/HOST_SERVICE_LIFECYCLE.md | 394 ++++++++++++++++++ .../lib/electron-app/factories/app/setup.ts | 22 +- apps/desktop/src/main/host-service/index.ts | 74 +++- apps/desktop/src/main/index.ts | 47 ++- .../src/main/lib/host-service-manager.test.ts | 4 + .../src/main/lib/host-service-manager.ts | 242 ++++++++++- .../src/main/lib/host-service-manifest.ts | 109 +++++ apps/desktop/src/main/lib/tray/index.ts | 75 ++-- apps/desktop/src/main/windows/main.ts | 13 +- 9 files changed, 909 insertions(+), 71 deletions(-) create mode 100644 apps/desktop/HOST_SERVICE_LIFECYCLE.md create mode 100644 apps/desktop/src/main/lib/host-service-manifest.ts diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/HOST_SERVICE_LIFECYCLE.md new file mode 100644 index 00000000000..d0010f41a5d --- /dev/null +++ b/apps/desktop/HOST_SERVICE_LIFECYCLE.md @@ -0,0 +1,394 @@ +# Host Service Lifecycle: Current State And Electron Benchmarks + +Date: 2026-04-03 + +## Purpose + +This document replaces the scattered plan notes with one view of: + +- the current desktop lifecycle shape in this codebase +- the important gap between that shape and the desired tray UX +- how other Electron apps handle similar lifecycle boundaries +- the architectural consequence for Superset + +The product requirement assumed here is: + +- local services should keep running when the UI closes +- the tray should remain available while those local services are alive +- the tray should be able to reopen the UI +- `Quit` from the tray should stop all local services and exit everything + +## Current State Of The Superset Desktop Codebase + +### 1. Electron `main` still owns app lifecycle today + +Today, app lifecycle is still centered in the window-owning Electron main +process. + +- `apps/desktop/src/main/index.ts` + - `before-quit` confirms quit, then calls `getHostServiceManager().stopAll()`, + disposes the tray, and exits the app +- `apps/desktop/src/lib/electron-app/factories/app/setup.ts` + - `window-all-closed` quits the app on non-macOS + - on macOS, the app remains alive after the last window closes + +That means the current app can already keep the process alive without windows on +macOS, but it is still one process owning: + +- windows +- tray +- quit policy +- host-service startup and shutdown + +It is not yet split into a durable desktop shell and a disposable UI process. + +### 2. The current tray is daemon-oriented, not host-service-oriented + +The tray implementation is still tied to the legacy terminal daemon model. + +- `apps/desktop/src/main/lib/tray/index.ts` + - polls daemon sessions with `tryListExistingDaemonSessions()` + - shows "Keep Sessions" vs "Kill Sessions" + - calls `restartDaemonShared()` to kill daemon-backed sessions + - is only initialized on macOS + +So the tray today is not a host-service control surface. It is a terminal daemon +control surface that happens to live in Electron main. + +### 3. `HostService` is process-separated, but still parent-owned + +`HostService` is a child process of Electron main, not an independently owned +background service. + +- `apps/desktop/src/main/lib/host-service-manager.ts` + - spawns `host-service.js` with stdio + IPC + - waits for a `ready` IPC message containing the port + - restarts crashed children +- `apps/desktop/src/main/host-service/index.ts` + - reports the port back via `process.send` + - exits on `SIGTERM` and `SIGINT` + - polls `process.ppid` and shuts down when the parent dies + +This is important: the code already has process separation, but it does not yet +have lifecycle separation. If Electron main exits, the tray dies and +`HostService` also dies. + +### 4. The renderer eagerly starts host-service, but only v2 local really depends on it + +The authenticated renderer currently starts host-service per organization: + +- `apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx` + - calls `utils.hostServiceManager.getLocalPort.ensureData(...)` for every org + - builds a map of org id to local host-service URL/client + +That gives the renderer a host-service connection surface, but the user-facing +benefit is not uniform across the app. + +### 5. v1 and v2 still have different runtime owners + +This is the most important current-state fact. + +#### v1 + +v1 terminals still run on the legacy Electron-owned stack. + +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` + - subscribes to `electronTrpc.terminal.stream` +- `apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SessionsSection.tsx` + - manages daemon sessions + - exposes "Kill all sessions" and "Restart daemon" + +The current tray and terminal settings UX still line up with v1. + +#### v2 local + +v2 local already treats host-service as the runtime boundary. + +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx` + - resolves a local host URL from `useHostService()` for local workspaces +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx` + - attaches a terminal runtime to `/terminal/:terminalId` over websocket + - detaches on unmount instead of immediately killing the runtime +- `apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts` + - disposes terminal runtimes only when their ids disappear from persisted pane + state +- `packages/host-service/src/terminal/terminal.ts` + - keeps PTY lifetime independent of socket lifetime + - allows detach and reattach to an existing terminal id + +This is much closer to the desired long-lived runtime model. + +### 6. The current mismatch + +Putting the pieces together: + +- app lifecycle is still owned by Electron main +- tray still reflects the legacy daemon +- host-service is still parent-owned +- v1 and v2 use different runtime owners +- v2 local is the only place where host-service persistence is already paying + off architecturally + +So the codebase is currently between two architectures: + +1. legacy app-owned runtime with daemon-oriented persistence +2. host-service-oriented runtime for v2 local + +That is why lifecycle work feels tangled right now. + +## What The Desired UX Actually Requires + +The requested UX is stricter than "keep the app alive with no windows." + +It requires: + +- tray survives UI process exit +- local services survive UI process exit +- tray `Quit` is the authoritative "stop services and exit everything" action + +That is not the same as the usual Electron pattern of: + +- keep Electron main alive +- hide the last window +- show a tray icon + +That simpler pattern is enough when the tray and background work are allowed to +die with the app process. It is not enough when the UI process is supposed to be +disposable while the tray and local services continue. + +## How Other Electron Apps Handle Lifecycle + +### 1. Electron Platform Baseline + +Electron itself makes the core rule explicit: + +- if you do not subscribe to `window-all-closed`, Electron quits by default +- if you do subscribe, you own the quit policy +- `Tray` is a main-process API + +That gives two baseline implications: + +1. a tray normally lives in the process that owns Electron main lifecycle +2. a tray does not outlive the process that owns Electron main lifecycle + +Source: + +- Electron `app` docs: + `https://www.electronjs.org/docs/latest/api/app/` +- Electron `Tray` docs: + `https://www.electronjs.org/docs/latest/api/tray/` + +### 2. GitHub Desktop: Main Process Owns Everything + +GitHub Desktop is an example of the classic Electron model: + +- one main app process owns lifecycle +- it explicitly overrides `window-all-closed` +- it controls visibility and quit behavior from that process + +In `app/src/main-process/main.ts`, GitHub Desktop subscribes to +`window-all-closed` specifically so Electron does not auto-quit before the app's +own window-close logic decides what to do. + +This is a good example of: + +- app-owned lifecycle +- no separate supervisor +- no durable tray/service owner outside the main app process + +Takeaway for Superset: + +- this is a good fit if "background mode" only means "keep the main app alive" +- it is not enough if we want the UI process to be disposable while tray and + local services continue + +Source: + +- GitHub Desktop main process: + `https://raw.githubusercontent.com/desktop/desktop/development/app/src/main-process/main.ts` + +### 3. Element Desktop: Tray Works By Keeping The App Process Alive + +Element Desktop is a useful tray example. + +In `src/electron-main.ts`: + +- when the user closes the main window and the app is not quitting, it hides + the window if a tray exists +- `before-quit` marks that the app is really quitting +- `window-all-closed` then quits the app + +This is the common tray pattern in Electron apps: + +- close window -> hide to tray +- explicit quit -> actually exit + +Takeaway for Superset: + +- this pattern is good for "close window, keep running in tray" +- it still depends on the same long-lived Electron app process +- the tray does not survive app-process exit + +Source: + +- Element Desktop main process: + `https://raw.githubusercontent.com/element-hq/element-desktop/develop/src/electron-main.ts` + +### 4. VS Code: Main Lifecycle Plus Separate Helper Processes + +VS Code does not use a tray-first UX, but it is still the most useful benchmark +for process boundaries. + +VS Code keeps application lifecycle in the main Electron process, but moves +specific long-lived domains into helper processes: + +- `SharedProcess` + - a utility process for shared services used across windows +- `pty-host` + - a dedicated process for terminal PTYs + +Important detail: those helper processes are still under main-process lifecycle. +On shutdown, VS Code's lifecycle service tells them to exit. + +This is not a supervisor architecture. It is: + +- main process owns lifecycle +- helper processes own specific runtime domains +- windows connect to those processes through IPC/message ports + +Takeaway for Superset: + +- VS Code is a strong benchmark for separating runtime ownership from renderer + ownership +- it is not a benchmark for "tray survives UI process exit" +- it shows the value of a dedicated runtime owner like `pty-host`, but not the + value of putting shell UX into that runtime owner + +Sources: + +- VS Code lifecycle service: + `https://raw.githubusercontent.com/microsoft/vscode/main/src/vs/platform/lifecycle/electron-main/lifecycleMainService.ts` +- VS Code shared process: + `https://raw.githubusercontent.com/microsoft/vscode/main/src/vs/platform/sharedProcess/electron-main/sharedProcess.ts` +- VS Code pty host starter: + `https://raw.githubusercontent.com/microsoft/vscode/main/src/vs/platform/terminal/electron-main/electronPtyHostStarter.ts` +- VS Code main app: + `https://raw.githubusercontent.com/microsoft/vscode/main/src/vs/code/electron-main/app.ts` + +## Pattern Summary + +| Pattern | Example | What owns tray | What owns runtime helpers | What happens when app process exits | +| --- | --- | --- | --- | --- | +| Single main-process owner | GitHub Desktop | main process | main process or children | tray and helpers die | +| Tray hide/minimize pattern | Element Desktop | main process | main process or children | tray and helpers die | +| Main + helper processes | VS Code | no tray focus | helper processes for domains like shared services and PTYs | helpers are shut down by main | +| Separate supervisor + runtime service | not the common default Electron pattern | supervisor | separate runtime service | tray can survive UI process exit if supervisor stays alive | + +The key point is that the first three patterns all keep tray ownership in the +desktop shell process, not inside the runtime helper. + +## What This Means For Superset + +### 1. Do Not Put The Tray Inside `HostService` + +The external benchmarks do not support putting shell UX into the runtime owner. + +Why: + +- Electron tray APIs are main-process shell APIs +- tray behavior is about desktop lifecycle policy, not workspace runtime state +- runtime services should stay reusable and headless +- restarting the runtime service should not imply restarting the tray shell + +So `HostService` should remain a headless runtime owner. + +### 2. A Supervisor Process Is The Right Fit For The Desired UX + +Given the stated requirement, the clean split is: + +- `BackgroundSupervisor` + - owns tray + - owns `Quit` + - owns `Open Superset` + - owns host-service discovery/adoption/restart +- `HostService` + - owns long-lived local runtime state + - owns terminal and future local services + - remains headless +- UI process + - owns windows only + - can exit and relaunch without changing service lifetime + +This is different from the current codebase, where Electron main still owns all +three roles. + +### 3. v2 Local Should Be The First-Class Migration Target + +The current codebase already points in this direction: + +- v2 local already depends on host-service as its runtime boundary +- v2 terminal panes already use attach/detach semantics +- global terminal disposal in v2 is already based on persisted pane state, not + immediate React unmount + +By contrast: + +- v1 still uses Electron-owned terminal runtime and daemon-centric UX +- tray and settings still describe daemon sessions, not host-service services + +So the least risky migration story is: + +1. make supervisor + host-service correct for v2 local +2. keep v1 explicit as a compatibility path during migration +3. retire or migrate v1 instead of deeply coupling the supervisor to both models + +## Proposed Target Lifecycle Contract + +### Close last window + +- closes the UI process only +- does not stop `HostService` +- does not remove the tray + +### Open Superset from tray + +- launches or focuses the UI process +- reattaches UI to already-running host-service state + +### HostService crash + +- supervisor detects failure +- tray reflects degraded state +- supervisor may restart host-service according to policy + +### Quit from tray + +- stop all hosted services +- stop `HostService` +- dispose tray +- exit supervisor + +That contract matches the stated product requirement and avoids overloading +either the renderer or `HostService` with desktop-shell responsibilities. + +## Bottom Line + +The current codebase is halfway between an old Electron-owned terminal model and +a new host-service-owned v2 local model. + +The external benchmarks point to a clean conclusion: + +- tray ownership belongs in the desktop shell layer +- runtime ownership belongs in a headless service layer +- renderer/window lifetime should not define runtime lifetime + +For Superset's requested UX, that means: + +- do not move tray into `HostService` +- do not keep lifecycle centered in the current window-owning Electron main + forever +- introduce a `BackgroundSupervisor` that owns tray and app lifecycle policy +- keep `HostService` as the headless runtime owner + +That is the architecture that best matches both the current v2 direction and +the desired Docker-like tray behavior. diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index f67aa03ad27..05a1138209e 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -33,8 +33,10 @@ export async function makeAppSetup( if (!windows.length) { window = await createWindow(); } else { + // Show hidden windows (macOS hide-to-tray) or restore minimized ones for (window of windows.reverse()) { - window.restore(); + window.show(); + window.focus(); } } }); @@ -50,20 +52,10 @@ export async function makeAppSetup( }); }); - app.on("window-all-closed", () => { - // On macOS, keep the app alive (standard behavior). - // On other platforms, keep alive only if host-service is running. - if (!PLATFORM.IS_MAC) { - const { - getHostServiceManager, - } = require("main/lib/host-service-manager"); - const manager = getHostServiceManager(); - if (!manager.hasActiveInstances()) { - app.quit(); - } - } - }); - app.on("before-quit", () => {}); + // macOS: keep the app alive (standard behavior) — tray/dock provide re-entry. + // Windows/Linux: quit the app UI. Host-services survive via releaseAll() + // and will be re-adopted on next launch. + app.on("window-all-closed", () => !PLATFORM.IS_MAC && app.quit()); return window; } diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 6e9522318d7..e9749d8c460 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -5,8 +5,13 @@ * * Starts the host-service HTTP server on a random local port. * The parent Electron process reads the port from the IPC channel. + * + * When KEEP_ALIVE_AFTER_PARENT=1, the service stays running even if the + * parent Electron process exits (out-of-app durability mode). */ +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { serve } from "@hono/node-server"; import { createApp, @@ -25,7 +30,10 @@ const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null; const protocolVersion = process.env.HOST_SERVICE_PROTOCOL_VERSION ? Number(process.env.HOST_SERVICE_PROTOCOL_VERSION) : null; +const organizationId = process.env.ORGANIZATION_ID ?? ""; const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; +const manifestDir = process.env.HOST_MANIFEST_DIR ?? null; +const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1"; const auth = authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined; @@ -51,9 +59,47 @@ const { app, injectWebSocket } = createApp({ const startedAt = Date.now(); +function writeManifest(port: number) { + if (!manifestDir) return; + try { + if (!existsSync(manifestDir)) { + mkdirSync(manifestDir, { recursive: true }); + } + const manifest = { + pid: process.pid, + endpoint: `http://127.0.0.1:${port}`, + authToken: hostServiceSecret ?? "", + serviceVersion: serviceVersion ?? "", + protocolVersion: protocolVersion ?? 0, + startedAt, + organizationId, + }; + writeFileSync( + join(manifestDir, "manifest.json"), + JSON.stringify(manifest), + "utf-8", + ); + } catch (error) { + console.error("[host-service] Failed to write manifest:", error); + } +} + +function removeManifest() { + if (!manifestDir) return; + try { + const filePath = join(manifestDir, "manifest.json"); + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } catch { + // Best-effort + } +} + const server = serve( { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, (info: { port: number }) => { + writeManifest(info.port); process.send?.({ type: "ready", port: info.port, @@ -66,6 +112,7 @@ const server = serve( injectWebSocket(server); const shutdown = () => { + removeManifest(); server.close(); process.exit(0); }; @@ -73,15 +120,18 @@ const shutdown = () => { process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); -// Orphan cleanup: exit if parent Electron process dies -const parentPid = process.ppid; -const parentCheck = setInterval(() => { - try { - process.kill(parentPid, 0); - } catch { - clearInterval(parentCheck); - console.log("[host-service] Parent process exited, shutting down"); - shutdown(); - } -}, 2000); -parentCheck.unref(); +// Orphan cleanup: exit if parent Electron process dies. +// Disabled in keep-alive mode so the service survives app quit. +if (!keepAliveAfterParent) { + const parentPid = process.ppid; + const parentCheck = setInterval(() => { + try { + process.kill(parentPid, 0); + } catch { + clearInterval(parentCheck); + console.log("[host-service] Parent process exited, shutting down"); + shutdown(); + } + }, 2000); + parentCheck.unref(); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8e41a9236c7..ef90546d22e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -149,6 +149,21 @@ app.on("open-url", async (event, url) => { let isQuitting = false; let skipConfirmation = false; +let stopServicesOnQuit = false; +let forceQuit = false; + +/** Call before app.quit() to also stop all host-service instances. + * Without this, services are released (detached) so they survive the app + * exit and can be re-adopted on next launch. */ +export function setStopServicesOnQuit(): void { + stopServicesOnQuit = true; +} + +/** Call before app.quit() to bypass the macOS hide-to-tray behavior + * and actually exit the process. Used by tray quit actions. */ +export function setForceQuit(): void { + forceQuit = true; +} function getConfirmOnQuitSetting(): boolean { try { @@ -171,9 +186,21 @@ export function quitWithoutConfirmation(): void { app.on("before-quit", async (event) => { if (isQuitting) return; + const manager = getHostServiceManager(); + + // macOS: when services are active and this isn't an explicit tray quit, + // just hide all windows. The tray and services stay alive. + if (PLATFORM.IS_MAC && !forceQuit && manager.hasActiveInstances()) { + event.preventDefault(); + for (const win of BrowserWindow.getAllWindows()) { + win.hide(); + } + return; + } + const isDev = process.env.NODE_ENV === "development"; const shouldConfirm = - !skipConfirmation && !isDev && getConfirmOnQuitSetting(); + !skipConfirmation && !forceQuit && !isDev && getConfirmOnQuitSetting(); if (shouldConfirm) { event.preventDefault(); @@ -188,16 +215,22 @@ app.on("before-quit", async (event) => { message: "Are you sure you want to quit?", }); - if (response === 1) return; + if (response === 1) { + stopServicesOnQuit = false; + forceQuit = false; + return; + } } catch (error) { console.error("[main] Quit confirmation dialog failed:", error); } } - // Quit confirmed or no confirmation needed - exit immediately - // Let OS clean up child processes, tray, etc. isQuitting = true; - getHostServiceManager().stopAll(); + if (stopServicesOnQuit) { + manager.stopAll(); + } else { + manager.releaseAll(); + } disposeTray(); app.exit(0); }); @@ -345,6 +378,10 @@ if (!gotTheLock) { console.error("[main] Failed to set up agent hooks:", error); } + // Discover and adopt host-services that survived a previous quit + // before the tray initializes, so it shows accurate status immediately. + await getHostServiceManager().discoverAndAdoptAll(); + await makeAppSetup(() => MainWindow()); setupAutoUpdater(); initTray(); diff --git a/apps/desktop/src/main/lib/host-service-manager.test.ts b/apps/desktop/src/main/lib/host-service-manager.test.ts index aa0342cbeba..a39b665c1c2 100644 --- a/apps/desktop/src/main/lib/host-service-manager.test.ts +++ b/apps/desktop/src/main/lib/host-service-manager.test.ts @@ -58,6 +58,7 @@ describe("HostServiceManager", () => { app: { isPackaged: false, getAppPath: () => "/tmp/app", + getVersion: () => "1.0.0-test", }, })); @@ -90,6 +91,9 @@ describe("HostServiceManager", () => { const secondStart = manager.start("org-1"); expect(manager.getStatus("org-1")).toBe("starting"); + + // Flush microtasks so tryAdopt completes (no manifest → falls through to spawn) + await new Promise((resolve) => setTimeout(resolve, 0)); expect(getProcessEnvWithShellPathMock.mock.calls).toHaveLength(1); pendingEnv.resolve({ PATH: "/usr/bin:/bin" }); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index 1a3496cc791..d5a88738466 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -7,6 +7,13 @@ import { app } from "electron"; import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; import { SUPERSET_HOME_DIR } from "./app-environment"; import { getDeviceName, getHashedDeviceId } from "./device-info"; +import { + type HostServiceManifest, + isProcessAlive, + listManifests, + readManifest, + removeManifest, +} from "./host-service-manifest"; export type HostServiceStatus = | "starting" @@ -31,6 +38,7 @@ export interface HostServiceInfo { restartCount: number; pendingRestart: boolean; compatibility: CompatibilityResult | null; + adopted: boolean; } export interface HostServiceStatusEvent { @@ -40,6 +48,7 @@ export interface HostServiceStatusEvent { } interface HostServiceProcess { + /** null when the instance was adopted from a manifest (no child handle). */ process: ChildProcess | null; port: number | null; secret: string | null; @@ -51,6 +60,10 @@ interface HostServiceProcess { serviceVersion: string | null; protocolVersion: number | null; pendingRestart: boolean; + /** True when this instance was adopted from a running manifest rather than spawned. */ + adopted: boolean; + /** PID of the adopted process (for liveness checks). */ + adoptedPid: number | null; } interface PendingStart { @@ -64,6 +77,9 @@ interface PendingStart { const MAX_RESTART_DELAY = 30_000; const BASE_RESTART_DELAY = 1_000; +/** Interval for checking liveness of adopted (non-child) processes. */ +const ADOPTED_LIVENESS_INTERVAL = 5_000; + /** Protocol version for the IPC contract between ElectronMain and HostService. * Bump this whenever the ready message shape, env contract, or health API * changes in a backwards-incompatible way. */ @@ -88,6 +104,10 @@ export class HostServiceManager extends EventEmitter { private instances = new Map(); private pendingStarts = new Map(); private scheduledRestarts = new Map>(); + private adoptedLivenessTimers = new Map< + string, + ReturnType + >(); private organizationNames = new Map(); private scriptPath = path.join(__dirname, "host-service.js"); private authToken: string | null = null; @@ -114,14 +134,28 @@ export class HostServiceManager extends EventEmitter { if (existing?.status === "running" && existing.port !== null) { return existing.port; } - const pendingStart = this.pendingStarts.get(organizationId); - if (pendingStart) { - return pendingStart.promise; + const existingPending = this.pendingStarts.get(organizationId); + if (existingPending) { + return existingPending.promise; } - // Cancel any scheduled restart since we're starting explicitly this.cancelScheduledRestart(organizationId); + // Register a pending start BEFORE the async tryAdopt so that concurrent + // callers see it and dedupe instead of racing through adoption + spawn. + const deferred = createPortDeferred(); + this.pendingStarts.set(organizationId, deferred); + + const adopted = await this.tryAdopt(organizationId); + if (adopted !== null) { + if (this.pendingStarts.get(organizationId) === deferred) { + this.pendingStarts.delete(organizationId); + } + deferred.resolve(adopted); + return adopted; + } + + // Adoption failed — spawn() will reuse the deferred already in pendingStarts. return this.spawn(organizationId); } @@ -129,13 +163,23 @@ export class HostServiceManager extends EventEmitter { const instance = this.instances.get(organizationId); this.cancelScheduledRestart(organizationId); this.cancelPendingStart(organizationId, new Error("Host service stopped")); + this.stopAdoptedLivenessCheck(organizationId); if (!instance) return; const previousStatus = instance.status; instance.status = "stopped"; - instance.process?.kill("SIGTERM"); + if (instance.adopted && instance.adoptedPid) { + try { + process.kill(instance.adoptedPid, "SIGTERM"); + } catch { + // Already dead + } + } else { + instance.process?.kill("SIGTERM"); + } this.instances.delete(organizationId); + removeManifest(organizationId); this.emitStatus(organizationId, "stopped", previousStatus); } @@ -145,6 +189,24 @@ export class HostServiceManager extends EventEmitter { } } + /** Release all instances without killing the underlying processes. + * The services keep running and can be re-adopted on next app start. */ + releaseAll(): void { + for (const [id] of this.instances) { + this.release(id); + } + } + + /** Scan for on-disk manifests and adopt any running services. + * Call during startup so the tray shows accurate state immediately. */ + async discoverAndAdoptAll(): Promise { + const manifests = listManifests(); + for (const manifest of manifests) { + if (this.instances.has(manifest.organizationId)) continue; + await this.tryAdopt(manifest.organizationId); + } + } + async restart(organizationId: string): Promise { const instance = this.instances.get(organizationId); if (instance) { @@ -157,8 +219,19 @@ export class HostServiceManager extends EventEmitter { organizationId, new Error("Host service restarting"), ); - instance.process?.kill("SIGTERM"); + this.stopAdoptedLivenessCheck(organizationId); + + if (instance.adopted && instance.adoptedPid) { + try { + process.kill(instance.adoptedPid, "SIGTERM"); + } catch { + // Already dead + } + } else { + instance.process?.kill("SIGTERM"); + } this.instances.delete(organizationId); + removeManifest(organizationId); } return this.spawn(organizationId); @@ -195,6 +268,7 @@ export class HostServiceManager extends EventEmitter { restartCount: 0, pendingRestart: false, compatibility: null, + adopted: false, }; } @@ -212,6 +286,7 @@ export class HostServiceManager extends EventEmitter { restartCount: instance.restartCount, pendingRestart: instance.pendingRestart, compatibility: this.checkCompatibility(instance), + adopted: instance.adopted, }; } @@ -281,8 +356,153 @@ export class HostServiceManager extends EventEmitter { } } + // ── Discovery / Adoption ────────────────────────────────────────── + + /** + * Try to adopt an already-running host-service from its on-disk manifest. + * Returns the port if adoption succeeds, null otherwise. + */ + private async tryAdopt(organizationId: string): Promise { + const manifest = readManifest(organizationId); + if (!manifest) return null; + + if (!isProcessAlive(manifest.pid)) { + console.log( + `[host-service:${organizationId}] Manifest process ${manifest.pid} is dead, removing stale manifest`, + ); + removeManifest(organizationId); + return null; + } + + const healthy = await this.healthCheck(manifest); + if (!healthy) { + console.log( + `[host-service:${organizationId}] Manifest endpoint ${manifest.endpoint} not reachable, removing stale manifest`, + ); + removeManifest(organizationId); + return null; + } + + const compat = this.checkCompatibility({ + protocolVersion: manifest.protocolVersion, + serviceVersion: manifest.serviceVersion, + }); + + if (compat && !compat.compatible) { + console.log( + `[host-service:${organizationId}] Manifest service incompatible: ${compat.reason}. Will kill and respawn.`, + ); + try { + process.kill(manifest.pid, "SIGTERM"); + } catch { + // Already dead + } + removeManifest(organizationId); + return null; + } + + const url = new URL(manifest.endpoint); + const port = Number(url.port); + const pendingRestart = + compat !== null && "updateAvailable" in compat && compat.updateAvailable; + + const instance: HostServiceProcess = { + process: null, + port, + secret: manifest.authToken, + status: "running", + restartCount: 0, + organizationId, + startedAt: manifest.startedAt, + serviceVersion: manifest.serviceVersion, + protocolVersion: manifest.protocolVersion, + pendingRestart, + adopted: true, + adoptedPid: manifest.pid, + }; + this.instances.set(organizationId, instance); + this.startAdoptedLivenessCheck(organizationId, manifest.pid); + + console.log( + `[host-service:${organizationId}] Adopted existing service pid=${manifest.pid} port=${port} v${manifest.serviceVersion}`, + ); + this.emitStatus(organizationId, "running", null); + return port; + } + + private async healthCheck(manifest: HostServiceManifest): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3_000); + const res = await fetch(`${manifest.endpoint}/trpc/health.check`, { + signal: controller.signal, + headers: { + Authorization: `Bearer ${manifest.authToken}`, + }, + }); + clearTimeout(timeout); + return res.ok; + } catch { + return false; + } + } + + private startAdoptedLivenessCheck(organizationId: string, pid: number): void { + this.stopAdoptedLivenessCheck(organizationId); + + const timer = setInterval(() => { + if (!isProcessAlive(pid)) { + console.log( + `[host-service:${organizationId}] Adopted process ${pid} died`, + ); + this.stopAdoptedLivenessCheck(organizationId); + + const current = this.instances.get(organizationId); + if (current?.adopted && current.status !== "stopped") { + current.status = "degraded"; + current.lastCrash = Date.now(); + this.emitStatus(organizationId, "degraded", "running"); + this.scheduleRestart(organizationId); + } + } + }, ADOPTED_LIVENESS_INTERVAL); + timer.unref(); + this.adoptedLivenessTimers.set(organizationId, timer); + } + + private stopAdoptedLivenessCheck(organizationId: string): void { + const timer = this.adoptedLivenessTimers.get(organizationId); + if (timer) { + clearInterval(timer); + this.adoptedLivenessTimers.delete(organizationId); + } + } + + /** Release an instance without killing it. Allows the process to keep running. */ + private release(organizationId: string): void { + this.cancelScheduledRestart(organizationId); + this.cancelPendingStart(organizationId, new Error("Host service released")); + this.stopAdoptedLivenessCheck(organizationId); + + const instance = this.instances.get(organizationId); + if (!instance) return; + + if (instance.process) { + instance.process.disconnect?.(); + instance.process.unref?.(); + instance.process = null; + } + this.instances.delete(organizationId); + // Leave the manifest on disk — next app start will adopt it. + } + + // ── Spawn ───────────────────────────────────────────────────────── + private async spawn(organizationId: string): Promise { - const pendingStart = createPortDeferred(); + // Reuse a pending start registered by start(), or create a fresh one + // (e.g. when called directly from restart/scheduleRestart). + const pendingStart = + this.pendingStarts.get(organizationId) ?? createPortDeferred(); const secret = randomBytes(32).toString("hex"); const previousInstance = this.instances.get(organizationId); @@ -299,6 +519,8 @@ export class HostServiceManager extends EventEmitter { serviceVersion: null, protocolVersion: null, pendingRestart: false, + adopted: false, + adoptedPid: null, }; this.instances.set(organizationId, instance); this.pendingStarts.set(organizationId, pendingStart); @@ -344,6 +566,10 @@ export class HostServiceManager extends EventEmitter { } } + private manifestDir(organizationId: string): string { + return path.join(SUPERSET_HOME_DIR, "host", organizationId); + } + private async buildHostServiceEnv( organizationId: string, secret: string, @@ -357,6 +583,8 @@ export class HostServiceManager extends EventEmitter { HOST_SERVICE_SECRET: secret, HOST_SERVICE_VERSION: app.getVersion(), HOST_SERVICE_PROTOCOL_VERSION: String(HOST_SERVICE_PROTOCOL_VERSION), + HOST_MANIFEST_DIR: this.manifestDir(organizationId), + KEEP_ALIVE_AFTER_PARENT: "1", HOST_DB_PATH: path.join( SUPERSET_HOME_DIR, "host", diff --git a/apps/desktop/src/main/lib/host-service-manifest.ts b/apps/desktop/src/main/lib/host-service-manifest.ts new file mode 100644 index 00000000000..bfbc2b112f2 --- /dev/null +++ b/apps/desktop/src/main/lib/host-service-manifest.ts @@ -0,0 +1,109 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { SUPERSET_HOME_DIR } from "./app-environment"; + +export interface HostServiceManifest { + pid: number; + endpoint: string; + authToken: string; + serviceVersion: string; + protocolVersion: number; + startedAt: number; + organizationId: string; +} + +function manifestDir(organizationId: string): string { + return join(SUPERSET_HOME_DIR, "host", organizationId); +} + +function manifestPath(organizationId: string): string { + return join(manifestDir(organizationId), "manifest.json"); +} + +export function writeManifest(manifest: HostServiceManifest): void { + const dir = manifestDir(manifest.organizationId); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync( + manifestPath(manifest.organizationId), + JSON.stringify(manifest), + "utf-8", + ); +} + +export function readManifest( + organizationId: string, +): HostServiceManifest | null { + const filePath = manifestPath(organizationId); + if (!existsSync(filePath)) return null; + + try { + const raw = readFileSync(filePath, "utf-8"); + const data = JSON.parse(raw); + + if ( + typeof data.pid !== "number" || + typeof data.endpoint !== "string" || + typeof data.authToken !== "string" || + typeof data.serviceVersion !== "string" || + typeof data.protocolVersion !== "number" || + typeof data.startedAt !== "number" || + typeof data.organizationId !== "string" + ) { + return null; + } + + return data as HostServiceManifest; + } catch { + return null; + } +} + +/** Scan the host directory for all valid manifests on disk. */ +export function listManifests(): HostServiceManifest[] { + const hostDir = join(SUPERSET_HOME_DIR, "host"); + if (!existsSync(hostDir)) return []; + + const manifests: HostServiceManifest[] = []; + try { + for (const entry of readdirSync(hostDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifest = readManifest(entry.name); + if (manifest) { + manifests.push(manifest); + } + } + } catch { + // Best-effort scan + } + return manifests; +} + +export function removeManifest(organizationId: string): void { + const filePath = manifestPath(organizationId); + try { + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } catch { + // Best-effort removal + } +} + +/** Check whether a process with the given PID is alive. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 45daf6652b6..e506272282f 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -8,6 +8,7 @@ import { nativeImage, Tray, } from "electron"; +import { setForceQuit, setStopServicesOnQuit } from "main/index"; import { getHostServiceManager, type HostServiceStatus, @@ -137,6 +138,7 @@ function buildHostServiceSubmenu(): MenuItemConstructorOptions[] { const versionSuffix = info.serviceVersion ? ` (v${info.serviceVersion})` : ""; + const isRunning = info.status === "running"; menuItems.push({ label: orgName, @@ -178,39 +180,31 @@ function buildHostServiceSubmenu(): MenuItemConstructorOptions[] { enabled: false, }); } - } - } - - menuItems.push({ type: "separator" }); - const hasRunning = orgIds.some((id) => manager.getStatus(id) === "running"); - - menuItems.push({ - label: "Restart Host Service", - enabled: hasRunning, - click: () => { - for (const orgId of orgIds) { - if (manager.getStatus(orgId) === "running") { + menuItems.push({ + label: " Restart", + enabled: isRunning, + click: () => { manager.restart(orgId).catch((err) => { console.error( `[Tray] Failed to restart host-service for ${orgId}:`, err, ); }); - } - } - updateTrayMenu(); - }, - }); + updateTrayMenu(); + }, + }); - menuItems.push({ - label: "Stop Host Service", - enabled: hasRunning, - click: () => { - manager.stopAll(); - updateTrayMenu(); - }, - }); + menuItems.push({ + label: " Stop", + enabled: isRunning, + click: () => { + manager.stop(orgId); + updateTrayMenu(); + }, + }); + } + } return menuItems; } @@ -259,10 +253,33 @@ function updateTrayMenu(): void { }, }, { type: "separator" }, - { - label: "Quit", - click: () => app.quit(), - }, + ...(hasActive + ? [ + { + label: "Quit (Keep Services Running)", + click: () => { + setForceQuit(); + app.quit(); + }, + }, + { + label: "Quit & Stop Services", + click: () => { + setForceQuit(); + setStopServicesOnQuit(); + app.quit(); + }, + }, + ] + : [ + { + label: "Quit", + click: () => { + setForceQuit(); + app.quit(); + }, + }, + ]), ]); tray.setContextMenu(menu); diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index e932fc634e2..8ef58e05fa8 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -298,7 +298,7 @@ export async function MainWindow() { console.error(` Error:`, error); }); - window.on("close", () => { + window.on("close", (event) => { // Save window state first, before any cleanup const isMaximized = window.isMaximized(); const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); @@ -313,13 +313,20 @@ export async function MainWindow() { }); persistedZoomLevel = zoomLevel; + // macOS: hide instead of destroy so "Open Superset" can reshow instantly. + // The quit flow uses app.exit(0) which bypasses close events entirely, + // so this hide path only runs for Cmd+W / red-X. + if (PLATFORM.IS_MAC) { + event.preventDefault(); + window.hide(); + return; + } + browserManager.unregisterAll(); server.close(); notificationManager.dispose(); notificationsEmitter.removeAllListeners(); - // Remove terminal listeners to prevent duplicates when window reopens on macOS getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); - // Detach window from IPC handler (handler stays alive for window reopen) ipcHandler?.detachWindow(window); currentWindow = null; }); From cfd5357fd2229e375089deaae6d05525a7d76cb4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 09:55:54 -0700 Subject: [PATCH 04/14] Refactor --- .../src/lib/trpc/routers/settings/index.ts | 4 +- apps/desktop/src/main/host-service/index.ts | 61 +++------- apps/desktop/src/main/index.ts | 65 +++++----- apps/desktop/src/main/lib/auto-updater.ts | 6 +- .../src/main/lib/host-service-manager.test.ts | 83 ++++++++++++- .../src/main/lib/host-service-manager.ts | 112 ++++++++---------- .../src/main/lib/host-service-manifest.ts | 4 +- apps/desktop/src/main/lib/tray/index.ts | 39 +----- 8 files changed, 197 insertions(+), 177 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 9a49802c7a7..5876d80fd22 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -17,7 +17,7 @@ import { } from "@superset/shared/agent-command"; import { TRPCError } from "@trpc/server"; import { app } from "electron"; -import { quitWithoutConfirmation } from "main/index"; +import { exitImmediately } from "main/index"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { localDb } from "main/lib/local-db"; import { @@ -696,7 +696,7 @@ export const createSettingsRouter = () => { restartApp: publicProcedure.mutation(() => { app.relaunch(); - quitWithoutConfirmation(); + exitImmediately(); return { success: true }; }), diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index e9749d8c460..da10df8d637 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -10,8 +10,6 @@ * parent Electron process exits (out-of-app durability mode). */ -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { serve } from "@hono/node-server"; import { createApp, @@ -19,6 +17,7 @@ import { LocalGitCredentialProvider, PskHostAuthProvider, } from "@superset/host-service"; +import { removeManifest, writeManifest } from "main/lib/host-service-manifest"; const authToken = process.env.AUTH_TOKEN; const cloudApiUrl = process.env.CLOUD_API_URL; @@ -32,7 +31,6 @@ const protocolVersion = process.env.HOST_SERVICE_PROTOCOL_VERSION : null; const organizationId = process.env.ORGANIZATION_ID ?? ""; const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; -const manifestDir = process.env.HOST_MANIFEST_DIR ?? null; const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1"; const auth = @@ -59,47 +57,24 @@ const { app, injectWebSocket } = createApp({ const startedAt = Date.now(); -function writeManifest(port: number) { - if (!manifestDir) return; - try { - if (!existsSync(manifestDir)) { - mkdirSync(manifestDir, { recursive: true }); - } - const manifest = { - pid: process.pid, - endpoint: `http://127.0.0.1:${port}`, - authToken: hostServiceSecret ?? "", - serviceVersion: serviceVersion ?? "", - protocolVersion: protocolVersion ?? 0, - startedAt, - organizationId, - }; - writeFileSync( - join(manifestDir, "manifest.json"), - JSON.stringify(manifest), - "utf-8", - ); - } catch (error) { - console.error("[host-service] Failed to write manifest:", error); - } -} - -function removeManifest() { - if (!manifestDir) return; - try { - const filePath = join(manifestDir, "manifest.json"); - if (existsSync(filePath)) { - unlinkSync(filePath); - } - } catch { - // Best-effort - } -} - const server = serve( { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, (info: { port: number }) => { - writeManifest(info.port); + if (organizationId) { + try { + writeManifest({ + pid: process.pid, + endpoint: `http://127.0.0.1:${info.port}`, + authToken: hostServiceSecret ?? "", + serviceVersion: serviceVersion ?? "", + protocolVersion: protocolVersion ?? 0, + startedAt, + organizationId, + }); + } catch (error) { + console.error("[host-service] Failed to write manifest:", error); + } + } process.send?.({ type: "ready", port: info.port, @@ -112,7 +87,9 @@ const server = serve( injectWebSocket(server); const shutdown = () => { - removeManifest(); + if (organizationId) { + removeManifest(organizationId); + } server.close(); process.exit(0); }; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index ef90546d22e..e07fd8d4b90 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -94,7 +94,7 @@ function findDeepLinkInArgv(argv: string[]): string | undefined { return argv.find((arg) => arg.startsWith(`${PROTOCOL_SCHEME}://`)); } -function focusMainWindow(): void { +export function focusMainWindow(): void { const windows = BrowserWindow.getAllWindows(); if (windows.length > 0) { const mainWindow = windows[0]; @@ -103,6 +103,9 @@ function focusMainWindow(): void { } mainWindow.show(); mainWindow.focus(); + } else { + // Triggers window creation via makeAppSetup's activate handler + app.emit("activate"); } } @@ -147,22 +150,28 @@ app.on("open-url", async (event, url) => { } }); +export type QuitMode = "release" | "stop"; +let pendingQuitMode: QuitMode | null = null; let isQuitting = false; -let skipConfirmation = false; -let stopServicesOnQuit = false; -let forceQuit = false; - -/** Call before app.quit() to also stop all host-service instances. - * Without this, services are released (detached) so they survive the app - * exit and can be re-adopted on next launch. */ -export function setStopServicesOnQuit(): void { - stopServicesOnQuit = true; + +/** Request the app to quit. + * - "release": keep services running (re-adoptable on next launch) + * - "stop": terminate all services before exit */ +export function requestQuit(mode: QuitMode): void { + pendingQuitMode = mode; + app.quit(); +} + +/** Set quit mode without triggering quit. + * Use when another API (e.g. autoUpdater.quitAndInstall) triggers quit internally. */ +export function prepareQuit(mode: QuitMode): void { + pendingQuitMode = mode; } -/** Call before app.quit() to bypass the macOS hide-to-tray behavior - * and actually exit the process. Used by tray quit actions. */ -export function setForceQuit(): void { - forceQuit = true; +/** Exit the process immediately, bypassing before-quit. + * Services are left running for adoption on next launch. */ +export function exitImmediately(): void { + app.exit(0); } function getConfirmOnQuitSetting(): boolean { @@ -174,23 +183,17 @@ function getConfirmOnQuitSetting(): boolean { } } -export function setSkipQuitConfirmation(): void { - skipConfirmation = true; -} - -export function quitWithoutConfirmation(): void { - skipConfirmation = true; - app.exit(0); -} - app.on("before-quit", async (event) => { if (isQuitting) return; const manager = getHostServiceManager(); - // macOS: when services are active and this isn't an explicit tray quit, - // just hide all windows. The tray and services stay alive. - if (PLATFORM.IS_MAC && !forceQuit && manager.hasActiveInstances()) { + // macOS: no explicit quit requested → hide windows if services are active + if ( + PLATFORM.IS_MAC && + pendingQuitMode === null && + manager.hasActiveInstances() + ) { event.preventDefault(); for (const win of BrowserWindow.getAllWindows()) { win.hide(); @@ -198,11 +201,9 @@ app.on("before-quit", async (event) => { return; } + // Show confirmation only for implicit quit in production with setting enabled const isDev = process.env.NODE_ENV === "development"; - const shouldConfirm = - !skipConfirmation && !forceQuit && !isDev && getConfirmOnQuitSetting(); - - if (shouldConfirm) { + if (pendingQuitMode === null && !isDev && getConfirmOnQuitSetting()) { event.preventDefault(); try { @@ -216,8 +217,6 @@ app.on("before-quit", async (event) => { }); if (response === 1) { - stopServicesOnQuit = false; - forceQuit = false; return; } } catch (error) { @@ -226,7 +225,7 @@ app.on("before-quit", async (event) => { } isQuitting = true; - if (stopServicesOnQuit) { + if (pendingQuitMode === "stop") { manager.stopAll(); } else { manager.releaseAll(); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index ecd4bfd4071..b985196bad9 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import { app, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { env } from "main/env.main"; -import { setSkipQuitConfirmation } from "main/index"; +import { prepareQuit } from "main/index"; import { prerelease } from "semver"; import { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; @@ -91,8 +91,8 @@ export function installUpdate(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); return; } - // Skip confirmation dialog - quitAndInstall internally calls app.quit() - setSkipQuitConfirmation(); + // quitAndInstall internally calls app.quit() — set mode beforehand + prepareQuit("release"); autoUpdater.quitAndInstall(false, true); } diff --git a/apps/desktop/src/main/lib/host-service-manager.test.ts b/apps/desktop/src/main/lib/host-service-manager.test.ts index a39b665c1c2..da22de15ab7 100644 --- a/apps/desktop/src/main/lib/host-service-manager.test.ts +++ b/apps/desktop/src/main/lib/host-service-manager.test.ts @@ -26,6 +26,8 @@ class MockChildProcess extends EventEmitter { stdout = new EventEmitter(); stderr = new EventEmitter(); kill = mock(() => true); + disconnect = mock(() => {}); + unref = mock(() => {}); } const getProcessEnvWithShellPathMock = mock( @@ -37,6 +39,8 @@ const spawnMock = mock((..._args: unknown[]) => { return lastChild as unknown as ChildProcess; }); let HostServiceManager: typeof import("./host-service-manager").HostServiceManager; +let checkCompatibility: typeof import("./host-service-manager").checkCompatibility; +let HOST_SERVICE_PROTOCOL_VERSION: typeof import("./host-service-manager").HOST_SERVICE_PROTOCOL_VERSION; describe("HostServiceManager", () => { beforeAll(async () => { @@ -62,7 +66,8 @@ describe("HostServiceManager", () => { }, })); - ({ HostServiceManager } = await import("./host-service-manager")); + ({ HostServiceManager, checkCompatibility, HOST_SERVICE_PROTOCOL_VERSION } = + await import("./host-service-manager")); }); afterAll(() => { @@ -111,4 +116,80 @@ describe("HostServiceManager", () => { expect(await secondStart).toBe(4242); expect(manager.getPort("org-1")).toBe(4242); }); + + it("stopAll() kills all instances", async () => { + const manager = new HostServiceManager(); + + const p1 = manager.start("org-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + const child1 = lastChild; + child1?.emit("message", { type: "ready", port: 4001 }); + await p1; + + const p2 = manager.start("org-2"); + await new Promise((resolve) => setTimeout(resolve, 0)); + const child2 = lastChild; + child2?.emit("message", { type: "ready", port: 4002 }); + await p2; + + manager.stopAll(); + + expect(child1?.kill).toHaveBeenCalledWith("SIGTERM"); + expect(child2?.kill).toHaveBeenCalledWith("SIGTERM"); + expect(manager.getStatus("org-1")).toBe("stopped"); + expect(manager.getStatus("org-2")).toBe("stopped"); + }); + + it("releaseAll() detaches without killing", async () => { + const manager = new HostServiceManager(); + + const p1 = manager.start("org-1"); + await new Promise((resolve) => setTimeout(resolve, 0)); + lastChild?.emit("message", { type: "ready", port: 4001 }); + await p1; + + const child = lastChild; + + manager.releaseAll(); + + expect(child?.kill).not.toHaveBeenCalled(); + expect(manager.getStatus("org-1")).toBe("stopped"); + }); + + describe("checkCompatibility", () => { + it("returns null when protocol version is unknown", () => { + const result = checkCompatibility({ + protocolVersion: null, + serviceVersion: null, + }); + expect(result).toBeNull(); + }); + + it("detects protocol mismatch", () => { + const result = checkCompatibility({ + protocolVersion: 999, + serviceVersion: "1.0.0", + }); + expect(result).toEqual({ + compatible: false, + reason: expect.stringContaining("Protocol mismatch"), + }); + }); + + it("detects compatible with update available", () => { + const result = checkCompatibility({ + protocolVersion: HOST_SERVICE_PROTOCOL_VERSION, + serviceVersion: "0.0.1-old", + }); + expect(result).toEqual({ compatible: true, updateAvailable: true }); + }); + + it("detects compatible with same version", () => { + const result = checkCompatibility({ + protocolVersion: HOST_SERVICE_PROTOCOL_VERSION, + serviceVersion: "1.0.0-test", + }); + expect(result).toEqual({ compatible: true, updateAvailable: false }); + }); + }); }); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index d5a88738466..b094c4054c5 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -5,12 +5,12 @@ import { EventEmitter } from "node:events"; import path from "node:path"; import { app } from "electron"; import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; -import { SUPERSET_HOME_DIR } from "./app-environment"; import { getDeviceName, getHashedDeviceId } from "./device-info"; import { type HostServiceManifest, isProcessAlive, listManifests, + manifestDir, readManifest, removeManifest, } from "./host-service-manifest"; @@ -100,6 +100,51 @@ function createPortDeferred(): { return { promise, resolve, reject }; } +/** Check whether a host-service instance is compatible with this app version. */ +export function checkCompatibility(instance: { + protocolVersion: number | null; + serviceVersion: string | null; +}): CompatibilityResult | null { + if (instance.protocolVersion === null) return null; + + if (instance.protocolVersion !== HOST_SERVICE_PROTOCOL_VERSION) { + return { + compatible: false, + reason: `Protocol mismatch: service=${instance.protocolVersion}, app=${HOST_SERVICE_PROTOCOL_VERSION}`, + }; + } + + const currentVersion = app.getVersion(); + const updateAvailable = + instance.serviceVersion !== null && + instance.serviceVersion !== currentVersion; + + return { compatible: true, updateAvailable }; +} + +async function buildHostServiceEnv( + organizationId: string, + secret: string, +): Promise> { + const orgDir = manifestDir(organizationId); + return getProcessEnvWithShellPath({ + ...(process.env as Record), + ELECTRON_RUN_AS_NODE: "1", + ORGANIZATION_ID: organizationId, + DEVICE_CLIENT_ID: getHashedDeviceId(), + DEVICE_NAME: getDeviceName(), + HOST_SERVICE_SECRET: secret, + HOST_SERVICE_VERSION: app.getVersion(), + HOST_SERVICE_PROTOCOL_VERSION: String(HOST_SERVICE_PROTOCOL_VERSION), + HOST_MANIFEST_DIR: orgDir, + KEEP_ALIVE_AFTER_PARENT: "1", + HOST_DB_PATH: path.join(orgDir, "host.db"), + HOST_MIGRATIONS_PATH: app.isPackaged + ? path.join(process.resourcesPath, "resources/host-migrations") + : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), + }); +} + export class HostServiceManager extends EventEmitter { private instances = new Map(); private pendingStarts = new Map(); @@ -285,7 +330,7 @@ export class HostServiceManager extends EventEmitter { : null, restartCount: instance.restartCount, pendingRestart: instance.pendingRestart, - compatibility: this.checkCompatibility(instance), + compatibility: checkCompatibility(instance), adopted: instance.adopted, }; } @@ -311,30 +356,6 @@ export class HostServiceManager extends EventEmitter { return ids; } - /** Check whether a running host-service is compatible with this app version. - * - protocol match + same version = compatible, no update - * - protocol match + older service = compatible, update available - * - protocol mismatch = incompatible, restart required */ - checkCompatibility( - instance: Pick, - ): CompatibilityResult | null { - if (instance.protocolVersion === null) return null; - - if (instance.protocolVersion !== HOST_SERVICE_PROTOCOL_VERSION) { - return { - compatible: false, - reason: `Protocol mismatch: service=${instance.protocolVersion}, app=${HOST_SERVICE_PROTOCOL_VERSION}`, - }; - } - - const currentVersion = app.getVersion(); - const updateAvailable = - instance.serviceVersion !== null && - instance.serviceVersion !== currentVersion; - - return { compatible: true, updateAvailable }; - } - /** Mark a host-service instance for restart when it becomes idle. */ markPendingRestart(organizationId: string): void { const instance = this.instances.get(organizationId); @@ -347,7 +368,7 @@ export class HostServiceManager extends EventEmitter { checkAllCompatibility(): void { for (const [orgId, instance] of this.instances) { if (instance.status !== "running") continue; - const result = this.checkCompatibility(instance); + const result = checkCompatibility(instance); if (result && !result.compatible) { console.log(`[host-service:${orgId}] Incompatible: ${result.reason}`); instance.pendingRestart = true; @@ -383,7 +404,7 @@ export class HostServiceManager extends EventEmitter { return null; } - const compat = this.checkCompatibility({ + const compat = checkCompatibility({ protocolVersion: manifest.protocolVersion, serviceVersion: manifest.serviceVersion, }); @@ -527,7 +548,7 @@ export class HostServiceManager extends EventEmitter { this.emitStatus(organizationId, "starting", null); try { - const env = await this.buildHostServiceEnv(organizationId, secret); + const env = await buildHostServiceEnv(organizationId, secret); if (this.authToken) { env.AUTH_TOKEN = this.authToken; } @@ -566,37 +587,6 @@ export class HostServiceManager extends EventEmitter { } } - private manifestDir(organizationId: string): string { - return path.join(SUPERSET_HOME_DIR, "host", organizationId); - } - - private async buildHostServiceEnv( - organizationId: string, - secret: string, - ): Promise> { - return getProcessEnvWithShellPath({ - ...(process.env as Record), - ELECTRON_RUN_AS_NODE: "1", - ORGANIZATION_ID: organizationId, - DEVICE_CLIENT_ID: getHashedDeviceId(), - DEVICE_NAME: getDeviceName(), - HOST_SERVICE_SECRET: secret, - HOST_SERVICE_VERSION: app.getVersion(), - HOST_SERVICE_PROTOCOL_VERSION: String(HOST_SERVICE_PROTOCOL_VERSION), - HOST_MANIFEST_DIR: this.manifestDir(organizationId), - KEEP_ALIVE_AFTER_PARENT: "1", - HOST_DB_PATH: path.join( - SUPERSET_HOME_DIR, - "host", - organizationId, - "host.db", - ), - HOST_MIGRATIONS_PATH: app.isPackaged - ? path.join(process.resourcesPath, "resources/host-migrations") - : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), - }); - } - private attachProcessHandlers( instance: HostServiceProcess, child: ChildProcess, @@ -701,7 +691,7 @@ export class HostServiceManager extends EventEmitter { ); // Check compatibility on connect - const compat = this.checkCompatibility(instance); + const compat = checkCompatibility(instance); if (compat && !compat.compatible) { console.warn( `[host-service:${instance.organizationId}] ${compat.reason} — marking for restart`, diff --git a/apps/desktop/src/main/lib/host-service-manifest.ts b/apps/desktop/src/main/lib/host-service-manifest.ts index bfbc2b112f2..d8e0240020c 100644 --- a/apps/desktop/src/main/lib/host-service-manifest.ts +++ b/apps/desktop/src/main/lib/host-service-manifest.ts @@ -1,8 +1,8 @@ import { existsSync, mkdirSync, - readFileSync, readdirSync, + readFileSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -19,7 +19,7 @@ export interface HostServiceManifest { organizationId: string; } -function manifestDir(organizationId: string): string { +export function manifestDir(organizationId: string): string { return join(SUPERSET_HOME_DIR, "host", organizationId); } diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index e506272282f..181a77e307a 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -2,13 +2,12 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { app, - BrowserWindow, Menu, type MenuItemConstructorOptions, nativeImage, Tray, } from "electron"; -import { setForceQuit, setStopServicesOnQuit } from "main/index"; +import { focusMainWindow, requestQuit } from "main/index"; import { getHostServiceManager, type HostServiceStatus, @@ -81,24 +80,8 @@ function createTrayIcon(): Electron.NativeImage | null { } } -function showWindow(): void { - const windows = BrowserWindow.getAllWindows(); - - if (windows.length > 0) { - const mainWindow = windows[0]; - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.show(); - mainWindow.focus(); - } else { - // Triggers window creation via makeAppSetup's activate handler - app.emit("activate"); - } -} - function openSettings(): void { - showWindow(); + focusMainWindow(); menuEmitter.emit("open-settings"); } @@ -238,7 +221,7 @@ function updateTrayMenu(): void { { type: "separator" }, { label: "Open Superset", - click: showWindow, + click: focusMainWindow, }, { label: "Settings", @@ -257,27 +240,17 @@ function updateTrayMenu(): void { ? [ { label: "Quit (Keep Services Running)", - click: () => { - setForceQuit(); - app.quit(); - }, + click: () => requestQuit("release"), }, { label: "Quit & Stop Services", - click: () => { - setForceQuit(); - setStopServicesOnQuit(); - app.quit(); - }, + click: () => requestQuit("stop"), }, ] : [ { label: "Quit", - click: () => { - setForceQuit(); - app.quit(); - }, + click: () => requestQuit("release"), }, ]), ]); From ef093c3d63f0ba1f960a927c8bd1e7a741f0ad4f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 10:35:37 -0700 Subject: [PATCH 05/14] fix comments --- apps/desktop/src/main/index.ts | 14 +++++++------- apps/desktop/src/main/lib/host-service-manager.ts | 2 +- .../renderer/lib/terminal/terminal-ws-transport.ts | 1 + .../HostServiceProvider/HostServiceProvider.tsx | 4 +++- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e07fd8d4b90..21d7bab33ff 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -186,14 +186,14 @@ function getConfirmOnQuitSetting(): boolean { app.on("before-quit", async (event) => { if (isQuitting) return; + // Consume the quit mode so it doesn't persist across aborted quits + const quitMode = pendingQuitMode; + pendingQuitMode = null; + const manager = getHostServiceManager(); // macOS: no explicit quit requested → hide windows if services are active - if ( - PLATFORM.IS_MAC && - pendingQuitMode === null && - manager.hasActiveInstances() - ) { + if (PLATFORM.IS_MAC && quitMode === null && manager.hasActiveInstances()) { event.preventDefault(); for (const win of BrowserWindow.getAllWindows()) { win.hide(); @@ -203,7 +203,7 @@ app.on("before-quit", async (event) => { // Show confirmation only for implicit quit in production with setting enabled const isDev = process.env.NODE_ENV === "development"; - if (pendingQuitMode === null && !isDev && getConfirmOnQuitSetting()) { + if (quitMode === null && !isDev && getConfirmOnQuitSetting()) { event.preventDefault(); try { @@ -225,7 +225,7 @@ app.on("before-quit", async (event) => { } isQuitting = true; - if (pendingQuitMode === "stop") { + if (quitMode === "stop") { manager.stopAll(); } else { manager.releaseAll(); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index b094c4054c5..ea137870043 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -769,7 +769,7 @@ export class HostServiceManager extends EventEmitter { this.scheduledRestarts.delete(organizationId); const current = this.instances.get(organizationId); if (current?.status === "degraded") { - this.instances.delete(organizationId); + // Don't delete the instance — spawn() reads restartCount from it this.spawn(organizationId).catch((err) => { console.error( `[host-service:${organizationId}] restart failed:`, diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 1e8925fb55a..1a469c70ab5 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -103,6 +103,7 @@ export function connect( cancelReconnect(transport); transport.currentUrl = wsUrl; transport._terminal = terminal; + transport._exited = false; setConnectionState(transport, "connecting"); const socket = new WebSocket(wsUrl); transport.socket = socket; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx index 395f9febcd2..e5093073abd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx @@ -97,8 +97,10 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { }; for (const orgId of orgIds) { + const org = organizations?.find((o) => o.id === orgId); const cached = utils.hostServiceManager.getLocalPort.getData({ organizationId: orgId, + organizationName: org?.name ?? undefined, }); if (cached?.port) { addOrg(orgId, cached.port, cached.secret ?? null); @@ -119,7 +121,7 @@ export function HostServiceProvider({ children }: { children: ReactNode }) { } return map; - }, [orgIds, utils, activeOrganizationId, activePortData]); + }, [orgIds, organizations, utils, activeOrganizationId, activePortData]); const value = useMemo(() => ({ services }), [services]); From 8a9d4a99569cb2d2c54726f9301cc5ea24bf1e2d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 10:57:40 -0700 Subject: [PATCH 06/14] Encding --- apps/desktop/src/main/lib/host-service-manifest.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/lib/host-service-manifest.ts b/apps/desktop/src/main/lib/host-service-manifest.ts index d8e0240020c..e3c27404ed3 100644 --- a/apps/desktop/src/main/lib/host-service-manifest.ts +++ b/apps/desktop/src/main/lib/host-service-manifest.ts @@ -30,12 +30,15 @@ function manifestPath(organizationId: string): string { export function writeManifest(manifest: HostServiceManifest): void { const dir = manifestDir(manifest.organizationId); if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); } writeFileSync( manifestPath(manifest.organizationId), JSON.stringify(manifest), - "utf-8", + { + encoding: "utf-8", + mode: 0o600, + }, ); } From 6a34d11f1db7252d58c945a6a11cc71d28aeed8b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 11:04:11 -0700 Subject: [PATCH 07/14] Delete corrupted manifest --- apps/desktop/src/main/lib/host-service-manager.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index ea137870043..c4115f0a7be 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -248,7 +248,15 @@ export class HostServiceManager extends EventEmitter { const manifests = listManifests(); for (const manifest of manifests) { if (this.instances.has(manifest.organizationId)) continue; - await this.tryAdopt(manifest.organizationId); + try { + await this.tryAdopt(manifest.organizationId); + } catch (error) { + console.error( + `[host-service:${manifest.organizationId}] Failed to adopt, removing bad manifest:`, + error, + ); + removeManifest(manifest.organizationId); + } } } @@ -644,7 +652,9 @@ export class HostServiceManager extends EventEmitter { const previousStatus = instance.status; instance.status = "degraded"; pendingStart.reject(error); - instance.process?.kill("SIGTERM"); + const child = instance.process; + instance.process = null; + child?.kill("SIGTERM"); instance.lastCrash = Date.now(); this.emitStatus(instance.organizationId, "degraded", previousStatus); this.scheduleRestart(instance.organizationId); From 0ec9aa638a63bee58d1ffae0da29f4be02fbb0c9 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 11:05:43 -0700 Subject: [PATCH 08/14] Deslop --- apps/desktop/src/main/index.ts | 4 +--- apps/desktop/src/main/lib/host-service-manager.ts | 9 +-------- apps/desktop/src/main/lib/tray/index.ts | 1 - 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 21d7bab33ff..917a135382e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -201,7 +201,6 @@ app.on("before-quit", async (event) => { return; } - // Show confirmation only for implicit quit in production with setting enabled const isDev = process.env.NODE_ENV === "development"; if (quitMode === null && !isDev && getConfirmOnQuitSetting()) { event.preventDefault(); @@ -349,7 +348,7 @@ if (!gotTheLock) { try { return await net.fetch(pathToFileURL(fontPath).toString()); } catch { - // Font not in this directory, try next + // Not in this directory } } return new Response("Not found", { status: 404 }); @@ -385,7 +384,6 @@ if (!gotTheLock) { setupAutoUpdater(); initTray(); - // Process any deep links from cold start const coldStartUrl = findDeepLinkInArgv(process.argv); if (coldStartUrl) { await processDeepLink(coldStartUrl); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index c4115f0a7be..e78881a68b6 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -343,7 +343,6 @@ export class HostServiceManager extends EventEmitter { }; } - /** Returns true if any instance is in running or starting state */ hasActiveInstances(): boolean { for (const instance of this.instances.values()) { if (instance.status === "running" || instance.status === "starting") { @@ -353,7 +352,6 @@ export class HostServiceManager extends EventEmitter { return this.pendingStarts.size > 0; } - /** Returns all organization IDs with active host-service instances */ getActiveOrganizationIds(): string[] { const ids: string[] = []; for (const [id, instance] of this.instances) { @@ -387,10 +385,7 @@ export class HostServiceManager extends EventEmitter { // ── Discovery / Adoption ────────────────────────────────────────── - /** - * Try to adopt an already-running host-service from its on-disk manifest. - * Returns the port if adoption succeeds, null otherwise. - */ + /** Try to adopt an already-running host-service from its on-disk manifest. */ private async tryAdopt(organizationId: string): Promise { const manifest = readManifest(organizationId); if (!manifest) return null; @@ -682,7 +677,6 @@ export class HostServiceManager extends EventEmitter { instance.startedAt = Date.now(); instance.restartCount = 0; - // Pick up version info from the ready message if available if ( "serviceVersion" in message && typeof message.serviceVersion === "string" @@ -700,7 +694,6 @@ export class HostServiceManager extends EventEmitter { `[host-service:${instance.organizationId}] listening on port ${message.port} (v${instance.serviceVersion}, protocol=${instance.protocolVersion})`, ); - // Check compatibility on connect const compat = checkCompatibility(instance); if (compat && !compat.compatible) { console.warn( diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 181a77e307a..0cc335788ba 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -281,7 +281,6 @@ export function initTray(): void { updateTrayMenu(); - // Rebuild menu on host-service status changes const manager = getHostServiceManager(); manager.on("status-changed", (_event: HostServiceStatusEvent) => { updateTrayMenu(); From fcd0a08d723db55c7217368035f809c42824a966 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 11:08:26 -0700 Subject: [PATCH 09/14] Host Service Lifecycle --- apps/desktop/HOST_SERVICE_LIFECYCLE.md | 408 ++----------------------- 1 file changed, 24 insertions(+), 384 deletions(-) diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/HOST_SERVICE_LIFECYCLE.md index d0010f41a5d..8e401349d91 100644 --- a/apps/desktop/HOST_SERVICE_LIFECYCLE.md +++ b/apps/desktop/HOST_SERVICE_LIFECYCLE.md @@ -1,394 +1,34 @@ -# Host Service Lifecycle: Current State And Electron Benchmarks +# Host Service Lifecycle -Date: 2026-04-03 +## Architecture -## Purpose +Electron main owns app lifecycle, tray, and host-service management. Host-services run as child processes that can outlive the app via manifest-based adoption. -This document replaces the scattered plan notes with one view of: +``` +Electron Main +├── Quit policy (requestQuit / prepareQuit / exitImmediately) +├── Tray (macOS only — status, restart, stop, quit) +├── HostServiceManager (start, stop, adopt, restart per org) +│ └── host-service child processes (survive app quit) +│ └── manifest.json (on-disk handoff for re-adoption) +└── Windows (disposable — hide to tray on macOS) +``` -- the current desktop lifecycle shape in this codebase -- the important gap between that shape and the desired tray UX -- how other Electron apps handle similar lifecycle boundaries -- the architectural consequence for Superset +### Quit modes -The product requirement assumed here is: +All quit paths use a single `QuitMode` (`"release" | "stop"`): -- local services should keep running when the UI closes -- the tray should remain available while those local services are alive -- the tray should be able to reopen the UI -- `Quit` from the tray should stop all local services and exit everything +- **release** — detach from services, they keep running for re-adoption on next launch +- **stop** — SIGTERM all services, then exit +- **implicit** (Cmd+Q with active services on macOS) — hide windows to tray -## Current State Of The Superset Desktop Codebase +### Service adoption -### 1. Electron `main` still owns app lifecycle today +On startup, the manager scans `~/.superset/host/*/manifest.json`, health-checks each endpoint, and reconnects to surviving services. Incompatible or unreachable services are cleaned up and respawned. -Today, app lifecycle is still centered in the window-owning Electron main -process. +### Design decisions -- `apps/desktop/src/main/index.ts` - - `before-quit` confirms quit, then calls `getHostServiceManager().stopAll()`, - disposes the tray, and exits the app -- `apps/desktop/src/lib/electron-app/factories/app/setup.ts` - - `window-all-closed` quits the app on non-macOS - - on macOS, the app remains alive after the last window closes - -That means the current app can already keep the process alive without windows on -macOS, but it is still one process owning: - -- windows -- tray -- quit policy -- host-service startup and shutdown - -It is not yet split into a durable desktop shell and a disposable UI process. - -### 2. The current tray is daemon-oriented, not host-service-oriented - -The tray implementation is still tied to the legacy terminal daemon model. - -- `apps/desktop/src/main/lib/tray/index.ts` - - polls daemon sessions with `tryListExistingDaemonSessions()` - - shows "Keep Sessions" vs "Kill Sessions" - - calls `restartDaemonShared()` to kill daemon-backed sessions - - is only initialized on macOS - -So the tray today is not a host-service control surface. It is a terminal daemon -control surface that happens to live in Electron main. - -### 3. `HostService` is process-separated, but still parent-owned - -`HostService` is a child process of Electron main, not an independently owned -background service. - -- `apps/desktop/src/main/lib/host-service-manager.ts` - - spawns `host-service.js` with stdio + IPC - - waits for a `ready` IPC message containing the port - - restarts crashed children -- `apps/desktop/src/main/host-service/index.ts` - - reports the port back via `process.send` - - exits on `SIGTERM` and `SIGINT` - - polls `process.ppid` and shuts down when the parent dies - -This is important: the code already has process separation, but it does not yet -have lifecycle separation. If Electron main exits, the tray dies and -`HostService` also dies. - -### 4. The renderer eagerly starts host-service, but only v2 local really depends on it - -The authenticated renderer currently starts host-service per organization: - -- `apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx` - - calls `utils.hostServiceManager.getLocalPort.ensureData(...)` for every org - - builds a map of org id to local host-service URL/client - -That gives the renderer a host-service connection surface, but the user-facing -benefit is not uniform across the app. - -### 5. v1 and v2 still have different runtime owners - -This is the most important current-state fact. - -#### v1 - -v1 terminals still run on the legacy Electron-owned stack. - -- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` - - subscribes to `electronTrpc.terminal.stream` -- `apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/SessionsSection.tsx` - - manages daemon sessions - - exposes "Kill all sessions" and "Restart daemon" - -The current tray and terminal settings UX still line up with v1. - -#### v2 local - -v2 local already treats host-service as the runtime boundary. - -- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx` - - resolves a local host URL from `useHostService()` for local workspaces -- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx` - - attaches a terminal runtime to `/terminal/:terminalId` over websocket - - detaches on unmount instead of immediately killing the runtime -- `apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts` - - disposes terminal runtimes only when their ids disappear from persisted pane - state -- `packages/host-service/src/terminal/terminal.ts` - - keeps PTY lifetime independent of socket lifetime - - allows detach and reattach to an existing terminal id - -This is much closer to the desired long-lived runtime model. - -### 6. The current mismatch - -Putting the pieces together: - -- app lifecycle is still owned by Electron main -- tray still reflects the legacy daemon -- host-service is still parent-owned -- v1 and v2 use different runtime owners -- v2 local is the only place where host-service persistence is already paying - off architecturally - -So the codebase is currently between two architectures: - -1. legacy app-owned runtime with daemon-oriented persistence -2. host-service-oriented runtime for v2 local - -That is why lifecycle work feels tangled right now. - -## What The Desired UX Actually Requires - -The requested UX is stricter than "keep the app alive with no windows." - -It requires: - -- tray survives UI process exit -- local services survive UI process exit -- tray `Quit` is the authoritative "stop services and exit everything" action - -That is not the same as the usual Electron pattern of: - -- keep Electron main alive -- hide the last window -- show a tray icon - -That simpler pattern is enough when the tray and background work are allowed to -die with the app process. It is not enough when the UI process is supposed to be -disposable while the tray and local services continue. - -## How Other Electron Apps Handle Lifecycle - -### 1. Electron Platform Baseline - -Electron itself makes the core rule explicit: - -- if you do not subscribe to `window-all-closed`, Electron quits by default -- if you do subscribe, you own the quit policy -- `Tray` is a main-process API - -That gives two baseline implications: - -1. a tray normally lives in the process that owns Electron main lifecycle -2. a tray does not outlive the process that owns Electron main lifecycle - -Source: - -- Electron `app` docs: - `https://www.electronjs.org/docs/latest/api/app/` -- Electron `Tray` docs: - `https://www.electronjs.org/docs/latest/api/tray/` - -### 2. GitHub Desktop: Main Process Owns Everything - -GitHub Desktop is an example of the classic Electron model: - -- one main app process owns lifecycle -- it explicitly overrides `window-all-closed` -- it controls visibility and quit behavior from that process - -In `app/src/main-process/main.ts`, GitHub Desktop subscribes to -`window-all-closed` specifically so Electron does not auto-quit before the app's -own window-close logic decides what to do. - -This is a good example of: - -- app-owned lifecycle -- no separate supervisor -- no durable tray/service owner outside the main app process - -Takeaway for Superset: - -- this is a good fit if "background mode" only means "keep the main app alive" -- it is not enough if we want the UI process to be disposable while tray and - local services continue - -Source: - -- GitHub Desktop main process: - `https://raw.githubusercontent.com/desktop/desktop/development/app/src/main-process/main.ts` - -### 3. Element Desktop: Tray Works By Keeping The App Process Alive - -Element Desktop is a useful tray example. - -In `src/electron-main.ts`: - -- when the user closes the main window and the app is not quitting, it hides - the window if a tray exists -- `before-quit` marks that the app is really quitting -- `window-all-closed` then quits the app - -This is the common tray pattern in Electron apps: - -- close window -> hide to tray -- explicit quit -> actually exit - -Takeaway for Superset: - -- this pattern is good for "close window, keep running in tray" -- it still depends on the same long-lived Electron app process -- the tray does not survive app-process exit - -Source: - -- Element Desktop main process: - `https://raw.githubusercontent.com/element-hq/element-desktop/develop/src/electron-main.ts` - -### 4. VS Code: Main Lifecycle Plus Separate Helper Processes - -VS Code does not use a tray-first UX, but it is still the most useful benchmark -for process boundaries. - -VS Code keeps application lifecycle in the main Electron process, but moves -specific long-lived domains into helper processes: - -- `SharedProcess` - - a utility process for shared services used across windows -- `pty-host` - - a dedicated process for terminal PTYs - -Important detail: those helper processes are still under main-process lifecycle. -On shutdown, VS Code's lifecycle service tells them to exit. - -This is not a supervisor architecture. It is: - -- main process owns lifecycle -- helper processes own specific runtime domains -- windows connect to those processes through IPC/message ports - -Takeaway for Superset: - -- VS Code is a strong benchmark for separating runtime ownership from renderer - ownership -- it is not a benchmark for "tray survives UI process exit" -- it shows the value of a dedicated runtime owner like `pty-host`, but not the - value of putting shell UX into that runtime owner - -Sources: - -- VS Code lifecycle service: - `https://raw.githubusercontent.com/microsoft/vscode/main/src/vs/platform/lifecycle/electron-main/lifecycleMainService.ts` -- VS Code shared process: - `https://raw.githubusercontent.com/microsoft/vscode/main/src/vs/platform/sharedProcess/electron-main/sharedProcess.ts` -- VS Code pty host starter: - `https://raw.githubusercontent.com/microsoft/vscode/main/src/vs/platform/terminal/electron-main/electronPtyHostStarter.ts` -- VS Code main app: - `https://raw.githubusercontent.com/microsoft/vscode/main/src/vs/code/electron-main/app.ts` - -## Pattern Summary - -| Pattern | Example | What owns tray | What owns runtime helpers | What happens when app process exits | -| --- | --- | --- | --- | --- | -| Single main-process owner | GitHub Desktop | main process | main process or children | tray and helpers die | -| Tray hide/minimize pattern | Element Desktop | main process | main process or children | tray and helpers die | -| Main + helper processes | VS Code | no tray focus | helper processes for domains like shared services and PTYs | helpers are shut down by main | -| Separate supervisor + runtime service | not the common default Electron pattern | supervisor | separate runtime service | tray can survive UI process exit if supervisor stays alive | - -The key point is that the first three patterns all keep tray ownership in the -desktop shell process, not inside the runtime helper. - -## What This Means For Superset - -### 1. Do Not Put The Tray Inside `HostService` - -The external benchmarks do not support putting shell UX into the runtime owner. - -Why: - -- Electron tray APIs are main-process shell APIs -- tray behavior is about desktop lifecycle policy, not workspace runtime state -- runtime services should stay reusable and headless -- restarting the runtime service should not imply restarting the tray shell - -So `HostService` should remain a headless runtime owner. - -### 2. A Supervisor Process Is The Right Fit For The Desired UX - -Given the stated requirement, the clean split is: - -- `BackgroundSupervisor` - - owns tray - - owns `Quit` - - owns `Open Superset` - - owns host-service discovery/adoption/restart -- `HostService` - - owns long-lived local runtime state - - owns terminal and future local services - - remains headless -- UI process - - owns windows only - - can exit and relaunch without changing service lifetime - -This is different from the current codebase, where Electron main still owns all -three roles. - -### 3. v2 Local Should Be The First-Class Migration Target - -The current codebase already points in this direction: - -- v2 local already depends on host-service as its runtime boundary -- v2 terminal panes already use attach/detach semantics -- global terminal disposal in v2 is already based on persisted pane state, not - immediate React unmount - -By contrast: - -- v1 still uses Electron-owned terminal runtime and daemon-centric UX -- tray and settings still describe daemon sessions, not host-service services - -So the least risky migration story is: - -1. make supervisor + host-service correct for v2 local -2. keep v1 explicit as a compatibility path during migration -3. retire or migrate v1 instead of deeply coupling the supervisor to both models - -## Proposed Target Lifecycle Contract - -### Close last window - -- closes the UI process only -- does not stop `HostService` -- does not remove the tray - -### Open Superset from tray - -- launches or focuses the UI process -- reattaches UI to already-running host-service state - -### HostService crash - -- supervisor detects failure -- tray reflects degraded state -- supervisor may restart host-service according to policy - -### Quit from tray - -- stop all hosted services -- stop `HostService` -- dispose tray -- exit supervisor - -That contract matches the stated product requirement and avoids overloading -either the renderer or `HostService` with desktop-shell responsibilities. - -## Bottom Line - -The current codebase is halfway between an old Electron-owned terminal model and -a new host-service-owned v2 local model. - -The external benchmarks point to a clean conclusion: - -- tray ownership belongs in the desktop shell layer -- runtime ownership belongs in a headless service layer -- renderer/window lifetime should not define runtime lifetime - -For Superset's requested UX, that means: - -- do not move tray into `HostService` -- do not keep lifecycle centered in the current window-owning Electron main - forever -- introduce a `BackgroundSupervisor` that owns tray and app lifecycle policy -- keep `HostService` as the headless runtime owner - -That is the architecture that best matches both the current v2 direction and -the desired Docker-like tray behavior. +- **No supervisor process.** Electron main owns everything. Simpler while v1 and v2 coexist. +- **No tray on Windows/Linux.** Services still survive quit and are re-adopted, but there's no persistent UI to manage them. +- **Tray calls `requestQuit(mode)`.** One function, one codepath — no setter chains or flag mutation. +- **Manifest handling is single-sourced.** Both parent and child use `host-service-manifest.ts`. Files are written with 0o600 permissions. From cdae57094b6aa07e5ec86ced3d7fb6a79bfd8cce Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 11:10:48 -0700 Subject: [PATCH 10/14] Update PR --- apps/desktop/HOST_SERVICE_LIFECYCLE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/HOST_SERVICE_LIFECYCLE.md index 8e401349d91..20d33fe0c9f 100644 --- a/apps/desktop/HOST_SERVICE_LIFECYCLE.md +++ b/apps/desktop/HOST_SERVICE_LIFECYCLE.md @@ -26,6 +26,12 @@ All quit paths use a single `QuitMode` (`"release" | "stop"`): On startup, the manager scans `~/.superset/host/*/manifest.json`, health-checks each endpoint, and reconnects to surviving services. Incompatible or unreachable services are cleaned up and respawned. +### v1 vs v2 terminal paths + +v1 terminals run on a separate **terminal-host daemon** (`src/main/terminal-host/`) — a persistent background process that owns PTYs over a Unix domain socket. It has its own survival and reconnection model independent of host-service. + +v2 terminals run through **host-service** child processes. The quit/adopt/tray lifecycle described here only applies to host-service instances. + ### Design decisions - **No supervisor process.** Electron main owns everything. Simpler while v1 and v2 coexist. From ae0f678f9baa6b213141c9f9ad0f469c59db38d2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 11:23:41 -0700 Subject: [PATCH 11/14] Diagram --- apps/desktop/HOST_SERVICE_LIFECYCLE.md | 53 +++++++++++++++++++++----- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/HOST_SERVICE_LIFECYCLE.md index 20d33fe0c9f..3daf0d05dc4 100644 --- a/apps/desktop/HOST_SERVICE_LIFECYCLE.md +++ b/apps/desktop/HOST_SERVICE_LIFECYCLE.md @@ -5,13 +5,35 @@ Electron main owns app lifecycle, tray, and host-service management. Host-services run as child processes that can outlive the app via manifest-based adoption. ``` -Electron Main -├── Quit policy (requestQuit / prepareQuit / exitImmediately) -├── Tray (macOS only — status, restart, stop, quit) -├── HostServiceManager (start, stop, adopt, restart per org) -│ └── host-service child processes (survive app quit) -│ └── manifest.json (on-disk handoff for re-adoption) -└── Windows (disposable — hide to tray on macOS) +┌─────────────────────────────────────────────────────┐ +│ Electron Main Process │ +│ │ +│ ┌──────────┐ ┌──────────────────────┐ ┌───────┐ │ +│ │ Tray │ │ HostServiceManager │ │Windows│ │ +│ │ (macOS) │ │ │ │ │ │ +│ │ │◄─┤ status events │ │ hide/ │ │ +│ │ restart │ │ start/stop/adopt │ │ show │ │ +│ │ stop │ │ per org │ │ │ │ +│ │ quit ────┼──┼──► requestQuit(mode) │ │ │ │ +│ └──────────┘ └──────┬───────────────┘ └───────┘ │ +└───────────────────────┼─────────────────────────────┘ + │ IPC + stdio + ┌─────────────┼─────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │host-service│ │host-service│ │host-service│ + │ (org A) │ │ (org B) │ │ (org C) │ + │ │ │ │ │ │ + │ HTTP/tRPC │ │ HTTP/tRPC │ │ HTTP/tRPC │ + │ port:rand │ │ port:rand │ │ port:rand │ + │ │ │ │ │ │ + │ writes │ │ writes │ │ writes │ + │ manifest │ │ manifest │ │ manifest │ + └────────────┘ └────────────┘ └────────────┘ + │ │ │ + ▼ ▼ ▼ + ~/.superset/host/{orgId}/manifest.json ``` ### Quit modes @@ -22,9 +44,22 @@ All quit paths use a single `QuitMode` (`"release" | "stop"`): - **stop** — SIGTERM all services, then exit - **implicit** (Cmd+Q with active services on macOS) — hide windows to tray -### Service adoption +### Manifest adoption -On startup, the manager scans `~/.superset/host/*/manifest.json`, health-checks each endpoint, and reconnects to surviving services. Incompatible or unreachable services are cleaned up and respawned. +Each host-service child writes `~/.superset/host/{orgId}/manifest.json` on startup (pid, endpoint, authToken, version). It's a pidfile extended with connection info. + +- **Release quit** — children keep running, manifests stay on disk +- **Next launch** — `discoverAndAdoptAll()` scans manifests, health-checks each pid/endpoint, reconnects if healthy, removes and respawns if not +- **Stop quit** — SIGTERM children, they remove their own manifests on shutdown + +``` +App Launch App Quit (release) Next Launch +───────── ────────────────── ─────────── +spawn child ──► child writes parent detaches scan manifests + manifest.json manifests stay on disk health-check pid/endpoint + {pid, endpoint, child keeps running ├─ healthy → reconnect + authToken, ...} └─ dead/bad → remove, respawn +``` ### v1 vs v2 terminal paths From 375e4dadf0484b37cb39c9c7c129a0527fbb55ad Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sat, 4 Apr 2026 12:54:50 -0700 Subject: [PATCH 12/14] docs: add host service architecture and boundaries docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the target architecture for the host service package and Electron desktop layer — API shapes, ownership boundaries, and what needs to move to make the host service deployable standalone without Electron awareness. --- apps/desktop/HOST_SERVICE_ARCHITECTURE.md | 62 ++++ apps/desktop/HOST_SERVICE_BOUNDARIES.md | 397 ++++++++++++++++++++++ 2 files changed, 459 insertions(+) create mode 100644 apps/desktop/HOST_SERVICE_ARCHITECTURE.md create mode 100644 apps/desktop/HOST_SERVICE_BOUNDARIES.md diff --git a/apps/desktop/HOST_SERVICE_ARCHITECTURE.md b/apps/desktop/HOST_SERVICE_ARCHITECTURE.md new file mode 100644 index 00000000000..528479413f0 --- /dev/null +++ b/apps/desktop/HOST_SERVICE_ARCHITECTURE.md @@ -0,0 +1,62 @@ +# Host Service Architecture + +What a host service is, how it's layered, and what needs to change. + +## What is a host service? + +A process that runs workspaces on a machine — laptop or remote server. It clones repos, runs terminals, watches filesystems, runs AI chat, and registers itself with the cloud as a **host**. + +A **device** is anything that connects (phone, browser, desktop app). A **host** is something that runs workspaces. A MacBook is both. A phone is only a device. A remote server is only a host. + +The host service must be deployable standalone with zero Electron awareness. + +## Layering + +``` +┌──────────────────────────────────────────────────────────────┐ +│ ELECTRON DESKTOP (apps/desktop) │ +│ │ +│ Owns: │ +│ - Spawning / adopting / releasing host service processes │ +│ - Desktop-specific credential providers │ +│ - Session config (auth token, cloud API URL) │ +│ - System tray UI │ +│ - Quit flow (release vs stop) │ +│ - Manifest files (on-disk persistence for process adoption) │ +│ │ +│ Does NOT own: │ +│ - Workspace CRUD, host registration, terminal sessions │ +│ - Organization metadata (the host service knows its own) │ +│ - Any business logic a remote host would also need │ +├──────────────────────────────────────────────────────────────┤ +│ HOST SERVICE (packages/host-service) │ +│ │ +│ Owns: │ +│ - Workspace lifecycle (create, delete, list) │ +│ - Host registration with the cloud │ +│ - Terminal PTY management │ +│ - Filesystem watching │ +│ - Git operations │ +│ - AI chat runtime │ +│ - Its own identity and metadata (host.info endpoint) │ +│ │ +│ Does NOT own: │ +│ - How it was started (Electron vs systemd vs docker) │ +│ - Credential discovery (keychain, ~/.claude, git cred mgr) │ +│ - Default paths like ~/.superset/host.db │ +│ - Electron concepts (resourcesPath, manifests, etc.) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Host vs Device + +Rename in host service context: +- `deviceClientId` → `hostId` (generated internally from machine identity) +- `deviceName` → `hostName` (generated internally from `os.hostname()`) +- `device.ensureV2Host` → `host.register` + +Host identity is intrinsic — the host service generates it at startup, not passed in as config. + +--- + +For API shapes, boundaries, and concrete migration steps, see [HOST_SERVICE_BOUNDARIES.md](./HOST_SERVICE_BOUNDARIES.md). diff --git a/apps/desktop/HOST_SERVICE_BOUNDARIES.md b/apps/desktop/HOST_SERVICE_BOUNDARIES.md new file mode 100644 index 00000000000..21340029654 --- /dev/null +++ b/apps/desktop/HOST_SERVICE_BOUNDARIES.md @@ -0,0 +1,397 @@ +# Host Service Boundaries + +API shapes and boundaries between the host service, the Electron desktop layer, and the tray. + +--- + +## 1. Host Service (`packages/host-service`) + +### `createApp()` — the sole entry point + +```ts +createApp({ + config: { + dbPath: string, // where the SQLite database lives + cloudApiUrl: string, // where the cloud API is + migrationsPath: string, // where Drizzle migration files live + allowedOrigins: string[], // CORS allowlist + }, + providers: { + auth: ApiAuthProvider, // outbound: how to authenticate with the cloud API + hostAuth: HostAuthProvider, // inbound: how to validate requests to this service + credentials: GitCredentialProvider, // how to get git/GitHub credentials + modelResolver: ModelProviderResolver, // how to resolve AI model credentials + }, +}); +``` + +All fields required. No optional fields. No defaults that assume a desktop environment. + +**Config** = static values (strings, paths, URLs). **Providers** = injectable behavior (interfaces with different implementations per deployment). + +**Not config, not providers:** + +- `hostId` / `hostName` — generated internally by the host service from machine identity +- Version — the service reads its own version from package.json, not from a passed-in string. + +### Provider interfaces + +```ts +interface ApiAuthProvider { + getHeaders(): Promise>; +} + +interface HostAuthProvider { + validate(request: Request): Promise; + validateToken(token: string): Promise; +} + +interface GitCredentialProvider { + getToken(host: string): Promise; +} + +interface ModelProviderResolver { + resolve(cwd: string): Promise; + // Returns env vars — does NOT mutate process.env +} +``` + +### tRPC endpoints + +**Unauthenticated (liveness probes):** + +```ts +health.check → { status: "ok" } +``` + +**Authenticated (PSK) — host identity and metadata:** + +This is how the tray gets the information it needs. `host.info` is the single source of truth for "who is this host" — no metadata passed through the Electron layer. + +```ts +host.info → { + hostId: string, + hostName: string, + organization: { + id: string, + name: string, + slug: string, + }, + version: string, // from package.json + platform: string, + uptime: number, +} +``` + +**Authenticated (PSK) — workspace and project management:** + +```ts +workspace.create → ... +workspace.delete → ... +workspace.list → ... +project.remove → ... // renamed from removeFromDevice +``` + +**Authenticated (PSK) — WebSocket routes:** + +```ts +terminal/* → WebSocket +filesystem/* → WebSocket +``` + +### What the host service is NOT + +`createApp()` is a factory — it wires config + providers into a Hono server and returns it. There is no "host service manager" inside the package. The complexity of the current `createApp()` (~150 lines) is just plumbing: create DB, create git factory, create API client, register routes. Provider construction is one-liners (`new PskHostAuthProvider(secret)`, etc.) — the callers are simple. + +--- + +## 2. Electron Coordinator (`apps/desktop`) + +Manages host service child processes. This is the only complex piece on the Electron side. + +### Interface + +```ts +interface HostServiceCoordinator { + // Lifecycle + start(organizationId: string, config: SpawnConfig): Promise<{ port: number; secret: string }>; + stop(organizationId: string): void; + restart(organizationId: string, config: SpawnConfig): Promise<{ port: number; secret: string }>; + stopAll(): void; + releaseAll(): void; + + // Discovery + discoverAll(): Promise; // scan manifests, adopt running services + + // Queries + getConnection(organizationId: string): { port: number; secret: string } | null; + getProcessStatus(organizationId: string): ProcessStatus; + getActiveOrganizationIds(): string[]; + hasActiveInstances(): boolean; + + // Events + on(event: "status-changed", handler: (e: StatusEvent) => void): void; +} + +interface SpawnConfig { + authToken: string; + cloudApiUrl: string; + dbPath: string; + migrationsPath: string; + allowedOrigins: string[]; +} + +type ProcessStatus = "starting" | "running" | "degraded" | "restarting" | "stopped"; + +interface StatusEvent { + organizationId: string; + status: ProcessStatus; + previousStatus: ProcessStatus | null; +} +``` + +### Per-instance state + +After a service is running (whether spawned or adopted), the coordinator holds: + +```ts +{ + pid: number, // the OS process ID — used for liveness checks and SIGTERM + port: number, // from ready message (spawned) or manifest (adopted) + secret: string, // PSK for authenticating with this instance +} +``` + +That's the steady-state. During spawn, the coordinator picks a free port, passes it to the host service as config (env var), then polls `health.check` on that port until the service is up. No Node IPC channel needed — the host service just starts on the port it's told. Once healthy, the coordinator records the pid/port/secret and discards the `ChildProcess` handle (`unref`'d so it survives app quit). From that point, spawned and adopted processes are treated identically: just a PID to check liveness and signal, a port to connect to, and a secret to authenticate. + +### Where the complexity lives + +The coordinator is ~500 lines. This is irreducible complexity from managing processes that survive app restarts: + +| Concern | Why it's unavoidable | +|---------|---------------------| +| Spawn + health poll | Must start the child, poll health.check until ready, handle timeout | +| Adoption from manifests | Must read disk, health-check the process, verify it's reachable | +| Liveness polling | Adopted processes have no exit event — must poll PID | +| Restart with backoff | Crashed services need exponential backoff, not immediate retry | +| Pending start dedup | Concurrent `start()` calls for the same org must coalesce | +| Release vs stop | Quit flow needs to either detach or kill each service | + +The current 800-line manager mixes these with org metadata, session config, display formatting, compatibility checks, and version tracking. The coordinator drops all of that — it only manages processes. The ~300 lines saved aren't from removing complexity; they're from removing concerns that don't belong. + +### What the coordinator does NOT hold + +| Data | Where it lives instead | +|------|----------------------| +| Organization name/metadata | Host service (`host.info` endpoint) | +| Auth token, cloud API URL | Passed per-call as `SpawnConfig`, not stored | +| Service version | Host service (`host.info` endpoint) | +| Uptime | Host service (`host.info` endpoint) | +| Compatibility / pending restart | Derived at query time by comparing `host.info` version vs app version | + +### Config passing + +```ts +// Before (mutate-then-call anti-pattern) +manager.setAuthToken(token); +manager.setCloudApiUrl(url); +manager.setOrganizationName(organizationId, name); +await manager.start(organizationId); + +// After (pass config per-call) +await coordinator.start(organizationId, { + authToken: token, + cloudApiUrl: url, + dbPath: path.join(orgDir, "host.db"), + migrationsPath: getMigrationsPath(), + allowedOrigins: [`http://localhost:${vitePort}`], +}); +``` + +--- + +## 3. Tray (`apps/desktop`) + +Pure view. Reads from two sources, writes to coordinator. + +### Data sources + +``` +From host.info (HTTP to each service, authenticated with PSK): + - organization.name → menu section header + - version → display label + - uptime → display label + +From coordinator (in-process): + - status → "Running" / "Starting..." / "Degraded" + - hasActiveInstances → controls quit menu options +``` + +### Actions + +``` +Restart → coordinator.restart(organizationId, config) +Stop → coordinator.stop(organizationId) +Quit (keep services) → coordinator.releaseAll() + app.exit() +Quit (stop services) → coordinator.stopAll() + app.exit() +``` + +### Menu structure + +``` +Host Service (N) +├── ← from host.info +│ ├── Running (v1.2.3) ← status from coordinator, version from host.info +│ ├── Uptime: 2h 15m ← from host.info +│ ├── Restart +│ └── Stop +├── ───────── +├── +│ └── ... +├── ───────── +├── Open Superset +├── Settings +├── Check for Updates +├── ───────── +├── Quit (Keep Services Running) ← only if hasActiveInstances +└── Quit & Stop Services ← only if hasActiveInstances +``` + +--- + +## 4. Renderer HostServiceProvider (`apps/desktop`) + +Queries the coordinator for connection info, then talks directly to host services over HTTP/WS. + +```ts +// From coordinator (via tRPC IPC) +const { port, secret } = await trpc.hostService.getConnection.query({ organizationId }); + +// Direct to host service (HTTP/WS) +const client = createHostServiceClient(port, secret); +await client.workspace.list.query(); +``` + +The provider maintains `Map` — just connection info. No metadata caching. + +--- + +## 5. Manifest (`apps/desktop` — Electron-only concept) + +On-disk JSON file per org. Written by the coordinator once the spawned service reports it's ready (pid, port). Read by the coordinator for adoption on next app launch. The host service itself has no knowledge of manifests. + +```ts +interface Manifest { + pid: number, + endpoint: string, // e.g. "http://127.0.0.1:4832" + authToken: string, // PSK secret for this instance + startedAt: number, + organizationId: string, +} +``` + +Minimal — just enough to reconnect. No version or protocol fields; the coordinator queries `host.info` after adoption for metadata if needed. + +Lives at `~/.superset/host//manifest.json`. The coordinator writes and reads it. Remote deployments don't use manifests. + +--- + +## 6. What moves where + +### Out of `packages/host-service` + +| Item | Current location | Moves to | Reason | +| --- | --- | --- | --- | +| `process.resourcesPath` / `ELECTRON_RUN_AS_NODE` | `db.ts` | Electron entry point | `migrationsPath` is now required config | +| `ORGANIZATION_ID` from `process.env` | `health.ts` | Removed | Org info served via `host.info`, fetched from cloud at registration | +| `LocalModelProvider` as default | `app.ts` | Injected by caller | `modelResolver` is required, no default | +| `LocalGitCredentialProvider` as default | `app.ts` | Injected by caller | `credentials` is required, no default | +| Default `~/.superset/host.db` | `app.ts` | Injected by caller | `dbPath` is required, no default | +| `~/.superset/chat-anthropic-env.json` | `anthropic-runtime-env.ts` | Moves with `LocalModelProvider` | Desktop-only path | +| macOS Keychain reads | `resolveAnthropicCredential.ts` | Moves with `LocalModelProvider` | macOS-only | +| `~/.claude/` credential reads | `resolveAnthropicCredential.ts` | Moves with `LocalModelProvider` | Claude Desktop-only | +| `project.removeFromDevice` | `project.ts` | Rename to `project.remove` | "Device" framing is wrong | +| `process.env` mutations in `applyRuntimeEnv()` | `runtime-env.ts` | Model providers return env, don't mutate | Dangerous in multi-tenant context | +| `health.info` (current combined endpoint) | `health.ts` | Split into `health.check` + `host.info` | Liveness vs metadata are different concerns | + +### Stays in `packages/host-service` + +| Item | Why | +| --- | --- | +| Workspace CRUD | Core host responsibility | +| Host registration (renamed from device) | Host registers itself as a network node | +| Terminal PTY management | Core host responsibility | +| Filesystem watching | Core host responsibility | +| Git operations | Core host responsibility | +| AI chat runtime | Core host responsibility | +| `health.check` (liveness only) | Every service needs this | +| `host.info` (new, authenticated) | Host is the source of truth for its own identity | +| `PskHostAuthProvider` | Pure validation, works everywhere | +| `CloudGitCredentialProvider` / `CloudModelProvider` | Cloud-backed, environment-agnostic | +| Shell resolution (`process.platform` in terminal) | Terminals inherently need to know the OS | +| `terminal_sessions` table | Session tracking is host-service state | + +### Gaps to fix in standalone `serve.ts` + +| Gap | Fix | +| --- | --- | +| `auth` / `cloudApiUrl` not passed | Make required — standalone needs cloud connectivity | +| `credentials` defaults to `LocalGitCredentialProvider` | Use `CloudGitCredentialProvider` | +| `modelResolver` defaults to `LocalModelProvider` | Use `CloudModelProvider` | +| No terminal session reconciliation at startup | Mark orphaned `"active"` sessions as `"disposed"` on boot | +| `health.info` unauthenticated | Move metadata to `host.info` behind PSK auth | + +--- + +## 7. Entry point examples + +### Electron + +```ts +// apps/desktop/src/main/host-service/index.ts +import { createApp, PskHostAuthProvider, JwtApiAuthProvider } from "@superset/host-service"; +import { LocalGitCredentialProvider } from "@superset/host-service/providers/desktop"; +import { LocalModelProvider } from "@superset/host-service/providers/desktop"; + +createApp({ + config: { + dbPath: path.join(orgDir, "host.db"), + cloudApiUrl: env.CLOUD_API_URL, + migrationsPath: app.isPackaged + ? path.join(process.resourcesPath, "resources/host-migrations") + : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), + allowedOrigins: [`http://localhost:${desktopVitePort}`], + }, + providers: { + auth: new JwtApiAuthProvider(authToken), + hostAuth: new PskHostAuthProvider(secret), + credentials: new LocalGitCredentialProvider(), + modelResolver: new LocalModelProvider(), + }, +}); +``` + +### Standalone + +```ts +// packages/host-service/src/serve.ts +import { createApp, PskHostAuthProvider, JwtApiAuthProvider, + CloudGitCredentialProvider, CloudModelProvider } from "./index"; + +createApp({ + config: { + dbPath: env.HOST_DB_PATH, + cloudApiUrl: env.CLOUD_API_URL, + migrationsPath: join(import.meta.dirname, "../../drizzle"), + allowedOrigins: env.CORS_ORIGINS, + }, + providers: { + auth: new JwtApiAuthProvider(env.AUTH_TOKEN), + hostAuth: new PskHostAuthProvider(env.HOST_SERVICE_SECRET), + credentials: new CloudGitCredentialProvider(), + modelResolver: new CloudModelProvider(), + }, +}); +``` + +No `if (process.resourcesPath)`. No `if (platform() === "darwin")`. No `~/.superset` defaults. The host service is a pure server; the caller decides how it's configured. From e26c60bdc64e87f93b6449d43321c05b0266ff70 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 13:06:45 -0700 Subject: [PATCH 13/14] Move ownership of version --- apps/desktop/src/main/host-service/index.ts | 10 ++++++---- apps/desktop/src/main/lib/host-service-manager.test.ts | 10 +++++++--- apps/desktop/src/main/lib/host-service-manager.ts | 7 +------ apps/desktop/src/main/lib/host-service-manifest.ts | 5 +++++ 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index da10df8d637..88f85fe793e 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -17,7 +17,11 @@ import { LocalGitCredentialProvider, PskHostAuthProvider, } from "@superset/host-service"; -import { removeManifest, writeManifest } from "main/lib/host-service-manifest"; +import { + HOST_SERVICE_PROTOCOL_VERSION, + removeManifest, + writeManifest, +} from "main/lib/host-service-manifest"; const authToken = process.env.AUTH_TOKEN; const cloudApiUrl = process.env.CLOUD_API_URL; @@ -26,9 +30,7 @@ const deviceClientId = process.env.DEVICE_CLIENT_ID; const deviceName = process.env.DEVICE_NAME; const hostServiceSecret = process.env.HOST_SERVICE_SECRET; const serviceVersion = process.env.HOST_SERVICE_VERSION ?? null; -const protocolVersion = process.env.HOST_SERVICE_PROTOCOL_VERSION - ? Number(process.env.HOST_SERVICE_PROTOCOL_VERSION) - : null; +const protocolVersion = HOST_SERVICE_PROTOCOL_VERSION; const organizationId = process.env.ORGANIZATION_ID ?? ""; const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; const keepAliveAfterParent = process.env.KEEP_ALIVE_AFTER_PARENT === "1"; diff --git a/apps/desktop/src/main/lib/host-service-manager.test.ts b/apps/desktop/src/main/lib/host-service-manager.test.ts index da22de15ab7..5618eec4be5 100644 --- a/apps/desktop/src/main/lib/host-service-manager.test.ts +++ b/apps/desktop/src/main/lib/host-service-manager.test.ts @@ -40,7 +40,7 @@ const spawnMock = mock((..._args: unknown[]) => { }); let HostServiceManager: typeof import("./host-service-manager").HostServiceManager; let checkCompatibility: typeof import("./host-service-manager").checkCompatibility; -let HOST_SERVICE_PROTOCOL_VERSION: typeof import("./host-service-manager").HOST_SERVICE_PROTOCOL_VERSION; +let HOST_SERVICE_PROTOCOL_VERSION: typeof import("./host-service-manifest").HOST_SERVICE_PROTOCOL_VERSION; describe("HostServiceManager", () => { beforeAll(async () => { @@ -66,8 +66,12 @@ describe("HostServiceManager", () => { }, })); - ({ HostServiceManager, checkCompatibility, HOST_SERVICE_PROTOCOL_VERSION } = - await import("./host-service-manager")); + ({ HostServiceManager, checkCompatibility } = await import( + "./host-service-manager" + )); + ({ HOST_SERVICE_PROTOCOL_VERSION } = await import( + "./host-service-manifest" + )); }); afterAll(() => { diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts index e78881a68b6..75062557811 100644 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ b/apps/desktop/src/main/lib/host-service-manager.ts @@ -7,6 +7,7 @@ import { app } from "electron"; import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; import { getDeviceName, getHashedDeviceId } from "./device-info"; import { + HOST_SERVICE_PROTOCOL_VERSION, type HostServiceManifest, isProcessAlive, listManifests, @@ -80,11 +81,6 @@ const BASE_RESTART_DELAY = 1_000; /** Interval for checking liveness of adopted (non-child) processes. */ const ADOPTED_LIVENESS_INTERVAL = 5_000; -/** Protocol version for the IPC contract between ElectronMain and HostService. - * Bump this whenever the ready message shape, env contract, or health API - * changes in a backwards-incompatible way. */ -export const HOST_SERVICE_PROTOCOL_VERSION = 1; - function createPortDeferred(): { promise: Promise; resolve: (port: number) => void; @@ -135,7 +131,6 @@ async function buildHostServiceEnv( DEVICE_NAME: getDeviceName(), HOST_SERVICE_SECRET: secret, HOST_SERVICE_VERSION: app.getVersion(), - HOST_SERVICE_PROTOCOL_VERSION: String(HOST_SERVICE_PROTOCOL_VERSION), HOST_MANIFEST_DIR: orgDir, KEEP_ALIVE_AFTER_PARENT: "1", HOST_DB_PATH: path.join(orgDir, "host.db"), diff --git a/apps/desktop/src/main/lib/host-service-manifest.ts b/apps/desktop/src/main/lib/host-service-manifest.ts index e3c27404ed3..171a112a2f1 100644 --- a/apps/desktop/src/main/lib/host-service-manifest.ts +++ b/apps/desktop/src/main/lib/host-service-manifest.ts @@ -9,6 +9,11 @@ import { import { join } from "node:path"; import { SUPERSET_HOME_DIR } from "./app-environment"; +/** Protocol version for the IPC contract between manager and host-service. + * Bump when the ready message shape, env contract, or health API + * changes in a backwards-incompatible way. */ +export const HOST_SERVICE_PROTOCOL_VERSION = 1; + export interface HostServiceManifest { pid: number; endpoint: string; From 00343d6bcc10f3dcbf11b8f8edbd98710078dab2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 4 Apr 2026 14:06:34 -0700 Subject: [PATCH 14/14] fix(desktop): keep tray alive when quitting with services running "Quit (Keep Services Running)" was exiting the process entirely, destroying the tray. Now it destroys windows but keeps the tray so users can still monitor and manage running services. --- apps/desktop/src/main/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 917a135382e..331341a2395 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -192,11 +192,15 @@ app.on("before-quit", async (event) => { const manager = getHostServiceManager(); - // macOS: no explicit quit requested → hide windows if services are active - if (PLATFORM.IS_MAC && quitMode === null && manager.hasActiveInstances()) { + // macOS: close windows & keep tray alive when services should stay running + if ( + PLATFORM.IS_MAC && + (quitMode === null || quitMode === "release") && + manager.hasActiveInstances() + ) { event.preventDefault(); for (const win of BrowserWindow.getAllWindows()) { - win.hide(); + win.destroy(); } return; }