feat(mini-apps): live data refresh + install reliability

- 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
This commit is contained in:
Gagan 2026-07-04 15:59:13 +05:30
parent b548173593
commit 2c038fe518
8 changed files with 150 additions and 16 deletions

View file

@ -39,7 +39,7 @@ import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js"; import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js"; import { getAgentSlackCliStatus } from "@x/core/dist/slack/agent-slack-exec.js";
import { resolveWorkspacePath } from "@x/core/dist/workspace/workspace.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 started from "electron-squirrel-startup";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js"; 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("/")); const absPath = resolveMiniAppAsset(id, segments.join("/"));
if (!absPath) return new Response("Forbidden", { status: 403 }); 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 { } catch {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
@ -426,6 +428,9 @@ app.whenReady().then(async () => {
// start bg-task scheduler (cron / window) // start bg-task scheduler (cron / window)
initBackgroundTaskScheduler(); initBackgroundTaskScheduler();
// watch ~/.rowboat/apps for data.json changes → live-refresh open Mini Apps
initMiniAppsWatcher();
// register event consumers and start the shared event processor // register event consumers and start the shared event processor
// (consumes $WorkDir/events/pending/, routes events to all consumers // (consumes $WorkDir/events/pending/, routes events to all consumers
// concurrently for Pass-1, then fires each consumer's candidates in parallel) // concurrently for Pass-1, then fires each consumer's candidates in parallel)

View file

@ -1,5 +1,6 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { BrowserWindow } from 'electron';
import { WorkDir } from '@x/core/dist/config/config.js'; import { WorkDir } from '@x/core/dist/config/config.js';
import { MiniAppManifest } from '@x/shared/dist/mini-app.js'; import { MiniAppManifest } from '@x/shared/dist/mini-app.js';
import type { z } from 'zod'; import type { z } from 'zod';
@ -61,6 +62,8 @@ export const MINIAPP_BRIDGE_JS = `
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:theme') { } else if (m.type === 'rowboat:mini-app:theme') {
applyTheme(m.theme); applyTheme(m.theme);
} else if (m.type === 'rowboat:mini-app:data-updated') {
loadData();
} else if (m.type === 'rowboat:mini-app:rpc-result') { } else if (m.type === 'rowboat:mini-app:rpc-result') {
var p = pending[m.id]; 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')); } 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 dir = appDir(manifest.id);
const distDir = path.join(dir, 'dist'); const distDir = path.join(dir, 'dist');
ensureDir(distDir); ensureDir(distDir);
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2)); // Atomic writes so a concurrently-open app never reads a partial file.
fs.writeFileSync(path.join(distDir, 'index.html'), app.html); 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'); const dataPath = path.join(dir, 'data.json');
if (app.data !== undefined && !fs.existsSync(dataPath)) { if (app.data !== undefined && !fs.existsSync(dataPath)) {
fs.writeFileSync(dataPath, JSON.stringify(app.data, null, 2)); 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 temprename, which fires multiple fs events).
*/
export function initMiniAppsWatcher(): void {
fs.mkdirSync(APPS_DIR, { recursive: true });
const pending = new Map<string, NodeJS.Timeout>();
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. "<id>/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<string, string> = {
'.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<Response> {
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/<id>/<rel> to an absolute file path under the app's dist * Resolve app://miniapp/<id>/<rel> to an absolute file path under the app's dist
* dir, guarding against path traversal. Returns null if outside the dist root. * dir, guarding against path traversal. Returns null if outside the dist root.

View file

@ -122,8 +122,22 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }
} }
window.addEventListener('message', handleMessage) 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 () => { return () => {
window.removeEventListener('message', handleMessage) window.removeEventListener('message', handleMessage)
offDataChanged()
offAppsChanged()
themeObserver.disconnect() themeObserver.disconnect()
} }
}, [manifest.id, scope]) }, [manifest.id, scope])

View file

@ -176,25 +176,32 @@ export function MiniAppsView({ initialAppId, initialVersion, onNewApp }: { initi
const [manifests, setManifests] = useState<miniApp.MiniAppManifest[]>([]) const [manifests, setManifests] = useState<miniApp.MiniAppManifest[]>([])
// Open a specific app when asked from outside (app-navigation open-app). // 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) if (initialAppId) setSelectedId(initialAppId)
}, [initialAppId, initialVersion]) }
// List apps installed under ~/.rowboat/apps. Apps are created there by the // List apps installed under ~/.rowboat/apps (created by the copilot builder or
// copilot builder (or placed manually); none are bundled in the repo. // 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { const load = async () => {
try { try {
const r = await window.ipc.invoke('mini-apps:list', null) const r = await window.ipc.invoke('mini-apps:list', null)
if (cancelled) return if (!cancelled) setManifests([...r.manifests].sort((a, b) => a.title.localeCompare(b.title)))
setManifests([...r.manifests].sort((a, b) => a.title.localeCompare(b.title)))
} catch { } catch {
if (!cancelled) setManifests([]) 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 const selected = selectedId ? manifests.find((m) => m.id === selectedId) : undefined

View file

@ -49,6 +49,8 @@ const BRIDGE_SHIM = /* js */ `
stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} });
} else if (m.type === 'rowboat:mini-app:theme') { } else if (m.type === 'rowboat:mini-app:theme') {
applyTheme(m.theme); applyTheme(m.theme);
} else if (m.type === 'rowboat:mini-app:data-updated') {
loadData();
} else if (m.type === 'rowboat:mini-app:rpc-result') { } else if (m.type === 'rowboat:mini-app:rpc-result') {
var p = pending[m.id]; var p = pending[m.id];
if (p) { if (p) {

View file

@ -73,6 +73,8 @@ export type MiniAppInboundMessage =
| { type: 'rowboat:mini-app:state'; state: unknown } | { type: 'rowboat:mini-app:state'; state: unknown }
/** Host theme; sent on ready and whenever the app theme changes. */ /** Host theme; sent on ready and whenever the app theme changes. */
| { type: 'rowboat:mini-app:theme'; theme: 'light' | 'dark' } | { 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. */ /** Result of a previously requested rpc call, correlated by id. */
| { type: 'rowboat:mini-app:rpc-result'; id: string; ok: boolean; result?: unknown; error?: string } | { 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', data: 'rowboat:mini-app:data',
state: 'rowboat:mini-app:state', state: 'rowboat:mini-app:state',
theme: 'rowboat:mini-app:theme', theme: 'rowboat:mini-app:theme',
dataUpdated: 'rowboat:mini-app:data-updated',
rpcResult: 'rowboat:mini-app:rpc-result', rpcResult: 'rowboat:mini-app:rpc-result',
} as const } as const

View file

@ -1527,8 +1527,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
const dir = path.join(WorkDir, 'apps', m.id); const dir = path.join(WorkDir, 'apps', m.id);
const dist = path.join(dir, 'dist'); const dist = path.join(dir, 'dist');
await fs.mkdir(dist, { recursive: true }); await fs.mkdir(dist, { recursive: true });
await fs.writeFile(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2)); // Atomic writes (temp→rename): an app opened mid-install must never
await fs.writeFile(path.join(dist, 'index.html'), html); // 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) { if (data !== undefined) {
let payload: unknown = data; let payload: unknown = data;
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = undefined; } } if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = undefined; } }

View file

@ -1033,6 +1033,19 @@ const ipcSchemas = {
req: z.object({ id: z.string() }), req: z.object({ id: z.string() }),
res: z.object({ data: z.unknown().nullable() }), 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 // Mini Apps: proxy an HTTP request through main (bypasses browser CORS for the
// sandboxed app origin). GET/POST to http(s) only. // sandboxed app origin). GET/POST to http(s) only.
'mini-apps:fetch': { 'mini-apps:fetch': {