mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/disk-skills
This commit is contained in:
commit
3fffe12978
22 changed files with 2642 additions and 47 deletions
|
|
@ -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 §12–13)
|
||||
'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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
231
apps/x/apps/renderer/src/components/apps/catalog.tsx
Normal file
231
apps/x/apps/renderer/src/components/apps/catalog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
apps/x/apps/renderer/src/components/apps/publish-dialog.tsx
Normal file
177
apps/x/apps/renderer/src/components/apps/publish-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue