From 30b075e4ba741fda46a91239d509571eae97eeec Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 20 Nov 2025 20:45:31 +0000 Subject: [PATCH] Improve CLI preload flow and SSE reconnects --- BUILD.md | 4 +- packages/electron-app/electron.vite.config.ts | 2 +- packages/electron-app/electron/main/main.ts | 200 +++++++++++------- .../electron/main/process-manager.ts | 9 +- packages/electron-app/package.json | 6 + packages/electron-app/scripts/build.js | 12 +- packages/ui/src/lib/sse-manager.ts | 21 +- 7 files changed, 147 insertions(+), 107 deletions(-) diff --git a/BUILD.md b/BUILD.md index 4d2a95b6..ccaf517e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -77,8 +77,8 @@ bun run build:all The build script performs these steps: -1. **Compile TypeScript** → Electron app (main, preload, renderer) -2. **Bundle with Vite** → Optimized production build +1. **Build @codenomad/cli** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves) +2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/` 3. **Package with electron-builder** → Platform-specific binaries ## Output diff --git a/packages/electron-app/electron.vite.config.ts b/packages/electron-app/electron.vite.config.ts index c3cdcc7c..612ef2cf 100644 --- a/packages/electron-app/electron.vite.config.ts +++ b/packages/electron-app/electron.vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ build: { outDir: "dist/preload", lib: { - entry: resolve(__dirname, "electron/preload/index.ts"), + entry: resolve(__dirname, "electron/preload/index.cjs"), formats: ["cjs"], fileName: () => "index.js", }, diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 7ecf3fa2..0f12de87 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,20 +1,22 @@ import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron" +import { existsSync } from "fs" import { dirname, join } from "path" import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" import { setupCliIPC } from "./ipc" import { CliProcessManager } from "./process-manager" -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +const mainFilename = fileURLToPath(import.meta.url) +const mainDirname = dirname(mainFilename) const isMac = process.platform === "darwin" + const cliManager = new CliProcessManager() let mainWindow: BrowserWindow | null = null -let cliView: BrowserView | null = null -let cliViewReady = false +let currentCliUrl: string | null = null let pendingCliUrl: string | null = null -let loadingScreenVisible = true +let showingLoadingScreen = false +let preloadingView: BrowserView | null = null if (isMac) { app.commandLine.appendSwitch("disable-spell-checking") @@ -25,7 +27,7 @@ function getIconPath() { return join(process.resourcesPath, "icon.png") } - return join(__dirname, "../resources/icon.png") + return join(mainDirname, "../resources/icon.png") } function getLoadingHtmlPath() { @@ -33,7 +35,61 @@ function getLoadingHtmlPath() { return join(process.resourcesPath, "loading.html") } - return join(__dirname, "../resources/loading.html") + const distResources = join(mainDirname, "../resources/loading.html") + if (existsSync(distResources)) { + return distResources + } + + const devResources = join(mainDirname, "../electron/resources/loading.html") + if (existsSync(devResources)) { + return devResources + } + + return join(process.cwd(), "electron/resources/loading.html") +} + +let cachedPreloadPath: string | null = null +function getPreloadPath() { + if (cachedPreloadPath && existsSync(cachedPreloadPath)) { + return cachedPreloadPath + } + + const candidates = [ + join(process.resourcesPath, "preload/index.js"), + join(mainDirname, "../preload/index.js"), + join(mainDirname, "../preload/index.cjs"), + join(mainDirname, "../../preload/index.cjs"), + join(mainDirname, "../../electron/preload/index.cjs"), + join(app.getAppPath(), "preload/index.cjs"), + join(app.getAppPath(), "electron/preload/index.cjs"), + ] + + for (const candidate of candidates) { + if (existsSync(candidate)) { + cachedPreloadPath = candidate + return candidate + } + } + + return join(mainDirname, "../preload/index.js") +} + +function destroyPreloadingView(target?: BrowserView | null) { + const view = target ?? preloadingView + if (!view) { + return + } + + try { + const contents = view.webContents as any + contents?.destroy?.() + } catch (error) { + console.warn("[cli] failed to destroy preloading view", error) + } + + if (!target || view === preloadingView) { + preloadingView = null + } } function createWindow() { @@ -49,7 +105,7 @@ function createWindow() { backgroundColor, icon: iconPath, webPreferences: { - preload: join(__dirname, "../preload/index.cjs"), + preload: getPreloadPath(), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, @@ -61,8 +117,9 @@ function createWindow() { } const loadingHtml = getLoadingHtmlPath() - mainWindow.loadFile(loadingHtml) - loadingScreenVisible = true + showingLoadingScreen = true + currentCliUrl = null + mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error)) if (process.env.NODE_ENV === "development") { mainWindow.webContents.openDevTools({ mode: "detach" }) @@ -71,110 +128,96 @@ function createWindow() { createApplicationMenu(mainWindow) setupCliIPC(mainWindow, cliManager) - mainWindow.on("resize", resizeCliView) - mainWindow.on("enter-full-screen", resizeCliView) - mainWindow.on("leave-full-screen", resizeCliView) - mainWindow.on("closed", () => { - destroyCliBrowserView() + destroyPreloadingView() mainWindow = null + currentCliUrl = null + pendingCliUrl = null + showingLoadingScreen = false }) - attachCliBrowserView() + if (pendingCliUrl) { + const url = pendingCliUrl + pendingCliUrl = null + startCliPreload(url) + } } -function destroyCliBrowserView() { - if (!cliView) { - cliViewReady = false +function showLoadingScreen(force = false) { + if (!mainWindow || mainWindow.isDestroyed()) { return } - try { - if (mainWindow && !mainWindow.isDestroyed()) { - try { - mainWindow.removeBrowserView(cliView) - } catch (error) { - console.warn("[cli] failed to remove BrowserView", error) - } - } - const contents = cliView.webContents as any - contents?.destroy?.() - } catch (error) { - console.warn("[cli] failed to destroy BrowserView", error) + if (showingLoadingScreen && !force) { + return } - cliView = null - cliViewReady = false + destroyPreloadingView() + showingLoadingScreen = true + currentCliUrl = null + pendingCliUrl = null + const loadingHtml = getLoadingHtmlPath() + mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error)) } -function createCliBrowserView(url: string) { +function startCliPreload(url: string) { + if (!mainWindow || mainWindow.isDestroyed()) { + pendingCliUrl = url + return + } + + if (currentCliUrl === url && !showingLoadingScreen) { + return + } + pendingCliUrl = url - cliViewReady = false - destroyCliBrowserView() + destroyPreloadingView() + + if (!showingLoadingScreen) { + showLoadingScreen(true) + } const view = new BrowserView({ webPreferences: { - preload: join(__dirname, "../preload/index.cjs"), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, }, }) - cliView = view - - view.webContents - .loadURL(url) - .catch((error) => console.error("[cli] failed to load BrowserView:", error)) + preloadingView = view view.webContents.once("did-finish-load", () => { - if (cliView !== view) { - const contents = view.webContents as any - contents?.destroy?.() + if (preloadingView !== view) { + destroyPreloadingView(view) return } - cliViewReady = true - attachCliBrowserView() + finalizeCliSwap(url) + }) + + view.webContents.loadURL(url).catch((error) => { + console.error("[cli] failed to preload CLI view:", error) + if (preloadingView === view) { + destroyPreloadingView(view) + } }) } -function attachCliBrowserView() { - if (!mainWindow || mainWindow.isDestroyed() || !cliView || !cliViewReady) { - return - } +function finalizeCliSwap(url: string) { + destroyPreloadingView() - try { - mainWindow.setBrowserView(cliView) - resizeCliView() - loadingScreenVisible = false - } catch (error) { - console.error("[cli] failed to attach BrowserView:", error) - } -} - -function resizeCliView() { - if (!mainWindow || !cliView) { - return - } - - const [width, height] = mainWindow.getContentSize() - cliView.setBounds({ x: 0, y: 0, width, height }) - cliView.setAutoResize({ width: true, height: true }) -} - -function showLoadingScreen() { - destroyCliBrowserView() if (!mainWindow || mainWindow.isDestroyed()) { + pendingCliUrl = url return } - if (!loadingScreenVisible) { - const loadingHtml = getLoadingHtmlPath() - loadingScreenVisible = true - mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error)) - } + showingLoadingScreen = false + currentCliUrl = url + pendingCliUrl = null + mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) } + async function startCli() { try { const devMode = process.env.NODE_ENV === "development" @@ -193,12 +236,11 @@ cliManager.on("ready", (status) => { if (!status.url) { return } - createCliBrowserView(status.url) + startCliPreload(status.url) }) cliManager.on("status", (status) => { if (status.state !== "ready") { - pendingCliUrl = null showLoadingScreen() } }) diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 452d28d3..f56841e3 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -6,7 +6,8 @@ import { existsSync } from "fs" import path from "path" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" -const require = createRequire(import.meta.url) +const nodeRequire = createRequire(import.meta.url) + type CliState = "starting" | "ready" | "error" | "stopped" @@ -283,7 +284,7 @@ export class CliProcessManager extends EventEmitter { private resolveTsx(): string | null { try { - const resolved = require.resolve("tsx/dist/cli.js") + const resolved = nodeRequire.resolve("tsx/dist/cli.js") if (resolved && existsSync(resolved)) { return resolved } @@ -295,8 +296,8 @@ export class CliProcessManager extends EventEmitter { private tryResolveDist(): string | null { const candidates: Array string)> = [ - () => require.resolve("@codenomad/cli/dist/bin.js"), - () => require.resolve("@codenomad/cli/dist/bin.js", { paths: [app.getAppPath()] }), + () => nodeRequire.resolve("@codenomad/cli/dist/bin.js"), + () => nodeRequire.resolve("@codenomad/cli/dist/bin.js", { paths: [app.getAppPath()] }), path.join(app.getAppPath(), "node_modules", "@codenomad", "cli", "dist", "bin.js"), path.resolve(app.getAppPath(), "..", "cli", "dist", "bin.js"), path.resolve(app.getAppPath(), "..", "packages", "cli", "dist", "bin.js"), diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 2086e7aa..57fc3fd7 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -56,6 +56,12 @@ "dist/**/*", "package.json" ], + "extraResources": [ + { + "from": "electron/resources", + "to": "" + } + ], "mac": { "category": "public.app-category.developer-tools", "target": [ diff --git a/packages/electron-app/scripts/build.js b/packages/electron-app/scripts/build.js index 7bbe23cc..7ae7d5cd 100644 --- a/packages/electron-app/scripts/build.js +++ b/packages/electron-app/scripts/build.js @@ -7,10 +7,12 @@ import { fileURLToPath } from "url" const __dirname = fileURLToPath(new URL(".", import.meta.url)) const appDir = join(__dirname, "..") +const workspaceRoot = join(appDir, "..", "..") const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx" const nodeModulesPath = join(appDir, "node_modules") +const workspaceNodeModulesPath = join(workspaceRoot, "node_modules") const platforms = { mac: { @@ -93,10 +95,16 @@ async function build(platform) { console.log(`\n🔨 Building for: ${config.description}\n`) try { - console.log("📦 Step 1/2: Building Electron app...\n") + console.log("📦 Step 1/3: Building CLI dependency...\n") + await run(npmCmd, ["run", "build", "--workspace", "@codenomad/cli"], { + cwd: workspaceRoot, + env: { NODE_PATH: workspaceNodeModulesPath }, + }) + + console.log("\n📦 Step 2/3: Building Electron app...\n") await run(npmCmd, ["run", "build"]) - console.log("\n📦 Step 2/2: Packaging binaries...\n") + console.log("\n📦 Step 3/3: Packaging binaries...\n") const distPath = join(appDir, "dist") if (!existsSync(distPath)) { throw new Error("dist/ directory not found. Build failed.") diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 90e86df6..2b993e3c 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -56,7 +56,7 @@ const [connectionStatus, setConnectionStatus] = createSignal< class SSEManager { private connections = new Map() - private static readonly MAX_RECONNECT_ATTEMPTS = 3 + private static readonly MAX_RECONNECT_DELAY_MS = 5000 connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void { const existing = this.connections.get(instanceId) @@ -165,13 +165,8 @@ class SSEManager { connection.eventSource.close() - if (connection.reconnectAttempts >= SSEManager.MAX_RECONNECT_ATTEMPTS) { - this.handleConnectionLost(instanceId, reason) - return - } - const nextAttempt = connection.reconnectAttempts + 1 - const delay = Math.min(nextAttempt * 1000, 5000) + const delay = Math.min(nextAttempt * 1000, SSEManager.MAX_RECONNECT_DELAY_MS) connection.reconnectAttempts = nextAttempt connection.status = "connecting" @@ -185,18 +180,6 @@ class SSEManager { }, delay) } - private handleConnectionLost(instanceId: string, reason: string): void { - const connection = this.connections.get(instanceId) - if (!connection) return - - this.clearReconnectTimer(connection) - connection.eventSource.close() - this.connections.delete(instanceId) - connection.status = "disconnected" - this.updateConnectionStatus(instanceId, "disconnected") - this.onConnectionLost?.(instanceId, reason) - } - private clearReconnectTimer(connection: SSEConnection): void { if (connection.reconnectTimer) { clearTimeout(connection.reconnectTimer)