feat(store): console plugin store, index repo, and the fixes on-glass found
apple / swift (push) Successful in 1m22s
release / apple (push) Successful in 9m59s
windows-host / package (push) Successful in 9m52s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m20s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m39s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m45s
audit / cargo-audit (push) Successful in 2m58s
audit / bun-audit (push) Successful in 13s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 49s
flatpak / build-publish (push) Successful in 7m53s
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Successful in 11m41s
deb / build-publish-host (push) Successful in 13m32s

Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.

Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.

Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
  scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
  own framework: it listed as installed and would have been imported as a
  unit. Both now key off the plugins dir's top-level dependencies, with an
  emptied dependency list meaning 'nothing installed' rather than falling
  back to the naming convention
- the store must not pass new flags to the runner: the scripting package
  ships separately and an older one reads an unknown flag's value as a
  package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
  as Ok with an empty body — handled as an error it made every refresh
  after the first verify a signature over zero bytes and sit stale

Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 20:48:02 +02:00
co-authored by Claude Fable 5
parent 45c3b96907
commit 833f3348a0
30 changed files with 4028 additions and 21 deletions
+227
View File
@@ -0,0 +1,227 @@
import { AlertTriangle, Ban, Check, Download, Search } from "lucide-react";
import { type FC, useMemo, useState } from "react";
import { pluginIcon } from "@/api/plugins";
import { type StoreEntry, useStoreCatalog } from "@/api/store";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
import { RunnerBanner } from "./Runner";
import { SourceChip, TierBadge } from "./TierBadge";
/** Case-insensitive substring match across the fields an operator would actually search by. */
function matches(entry: StoreEntry, needle: string): boolean {
if (!needle) return true;
const q = needle.toLowerCase();
return [entry.title, entry.description, entry.pkg, entry.author].some((f) =>
f.toLowerCase().includes(q),
);
}
/**
* Container: the catalog. Owns the catalog query plus the local search/source filter; installing is
* escalated to the parent, which owns the tier-appropriate dialog and the resulting job — so this
* subsection never installs anything itself.
*/
export const BrowseTab: FC<{
onInstall: (entry: StoreEntry) => void;
onInstallSpec: () => void;
}> = ({ onInstall, onInstallSpec }) => {
const catalog = useStoreCatalog();
const [query, setQuery] = useState("");
const [source, setSource] = useState<string | null>(null);
const entries = catalog.data?.plugins ?? [];
const sources = catalog.data?.sources ?? [];
const shown = useMemo(
() =>
entries.filter(
(e) => (source === null || e.source === source) && matches(e, query),
),
[entries, source, query],
);
return (
<div className="flex flex-col gap-card">
<RunnerBanner />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative sm:max-w-xs sm:flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
className="pl-9"
aria-label={m.store_search_placeholder()}
placeholder={m.store_search_placeholder()}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{/* One chip per source, so an operator can see a third-party catalog's entries alone. */}
{sources.length > 1 && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={source === null ? "default" : "outline"}
aria-pressed={source === null}
onClick={() => setSource(null)}
>
{m.store_filter_all()}
</Button>
{sources.map((s) => (
<Button
key={s.name}
size="sm"
variant={source === s.name ? "default" : "outline"}
aria-pressed={source === s.name}
onClick={() => setSource(s.name)}
>
{s.name}
</Button>
))}
</div>
)}
</div>
<QueryState
isLoading={catalog.isLoading}
error={catalog.error}
refetch={catalog.refetch}
>
{shown.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{entries.length === 0 ? m.store_empty() : m.store_no_match()}
</CardContent>
</Card>
) : (
<div className="@container">
<div className="grid grid-cols-1 gap-card @xl:grid-cols-2 @4xl:grid-cols-3">
{shown.map((entry) => (
<StoreCard
key={`${entry.source}/${entry.id}`}
entry={entry}
onInstall={() => onInstall(entry)}
/>
))}
</div>
</div>
)}
</QueryState>
{/* The ONLY way to the raw-spec install. Deliberately a quiet footer link, not a button on
a card: an unverified install should take a decision, never a stray click. */}
<div className="text-center">
<button
type="button"
onClick={onInstallSpec}
className="text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
{m.store_spec_open()}
</button>
</div>
</div>
);
};
/** One catalog entry. Blocked entries shout; incompatible ones grey out; neither can be installed. */
export const StoreCard: FC<{ entry: StoreEntry; onInstall: () => void }> = ({
entry,
onInstall,
}) => {
const Icon = pluginIcon(entry.icon);
const blocked = entry.blocked !== undefined;
const installed = entry.installed_version !== undefined;
const installable = !blocked && entry.compatible;
return (
<Card
className={cn(
"flex flex-col",
blocked && "ring-2 ring-destructive/60",
!entry.compatible && !blocked && "opacity-60",
)}
>
<CardContent className="flex flex-1 flex-col gap-3 p-card">
<div className="flex items-start gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-md bg-primary/15">
<Icon className="size-5 text-foreground" />
</span>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium" title={entry.title}>
{entry.title}
</h3>
<p className="truncate text-xs text-muted-foreground">
{m.store_by_author({ author: entry.author })} · v{entry.version}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<TierBadge tier={entry.tier} />
{/* Attribution, never verification: an external entry names who curated it. */}
{entry.tier === "external" && <SourceChip source={entry.source} />}
</div>
<p className="line-clamp-3 text-sm text-muted-foreground">
{entry.description}
</p>
<div className="flex flex-wrap gap-1.5">
{entry.platforms.map((p) => (
<Badge key={p} variant="secondary" className="font-normal">
{p}
</Badge>
))}
</div>
{blocked && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
<Ban className="mt-0.5 size-4 shrink-0" />
<span>{m.store_blocked({ reason: entry.blocked ?? "" })}</span>
</p>
)}
{!entry.compatible && !blocked && (
<p className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="mt-px size-3.5 shrink-0" />
<span>{entry.incompatible_reason ?? m.store_incompatible()}</span>
</p>
)}
{/* Footer pinned to the bottom so cards in a row line their actions up. */}
<div className="mt-auto flex items-center gap-3 pt-1">
{entry.update_available ? (
<Button size="sm" disabled={!installable} onClick={onInstall}>
<Download className="size-4" />
{m.store_update_to({ version: entry.version })}
</Button>
) : installed ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Check className="size-4" />
{m.store_installed_label()}
</span>
) : (
<Button size="sm" disabled={!installable} onClick={onInstall}>
<Download className="size-4" />
{m.store_install()}
</Button>
)}
{entry.homepage && (
<a
href={entry.homepage}
target="_blank"
rel="noreferrer"
className="ml-auto text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground"
>
{m.store_homepage()}
</a>
)}
</div>
</CardContent>
</Card>
);
};