diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index f831930a..607dc213 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1565,6 +1565,9 @@ export function setupIpcHandlers() { return qualifyAndDisconnectComposioGoogle(); }, // Rowboat Apps handlers (spec §13) + 'apps:serverStatus': async () => { + return appsServer.getServerStatus(); + }, 'apps:list': async () => { const status = appsServer.getServerStatus(); const apps = await appsIndexer.listApps(); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index b7ac13ac..dfb5941b 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -342,6 +342,16 @@ function createWindow() { } } +// Renderer/child process deaths are otherwise silent in packaged builds (the +// window or an app iframe just goes blank). Log the reason so crash reports +// can be correlated with what Chromium thought happened. +app.on('render-process-gone', (_event, webContents, details) => { + console.error(`[Crash] renderer gone: reason=${details.reason} exitCode=${details.exitCode} url=${webContents.getURL()}`); +}); +app.on('child-process-gone', (_event, details) => { + console.error(`[Crash] child process gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? ''} name=${details.name ?? ''}`); +}); + app.whenReady().then(async () => { // Register custom protocol before creating window. // In production this serves the renderer SPA; in dev (and prod) it also @@ -383,6 +393,24 @@ app.whenReady().then(async () => { setupIpcHandlers(); setupBrowserEventForwarding(); + // Start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) BEFORE + // the window and the long service-init chain below. The Apps view is + // reachable as soon as the window paints; starting the server last meant + // every app iframe hit connection-refused (blank app) for the first ~10s of + // each launch. Route registration and the token cipher are synchronous; + // the listen itself is fire-and-forget. + registerAppsHostApi(); + // GitHub publish token at rest: encrypt via the OS keychain when available + // (core stays electron-free; the cipher is injected here). + setGithubTokenCipher({ + isAvailable: () => safeStorage.isEncryptionAvailable(), + encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'), + decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')), + }); + initAppsServer().catch((error) => { + console.error('[Apps] Failed to start:', error); + }); + createWindow(); // Start workspace watcher as a main-process service @@ -503,21 +531,6 @@ app.whenReady().then(async () => { // start chrome extension sync server initChromeSync(); - // start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) with the - // full Host API (tools/fetch/llm/copilot behind the capability gate) - registerAppsHostApi(); - - // GitHub publish token at rest: encrypt via the OS keychain when available - // (core stays electron-free; the cipher is injected here). - setGithubTokenCipher({ - isAvailable: () => safeStorage.isEncryptionAvailable(), - encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'), - decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')), - }); - initAppsServer().catch((error) => { - console.error('[Apps] Failed to start:', error); - }); - app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/apps/x/apps/renderer/src/components/apps/app-frame.tsx b/apps/x/apps/renderer/src/components/apps/app-frame.tsx index ae7dcc5c..90527853 100644 --- a/apps/x/apps/renderer/src/components/apps/app-frame.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-frame.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { ArrowLeft, BadgeCheck, ExternalLink, Info, RotateCw, UploadCloud } from 'lucide-react' +import { ArrowLeft, BadgeCheck, ExternalLink, Info, Loader2, RotateCw, UploadCloud } from 'lucide-react' import type { rowboatApp } from '@x/shared' import { appOpened } from '@/lib/analytics' import { AppDetail } from '@/components/apps/app-detail' @@ -36,6 +36,24 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: return () => window.clearTimeout(timer) }, [watchKey]) + // Stuck recovery: the usual cause is the apps server not being reachable yet + // (it starts with the main process; on a fresh launch or a quick relaunch the + // first iframe load can beat it). Auto-retry instead of demanding a manual + // click, and say when the server itself is the problem. + const [serverDown, setServerDown] = useState(false) + useEffect(() => { + if (loadState !== 'stuck') return + let cancelled = false + void window.ipc.invoke('apps:serverStatus', {}).then((s) => { + if (!cancelled) setServerDown(!s.running) + }).catch(() => { /* status probe is best-effort */ }) + const timer = window.setTimeout(() => setReloadNonce((n) => n + 1), 4000) + return () => { + cancelled = true + window.clearTimeout(timer) + } + }, [loadState]) + return (
@@ -105,13 +123,18 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: /> {loadState === 'stuck' && (
-
This app is taking too long to load.
+
+ {serverDown ? 'The Rowboat apps server is still starting up.' : 'This app is taking too long to load.'} +
+
+ Retrying automatically… +
{app.origin}
diff --git a/apps/x/packages/core/src/apps/server.ts b/apps/x/packages/core/src/apps/server.ts index a612e356..5861f7d6 100644 --- a/apps/x/packages/core/src/apps/server.ts +++ b/apps/x/packages/core/src/apps/server.ts @@ -741,22 +741,29 @@ export async function init(): Promise { startLagMonitor(); await startWatcher(); const expressApp = createApp(); - await new Promise((resolve, reject) => { - const s = expressApp.listen(APPS_PORT, '127.0.0.1', () => { - server = s; + const listenOn = (host: string) => new Promise((resolve, reject) => { + const s = expressApp.listen(APPS_PORT, host, () => resolve(s)); + s.on('error', (error: NodeJS.ErrnoException) => reject(error)); + }); + // EADDRINUSE almost always means a previous Rowboat instance is + // still shutting down and holding the port (quick relaunch). Retry + // on the SAME port for a while — never scan for alternate ports + // (§6.1), origins embed the port — instead of disabling apps for + // the whole session on the first failure. + for (let attempt = 1; ; attempt++) { + try { + server = await listenOn('127.0.0.1'); serverError = null; console.log(`[Apps] server on 127.0.0.1:${APPS_PORT} (host suffix ${APPS_HOST_SUFFIX}), dir ${APPS_DIR}`); - resolve(); - }); - s.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - // Never scan for alternate ports (§6.1) — origins embed the port. - reject(new Error(`Port ${APPS_PORT} is already in use.`)); - return; - } - reject(error); - }); - }); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EADDRINUSE') throw error; + if (attempt >= 15) throw new Error(`Port ${APPS_PORT} is already in use.`); + if (attempt === 1) console.warn(`[Apps] port ${APPS_PORT} in use — retrying (old instance still shutting down?)`); + await new Promise((r) => setTimeout(r, 1000)); + } + } // Dual-stack loopback: also listen on ::1 (see server6 note above). // Best-effort — some machines have IPv6 disabled. await new Promise((resolve) => { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 0f3c762a..6ac706d3 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1228,6 +1228,13 @@ const ipcSchemas = { }), }, // Rowboat Apps (spec §13) — M1 local channels. + 'apps:serverStatus': { + req: z.object({}), + res: z.object({ + running: z.boolean(), + error: z.string().optional(), + }), + }, 'apps:list': { req: z.object({}), res: z.object({