Skip to content
Merged
8 changes: 8 additions & 0 deletions apps/x/apps/main/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
Expand Down
22 changes: 16 additions & 6 deletions apps/x/apps/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 })}
Expand Down
4 changes: 2 additions & 2 deletions apps/x/apps/renderer/src/components/apps/apps-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -229,7 +229,7 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {

const selected = selectedFolder ? apps.find((a) => a.folder === selectedFolder) : undefined
if (selected) {
return <AppFrame app={selected} onBack={() => setSelectedFolder(null)} />
return <AppFrame key={selected.folder} app={selected} onBack={() => setSelectedFolder(null)} />
}

const noOwnApps = appsLoaded && apps.length === 0
Expand Down
10 changes: 9 additions & 1 deletion apps/x/packages/core/src/apps/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,15 @@ export async function listApps(): Promise<AppSummary[]> {
}
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}`);
Expand Down
66 changes: 47 additions & 19 deletions apps/x/packages/core/src/apps/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,35 +56,63 @@ 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)
// ---------------------------------------------------------------------------

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<void>((resolve, reject) => out!.write(value, (e) => (e ? reject(e) : resolve())));
}
await new Promise<void>((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<void>((resolve, reject) => out.write(value, (e) => (e ? reject(e) : resolve())));
throw err;
} finally {
clearTimeout(totalTimer);
}
await new Promise<void>((resolve, reject) => out.end((e?: Error | null) => (e ? reject(e) : resolve())));
return { zipPath, sha256: hash.digest('hex') };
}

/**
Expand Down
42 changes: 39 additions & 3 deletions apps/x/packages/core/src/apps/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,17 @@ const BOOTSTRAP = String.raw`<script>
</script>`;

function injectBootstrap(htmlContent: string): string {
if (/<\/body>/i.test(htmlContent)) return htmlContent.replace(/<\/body>/i, `${BOOTSTRAP}\n</body>`);
return `${htmlContent}\n${BOOTSTRAP}`;
// Inject before the LAST </body>, not the first. An app whose markup
// contains a literal "</body>" earlier — a template inside a <textarea>,
// an HTML string inside a <script> — got the bootstrap spliced into that
// text instead: visible junk at best, a broken <script> at worst, and no
// live reload either way. (Regex loop rather than toLowerCase+lastIndexOf:
// case mapping can change string length and misalign the index.)
const re = /<\/body>/gi;
let idx = -1;
for (let m = re.exec(htmlContent); m; m = re.exec(htmlContent)) idx = m.index;
if (idx === -1) return `${htmlContent}\n${BOOTSTRAP}`;
return `${htmlContent.slice(0, idx)}${BOOTSTRAP}\n${htmlContent.slice(idx)}`;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -617,6 +626,16 @@ async function handleStatic(
return html503(res, 'App entry not found', `dist/${entryRel} does not exist.`);
}

// The entry itself is missing. `dist/index.html` has an extension, so the
// SPA-fallback branch above never catches it and this fell through to the
// JSON asset error — opening the app showed a raw {"error":...} blob
// instead of a page. This is the common copilot failure shape: manifest
// and dist/ get written, the entry does not.
const entryOnDisk = confinePath(distRoot, `/${entryRel}`);
if (entryOnDisk && resolved === entryOnDisk) {
return html503(res, 'App entry not found', `dist/${entryRel} does not exist.`);
}

sendError(res, 404, 'not_found', 'asset not found');
}

Expand Down Expand Up @@ -651,6 +670,13 @@ function createApp(): express.Express {
}
const slug = match[1];
if (!FOLDER_SLUG_RE.test(slug) || !fs.existsSync(appDirFor(slug))) {
// A browser navigating here — the app frame reloading after its folder
// was deleted or renamed — should get a page, not a JSON blob. Asset and
// XHR requests keep the machine-readable error.
if (req.method === 'GET' && (req.headers.accept ?? '').includes('text/html')) {
html503(res, 'App not found', `There is no app folder named “${slug}”. It may have been deleted or renamed.`);
return;
}
sendError(res, 404, 'app_not_found', `no app folder named "${slug}"`);
return;
}
Expand Down Expand Up @@ -736,7 +762,17 @@ async function startWatcher(): Promise<void> {
if (!['add', 'addDir', 'change', 'unlink', 'unlinkDir'].includes(eventName)) return;
const hit = slugFromAbsolutePath(absolutePath);
if (!hit || hit.rel.endsWith('.tmp') || /\.tmp-[0-9a-f]+$/.test(hit.rel)) return;
const area: 'dist' | 'data' = hit.rel === 'data' || hit.rel.startsWith('data/') ? 'data' : 'dist';
// Only dist/, data/ and the manifest change what the app serves.
// Everything else in the folder fell into the "dist" bucket and forced
// a full page reload — README.md, .rowboat-install.json,
// .rowboat-publish.json (rewritten at every publish step), agents/,
// defaults/, .previous/ — discarding whatever the user had typed in the
// open app for a file the app never reads.
const top = hit.rel.split('/')[0];
let area: 'dist' | 'data';
if (top === 'data') area = 'data';
else if (top === 'dist' || hit.rel === 'rowboat-app.json') area = 'dist';
else return;
scheduleChangeBroadcast(hit.slug, area, hit.rel);
if (hit.rel === 'data/config.json' && (eventName === 'add' || eventName === 'change')) {
scheduleAgentKick(hit.slug);
Expand Down
Loading