mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
fix(mini-apps): reliability fixes + self-contained data loading
From two rounds of copilot build feedback: - runtime: null-check hallucinated tool names (was crashing the whole run) - add window.rowboat.fetch + builtin fetch-url: CORS-safe HTTP for UI and bg-task agents (no shell needed for plain HTTP) - mini-app-set-data/install: reject stringified JSON; require object - manifest dataContract (requiredKeys/nonEmptyArrayKeys) enforced on writes so a stray/legacy task can't corrupt an app's data or wipe series - self-contained data: data.json is a served sibling (dist/), apps fetch it via relative URL; removed host-side data injection - build-mini-app skill: CORS/fetch-url/no-shell/capable-model/dataContract gotchas
This commit is contained in:
parent
6d7fd7b013
commit
c424976926
11 changed files with 288 additions and 75 deletions
|
|
@ -1324,6 +1324,9 @@ export function setupIpcHandlers() {
|
|||
'mini-apps:get-data': async (_event, args) => {
|
||||
return miniAppsHandler.getAppData(args.id);
|
||||
},
|
||||
'mini-apps:fetch': async (_event, args) => {
|
||||
return miniAppsHandler.proxyFetch(args);
|
||||
},
|
||||
// Agent schedule handlers
|
||||
'agent-schedule:getConfig': async () => {
|
||||
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function appDir(id: string): string {
|
|||
*/
|
||||
export const MINIAPP_BRIDGE_JS = `
|
||||
(function () {
|
||||
var data = null, state = null;
|
||||
var data = null, dataLoaded = false, state = null;
|
||||
var dataCbs = [], stateCbs = [];
|
||||
var pending = {}, seq = 0;
|
||||
function post(msg) { parent.postMessage(msg, '*'); }
|
||||
|
|
@ -35,13 +35,18 @@ export const MINIAPP_BRIDGE_JS = `
|
|||
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:data') {
|
||||
data = m.data;
|
||||
dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} });
|
||||
} else if (m.type === 'rowboat:mini-app:state') {
|
||||
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:rpc-result') {
|
||||
|
|
@ -51,7 +56,8 @@ export const MINIAPP_BRIDGE_JS = `
|
|||
});
|
||||
window.rowboat = {
|
||||
getData: function () { return data; },
|
||||
onData: function (cb) { dataCbs.push(cb); if (data !== null) { try { cb(data); } catch (_) {} } return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.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 (_) {} }); },
|
||||
|
|
@ -59,8 +65,15 @@ export const MINIAPP_BRIDGE_JS = `
|
|||
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();
|
||||
})();
|
||||
`;
|
||||
|
||||
|
|
@ -108,6 +121,35 @@ export function listApps(): { manifests: 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<string, string>;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }
|
|||
}
|
||||
|
||||
async function handleRpc(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
// fetch is a network proxy, not toolkit-scoped — handle before the scope gate.
|
||||
if (method === 'fetch') {
|
||||
const url = typeof params.url === 'string' ? params.url : ''
|
||||
if (!url) throw new Error('No url specified.')
|
||||
return window.ipc.invoke('mini-apps:fetch', {
|
||||
url,
|
||||
method: typeof params.method === 'string' ? params.method : undefined,
|
||||
headers: (params.headers && typeof params.headers === 'object' ? params.headers : undefined) as Record<string, string> | undefined,
|
||||
body: typeof params.body === 'string' ? params.body : undefined,
|
||||
})
|
||||
}
|
||||
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)'}".`)
|
||||
|
|
@ -62,15 +73,9 @@ export function MiniAppFrame({ manifest }: { manifest: miniApp.MiniAppManifest }
|
|||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
// Apps load their own data.json (served sibling) via a relative fetch; the
|
||||
// host only provides per-app UI state on ready.
|
||||
function handleReady() {
|
||||
postToFrame({ type: MINI_APP_MESSAGE.state, state: stateRef.current })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,53 +35,65 @@ const patternFor = (id: string): string => PATTERNS[hash(id + '·pat') % PATTERN
|
|||
// Tailwind tokens). Injected once; per-card accent is passed via CSS variables.
|
||||
const CARD_CSS = `
|
||||
/* Light is the baseline; .dark (set on <html>) overrides the surface tokens.
|
||||
The accent system (badge/pill/glow/pattern via --accent) is identical in both. */
|
||||
The accent system (badge/pill/glow/pattern via --accent) is identical in both.
|
||||
Surfaces are brushed-metal: a base gradient + a diagonal sheen + a hairline
|
||||
top highlight. Sizing responds to the PANE width via container queries. */
|
||||
.ma-page {
|
||||
--ma-bg:#f6f6f7;
|
||||
--ma-card-from:#ffffff; --ma-card-to:#fbfbfc;
|
||||
--ma-card-hover-from:#ffffff; --ma-card-hover-to:#f5f5f7;
|
||||
--ma-border:rgba(0,0,0,0.08); --ma-border-hover:rgba(0,0,0,0.14);
|
||||
--ma-shadow:0 1px 2px rgba(0,0,0,0.06);
|
||||
--ma-title:#101013; --ma-desc:rgba(0,0,0,0.62);
|
||||
--ma-h1:#101013; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42);
|
||||
--ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5); --ma-off-dot:rgba(0,0,0,0.4);
|
||||
container-type: inline-size;
|
||||
--ma-bg:#eceef1;
|
||||
/* metallic silver */
|
||||
--ma-card-from:#ffffff; --ma-card-mid:#f2f3f6; --ma-card-to:#e6e8ee;
|
||||
--ma-card-hover-from:#ffffff; --ma-card-hover-mid:#f5f6f9; --ma-card-hover-to:#eaecf1;
|
||||
--ma-sheen:rgba(255,255,255,0.55); --ma-top-highlight:rgba(255,255,255,0.9);
|
||||
--ma-border:rgba(0,0,0,0.09); --ma-border-hover:rgba(0,0,0,0.15);
|
||||
--ma-shadow:0 1px 2px rgba(0,0,0,0.08);
|
||||
--ma-title:#0d0e11; --ma-desc:rgba(0,0,0,0.6);
|
||||
--ma-h1:#0d0e11; --ma-sub:rgba(0,0,0,0.5); --ma-lastrun:rgba(0,0,0,0.42);
|
||||
--ma-off-bg:rgba(0,0,0,0.05); --ma-off-fg:rgba(0,0,0,0.5);
|
||||
--ma-new-border:rgba(0,0,0,0.14); --ma-new-title:rgba(0,0,0,0.6); --ma-new-hint:rgba(0,0,0,0.4);
|
||||
--ma-pat-opacity:0.16; --ma-glow-opacity:0.22; --ma-glow-hover-opacity:0.30;
|
||||
--ma-badge-mix:22%; --ma-pill-mix:18%;
|
||||
--ma-pat-opacity:0.10; --ma-glow-opacity:0.16; --ma-glow-hover-opacity:0.24;
|
||||
--ma-badge-mix:20%; --ma-pill-mix:16%; --ma-tint:16%; --ma-tint-hover:22%;
|
||||
height:100%; overflow:auto; background:var(--ma-bg);
|
||||
}
|
||||
.dark .ma-page {
|
||||
--ma-bg:#0c0c0e;
|
||||
--ma-card-from:#17171B; --ma-card-to:#111114;
|
||||
--ma-card-hover-from:#19191e; --ma-card-hover-to:#121215;
|
||||
--ma-border:rgba(255,255,255,0.06); --ma-border-hover:rgba(255,255,255,0.09);
|
||||
--ma-shadow:0 1px 2px rgba(0,0,0,0.18);
|
||||
--ma-title:#f5f5f5; --ma-desc:rgba(255,255,255,0.68);
|
||||
--ma-h1:#f5f5f5; --ma-sub:rgba(255,255,255,0.55); --ma-lastrun:rgba(255,255,255,0.4);
|
||||
--ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5); --ma-off-dot:rgba(255,255,255,0.45);
|
||||
--ma-bg:#0b0b0d;
|
||||
/* metallic gunmetal */
|
||||
--ma-card-from:#262930; --ma-card-mid:#191b21; --ma-card-to:#101116;
|
||||
--ma-card-hover-from:#2b2e36; --ma-card-hover-mid:#1c1e25; --ma-card-hover-to:#131419;
|
||||
--ma-sheen:rgba(255,255,255,0.07); --ma-top-highlight:rgba(255,255,255,0.09);
|
||||
--ma-border:rgba(255,255,255,0.07); --ma-border-hover:rgba(255,255,255,0.12);
|
||||
--ma-shadow:0 1px 2px rgba(0,0,0,0.35);
|
||||
--ma-title:#f4f5f7; --ma-desc:rgba(255,255,255,0.66);
|
||||
--ma-h1:#f4f5f7; --ma-sub:rgba(255,255,255,0.52); --ma-lastrun:rgba(255,255,255,0.38);
|
||||
--ma-off-bg:rgba(255,255,255,0.06); --ma-off-fg:rgba(255,255,255,0.5);
|
||||
--ma-new-border:rgba(255,255,255,0.12); --ma-new-title:rgba(255,255,255,0.6); --ma-new-hint:rgba(255,255,255,0.38);
|
||||
--ma-pat-opacity:0.07; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.13;
|
||||
--ma-badge-mix:15%; --ma-pill-mix:13%;
|
||||
--ma-pat-opacity:0.05; --ma-glow-opacity:0.10; --ma-glow-hover-opacity:0.16;
|
||||
--ma-badge-mix:15%; --ma-pill-mix:13%; --ma-tint:20%; --ma-tint-hover:26%;
|
||||
}
|
||||
.ma-inner { max-width:1080px; margin:0 auto; padding:32px 28px 48px; }
|
||||
.ma-h1 { font-size:24px; font-weight:600; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
|
||||
.ma-sub { font-size:14px; color:var(--ma-sub); margin:0 0 28px; }
|
||||
.ma-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:28px; }
|
||||
@media (max-width:1040px) { .ma-grid { grid-template-columns:repeat(2, 1fr); } }
|
||||
@media (max-width:700px) { .ma-grid { grid-template-columns:1fr; } }
|
||||
.ma-inner { max-width:1120px; margin:0 auto; padding:clamp(20px,3.5cqw,34px) clamp(16px,3cqw,30px) 48px; }
|
||||
.ma-h1 { font-size:clamp(19px,2.6cqw,24px); font-weight:650; letter-spacing:-0.02em; color:var(--ma-h1); margin:0 0 4px; }
|
||||
.ma-sub { font-size:clamp(13px,1.5cqw,14px); color:var(--ma-sub); margin:0 0 clamp(18px,2.5cqw,28px); }
|
||||
/* Fluid columns: as many ~250px cards as fit the pane; single column when narrow. */
|
||||
.ma-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(min(100%,248px),1fr)); gap:clamp(14px,2cqw,24px); }
|
||||
|
||||
.ma-card {
|
||||
position:relative; min-height:252px; border-radius:20px;
|
||||
position:relative; min-height:clamp(190px,24cqw,244px); border-radius:18px;
|
||||
border:1px solid var(--ma-border);
|
||||
background:linear-gradient(160deg, var(--ma-card-from) 0%, var(--ma-card-to) 100%);
|
||||
padding:22px; text-align:left; cursor:pointer; overflow:hidden;
|
||||
background:
|
||||
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 34%),
|
||||
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint), transparent) 0%, transparent 62%),
|
||||
linear-gradient(158deg, var(--ma-card-from) 0%, var(--ma-card-mid) 52%, var(--ma-card-to) 100%);
|
||||
padding:clamp(15px,2cqw,22px); text-align:left; cursor:pointer; overflow:hidden;
|
||||
display:flex; flex-direction:column; isolation:isolate;
|
||||
box-shadow: var(--ma-shadow), 0 6px 18px -20px var(--glow);
|
||||
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease;
|
||||
box-shadow: var(--ma-shadow), inset 0 1px 0 var(--ma-top-highlight), 0 8px 22px -20px var(--glow);
|
||||
transition: box-shadow .22s ease, border-color .22s ease, background .22s ease, transform .22s ease;
|
||||
}
|
||||
.ma-card:hover {
|
||||
border-color: var(--ma-border-hover);
|
||||
background:linear-gradient(160deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-to) 100%);
|
||||
background:
|
||||
linear-gradient(135deg, var(--ma-sheen) 0%, transparent 36%),
|
||||
linear-gradient(158deg, color-mix(in srgb, var(--accent) var(--ma-tint-hover), transparent) 0%, transparent 64%),
|
||||
linear-gradient(158deg, var(--ma-card-hover-from) 0%, var(--ma-card-hover-mid) 52%, var(--ma-card-hover-to) 100%);
|
||||
}
|
||||
/* decorative pattern layer (accent-tinted, very low opacity) */
|
||||
.ma-card::before {
|
||||
|
|
@ -110,22 +122,27 @@ const CARD_CSS = `
|
|||
}
|
||||
.ma-badge.off { color: var(--ma-off-fg); background: var(--ma-off-bg); }
|
||||
|
||||
.ma-title { font-size:22px; font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:18px 0 8px; }
|
||||
.ma-title { font-size:clamp(17px,2.3cqw,21px); font-weight:600; letter-spacing:-0.02em; color:var(--ma-title); margin:clamp(12px,2cqw,18px) 0 8px; }
|
||||
.ma-desc {
|
||||
font-size:15px; font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0;
|
||||
font-size:clamp(13px,1.5cqw,14.5px); font-weight:400; line-height:1.45; color:var(--ma-desc); margin:0;
|
||||
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
|
||||
}
|
||||
.ma-footer { margin-top:auto; padding-top:22px; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.ma-source { font-size:12px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 11px; border-radius:999px; }
|
||||
.ma-lastrun { font-size:12px; color:var(--ma-lastrun); }
|
||||
.ma-footer { margin-top:auto; padding-top:clamp(14px,2cqw,22px); display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.ma-source { font-size:11.5px; font-weight:600; color:var(--accent); background: color-mix(in srgb, var(--accent) var(--ma-pill-mix), transparent); padding:5px 10px; border-radius:999px; white-space:nowrap; }
|
||||
.ma-lastrun { font-size:11.5px; color:var(--ma-lastrun); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
.ma-new {
|
||||
min-height:252px; border-radius:20px; border:1px dashed var(--ma-new-border);
|
||||
min-height:clamp(190px,24cqw,244px); border-radius:18px; border:1px dashed var(--ma-new-border);
|
||||
background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
gap:8px; color:var(--ma-new-title); cursor:default; transition: border-color .2s ease, color .2s ease;
|
||||
}
|
||||
.ma-new-title { font-size:15px; font-weight:600; color:var(--ma-new-title); }
|
||||
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); }
|
||||
.ma-new-title { font-size:14.5px; font-weight:600; color:var(--ma-new-title); }
|
||||
.ma-new-hint { font-size:12px; color:var(--ma-new-hint); text-align:center; padding:0 12px; }
|
||||
|
||||
/* Very narrow pane: tighten footer so source + last-run don't collide. */
|
||||
@container (max-width: 380px) {
|
||||
.ma-footer { flex-direction:column; align-items:flex-start; gap:6px; }
|
||||
}
|
||||
`
|
||||
|
||||
function Card({ app, index, onOpen }: { app: miniApp.MiniAppManifest; index: number; onOpen: () => void }) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
*/
|
||||
const BRIDGE_SHIM = /* js */ `
|
||||
(function () {
|
||||
var data = null, state = null;
|
||||
var data = null, dataLoaded = false, state = null;
|
||||
var dataCbs = [], stateCbs = [];
|
||||
var pending = {}, seq = 0;
|
||||
function post(msg) { parent.postMessage(msg, '*'); }
|
||||
|
|
@ -26,13 +26,17 @@ const BRIDGE_SHIM = /* js */ `
|
|||
post({ type: 'rowboat:mini-app:rpc', id: id, method: method, params: params });
|
||||
});
|
||||
}
|
||||
// data.json is a served sibling of index.html — apps load it themselves.
|
||||
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:data') {
|
||||
data = m.data;
|
||||
dataCbs.forEach(function (cb) { try { cb(data); } catch (_) {} });
|
||||
} else if (m.type === 'rowboat:mini-app:state') {
|
||||
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:rpc-result') {
|
||||
|
|
@ -45,9 +49,10 @@ const BRIDGE_SHIM = /* js */ `
|
|||
});
|
||||
window.rowboat = {
|
||||
getData: function () { return data; },
|
||||
refreshData: function () { return loadData(); },
|
||||
onData: function (cb) {
|
||||
dataCbs.push(cb);
|
||||
if (data !== null) { try { cb(data); } catch (_) {} }
|
||||
if (dataLoaded) { try { cb(data); } catch (_) {} }
|
||||
return function () { var i = dataCbs.indexOf(cb); if (i >= 0) dataCbs.splice(i, 1); };
|
||||
},
|
||||
getState: function () { return state; },
|
||||
|
|
@ -70,8 +75,15 @@ const BRIDGE_SHIM = /* js */ `
|
|||
isConnected: function (scope) { return rpc('isConnected', { scope: scope }); },
|
||||
// Trigger the Composio OAuth flow for the toolkit. Resolves when started.
|
||||
connect: function (scope) { return rpc('connect', { scope: scope }); },
|
||||
// CORS-safe HTTP via the main process (for third-party APIs without CORS).
|
||||
// Resolves to { ok, status, text, json } (json = parsed body or 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();
|
||||
})();
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export type MiniApp = {
|
|||
* - isConnected: is the toolkit connected? params: { scope }
|
||||
* - connect: trigger the Composio OAuth flow params: { scope }
|
||||
*/
|
||||
export type MiniAppRpcMethod = 'callAction' | 'searchTools' | 'isConnected' | 'connect'
|
||||
export type MiniAppRpcMethod = 'callAction' | 'searchTools' | 'isConnected' | 'connect' | 'fetch'
|
||||
|
||||
/** Messages sent from the iframe (app) up to the host (renderer). */
|
||||
export type MiniAppOutboundMessage =
|
||||
|
|
|
|||
|
|
@ -1541,6 +1541,14 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
|
|||
for (const part of message.content) {
|
||||
if (part.type === "tool-call") {
|
||||
const underlyingTool = agent.tools![part.toolName];
|
||||
// The model can hallucinate a tool name that isn't declared.
|
||||
// Skip it here instead of dereferencing undefined (which would
|
||||
// crash the whole run); the SDK returns an error tool-result
|
||||
// for the unknown call so the model can self-correct.
|
||||
if (!underlyingTool) {
|
||||
loopLogger.log('model called unknown tool, skipping:', part.toolName);
|
||||
continue;
|
||||
}
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
||||
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
|
||||
const rawOptions = (part.arguments as { options?: unknown }).options;
|
||||
|
|
|
|||
|
|
@ -63,13 +63,19 @@ bridge: include the shim and code against \`window.rowboat\`:
|
|||
\`\`\`
|
||||
|
||||
\`window.rowboat\` API:
|
||||
- \`getData()\` / \`onData(cb)\` — the app's data (host serves \`data.json\`). Register
|
||||
\`onData\` then call \`ready()\`.
|
||||
- \`getData()\` / \`onData(cb)\` / \`refreshData()\` — the app's data. \`data.json\` is a
|
||||
**served sibling of index.html**, so the app is self-contained: \`onData\`
|
||||
fetches \`data.json\` via a relative URL (you can also just \`fetch('data.json')\`
|
||||
yourself). \`refreshData()\` re-fetches. Rowboat does NOT inject data.
|
||||
- \`getState()\` / \`setState(patch)\` — per-app persistent UI state.
|
||||
- \`isConnected(scope)\` / \`connect(scope)\` — connection status / start OAuth.
|
||||
- \`searchTools(scope, query)\` → [{slug,name,description}], and
|
||||
\`callAction(scope, toolSlug, args)\` → tool result (rejects on error). Scope is
|
||||
enforced against the manifest, so only declared toolkits work.
|
||||
- \`fetch(url, opts?)\` → { ok, status, text, json } — a CORS-safe HTTP proxy
|
||||
through the main process. **Use this for any third-party API, never the
|
||||
browser's \`fetch\`** (public APIs rarely send CORS headers, so direct fetch
|
||||
from the app origin fails with "Failed to fetch").
|
||||
- \`ready()\` — call once after registering callbacks to receive initial data/state.
|
||||
|
||||
Keep it dependency-free (no remote CDNs unless truly needed; the app:// origin
|
||||
|
|
@ -84,14 +90,25 @@ allows them but offline-safe is better). Render loading / empty / error states.
|
|||
## 4. Data pipeline (agent-backed apps only)
|
||||
|
||||
Create a background task with \`create-background-task\` whose instructions:
|
||||
- fetch the data via Composio (or browse via the embedded browser for social
|
||||
feeds), and
|
||||
- call **\`mini-app-set-data\`** with \`{ appId: "<id>", data: <payload> }\`.
|
||||
- fetch the data — via **Composio** if a toolkit exists, otherwise via the
|
||||
builtin **\`fetch-url\`** tool (server-side HTTP, no CORS). **The bg-task agent
|
||||
has NO shell** (\`executeCommand\` is disabled headlessly) and \`rowboat.fetch\`
|
||||
is frontend-only — never tell it to run a \`refresh.sh\`/script; it fetches via
|
||||
\`fetch-url\` or Composio, and
|
||||
- call **\`mini-app-set-data\`** with \`{ appId: "<id>", data: <object> }\` —
|
||||
pass the **object directly, never \`JSON.stringify\`** it. If a fetch fails,
|
||||
**keep the last good data** (don't overwrite good series with empty ones).
|
||||
|
||||
Set the manifest's **\`dataContract\`** (\`requiredKeys\` + \`nonEmptyArrayKeys\`)
|
||||
to the shape the UI needs — \`mini-app-set-data\` enforces it, so a stale or
|
||||
buggy run can't corrupt the app with the wrong shape or wipe series with empties.
|
||||
|
||||
The agent only RETURNS the data; \`mini-app-set-data\` writes \`data.json\`
|
||||
atomically (deterministic path + write — the agent never touches files). Set the
|
||||
manifest's \`agent\` field to the task's slug. Give the task a sensible trigger
|
||||
(cron/window) from the background-task skill.
|
||||
manifest's \`agent\` field to the task's slug, and give the task a **capable model**
|
||||
(e.g. a Claude Sonnet / GPT-class model) via its model override — the default
|
||||
light model fabricates output and hallucinates tool names on side-effect tasks.
|
||||
Give it a sensible trigger (cron/window) from the background-task skill.
|
||||
|
||||
## 5. Finalize
|
||||
|
||||
|
|
@ -104,6 +121,22 @@ Then **open it for the user**: call \`app-navigation\` with
|
|||
(under Mini Apps / its title) and shows an "Opened <app>" card in the chat. Only
|
||||
do this once the app is installed AND its data is populated, so it renders ready.
|
||||
|
||||
## Common gotchas (read before building)
|
||||
|
||||
- **CORS:** third-party APIs usually block browser fetches. From the app UI use
|
||||
\`rowboat.fetch(url)\`, not \`fetch(url)\`. If you don't, it fails with
|
||||
"Failed to fetch" even though curl works.
|
||||
- **No Composio toolkit for the data?** That's fine — use \`rowboat.fetch\` from a
|
||||
live app, or a bg-task that fetches with **\`fetch-url\`** and calls
|
||||
\`mini-app-set-data\`. Don't force a toolkit that doesn't exist (e.g. there's no
|
||||
FX/currency toolkit), and don't reach for a shell or MCP server for plain HTTP.
|
||||
- **data.json shape:** pass a plain **object** to \`mini-app-set-data\`; never
|
||||
\`JSON.stringify\` it first (double-encoding breaks the UI silently).
|
||||
- **bg-task has no shell:** don't generate \`refresh.sh\` / \`executeCommand\`
|
||||
steps for the data agent — it can't run them headlessly.
|
||||
- **model:** set a capable model on any data/side-effect bg-task; the default is
|
||||
too weak and will fabricate results.
|
||||
|
||||
## Manifest schema (manifest.json)
|
||||
|
||||
\`\`\`yaml
|
||||
|
|
|
|||
|
|
@ -1528,8 +1528,13 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
await fs.writeFile(path.join(dir, 'manifest.json'), JSON.stringify(m, null, 2));
|
||||
await fs.writeFile(path.join(dist, 'index.html'), html);
|
||||
if (data !== undefined) {
|
||||
const dataPath = path.join(dir, 'data.json');
|
||||
try { await fs.access(dataPath); } catch { await fs.writeFile(dataPath, JSON.stringify(data, null, 2)); }
|
||||
let payload: unknown = data;
|
||||
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch { payload = undefined; } }
|
||||
if (payload !== undefined && payload !== null && typeof payload === 'object') {
|
||||
// sibling of index.html so the app can fetch('data.json')
|
||||
const dataPath = path.join(dist, 'data.json');
|
||||
try { await fs.access(dataPath); } catch { await fs.writeFile(dataPath, JSON.stringify(payload, null, 2)); }
|
||||
}
|
||||
}
|
||||
return { success: true, id: m.id, url: `app://miniapp/${m.id}/index.html` };
|
||||
} catch (e) {
|
||||
|
|
@ -1545,11 +1550,45 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}),
|
||||
execute: async ({ appId, data }: { appId: string; data: unknown }) => {
|
||||
try {
|
||||
// Guard the #1 mistake: passing a JSON *string* (JSON.stringify'd)
|
||||
// instead of the object. Auto-parse a string; reject non-objects so
|
||||
// the UI never gets a double-encoded blob it can't read.
|
||||
let payload: unknown = data;
|
||||
if (typeof payload === 'string') {
|
||||
try { payload = JSON.parse(payload); }
|
||||
catch { return { success: false, error: 'data must be a JSON object/array — pass the object directly, do NOT JSON.stringify it.' }; }
|
||||
}
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
return { success: false, error: 'data must be a JSON object or array.' };
|
||||
}
|
||||
const dir = path.join(WorkDir, 'apps', appId);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const dataPath = path.join(dir, 'data.json');
|
||||
// The app must already exist — never create a stray folder, and load
|
||||
// its dataContract to guard the write.
|
||||
let manifest: z.infer<typeof MiniAppManifest>;
|
||||
try {
|
||||
manifest = MiniAppManifest.parse(JSON.parse(await fs.readFile(path.join(dir, 'manifest.json'), 'utf-8')));
|
||||
} catch {
|
||||
return { success: false, error: `No installed Mini App "${appId}". Install it first (mini-app-install).` };
|
||||
}
|
||||
const contract = manifest.dataContract;
|
||||
if (contract && !Array.isArray(payload)) {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
const missing = (contract.requiredKeys ?? []).filter((k) => obj[k] === undefined || obj[k] === null);
|
||||
if (missing.length) {
|
||||
return { success: false, error: `data is missing required key(s): ${missing.join(', ')}. Match the app's data shape — do NOT write a different shape; keep the last good data.` };
|
||||
}
|
||||
const badArrays = (contract.nonEmptyArrayKeys ?? []).filter((k) => !Array.isArray(obj[k]) || (obj[k] as unknown[]).length === 0);
|
||||
if (badArrays.length) {
|
||||
return { success: false, error: `these key(s) must be non-empty arrays: ${badArrays.join(', ')}. Don't overwrite good series with empty ones — keep the last good data.` };
|
||||
}
|
||||
}
|
||||
// data.json is a served sibling of index.html so apps fetch it
|
||||
// via a relative URL (app://miniapp/<id>/data.json).
|
||||
const distDir = path.join(dir, 'dist');
|
||||
await fs.mkdir(distDir, { recursive: true });
|
||||
const dataPath = path.join(distDir, 'data.json');
|
||||
const tmp = `${dataPath}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(data, null, 2));
|
||||
await fs.writeFile(tmp, JSON.stringify(payload, null, 2));
|
||||
await fs.rename(tmp, dataPath);
|
||||
return { success: true, appId };
|
||||
} catch (e) {
|
||||
|
|
@ -1557,6 +1596,32 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
},
|
||||
},
|
||||
'fetch-url': {
|
||||
description: "Fetch an HTTP(S) URL and return the response body as text. Use this to pull data from web APIs or pages (e.g. a JSON endpoint) — especially in background tasks, which have no shell. GET by default; supports POST with a body. Returns { ok, status, statusText, body } (body truncated if very large). For JSON, parse the returned body.",
|
||||
inputSchema: z.object({
|
||||
url: z.string().describe('The http(s) URL to fetch.'),
|
||||
method: z.enum(['GET', 'POST']).optional().describe('HTTP method (default GET).'),
|
||||
headers: z.record(z.string(), z.string()).optional().describe('Optional request headers.'),
|
||||
body: z.string().optional().describe('Request body (for POST).'),
|
||||
}),
|
||||
execute: async ({ url, method, headers, body }: { url: string; method?: string; headers?: Record<string, string>; body?: string }) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { ok: false, status: 0, error: 'Only http(s) URLs are allowed.' };
|
||||
}
|
||||
const m = (method || 'GET').toUpperCase();
|
||||
const res = await fetch(url, { method: m, headers, body: m === 'GET' || m === 'HEAD' ? undefined : body });
|
||||
let text = await res.text();
|
||||
const MAX = 200_000;
|
||||
const truncated = text.length > MAX;
|
||||
if (truncated) text = text.slice(0, MAX);
|
||||
return { ok: res.ok, status: res.status, statusText: res.statusText, body: text, truncated };
|
||||
} catch (e) {
|
||||
return { ok: false, status: 0, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
},
|
||||
},
|
||||
'run-live-note-agent': {
|
||||
description: "Manually trigger the live-note agent to run now on a note. Equivalent to the user clicking the Run button in the live-note sidebar, but you can pass extra `context` to bias what the agent does this run — most useful for backfills (e.g. seeding a newly-made-live note from existing synced emails) or focused refreshes. Returns the action taken, summary, and the new note body.",
|
||||
inputSchema: z.object({
|
||||
|
|
|
|||
|
|
@ -1033,6 +1033,23 @@ const ipcSchemas = {
|
|||
req: z.object({ id: z.string() }),
|
||||
res: z.object({ data: z.unknown().nullable() }),
|
||||
},
|
||||
// Mini Apps: proxy an HTTP request through main (bypasses browser CORS for the
|
||||
// sandboxed app origin). GET/POST to http(s) only.
|
||||
'mini-apps:fetch': {
|
||||
req: z.object({
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
status: z.number(),
|
||||
statusText: z.string(),
|
||||
text: z.string(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'composio:didConnect': {
|
||||
req: z.object({
|
||||
toolkitSlug: z.string(),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,17 @@ export const MiniAppManifest = z.object({
|
|||
entry: z.string().default('dist/index.html'),
|
||||
/** Optional associated background-task slug that produces data.json. */
|
||||
agent: z.string().optional(),
|
||||
/**
|
||||
* Optional guard on what may be written to data.json (via mini-app-set-data).
|
||||
* Prevents a stray/legacy task from corrupting the app with the wrong shape or
|
||||
* wiping good series with empty ones.
|
||||
*/
|
||||
dataContract: z.object({
|
||||
/** Top-level keys that must be present and non-null. */
|
||||
requiredKeys: z.array(z.string()).default([]),
|
||||
/** Top-level keys that must be non-empty arrays. */
|
||||
nonEmptyArrayKeys: z.array(z.string()).default([]),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type MiniAppManifest = z.infer<typeof MiniAppManifest>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue