Skip to content

fix/apps-corner-cases - #877

Merged
prakhar1605 merged 7 commits into
mainfrom
fix/apps-corner-cases
Aug 20, 2026
Merged

fix/apps-corner-cases#877
prakhar1605 merged 7 commits into
mainfrom
fix/apps-corner-cases

Conversation

@prakhar1605

@prakhar1605 prakhar1605 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

A pass over the Apps section fixing corner cases — situations that don't come up
in the happy path, so they were never exercised: navigating back to the grid
from inside an app, switching between apps, apps whose files are missing or
half-written, apps whose own markup collides with what the server injects,
installs that lose the network mid-download, and staging directories that were
never cleaned up.

These were found by working through the Apps section manually rather than from a
single bug report. Each commit is independent and can be reviewed on its own.

This PR is a draft — more fixes from the same pass are still landing. The list of
what's still outstanding is at the bottom.

1. Sidebar "Apps" was a no-op while an app was open

What was wrong. With an app open, clicking "Apps" in the sidebar did nothing.
The only way back to the grid was the toolbar's "← Apps" button. A person who
reached for the sidebar just saw a dead click.

Why. isAppsOpen is already true in that state, so currentViewState is
already { type: 'apps' }. navigateToView compares with viewStatesEqual,
finds them equal, and early-returns — applyViewState never runs, AppsView
never receives new props, and its internal selectedFolder keeps pointing at the
open app. The bg-tasks view doesn't have this problem because onOpenBgTasks
already clears its slug and bumps a version counter.

How it's fixed. A sibling helper openAppsGrid mirrors that pattern: clear
appInitialId, bump appIdVersion, then call openAppsView. Used at the three
call sites that mean "show me the grid" — the sidebar item,
navigateToNamedView's 'apps' case, and the product tour. The open-app path
and onOpenApp are deliberately untouched; those target a specific app and
already work.

AppsView's adjust-during-render block now does
setSelectedFolder(initialAppFolder ?? null) instead of
if (initialAppFolder) setSelectedFolder(initialAppFolder), so a null folder
actually clears the selection rather than being ignored.

Also in this commit. AppFrame is now keyed by key={selected.folder}.
Without it, switching from app A to app B (via a pinned sidebar entry or
open-app) reused the same component instance, so showDetail, showPublish
and reloadNonce carried over — with the publish dialog open on app A, switching
to B left it open and pointed at B. The header title for the apps view also said
"Mini Apps" while every other surface said "Apps".

Files: apps/renderer/src/App.tsx,
apps/renderer/src/components/apps/apps-view.tsx

2. A raw JSON error blob where the app should be

What was wrong. Opening an app whose dist/index.html was missing rendered
{"error":{"code":"not_found","message":"asset not found"}} as plain text in the
app pane. Same class of failure after an app's folder was deleted or renamed
while it was open — the frame reloaded and showed a JSON blob for a second or two
before the grid took over.

Why this matters more than it looks. This is the visible face of a
half-written app. The copilot writes rowboat-app.json and creates dist/, then
fails before writing the entry — and the person opens the app and gets an error
blob. From the outside it reads as "the app didn't get built", which is exactly
the shape of the failure reports we've had.

Why it happened. handleStatic maps / to /${manifest.entry}, i.e.
index.html. The friendly "App entry not found" page lives behind
if (!path.extname(resolved)) — the SPA fallback branch for extensionless paths.
index.html has an extension, so that branch is skipped and the request falls
through to the JSON asset error at the end of the function. The friendly page
existed but was unreachable for the one case it was written for: you could only
see it by requesting an extensionless path.

How it's fixed. Two changes in server.ts:

  • In handleStatic, before the final sendError, the resolved path is compared
    against the manifest entry. If they match, the entry itself is missing and the
    friendly page is returned regardless of extension.
  • In the router's "no such app folder" branch, a document navigation (GET with
    Accept: text/html) gets an "App not found" page explaining the folder may
    have been deleted or renamed. Asset and XHR requests keep the JSON error — app
    code calling fetch still needs the machine-readable shape.

Deciding by Accept rather than by path keeps the two audiences separate: the
browser frame gets a page, the app's own JavaScript gets JSON.

File: packages/core/src/apps/server.ts

3. Losing the network mid-install hung forever

What was wrong. Turning off Wi-Fi during an install left the UI on
"Installing…" indefinitely — no error, no timeout, no way out except restarting
the app.

Why. downloadBundle had no timeout anywhere. fetch itself can stall on
connect, and the streaming read loop is worse: the socket stays half-open, so
reader.read() never settles and the loop parks on it forever at whatever byte
count it had reached.

How it's fixed. Two bounds, because one value can't serve both purposes — a
short one would kill legitimate large bundles on slow links, a long one would
leave a dead connection hanging for minutes:

  • DOWNLOAD_CONNECT_TIMEOUT_MS (20s) for the initial response.
  • DOWNLOAD_TOTAL_TIMEOUT_MS (180s) for the whole transfer.

A single AbortController drives both; on abort the reader is cancelled, the
partial bundle.zip is removed, and an InstallError('download_failed', …)
surfaces in the catalog's error banner. Hashing, the compressed-size cap and the
write stream are unchanged.

File: packages/core/src/apps/installer.ts

4. Install stagings were never cleaned up

What was wrong. ~/.rowboat/tmp/app-install-* and app-update-* directories
accumulated across launches, each holding a bundle zip.

Why. cleanInstallTmp was written and exported but never called from
anywhere. installFromRegistry and updateApp clean their own staging in a
finally, but the URL-install flow retains its staging by design — it is
two-phase, and preview must survive until the person confirms. Cancel the preview
and that directory is orphaned; the in-memory urlStagings map is gone on the
next launch, so nothing can ever find it again. A download that fails or times
out (see #3) leaves the same residue.

How it's fixed. cleanInstallTmp() is called once at startup, immediately
before initAppsServer(), fire-and-forget with a logged catch so it can never
block or fail launch. It only removes entries matching the two known prefixes,
never the whole tmp directory.

File: apps/main/src/main.ts

5. The live-reload bootstrap landed in the middle of the page

What was wrong. An app whose markup contains a literal </body> before the
document end — an HTML template inside a <textarea>, an HTML string inside a
<script> — got the injected reload bootstrap spliced into that text. The script
source showed up as visible junk on the page, and live reload stopped working for
that app entirely.

Why. injectBootstrap used String.replace with a non-global regex, which
replaces the first match. For every normal app the first </body> is also the
last one, so this was invisible until an app quoted HTML in its own content.

How it's fixed. Scan for the last </body> and splice there; fall back to
appending when the document has none. Uses a /gi regex loop rather than
toLowerCase() + lastIndexOf — case mapping can change string length and
misalign the index.

File: packages/core/src/apps/server.ts

6. Symlinked app folders were invisible in the grid

What was wrong. An app folder that is a symlink (linking a dev checkout into
~/.rowboat/apps) never appeared in the grid, while the server happily served it
curl against its origin returned 200 and the app rendered. An app you could
open by URL but never see.

Why. listApps filters on entry.isDirectory(), and a Dirent reports a
symlink as a symlink, not a directory, so the entry was skipped before it was
ever summarized. The server takes a different path — it existsSynces the
resolved directory — so the two disagreed.

How it's fixed. When the entry is a symlink, stat the target and treat it as
a directory if it resolves to one. Everything downstream (slug validation,
manifest parsing, the dist/ guard) is unchanged, and a broken link still fails
closed.

File: packages/core/src/apps/indexer.ts

7. Any file change in the app folder forced a full page reload

The watcher bucketed everything not under data/ as "dist", so README.md,
.rowboat-install.json, .rowboat-publish.json (rewritten at every publish
step), agents/ and .previous/ all reloaded the open app — discarding
whatever the person had typed, for files the app never reads. Now only dist/,
data/ and the manifest trigger a reload. Verified by hand: a README append no
longer reloads, a dist/ edit and a data/ write still do.

File: packages/core/src/apps/server.ts

How to test

Build and run: cd apps/x && npm run deps && npm run dev

Navigation

  1. Apps → open any app → click "Apps" in the sidebar (not the toolbar) → the
    grid should come back.
  2. Pin two apps (right-click a card → "Add to sidebar"). Open app A, open its
    detail panel with the "i" button, then click app B in the sidebar → B opens
    with a fresh panel. Repeat with the Publish dialog open on A → it should close
    rather than follow you to B.
  3. Ask the copilot to "open the <name> app" → still opens that app directly.
  4. Header title reads "Apps".

@prakhar1605 prakhar1605 changed the title git push -u upstream fix/apps-corner-cases fix/apps-corner-cases Aug 19, 2026
navigateToView early-returns when the apps view is already showing, so the
sidebar item was a no-op while an app was open. Reset the initial folder and
bump the version (mirrors onOpenBgTasks); AppsView drops its selection on a
null folder. Also key AppFrame by folder so switching apps remounts the
frame, and rename the header title from 'Mini Apps' to 'Apps'.
Opening an app whose dist/index.html is missing rendered a raw
{"error":{"code":"not_found"}} blob: the entry has an .html extension, so the
extensionless SPA-fallback branch never caught it and the request fell through
to the asset error. Catch the entry explicitly. Same for a deleted or renamed
app folder — a document navigation (the frame reloading) now gets the error
page while asset/XHR requests keep the JSON error.
cleanInstallTmp was exported but never called, so every cancelled URL-install
preview and every failed download left an app-install-*/app-update-* dir with
a partial bundle.zip in ~/.rowboat/tmp forever. Call it once at startup,
fire-and-forget so it can never block or fail launch.
@prakhar1605
prakhar1605 force-pushed the fix/apps-corner-cases branch from 7706773 to 9dcb5c9 Compare August 20, 2026 19:27
@prakhar1605
prakhar1605 merged commit a6225cb into main Aug 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant