Improve CLI preload flow and SSE reconnects
This commit is contained in:
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 | (() => 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"),
|
||||
|
||||
@@ -56,6 +56,12 @@
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources",
|
||||
"to": ""
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -56,7 +56,7 @@ const [connectionStatus, setConnectionStatus] = createSignal<
|
||||
|
||||
class SSEManager {
|
||||
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 {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user