From 7f15f6727383b96ce96deb8d7ae4329a491d389d Mon Sep 17 00:00:00 2001 From: Gagan Date: Sat, 4 Jul 2026 17:12:54 +0530 Subject: [PATCH] =?UTF-8?q?feat(apps):=20Rowboat=20Apps=20M1=20=E2=80=94?= =?UTF-8?q?=20per-app=20origins,=20Host=20API=20core,=20apps=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements M1 of the Apps V1 spec, replacing the mini-apps prototype: - rowboat-app.json manifest + AppSummary/install/publish/registry schemas; sourceApp on background tasks - apps server on 127.0.0.1:3210 (absorbs local-sites): Host-header routing to .apps.localhost origins, 421 rebinding guard, static from dist/ with entry/SPA fallback + realpath confinement, control host /health, chokidar watcher + per-app SSE with dist/data areas, embed-opt-in autosize bootstrap and cancelable rowboat:data-change event - Host API: D17 anti-CSRF (X-Rowboat-App + strict Origin on non-GET), GET /_rowboat/app (manifest + theme), /_rowboat/events, data API under data/ with atomic writes and dataContracts enforcement - indexer: list (ok/invalid surfaced) / create scaffold / delete - IPC: apps:list/get/create/delete/setTheme; renderer reports theme live - renderer: Apps home (My apps grid + catalog placeholder, server banner, invalid/kind badges) and full-height app view on the app origin - apps copilot skill (replaces build-mini-app); app-set-data builtin replaces mini-app-install/set-data; deleted the app://miniapp protocol, postMessage bridge, mini-apps handler/channels, and local-sites --- apps/x/apps/main/src/ipc.ts | 34 +- apps/x/apps/main/src/main.ts | 38 +- apps/x/apps/main/src/mini-apps-handler.ts | 270 ------- apps/x/apps/renderer/src/App.tsx | 21 +- .../src/components/apps/app-frame.tsx | 52 ++ .../apps-view.tsx} | 165 ++-- .../src/components/mini-app-frame.tsx | 154 ---- apps/x/apps/renderer/src/mini-apps/runtime.ts | 143 ---- apps/x/apps/renderer/src/mini-apps/types.ts | 90 --- .../assistant/skills/apps/skill.ts | 200 +++++ .../assistant/skills/build-mini-app/skill.ts | 160 ---- .../assistant/skills/doc-collab/skill.ts | 8 +- .../src/application/assistant/skills/index.ts | 10 +- .../core/src/application/lib/builtin-tools.ts | 125 ++- apps/x/packages/core/src/apps/constants.ts | 43 + apps/x/packages/core/src/apps/indexer.ts | 189 +++++ apps/x/packages/core/src/apps/server.ts | 762 ++++++++++++++++++ .../x/packages/core/src/local-sites/server.ts | 606 -------------- .../core/src/local-sites/templates.ts | 625 -------------- apps/x/packages/shared/src/background-task.ts | 6 + apps/x/packages/shared/src/index.ts | 2 +- apps/x/packages/shared/src/ipc.ts | 68 +- apps/x/packages/shared/src/mini-app.ts | 42 - apps/x/packages/shared/src/rowboat-app.ts | 113 +++ 24 files changed, 1567 insertions(+), 2359 deletions(-) delete mode 100644 apps/x/apps/main/src/mini-apps-handler.ts create mode 100644 apps/x/apps/renderer/src/components/apps/app-frame.tsx rename apps/x/apps/renderer/src/components/{mini-apps-view.tsx => apps/apps-view.tsx} (64%) delete mode 100644 apps/x/apps/renderer/src/components/mini-app-frame.tsx delete mode 100644 apps/x/apps/renderer/src/mini-apps/runtime.ts delete mode 100644 apps/x/apps/renderer/src/mini-apps/types.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/apps/skill.ts delete mode 100644 apps/x/packages/core/src/application/assistant/skills/build-mini-app/skill.ts create mode 100644 apps/x/packages/core/src/apps/constants.ts create mode 100644 apps/x/packages/core/src/apps/indexer.ts create mode 100644 apps/x/packages/core/src/apps/server.ts delete mode 100644 apps/x/packages/core/src/local-sites/server.ts delete mode 100644 apps/x/packages/core/src/local-sites/templates.ts delete mode 100644 apps/x/packages/shared/src/mini-app.ts create mode 100644 apps/x/packages/shared/src/rowboat-app.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 7cc36036..eaed74f9 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -56,7 +56,8 @@ 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 * as appsIndexer from '@x/core/dist/apps/indexer.js'; +import * as appsServer from '@x/core/dist/apps/server.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'; @@ -1314,18 +1315,31 @@ export function setupIpcHandlers() { 'migration:check-composio-google': async () => { return qualifyAndDisconnectComposioGoogle(); }, - // Mini Apps handlers - 'mini-apps:seed': async (_event, args) => { - return miniAppsHandler.seedApps(args.apps); + // Rowboat Apps handlers (spec §13) + 'apps:list': async () => { + const status = appsServer.getServerStatus(); + return { + serverRunning: status.running, + ...(status.error ? { serverError: status.error } : {}), + apps: await appsIndexer.listApps(), + }; }, - 'mini-apps:list': async () => { - return miniAppsHandler.listApps(); + 'apps:get': async (_event, args) => { + const app = await appsIndexer.getApp(args.folder); + if (!app) throw new Error(`no such app: ${args.folder}`); + // readme + rollback are M3 concerns; shapes are stable now. + return { app, rollbackAvailable: false }; }, - 'mini-apps:get-data': async (_event, args) => { - return miniAppsHandler.getAppData(args.id); + 'apps:create': async (_event, args) => { + return { app: await appsIndexer.createApp(args) }; }, - 'mini-apps:fetch': async (_event, args) => { - return miniAppsHandler.proxyFetch(args); + 'apps:delete': async (_event, args) => { + await appsIndexer.deleteApp(args.folder); + return { ok: true as const }; + }, + 'apps:setTheme': async (_event, args) => { + appsServer.setAppsTheme(args.theme); + return { ok: true as const }; }, // Agent schedule handlers 'agent-schedule:getConfig': async () => { diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 82048e38..fdd0d2f6 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -32,14 +32,13 @@ import { init as initEventProcessor, registerConsumer } from "@x/core/dist/event import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js"; import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js"; import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js"; -import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; +import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js"; import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; 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, 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"; @@ -167,28 +166,6 @@ 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 }); - // Canonical bridge shim shared by all apps. - if (id === "__bridge__.js") { - return new Response(MINIAPP_BRIDGE_JS, { - headers: { "content-type": "text/javascript; charset=utf-8" }, - }); - } - const absPath = resolveMiniAppAsset(id, segments.join("/")); - if (!absPath) return new Response("Forbidden", { status: 403 }); - // 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 }); - } - } - // Renderer SPA — existing logic let urlPath = url.pathname; if (urlPath === "/" || !path.extname(urlPath)) { @@ -428,9 +405,6 @@ 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) @@ -479,9 +453,9 @@ app.whenReady().then(async () => { // start chrome extension sync server initChromeSync(); - // start local sites server for iframe dashboards and other mini apps - initLocalSites().catch((error) => { - console.error('[LocalSites] Failed to start:', error); + // start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) + initAppsServer().catch((error) => { + console.error('[Apps] Failed to start:', error); }); app.on("activate", () => { @@ -510,8 +484,8 @@ app.on("before-quit", () => { } // Kill embedded terminal shells. disposeAllTerminals(); - shutdownLocalSites().catch((error) => { - console.error('[LocalSites] Failed to shut down cleanly:', error); + shutdownAppsServer().catch((error) => { + console.error('[Apps] Failed to shut down cleanly:', error); }); shutdownAnalytics().catch((error) => { console.error('[Analytics] Failed to flush on quit:', error); diff --git a/apps/x/apps/main/src/mini-apps-handler.ts b/apps/x/apps/main/src/mini-apps-handler.ts deleted file mode 100644 index 97a9b3c8..00000000 --- a/apps/x/apps/main/src/mini-apps-handler.ts +++ /dev/null @@ -1,270 +0,0 @@ -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'; - -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); -} - -/** - * Canonical Mini App bridge shim, served at app://miniapp/__bridge__.js. Apps - * include it with `` and code against - * `window.rowboat`. This is the single source of truth for the bridge protocol - * (kept in sync with the host in components/mini-app-frame.tsx + types.ts). - */ -export const MINIAPP_BRIDGE_JS = ` -(function () { - var data = null, dataLoaded = false, state = null, theme = 'dark'; - var dataCbs = [], stateCbs = [], themeCbs = []; - var pending = {}, seq = 0; - function post(msg) { parent.postMessage(msg, '*'); } - // Apply the host theme to so app CSS can use html.dark / html.light - // (and native controls via color-scheme). - function applyTheme(t) { - theme = t === 'light' ? 'light' : 'dark'; - var el = document.documentElement; - el.classList.remove('light', 'dark'); el.classList.add(theme); - el.setAttribute('data-theme', theme); - el.style.colorScheme = theme; - themeCbs.forEach(function (cb) { try { cb(theme); } catch (_) {} }); - } - function rpc(method, params) { - var id = 'r' + (++seq); - return new Promise(function (resolve, reject) { - pending[id] = { resolve: resolve, reject: reject }; - post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params }); - }); - } - // Apps are self-contained: data.json is a served sibling of index.html, loaded - // via a relative fetch (same origin, no CORS). Rowboat does not inject it. - function loadData() { - return fetch('data.json', { cache: 'no-store' }) - .then(function (r) { return r && r.ok ? r.json() : null; }) - .then(function (d) { data = d; dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} }); return data; }) - .catch(function () { dataLoaded = true; dataCbs.forEach(function (cb) { try { cb(null); } catch (_) {} }); return null; }); - } - window.addEventListener('message', function (e) { - var m = e.data; - if (!m || typeof m !== 'object') return; - if (m.type === 'rowboat:mini-app:state') { - state = m.state; - 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')); } - } - }); - window.rowboat = { - getData: function () { return data; }, - getTheme: function () { return theme; }, - onTheme: function (cb) { themeCbs.push(cb); try { cb(theme); } catch (_) {} return function () { var i = themeCbs.indexOf(cb); if (i >= 0) themeCbs.splice(i, 1); }; }, - onData: function (cb) { dataCbs.push(cb); if (dataLoaded) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); }; }, - refreshData: function () { return loadData(); }, - getState: function () { return state; }, - onState: function (cb) { stateCbs.push(cb); if (state !== null) { try { cb(state); } catch (_) {} } return function () { var i = stateCbs.indexOf(cb); if (i >= 0) stateCbs.splice(i, 1); }; }, - setState: function (patch) { state = Object.assign({}, state || {}, patch); post({ type: 'rowboat:mini-app:setState', patch: patch }); stateCbs.forEach(function (cb) { try { cb(state); } catch (_) {} }); }, - callAction: function (scope, tool, args) { return rpc('callAction', { scope: scope, tool: tool, args: args }); }, - searchTools: function (scope, query) { return rpc('searchTools', { scope: scope, query: query }); }, - isConnected: function (scope) { return rpc('isConnected', { scope: scope }); }, - connect: function (scope) { return rpc('connect', { scope: scope }); }, - // CORS-safe HTTP via the main process. Resolves to { ok, status, text, json } - // (json is the parsed body when it's valid JSON, else null). - fetch: function (url, opts) { - return rpc('fetch', { url: url, method: (opts && opts.method), headers: (opts && opts.headers), body: (opts && opts.body) }) - .then(function (r) { var j = null; try { j = JSON.parse(r.text); } catch (_) {} r.json = j; return r; }); - }, - ready: function () { post({ type: 'rowboat:mini-app:ready' }); }, - }; - loadData(); -})(); -`; - -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); - // 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)); - } - 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 }; -} - -/** - * Proxy an HTTP request through the main process so Mini Apps can call - * third-party APIs that don't send CORS headers (the sandboxed app:// origin - * can't fetch them directly). GET/POST over http(s) only. - */ -export async function proxyFetch(input: { - url: string; - method?: string; - headers?: Record; - body?: string; -}): Promise<{ ok: boolean; status: number; statusText: string; text: string; error?: string }> { - try { - const parsed = new URL(input.url); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return { ok: false, status: 0, statusText: '', text: '', error: 'Only http(s) URLs are allowed.' }; - } - const method = (input.method || 'GET').toUpperCase(); - const res = await fetch(input.url, { - method, - headers: input.headers, - body: method === 'GET' || method === 'HEAD' ? undefined : input.body, - }); - const text = await res.text(); - return { ok: res.ok, status: res.status, statusText: res.statusText, text }; - } catch (e) { - return { ok: false, status: 0, statusText: '', text: '', error: e instanceof Error ? e.message : String(e) }; - } -} - -/** 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 }; - } -} - -/** - * 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. - */ -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/App.tsx b/apps/x/apps/renderer/src/App.tsx index 30341ac7..5d657371 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -27,7 +27,7 @@ import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; -import { MiniAppsView } from '@/components/mini-apps-view'; +import { AppsView } from '@/components/apps/apps-view'; import { EmailView } from '@/components/email-view'; import { WorkspaceView } from '@/components/workspace-view'; import { CodingRunBlock } from '@/components/coding-run'; @@ -4424,6 +4424,19 @@ function App() { return window.ipc.on('app:openUrl', ({ url }) => handle(url)) }, []) + // Report the UI theme to the apps server (spec §7.1): apps read it from + // GET /_rowboat/app and get live changes via the SSE theme event. + useEffect(() => { + const report = () => { + const theme = document.documentElement.classList.contains('dark') ? 'dark' as const : 'light' as const + void window.ipc.invoke('apps:setTheme', { theme }).catch(() => { /* server may be down */ }) + } + report() + const observer = new MutationObserver(report) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + return () => observer.disconnect() + }, []) + // Triggered by main when the user clicks a calendar-meeting notification. // Reuses the same flow as the in-app "Join meeting & take notes" button. // When `openMeeting` is true, also opens the meeting URL in the system browser. @@ -6000,10 +6013,10 @@ function App() { ) : isAppsOpen ? (
- prefillChat('Build me a mini-app that ')} + onNewApp={() => prefillChat('Build me an app that ')} />
) : isEmailOpen ? ( diff --git a/apps/x/apps/renderer/src/components/apps/app-frame.tsx b/apps/x/apps/renderer/src/components/apps/app-frame.tsx new file mode 100644 index 00000000..5c28fb39 --- /dev/null +++ b/apps/x/apps/renderer/src/components/apps/app-frame.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react' +import { ArrowLeft, ExternalLink, RotateCw } from 'lucide-react' +import type { rowboatApp } from '@x/shared' + +// Full-height iframe on the app's own origin (spec §6.6). No sandbox attr — +// per-app browser origins are the isolation boundary. Toolbar: back, reload, +// open-in-browser. + +export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) { + const [reloadNonce, setReloadNonce] = useState(0) + const title = app.manifest?.name ?? app.folder + + return ( +
+
+ + {title} + + +
+
+