diff --git a/apps/x/MINI_APPS_PLAN.md b/apps/x/MINI_APPS_PLAN.md index 353883be..4f69e4cc 100644 --- a/apps/x/MINI_APPS_PLAN.md +++ b/apps/x/MINI_APPS_PLAN.md @@ -78,6 +78,64 @@ rowboat.ready() // handshake: "send me data" notifications (reuse `notify-user`). 6. **(V2)** Drag an app into the main workspace (macOS-style). +## 2b. Phase 2.5 — On-disk apps + `app://miniapp` serving (CURRENT) + +Move built-in apps out of source into `~/.rowboat/apps//`, served as static +assets, with an optional background agent producing `data.json`. Sets up the +copilot builder path (it will write the same folder layout). Team-agreed. + +### On-disk layout (one folder per app) +``` +~/.rowboat/apps// + manifest.json # id, title, description, source, scope[], active, lastRun, + # entry (default "dist/index.html"), agent (optional bg-task slug) + dist/ # static assets served via app://miniapp//... + index.html # the app (self-contained; bridge shim inlined) + data.json # latest agent output (read by the host, pushed to the app) +``` + +### Serving — `app://miniapp//` (option A) +- Extend `registerAppProtocol` (`apps/main/src/main.ts`) with a new host + `miniapp`: map `app://miniapp//` → `~/.rowboat/apps//dist/` + (path-traversal guarded; default `index.html`). `app://` is already registered + privileged (standard/secure/fetch/cors) so apps get a **real origin** — + remote CDNs/images and `fetch` work, unlike the opaque `srcdoc` origin. +- The iframe loads via `src="app://miniapp//index.html"` (not `srcDoc`). + Sandbox becomes `allow-scripts allow-same-origin allow-popups allow-forms + allow-modals allow-downloads` — same-origin is its OWN `app://miniapp` origin, + still isolated from the renderer (different host). + +### Data path +- Built-in/static `data` is seeded to `data.json`. A future background agent + (reuse bg-tasks engine, linked via manifest `agent`) overwrites it on schedule. +- App keeps using `rowboat.onData`; the **host** now sources that data by reading + `~/.rowboat/apps//data.json` (via IPC) and posting it on `ready`. Composio + still flows through the bridge RPC. (GitHub app needs no `data.json` — it pulls + live via Composio.) + +### Steps +1. **Shared schema** — `packages/shared/src/mini-app.ts`: `MiniAppManifest` zod + + type. Export it. New IPC channels in `shared/src/ipc.ts`: + - `mini-apps:seed` (req: `{apps:[{manifest, html, data?}]}`) — idempotent. + - `mini-apps:list` (res: `{manifests: MiniAppManifest[]}`). + - `mini-apps:get-data` (req `{id}`, res `{data: unknown|null}`). +2. **Main** — `apps/main/src/mini-apps-handler.ts`: `seedApps` (write + manifest/dist/data to `~/.rowboat/apps//` only if absent), `listApps` + (read manifests), `getAppData` (read data.json). Register handlers in + `ipc.ts`. Extend `registerAppProtocol` for the `miniapp` host. +3. **Renderer** — keep `MiniApp` defs + `buildMiniAppHtml`; `registry.ts` adds + `toSeed(app)` (→ `{manifest, html, data}`). `MiniAppsView`: on mount → `seed` + → `list` → render cards from manifests. `MiniAppFrame`: take the manifest, + load `src=app://miniapp//`, on `ready` fetch `mini-apps:get-data` + and post it; RPC scope from `manifest.scope`. +4. **Verify**: GitHub app end-to-end from disk (`~/.rowboat/apps/github-radar/`), + then the others; confirm connect + live PRs still work. + +### Out of scope here (next: copilot builder, tomorrow) +Copilot writing a new app folder + an associated background agent; the agent +browsing via embedded browser for social feeds; copilot verifying Composio wiring +by actually calling tools before finalizing. + ## 3. Phase 1 — detailed implementation UI-first. Everything hand-coded; no `~/.rowboat` storage, no IPC, no agent yet. diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 754ad77b..04163f38 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -56,6 +56,7 @@ import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, ge import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js'; import * as composioHandler from './composio-handler.js'; +import * as miniAppsHandler from './mini-apps-handler.js'; import { consumePendingDeepLink } from './deeplink.js'; import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; @@ -1313,6 +1314,16 @@ export function setupIpcHandlers() { 'migration:check-composio-google': async () => { return qualifyAndDisconnectComposioGoogle(); }, + // Mini Apps handlers + 'mini-apps:seed': async (_event, args) => { + return miniAppsHandler.seedApps(args.apps); + }, + 'mini-apps:list': async () => { + return miniAppsHandler.listApps(); + }, + 'mini-apps:get-data': async (_event, args) => { + return miniAppsHandler.getAppData(args.id); + }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { const repo = container.resolve('agentScheduleRepo'); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 450d0f9e..b102065d 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -39,6 +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 } 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"; @@ -166,6 +167,20 @@ function registerAppProtocol() { } } + // Mini App assets: app://miniapp// → ~/.rowboat/apps//dist/ + if (url.host === "miniapp") { + try { + const segments = decodeURIComponent(url.pathname).replace(/^\/+/, "").split("/"); + const id = segments.shift(); + if (!id) return new Response("Not Found", { status: 404 }); + const absPath = resolveMiniAppAsset(id, segments.join("/")); + if (!absPath) return new Response("Forbidden", { status: 403 }); + return net.fetch(pathToFileURL(absPath).toString()); + } catch { + return new Response("Forbidden", { status: 403 }); + } + } + // Renderer SPA — existing logic let urlPath = url.pathname; if (urlPath === "/" || !path.extname(urlPath)) { diff --git a/apps/x/apps/main/src/mini-apps-handler.ts b/apps/x/apps/main/src/mini-apps-handler.ts new file mode 100644 index 00000000..07025635 --- /dev/null +++ b/apps/x/apps/main/src/mini-apps-handler.ts @@ -0,0 +1,83 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '@x/core/dist/config/config.js'; +import { MiniAppManifest } from '@x/shared/dist/mini-app.js'; +import type { z } from 'zod'; + +type Manifest = z.infer; + +// All Mini Apps live under ~/.rowboat/apps//. +// manifest.json — MiniAppManifest +// dist/ — static assets served via app://miniapp// +// data.json — latest agent output (read by the host) +const APPS_DIR = path.join(WorkDir, 'apps'); + +function appDir(id: string): string { + return path.join(APPS_DIR, id); +} + +function ensureDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); +} + +/** + * Seed/refresh built-in apps. Manifest + dist are managed (always overwritten so + * built-in updates propagate); data.json is written only if missing so a + * background agent's output is never clobbered. + */ +export function seedApps(apps: Array<{ manifest: Manifest; html: string; data?: unknown }>): { seeded: string[] } { + const seeded: string[] = []; + for (const app of apps) { + const manifest = MiniAppManifest.parse(app.manifest); + 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); + const dataPath = path.join(dir, 'data.json'); + if (app.data !== undefined && !fs.existsSync(dataPath)) { + fs.writeFileSync(dataPath, JSON.stringify(app.data, null, 2)); + } + seeded.push(manifest.id); + } + return { seeded }; +} + +/** List installed app manifests (skips folders without a valid manifest). */ +export function listApps(): { manifests: Manifest[] } { + const manifests: Manifest[] = []; + if (!fs.existsSync(APPS_DIR)) return { manifests }; + for (const entry of fs.readdirSync(APPS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const file = path.join(APPS_DIR, entry.name, 'manifest.json'); + try { + const parsed = MiniAppManifest.parse(JSON.parse(fs.readFileSync(file, 'utf-8'))); + manifests.push(parsed); + } catch { + // skip folders without a valid manifest + } + } + return { manifests }; +} + +/** Read an app's latest data.json (agent output), or null if absent/invalid. */ +export function getAppData(id: string): { data: unknown | null } { + try { + const raw = fs.readFileSync(path.join(appDir(id), 'data.json'), 'utf-8'); + return { data: JSON.parse(raw) }; + } catch { + return { data: null }; + } +} + +/** + * 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. + */ +export function resolveMiniAppAsset(id: string, relPath: string): string | null { + const distRoot = path.resolve(appDir(id), 'dist'); + const clean = relPath.replace(/^\/+/, '') || 'index.html'; + const abs = path.resolve(distRoot, clean); + if (abs !== distRoot && !abs.startsWith(distRoot + path.sep)) return null; + return abs; +} 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 77ccd40d..734bd73d 100644 --- a/apps/x/apps/renderer/src/components/mini-app-frame.tsx +++ b/apps/x/apps/renderer/src/components/mini-app-frame.tsx @@ -1,22 +1,25 @@ import { useEffect, useRef } from 'react' -import type { MiniApp, MiniAppOutboundMessage } from '@/mini-apps/types' +import { miniApp } from '@x/shared' +import type { MiniAppOutboundMessage } from '@/mini-apps/types' import { MINI_APP_MESSAGE } from '@/mini-apps/types' -// Host side of the Mini App bridge. Renders the app's self-contained HTML in a -// sandboxed iframe and answers the postMessage protocol in mini-apps/types.ts. +// Host side of the Mini App bridge. Loads the app's static assets from +// app://miniapp// (served from ~/.rowboat/apps//dist) and answers the +// postMessage protocol in mini-apps/types.ts. // -// Phase 2: the bridge is real. App rpc calls (callAction/searchTools/ -// isConnected/connect) are scope-checked against the app's declared scope and -// routed to Composio over IPC. Per-app state is still in-memory for now. +// - data: sourced from the app's on-disk data.json (agent output) via IPC. +// - bridge rpc (callAction/searchTools/isConnected/connect): scope-checked +// against the manifest, routed to Composio over IPC. -// Sandbox intentionally omits allow-same-origin: the app gets an opaque origin so -// it cannot reach host cookies/storage. The bridge is the only channel out. -const SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads' +// app://miniapp/ is a distinct origin from the renderer, so allow-same-origin +// here grants the app same-origin to its OWN origin only (lets it use fetch / +// remote assets) while staying isolated from the renderer. +const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads' -export function MiniAppFrame({ app }: { app: MiniApp }) { +export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }) { const iframeRef = useRef(null) - // In-memory per-app state for Phase 1/2 (resets when the frame unmounts). const stateRef = useRef>({}) + const scope = manifest.scope useEffect(() => { stateRef.current = {} @@ -25,26 +28,24 @@ export function MiniAppFrame({ app }: { app: MiniApp }) { iframeRef.current?.contentWindow?.postMessage(message, '*') } - // Dispatch an app rpc call to real Composio IPC. Throws on scope violation - // or failure; the caller turns that into an rpc-result error. async function handleRpc(method: string, params: Record): Promise { - const scope = typeof params.scope === 'string' ? params.scope : '' - if (!scope || !app.scope.includes(scope)) { - throw new Error(`This app is not allowed to use "${scope || '(none)'}".`) + const s = typeof params.scope === 'string' ? params.scope : '' + if (!s || !scope.includes(s)) { + throw new Error(`This app is not allowed to use "${s || '(none)'}".`) } switch (method) { case 'isConnected': { - const r = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: scope }) + const r = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: s }) return r.isConnected } case 'connect': { - const r = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: scope }) + const r = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: s }) if (!r.success && r.error) throw new Error(r.error) return { started: r.success } } case 'searchTools': { const query = typeof params.query === 'string' ? params.query : '' - const r = await window.ipc.invoke('composio:search-tools', { toolkitSlug: scope, query }) + const r = await window.ipc.invoke('composio:search-tools', { toolkitSlug: s, query }) if (r.error) throw new Error(r.error) return r.tools } @@ -52,7 +53,7 @@ export function MiniAppFrame({ app }: { app: MiniApp }) { const tool = typeof params.tool === 'string' ? params.tool : '' if (!tool) throw new Error('No tool specified.') const args = (params.args && typeof params.args === 'object' ? params.args : {}) as Record - const r = await window.ipc.invoke('composio:execute-tool', { toolkitSlug: scope, toolSlug: tool, arguments: args }) + const r = await window.ipc.invoke('composio:execute-tool', { toolkitSlug: s, toolSlug: tool, arguments: args }) if (!r.successful) throw new Error(r.error || 'Action failed.') return r.data } @@ -61,6 +62,18 @@ export function MiniAppFrame({ app }: { app: MiniApp }) { } } + async function handleReady() { + let data: unknown = null + try { + const r = await window.ipc.invoke('mini-apps:get-data', { id: manifest.id }) + data = r.data + } catch { + data = null + } + postToFrame({ type: MINI_APP_MESSAGE.data, data }) + postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current }) + } + function handleMessage(event: MessageEvent) { const frameWindow = iframeRef.current?.contentWindow if (!frameWindow || event.source !== frameWindow) return @@ -70,8 +83,7 @@ export function MiniAppFrame({ app }: { app: MiniApp }) { switch (msg.type) { case MINI_APP_MESSAGE.ready: { - postToFrame({ type: MINI_APP_MESSAGE.data, data: app.data }) - postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current }) + void handleReady() break } case MINI_APP_MESSAGE.rpc: { @@ -96,13 +108,13 @@ export function MiniAppFrame({ app }: { app: MiniApp }) { window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) - }, [app]) + }, [manifest.id, scope]) return (