From e76a03d0f433f8febc8c5f7da616e1922450c6c4 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Mon, 16 Feb 2026 04:06:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E6=8B=86=E5=88=86=20sidecar?= =?UTF-8?q?=20=E7=AE=A1=E7=90=86=E5=B9=B6=E6=8E=A5=E5=85=A5=E5=81=A5?= =?UTF-8?q?=E5=BA=B7=E6=A3=80=E6=9F=A5=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/main/index.ts | 264 +++++++++----------------- apps/desktop/src/main/sidecar.ts | 268 +++++++++++++++++++++++++++ apps/server/src/routeTree.gen.ts | 24 ++- apps/server/src/routes/api/health.ts | 27 +++ 4 files changed, 408 insertions(+), 175 deletions(-) create mode 100644 apps/desktop/src/main/sidecar.ts create mode 100644 apps/server/src/routes/api/health.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3e410e5..c975b79 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,142 +1,57 @@ -import { spawn } from 'node:child_process' -import { createServer } from 'node:net' import { join } from 'node:path' import { app, BrowserWindow, dialog, shell } from 'electron' -import killProcessTree from 'tree-kill' +import { createSidecarRuntime } from './sidecar' const DEV_SERVER_URL = 'http://localhost:3000' +const SAFE_EXTERNAL_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']) let mainWindow: BrowserWindow | null = null -let serverProcess: ReturnType | null = null +let windowCreationPromise: Promise | null = null let isQuitting = false -let serverReady = false - -const shouldAbortWindowLoad = (): boolean => - isQuitting || !mainWindow || mainWindow.isDestroyed() const showErrorAndQuit = (title: string, detail: string) => { - if (isQuitting) return + if (isQuitting) { + return + } + dialog.showErrorBox(title, detail) app.quit() } -const getAvailablePort = (): Promise => - new Promise((resolve, reject) => { - const server = createServer() - server.listen(0, () => { - const addr = server.address() - if (!addr || typeof addr === 'string') { - server.close() - reject(new Error('Failed to resolve port')) - return - } - server.close(() => resolve(addr.port)) - }) - server.on('error', reject) - }) +const sidecar = createSidecarRuntime({ + devServerUrl: DEV_SERVER_URL, + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + isQuitting: () => isQuitting, + onUnexpectedStop: (detail) => { + showErrorAndQuit('Service Stopped', detail) + }, +}) -const isServerReady = async (url: string): Promise => { +const toErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const canOpenExternally = (url: string): boolean => { try { - const response = await fetch(url, { method: 'HEAD' }) - return response.ok + const parsed = new URL(url) + return SAFE_EXTERNAL_PROTOCOLS.has(parsed.protocol) } catch { return false } } -const waitForServer = async ( - url: string, - timeoutMs = 15_000, -): Promise => { - const start = Date.now() - while (Date.now() - start < timeoutMs && !isQuitting) { - if (await isServerReady(url)) return true - await new Promise((resolve) => setTimeout(resolve, 200)) - } - return false -} - -const stopServerProcess = () => { - if (!serverProcess) { - return - } - - const runningServer = serverProcess - serverProcess = null - - if (!runningServer.pid || runningServer.exitCode !== null) { - return - } - - killProcessTree(runningServer.pid, (error?: Error) => { - if (error) { - console.error('Failed to stop server process:', error) - } - }) -} - -const spawnServer = (port: number): string => { - const binaryName = process.platform === 'win32' ? 'server.exe' : 'server' - const binaryPath = join(process.resourcesPath, binaryName) - - serverProcess = spawn(binaryPath, [], { - env: { - ...process.env, - PORT: String(port), - HOST: '127.0.0.1', - }, - stdio: 'ignore', - }) - serverProcess.unref() - - serverProcess.on('error', (err) => { - console.error('Failed to start server:', err) - showErrorAndQuit( - 'Startup Failed', - 'A required component failed to start. Please reinstall the app.', - ) - }) - - serverProcess.on('exit', (code) => { - serverProcess = null - if (!isQuitting && serverReady) { - showErrorAndQuit( - 'Service Stopped', - app.isPackaged - ? 'The background service stopped unexpectedly. Please restart the app.' - : `Server process exited unexpectedly (code ${code}). Check the server logs for details.`, - ) - } - }) - - return `http://127.0.0.1:${port}` -} - -const resolveServerUrl = async (): Promise => { - if (!app.isPackaged) { - return DEV_SERVER_URL - } - - let port: number - try { - port = await getAvailablePort() - } catch { - showErrorAndQuit( - 'Startup Failed', - "The app couldn't allocate a local port. Please close other apps and try again.", - ) - return null - } - - if (isQuitting) { - return null - } - - return spawnServer(port) -} - const createWindow = async () => { - mainWindow = new BrowserWindow({ + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.focus() + return + } + + const targetUrl = await sidecar.resolveUrl() + if (isQuitting) { + return + } + + const windowRef = new BrowserWindow({ width: 1200, height: 800, show: false, @@ -145,89 +60,94 @@ const createWindow = async () => { sandbox: true, }, }) + mainWindow = windowRef - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url) + windowRef.webContents.setWindowOpenHandler(({ url }) => { + if (!canOpenExternally(url)) { + if (!app.isPackaged) { + console.warn(`Blocked external URL: ${url}`) + } + + return { action: 'deny' } + } + + void shell.openExternal(url) return { action: 'deny' } }) - mainWindow.on('closed', () => { - mainWindow = null + windowRef.on('closed', () => { + if (mainWindow === windowRef) { + mainWindow = null + } }) - if (process.env.ELECTRON_RENDERER_URL) { - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) - } - mainWindow.show() + try { + await windowRef.loadURL(targetUrl) + } catch (error) { + if (mainWindow === windowRef) { + mainWindow = null + } - const serverUrl = await resolveServerUrl() - if (!serverUrl || shouldAbortWindowLoad()) { - stopServerProcess() - return + if (!windowRef.isDestroyed()) { + windowRef.destroy() + } + + throw error } - if (!app.isPackaged) console.log(`Waiting for server at ${serverUrl}...`) - const ready = await waitForServer(serverUrl) - if (shouldAbortWindowLoad()) { - stopServerProcess() - return + if (!windowRef.isDestroyed()) { + windowRef.show() + } +} + +const ensureWindow = async () => { + if (windowCreationPromise) { + return windowCreationPromise } - if (!ready) { - showErrorAndQuit( - 'Startup Failed', - app.isPackaged - ? 'The app is taking too long to start. Please try again.' - : 'Dev server not responding. Run `bun dev` in apps/server first.', - ) - return - } + windowCreationPromise = createWindow().finally(() => { + windowCreationPromise = null + }) - serverReady = true - mainWindow.loadURL(serverUrl) + return windowCreationPromise } const beginQuit = () => { isQuitting = true - stopServerProcess() + sidecar.stop() +} + +const handleWindowCreationError = (error: unknown, context: string) => { + console.error(`${context}:`, error) + showErrorAndQuit( + "App Couldn't Start", + app.isPackaged + ? 'A required component failed to start. Please reinstall the app.' + : `${context}: ${toErrorMessage(error)}`, + ) } app .whenReady() - .then(createWindow) - .catch((e) => { - console.error('Failed to create window:', e) - showErrorAndQuit( - "App Couldn't Start", - app.isPackaged - ? "We couldn't open the application window. Please restart your computer and try again." - : `Failed to create window: ${e instanceof Error ? e.message : String(e)}`, - ) + .then(() => ensureWindow()) + .catch((error) => { + handleWindowCreationError(error, 'Failed to create window') }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { - beginQuit() app.quit() } }) app.on('activate', () => { - if (!isQuitting && BrowserWindow.getAllWindows().length === 0) { - createWindow().catch((e) => { - console.error('Failed to re-create window:', e) - showErrorAndQuit( - "App Couldn't Start", - app.isPackaged - ? "We couldn't open the application window. Please restart the app." - : `Failed to re-create window: ${e instanceof Error ? e.message : String(e)}`, - ) - }) + if (isQuitting || BrowserWindow.getAllWindows().length > 0) { + return } + + ensureWindow().catch((error) => { + handleWindowCreationError(error, 'Failed to re-create window') + }) }) -app.on('before-quit', () => { - beginQuit() -}) +app.on('before-quit', beginQuit) diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts new file mode 100644 index 0000000..987f853 --- /dev/null +++ b/apps/desktop/src/main/sidecar.ts @@ -0,0 +1,268 @@ +import { type ChildProcess, spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { createServer } from 'node:net' +import { join } from 'node:path' +import killProcessTree from 'tree-kill' + +const SERVER_HOST = '127.0.0.1' +const SERVER_READY_TIMEOUT_MS = 10_000 +const SERVER_REQUEST_TIMEOUT_MS = 1_500 +const SERVER_POLL_INTERVAL_MS = 250 +const SERVER_PROBE_PATHS = ['/api/health', '/'] + +type SidecarState = { + process: ChildProcess | null + startup: Promise | null + url: string | null +} + +type SidecarRuntimeOptions = { + devServerUrl: string + isPackaged: boolean + resourcesPath: string + isQuitting: () => boolean + onUnexpectedStop: (detail: string) => void +} + +type SidecarRuntime = { + resolveUrl: () => Promise + stop: () => void +} + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + +const isProcessAlive = ( + processToCheck: ChildProcess | null, +): processToCheck is ChildProcess => { + if (!processToCheck || !processToCheck.pid) { + return false + } + + return processToCheck.exitCode === null && !processToCheck.killed +} + +const getAvailablePort = (): Promise => + new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, () => { + const addr = server.address() + if (!addr || typeof addr === 'string') { + server.close() + reject(new Error('Failed to resolve port')) + return + } + + server.close(() => resolve(addr.port)) + }) + server.on('error', reject) + }) + +const isServerReady = async (url: string): Promise => { + for (const probePath of SERVER_PROBE_PATHS) { + try { + const probeUrl = new URL(probePath, `${url}/`) + const response = await fetch(probeUrl, { + method: 'GET', + cache: 'no-store', + signal: AbortSignal.timeout(SERVER_REQUEST_TIMEOUT_MS), + }) + + if (response.status < 500) { + if (probePath === '/api/health' && response.status === 404) { + continue + } + + return true + } + } catch {} + } + + return false +} + +const waitForServer = async ( + url: string, + isQuitting: () => boolean, + processRef?: ChildProcess, +): Promise => { + const start = Date.now() + while (Date.now() - start < SERVER_READY_TIMEOUT_MS && !isQuitting()) { + if (processRef && processRef.exitCode !== null) { + return false + } + + if (await isServerReady(url)) { + return true + } + + await sleep(SERVER_POLL_INTERVAL_MS) + } + + return false +} + +const resolveBinaryPath = (resourcesPath: string): string => { + const binaryName = process.platform === 'win32' ? 'server.exe' : 'server' + return join(resourcesPath, binaryName) +} + +const formatUnexpectedStopMessage = ( + isPackaged: boolean, + code: number | null, + signal: NodeJS.Signals | null, +): string => { + if (isPackaged) { + return 'The background service stopped unexpectedly. Please restart the app.' + } + + return `Server process exited unexpectedly (code ${code ?? 'unknown'}, signal ${signal ?? 'none'}).` +} + +export const createSidecarRuntime = ( + options: SidecarRuntimeOptions, +): SidecarRuntime => { + const state: SidecarState = { + process: null, + startup: null, + url: null, + } + + const resetState = (processRef?: ChildProcess) => { + if (processRef && state.process !== processRef) { + return + } + + state.process = null + state.url = null + } + + const stop = () => { + const runningServer = state.process + resetState() + + if (!runningServer?.pid || runningServer.exitCode !== null) { + return + } + + killProcessTree(runningServer.pid, 'SIGTERM', (error?: Error) => { + if (error) { + console.error('Failed to stop server process:', error) + } + }) + } + + const attachLifecycleHandlers = (processRef: ChildProcess) => { + processRef.on('error', (error) => { + if (state.process !== processRef) { + return + } + + const hadReadyServer = state.url !== null + resetState(processRef) + + if (!options.isQuitting() && hadReadyServer) { + options.onUnexpectedStop( + 'The background service crashed unexpectedly. Please restart the app.', + ) + return + } + + console.error('Failed to start server process:', error) + }) + + processRef.on('exit', (code, signal) => { + if (state.process !== processRef) { + return + } + + const hadReadyServer = state.url !== null + resetState(processRef) + + if (!options.isQuitting() && hadReadyServer) { + options.onUnexpectedStop( + formatUnexpectedStopMessage(options.isPackaged, code, signal), + ) + } + }) + } + + const startPackagedServer = async (): Promise => { + if (state.url && isProcessAlive(state.process)) { + return state.url + } + + if (state.startup) { + return state.startup + } + + state.startup = (async () => { + const binaryPath = resolveBinaryPath(options.resourcesPath) + if (!existsSync(binaryPath)) { + throw new Error(`Sidecar server binary is missing: ${binaryPath}`) + } + + if (options.isQuitting()) { + throw new Error('Application is shutting down.') + } + + const port = await getAvailablePort() + const nextServerUrl = `http://${SERVER_HOST}:${port}` + const processRef = spawn(binaryPath, [], { + env: { + ...process.env, + HOST: SERVER_HOST, + PORT: String(port), + }, + stdio: 'ignore', + windowsHide: true, + }) + + processRef.unref() + state.process = processRef + attachLifecycleHandlers(processRef) + + const ready = await waitForServer( + nextServerUrl, + options.isQuitting, + processRef, + ) + if (ready && isProcessAlive(processRef)) { + state.url = nextServerUrl + return nextServerUrl + } + + const failureReason = + processRef.exitCode !== null + ? `The service exited early (code ${processRef.exitCode}).` + : `The service did not respond at ${nextServerUrl} within 10 seconds.` + + stop() + throw new Error(failureReason) + })().finally(() => { + state.startup = null + }) + + return state.startup + } + + const resolveUrl = async (): Promise => { + if (options.isPackaged) { + return startPackagedServer() + } + + const ready = await waitForServer(options.devServerUrl, options.isQuitting) + if (!ready) { + throw new Error( + 'Dev server not responding. Run `bun dev` in apps/server first.', + ) + } + + return options.devServerUrl + } + + return { + resolveUrl, + stop, + } +} diff --git a/apps/server/src/routeTree.gen.ts b/apps/server/src/routeTree.gen.ts index 98e1a7d..b1b1e73 100644 --- a/apps/server/src/routeTree.gen.ts +++ b/apps/server/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiSplatRouteImport } from './routes/api/$' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' @@ -18,6 +19,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ApiHealthRoute = ApiHealthRouteImport.update({ + id: '/api/health', + path: '/api/health', + getParentRoute: () => rootRouteImport, +} as any) const ApiSplatRoute = ApiSplatRouteImport.update({ id: '/api/$', path: '/api/$', @@ -32,30 +38,34 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/api/$': typeof ApiSplatRoute + '/api/health': typeof ApiHealthRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/api/$': typeof ApiSplatRoute + '/api/health': typeof ApiHealthRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/api/$': typeof ApiSplatRoute + '/api/health': typeof ApiHealthRoute '/api/rpc/$': typeof ApiRpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/api/$' | '/api/rpc/$' + fullPaths: '/' | '/api/$' | '/api/health' | '/api/rpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/api/$' | '/api/rpc/$' - id: '__root__' | '/' | '/api/$' | '/api/rpc/$' + to: '/' | '/api/$' | '/api/health' | '/api/rpc/$' + id: '__root__' | '/' | '/api/$' | '/api/health' | '/api/rpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute ApiSplatRoute: typeof ApiSplatRoute + ApiHealthRoute: typeof ApiHealthRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute } @@ -68,6 +78,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/api/health': { + id: '/api/health' + path: '/api/health' + fullPath: '/api/health' + preLoaderRoute: typeof ApiHealthRouteImport + parentRoute: typeof rootRouteImport + } '/api/$': { id: '/api/$' path: '/api/$' @@ -88,6 +105,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ApiSplatRoute: ApiSplatRoute, + ApiHealthRoute: ApiHealthRoute, ApiRpcSplatRoute: ApiRpcSplatRoute, } export const routeTree = rootRouteImport diff --git a/apps/server/src/routes/api/health.ts b/apps/server/src/routes/api/health.ts new file mode 100644 index 0000000..fcc5e7d --- /dev/null +++ b/apps/server/src/routes/api/health.ts @@ -0,0 +1,27 @@ +import { createFileRoute } from '@tanstack/react-router' +import { name, version } from '@/../package.json' + +const createHealthResponse = (): Response => + Response.json( + { + status: 'ok', + service: name, + version, + timestamp: new Date().toISOString(), + }, + { + status: 200, + headers: { + 'cache-control': 'no-store', + }, + }, + ) + +export const Route = createFileRoute('/api/health')({ + server: { + handlers: { + GET: async () => createHealthResponse(), + HEAD: async () => new Response(null, { status: 200 }), + }, + }, +})