From 2c038fe518caa3f428fa5ca8a1152314b5376e00 Mon Sep 17 00:00:00 2001 From: Gagan Date: Sat, 4 Jul 2026 15:59:13 +0530 Subject: [PATCH] feat(mini-apps): live data refresh + install reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - watch ~/.rowboat/apps: data.json changes push into open apps (shim re-fetches, onData fires); installs/updates re-list the gallery live and reload an open app once the write settles - atomic installs (temp->rename) so an app opened mid-install never reads a partial file - serve app assets by direct disk read instead of net.fetch(file://) — Chromium's network service could stall and blank every open app at once --- apps/x/apps/main/src/main.ts | 9 +- apps/x/apps/main/src/mini-apps-handler.ts | 87 ++++++++++++++++++- .../src/components/mini-app-frame.tsx | 14 +++ .../src/components/mini-apps-view.tsx | 27 +++--- apps/x/apps/renderer/src/mini-apps/runtime.ts | 2 + apps/x/apps/renderer/src/mini-apps/types.ts | 3 + .../core/src/application/lib/builtin-tools.ts | 11 ++- apps/x/packages/shared/src/ipc.ts | 13 +++ 8 files changed, 150 insertions(+), 16 deletions(-) diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 6f064414..82048e38 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -39,7 +39,7 @@ import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js"; import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.js"; -import { resolveMiniAppAsset, MINIAPP_BRIDGE_JS } from "./mini-apps-handler.js"; +import { resolveMiniAppAsset, serveMiniAppAsset, initMiniAppsWatcher, MINIAPP_BRIDGE_JS } from "./mini-apps-handler.js"; import started from "electron-squirrel-startup"; import { execFileSync } from "node:child_process"; import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; @@ -181,7 +181,9 @@ function registerAppProtocol() { } const absPath = resolveMiniAppAsset(id, segments.join("/")); if (!absPath) return new Response("Forbidden", { status: 403 }); - return net.fetch(pathToFileURL(absPath).toString()); + // Direct disk read — bypasses Chromium's network service, which can + // stall under load and blank every open app at once. + return serveMiniAppAsset(absPath); } catch { return new Response("Forbidden", { status: 403 }); } @@ -426,6 +428,9 @@ app.whenReady().then(async () => { // start bg-task scheduler (cron / window) initBackgroundTaskScheduler(); + // watch ~/.rowboat/apps for data.json changes → live-refresh open Mini Apps + initMiniAppsWatcher(); + // register event consumers and start the shared event processor // (consumes $WorkDir/events/pending/, routes events to all consumers // concurrently for Pass-1, then fires each consumer's candidates in parallel) diff --git a/apps/x/apps/main/src/mini-apps-handler.ts b/apps/x/apps/main/src/mini-apps-handler.ts index 50d82b30..97a9b3c8 100644 --- a/apps/x/apps/main/src/mini-apps-handler.ts +++ b/apps/x/apps/main/src/mini-apps-handler.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { BrowserWindow } from 'electron'; import { WorkDir } from '@x/core/dist/config/config.js'; import { MiniAppManifest } from '@x/shared/dist/mini-app.js'; import type { z } from 'zod'; @@ -61,6 +62,8 @@ export const MINIAPP_BRIDGE_JS = ` stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); } else if (m.type === 'rowboat:mini-app:theme') { applyTheme(m.theme); + } else if (m.type === 'rowboat:mini-app:data-updated') { + loadData(); } else if (m.type === 'rowboat:mini-app:rpc-result') { var p = pending[m.id]; if (p) { delete pending[m.id]; if (m.ok) p.resolve(m.result); else p.reject(new Error(m.error || 'request failed')); } @@ -107,8 +110,13 @@ export function seedApps(apps: Array<{ manifest: Manifest; html: string; data?: const dir = appDir(manifest.id); const distDir = path.join(dir, 'dist'); ensureDir(distDir); - fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2)); - fs.writeFileSync(path.join(distDir, 'index.html'), app.html); + // Atomic writes so a concurrently-open app never reads a partial file. + const writeAtomic = (p: string, content: string) => { + fs.writeFileSync(`${p}.tmp`, content); + fs.renameSync(`${p}.tmp`, p); + }; + writeAtomic(path.join(distDir, 'index.html'), app.html); + writeAtomic(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2)); const dataPath = path.join(dir, 'data.json'); if (app.data !== undefined && !fs.existsSync(dataPath)) { fs.writeFileSync(dataPath, JSON.stringify(app.data, null, 2)); @@ -174,6 +182,81 @@ export function getAppData(id: string): { data: unknown | null } { } } +/** + * Watch ~/.rowboat/apps for data.json changes and notify all renderer windows + * (mini-apps:dataChanged) so an open app can reload its data live. Debounced + * per app id (agents write via temp→rename, which fires multiple fs events). + */ +export function initMiniAppsWatcher(): void { + fs.mkdirSync(APPS_DIR, { recursive: true }); + const pending = new Map(); + const notify = (channel: 'mini-apps:dataChanged' | 'mini-apps:appsChanged', id: string) => { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send(channel, { id }); + } + } + }; + const schedule = (channel: 'mini-apps:dataChanged' | 'mini-apps:appsChanged', id: string) => { + const key = `${channel}:${id}`; + const existing = pending.get(key); + if (existing) clearTimeout(existing); + pending.set(key, setTimeout(() => { pending.delete(key); notify(channel, id); }, 300)); + }; + try { + fs.watch(APPS_DIR, { recursive: true }, (_event, filename) => { + if (!filename) return; + // filename is relative to APPS_DIR, e.g. "/dist/data.json" + const id = filename.split(path.sep)[0]; + if (!id || id.startsWith('.') || filename.endsWith('.tmp')) return; + if (filename.endsWith('data.json')) { + schedule('mini-apps:dataChanged', id); + } else if (filename.endsWith('manifest.json') || filename.endsWith('index.html')) { + // install / update settled → gallery re-lists, open frames reload + schedule('mini-apps:appsChanged', id); + } + }); + } catch (err) { + console.error('[MiniApps] watcher failed to start:', err); + } +} + +const MIME_BY_EXT: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', +}; + +/** + * Serve a mini-app asset by reading it directly from disk. Deliberately avoids + * net.fetch(file://…): that routes through Chromium's shared network service, + * which can stall under load and blank every open app at once. Logs slow serves + * so stalls are visible in the terminal. + */ +export async function serveMiniAppAsset(absPath: string): Promise { + const started = Date.now(); + try { + const buf = await fs.promises.readFile(absPath); + const mime = MIME_BY_EXT[path.extname(absPath).toLowerCase()] ?? 'application/octet-stream'; + const ms = Date.now() - started; + if (ms > 250) console.warn(`[MiniApps] slow asset serve (${ms}ms): ${absPath}`); + return new Response(new Uint8Array(buf), { headers: { 'content-type': mime } }); + } catch { + return new Response('Not Found', { status: 404 }); + } +} + /** * Resolve app://miniapp// to an absolute file path under the app's dist * dir, guarding against path traversal. Returns null if outside the dist root. diff --git a/apps/x/apps/renderer/src/components/mini-app-frame.tsx b/apps/x/apps/renderer/src/components/mini-app-frame.tsx index 3e152301..07cf608b 100644 --- a/apps/x/apps/renderer/src/components/mini-app-frame.tsx +++ b/apps/x/apps/renderer/src/components/mini-app-frame.tsx @@ -122,8 +122,22 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest } } window.addEventListener('message', handleMessage) + // Live refresh: when this app's data.json changes on disk (agent run), + // tell the shim to re-fetch so onData fires with fresh data. + const offDataChanged = window.ipc.on('mini-apps:dataChanged', ({ id }) => { + if (id === manifest.id) postToFrame({ type: MINI_APP_MESSAGE.dataUpdated }) + }) + // If this app itself was just (re)installed, reload the frame — covers an + // app opened mid-install that rendered a partial/blank page. Reset src + // (contentWindow.location is cross-origin and would throw). + const offAppsChanged = window.ipc.on('mini-apps:appsChanged', ({ id }) => { + const frame = iframeRef.current + if (id === manifest.id && frame) frame.src = frame.src // eslint-disable-line no-self-assign + }) return () => { window.removeEventListener('message', handleMessage) + offDataChanged() + offAppsChanged() themeObserver.disconnect() } }, [manifest.id, scope]) diff --git a/apps/x/apps/renderer/src/components/mini-apps-view.tsx b/apps/x/apps/renderer/src/components/mini-apps-view.tsx index 571449ce..d4f18c4b 100644 --- a/apps/x/apps/renderer/src/components/mini-apps-view.tsx +++ b/apps/x/apps/renderer/src/components/mini-apps-view.tsx @@ -176,25 +176,32 @@ export function MiniAppsView({ initialAppId, initialVersion, onNewApp }: { initi const [manifests, setManifests] = useState([]) // Open a specific app when asked from outside (app-navigation open-app). - useEffect(() => { + // Adjust-during-render pattern: react to a new request (version bump) without + // an effect. + const [appliedVersion, setAppliedVersion] = useState(initialVersion) + if (initialVersion !== appliedVersion) { + setAppliedVersion(initialVersion) if (initialAppId) setSelectedId(initialAppId) - }, [initialAppId, initialVersion]) + } - // List apps installed under ~/.rowboat/apps. Apps are created there by the - // copilot builder (or placed manually); none are bundled in the repo. + // List apps installed under ~/.rowboat/apps (created by the copilot builder or + // placed manually; none are bundled in the repo). Keeps the list live as apps + // are installed/updated, and re-lists on each open-app request (the app may + // have been installed after this view mounted). useEffect(() => { let cancelled = false - ;(async () => { + const load = async () => { try { const r = await window.ipc.invoke('mini-apps:list', null) - if (cancelled) return - setManifests([...r.manifests].sort((a, b) => a.title.localeCompare(b.title))) + if (!cancelled) setManifests([...r.manifests].sort((a, b) => a.title.localeCompare(b.title))) } catch { if (!cancelled) setManifests([]) } - })() - return () => { cancelled = true } - }, []) + } + void load() + const off = window.ipc.on('mini-apps:appsChanged', () => { void load() }) + return () => { cancelled = true; off() } + }, [initialVersion]) const selected = selectedId ? manifests.find((m) => m.id === selectedId) : undefined diff --git a/apps/x/apps/renderer/src/mini-apps/runtime.ts b/apps/x/apps/renderer/src/mini-apps/runtime.ts index c88feb62..ffe3b3d7 100644 --- a/apps/x/apps/renderer/src/mini-apps/runtime.ts +++ b/apps/x/apps/renderer/src/mini-apps/runtime.ts @@ -49,6 +49,8 @@ const BRIDGE_SHIM = /* js */ ` stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); } else if (m.type === 'rowboat:mini-app:theme') { applyTheme(m.theme); + } else if (m.type === 'rowboat:mini-app:data-updated') { + loadData(); } else if (m.type === 'rowboat:mini-app:rpc-result') { var p = pending[m.id]; if (p) { diff --git a/apps/x/apps/renderer/src/mini-apps/types.ts b/apps/x/apps/renderer/src/mini-apps/types.ts index 49e335c9..f865e00b 100644 --- a/apps/x/apps/renderer/src/mini-apps/types.ts +++ b/apps/x/apps/renderer/src/mini-apps/types.ts @@ -73,6 +73,8 @@ export type MiniAppInboundMessage = | { type: 'rowboat:mini-app:state'; state: unknown } /** Host theme; sent on ready and whenever the app theme changes. */ | { type: 'rowboat:mini-app:theme'; theme: 'light' | 'dark' } + /** data.json changed on disk — the shim re-fetches and fires onData. */ + | { type: 'rowboat:mini-app:data-updated' } /** Result of a previously requested rpc call, correlated by id. */ | { type: 'rowboat:mini-app:rpc-result'; id: string; ok: boolean; result?: unknown; error?: string } @@ -83,5 +85,6 @@ export const MINI_APP_MESSAGE = { data: 'rowboat:mini-app:data', state: 'rowboat:mini-app:state', theme: 'rowboat:mini-app:theme', + dataUpdated: 'rowboat:mini-app:data-updated', rpcResult: 'rowboat:mini-app:rpc-result', } as const diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 1bccb69e..f70839b7 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1527,8 +1527,15 @@ export const BuiltinTools: z.infer = { const dir = path.join(WorkDir, 'apps', m.id); const dist = path.join(dir, 'dist'); await fs.mkdir(dist, { recursive: true }); - await fs.writeFile(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2)); - await fs.writeFile(path.join(dist, 'index.html'), html); + // Atomic writes (temp→rename): an app opened mid-install must never + // see a partially-written manifest or index.html (blank screen). + const writeAtomic = async (p: string, content: string) => { + const tmp = `${p}.tmp`; + await fs.writeFile(tmp, content); + await fs.rename(tmp, p); + }; + await writeAtomic(path.join(dist, 'index.html'), html); + await writeAtomic(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2)); if (data !== undefined) { let payload: unknown = data; if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = undefined; } } diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index ccca5bf9..100cde6a 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1033,6 +1033,19 @@ const ipcSchemas = { req: z.object({ id: z.string() }), res: z.object({ data: z.unknown().nullable() }), }, + // Mini Apps: event pushed main→renderer when an app's data.json changes on + // disk (agent refresh), so an open app can reload its data live. + 'mini-apps:dataChanged': { + req: z.object({ id: z.string() }), + res: z.null(), + }, + // Mini Apps: event pushed main→renderer when an app is installed/updated + // (manifest or index.html changed), so the gallery re-lists and an open app + // reloads once the install settles. + 'mini-apps:appsChanged': { + req: z.object({ id: z.string() }), + res: z.null(), + }, // Mini Apps: proxy an HTTP request through main (bypasses browser CORS for the // sandboxed app origin). GET/POST to http(s) only. 'mini-apps:fetch': {