diff --git a/apps/x/apps/renderer/src/components/apps/app-detail.tsx b/apps/x/apps/renderer/src/components/apps/app-detail.tsx index 3b2e12d3..1068149e 100644 --- a/apps/x/apps/renderer/src/components/apps/app-detail.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-detail.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' -import { X, RotateCcw, Play } from 'lucide-react' +import { X, RotateCcw, Play, UploadCloud, ArrowUpCircle, Trash2 } from 'lucide-react' import type { rowboatApp } from '@x/shared' +import { PublishDialog } from '@/components/apps/publish-dialog' // App detail panel (spec §14): manifest info, provenance/publish state, // bundled agents with enable toggles, rollback when available. Update/publish @@ -21,6 +22,64 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => const [agents, setAgents] = useState([]) const [error, setError] = useState(null) const [reloadNonce, setReloadNonce] = useState(0) + const [notice, setNotice] = useState(null) + const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string; updateAvailable: boolean } | null>(null) + const [showPublish, setShowPublish] = useState(false) + const [busyAction, setBusyAction] = useState(null) + + const runAction = async (name: string, fn: () => Promise) => { + setBusyAction(name) + setError(null) + setNotice(null) + try { + const msg = await fn() + if (msg) setNotice(msg) + setReloadNonce((n) => n + 1) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusyAction(null) + } + } + + const checkForUpdate = () => runAction('check', async () => { + const r = await window.ipc.invoke('apps:checkUpdate', { folder }) + setUpdateInfo(r) + return r.updateAvailable ? `v${r.latest} is available (you have v${r.current}).` : `Up to date (v${r.current}).` + }) + + const doUpdate = () => runAction('update', async () => { + try { + await window.ipc.invoke('apps:update', { folder }) + return 'Updated.' + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + // D18 / modified-files confirmation flows (§12.3) + if (msg.includes('new_capabilities')) { + if (!window.confirm(`This update widens the app's access:\n${msg}\n\nProceed?`)) return + await window.ipc.invoke('apps:update', { folder, confirmNewCapabilities: true, confirmOverwriteModified: true }) + return 'Updated (new capabilities confirmed).' + } + if (msg.includes('modified_files')) { + if (!window.confirm(`You modified files this update will overwrite:\n${msg}\n\nProceed?`)) return + await window.ipc.invoke('apps:update', { folder, confirmOverwriteModified: true }) + return 'Updated (local modifications overwritten).' + } + throw e + } + }) + + const doRollback = () => runAction('rollback', async () => { + await window.ipc.invoke('apps:rollback', { folder }) + return 'Rolled back to the previous version.' + }) + + const doUninstall = () => runAction('uninstall', async () => { + const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : '' + if (!window.confirm(`Uninstall this app? Its data/ folder will be deleted.${agentNote}`)) return + await window.ipc.invoke('apps:uninstall', { folder }) + onClose() + }) useEffect(() => { let cancelled = false @@ -82,6 +141,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
{error &&
{error}
} + {notice &&
{notice}
} {!app ? (
Loading…
) : ( @@ -123,11 +183,35 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => ) : (

Local app — created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.

)} - {rollback && ( - - )} +
+ {app.kind === 'installed' && app.install?.repo && ( + + )} + {rollback && ( + + )} + {app.kind === 'local' && ( + + )} + {app.kind === 'installed' && ( + + )} +
@@ -172,6 +256,14 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
)} + {showPublish && app && ( + setShowPublish(false)} + onPublished={() => setReloadNonce((n) => n + 1)} + /> + )} ) } diff --git a/apps/x/apps/renderer/src/components/apps/apps-view.tsx b/apps/x/apps/renderer/src/components/apps/apps-view.tsx index b698494f..44db99fa 100644 --- a/apps/x/apps/renderer/src/components/apps/apps-view.tsx +++ b/apps/x/apps/renderer/src/components/apps/apps-view.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Plus, RefreshCw } from 'lucide-react' import type { rowboatApp } from '@x/shared' import { AppFrame } from '@/components/apps/app-frame' +import { CatalogTab } from '@/components/apps/catalog' // Apps home (spec §14): "My apps" grid + Catalog placeholder (M3). Cards are // AppSummary-driven; click opens the app full-height on its own origin. @@ -219,7 +220,7 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: { )} {tab === 'catalog' ? ( -
The community catalog is coming soon.
+ { setSelectedFolder(folder); setTab('mine') }} /> ) : (
{apps.map((app, i) => ( diff --git a/apps/x/apps/renderer/src/components/apps/catalog.tsx b/apps/x/apps/renderer/src/components/apps/catalog.tsx new file mode 100644 index 00000000..3820c6ab --- /dev/null +++ b/apps/x/apps/renderer/src/components/apps/catalog.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState } from 'react' +import { Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react' +import type { rowboatApp } from '@x/shared' + +// Catalog tab (spec §14): search the registry, install with the D18 capability +// disclosure, install from a direct bundle URL. + +type Preview = { + name?: string + version?: string + description?: string + capabilities?: string[] + agents?: string[] + updateSource?: 'github' | 'none' + url?: string // set for URL installs +} + +function capabilityDescription(cap: string): string { + if (cap === 'llm') return 'use your AI models (spends your tokens)' + if (cap === 'copilot') return 'run the copilot agent on your behalf (tools + your knowledge)' + return `read and act on your ${cap} through your connected account` +} + +/** D18 disclosure dialog: every declared capability + bundled agent, explicit confirm. */ +function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: { + preview: Preview + busy: boolean + onConfirm: () => void + onCancel: () => void +}) { + const caps = preview.capabilities ?? [] + const agents = preview.agents ?? [] + return ( +
+
+
Install {preview.name} v{preview.version}?
+ {preview.description &&

{preview.description}

} + +
+
+ This app will be able to: +
+ {caps.length === 0 ? ( +

Nothing — it declares no capabilities (no tools, LLM, or copilot access).

+ ) : ( +
    + {caps.map((c) =>
  • {c}: {capabilityDescription(c)}
  • )} +
+ )} + {agents.length > 0 && ( +
+
Bundled background agents (installed disabled):
+
    + {agents.map((a) =>
  • {a}
  • )} +
+
+ )} +
+ + {preview.updateSource === 'none' && ( +

Installed from a direct URL — updates will be unavailable.

+ )} + +
+ + +
+
+
+ ) +} + +export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) { + const [records, setRecords] = useState([]) + const [stale, setStale] = useState(false) + const [query, setQuery] = useState('') + const [error, setError] = useState(null) + const [preview, setPreview] = useState(null) + const [busy, setBusy] = useState(false) + const [urlDialog, setUrlDialog] = useState(false) + const [url, setUrl] = useState('') + + const load = async (force = false) => { + setError(null) + try { + const r = await window.ipc.invoke('apps:catalogIndex', { force }) + setRecords(r.records) + setStale(r.stale) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + useEffect(() => { void load() }, []) + + const search = async (q: string) => { + setQuery(q) + try { + const r = q.trim() + ? await window.ipc.invoke('apps:catalogSearch', { query: q }) + : await window.ipc.invoke('apps:catalogIndex', {}) + setRecords(r.records) + } catch { /* keep current list */ } + } + + const startInstall = async (name: string) => { + setError(null) + try { + const r = await window.ipc.invoke('apps:install', { name }) + if (r.status === 'preview') setPreview({ ...r }) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + const startUrlPreview = async () => { + setError(null) + try { + const r = await window.ipc.invoke('apps:installFromUrl', { url: url.trim(), confirmed: false }) + if (r.status === 'preview') setPreview({ ...r, url: url.trim() }) + setUrlDialog(false) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + const confirmInstall = async () => { + if (!preview) return + setBusy(true) + setError(null) + try { + const r = preview.url + ? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true }) + : await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true }) + setPreview(null) + if (r.status === 'installed' && r.app) onInstalled(r.app.folder) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + return ( +
+
+
+ + void search(e.target.value)} + placeholder="Search the catalog…" + className="w-full rounded-lg border border-border bg-background py-2 pl-8 pr-3 text-sm outline-none focus:border-foreground/30" + /> +
+ + {stale && ( + + )} +
+ + {error &&
{error}
} + + {records.length === 0 ? ( +
+ {query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'} +
+ ) : ( +
+ {records.map((r) => ( +
+
+
+ {r.name} + by {r.owner} +
+

{r.description || 'No description.'}

+
+ +
+ ))} +
+ )} + + {urlDialog && ( +
+
+
Install from URL
+

Paste a direct https link to a .rowboat-app bundle (e.g. a GitHub release asset).

+ setUrl(e.target.value)} + placeholder="https://github.com/owner/repo/releases/download/v1.0.0/name.rowboat-app" + className="mb-3 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:border-foreground/30" + /> +
+ + +
+
+
+ )} + + {preview && ( + void confirmInstall()} + onCancel={() => setPreview(null)} + /> + )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx new file mode 100644 index 00000000..fe249481 --- /dev/null +++ b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from 'react' +import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react' + +// Publish dialog (spec §14): device-flow sign-in → confirmation → step +// progress (§11.2 step names) → success links / typed error (name_taken gets +// an inline hint to rename and retry). + +type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error' + +export function PublishDialog({ folder, appName, onClose, onPublished }: { + folder: string + appName: string + onClose: () => void + onPublished: () => void +}) { + const [phase, setPhase] = useState('auth') + const [login, setLogin] = useState() + const [userCode, setUserCode] = useState('') + const [steps, setSteps] = useState([]) + const [error, setError] = useState(null) + const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null) + const pollTimer = useRef(null) + + useEffect(() => { + void (async () => { + const s = await window.ipc.invoke('githubAuth:status', {}) + if (s.signedIn) { + setLogin(s.login) + setPhase('confirm') + } + })() + return () => { if (pollTimer.current) window.clearInterval(pollTimer.current) } + }, []) + + useEffect(() => { + return window.ipc.on('apps:progress', ({ folder: f, step }) => { + if (f === folder) setSteps((prev) => (prev.includes(step) ? prev : [...prev, step])) + }) + }, [folder]) + + const startSignIn = async () => { + setError(null) + try { + const r = await window.ipc.invoke('githubAuth:start', {}) + setUserCode(r.userCode) + setPhase('device') + pollTimer.current = window.setInterval(() => { + void (async () => { + const p = await window.ipc.invoke('githubAuth:poll', {}) + if (p.status === 'authorized') { + if (pollTimer.current) window.clearInterval(pollTimer.current) + setLogin(p.login) + setPhase('confirm') + } else if (p.status === 'expired' || p.status === 'denied') { + if (pollTimer.current) window.clearInterval(pollTimer.current) + setError(p.status === 'expired' ? 'The code expired — start again.' : 'Authorization was denied.') + setPhase('auth') + } + })() + }, 5000) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + const publish = async () => { + setPhase('publishing') + setError(null) + setSteps([]) + try { + const r = await window.ipc.invoke('apps:publish', { folder }) + setResult(r) + setPhase('done') + onPublished() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + setPhase('error') + } + } + + const STEP_LABELS: Record = { + packaged: 'Packaged bundle', + repo_created: 'Created GitHub repo', + source_pushed: 'Pushed source', + release_created: 'Created release', + assets_uploaded: 'Uploaded assets', + registered: 'Opened registry PR', + polling: 'Waiting for registry validation…', + published: 'Published', + } + + return ( +
+
+
+ Publish {appName} +
+ + {error &&
+ {error} + {error.includes('name_taken') &&
That name is taken — rename the app in rowboat-app.json and retry.
} +
} + + {phase === 'auth' && ( +
+

Publishing creates a public GitHub repo under your account, uploads the app as a release, and lists it in the Rowboat catalog. A generated MIT LICENSE is added if your app has none.

+ +
+ )} + + {phase === 'device' && ( +
+

Enter this code on the GitHub page that just opened:

+
{userCode}
+

Waiting for authorization…

+
+ )} + + {phase === 'confirm' && ( +
+

Signed in as @{login}. This will publish {appName} publicly.

+ +
+ )} + + {(phase === 'publishing' || phase === 'done' || phase === 'error') && ( +
+ {Object.entries(STEP_LABELS).map(([key, label]) => { + const doneStep = steps.includes(key) || phase === 'done' + const active = phase === 'publishing' && !doneStep && steps[steps.length - 1] !== key + if (key === 'published' && phase !== 'done') return null + return ( +
+ {doneStep + ? + : phase === 'error' + ? + : } + {label} +
+ ) + })} + {phase === 'done' && result && ( +
+ + + {result.status === 'pending' && result.prUrl && ( +
Registry validation still pending — track it at the PR.
+ )} +
+ )} +
+ )} + +
+ +
+
+
+ ) +} diff --git a/apps/x/docs/publishing-apps.md b/apps/x/docs/publishing-apps.md new file mode 100644 index 00000000..46a3cdae --- /dev/null +++ b/apps/x/docs/publishing-apps.md @@ -0,0 +1,74 @@ +# Publishing Rowboat Apps — advanced path + +Most authors should use the in-app guided publish (Apps → app detail → +Publish): it creates the repo, pushes source, cuts the release, and registers +the app automatically. This doc is for developers who manage their own repo, +build pipeline, or monorepo and want to publish releases themselves. + +## The bundle format (`.rowboat-app`) + +A `.rowboat-app` file is a ZIP named `.rowboat-app` containing, at the +archive root (relative, forward-slash paths): + +``` +rowboat-app.json # the manifest (required) +dist/** # browser-ready static files (required; dist/ must exist) +agents/.yaml # only files listed in manifest.agents +defaults/** # optional starter data, copied to data/ on first install +``` + +Rules: + +- Never include `src/`, `package.json`, `node_modules/`, `data/`, dotfiles, or + `.rowboat-*.json`. Installers enforce size limits (100 MB compressed, 500 MB + uncompressed, 10,000 entries) and reject symlink entries and unsafe paths. +- `manifest.name` must match the bundle filename stem and, once registered, + never changes. `version` is strict semver (`MAJOR.MINOR.PATCH`). +- Agent definitions may contain ONLY `name`, `instructions`, `triggers` — + runtime state, `active`, and model overrides are rejected. + +## The two-asset requirement + +Every release on a registered repo MUST attach **both** assets: + +1. `.rowboat-app` — the bundle +2. `rowboat-app.json` — a standalone copy of the manifest + +The standalone manifest powers Rowboat's quota-free update check (it is +fetched via `releases/latest/download/rowboat-app.json`). + +## Tag convention + +Tag releases `v` (e.g. `v1.2.0`), matching `manifest.version`. + +## Registry record + +The registry (`rowboatlabs/apps-registry`) holds one record per app at +`apps/.json`: + +```json +{ + "schemaVersion": 1, + "name": "my-app", + "owner": "your-github-login", + "repo": "your-github-login/my-app", + "description": "What the app does", + "iconUrl": "https://raw.githubusercontent.com/you/my-app/HEAD/dist/icon.png", + "createdAt": "2026-07-06T00:00:00Z" +} +``` + +Register either via the in-app form (Apps → Catalog → Register existing +release) or by opening the one-file PR yourself. A validation Action checks: +the PR adds exactly that one file; the name is valid, unique, and not retired; +`owner` equals the PR author; the author has push access to `repo`; and +`releases/latest/download/.rowboat-app` exists. It auto-merges on +success or closes the PR with a `rejected: ` comment. + +## The latest-release constraint (monorepos) + +Version discovery always reads the repo's **latest** release. A monorepo +registering multiple apps therefore works only if **every** release attaches +**every** registered app's asset pair — otherwise resolution for the other +apps breaks whenever any one app releases. One repo per app avoids this +entirely and is what the guided path does.