diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 3c70dc6a7..0341a5d88 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -43,6 +43,7 @@ import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tas import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js"; import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/runtime/assembly/skills/watcher.js"; import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js"; +import { cleanInstallTmp } from "@x/core/dist/apps/installer.js"; import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js"; import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js"; import { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.js"; @@ -568,6 +569,13 @@ app.whenReady().then(async () => { encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'), decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')), }); + // Startup hygiene: drop leftover install/update stagings. A cancelled URL + // preview retains its staging by design and a failed download leaves a + // partial bundle.zip; nothing else ever removes them, so they accumulate + // across launches. Fire-and-forget — never block or fail startup on it. + cleanInstallTmp().catch((error) => { + console.error('[Apps] Failed to clear install stagings:', error); + }); initAppsServer().catch((error) => { console.error('[Apps] Failed to start:', error); }); diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 1f88439fb..45eb6e7fb 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -4554,7 +4554,7 @@ function App() { case 'meetings': return 'Meetings' case 'live-notes': return 'Live notes' case 'bg-tasks': return 'Background tasks' - case 'apps': return 'Mini Apps' + case 'apps': return 'Apps' case 'workspace': return 'Workspace' case 'knowledge-view': return 'Brain' case 'graph': return 'Graph View' @@ -5099,6 +5099,16 @@ function App() { void navigateToView({ type: 'apps' }) }, [navigateToView]) + // navigateToView early-returns when the apps view is already showing, so + // `openAppsView` alone is a no-op while an app is open — the sidebar "Apps" + // item did nothing. Bumping the version with a null folder tells AppsView to + // drop its selection (mirrors onOpenBgTasks). + const openAppsGrid = useCallback(() => { + setAppInitialId(null) + setAppIdVersion((v) => v + 1) + openAppsView() + }, [openAppsView]) + const openMeetingsView = useCallback(() => { void navigateToView({ type: 'meetings' }) }, [navigateToView]) @@ -5387,7 +5397,7 @@ function App() { case 'knowledge': void navigateToView({ type: 'knowledge-view' }); break case 'workspace': void navigateToView({ type: 'workspace' }); break case 'code': void navigateToView({ type: 'code' }); break - case 'apps': openAppsView(); break + case 'apps': openAppsGrid(); break } } @@ -5511,7 +5521,7 @@ function App() { break } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [navigateToFile, navigateToView, selectedPath]) + }, [navigateToFile, navigateToView, openAppsGrid, selectedPath]) // Legacy runs:events path: handleRunEvent stashes the result in a ref; // polled every render (the triggering event always causes one). @@ -6049,13 +6059,13 @@ function App() { openBgTasksView() break case 'apps': - openAppsView() + openAppsGrid() break case 'workspaces': knowledgeActions.openWorkspaceAt() break } - }, [navigateToView, openEmailView, openMeetingsView, openCodeView, knowledgeActions, openBgTasksView, openAppsView]) + }, [navigateToView, openEmailView, openMeetingsView, openCodeView, knowledgeActions, openBgTasksView, openAppsGrid]) // Handler for when a voice note is created/updated const handleVoiceNoteCreated = useCallback(async (notePath: string) => { @@ -6544,7 +6554,7 @@ function App() { onOpenCode={openCodeView} onOpenBgTasks={() => { setBgTaskInitialSlug(null); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} onOpenAgent={(slug) => { setBgTaskInitialSlug(slug); setBgTaskSlugVersion((v) => v + 1); openBgTasksView() }} - onOpenApps={openAppsView} + onOpenApps={openAppsGrid} onOpenApp={(folder) => { setAppInitialId(folder); setAppIdVersion((v) => v + 1); openAppsView() }} recentRuns={runs} onOpenRun={(rid) => void navigateToView({ type: 'chat', runId: rid })} diff --git a/apps/x/apps/renderer/src/components/apps/apps-view.tsx b/apps/x/apps/renderer/src/components/apps/apps-view.tsx index b4833d1c1..d35228230 100644 --- a/apps/x/apps/renderer/src/components/apps/apps-view.tsx +++ b/apps/x/apps/renderer/src/components/apps/apps-view.tsx @@ -194,7 +194,7 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: { const [appliedVersion, setAppliedVersion] = useState(initialVersion) if (initialVersion !== appliedVersion) { setAppliedVersion(initialVersion) - if (initialAppFolder) setSelectedFolder(initialAppFolder) + setSelectedFolder(initialAppFolder ?? null) } useEffect(() => { @@ -229,7 +229,7 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: { const selected = selectedFolder ? apps.find((a) => a.folder === selectedFolder) : undefined if (selected) { - return setSelectedFolder(null)} /> + return setSelectedFolder(null)} /> } const noOwnApps = appsLoaded && apps.length === 0 diff --git a/apps/x/packages/core/src/apps/indexer.ts b/apps/x/packages/core/src/apps/indexer.ts index 1e3081728..53785b45c 100644 --- a/apps/x/packages/core/src/apps/indexer.ts +++ b/apps/x/packages/core/src/apps/indexer.ts @@ -99,7 +99,15 @@ export async function listApps(): Promise { } const out: AppSummary[] = []; for (const entry of entries) { - if (!entry.isDirectory()) continue; + // A Dirent reports a symlink as a symlink, not a directory, so a linked + // app folder (a dev repo linked into ~/.rowboat/apps) was skipped here + // while the server — which stats the path — served it fine: an app you + // could open by URL but never see in the grid. Resolve links first. + let isDir = entry.isDirectory(); + if (!isDir && entry.isSymbolicLink()) { + try { isDir = (await fs.stat(path.join(APPS_DIR, entry.name))).isDirectory(); } catch { isDir = false; } + } + if (!isDir) continue; if (!FOLDER_SLUG_RE.test(entry.name)) { if (!entry.name.startsWith('.')) { console.warn(`[Apps] ignoring folder with invalid slug: ${entry.name}`); diff --git a/apps/x/packages/core/src/apps/installer.ts b/apps/x/packages/core/src/apps/installer.ts index 3f40e0b99..0edd9f0ae 100644 --- a/apps/x/packages/core/src/apps/installer.ts +++ b/apps/x/packages/core/src/apps/installer.ts @@ -56,6 +56,14 @@ export interface InstallDone { const RELEASE_MANAGED = ['rowboat-app.json', 'dist', 'agents', 'defaults']; +// Losing the network mid-download used to hang the install forever: the +// socket stays half-open, `reader.read()` never settles, and the UI sits +// on "Installing…" with no way out. Two bounds, because one can't do both +// jobs: a short one for the initial response, a long one for the whole +// transfer (a large bundle on a slow link is legitimate). +const DOWNLOAD_CONNECT_TIMEOUT_MS = 20_000; +const DOWNLOAD_TOTAL_TIMEOUT_MS = 180_000; + // --------------------------------------------------------------------------- // Bundle download + extraction (shared by catalog + URL installs) // --------------------------------------------------------------------------- @@ -63,28 +71,48 @@ const RELEASE_MANAGED = ['rowboat-app.json', 'dist', 'agents', 'defaults']; async function downloadBundle(url: string, destDir: string): Promise<{ zipPath: string; sha256: string }> { await fsp.mkdir(destDir, { recursive: true }); const zipPath = path.join(destDir, 'bundle.zip'); - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) throw new InstallError('download_failed', `bundle download: HTTP ${res.status}`); - - const hash = crypto.createHash('sha256'); - const out = fs.createWriteStream(zipPath); - const reader = res.body?.getReader(); - if (!reader) throw new InstallError('download_failed', 'empty response body'); - let received = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - received += value.byteLength; - if (received > MAX_BUNDLE_COMPRESSED) { - out.destroy(); + const controller = new AbortController(); + const totalTimer = setTimeout(() => controller.abort(), DOWNLOAD_TOTAL_TIMEOUT_MS); + let out: fs.WriteStream | undefined; + try { + const res = await fetch(url, { + redirect: 'follow', + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(DOWNLOAD_CONNECT_TIMEOUT_MS)]), + }); + if (!res.ok) throw new InstallError('download_failed', `bundle download: HTTP ${res.status}`); + + const hash = crypto.createHash('sha256'); + out = fs.createWriteStream(zipPath); + const reader = res.body?.getReader(); + if (!reader) throw new InstallError('download_failed', 'empty response body'); + controller.signal.addEventListener('abort', () => { void reader.cancel().catch(() => {}); }); + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > MAX_BUNDLE_COMPRESSED) { + out.destroy(); + await fsp.rm(zipPath, { force: true }); + throw new InstallError('bundle_too_large', `bundle exceeds ${MAX_BUNDLE_COMPRESSED} bytes compressed`); + } + hash.update(value); + await new Promise((resolve, reject) => out!.write(value, (e) => (e ? reject(e) : resolve()))); + } + await new Promise((resolve, reject) => out!.end((e?: Error | null) => (e ? reject(e) : resolve()))); + return { zipPath, sha256: hash.digest('hex') }; + } catch (err) { + if (err instanceof InstallError) throw err; + const aborted = err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError'); + if (aborted) { + out?.destroy(); await fsp.rm(zipPath, { force: true }); - throw new InstallError('bundle_too_large', `bundle exceeds ${MAX_BUNDLE_COMPRESSED} bytes compressed`); + throw new InstallError('download_failed', 'bundle download timed out — check your connection and try again'); } - hash.update(value); - await new Promise((resolve, reject) => out.write(value, (e) => (e ? reject(e) : resolve()))); + throw err; + } finally { + clearTimeout(totalTimer); } - await new Promise((resolve, reject) => out.end((e?: Error | null) => (e ? reject(e) : resolve()))); - return { zipPath, sha256: hash.digest('hex') }; } /** diff --git a/apps/x/packages/core/src/apps/server.ts b/apps/x/packages/core/src/apps/server.ts index a598e100e..091549de3 100644 --- a/apps/x/packages/core/src/apps/server.ts +++ b/apps/x/packages/core/src/apps/server.ts @@ -222,8 +222,17 @@ const BOOTSTRAP = String.raw``; function injectBootstrap(htmlContent: string): string { - if (/<\/body>/i.test(htmlContent)) return htmlContent.replace(/<\/body>/i, `${BOOTSTRAP}\n`); - return `${htmlContent}\n${BOOTSTRAP}`; + // Inject before the LAST , not the first. An app whose markup + // contains a literal "" earlier — a template inside a