Improve CLI preload flow and SSE reconnects

This commit is contained in:
Shantur Rathore
2025-11-20 20:45:31 +00:00
parent 3f46d73a31
commit 30b075e4ba
7 changed files with 147 additions and 107 deletions

View File

@@ -77,8 +77,8 @@ bun run build:all
The build script performs these steps: The build script performs these steps:
1. **Compile TypeScript** → Electron app (main, preload, renderer) 1. **Build @codenomad/cli** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves)
2. **Bundle with Vite** → Optimized production build 2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/`
3. **Package with electron-builder** → Platform-specific binaries 3. **Package with electron-builder** → Platform-specific binaries
## Output ## Output

View File

@@ -25,7 +25,7 @@ export default defineConfig({
build: { build: {
outDir: "dist/preload", outDir: "dist/preload",
lib: { lib: {
entry: resolve(__dirname, "electron/preload/index.ts"), entry: resolve(__dirname, "electron/preload/index.cjs"),
formats: ["cjs"], formats: ["cjs"],
fileName: () => "index.js", fileName: () => "index.js",
}, },

View File

@@ -1,20 +1,22 @@
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron" import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
import { existsSync } from "fs"
import { dirname, join } from "path" import { dirname, join } from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu" import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc" import { setupCliIPC } from "./ipc"
import { CliProcessManager } from "./process-manager" import { CliProcessManager } from "./process-manager"
const __filename = fileURLToPath(import.meta.url) const mainFilename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename) const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin" const isMac = process.platform === "darwin"
const cliManager = new CliProcessManager() const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
let cliView: BrowserView | null = null let currentCliUrl: string | null = null
let cliViewReady = false
let pendingCliUrl: string | null = null let pendingCliUrl: string | null = null
let loadingScreenVisible = true let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
if (isMac) { if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking") app.commandLine.appendSwitch("disable-spell-checking")
@@ -25,7 +27,7 @@ function getIconPath() {
return join(process.resourcesPath, "icon.png") return join(process.resourcesPath, "icon.png")
} }
return join(__dirname, "../resources/icon.png") return join(mainDirname, "../resources/icon.png")
} }
function getLoadingHtmlPath() { function getLoadingHtmlPath() {
@@ -33,7 +35,61 @@ function getLoadingHtmlPath() {
return join(process.resourcesPath, "loading.html") 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() { function createWindow() {
@@ -49,7 +105,7 @@ function createWindow() {
backgroundColor, backgroundColor,
icon: iconPath, icon: iconPath,
webPreferences: { webPreferences: {
preload: join(__dirname, "../preload/index.cjs"), preload: getPreloadPath(),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
spellcheck: !isMac, spellcheck: !isMac,
@@ -61,8 +117,9 @@ function createWindow() {
} }
const loadingHtml = getLoadingHtmlPath() const loadingHtml = getLoadingHtmlPath()
mainWindow.loadFile(loadingHtml) showingLoadingScreen = true
loadingScreenVisible = true currentCliUrl = null
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" }) mainWindow.webContents.openDevTools({ mode: "detach" })
@@ -71,110 +128,96 @@ function createWindow() {
createApplicationMenu(mainWindow) createApplicationMenu(mainWindow)
setupCliIPC(mainWindow, cliManager) setupCliIPC(mainWindow, cliManager)
mainWindow.on("resize", resizeCliView)
mainWindow.on("enter-full-screen", resizeCliView)
mainWindow.on("leave-full-screen", resizeCliView)
mainWindow.on("closed", () => { mainWindow.on("closed", () => {
destroyCliBrowserView() destroyPreloadingView()
mainWindow = null mainWindow = null
currentCliUrl = null
pendingCliUrl = null
showingLoadingScreen = false
}) })
attachCliBrowserView() if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
} }
function destroyCliBrowserView() { function showLoadingScreen(force = false) {
if (!cliView) { if (!mainWindow || mainWindow.isDestroyed()) {
cliViewReady = false
return return
} }
try { if (showingLoadingScreen && !force) {
if (mainWindow && !mainWindow.isDestroyed()) { return
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)
} }
cliView = null destroyPreloadingView()
cliViewReady = false 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 pendingCliUrl = url
cliViewReady = false return
destroyCliBrowserView() }
if (currentCliUrl === url && !showingLoadingScreen) {
return
}
pendingCliUrl = url
destroyPreloadingView()
if (!showingLoadingScreen) {
showLoadingScreen(true)
}
const view = new BrowserView({ const view = new BrowserView({
webPreferences: { webPreferences: {
preload: join(__dirname, "../preload/index.cjs"),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
spellcheck: !isMac, spellcheck: !isMac,
}, },
}) })
cliView = view preloadingView = view
view.webContents
.loadURL(url)
.catch((error) => console.error("[cli] failed to load BrowserView:", error))
view.webContents.once("did-finish-load", () => { view.webContents.once("did-finish-load", () => {
if (cliView !== view) { if (preloadingView !== view) {
const contents = view.webContents as any destroyPreloadingView(view)
contents?.destroy?.()
return return
} }
cliViewReady = true finalizeCliSwap(url)
attachCliBrowserView() })
view.webContents.loadURL(url).catch((error) => {
console.error("[cli] failed to preload CLI view:", error)
if (preloadingView === view) {
destroyPreloadingView(view)
}
}) })
} }
function attachCliBrowserView() { function finalizeCliSwap(url: string) {
if (!mainWindow || mainWindow.isDestroyed() || !cliView || !cliViewReady) { destroyPreloadingView()
return
}
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()) { if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return return
} }
if (!loadingScreenVisible) { showingLoadingScreen = false
const loadingHtml = getLoadingHtmlPath() currentCliUrl = url
loadingScreenVisible = true pendingCliUrl = null
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error)) mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
} }
async function startCli() { async function startCli() {
try { try {
const devMode = process.env.NODE_ENV === "development" const devMode = process.env.NODE_ENV === "development"
@@ -193,12 +236,11 @@ cliManager.on("ready", (status) => {
if (!status.url) { if (!status.url) {
return return
} }
createCliBrowserView(status.url) startCliPreload(status.url)
}) })
cliManager.on("status", (status) => { cliManager.on("status", (status) => {
if (status.state !== "ready") { if (status.state !== "ready") {
pendingCliUrl = null
showLoadingScreen() showLoadingScreen()
} }
}) })

View File

@@ -6,7 +6,8 @@ import { existsSync } from "fs"
import path from "path" import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" 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" type CliState = "starting" | "ready" | "error" | "stopped"
@@ -283,7 +284,7 @@ export class CliProcessManager extends EventEmitter {
private resolveTsx(): string | null { private resolveTsx(): string | null {
try { try {
const resolved = require.resolve("tsx/dist/cli.js") const resolved = nodeRequire.resolve("tsx/dist/cli.js")
if (resolved && existsSync(resolved)) { if (resolved && existsSync(resolved)) {
return resolved return resolved
} }
@@ -295,8 +296,8 @@ export class CliProcessManager extends EventEmitter {
private tryResolveDist(): string | null { private tryResolveDist(): string | null {
const candidates: Array<string | (() => string)> = [ const candidates: Array<string | (() => string)> = [
() => require.resolve("@codenomad/cli/dist/bin.js"), () => nodeRequire.resolve("@codenomad/cli/dist/bin.js"),
() => require.resolve("@codenomad/cli/dist/bin.js", { paths: [app.getAppPath()] }), () => nodeRequire.resolve("@codenomad/cli/dist/bin.js", { paths: [app.getAppPath()] }),
path.join(app.getAppPath(), "node_modules", "@codenomad", "cli", "dist", "bin.js"), path.join(app.getAppPath(), "node_modules", "@codenomad", "cli", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "cli", "dist", "bin.js"), path.resolve(app.getAppPath(), "..", "cli", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "packages", "cli", "dist", "bin.js"), path.resolve(app.getAppPath(), "..", "packages", "cli", "dist", "bin.js"),

View File

@@ -56,6 +56,12 @@
"dist/**/*", "dist/**/*",
"package.json" "package.json"
], ],
"extraResources": [
{
"from": "electron/resources",
"to": ""
}
],
"mac": { "mac": {
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"target": [ "target": [

View File

@@ -7,10 +7,12 @@ import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url)) const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..") const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx" const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
const nodeModulesPath = join(appDir, "node_modules") const nodeModulesPath = join(appDir, "node_modules")
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
const platforms = { const platforms = {
mac: { mac: {
@@ -93,10 +95,16 @@ async function build(platform) {
console.log(`\n🔨 Building for: ${config.description}\n`) console.log(`\n🔨 Building for: ${config.description}\n`)
try { 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"]) 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") const distPath = join(appDir, "dist")
if (!existsSync(distPath)) { if (!existsSync(distPath)) {
throw new Error("dist/ directory not found. Build failed.") throw new Error("dist/ directory not found. Build failed.")

View File

@@ -56,7 +56,7 @@ const [connectionStatus, setConnectionStatus] = createSignal<
class SSEManager { class SSEManager {
private connections = new Map<string, SSEConnection>() private connections = new Map<string, SSEConnection>()
private static readonly MAX_RECONNECT_ATTEMPTS = 3 private static readonly MAX_RECONNECT_DELAY_MS = 5000
connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void { connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void {
const existing = this.connections.get(instanceId) const existing = this.connections.get(instanceId)
@@ -165,13 +165,8 @@ class SSEManager {
connection.eventSource.close() connection.eventSource.close()
if (connection.reconnectAttempts >= SSEManager.MAX_RECONNECT_ATTEMPTS) {
this.handleConnectionLost(instanceId, reason)
return
}
const nextAttempt = connection.reconnectAttempts + 1 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.reconnectAttempts = nextAttempt
connection.status = "connecting" connection.status = "connecting"
@@ -185,18 +180,6 @@ class SSEManager {
}, delay) }, 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 { private clearReconnectTimer(connection: SSEConnection): void {
if (connection.reconnectTimer) { if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer) clearTimeout(connection.reconnectTimer)