mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(mini-apps): serve apps from ~/.rowboat/apps via app://miniapp
Apps are now user data on disk, not bundled in the repo. - MiniAppManifest schema + mini-apps:list/get-data/seed IPC channels - main: mini-apps-handler (list/get-data + seed install primitive) and an app://miniapp/<id>/ protocol host serving ~/.rowboat/apps/<id>/dist - renderer lists installed apps from disk and loads each via app://miniapp (real origin: remote images/fetch work); data sourced from data.json - removed the bundled sample apps from source (they live in ~/.rowboat/apps)
This commit is contained in:
parent
6cbf40b165
commit
924a93fa4a
14 changed files with 289 additions and 693 deletions
|
|
@ -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/<id>/`, 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/<id>/
|
||||
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/<id>/...
|
||||
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/<id>/<path>` (option A)
|
||||
- Extend `registerAppProtocol` (`apps/main/src/main.ts`) with a new host
|
||||
`miniapp`: map `app://miniapp/<id>/<path>` → `~/.rowboat/apps/<id>/dist/<path>`
|
||||
(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/<id>/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/<id>/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/<id>/` 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/<id>/<entry>`, 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.
|
||||
|
|
|
|||
|
|
@ -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<IAgentScheduleRepo>('agentScheduleRepo');
|
||||
|
|
|
|||
|
|
@ -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/<id>/<rel-path> → ~/.rowboat/apps/<id>/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)) {
|
||||
|
|
|
|||
83
apps/x/apps/main/src/mini-apps-handler.ts
Normal file
83
apps/x/apps/main/src/mini-apps-handler.ts
Normal file
|
|
@ -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<typeof MiniAppManifest>;
|
||||
|
||||
// All Mini Apps live under ~/.rowboat/apps/<id>/.
|
||||
// manifest.json — MiniAppManifest
|
||||
// dist/ — static assets served via app://miniapp/<id>/
|
||||
// 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/<id>/<rel> 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;
|
||||
}
|
||||
|
|
@ -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/<id>/ (served from ~/.rowboat/apps/<id>/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/<id> 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<HTMLIFrameElement | null>(null)
|
||||
// In-memory per-app state for Phase 1/2 (resets when the frame unmounts).
|
||||
const stateRef = useRef<Record<string, unknown>>({})
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>
|
||||
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 (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={app.name}
|
||||
srcDoc={app.html}
|
||||
title={manifest.title}
|
||||
src={`app://miniapp/${manifest.id}/index.html`}
|
||||
className="h-full w-full border-0 bg-neutral-950"
|
||||
sandbox={SANDBOX}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ArrowLeft, Plus } from 'lucide-react'
|
||||
import { MINI_APPS, getMiniApp } from '@/mini-apps/registry'
|
||||
import type { MiniApp } from '@/mini-apps/types'
|
||||
import { miniApp } from '@x/shared'
|
||||
import { MiniAppFrame } from '@/components/mini-app-frame'
|
||||
|
||||
// The "Mini Apps" section: a gallery of premium product tiles; click one to open
|
||||
|
|
@ -129,7 +128,7 @@ const CARD_CSS = `
|
|||
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); }
|
||||
`
|
||||
|
||||
function Card({ app, index, onOpen }: { app: MiniApp; index: number; onOpen: () => void }) {
|
||||
function Card({ app, index, onOpen }: { app: miniApp.MiniAppManifest; index: number; onOpen: () => void }) {
|
||||
const theme = themeForIndex(index)
|
||||
const pattern = patternFor(app.id)
|
||||
return (
|
||||
|
|
@ -144,7 +143,7 @@ function Card({ app, index, onOpen }: { app: MiniApp; index: number; onOpen: ()
|
|||
{app.active ? 'ACTIVE' : 'PAUSED'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ma-title">{app.name}</div>
|
||||
<div className="ma-title">{app.title}</div>
|
||||
<div className="ma-desc">{app.description}</div>
|
||||
<div className="ma-footer">
|
||||
<span className="ma-source">{app.source}</span>
|
||||
|
|
@ -156,7 +155,25 @@ function Card({ app, index, onOpen }: { app: MiniApp; index: number; onOpen: ()
|
|||
|
||||
export function MiniAppsView() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const selected = selectedId ? getMiniApp(selectedId) : undefined
|
||||
const [manifests, setManifests] = useState<miniApp.MiniAppManifest[]>([])
|
||||
|
||||
// List apps installed under ~/.rowboat/apps. Apps are created there by the
|
||||
// copilot builder (or placed manually); none are bundled in the repo.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(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)))
|
||||
} catch {
|
||||
if (!cancelled) setManifests([])
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const selected = selectedId ? manifests.find((m) => m.id === selectedId) : undefined
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
|
|
@ -170,10 +187,10 @@ export function MiniAppsView() {
|
|||
<ArrowLeft className="size-4" />
|
||||
Apps
|
||||
</button>
|
||||
<span className="text-sm font-medium">{selected.name}</span>
|
||||
<span className="text-sm font-medium">{selected.title}</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<MiniAppFrame app={selected} />
|
||||
<MiniAppFrame manifest={selected} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -187,8 +204,8 @@ export function MiniAppsView() {
|
|||
<p className="ma-sub">Little apps that live inside Rowboat, powered by your agents and integrations.</p>
|
||||
|
||||
<div className="ma-grid">
|
||||
{MINI_APPS.map((app, i) => (
|
||||
<Card key={app.id} app={app} index={i} onOpen={() => setSelectedId(app.id)} />
|
||||
{manifests.map((m, i) => (
|
||||
<Card key={m.id} app={m} index={i} onOpen={() => setSelectedId(m.id)} />
|
||||
))}
|
||||
|
||||
{/* Placeholder for copilot-generated apps (Phase 3). */}
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
// Two simple digest-style sample Mini Apps (Newsletter Digest, Competitor Watch).
|
||||
// They share the list template in ./simple-list and exist mainly so the gallery
|
||||
// shows the card design across several apps with different accent themes.
|
||||
|
||||
import type { MiniApp } from '../types'
|
||||
import { buildSimpleListHtml } from './simple-list'
|
||||
|
||||
export const newsletterDigestApp: MiniApp = {
|
||||
id: 'newsletter-digest',
|
||||
name: 'Newsletter Digest',
|
||||
description: 'Summarises your subscriptions into a single skimmable morning read.',
|
||||
source: 'Email',
|
||||
active: true,
|
||||
lastRun: '1h ago',
|
||||
scope: ['gmail'],
|
||||
data: {
|
||||
title: 'Newsletter Digest',
|
||||
subtitle: 'Your subscriptions, summarised — 6 this morning',
|
||||
items: [
|
||||
{ id: 'n1', title: 'Stratechery', meta: '7 min', body: 'The platform shift to agents mirrors the mobile transition — distribution, not models, decides the winners.' },
|
||||
{ id: 'n2', title: 'Lenny’s Newsletter', meta: '5 min', body: 'How three PMs run discovery without a researcher: weekly user calls, a shared notes doc, and ruthless prioritisation.' },
|
||||
{ id: 'n3', title: 'The Pragmatic Engineer', meta: '9 min', body: 'Inside a staff-level promo packet: scope, impact, and the "without you it wouldn’t have happened" test.' },
|
||||
],
|
||||
},
|
||||
html: buildSimpleListHtml('Newsletter Digest'),
|
||||
}
|
||||
|
||||
export const competitorWatchApp: MiniApp = {
|
||||
id: 'competitor-watch',
|
||||
name: 'Competitor Watch',
|
||||
description: 'Tracks launches, pricing changes, and notable posts from your rivals.',
|
||||
source: 'Web',
|
||||
active: false,
|
||||
lastRun: '4h ago',
|
||||
scope: ['web'],
|
||||
data: {
|
||||
title: 'Competitor Watch',
|
||||
subtitle: '3 updates since yesterday',
|
||||
items: [
|
||||
{ id: 'c1', title: 'Acme launched AI Mode', meta: 'pricing', body: 'New $40/mo tier bundles their assistant; older Pro plan unchanged. Positioning leans on “team workspaces”.' },
|
||||
{ id: 'c2', title: 'Globex changelog', meta: 'product', body: 'Shipped offline sync and a public API. Docs hint at a desktop app in private beta.' },
|
||||
{ id: 'c3', title: 'Initech blog', meta: 'content', body: 'A “build vs buy” post aimed squarely at your ICP — worth a counter-piece on local-first privacy.' },
|
||||
],
|
||||
},
|
||||
html: buildSimpleListHtml('Competitor Watch'),
|
||||
}
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
// Sample Mini App: GitHub Dashboard.
|
||||
//
|
||||
// A custom dashboard: pick repos to track, then view their open pull requests
|
||||
// (title, author, description) — fetched LIVE from GitHub through the Phase 2
|
||||
// bridge (searchTools -> callAction against a Composio managed-OAuth2 toolkit).
|
||||
// Tracked repos persist via the per-app state store.
|
||||
|
||||
import { buildMiniAppHtml } from '../runtime'
|
||||
import type { MiniApp } from '../types'
|
||||
|
||||
// Seed repos shown on first open (the user can add/remove their own).
|
||||
const data = {
|
||||
seedRepos: [
|
||||
{ owner: 'vercel', repo: 'next.js' },
|
||||
{ owner: 'rowboatlabs', repo: 'rowboat' },
|
||||
],
|
||||
}
|
||||
|
||||
const style = `
|
||||
.app { height:100%; overflow:auto; background:#0d1117; color:#e6edf3; }
|
||||
.wrap { max-width:760px; margin:0 auto; padding:24px 16px 64px; }
|
||||
.h { font-size:22px; font-weight:600; letter-spacing:-0.02em; margin:0 0 4px; color:#f0f6fc; }
|
||||
.sub { font-size:13px; color:#8b949e; margin:0 0 18px; }
|
||||
.banner { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:0 0 16px; padding:12px 14px; border:1px solid #1f6feb; background:rgba(31,111,235,0.12); border-radius:10px; }
|
||||
.banner.ok { border-color:#238636; background:rgba(35,134,54,0.12); }
|
||||
.banner-text { font-size:13px; color:#e6edf3; }
|
||||
.banner-btn { background:#238636; color:#fff; border:0; border-radius:8px; padding:7px 14px; font-size:13px; font-weight:600; cursor:pointer; }
|
||||
.banner-btn:disabled { opacity:.6; cursor:default; }
|
||||
.profile { display:flex; gap:14px; align-items:center; margin-bottom:16px; }
|
||||
.avatar { width:64px; height:64px; border-radius:50%; border:1px solid #30363d; flex:0 0 auto; object-fit:cover; }
|
||||
.avatar-fb { width:64px; height:64px; border-radius:50%; background:#21262d; display:none; align-items:center; justify-content:center; font-size:24px; font-weight:600; color:#8b949e; flex:0 0 auto; }
|
||||
.pname { font-size:17px; font-weight:600; color:#f0f6fc; }
|
||||
.plogin { font-size:13px; color:#8b949e; }
|
||||
.pbio { font-size:13px; color:#adbac7; margin-top:4px; }
|
||||
.pstats { display:flex; gap:16px; margin-top:8px; font-size:12px; color:#8b949e; }
|
||||
.pstats b { color:#e6edf3; }
|
||||
.contrib { border:1px solid #30363d; background:#161b22; border-radius:10px; padding:14px; margin-bottom:18px; }
|
||||
.contrib-title { font-size:13px; font-weight:600; color:#f0f6fc; margin-bottom:10px; }
|
||||
.contrib-img { width:100%; height:auto; display:block; border-radius:6px; }
|
||||
.contrib-note { font-size:12px; color:#6e7681; }
|
||||
.adder { display:flex; gap:8px; margin-bottom:18px; }
|
||||
.adder input { flex:1; background:#0d1117; border:1px solid #30363d; color:#e6edf3; border-radius:8px; padding:8px 11px; font-size:13px; outline:none; }
|
||||
.adder input:focus { border-color:#1f6feb; }
|
||||
.adder button { background:#21262d; border:1px solid #30363d; color:#e6edf3; border-radius:8px; padding:0 14px; font-size:13px; font-weight:600; cursor:pointer; }
|
||||
.adder button:hover { background:#30363d; }
|
||||
.repo { border:1px solid #30363d; background:#161b22; border-radius:10px; margin-bottom:14px; overflow:hidden; }
|
||||
.repo-head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 14px; border-bottom:1px solid #21262d; }
|
||||
.repo-name { font-size:14px; font-weight:600; color:#2f81f7; }
|
||||
.repo-actions { display:flex; gap:8px; align-items:center; }
|
||||
.repo-count { font-size:12px; color:#8b949e; }
|
||||
.icon-btn { background:none; border:0; color:#8b949e; cursor:pointer; font-size:13px; padding:2px 6px; border-radius:6px; }
|
||||
.icon-btn:hover { background:#21262d; color:#e6edf3; }
|
||||
.pr { border-bottom:1px solid #21262d; }
|
||||
.pr:last-child { border-bottom:0; }
|
||||
.pr-row { display:flex; gap:10px; align-items:flex-start; padding:10px 14px; cursor:pointer; }
|
||||
.pr-row:hover { background:#1c2128; }
|
||||
.pr-icon { color:#3fb950; font-size:13px; line-height:1.5; flex:0 0 auto; }
|
||||
.pr-main { min-width:0; flex:1; }
|
||||
.pr-title { font-size:13px; color:#e6edf3; }
|
||||
.pr-sub { font-size:12px; color:#8b949e; margin-top:2px; }
|
||||
.pr-body { padding:0 14px 12px 38px; font-size:13px; line-height:1.5; color:#adbac7; white-space:pre-wrap; word-wrap:break-word; }
|
||||
.pr-body.empty { color:#6e7681; font-style:italic; }
|
||||
.state { padding:14px; font-size:13px; color:#8b949e; }
|
||||
.state.err { color:#f85149; }
|
||||
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#238636; color:#fff; font-size:14px; font-weight:600; padding:10px 18px; border-radius:8px; opacity:0; transition:opacity .2s; pointer-events:none; }
|
||||
.toast.show { opacity:1; }
|
||||
.toast.err { background:#da3633; }
|
||||
.empty-state { padding:32px 14px; text-align:center; color:#6e7681; font-size:13px; }
|
||||
.loading { padding:48px 16px; text-align:center; color:#8b949e; }
|
||||
`
|
||||
|
||||
const script = `
|
||||
var root = document.getElementById('root');
|
||||
var seed = [];
|
||||
var repos = null; // [{owner,repo}], persisted
|
||||
var expanded = {}; // pr key -> bool
|
||||
var prCache = {}; // 'owner/repo' -> { loading, error, prs }
|
||||
var profile = null; // authenticated user profile
|
||||
var profileState = null; // { loading, error }
|
||||
var repoInput = '';
|
||||
var connected = null;
|
||||
var connecting = false;
|
||||
var toastTimer = null;
|
||||
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function key(o,r){ return o + '/' + r; }
|
||||
function persist(){ window.rowboat.setState({ repos: repos }); }
|
||||
|
||||
// ---- live GitHub reads via the bridge -------------------------------------
|
||||
function asArray(d){
|
||||
if (Array.isArray(d)) return d;
|
||||
if (d && typeof d === 'object') {
|
||||
var keys = ['details','data','items','pull_requests','pullRequests','response_data','result'];
|
||||
for (var i=0;i<keys.length;i++){ if (Array.isArray(d[keys[i]])) return d[keys[i]]; }
|
||||
for (var k in d){ if (Array.isArray(d[k]) && d[k].length && typeof d[k][0] === 'object') return d[k]; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
function normPRs(d){
|
||||
return asArray(d).map(function(p){
|
||||
return {
|
||||
number: (p.number != null ? p.number : (p.id != null ? p.id : '?')),
|
||||
title: p.title || '(untitled)',
|
||||
body: (p.body || '').trim(),
|
||||
author: (p.user && (p.user.login || p.user.name)) || p.author || '',
|
||||
url: p.html_url || p.url || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
// Pick the *list pull requests* tool, excluding review/comment/file/commit
|
||||
// variants that happen to contain PULL + LIST but need pull_number/review_id.
|
||||
function pickPRTool(tools){
|
||||
var bad = ['REVIEW','COMMENT','FILE','COMMIT','REQUESTED'];
|
||||
function ok(s){ for (var i=0;i<bad.length;i++) if (s.indexOf(bad[i])>=0) return false; return true; }
|
||||
for (var i=0;i<tools.length;i++){ if ((tools[i].slug||'').toUpperCase() === 'GITHUB_LIST_PULL_REQUESTS') return tools[i]; }
|
||||
for (var j=0;j<tools.length;j++){ var s=(tools[j].slug||'').toUpperCase(); if (/LIST_PULL_REQUESTS$/.test(s) && ok(s)) return tools[j]; }
|
||||
for (var m=0;m<tools.length;m++){ var t=(tools[m].slug||'').toUpperCase(); if (t.indexOf('PULL_REQUEST')>=0 && t.indexOf('LIST')>=0 && ok(t)) return tools[m]; }
|
||||
return null;
|
||||
}
|
||||
function fetchPRs(o, r){
|
||||
var k = key(o,r);
|
||||
prCache[k] = { loading:true }; render();
|
||||
var args = { owner:o, repo:r, state:'open', sort:'updated', direction:'desc', per_page:20 };
|
||||
// Canonical slug first; fall back to a strict search only if it's unknown.
|
||||
window.rowboat.callAction('github', 'GITHUB_LIST_PULL_REQUESTS', args).catch(function(){
|
||||
return window.rowboat.searchTools('github','list pull requests for a repository').then(function(tools){
|
||||
var tool = pickPRTool(tools || []);
|
||||
if (!tool) throw new Error('could not find a list-pull-requests tool');
|
||||
return window.rowboat.callAction('github', tool.slug, args);
|
||||
});
|
||||
}).then(function(d){
|
||||
prCache[k] = { loading:false, prs: normPRs(d) }; render();
|
||||
}).catch(function(e){
|
||||
prCache[k] = { loading:false, error: (e && e.message ? e.message : String(e)) }; render();
|
||||
});
|
||||
}
|
||||
function fetchAll(){ if (!connected) return; loadProfile(); if (!repos) return; repos.forEach(function(rp){ var k=key(rp.owner,rp.repo); if (!prCache[k]) fetchPRs(rp.owner, rp.repo); }); }
|
||||
|
||||
// ---- authenticated user profile -------------------------------------------
|
||||
function asObj(d){
|
||||
if (d && typeof d === 'object'){
|
||||
if (d.login) return d;
|
||||
var ks = ['data','details','response_data','result','user'];
|
||||
for (var i=0;i<ks.length;i++){ var v=d[ks[i]]; if (v && typeof v === 'object' && v.login) return v; }
|
||||
}
|
||||
return d || {};
|
||||
}
|
||||
function loadProfile(){
|
||||
if (!connected || profile || (profileState && profileState.loading)) return;
|
||||
profileState = { loading:true }; render();
|
||||
window.rowboat.callAction('github','GITHUB_GET_THE_AUTHENTICATED_USER',{}).catch(function(){
|
||||
return window.rowboat.searchTools('github','get the authenticated user').then(function(tools){
|
||||
var pick = null;
|
||||
for (var i=0;i<(tools||[]).length;i++){ if (/AUTHENTICATED_USER$/.test((tools[i].slug||'').toUpperCase())){ pick = tools[i]; break; } }
|
||||
if (!pick && tools && tools.length) pick = tools[0];
|
||||
if (!pick) throw new Error('no profile tool found');
|
||||
return window.rowboat.callAction('github', pick.slug, {});
|
||||
});
|
||||
}).then(function(d){
|
||||
var u = asObj(d);
|
||||
profile = { login:u.login||'', name:u.name||u.login||'', avatar:u.avatar_url||'', bio:u.bio||'', repos:u.public_repos||0, followers:u.followers||0, following:u.following||0 };
|
||||
profileState = { loading:false }; render();
|
||||
}).catch(function(e){
|
||||
profileState = { loading:false, error:(e && e.message ? e.message : String(e)) }; render();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- connection -----------------------------------------------------------
|
||||
function bannerHtml(){
|
||||
if (connecting) return '<div class="banner"><span class="banner-text">Connecting GitHub — finish in your browser…</span><button class="banner-btn" disabled>Connecting…</button></div>';
|
||||
if (connected === false) return '<div class="banner"><span class="banner-text">Connect GitHub to load pull requests.</span><button class="banner-btn" data-connect="1">Connect GitHub</button></div>';
|
||||
if (connected === true) return '<div class="banner ok"><span class="banner-text">GitHub connected — pull requests are live.</span></div>';
|
||||
return '';
|
||||
}
|
||||
function checkConn(){ window.rowboat.isConnected('github').then(function(c){ connected = c; render(); fetchAll(); }); }
|
||||
function connect(){
|
||||
connecting = true; render();
|
||||
window.rowboat.connect('github').then(function(){
|
||||
var tries = 0;
|
||||
var iv = setInterval(function(){
|
||||
tries++;
|
||||
window.rowboat.isConnected('github').then(function(c){
|
||||
if (c){ connected = true; connecting = false; clearInterval(iv); render(); flash('GitHub connected'); fetchAll(); }
|
||||
else if (tries > 30){ connecting = false; clearInterval(iv); render(); }
|
||||
});
|
||||
}, 2000);
|
||||
}).catch(function(e){ connecting = false; render(); flash('Connect failed: ' + (e && e.message ? e.message : e), true); });
|
||||
}
|
||||
|
||||
// ---- repo management ------------------------------------------------------
|
||||
function addRepo(){
|
||||
var v = (repoInput || '').trim().replace(/^https?:\\/\\/github.com\\//i, '');
|
||||
var m = v.match(/^([\\w.-]+)\\/([\\w.-]+)$/);
|
||||
if (!m){ flash('Enter as owner/repo', true); return; }
|
||||
var o = m[1], r = m[2];
|
||||
if (repos.some(function(x){ return x.owner === o && x.repo === r; })){ flash('Already tracked'); return; }
|
||||
repos.push({ owner:o, repo:r }); repoInput = ''; persist(); render();
|
||||
if (connected) fetchPRs(o, r);
|
||||
}
|
||||
function removeRepo(o, r){
|
||||
repos = repos.filter(function(x){ return !(x.owner === o && x.repo === r); });
|
||||
delete prCache[key(o,r)]; persist(); render();
|
||||
}
|
||||
|
||||
// ---- render ---------------------------------------------------------------
|
||||
function prHtml(o, r, pr){
|
||||
var k = key(o,r) + '#' + pr.number;
|
||||
var open = !!expanded[k];
|
||||
var body = open
|
||||
? (pr.body ? '<div class="pr-body">' + esc(pr.body) + '</div>' : '<div class="pr-body empty">No description.</div>')
|
||||
: '';
|
||||
return '<div class="pr"><div class="pr-row" data-pr="' + esc(k) + '">'
|
||||
+ '<span class="pr-icon">⌥</span><div class="pr-main">'
|
||||
+ '<div class="pr-title">' + esc(pr.title) + '</div>'
|
||||
+ '<div class="pr-sub">#' + esc(pr.number) + (pr.author ? ' · ' + esc(pr.author) : '') + '</div>'
|
||||
+ '</div></div>' + body + '</div>';
|
||||
}
|
||||
function repoHtml(rp){
|
||||
var k = key(rp.owner, rp.repo);
|
||||
var c = prCache[k];
|
||||
var inner;
|
||||
if (!connected) inner = '<div class="state">Connect GitHub to load PRs.</div>';
|
||||
else if (!c || c.loading) inner = '<div class="state">Loading pull requests…</div>';
|
||||
else if (c.error) inner = '<div class="state err">' + esc(c.error) + '</div>';
|
||||
else if (!c.prs.length) inner = '<div class="empty-state">No open pull requests 🎉</div>';
|
||||
else inner = c.prs.map(function(pr){ return prHtml(rp.owner, rp.repo, pr); }).join('');
|
||||
var count = (c && c.prs) ? '<span class="repo-count">' + c.prs.length + ' open</span>' : '';
|
||||
return '<div class="repo"><div class="repo-head">'
|
||||
+ '<span class="repo-name">' + esc(rp.owner) + '/' + esc(rp.repo) + '</span>'
|
||||
+ '<span class="repo-actions">' + count
|
||||
+ '<button class="icon-btn" data-refresh="' + esc(k) + '">↻</button>'
|
||||
+ '<button class="icon-btn" data-remove="' + esc(k) + '">✕</button>'
|
||||
+ '</span></div>' + inner + '</div>';
|
||||
}
|
||||
function profileHtml(){
|
||||
if (!connected) return '';
|
||||
if (!profile) return (profileState && profileState.loading) ? '<div class="state">Loading profile…</div>' : '';
|
||||
var initials = (profile.name || profile.login || '?').slice(0,1).toUpperCase();
|
||||
var img = profile.avatar ? '<img class="avatar" src="' + esc(profile.avatar) + '" alt="" />' : '';
|
||||
var fbStyle = profile.avatar ? '' : ' style="display:flex"';
|
||||
return '<div class="profile">' + img
|
||||
+ '<div id="avfb" class="avatar-fb"' + fbStyle + '>' + esc(initials) + '</div>'
|
||||
+ '<div><div class="pname">' + esc(profile.name) + '</div><div class="plogin">@' + esc(profile.login) + '</div>'
|
||||
+ (profile.bio ? '<div class="pbio">' + esc(profile.bio) + '</div>' : '')
|
||||
+ '<div class="pstats"><span><b>' + profile.repos + '</b> repos</span><span><b>' + profile.followers + '</b> followers</span><span><b>' + profile.following + '</b> following</span></div>'
|
||||
+ '</div></div>'
|
||||
+ '<div class="contrib"><div class="contrib-title">Contributions</div>'
|
||||
+ '<img class="contrib-img" src="https://ghchart.rshah.org/' + encodeURIComponent(profile.login) + '" alt="contributions" />'
|
||||
+ '<div id="contribnote" class="contrib-note" style="display:none">Contribution graph could not load.</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
function render(){
|
||||
var list = repos
|
||||
? (repos.length ? repos.map(repoHtml).join('') : '<div class="empty-state">No repos yet — add one above.</div>')
|
||||
: '<div class="loading">Loading…</div>';
|
||||
root.innerHTML = '<div class="app"><div class="wrap">'
|
||||
+ '<h1 class="h">GitHub Dashboard</h1><p class="sub">Track repos and read their open pull requests.</p>'
|
||||
+ bannerHtml()
|
||||
+ profileHtml()
|
||||
+ '<div class="adder"><input id="repo-input" placeholder="owner/repo (e.g. vercel/next.js)" /><button data-add="1">Add</button></div>'
|
||||
+ list
|
||||
+ '</div><div class="toast" id="toast"></div></div>';
|
||||
var el = document.getElementById('repo-input');
|
||||
if (el) el.value = repoInput;
|
||||
var av = root.querySelector('.avatar');
|
||||
if (av) av.onerror = function(){ this.style.display='none'; var f=document.getElementById('avfb'); if (f) f.style.display='flex'; };
|
||||
var ci = root.querySelector('.contrib-img');
|
||||
if (ci) ci.onerror = function(){ this.style.display='none'; var n=document.getElementById('contribnote'); if (n) n.style.display='block'; };
|
||||
}
|
||||
|
||||
function flash(msg, isErr){
|
||||
var el = document.getElementById('toast'); if (!el) return;
|
||||
el.textContent = msg; el.className = 'toast show' + (isErr ? ' err' : '');
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function(){ el.className = 'toast' + (isErr ? ' err' : ''); }, 2400);
|
||||
}
|
||||
|
||||
// ---- events ---------------------------------------------------------------
|
||||
root.addEventListener('input', function(e){ if (e.target && e.target.id === 'repo-input') repoInput = e.target.value; });
|
||||
root.addEventListener('keydown', function(e){ if (e.target && e.target.id === 'repo-input' && e.key === 'Enter') addRepo(); });
|
||||
root.addEventListener('click', function(e){
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) return;
|
||||
if (t.closest('[data-connect]')) { connect(); return; }
|
||||
if (t.closest('[data-add]')) { addRepo(); return; }
|
||||
var rm = t.closest('[data-remove]');
|
||||
if (rm) { var p = rm.getAttribute('data-remove').split('/'); removeRepo(p[0], p[1]); return; }
|
||||
var rf = t.closest('[data-refresh]');
|
||||
if (rf) { var q = rf.getAttribute('data-refresh').split('/'); fetchPRs(q[0], q[1]); return; }
|
||||
var pr = t.closest('[data-pr]');
|
||||
if (pr) { var pk = pr.getAttribute('data-pr'); expanded[pk] = !expanded[pk]; render(); return; }
|
||||
});
|
||||
|
||||
window.rowboat.onData(function(d){ seed = (d && d.seedRepos) || []; if (repos === null) { repos = seed.slice(); render(); } });
|
||||
window.rowboat.onState(function(st){
|
||||
if (st && st.repos) { repos = st.repos; render(); fetchAll(); }
|
||||
else if (repos === null) { repos = seed.slice(); render(); }
|
||||
});
|
||||
render();
|
||||
window.rowboat.ready();
|
||||
checkConn();
|
||||
`
|
||||
|
||||
export const githubRadarApp: MiniApp = {
|
||||
id: 'github-radar',
|
||||
name: 'GitHub Dashboard',
|
||||
description: 'Track repos and read their open pull requests — live via GitHub.',
|
||||
source: 'GitHub',
|
||||
active: true,
|
||||
lastRun: 'just now',
|
||||
scope: ['github'],
|
||||
data,
|
||||
html: buildMiniAppHtml({ title: 'GitHub Dashboard', style, script }),
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// Lightweight Mini App template: a clean single-column list of items.
|
||||
//
|
||||
// Used for simple "digest"-style apps (newsletter summaries, competitor updates)
|
||||
// so the gallery shows the card design across multiple apps. Same dependency-free
|
||||
// vanilla approach as the Twitter client.
|
||||
|
||||
import { buildMiniAppHtml } from '../runtime'
|
||||
|
||||
export type ListItem = { id: string; title: string; meta: string; body: string }
|
||||
|
||||
const style = `
|
||||
.app { height:100%; overflow:auto; background:#0a0a0b; color:#e7e9ea; }
|
||||
.wrap { max-width:640px; margin:0 auto; padding:24px 16px; }
|
||||
.h { font-size:22px; font-weight:600; letter-spacing:-0.02em; margin:0 0 4px; color:#f5f5f5; }
|
||||
.sub { font-size:13px; color:#71767b; margin:0 0 20px; }
|
||||
.item { border:1px solid rgba(255,255,255,0.07); background:#141417; border-radius:14px; padding:16px; margin-bottom:12px; }
|
||||
.it-top { display:flex; justify-content:space-between; align-items:baseline; gap:12px; }
|
||||
.it-title { font-size:15px; font-weight:600; }
|
||||
.it-meta { font-size:12px; color:#71767b; flex:0 0 auto; }
|
||||
.it-body { font-size:14px; line-height:1.5; color:rgba(255,255,255,0.7); margin:6px 0 0; }
|
||||
.loading { padding:48px 16px; text-align:center; color:#71767b; }
|
||||
`
|
||||
|
||||
const script = `
|
||||
var root = document.getElementById('root');
|
||||
var current = null;
|
||||
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function render(){
|
||||
if(!current){ root.innerHTML = '<div class="app"><div class="loading">Loading…</div></div>'; return; }
|
||||
var items = (current.items||[]).map(function(it){
|
||||
return '<div class="item"><div class="it-top"><div class="it-title">'+esc(it.title)+'</div><div class="it-meta">'+esc(it.meta)+'</div></div>'
|
||||
+ '<p class="it-body">'+esc(it.body)+'</p></div>';
|
||||
}).join('');
|
||||
root.innerHTML = '<div class="app"><div class="wrap"><h1 class="h">'+esc(current.title)+'</h1><p class="sub">'+esc(current.subtitle)+'</p>'+items+'</div></div>';
|
||||
}
|
||||
window.rowboat.onData(function(d){ current = d; render(); });
|
||||
render();
|
||||
window.rowboat.ready();
|
||||
`
|
||||
|
||||
export function buildSimpleListHtml(title: string): string {
|
||||
return buildMiniAppHtml({ title, style, script })
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
// Sample Mini App: a Twitter/X client.
|
||||
//
|
||||
// An X-style single-column timeline of important posts the agent has curated,
|
||||
// with simple topic chips to filter what you see. Phase 1 ships static `data`
|
||||
// and stubbed actions; later the bg-tasks engine produces the feed on a trigger
|
||||
// and the bridge runs real Composio actions.
|
||||
//
|
||||
// Dependency-free vanilla JS + CSS so it renders reliably in the sandboxed
|
||||
// iframe with no network. window.rowboat is the only channel to the host;
|
||||
// selected topics persist via rowboat.setState.
|
||||
|
||||
import { buildMiniAppHtml } from '../runtime'
|
||||
import type { MiniApp } from '../types'
|
||||
|
||||
const data = {
|
||||
handle: '@you',
|
||||
topics: ['AI', 'Startups', 'Dev', 'Design'],
|
||||
posts: [
|
||||
{
|
||||
id: 't1',
|
||||
author: 'Andrej Karpathy',
|
||||
handle: '@karpathy',
|
||||
avatar: '🧠',
|
||||
time: '2h',
|
||||
topics: ['AI', 'Dev'],
|
||||
text: 'The hottest new programming language is English. Spend your time crafting the prompt, not the syntax.',
|
||||
likes: 1240,
|
||||
reposts: 312,
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
author: 'Vercel',
|
||||
handle: '@vercel',
|
||||
avatar: '▲',
|
||||
time: '5h',
|
||||
topics: ['Dev'],
|
||||
text: 'Shipping is a feature. We just cut cold starts by another 40% on the edge runtime.',
|
||||
likes: 842,
|
||||
reposts: 96,
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
author: 'Indie Hackers',
|
||||
handle: '@IndieHackers',
|
||||
avatar: '🚀',
|
||||
time: '1h',
|
||||
topics: ['Startups'],
|
||||
text: 'You do not need 1,000 true fans. You need 100 who will tell 10 friends each. Build for the tellers.',
|
||||
likes: 503,
|
||||
reposts: 121,
|
||||
},
|
||||
{
|
||||
id: 't4',
|
||||
author: 'Sarah Dev',
|
||||
handle: '@sarah_builds',
|
||||
avatar: '👩💻',
|
||||
time: '32m',
|
||||
topics: ['AI', 'Startups'],
|
||||
text: 'Anyone using local-first AI desktop apps day to day? Curious what actually sticks vs. demo-ware.',
|
||||
likes: 88,
|
||||
reposts: 7,
|
||||
},
|
||||
{
|
||||
id: 't5',
|
||||
author: 'Design Notes',
|
||||
handle: '@designnotes',
|
||||
avatar: '🎨',
|
||||
time: '3h',
|
||||
topics: ['Design'],
|
||||
text: 'Good defaults beat good settings. Every preference you add is a decision you forced on the user.',
|
||||
likes: 967,
|
||||
reposts: 204,
|
||||
},
|
||||
{
|
||||
id: 't6',
|
||||
author: 'Founder Diary',
|
||||
handle: '@founderdiary',
|
||||
avatar: '📈',
|
||||
time: '6h',
|
||||
topics: ['Startups', 'Design'],
|
||||
text: 'Talked to 12 users this week. Shipped 0 features. Best week in a month.',
|
||||
likes: 1502,
|
||||
reposts: 388,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const style = `
|
||||
.app { height:100%; overflow:auto; background:#000; color:#e7e9ea; }
|
||||
.feed { max-width:600px; margin:0 auto; border-left:1px solid #2f3336; border-right:1px solid #2f3336; min-height:100%; }
|
||||
.header { position:sticky; top:0; backdrop-filter:blur(12px); background:rgba(0,0,0,.65); border-bottom:1px solid #2f3336; padding:12px 16px; z-index:2; }
|
||||
.h-title { font-size:20px; font-weight:800; margin:0 0 10px; }
|
||||
.chips { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.chip { border:1px solid #536471; background:transparent; color:#e7e9ea; border-radius:999px; padding:5px 14px; font-size:13px; font-weight:600; cursor:pointer; }
|
||||
.chip.on { background:#1d9bf0; border-color:#1d9bf0; color:#fff; }
|
||||
.post { display:flex; gap:12px; padding:12px 16px; border-bottom:1px solid #2f3336; }
|
||||
.post:hover { background:#080808; }
|
||||
.avatar { width:40px; height:40px; border-radius:50%; background:#16181c; display:flex; align-items:center; justify-content:center; font-size:18px; flex:0 0 auto; }
|
||||
.pbody { flex:1; min-width:0; }
|
||||
.line { display:flex; align-items:center; gap:4px; font-size:15px; }
|
||||
.name { font-weight:700; }
|
||||
.handle, .dot, .time { color:#71767b; font-weight:400; }
|
||||
.text { font-size:15px; line-height:1.4; margin:2px 0 0; white-space:pre-wrap; word-wrap:break-word; }
|
||||
.tags { margin-top:6px; display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.tag { font-size:12px; color:#1d9bf0; }
|
||||
.actions { display:flex; justify-content:space-between; max-width:300px; margin-top:8px; }
|
||||
.action { display:flex; align-items:center; gap:6px; background:none; border:0; color:#71767b; font-size:13px; cursor:pointer; padding:4px; border-radius:999px; }
|
||||
.action:hover { color:#1d9bf0; }
|
||||
.action.like.on { color:#f91880; }
|
||||
.action.repost.on { color:#00ba7c; }
|
||||
.empty { padding:48px 16px; text-align:center; color:#71767b; font-size:15px; }
|
||||
.banner { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:14px 16px 0; padding:12px 14px; border:1px solid #1d9bf0; background:rgba(29,155,240,0.12); border-radius:12px; }
|
||||
.banner-text { font-size:13px; color:#e7e9ea; }
|
||||
.banner-btn { background:#1d9bf0; color:#fff; border:0; border-radius:999px; padding:7px 16px; font-size:13px; font-weight:700; cursor:pointer; }
|
||||
.banner-btn:disabled { opacity:.6; cursor:default; }
|
||||
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); background:#1d9bf0; color:#fff; font-size:14px; font-weight:600; padding:10px 18px; border-radius:999px; opacity:0; transition:opacity .2s; pointer-events:none; }
|
||||
.toast.show { opacity:1; }
|
||||
.loading { padding:48px 16px; text-align:center; color:#71767b; font-size:15px; }
|
||||
`
|
||||
|
||||
// Vanilla JS. No backticks (so it embeds cleanly); window.rowboat is the bridge.
|
||||
const script = `
|
||||
var root = document.getElementById('root');
|
||||
var current = null;
|
||||
var selected = null; // selected topic names
|
||||
var liked = {}; // id -> bool
|
||||
var reposted = {}; // id -> bool
|
||||
var toastTimer = null;
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
function fmt(n) {
|
||||
return n >= 1000 ? (n / 1000).toFixed(1).replace('.0', '') + 'K' : String(n);
|
||||
}
|
||||
function persist() {
|
||||
window.rowboat.setState({ topics: selected, liked: liked, reposted: reposted });
|
||||
}
|
||||
|
||||
// X/Twitter has no Composio managed-OAuth2 flow, so this sample stays a local UI
|
||||
// demo (see GitHub Radar for the real connect -> execute bridge).
|
||||
function act(action, id) {
|
||||
if (action === 'reply') { flash('Reply drafted (demo)'); return; }
|
||||
if (action === 'like') liked[id] = !liked[id];
|
||||
if (action === 'repost') reposted[id] = !reposted[id];
|
||||
persist(); render();
|
||||
if (action === 'repost') flash(reposted[id] ? 'Reposted (demo)' : 'Removed');
|
||||
if (action === 'like') flash(liked[id] ? 'Liked (demo)' : 'Unliked');
|
||||
}
|
||||
|
||||
function postHtml(p) {
|
||||
var likeOn = liked[p.id] ? ' on' : '';
|
||||
var repoOn = reposted[p.id] ? ' on' : '';
|
||||
var likeCt = p.likes + (liked[p.id] ? 1 : 0);
|
||||
var repoCt = p.reposts + (reposted[p.id] ? 1 : 0);
|
||||
var tags = (p.topics || []).map(function (t) { return '<span class="tag">#' + esc(t) + '</span>'; }).join('');
|
||||
return '<div class="post">'
|
||||
+ '<div class="avatar">' + esc(p.avatar) + '</div>'
|
||||
+ '<div class="pbody">'
|
||||
+ '<div class="line"><span class="name">' + esc(p.author) + '</span>'
|
||||
+ '<span class="handle">' + esc(p.handle) + '</span><span class="dot">·</span><span class="time">' + esc(p.time) + '</span></div>'
|
||||
+ '<p class="text">' + esc(p.text) + '</p>'
|
||||
+ (tags ? '<div class="tags">' + tags + '</div>' : '')
|
||||
+ '<div class="actions">'
|
||||
+ '<button class="action reply" data-action="reply" data-id="' + p.id + '">💬</button>'
|
||||
+ '<button class="action repost' + repoOn + '" data-action="repost" data-id="' + p.id + '">🔁 <span class="ct">' + fmt(repoCt) + '</span></button>'
|
||||
+ '<button class="action like' + likeOn + '" data-action="like" data-id="' + p.id + '">' + (liked[p.id] ? '♥' : '♡') + ' <span class="ct">' + fmt(likeCt) + '</span></button>'
|
||||
+ '</div></div></div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!current) { root.innerHTML = '<div class="app"><div class="feed"><div class="loading">Loading your feed…</div></div></div>'; return; }
|
||||
var chips = current.topics.map(function (t) {
|
||||
var on = selected.indexOf(t) >= 0 ? ' on' : '';
|
||||
return '<button class="chip' + on + '" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
|
||||
}).join('');
|
||||
var visible = current.posts.filter(function (p) {
|
||||
return (p.topics || []).some(function (t) { return selected.indexOf(t) >= 0; });
|
||||
});
|
||||
var body = visible.length
|
||||
? visible.map(postHtml).join('')
|
||||
: '<div class="empty">No posts. Pick a topic above to see what is happening.</div>';
|
||||
root.innerHTML = '<div class="app"><div class="feed">'
|
||||
+ '<div class="header"><h1 class="h-title">Home</h1><div class="chips">' + chips + '</div></div>'
|
||||
+ body
|
||||
+ '</div><div class="toast" id="toast"></div></div>';
|
||||
}
|
||||
|
||||
function flash(msg) {
|
||||
var el = document.getElementById('toast');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show';
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () { el.className = 'toast'; }, 2000);
|
||||
}
|
||||
|
||||
root.addEventListener('click', function (e) {
|
||||
var t = e.target;
|
||||
var chip = t && t.closest ? t.closest('.chip') : null;
|
||||
if (chip) {
|
||||
var topic = chip.getAttribute('data-topic');
|
||||
var i = selected.indexOf(topic);
|
||||
if (i >= 0) selected.splice(i, 1); else selected.push(topic);
|
||||
persist();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
var btn = t && t.closest ? t.closest('.action') : null;
|
||||
if (!btn) return;
|
||||
act(btn.getAttribute('data-action'), btn.getAttribute('data-id'));
|
||||
});
|
||||
|
||||
window.rowboat.onData(function (d) {
|
||||
current = d;
|
||||
if (selected === null) selected = d.topics.slice();
|
||||
render();
|
||||
});
|
||||
window.rowboat.onState(function (st) {
|
||||
if (!st) return;
|
||||
if (st.topics) selected = st.topics;
|
||||
if (st.liked) liked = st.liked;
|
||||
if (st.reposted) reposted = st.reposted;
|
||||
if (current) render();
|
||||
});
|
||||
render();
|
||||
window.rowboat.ready();
|
||||
`
|
||||
|
||||
export const twitterClientApp: MiniApp = {
|
||||
id: 'twitter-client',
|
||||
name: 'Twitter, curated',
|
||||
description: 'An X-style feed of the posts that matter, filtered by your topics.',
|
||||
source: 'Twitter',
|
||||
active: true,
|
||||
lastRun: '2m ago',
|
||||
scope: ['twitter'],
|
||||
data,
|
||||
html: buildMiniAppHtml({ title: 'Twitter, curated', style, script }),
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
// Mini Apps registry.
|
||||
//
|
||||
// Phase 1: apps are hardcoded here. Later phases replace this with apps loaded
|
||||
// from ~/.rowboat/apps/<id>/ over IPC.
|
||||
|
||||
import type { MiniApp } from './types'
|
||||
import { githubRadarApp } from './apps/github-radar'
|
||||
import { twitterClientApp } from './apps/twitter-client'
|
||||
import { newsletterDigestApp, competitorWatchApp } from './apps/digests'
|
||||
|
||||
export const MINI_APPS: MiniApp[] = [githubRadarApp, twitterClientApp, newsletterDigestApp, competitorWatchApp]
|
||||
|
||||
export function getMiniApp(id: string): MiniApp | undefined {
|
||||
return MINI_APPS.find((app) => app.id === id)
|
||||
}
|
||||
|
|
@ -19,4 +19,5 @@ export * as browserControl from './browser-control.js';
|
|||
export * as billing from './billing.js';
|
||||
export * as notificationSettings from './notification-settings.js';
|
||||
export * as codeSessions from './code-sessions.js';
|
||||
export * as miniApp from './mini-app.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { MiniAppManifest } from './mini-app.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
|
|
@ -1007,6 +1008,31 @@ const ipcSchemas = {
|
|||
shouldShow: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: seed built-in apps to ~/.rowboat/apps/<id>/ (idempotent).
|
||||
'mini-apps:seed': {
|
||||
req: z.object({
|
||||
apps: z.array(z.object({
|
||||
manifest: MiniAppManifest,
|
||||
html: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
})),
|
||||
}),
|
||||
res: z.object({
|
||||
seeded: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: list installed app manifests from ~/.rowboat/apps/.
|
||||
'mini-apps:list': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
manifests: z.array(MiniAppManifest),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: read an app's latest data.json (agent output).
|
||||
'mini-apps:get-data': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.object({ data: z.unknown().nullable() }),
|
||||
},
|
||||
'composio:didConnect': {
|
||||
req: z.object({
|
||||
toolkitSlug: z.string(),
|
||||
|
|
|
|||
31
apps/x/packages/shared/src/mini-app.ts
Normal file
31
apps/x/packages/shared/src/mini-app.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Manifest for a Mini App stored at ~/.rowboat/apps/<id>/manifest.json.
|
||||
*
|
||||
* Static assets are served from `<id>/dist/` via app://miniapp/<id>/. The
|
||||
* optional `agent` links a background task (bg-tasks engine) that writes
|
||||
* `<id>/data.json`, which the host reads and pushes to the app.
|
||||
*/
|
||||
export const MiniAppManifest = z.object({
|
||||
/** Stable slug; also the on-disk folder name and the app:// host path. */
|
||||
id: z.string(),
|
||||
/** Display name shown on the card and in the open view. */
|
||||
title: z.string(),
|
||||
/** One-line description for the card. */
|
||||
description: z.string().default(''),
|
||||
/** Primary integration shown in the card footer pill (e.g. 'GitHub'). */
|
||||
source: z.string().default(''),
|
||||
/** Composio toolkits this app may use; enforced host-side on bridge calls. */
|
||||
scope: z.array(z.string()).default([]),
|
||||
/** Whether the app's agent is active (drives the status badge). */
|
||||
active: z.boolean().default(true),
|
||||
/** Human last-run label for the card footer (e.g. '2m ago'). */
|
||||
lastRun: z.string().default(''),
|
||||
/** Entry file within the app folder, served via app://miniapp/<id>/. */
|
||||
entry: z.string().default('dist/index.html'),
|
||||
/** Optional associated background-task slug that produces data.json. */
|
||||
agent: z.string().optional(),
|
||||
});
|
||||
|
||||
export type MiniAppManifest = z.infer<typeof MiniAppManifest>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue