fix(apps): eliminate blank-app window on launch and add crash visibility

- start the apps server before createWindow instead of after the full
  service-init chain: the Apps view was reachable ~10s before port 3210
  was listening, so every app iframe opened to connection-refused
- retry EADDRINUSE binds (quick relaunch races left the server disabled
  for the whole session with no retry)
- app frame: auto-retry the iframe instead of a dead manual Retry, and
  surface when the apps server itself is down (new apps:serverStatus)
- log render-process-gone/child-process-gone reasons; renderer helper
  crashes were previously invisible in packaged builds
This commit is contained in:
Gagan 2026-07-06 20:52:31 +05:30
parent 5e0fc059e7
commit f2bc01ffa9
5 changed files with 85 additions and 32 deletions

View file

@ -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();

View file

@ -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();

View file

@ -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 (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
@ -105,13 +123,18 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
/>
{loadState === 'stuck' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
<div className="text-muted-foreground">This app is taking too long to load.</div>
<div className="text-muted-foreground">
{serverDown ? 'The Rowboat apps server is still starting up.' : 'This app is taking too long to load.'}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Retrying automatically
</div>
<button
type="button"
onClick={() => setReloadNonce((n) => n + 1)}
className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-accent"
>
Retry
Retry now
</button>
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
</div>