mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
5e0fc059e7
commit
f2bc01ffa9
5 changed files with 85 additions and 32 deletions
|
|
@ -1565,6 +1565,9 @@ export function setupIpcHandlers() {
|
||||||
return qualifyAndDisconnectComposioGoogle();
|
return qualifyAndDisconnectComposioGoogle();
|
||||||
},
|
},
|
||||||
// Rowboat Apps handlers (spec §13)
|
// Rowboat Apps handlers (spec §13)
|
||||||
|
'apps:serverStatus': async () => {
|
||||||
|
return appsServer.getServerStatus();
|
||||||
|
},
|
||||||
'apps:list': async () => {
|
'apps:list': async () => {
|
||||||
const status = appsServer.getServerStatus();
|
const status = appsServer.getServerStatus();
|
||||||
const apps = await appsIndexer.listApps();
|
const apps = await appsIndexer.listApps();
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
app.whenReady().then(async () => {
|
||||||
// Register custom protocol before creating window.
|
// Register custom protocol before creating window.
|
||||||
// In production this serves the renderer SPA; in dev (and prod) it also
|
// In production this serves the renderer SPA; in dev (and prod) it also
|
||||||
|
|
@ -383,6 +393,24 @@ app.whenReady().then(async () => {
|
||||||
setupIpcHandlers();
|
setupIpcHandlers();
|
||||||
setupBrowserEventForwarding();
|
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();
|
createWindow();
|
||||||
|
|
||||||
// Start workspace watcher as a main-process service
|
// Start workspace watcher as a main-process service
|
||||||
|
|
@ -503,21 +531,6 @@ app.whenReady().then(async () => {
|
||||||
// start chrome extension sync server
|
// start chrome extension sync server
|
||||||
initChromeSync();
|
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", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
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 type { rowboatApp } from '@x/shared'
|
||||||
import { appOpened } from '@/lib/analytics'
|
import { appOpened } from '@/lib/analytics'
|
||||||
import { AppDetail } from '@/components/apps/app-detail'
|
import { AppDetail } from '@/components/apps/app-detail'
|
||||||
|
|
@ -36,6 +36,24 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [watchKey])
|
}, [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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
<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' && (
|
{loadState === 'stuck' && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setReloadNonce((n) => n + 1)}
|
onClick={() => setReloadNonce((n) => n + 1)}
|
||||||
className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-accent"
|
className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-accent"
|
||||||
>
|
>
|
||||||
Retry
|
Retry now
|
||||||
</button>
|
</button>
|
||||||
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
|
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -741,22 +741,29 @@ export async function init(): Promise<void> {
|
||||||
startLagMonitor();
|
startLagMonitor();
|
||||||
await startWatcher();
|
await startWatcher();
|
||||||
const expressApp = createApp();
|
const expressApp = createApp();
|
||||||
await new Promise<void>((resolve, reject) => {
|
const listenOn = (host: string) => new Promise<Server>((resolve, reject) => {
|
||||||
const s = expressApp.listen(APPS_PORT, '127.0.0.1', () => {
|
const s = expressApp.listen(APPS_PORT, host, () => resolve(s));
|
||||||
server = 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;
|
serverError = null;
|
||||||
console.log(`[Apps] server on 127.0.0.1:${APPS_PORT} (host suffix ${APPS_HOST_SUFFIX}), dir ${APPS_DIR}`);
|
console.log(`[Apps] server on 127.0.0.1:${APPS_PORT} (host suffix ${APPS_HOST_SUFFIX}), dir ${APPS_DIR}`);
|
||||||
resolve();
|
break;
|
||||||
});
|
} catch (error) {
|
||||||
s.on('error', (error: NodeJS.ErrnoException) => {
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
if (error.code === 'EADDRINUSE') {
|
if (code !== 'EADDRINUSE') throw error;
|
||||||
// Never scan for alternate ports (§6.1) — origins embed the port.
|
if (attempt >= 15) throw new Error(`Port ${APPS_PORT} is already in use.`);
|
||||||
reject(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?)`);
|
||||||
return;
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
reject(error);
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
// Dual-stack loopback: also listen on ::1 (see server6 note above).
|
// Dual-stack loopback: also listen on ::1 (see server6 note above).
|
||||||
// Best-effort — some machines have IPv6 disabled.
|
// Best-effort — some machines have IPv6 disabled.
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
|
|
|
||||||
|
|
@ -1228,6 +1228,13 @@ const ipcSchemas = {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Rowboat Apps (spec §13) — M1 local channels.
|
// Rowboat Apps (spec §13) — M1 local channels.
|
||||||
|
'apps:serverStatus': {
|
||||||
|
req: z.object({}),
|
||||||
|
res: z.object({
|
||||||
|
running: z.boolean(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
'apps:list': {
|
'apps:list': {
|
||||||
req: z.object({}),
|
req: z.object({}),
|
||||||
res: z.object({
|
res: z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue