mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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:
parent
b548173593
commit
2c038fe518
8 changed files with 150 additions and 16 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
* dir, guarding against path traversal. Returns null if outside the dist root.
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -176,25 +176,32 @@ export function MiniAppsView({ initialAppId, initialVersion, onNewApp }: { initi
|
|||
const [manifests, setManifests] = useState<miniApp.MiniAppManifest[]>([])
|
||||
|
||||
// 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1527,8 +1527,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
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; } }
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue