diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 6c60ea63..febe3e91 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -67,6 +67,7 @@ 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 appsStars from '@x/core/dist/apps/stars.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'; @@ -1616,6 +1617,18 @@ export function setupIpcHandlers() { 'apps:catalogSearch': async (_event, args) => { return { records: await registryClient.search(args.query) }; }, + 'apps:catalogStars': async (_event, args) => { + const [stars, starred] = await Promise.all([ + appsStars.repoStars(args.repos), + appsStars.starredStatus(args.repos), + ]); + return { stars, starred }; + }, + 'apps:star': async (_event, args) => { + const result = await appsStars.setStar(args.repo, args.star); + capture('app_starred', { repo: args.repo, star: args.star }); + return result; + }, '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}`); 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 6e8aa554..5304c81d 100644 --- a/apps/x/apps/renderer/src/components/apps/app-detail.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-detail.tsx @@ -81,6 +81,15 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => onClose() }) + // Local apps aren't "installed", so they get delete instead of uninstall. + const doDelete = () => runAction('delete', async () => { + const agentNote = agents.length ? `\n\nThis also deletes its background agents: ${agents.map((a) => a.name).join(', ')}.` : '' + const publishNote = app?.publish ? '\n\nThe published copy (GitHub repo + catalog listing) is not touched.' : '' + if (!window.confirm(`Delete this app? The whole folder, including data/, is removed from this machine.${publishNote}${agentNote}`)) return + await window.ipc.invoke('apps:delete', { folder }) + onClose() + }) + useEffect(() => { let cancelled = false ;(async () => { @@ -217,11 +226,16 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => {app.publish ? 'Publish update' : 'Publish'} )} - {app.kind === 'installed' && ( + {app.kind === 'installed' ? ( 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"> Uninstall + ) : ( + void doDelete()} + 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"> + Delete + )} diff --git a/apps/x/apps/renderer/src/components/apps/catalog.tsx b/apps/x/apps/renderer/src/components/apps/catalog.tsx index 99bdda2c..3741f56a 100644 --- a/apps/x/apps/renderer/src/components/apps/catalog.tsx +++ b/apps/x/apps/renderer/src/components/apps/catalog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react' +import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert, Star } from 'lucide-react' import type { rowboatApp } from '@x/shared' // Catalog tab (spec §14): search the registry, install with the D18 capability @@ -162,6 +162,35 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v const [enabling, setEnabling] = useState(false) // Registry name → local folder, for apps already installed from the catalog. const [installedByName, setInstalledByName] = useState>(new Map()) + // GitHub star counts rank the list; `starred` is the signed-in user's set. + const [stars, setStars] = useState>({}) + const [starred, setStarred] = useState>({}) + + const loadStars = async (recs: rowboatApp.RegistryRecord[]) => { + if (recs.length === 0) return + try { + const r = await window.ipc.invoke('apps:catalogStars', { repos: recs.map((x) => x.repo) }) + setStars((prev) => ({ ...prev, ...r.stars })) + setStarred((prev) => ({ ...prev, ...r.starred })) + } catch { /* unranked list is fine */ } + } + + const toggleStar = async (repo: string) => { + const next = !starred[repo] + // Optimistic; revert on failure. + setStarred((prev) => ({ ...prev, [repo]: next })) + setStars((prev) => ({ ...prev, [repo]: Math.max(0, (prev[repo] ?? 0) + (next ? 1 : -1)) })) + try { + await window.ipc.invoke('apps:star', { repo, star: next }) + } catch (e) { + setStarred((prev) => ({ ...prev, [repo]: !next })) + setStars((prev) => ({ ...prev, [repo]: Math.max(0, (prev[repo] ?? 0) + (next ? -1 : 1)) })) + const msg = e instanceof Error ? e.message : String(e) + setError(msg.includes('not_signed_in') + ? 'Starring uses your GitHub account — sign in once via any app’s Publish flow, then try again.' + : msg) + } + } const loadInstalled = async () => { try { @@ -179,6 +208,7 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v const r = await window.ipc.invoke('apps:catalogIndex', { force }) setRecords(r.records) setStale(r.stale) + void loadStars(r.records) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } @@ -192,9 +222,14 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v ? await window.ipc.invoke('apps:catalogSearch', { query: q }) : await window.ipc.invoke('apps:catalogIndex', {}) setRecords(r.records) + void loadStars(r.records) } catch { /* keep current list */ } } + // Rank by stars (unknown counts sink), name as the stable tiebreak. + const ranked = [...records].sort((a, b) => + ((stars[b.repo] ?? -1) - (stars[a.repo] ?? -1)) || a.name.localeCompare(b.name)) + const startInstall = async (name: string) => { setError(null) try { @@ -304,7 +339,7 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v ) : ( - {records.map((r) => ( + {ranked.map((r) => ( @@ -313,6 +348,13 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v {r.description || 'No description.'} + void toggleStar(r.repo)} + className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium hover:bg-accent ${starred[r.repo] ? 'text-amber-500' : 'text-muted-foreground'}`}> + + {stars[r.repo] ?? '—'} + {installedByName.has(r.name) ? ( onInstalled(installedByName.get(r.name)!)} diff --git a/apps/x/packages/core/src/apps/stars.ts b/apps/x/packages/core/src/apps/stars.ts new file mode 100644 index 00000000..bf48c073 --- /dev/null +++ b/apps/x/packages/core/src/apps/stars.ts @@ -0,0 +1,81 @@ +import { getGithubToken } from './github-auth.js'; + +// GitHub stars for catalog apps. Star counts rank the catalog; starring an +// app stars its repo (the device-flow token's public_repo scope covers it). +// Counts are fetched per-repo with a short cache — unauthenticated GitHub API +// allows 60 req/h, so the cache is what keeps a busy catalog usable when the +// user never signed in. + +const COUNT_TTL_MS = 10 * 60 * 1000; +const countCache = new Map(); + +const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + +async function ghHeaders(): Promise> { + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'rowboat-apps', + }; + const auth = await getGithubToken(); + if (auth) headers['Authorization'] = `Bearer ${auth.token}`; + return headers; +} + +/** Star counts for a set of "owner/repo" strings. Unknown/unreachable repos are omitted. */ +export async function repoStars(repos: string[]): Promise> { + const headers = await ghHeaders(); + const now = Date.now(); + const out: Record = {}; + await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => { + const cached = countCache.get(repo); + if (cached && now - cached.at < COUNT_TTL_MS) { + out[repo] = cached.stars; + return; + } + try { + const res = await fetch(`https://api.github.com/repos/${repo}`, { headers }); + if (!res.ok) return; // deleted repo / rate limited — no count is fine + const body = await res.json() as { stargazers_count?: number }; + if (typeof body.stargazers_count === 'number') { + countCache.set(repo, { stars: body.stargazers_count, at: now }); + out[repo] = body.stargazers_count; + } + } catch { /* offline — no count */ } + })); + return out; +} + +/** Which of these repos the signed-in user has starred. Empty when signed out. */ +export async function starredStatus(repos: string[]): Promise> { + const auth = await getGithubToken(); + if (!auth) return {}; + const headers = await ghHeaders(); + const out: Record = {}; + await Promise.all([...new Set(repos)].filter((r) => REPO_RE.test(r)).map(async (repo) => { + try { + const res = await fetch(`https://api.github.com/user/starred/${repo}`, { headers }); + if (res.status === 204) out[repo] = true; + else if (res.status === 404) out[repo] = false; + // other statuses (401 revoked token, 403 rate limit) → unknown, omit + } catch { /* offline — unknown */ } + })); + return out; +} + +/** Star/unstar a repo as the signed-in user. Throws not_signed_in without a token. */ +export async function setStar(repo: string, star: boolean): Promise<{ starred: boolean }> { + if (!REPO_RE.test(repo)) throw new Error(`invalid repo: ${repo}`); + const auth = await getGithubToken(); + if (!auth) throw new Error('not_signed_in: sign in with GitHub (used for publishing) to star apps'); + const res = await fetch(`https://api.github.com/user/starred/${repo}`, { + method: star ? 'PUT' : 'DELETE', + headers: { ...(await ghHeaders()), 'Content-Length': '0' }, + }); + if (res.status !== 204) { + throw new Error(`star_failed: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim()); + } + // Nudge the cached count so the UI reflects the action before the TTL expires. + const cached = countCache.get(repo); + if (cached) countCache.set(repo, { stars: Math.max(0, cached.stars + (star ? 1 : -1)), at: cached.at }); + return { starred: star }; +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 6ac706d3..24a9fbcc 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -1272,6 +1272,18 @@ const ipcSchemas = { req: z.object({ query: z.string() }), res: z.object({ records: z.array(RegistryRecordSchema) }), }, + // GitHub star counts (catalog ranking) + the signed-in user's starred set. + 'apps:catalogStars': { + req: z.object({ repos: z.array(z.string()) }), + res: z.object({ + stars: z.record(z.string(), z.number()), + starred: z.record(z.string(), z.boolean()), + }), + }, + 'apps:star': { + req: z.object({ repo: z.string(), star: z.boolean() }), + res: z.object({ starred: z.boolean() }), + }, 'apps:catalogDetail': { req: z.object({ name: z.string() }), res: z.object({
{r.description || 'No description.'}