Merge remote-tracking branch 'upstream/dev' into feat/disk-skills

This commit is contained in:
Prakhar Pandey 2026-07-06 22:37:30 +05:30
commit 3fffe12978
22 changed files with 2642 additions and 47 deletions

View file

@ -66,6 +66,13 @@ import * as appsIndexer from '@x/core/dist/apps/indexer.js';
import * as appsServer from '@x/core/dist/apps/server.js';
import * as appsAgents from '@x/core/dist/apps/agents.js';
import { capture } from '@x/core/dist/analytics/posthog.js';
import * as githubAuth from '@x/core/dist/apps/github-auth.js';
import * as appsInstaller from '@x/core/dist/apps/installer.js';
import { registryClient } from '@x/core/dist/apps/registry.js';
import * as appsPublisher from '@x/core/dist/apps/publisher.js';
// D18 install previews awaiting confirmation, keyed by app name.
const appInstallPreviews = new Map<string, Awaited<ReturnType<typeof appsInstaller.previewInstall>>>();
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';
@ -1558,6 +1565,9 @@ export function setupIpcHandlers() {
return qualifyAndDisconnectComposioGoogle();
},
// Rowboat Apps handlers (spec §13)
'apps:serverStatus': async () => {
return appsServer.getServerStatus();
},
'apps:list': async () => {
const status = appsServer.getServerStatus();
const apps = await appsIndexer.listApps();
@ -1598,6 +1608,109 @@ export function setupIpcHandlers() {
appsServer.setAppsTheme(args.theme);
return { ok: true as const };
},
// GitHub auth (device flow) — publishing only
// Catalog + install/update (spec §1213)
'apps:catalogIndex': async (_event, args) => {
return registryClient.refreshIndex(args.force);
},
'apps:catalogSearch': async (_event, args) => {
return { records: await registryClient.search(args.query) };
},
'apps:catalogDetail': async (_event, args) => {
const record = await registryClient.resolve(args.name);
if (!record) throw new Error(`no such app in the catalog: ${args.name}`);
let manifest;
try { manifest = await registryClient.latestManifest(record); } catch { /* best effort */ }
let readme: string | undefined;
try {
const res = await fetch(`https://raw.githubusercontent.com/${record.repo}/HEAD/README.md`);
if (res.ok) readme = await res.text();
} catch { /* best effort */ }
const installed = (await appsIndexer.listApps()).find((a) => a.install?.name === args.name);
return {
record,
...(manifest ? { manifest } : {}),
...(readme ? { readme } : {}),
...(installed ? { installedFolder: installed.folder } : {}),
};
},
'apps:install': async (_event, args) => {
const record = await registryClient.resolve(args.name);
if (!record) throw new Error(`no such app in the catalog: ${args.name}`);
if (!args.confirmed) {
const preview = await appsInstaller.previewInstall(record);
appInstallPreviews.set(args.name, preview);
return preview;
}
// D18: the confirmed phase checks the bundle against what was previewed.
const preview = appInstallPreviews.get(args.name) ?? await appsInstaller.previewInstall(record);
const result = await appsInstaller.installFromRegistry(record, preview);
appInstallPreviews.delete(args.name);
capture('app_installed', { name: args.name });
return result;
},
'apps:installFromUrl': async (_event, args) => {
if (!args.confirmed) {
return appsInstaller.previewUrlInstall(args.url);
}
const result = await appsInstaller.confirmUrlInstall(args.url);
capture('app_installed', { name: result.app.manifest?.name ?? result.app.folder });
return result;
},
'apps:uninstall': async (_event, args) => {
await appsInstaller.uninstallApp(args.folder);
capture('app_uninstalled', { folder: args.folder });
return { ok: true as const };
},
'apps:checkUpdate': async (_event, args) => {
return appsInstaller.checkUpdate(args.folder);
},
'apps:update': async (_event, args) => {
const before = (await appsIndexer.getApp(args.folder))?.manifest?.version;
const app = await appsInstaller.updateApp(args.folder, {
confirmOverwriteModified: args.confirmOverwriteModified,
confirmNewCapabilities: args.confirmNewCapabilities,
});
capture('app_updated', { from: before, to: app.manifest?.version });
return { app };
},
'apps:rollback': async (_event, args) => {
return { app: await appsInstaller.rollbackApp(args.folder) };
},
'apps:publish': async (event, args) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await appsPublisher.publishApp(args.folder, (step, detail) => {
win?.webContents.send('apps:progress', { folder: args.folder, step, detail });
});
capture('app_published', { firstPublish: true });
return result;
},
'apps:publishUpdate': async (_event, args) => {
const result = await appsPublisher.publishUpdate(args.folder, args.increment);
capture('app_published', { version: result.version, firstPublish: false });
return result;
},
'apps:registerExisting': async (_event, args) => {
return appsPublisher.registerExisting(args.name, args.repo);
},
'githubAuth:start': async () => {
const result = await githubAuth.startDeviceFlow();
// Surface the code and open GitHub's verification page externally (§10).
void shell.openExternal(result.verificationUri);
return result;
},
'githubAuth:poll': async () => {
const result = await githubAuth.pollDeviceFlow();
console.log(`[GitHubAuth] poll result → ${result.status}`);
return result;
},
'githubAuth:status': async () => {
return githubAuth.getAuthStatus();
},
'githubAuth:signOut': async () => {
await githubAuth.clearAuth();
return { ok: true as const };
},
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, type Session } from "electron";
import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, safeStorage, type Session } from "electron";
import path from "node:path";
import {
setupIpcHandlers,
@ -39,6 +39,7 @@ import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event
import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js";
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
@ -342,6 +343,16 @@ function createWindow() {
}
}
// Renderer/child process deaths are otherwise silent in packaged builds (the
// window or an app iframe just goes blank). Log the reason so crash reports
// can be correlated with what Chromium thought happened.
app.on('render-process-gone', (_event, webContents, details) => {
console.error(`[Crash] renderer gone: reason=${details.reason} exitCode=${details.exitCode} url=${webContents.getURL()}`);
});
app.on('child-process-gone', (_event, details) => {
console.error(`[Crash] child process gone: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? ''} name=${details.name ?? ''}`);
});
app.whenReady().then(async () => {
// Register custom protocol before creating window.
// In production this serves the renderer SPA; in dev (and prod) it also
@ -383,6 +394,24 @@ app.whenReady().then(async () => {
setupIpcHandlers();
setupBrowserEventForwarding();
// Start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) BEFORE
// the window and the long service-init chain below. The Apps view is
// reachable as soon as the window paints; starting the server last meant
// every app iframe hit connection-refused (blank app) for the first ~10s of
// each launch. Route registration and the token cipher are synchronous;
// the listen itself is fire-and-forget.
registerAppsHostApi();
// GitHub publish token at rest: encrypt via the OS keychain when available
// (core stays electron-free; the cipher is injected here).
setGithubTokenCipher({
isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
});
initAppsServer().catch((error) => {
console.error('[Apps] Failed to start:', error);
});
createWindow();
// Start workspace watcher as a main-process service
@ -507,13 +536,6 @@ app.whenReady().then(async () => {
// start chrome extension sync server
initChromeSync();
// start the Rowboat Apps server (per-app origins on 127.0.0.1:3210) with the
// full Host API (tools/fetch/llm/copilot behind the capability gate)
registerAppsHostApi();
initAppsServer().catch((error) => {
console.error('[Apps] Failed to start:', error);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();

View file

@ -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<AgentRow[]>([])
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string; updateAvailable: boolean } | null>(null)
const [showPublish, setShowPublish] = useState(false)
const [busyAction, setBusyAction] = useState<string | null>(null)
const runAction = async (name: string, fn: () => Promise<string | void>) => {
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: () =>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 text-sm">
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">{error}</div>}
{notice && <div className="mb-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-muted-foreground">{notice}</div>}
{!app ? (
<div className="text-muted-foreground">Loading</div>
) : (
@ -120,14 +180,50 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
<InfoRow k="Installed" v={new Date(app.install.installedAt).toLocaleString()} />
{app.install.updatedAt && <InfoRow k="Updated" v={new Date(app.install.updatedAt).toLocaleString()} />}
</>
) : app.publish ? (
<>
<p className="text-muted-foreground">Local app published to the Rowboat catalog.</p>
<InfoRow k="Repository" v={app.publish.repo} mono link={`https://github.com/${app.publish.repo}`} />
{app.publish.lastPublishedVersion && (
<InfoRow
k="Published"
v={`v${app.publish.lastPublishedVersion}`}
link={`https://github.com/${app.publish.repo}/releases/tag/v${app.publish.lastPublishedVersion}`}
/>
)}
</>
) : (
<p className="text-muted-foreground">Local app created on this machine{app.publish ? `, published as ${app.publish.repo}` : ''}.</p>
)}
{rollback && (
<button type="button" className="mt-1 flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent">
<RotateCcw className="size-3.5" /> Roll back to previous version
</button>
<p className="text-muted-foreground">Local app created on this machine.</p>
)}
<div className="flex flex-wrap gap-2 pt-1">
{app.kind === 'installed' && app.install?.repo && (
<button type="button" disabled={busyAction !== null}
onClick={() => void (updateInfo?.updateAvailable ? doUpdate() : checkForUpdate())}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<ArrowUpCircle className="size-3.5" />
{busyAction === 'check' || busyAction === 'update' ? 'Working…'
: updateInfo?.updateAvailable ? `Update to v${updateInfo.latest}` : 'Check for update'}
</button>
)}
{rollback && (
<button type="button" disabled={busyAction !== null} onClick={() => void doRollback()}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<RotateCcw className="size-3.5" /> Roll back
</button>
)}
{app.kind === 'local' && (
<button type="button" disabled={busyAction !== null} onClick={() => setShowPublish(true)}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50">
<UploadCloud className="size-3.5" /> {app.publish ? 'Publish update' : 'Publish'}
</button>
)}
{app.kind === 'installed' && (
<button type="button" disabled={busyAction !== null} onClick={() => void doUninstall()}
className="flex items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium text-destructive hover:bg-destructive/10 disabled:opacity-50">
<Trash2 className="size-3.5" /> Uninstall
</button>
)}
</div>
</section>
<section className="space-y-2">
@ -172,15 +268,34 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
</div>
)}
</div>
{showPublish && app && (
<PublishDialog
folder={folder}
appName={manifest?.name ?? folder}
onClose={() => setShowPublish(false)}
onPublished={() => setReloadNonce((n) => n + 1)}
/>
)}
</div>
)
}
function InfoRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
function InfoRow({ k, v, mono, link }: { k: string; v: string; mono?: boolean; link?: string }) {
return (
<div className="flex items-baseline gap-2">
<span className="w-28 shrink-0 text-xs text-muted-foreground">{k}</span>
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
{link ? (
<a
href={link}
target="_blank"
rel="noreferrer"
className={`min-w-0 truncate text-primary hover:underline ${mono ? 'font-mono text-xs' : ''}`}
>
{v}
</a>
) : (
<span className={`min-w-0 truncate ${mono ? 'font-mono text-xs' : ''}`}>{v}</span>
)}
</div>
)
}

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { ArrowLeft, ExternalLink, Info, RotateCw } from 'lucide-react'
import { ArrowLeft, BadgeCheck, ExternalLink, Info, RotateCw, UploadCloud } from 'lucide-react'
import type { rowboatApp } from '@x/shared'
import { appOpened } from '@/lib/analytics'
import { AppDetail } from '@/components/apps/app-detail'
import { PublishDialog } from '@/components/apps/publish-dialog'
// Full-height iframe on the app's own origin (spec §6.6). No sandbox attr —
// per-app browser origins are the isolation boundary. Toolbar: back, reload,
@ -11,6 +12,7 @@ import { AppDetail } from '@/components/apps/app-detail'
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
const [reloadNonce, setReloadNonce] = useState(0)
const [showDetail, setShowDetail] = useState(false)
const [showPublish, setShowPublish] = useState(false)
// Load watchdog: if the iframe hasn't fired `load` within the deadline,
// surface a visible retry state instead of a silent blank pane.
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'stuck'>('loading')
@ -34,6 +36,20 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
return () => window.clearTimeout(timer)
}, [watchKey])
// Stuck diagnosis: the usual cause is the apps server not being reachable
// yet (it starts with the main process; on a fresh launch or a quick
// relaunch the first iframe load can beat it). Say when the server itself
// is the problem.
const [serverDown, setServerDown] = useState(false)
useEffect(() => {
if (loadState !== 'stuck') return
let cancelled = false
void window.ipc.invoke('apps:serverStatus', {}).then((s) => {
if (!cancelled) setServerDown(!s.running)
}).catch(() => { /* status probe is best-effort */ })
return () => { cancelled = true }
}, [loadState])
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
@ -62,6 +78,27 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
>
<ExternalLink className="size-4" />
</button>
{app.kind === 'local' && (
app.publish ? (
<button
type="button"
title={`Published as ${app.publish.repo} — view details`}
onClick={() => setShowDetail(true)}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500"
>
<BadgeCheck className="size-4" /> Published
</button>
) : (
<button
type="button"
title="Publish this app"
onClick={() => setShowPublish(true)}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
<UploadCloud className="size-4" /> Publish
</button>
)
)}
<button
type="button"
title="App details"
@ -82,7 +119,9 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
/>
{loadState === 'stuck' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
<div className="text-muted-foreground">This app is taking too long to load.</div>
<div className="text-muted-foreground">
{serverDown ? 'The Rowboat apps server is still starting up.' : 'This app is taking too long to load.'}
</div>
<button
type="button"
onClick={() => setReloadNonce((n) => n + 1)}
@ -90,6 +129,9 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
>
Retry
</button>
<div className="max-w-xs text-center text-xs text-muted-foreground">
If it still doesn&apos;t load, go back to Apps and open it again.
</div>
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
</div>
)}
@ -100,6 +142,14 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
</div>
)}
</div>
{showPublish && (
<PublishDialog
folder={app.folder}
appName={title}
onClose={() => setShowPublish(false)}
onPublished={() => setShowDetail(true)}
/>
)}
</div>
)
}

View file

@ -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' ? (
<div className="ma-empty">The community catalog is coming soon.</div>
<CatalogTab onInstalled={(folder) => { setSelectedFolder(folder); setTab('mine') }} />
) : (
<div className="ma-grid">
{apps.map((app, i) => (

View file

@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-1 text-base font-semibold">Install {preview.name} v{preview.version}?</div>
{preview.description && <p className="mb-3 text-sm text-muted-foreground">{preview.description}</p>}
<div className="mb-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<div className="mb-1.5 flex items-center gap-1.5 font-medium">
<ShieldAlert className="size-4 text-amber-500" /> This app will be able to:
</div>
{caps.length === 0 ? (
<p className="text-muted-foreground">Nothing it declares no capabilities (no tools, LLM, or copilot access).</p>
) : (
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
{caps.map((c) => <li key={c}><span className="font-medium text-foreground">{c}</span>: {capabilityDescription(c)}</li>)}
</ul>
)}
{agents.length > 0 && (
<div className="mt-2">
<div className="font-medium">Bundled background agents (installed disabled):</div>
<ul className="list-inside list-disc text-muted-foreground">
{agents.map((a) => <li key={a}>{a}</li>)}
</ul>
</div>
)}
</div>
{preview.updateSource === 'none' && (
<p className="mb-3 text-xs text-amber-600 dark:text-amber-400">Installed from a direct URL updates will be unavailable.</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
<button type="button" onClick={onConfirm} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
{busy ? 'Installing…' : 'Install'}
</button>
</div>
</div>
</div>
)
}
export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) {
const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([])
const [stale, setStale] = useState(false)
const [query, setQuery] = useState('')
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<Preview | null>(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 (
<div>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => 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"
/>
</div>
<button type="button" title="Install from URL"
onClick={() => setUrlDialog(true)}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm font-medium hover:bg-accent">
<Link2 className="size-4" /> From URL
</button>
{stale && (
<button type="button" onClick={() => void load(true)}
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm font-medium">
<RefreshCw className="size-4" /> Stale refresh
</button>
)}
</div>
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</div>}
{records.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{query ? 'No apps match your search.' : 'No apps in the catalog yet — be the first to publish one.'}
</div>
) : (
<div className="space-y-2">
{records.map((r) => (
<div key={r.name} className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="truncate text-sm font-semibold">{r.name}</span>
<span className="text-xs text-muted-foreground">by {r.owner}</span>
</div>
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
</div>
<button type="button" onClick={() => void startInstall(r.name)}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
<Download className="size-4" /> Install
</button>
</div>
))}
</div>
)}
{urlDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-2 text-base font-semibold">Install from URL</div>
<p className="mb-3 text-sm text-muted-foreground">Paste a direct https link to a <code>.rowboat-app</code> bundle (e.g. a GitHub release asset).</p>
<input
value={url}
onChange={(e) => 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"
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setUrlDialog(false)}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Cancel</button>
<button type="button" onClick={() => void startUrlPreview()} disabled={!url.trim().startsWith('https://')}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
Preview
</button>
</div>
</div>
</div>
)}
{preview && (
<InstallConfirmDialog
preview={preview}
busy={busy}
onConfirm={() => void confirmInstall()}
onCancel={() => setPreview(null)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,177 @@
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<Phase>('auth')
const [login, setLogin] = useState<string | undefined>()
const [userCode, setUserCode] = useState('')
const [steps, setSteps] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null)
const pollTimer = useRef<number | null>(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')
}
})().catch((e: unknown) => {
// A hard failure (bad client config, network) must surface, not spin.
if (pollTimer.current) window.clearInterval(pollTimer.current)
setError(e instanceof Error ? e.message : String(e))
setPhase('auth')
})
// Heartbeat only — core paces the actual GitHub requests to the
// flow's required interval (and skips the request when it's too soon).
}, 2000)
} 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<string, string> = {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-3 flex items-center gap-2 text-base font-semibold">
<Github className="size-5" /> Publish {appName}
</div>
{error && <div className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
{error.includes('name_taken') && <div className="mt-1 text-xs">That name is taken rename the app in <code>rowboat-app.json</code> and retry.</div>}
</div>}
{phase === 'auth' && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">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.</p>
<button type="button" onClick={() => void startSignIn()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Sign in with GitHub
</button>
</div>
)}
{phase === 'device' && (
<div className="space-y-3 text-center text-sm">
<p className="text-muted-foreground">Enter this code on the GitHub page that just opened:</p>
<div className="select-all rounded-lg border border-border bg-muted/40 py-3 font-mono text-2xl font-bold tracking-widest">{userCode}</div>
<p className="flex items-center justify-center gap-2 text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Waiting for authorization</p>
</div>
)}
{phase === 'confirm' && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">Signed in as <span className="font-medium text-foreground">@{login}</span>. This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</p>
<button type="button" onClick={() => void publish()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Publish
</button>
</div>
)}
{(phase === 'publishing' || phase === 'done' || phase === 'error') && (
<div className="space-y-1.5 text-sm">
{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 (
<div key={key} className="flex items-center gap-2">
{doneStep
? <CheckCircle2 className="size-4 text-green-600" />
: phase === 'error'
? <XCircle className="size-4 text-muted-foreground/40" />
: <Loader2 className={`size-4 ${active ? 'text-muted-foreground/40' : 'animate-spin text-muted-foreground'}`} />}
<span className={doneStep ? '' : 'text-muted-foreground'}>{label}</span>
</div>
)
})}
{phase === 'done' && result && (
<div className="mt-3 space-y-1 text-xs">
<div>Repo: <a className="text-primary underline" href={result.repoUrl} target="_blank" rel="noreferrer">{result.repoUrl}</a></div>
<div>Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
{result.status === 'pending' && result.prUrl && (
<div className="text-amber-600 dark:text-amber-400">Registry validation still pending track it at <a className="underline" href={result.prUrl} target="_blank" rel="noreferrer">the PR</a>.</div>
)}
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end">
<button type="button" onClick={onClose}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
{phase === 'done' ? 'Done' : 'Close'}
</button>
</div>
</div>
</div>
)
}