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
+37 -2
View File
@@ -54,9 +54,13 @@ pub(crate) fn fetch(source: &Source, etag: Option<&str>) -> Fetched {
req = req.set("If-None-Match", tag);
}
let resp = match req.call() {
// `ureq` only turns status >= 400 into `Err(Status)`, so a conditional request's 304
// arrives here as **Ok with an empty body** — not as an error. Reading it as an error arm
// (the intuitive reading) means every refresh after the first one verifies a signature
// over zero bytes and "fails"; the catalog then sits permanently stale, serving cache and
// never picking up a new entry. Found on-glass; pinned by `ureq_returns_304_as_ok`.
Ok(r) if r.status() == 304 => return Fetched::NotModified,
Ok(r) => r,
// 304 is the happy "nothing changed" path, not a failure.
Err(ureq::Error::Status(304, _)) => return Fetched::NotModified,
Err(ureq::Error::Status(code, _)) => {
return Fetched::Failed(format!("index fetch returned HTTP {code}"))
}
@@ -243,6 +247,37 @@ mod tests {
assert!(!meta_path(dir.path(), "s").exists());
}
/// Pins the HTTP-client assumption that [`fetch`]'s conditional-request handling rests on.
///
/// `ureq` converts only status >= 400 into `Err(Status)`. A 304 — which is exactly what a
/// successful `If-None-Match` produces, i.e. the *steady state* of a healthy catalog — comes
/// back as `Ok` with an empty body. Treating it as an error arm compiles, looks right, and
/// silently breaks every refresh after the first: the empty body fails the signature check and
/// the source sits stale forever. If a `ureq` upgrade ever changes this, fail here rather than
/// on someone's host.
#[test]
fn ureq_returns_304_as_ok() {
use std::io::Write as _;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let _ = sock.write_all(b"HTTP/1.1 304 Not Modified\r\nETag: \"x\"\r\n\r\n");
let _ = sock.flush();
}
});
let resp = ureq::get(&format!("http://{addr}/index.json"))
.set("If-None-Match", "\"x\"")
.call();
let _ = server.join();
match resp {
Ok(r) => assert_eq!(r.status(), 304, "304 must arrive as Ok, and be checked for"),
Err(e) => panic!("ureq now reports 304 as an error ({e}) — fetch() must be updated"),
}
}
#[test]
fn non_https_source_never_reaches_the_network() {
let s = Source {
+50
View File
@@ -561,6 +561,56 @@ mod tests {
assert!(verify_signature(body, &sig, &[]).is_err());
}
/// The real published index must parse here, field for field.
///
/// This fixture is a snapshot of `unom/punktfunk-plugin-index`'s `v1/index.json`. The index
/// repo's validator reimplements this module's rules in TypeScript (it has to — it gates PRs
/// before anything is signed), and two hand-written parsers of the same grammar drift. This
/// test is the seam that catches the drift from our side: if a document the publisher accepts
/// stops being one we accept, an entry silently disappears from every operator's store.
#[test]
fn the_published_seed_index_parses() {
let bytes = include_bytes!("testdata/seed-index.json");
let idx = Index::parse(bytes).expect("the published index must parse");
assert_eq!(idx.plugins.len(), 2, "no entry may be silently dropped");
let rom = idx
.plugins
.iter()
.find(|e| e.id == "rom-manager")
.expect("rom-manager entry");
assert_eq!(rom.pkg, "@punktfunk/plugin-rom-manager");
assert!(rom.integrity.starts_with("sha512-"));
assert!(
semver::Version::parse(&rom.version).is_ok(),
"exact version"
);
assert_eq!(
rom.verification.as_ref().map(|v| v.reviewed_at.as_str()),
Some("2026-07-20"),
"camelCase `reviewedAt` must decode"
);
assert_eq!(
rom.min_host.as_deref(),
Some("0.15.0"),
"camelCase `minHost`"
);
assert_eq!(scope_of(&rom.pkg).unwrap(), "@punktfunk");
// A windows-only entry is listed everywhere but only *installable* where it can run.
let playnite = idx
.plugins
.iter()
.find(|e| e.id == "playnite")
.expect("playnite entry");
assert_eq!(playnite.platforms, vec!["windows"]);
if HOST_PLATFORM == "windows" {
assert!(playnite.incompatible_reason().is_none());
} else {
assert!(playnite.incompatible_reason().is_some());
}
}
#[test]
fn public_key_parsing_rejects_junk() {
assert!(PublicKey::parse("nope").is_err());
+10 -5
View File
@@ -354,16 +354,21 @@ fn run_install(id: &str, plan: Plan) -> Result<()> {
// ---- install ------------------------------------------------------------------------------
set_phase(id, "installing");
let before = super::installed_packages(&dir);
// Map the entry's scope to its registry ourselves rather than through a runner flag: the
// installed scripting package can be older than this binary, and an older runner would read an
// unknown flag's value as a package name (see `ensure_bunfig_scope`).
if let Some((scope, url)) = &plan.registry {
super::ensure_bunfig_scope(&dir, scope, url)
.with_context(|| format!("map {scope} to {url}"))?;
}
let mut args = vec!["add".to_string(), plan.spec.clone()];
if plan.version.is_some() {
// Pin the dependency range too, so a later `bun install` in this tree can't drift off the
// reviewed version.
// reviewed version. Safely ignored by a runner too old to know it (an unknown `-`-prefixed
// flag is skipped, not misread) — the version we install is exact either way, so the worst
// case is a caret range recorded in package.json.
args.push("--exact".into());
}
if let Some((scope, url)) = &plan.registry {
args.push("--registry".into());
args.push(format!("{scope}={url}"));
}
if !plan.spec.starts_with("@punktfunk/") {
// The runner CLI's supply-chain gate refuses anything resolving off Punktfunk's own
// registry unless told otherwise. Here the operator already made that decision — either by
+4 -4
View File
@@ -193,7 +193,7 @@ mod tests {
assert_eq!(r.tier, Tier::Verified);
assert_eq!(r.version.as_deref(), Some("0.2.1"));
// A package we never recorded has no entry — the caller reports it as CLI-installed.
assert!(m.get("punktfunk-plugin-other").is_none());
assert!(!m.contains_key("punktfunk-plugin-other"));
}
#[test]
@@ -211,8 +211,8 @@ mod tests {
record(dir.path(), "@x/z", rec(Tier::External)).unwrap();
forget(dir.path(), "@x/y").unwrap();
let m = load(dir.path());
assert!(m.get("@x/y").is_none());
assert!(m.get("@x/z").is_some());
assert!(!m.contains_key("@x/y"));
assert!(m.contains_key("@x/z"));
forget(dir.path(), "@not/here").unwrap(); // no-op, no error
}
@@ -241,6 +241,6 @@ mod tests {
assert_eq!(&s[4..5], "-");
assert_eq!(&s[10..11], "T");
// A known epoch value, to catch the civil-date arithmetic drifting.
assert!(s > "2026-01-01T00:00:00Z".to_string(), "{s}");
assert!(s.as_str() > "2026-01-01T00:00:00Z", "{s}");
}
}
+11 -1
View File
@@ -17,7 +17,17 @@ use serde::{Deserialize, Serialize};
pub(crate) const OFFICIAL_NAME: &str = "unom";
/// The built-in source's index URL.
pub(crate) const OFFICIAL_URL: &str = "https://plugins.punktfunk.unom.io/v1/index.json";
///
/// Served straight out of the index repo over Gitea's anonymous raw endpoint: real HTTPS with a
/// real certificate, no new vhost to stand up, and "merged to main" *is* "published". The document
/// is signed, so the transport is not what we're trusting — swapping this for a dedicated static
/// host later is a one-constant change with no protocol impact.
///
/// One consequence worth knowing: CI signs *after* the merge, so between a merge and the signature
/// commit the index is newer than its `.sig`. That window fails **closed** — the signature check
/// rejects the document and hosts keep serving their last good copy, marked stale.
pub(crate) const OFFICIAL_URL: &str =
"https://git.unom.io/unom/punktfunk-plugin-index/raw/branch/main/v1/index.json";
/// Pinned signing keys for the built-in source. **Two slots** so a key rotation is "sign with the
/// new key, ship a host that trusts both, retire the old one" instead of a flag day where old
@@ -0,0 +1,49 @@
{
"schema": 1,
"name": "unom official",
"generated": "2026-07-20T18:00:00Z",
"plugins": [
{
"id": "rom-manager",
"pkg": "@punktfunk/plugin-rom-manager",
"registry": "https://git.unom.io/api/packages/unom/npm/",
"title": "ROM Manager",
"description": "Scans your ROM directories, maps them to emulators, fetches box art, and reconciles everything into the host game library as a provider. Includes a console-hosted web UI for mapping and cleanup.",
"icon": "gamepad-2",
"author": "unom",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-rom-manager",
"license": "MIT OR Apache-2.0",
"version": "0.3.1",
"integrity": "sha512-SGqMriqQPOQobXMYiT20w0rcTMNdYfZc8No3fPu57njMN+2eTVBVlQjyn1t3KoRFxfoRYGwuumN4X3aNmS0Tpw==",
"verification": {
"reviewedAt": "2026-07-20"
},
"minHost": "0.15.0",
"platforms": [
"linux",
"windows"
]
},
{
"id": "playnite",
"pkg": "@punktfunk/plugin-playnite",
"registry": "https://git.unom.io/api/packages/unom/npm/",
"title": "Playnite",
"description": "Bridges your Playnite library on Windows into the host game library, keeping installed games, metadata, and launch commands in sync as a library provider.",
"icon": "library",
"author": "unom",
"homepage": "https://git.unom.io/unom/punktfunk-plugin-playnite",
"license": "MIT OR Apache-2.0",
"version": "0.1.1",
"integrity": "sha512-H40QEcUN7g0wHTy5CFCOTA4+j/FypbVzWSZ0OlW5Ej5+FnnT1fMUDVOx8KEH34mavTy70fd1zx3zDucu9hNQuQ==",
"verification": {
"reviewedAt": "2026-07-20"
},
"minHost": "0.15.0",
"platforms": [
"windows"
]
}
],
"security": []
}