Improve CLI preload flow and SSE reconnects
This commit is contained in:
4
BUILD.md
4
BUILD.md
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user