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
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:
+1132
File diff suppressed because it is too large
Load Diff
@@ -434,6 +434,46 @@ fn plugin_allowlist_excludes_escalation_routes() {
|
||||
&Method::GET,
|
||||
"/api/v1/plugins/x/ui-credential"
|
||||
));
|
||||
|
||||
// The plugin STORE, wholesale. Installing a plugin runs new code with operator privileges, so a
|
||||
// plugin able to do it could install a helper that isn't constrained the way it is — and
|
||||
// `POST /store/runtime` would let it switch its own supervisor. Denied by whole-prefix so a
|
||||
// route added here later is denied by default rather than by remembering to list it.
|
||||
for path in [
|
||||
"/api/v1/store/catalog",
|
||||
"/api/v1/store/installed",
|
||||
"/api/v1/store/sources",
|
||||
"/api/v1/store/jobs",
|
||||
"/api/v1/store/jobs/job-1",
|
||||
"/api/v1/store/runtime",
|
||||
"/api/v1/store/some-route-that-does-not-exist-yet",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::GET, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/api/v1/store/install",
|
||||
"/api/v1/store/uninstall",
|
||||
"/api/v1/store/refresh",
|
||||
"/api/v1/store/runtime",
|
||||
] {
|
||||
assert!(
|
||||
!auth::plugin_may_access(&Method::POST, path),
|
||||
"plugin token must not reach {path}"
|
||||
);
|
||||
}
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::PUT,
|
||||
"/api/v1/store/sources/evil"
|
||||
));
|
||||
assert!(!auth::plugin_may_access(
|
||||
&Method::DELETE,
|
||||
"/api/v1/store/sources/unom"
|
||||
));
|
||||
// …but a route that merely starts with the same letters is unaffected.
|
||||
assert!(auth::plugin_may_access(&Method::GET, "/api/v1/status"));
|
||||
}
|
||||
|
||||
/// The plugin bearer lane end-to-end: scoped 403s on the carve-outs, 200s on the plugin surface,
|
||||
@@ -484,6 +524,12 @@ async fn plugin_token_lane_is_scoped_and_loopback_only() {
|
||||
(Method::GET, "/api/v1/native/pending"),
|
||||
(Method::DELETE, "/api/v1/clients/aabbcc"),
|
||||
(Method::GET, "/api/v1/plugins/x/ui-credential"),
|
||||
// The plugin store: a plugin must not be able to install plugins or switch its own runner.
|
||||
(Method::GET, "/api/v1/store/catalog"),
|
||||
(Method::POST, "/api/v1/store/install"),
|
||||
(Method::POST, "/api/v1/store/uninstall"),
|
||||
(Method::POST, "/api/v1/store/runtime"),
|
||||
(Method::PUT, "/api/v1/store/sources/evil"),
|
||||
] {
|
||||
let (status, body) = send(&app, plugin_req(method.clone(), path)).await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{method} {path}");
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(crate) mod jobs;
|
||||
pub(crate) mod manifest;
|
||||
pub(crate) mod sources;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use index::{Advisory, Entry, Index};
|
||||
use sources::Source;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -50,6 +50,30 @@ pub(crate) struct InstalledPkg {
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// The packages the operator actually asked for: the `dependencies` of the plugins dir's own
|
||||
/// `package.json`, which `bun add` maintains. `None` only when there is no readable `package.json`
|
||||
/// at all.
|
||||
///
|
||||
/// This is what separates a plugin from a plugin's *library*. `@punktfunk/plugin-kit` is the
|
||||
/// framework every kit-built plugin depends on — it matches the `plugin-*` naming convention
|
||||
/// exactly, lands in `node_modules` as a transitive dependency, and is emphatically not something
|
||||
/// the operator installed or can meaningfully uninstall. The convention alone cannot tell the two
|
||||
/// apart; the top-level dependency list can.
|
||||
///
|
||||
/// A `package.json` with **no** `dependencies` key returns an empty list, not `None`: `bun remove`
|
||||
/// drops the key entirely when the last plugin goes, and orphaned transitive packages can outlive
|
||||
/// it in `node_modules`. Falling back to the naming convention there resurrects a plugin's library
|
||||
/// as an installed plugin the moment you uninstall the last real one (seen on-glass). If bun is
|
||||
/// managing this tree at all, its answer is the answer — including when the answer is "nothing".
|
||||
fn top_level_deps(dir: &Path) -> Option<Vec<String>> {
|
||||
let bytes = std::fs::read(dir.join("package.json")).ok()?;
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(match v.get("dependencies").and_then(|d| d.as_object()) {
|
||||
Some(deps) => deps.keys().cloned().collect(),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enumerate installed plugin packages under `<dir>/node_modules`.
|
||||
///
|
||||
/// Mirrors the runner's own discovery so the store never claims something is installed that the
|
||||
@@ -57,8 +81,14 @@ pub(crate) struct InstalledPkg {
|
||||
/// `plugin-*` (`@punktfunk/plugin-rom-manager`, `@retro-hub/plugin-x`). Scoped-any is what makes a
|
||||
/// third-party catalog entry work at all — a scoped name is required for the registry mapping
|
||||
/// (D8), so discovery must not be limited to the first-party scope.
|
||||
///
|
||||
/// Then narrowed to [`top_level_deps`] when a dependency list exists, so a plugin's *dependencies*
|
||||
/// (notably `@punktfunk/plugin-kit`) aren't reported as installed plugins. A tree with no readable
|
||||
/// `package.json` — hand-assembled, or an older layout — falls back to the convention alone rather
|
||||
/// than reporting nothing.
|
||||
pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
|
||||
let modules = dir.join("node_modules");
|
||||
let top_level = top_level_deps(dir);
|
||||
let mut out = Vec::new();
|
||||
let version_of = |pkg_dir: &Path| -> Option<String> {
|
||||
let bytes = std::fs::read(pkg_dir.join("package.json")).ok()?;
|
||||
@@ -101,9 +131,74 @@ pub(crate) fn installed_packages(dir: &Path) -> Vec<InstalledPkg> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(top) = top_level {
|
||||
out.retain(|p| top.iter().any(|d| d == &p.pkg));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Point a package scope at its registry in the plugins dir's `bunfig.toml`.
|
||||
///
|
||||
/// The runner CLI can do this too (`--registry @scope=URL`), but the store must **not** depend on
|
||||
/// that: `runner_command()` resolves whatever scripting package is installed on the box, which can
|
||||
/// predate the host binary (the packaged runner and the host ship separately). An older runner
|
||||
/// treats an unknown flag's *value* as a package name and the install dies with
|
||||
/// "unrecognised dependency format" — found on-glass. Writing the mapping here keeps a
|
||||
/// catalog-driven install working against every runner that has ever shipped.
|
||||
///
|
||||
/// Idempotent and non-destructive, matching `sdk/src/plugins.ts::ensureBunfig`: a scope already
|
||||
/// mapped to this URL is left alone, one mapped elsewhere is rewritten, unrelated content survives.
|
||||
pub(crate) fn ensure_bunfig_scope(dir: &Path, scope: &str, url: &str) -> Result<()> {
|
||||
// The scope and URL both come from a signature-verified, field-validated index entry
|
||||
// (`@`-prefixed, `[a-z0-9._-]`, https), so neither can smuggle a quote or newline into the TOML.
|
||||
if !index::valid_scoped_pkg(&format!("{scope}/x")) || !url.starts_with("https://") {
|
||||
bail!("refusing to map scope `{scope}` to `{url}`");
|
||||
}
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
let path = dir.join("bunfig.toml");
|
||||
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let wanted = format!("\"{scope}\" = \"{url}\"");
|
||||
|
||||
let is_mapping_for_scope = |line: &str| {
|
||||
let t = line.trim_start();
|
||||
t.starts_with(&format!("\"{scope}\"")) || t.starts_with(&format!("{scope} "))
|
||||
};
|
||||
if existing.lines().any(|l| l.trim() == wanted) {
|
||||
return Ok(()); // already correct
|
||||
}
|
||||
let updated = if existing.lines().any(is_mapping_for_scope) {
|
||||
existing
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if is_mapping_for_scope(l) {
|
||||
wanted.clone()
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n"
|
||||
} else if let Some(pos) = existing
|
||||
.lines()
|
||||
.position(|l| l.trim() == "[install.scopes]")
|
||||
{
|
||||
let mut lines: Vec<String> = existing.lines().map(str::to_string).collect();
|
||||
lines.insert(pos + 1, wanted);
|
||||
lines.join("\n") + "\n"
|
||||
} else if existing.trim().is_empty() {
|
||||
format!("[install.scopes]\n{wanted}\n")
|
||||
} else {
|
||||
format!(
|
||||
"{}{}\n[install.scopes]\n{wanted}\n",
|
||||
existing,
|
||||
if existing.ends_with('\n') { "" } else { "\n" }
|
||||
)
|
||||
};
|
||||
std::fs::write(&path, updated).with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is `pkg` a name the runner would supervise? Guards the uninstall route so a stray
|
||||
/// `POST /store/uninstall {"pkg": "effect"}` can't rip a shared dependency out of the tree.
|
||||
pub(crate) fn valid_installed_pkg(pkg: &str) -> Result<()> {
|
||||
@@ -366,6 +461,62 @@ mod tests {
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
/// A plugin's LIBRARY is not an installed plugin.
|
||||
///
|
||||
/// Regression from a live install: `@punktfunk/plugin-kit` is the framework every kit-built
|
||||
/// plugin depends on. It matches the `plugin-*` convention exactly and lands in `node_modules`
|
||||
/// transitively, so a convention-only scan reported the framework as an installed plugin the
|
||||
/// operator could uninstall. The plugins dir's own `dependencies` is the authority.
|
||||
#[test]
|
||||
fn transitive_plugin_named_dependencies_are_not_installed_plugins() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-rom-manager", "0.3.1");
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // a dependency of the above
|
||||
touch_pkg(dir.path(), "@punktfunk/host", "0.1.2");
|
||||
std::fs::write(
|
||||
dir.path().join("package.json"),
|
||||
r#"{"dependencies":{"@punktfunk/plugin-rom-manager":"^0.3.1"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let found = installed_packages(dir.path());
|
||||
assert_eq!(
|
||||
found.iter().map(|p| p.pkg.as_str()).collect::<Vec<_>>(),
|
||||
vec!["@punktfunk/plugin-rom-manager"],
|
||||
"only the top-level install counts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_with_no_package_json_falls_back_to_the_convention() {
|
||||
// Hand-assembled or older layouts must still be discovered, not silently reported empty.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "punktfunk-plugin-legacy", "0.1.0");
|
||||
assert_eq!(installed_packages(dir.path()).len(), 1);
|
||||
}
|
||||
|
||||
/// Uninstalling the last plugin must not resurrect its library as an installed plugin.
|
||||
///
|
||||
/// `bun remove` drops the `dependencies` key entirely once it empties, while orphaned
|
||||
/// transitive packages can linger in `node_modules`. Treating "package.json exists but has no
|
||||
/// dependencies" as "no authority, fall back to the naming convention" made
|
||||
/// `@punktfunk/plugin-kit` pop back into the installed list right after the operator removed
|
||||
/// the only real plugin — seen on-glass.
|
||||
#[test]
|
||||
fn an_emptied_dependency_list_means_nothing_is_installed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
touch_pkg(dir.path(), "@punktfunk/plugin-kit", "0.1.3"); // orphan left behind
|
||||
std::fs::write(dir.path().join("package.json"), r#"{"name":"plugins"}"#).unwrap();
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
|
||||
std::fs::write(
|
||||
dir.path().join("package.json"),
|
||||
r#"{"name":"plugins","dependencies":{}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(installed_packages(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_target_must_be_a_plugin_package() {
|
||||
assert!(valid_installed_pkg("@punktfunk/plugin-rom-manager").is_ok());
|
||||
@@ -378,6 +529,48 @@ mod tests {
|
||||
assert!(valid_installed_pkg("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bunfig_scope_mapping_is_idempotent_and_preserves_other_scopes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let read = || std::fs::read_to_string(dir.path().join("bunfig.toml")).unwrap();
|
||||
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
|
||||
assert!(read().contains("[install.scopes]"));
|
||||
assert!(read().contains("\"@retro-hub\" = \"https://retro.example/npm/\""));
|
||||
|
||||
// Idempotent — no duplicate line.
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://retro.example/npm/").unwrap();
|
||||
assert_eq!(read().matches("@retro-hub").count(), 1);
|
||||
|
||||
// A second scope joins the same table.
|
||||
ensure_bunfig_scope(
|
||||
dir.path(),
|
||||
"@punktfunk",
|
||||
"https://git.unom.io/api/packages/unom/npm/",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(read().contains("@punktfunk"));
|
||||
assert!(read().contains("@retro-hub"));
|
||||
|
||||
// A changed registry rewrites in place rather than duplicating.
|
||||
ensure_bunfig_scope(dir.path(), "@retro-hub", "https://new.example/npm/").unwrap();
|
||||
assert_eq!(read().matches("@retro-hub").count(), 1);
|
||||
assert!(read().contains("https://new.example/npm/"));
|
||||
assert!(!read().contains("retro.example"));
|
||||
assert!(read().contains("@punktfunk"), "unrelated scope survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bunfig_scope_mapping_refuses_junk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Only https, only a well-formed scope — both already guaranteed by index validation, held
|
||||
// here as the second line of defence for the one place we format TOML by hand.
|
||||
assert!(ensure_bunfig_scope(dir.path(), "@x", "http://insecure/").is_err());
|
||||
assert!(ensure_bunfig_scope(dir.path(), "no-at-sign", "https://e/").is_err());
|
||||
assert!(ensure_bunfig_scope(dir.path(), "@bad\"quote", "https://e/").is_err());
|
||||
assert!(!dir.path().join("bunfig.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_id_derivation() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -30,6 +30,7 @@ and nothing you configure here runs anywhere near the streaming path.
|
||||
| `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count |
|
||||
| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) |
|
||||
| `plugins.changed` | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id |
|
||||
| `store.changed` | an install or uninstall finished, or a plugin catalog was refreshed | none — re-read `GET /api/v1/store/catalog` / `…/installed` |
|
||||
| `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled |
|
||||
|
||||
Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema`
|
||||
|
||||
@@ -14,7 +14,49 @@ Two first-party plugins today:
|
||||
| **ROM Manager** | Scans your ROM directories, matches each platform to an installed emulator, and syncs them into the library with box art. |
|
||||
| **Playnite** | Mirrors your [Playnite](https://playnite.link) library — every store and emulator it manages — into the library, launched back through Playnite. |
|
||||
|
||||
## Installing a plugin
|
||||
## Installing from the console
|
||||
|
||||
Open the [web console](/docs/web-console) → **Plugins**. The **Browse** tab lists the plugin
|
||||
catalog; pick one, confirm, and the host installs it and restarts the runner for you. **Installed**
|
||||
shows what you have (and switches the runner on and off), **Sources** is where the catalog comes
|
||||
from.
|
||||
|
||||
That is the whole install. The rest of this page covers the CLI, which does the same thing, and the
|
||||
trust model behind the badges — worth reading once, because a plugin runs with the same privileges
|
||||
as the host.
|
||||
|
||||
### What "Verified" means
|
||||
|
||||
Every catalogued plugin pins **one exact version** and that version's package hash. A verified entry
|
||||
means somebody at unom reviewed *that exact package* — not the project in general, and not whatever
|
||||
it publishes next. When a plugin releases a new version, the store keeps offering the reviewed one
|
||||
until the new release is reviewed too. Before anything is downloaded, the host re-checks the pinned
|
||||
hash against the registry, so a package that was quietly republished under the same version number
|
||||
is refused rather than installed.
|
||||
|
||||
Three things a plugin can be:
|
||||
|
||||
| Badge | Where it came from |
|
||||
|---|---|
|
||||
| **Verified** | The built-in catalog. unom reviewed this exact package. |
|
||||
| **Unverified**, *from <source>* | A catalog **you** added. Still pinned and hash-checked, but curated by somebody else — unom has not looked at the code. |
|
||||
| **Unverified** | Installed by hand from a package spec. Nobody reviewed it and nothing pins it; the console asks you to type the name to confirm, and the plugin stays marked this way for as long as it is installed. |
|
||||
|
||||
### Adding another catalog
|
||||
|
||||
**Sources** → *Add a catalog source*: a name and the URL of its index. Optionally paste the source's
|
||||
`ed25519:…` public key — with a key set, the host refuses any index from that source that isn't
|
||||
correctly signed, rather than falling back to an unsigned one.
|
||||
|
||||
Adding a source is a trust decision you make once: its plugins become installable on this host.
|
||||
They are always attributed to it and never carry the Verified badge, which belongs to the built-in
|
||||
catalog alone.
|
||||
|
||||
To publish a plugin to the built-in catalog, open a pull request against
|
||||
[`punktfunk-plugin-index`](https://git.unom.io/unom/punktfunk-plugin-index) — its README covers the
|
||||
format and what review looks for.
|
||||
|
||||
## Installing from the CLI
|
||||
|
||||
Two commands: install the plugin, then turn the runner on. The host CLI handles the rest — creating
|
||||
the plugins directory, pointing it at the package registry, and starting the supervisor.
|
||||
@@ -50,7 +92,13 @@ Open the [web console](/docs/web-console) and the plugin's page appears in the n
|
||||
that's the whole install.
|
||||
|
||||
The runner is **opt-in**: `plugins add` installs, `plugins enable` turns it on. You only need
|
||||
`enable` once — plugins you add later are picked up automatically.
|
||||
`enable` once. The runner discovers plugins when it starts, so one installed later needs a restart
|
||||
to come up (`systemctl --user restart punktfunk-scripting`, or `Restart` the `PunktfunkScripting`
|
||||
task) — the console does that restart for you as part of installing.
|
||||
|
||||
A plugin installed from the CLI shows up in the console as **Installed via CLI**: the console knows
|
||||
what is installed, but not who vouched for it. Install the same plugin from the store's Browse tab
|
||||
and it carries its catalog badge instead.
|
||||
|
||||
### The rest of the commands
|
||||
|
||||
|
||||
+340
-2
File diff suppressed because one or more lines are too long
@@ -302,8 +302,27 @@ export const discoverUnits = (
|
||||
// no scripts dir — fine
|
||||
}
|
||||
const modules = path.join(pluginsDir, "node_modules");
|
||||
// The packages the operator actually installed — `bun add` records them as the plugins dir's
|
||||
// own `dependencies`. This is what separates a plugin from a plugin's LIBRARY:
|
||||
// `@punktfunk/plugin-kit` matches the `plugin-*` naming convention exactly but arrives as a
|
||||
// transitive dependency of every kit-built plugin, and running it as a unit is nonsense.
|
||||
// `undefined` only when there is no readable `package.json` at all — a hand-assembled tree then
|
||||
// falls back to the naming convention rather than discovering nothing. A package.json with no
|
||||
// `dependencies` key yields an EMPTY set, not `undefined`: `bun remove` drops the key when the
|
||||
// last plugin goes, and orphaned transitive packages can outlive it, so falling back there
|
||||
// would start running a plugin's library the moment you uninstall the last real plugin.
|
||||
let topLevel: Set<string> | undefined;
|
||||
try {
|
||||
const root = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginsDir, "package.json"), "utf8"),
|
||||
) as { dependencies?: Record<string, string> };
|
||||
topLevel = new Set(Object.keys(root.dependencies ?? {}));
|
||||
} catch {
|
||||
// no package.json — fall back to the convention
|
||||
}
|
||||
// Read a plugin package's manifest (`module`/`main` entry) and add it as a unit.
|
||||
const addPlugin = (dir: string, name: string): void => {
|
||||
if (topLevel && !topLevel.has(name)) return; // a dependency, not an installed plugin
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
|
||||
|
||||
@@ -107,6 +107,83 @@ describe("discovery", () => {
|
||||
const units = discoverUnits(d);
|
||||
expect(units.map((u) => u.name)).toEqual(["@punktfunk/plugin-y"]);
|
||||
});
|
||||
|
||||
test("discovers plugins in ANY scope, not just @punktfunk", () => {
|
||||
// Plugin-store catalog entries must be scoped so the scope can map to that entry's
|
||||
// registry, so a third-party plugin necessarily arrives under its own scope. Discovery
|
||||
// limited to @punktfunk would let it install and then never run.
|
||||
const d = mkdirs("discover-foreign-scope");
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "package.json"),
|
||||
JSON.stringify({ name: "@retro-hub/plugin-z", main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@retro-hub", "plugin-z", "index.js"),
|
||||
"export default { name: 'z', main: async () => {} };",
|
||||
);
|
||||
expect(discoverUnits(d).map((u) => u.name)).toEqual(["@retro-hub/plugin-z"]);
|
||||
});
|
||||
|
||||
test("a plugin's LIBRARY is not a unit — top-level dependencies win", () => {
|
||||
// Regression from a live install: `@punktfunk/plugin-kit` is the framework kit-built
|
||||
// plugins depend on. It matches the `plugin-*` convention exactly and lands in
|
||||
// node_modules transitively, so a convention-only scan would import and run the framework
|
||||
// as if it were a plugin.
|
||||
const d = mkdirs("discover-toplevel");
|
||||
for (const name of ["plugin-real", "plugin-kit"]) {
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "package.json"),
|
||||
JSON.stringify({ name: `@punktfunk/${name}`, main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", name, "index.js"),
|
||||
"export default { name: 'p', main: async () => {} };",
|
||||
);
|
||||
}
|
||||
write(
|
||||
path.join(d.pluginsDir, "package.json"),
|
||||
JSON.stringify({ dependencies: { "@punktfunk/plugin-real": "^1.0.0" } }),
|
||||
);
|
||||
expect(discoverUnits(d).map((u) => u.name)).toEqual(["@punktfunk/plugin-real"]);
|
||||
});
|
||||
|
||||
test("no package.json at all falls back to the naming convention", () => {
|
||||
// Hand-assembled or older trees must still run, not silently discover nothing.
|
||||
const d = mkdirs("discover-nodeps");
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "package.json"),
|
||||
JSON.stringify({ name: "punktfunk-plugin-legacy", main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "punktfunk-plugin-legacy", "index.js"),
|
||||
"export default { name: 'l', main: async () => {} };",
|
||||
);
|
||||
expect(discoverUnits(d).map((u) => u.name)).toEqual(["punktfunk-plugin-legacy"]);
|
||||
});
|
||||
|
||||
test("an emptied dependency list means nothing to run", () => {
|
||||
// `bun remove` drops the `dependencies` key when the last plugin goes, while orphaned
|
||||
// transitive packages linger in node_modules. Falling back to the naming convention there
|
||||
// would start running a plugin's LIBRARY right after the operator removed the only real
|
||||
// plugin — seen on-glass.
|
||||
const d = mkdirs("discover-emptied");
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "package.json"),
|
||||
JSON.stringify({ name: "@punktfunk/plugin-kit", main: "index.js" }),
|
||||
);
|
||||
write(
|
||||
path.join(d.pluginsDir, "node_modules", "@punktfunk", "plugin-kit", "index.js"),
|
||||
"export default {};",
|
||||
);
|
||||
write(path.join(d.pluginsDir, "package.json"), JSON.stringify({ name: "plugins" }));
|
||||
expect(discoverUnits(d)).toEqual([]);
|
||||
|
||||
write(
|
||||
path.join(d.pluginsDir, "package.json"),
|
||||
JSON.stringify({ name: "plugins", dependencies: {} }),
|
||||
);
|
||||
expect(discoverUnits(d)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowsSddlUnsafeReason (the sshd rule's Windows half, pure)", () => {
|
||||
|
||||
+108
-1
@@ -267,5 +267,112 @@
|
||||
"stats_delete_confirm": "Diese Aufnahme löschen? Das kann nicht rückgängig gemacht werden.",
|
||||
"stats_detail_title": "Aufnahme-Details",
|
||||
"stats_close": "Schließen",
|
||||
"stats_no_samples": "Diese Aufnahme enthält keine Proben."
|
||||
"stats_no_samples": "Diese Aufnahme enthält keine Proben.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Durchstöbere den Plugin-Katalog, installiere und entferne Plugins und lege fest, welchen Katalogen dieser Host vertraut.",
|
||||
"store_tab_browse": "Entdecken",
|
||||
"store_tab_installed": "Installiert",
|
||||
"store_tab_sources": "Quellen",
|
||||
"store_tier_verified": "Geprüft",
|
||||
"store_tier_verified_hint": "Aus dem eingebauten unom-Katalog — unom hat genau dieses Paket geprüft.",
|
||||
"store_tier_external": "Externe Quelle",
|
||||
"store_tier_external_hint": "Festgepinnt und auf Integrität geprüft, aber von jemand anderem als unom kuratiert. Niemand bei unom hat diesen Code geprüft.",
|
||||
"store_tier_unverified": "Ungeprüft",
|
||||
"store_tier_unverified_hint": "Aus einer rohen Paketangabe installiert. Kein Katalog, keine Prüfung — diesen Code hat nie jemand kontrolliert.",
|
||||
"store_tier_cli": "Über die CLI installiert",
|
||||
"store_tier_cli_hint": "Von der Kommandozeile installiert — der Host weiß daher nicht, woher es stammt.",
|
||||
"store_from_source": "von",
|
||||
"store_search_placeholder": "Plugins durchsuchen…",
|
||||
"store_filter_all": "Alle Quellen",
|
||||
"store_empty": "Noch keine Plugins im Katalog.",
|
||||
"store_no_match": "Kein Plugin passt zur Suche.",
|
||||
"store_by_author": "von {author}",
|
||||
"store_homepage": "Website",
|
||||
"store_install": "Installieren",
|
||||
"store_installed_label": "Installiert",
|
||||
"store_update_to": "Auf {version} aktualisieren",
|
||||
"store_incompatible": "Nicht kompatibel mit diesem Host.",
|
||||
"store_blocked": "Vom Host blockiert: {reason}",
|
||||
"store_spec_open": "Aus Paketangabe installieren…",
|
||||
"store_runner_title": "Plugin-Runner",
|
||||
"store_runner_help": "Der Dienst, in dem jedes Plugin läuft. Ihn abzuschalten stoppt alle Plugins auf einmal, ohne etwas zu deinstallieren.",
|
||||
"store_runner_not_installed": "Der Plugin-Runner ist auf diesem Host nicht installiert — Plugins können daher nicht starten.",
|
||||
"store_runner_banner": "Der Plugin-Runner ist ausgeschaltet — Plugins starten erst, wenn du ihn aktivierst.",
|
||||
"store_runner_enable": "Runner aktivieren",
|
||||
"store_runner_disable": "Runner deaktivieren",
|
||||
"store_runner_state_running": "Läuft",
|
||||
"store_runner_state_stopped": "Gestoppt",
|
||||
"store_runner_state_disabled": "Deaktiviert",
|
||||
"store_runner_state_missing": "Nicht installiert",
|
||||
"store_runner_unit": "Dienst",
|
||||
"store_runner_principal": "Läuft als",
|
||||
"store_runner_failed": "Der Plugin-Runner konnte nicht umgeschaltet werden.",
|
||||
"store_installed_title": "Installierte Plugins",
|
||||
"store_installed_empty": "Noch keine Plugins installiert.",
|
||||
"store_running": "Läuft",
|
||||
"store_stopped": "Läuft nicht",
|
||||
"store_uninstall": "Deinstallieren",
|
||||
"store_uninstall_confirm": "{title} deinstallieren? Du kannst es jederzeit wieder aus dem Katalog installieren.",
|
||||
"store_uninstall_failed": "Die Deinstallation konnte nicht gestartet werden.",
|
||||
"store_update_no_entry": "Dieses Plugin steckt derzeit in keinem Katalog — aktualisiere die Quellen und versuche es erneut.",
|
||||
"store_sources_title": "Katalogquellen",
|
||||
"store_sources_help": "Wo dieser Host nach Plugins sucht. Der eingebaute unom-Katalog ist immer dabei; jede weitere Quelle hast du selbst hinzugefügt und stehst selbst dafür ein.",
|
||||
"store_refresh_all": "Alle aktualisieren",
|
||||
"store_refresh_failed": "Die Kataloge konnten nicht aktualisiert werden.",
|
||||
"store_source_builtin": "Eingebaut",
|
||||
"store_source_signed": "Signiert",
|
||||
"store_source_unsigned": "Unsigniert",
|
||||
"store_source_stale": "Veraltet",
|
||||
"store_source_entries": "{count} Plugins",
|
||||
"store_source_fetched": "zuletzt geladen {when}",
|
||||
"store_source_never": "nie",
|
||||
"store_source_remove": "Quelle entfernen",
|
||||
"store_source_remove_confirm": "Die Quelle „{name}“ entfernen? Bereits daraus installierte Plugins bleiben installiert.",
|
||||
"store_source_remove_failed": "Die Quelle konnte nicht entfernt werden.",
|
||||
"store_source_builtin_locked": "Der eingebaute unom-Katalog kann nicht entfernt werden.",
|
||||
"store_add_source_title": "Katalogquelle hinzufügen",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index-URL",
|
||||
"store_field_source_key": "Öffentlicher Schlüssel (optional)",
|
||||
"store_field_source_key_help": "Ein ed25519:…-Schlüssel. Ist er gesetzt, akzeptiert der Host von dieser Quelle nur einen signierten Index.",
|
||||
"store_add_source": "Quelle hinzufügen",
|
||||
"store_add_source_failed": "Die Quelle konnte nicht gespeichert werden — prüfe Name und URL.",
|
||||
"store_source_trust_title": "Dieser Quelle vertrauen?",
|
||||
"store_source_trust_body": "Alles, was du aus „{name}“ installierst, ist Code, den unom nicht geprüft hat. Er läuft auf diesem Host mit den Rechten des Plugin-Runners. Füge nur einen Katalog hinzu, dessen Betreiber du vertraust.",
|
||||
"store_source_trust_unsigned": "Ohne öffentlichen Schlüssel kann der Host nicht erkennen, ob dieser Index unterwegs manipuliert wurde.",
|
||||
"store_source_trust_confirm": "Verstanden — Quelle hinzufügen",
|
||||
"store_install_title": "{title} installieren?",
|
||||
"store_install_verified_body": "Version {version} aus dem eingebauten unom-Katalog. unom hat genau dieses Paket geprüft.",
|
||||
"store_install_confirm": "Installieren",
|
||||
"store_install_external_title": "{title} aus einer externen Quelle installieren?",
|
||||
"store_install_external_body": "Version {version} stammt aus „{source}“ — einem Katalog, den du selbst hinzugefügt hast.",
|
||||
"store_install_external_note": "unom hat diesen Code nicht geprüft. Das Paket ist festgepinnt und auf Integrität geprüft, läuft auf diesem Host aber mit den Rechten des Plugin-Runners.",
|
||||
"store_install_external_confirm": "Trotzdem installieren",
|
||||
"store_install_failed": "Die Installation konnte nicht gestartet werden.",
|
||||
"store_busy": "Der Host installiert oder entfernt gerade schon ein Plugin. Versuche es, sobald das durch ist.",
|
||||
"store_spec_title": "Aus einer Paketangabe installieren",
|
||||
"store_spec_lead": "Das installiert Code direkt aus einer Paket-Registry. Er steht in keinem Katalog, niemand hat ihn geprüft, und er läuft auf diesem Host mit den Rechten des Plugin-Runners — mit demselben Zugriff auf deine Dateien und deine Sitzung.",
|
||||
"store_spec_permanent": "Ein so installiertes Plugin bleibt als „Ungeprüft“ markiert, solange es installiert ist.",
|
||||
"store_spec_field": "Paketangabe",
|
||||
"store_spec_field_help": "Zum Beispiel @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Gib die Paketangabe zur Bestätigung erneut ein",
|
||||
"store_spec_checkbox": "Mir ist klar, dass hier ungeprüfter Code mit Betreiberrechten ausgeführt wird.",
|
||||
"store_spec_confirm": "Ungeprüft installieren",
|
||||
"store_job_install": "{target} wird installiert",
|
||||
"store_job_uninstall": "{target} wird entfernt",
|
||||
"store_job_done_install": "Installiert.",
|
||||
"store_job_done_uninstall": "Entfernt.",
|
||||
"store_job_failed": "Der Vorgang ist fehlgeschlagen.",
|
||||
"store_job_restarting": "Der Plugin-Runner startet neu — die Seitenleiste zieht gleich nach.",
|
||||
"store_job_log": "Log anzeigen",
|
||||
"store_job_dismiss": "Ausblenden",
|
||||
"store_phase_queued": "In der Warteschlange",
|
||||
"store_phase_verifying": "Paket wird verifiziert",
|
||||
"store_phase_installing": "Wird installiert",
|
||||
"store_phase_removing": "Wird entfernt",
|
||||
"store_phase_checking": "Installation wird geprüft",
|
||||
"store_phase_rolling_back": "Wird zurückgerollt",
|
||||
"store_phase_recording": "Herkunft wird vermerkt",
|
||||
"store_phase_restarting": "Plugin-Runner startet neu",
|
||||
"store_phase_done": "Fertig"
|
||||
}
|
||||
|
||||
+108
-1
@@ -267,5 +267,112 @@
|
||||
"stats_delete_confirm": "Delete this recording? This can't be undone.",
|
||||
"stats_detail_title": "Recording detail",
|
||||
"stats_close": "Close",
|
||||
"stats_no_samples": "This recording has no samples."
|
||||
"stats_no_samples": "This recording has no samples.",
|
||||
"store_title": "Plugins",
|
||||
"store_subtitle": "Browse the plugin catalog, install and remove plugins, and choose which catalogs this host trusts.",
|
||||
"store_tab_browse": "Browse",
|
||||
"store_tab_installed": "Installed",
|
||||
"store_tab_sources": "Sources",
|
||||
"store_tier_verified": "Verified",
|
||||
"store_tier_verified_hint": "From the built-in unom catalog — unom reviewed this exact package.",
|
||||
"store_tier_external": "External source",
|
||||
"store_tier_external_hint": "Pinned and integrity-checked, but curated by someone other than unom. Nobody at unom reviewed this code.",
|
||||
"store_tier_unverified": "Unverified",
|
||||
"store_tier_unverified_hint": "Installed from a raw package spec. No catalog, no review — nobody checked this code.",
|
||||
"store_tier_cli": "Installed via CLI",
|
||||
"store_tier_cli_hint": "Installed from the command line, so the host has no record of where it came from.",
|
||||
"store_from_source": "from",
|
||||
"store_search_placeholder": "Search plugins…",
|
||||
"store_filter_all": "All sources",
|
||||
"store_empty": "No plugins in the catalog yet.",
|
||||
"store_no_match": "No plugin matches your search.",
|
||||
"store_by_author": "by {author}",
|
||||
"store_homepage": "Homepage",
|
||||
"store_install": "Install",
|
||||
"store_installed_label": "Installed",
|
||||
"store_update_to": "Update to {version}",
|
||||
"store_incompatible": "Not compatible with this host.",
|
||||
"store_blocked": "Blocked by the host: {reason}",
|
||||
"store_spec_open": "Install from package spec…",
|
||||
"store_runner_title": "Plugin runner",
|
||||
"store_runner_help": "The service every plugin runs inside. Disabling it stops all plugins at once without uninstalling anything.",
|
||||
"store_runner_not_installed": "The plugin runner isn't installed on this host, so plugins can't start.",
|
||||
"store_runner_banner": "The plugin runner is switched off — plugins won't start until you enable it.",
|
||||
"store_runner_enable": "Enable runner",
|
||||
"store_runner_disable": "Disable runner",
|
||||
"store_runner_state_running": "Running",
|
||||
"store_runner_state_stopped": "Stopped",
|
||||
"store_runner_state_disabled": "Disabled",
|
||||
"store_runner_state_missing": "Not installed",
|
||||
"store_runner_unit": "Service",
|
||||
"store_runner_principal": "Runs as",
|
||||
"store_runner_failed": "Could not change the plugin runner.",
|
||||
"store_installed_title": "Installed plugins",
|
||||
"store_installed_empty": "No plugins installed yet.",
|
||||
"store_running": "Running",
|
||||
"store_stopped": "Not running",
|
||||
"store_uninstall": "Uninstall",
|
||||
"store_uninstall_confirm": "Uninstall {title}? You can install it again from the catalog.",
|
||||
"store_uninstall_failed": "Could not start the removal.",
|
||||
"store_update_no_entry": "That plugin isn't in any catalog right now — refresh the sources and try again.",
|
||||
"store_sources_title": "Catalog sources",
|
||||
"store_sources_help": "Where this host looks for plugins. The built-in unom catalog is always present; every other source is one you added and vouch for yourself.",
|
||||
"store_refresh_all": "Refresh all",
|
||||
"store_refresh_failed": "Could not refresh the catalogs.",
|
||||
"store_source_builtin": "Built in",
|
||||
"store_source_signed": "Signed",
|
||||
"store_source_unsigned": "Unsigned",
|
||||
"store_source_stale": "Out of date",
|
||||
"store_source_entries": "{count} plugins",
|
||||
"store_source_fetched": "last fetched {when}",
|
||||
"store_source_never": "never",
|
||||
"store_source_remove": "Remove source",
|
||||
"store_source_remove_confirm": "Remove the source “{name}”? Plugins already installed from it stay installed.",
|
||||
"store_source_remove_failed": "Could not remove the source.",
|
||||
"store_source_builtin_locked": "The built-in unom catalog can't be removed.",
|
||||
"store_add_source_title": "Add a catalog source",
|
||||
"store_field_source_name": "Name",
|
||||
"store_field_source_url": "Index URL",
|
||||
"store_field_source_key": "Public key (optional)",
|
||||
"store_field_source_key_help": "An ed25519:… key. With a key set, the host only accepts a signed index from this source.",
|
||||
"store_add_source": "Add source",
|
||||
"store_add_source_failed": "Could not save the source — check the name and the URL.",
|
||||
"store_source_trust_title": "Trust this source?",
|
||||
"store_source_trust_body": "Everything you install from “{name}” is code unom has not reviewed. It runs on this host with the plugin runner's privileges. Only add a catalog whose operator you trust.",
|
||||
"store_source_trust_unsigned": "Without a public key the host can't tell whether this index was tampered with in transit.",
|
||||
"store_source_trust_confirm": "I understand — add the source",
|
||||
"store_install_title": "Install {title}?",
|
||||
"store_install_verified_body": "Version {version} from the built-in unom catalog. unom reviewed this exact package.",
|
||||
"store_install_confirm": "Install",
|
||||
"store_install_external_title": "Install {title} from an external source?",
|
||||
"store_install_external_body": "Version {version} comes from “{source}”, a catalog you added yourself.",
|
||||
"store_install_external_note": "unom has not reviewed this code. The package is pinned and integrity-checked, but it will run on this host with the plugin runner's privileges.",
|
||||
"store_install_external_confirm": "Install anyway",
|
||||
"store_install_failed": "Could not start the install.",
|
||||
"store_busy": "The host is already installing or removing a plugin. Try again once it's finished.",
|
||||
"store_spec_title": "Install from a package spec",
|
||||
"store_spec_lead": "This installs code straight from a package registry. It is in no catalog, nobody has reviewed it, and it will run on this host with the plugin runner's privileges — the same access it has to your files and your session.",
|
||||
"store_spec_permanent": "A plugin installed this way stays marked Unverified for as long as it is installed.",
|
||||
"store_spec_field": "Package spec",
|
||||
"store_spec_field_help": "For example @scope/plugin-name@1.2.3.",
|
||||
"store_spec_confirm_field": "Type the package spec again to confirm",
|
||||
"store_spec_checkbox": "I understand that this runs unreviewed code with operator privileges.",
|
||||
"store_spec_confirm": "Install unverified",
|
||||
"store_job_install": "Installing {target}",
|
||||
"store_job_uninstall": "Removing {target}",
|
||||
"store_job_done_install": "Installed.",
|
||||
"store_job_done_uninstall": "Removed.",
|
||||
"store_job_failed": "The job failed.",
|
||||
"store_job_restarting": "The plugin runner is restarting — the sidebar catches up in a moment.",
|
||||
"store_job_log": "Show log",
|
||||
"store_job_dismiss": "Dismiss",
|
||||
"store_phase_queued": "Queued",
|
||||
"store_phase_verifying": "Verifying the package",
|
||||
"store_phase_installing": "Installing",
|
||||
"store_phase_removing": "Removing",
|
||||
"store_phase_checking": "Checking the install",
|
||||
"store_phase_rolling_back": "Rolling back",
|
||||
"store_phase_recording": "Recording provenance",
|
||||
"store_phase_restarting": "Restarting the plugin runner",
|
||||
"store_phase_done": "Done"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// The plugin store: catalog, install/uninstall jobs, catalog sources, and the scripting runner.
|
||||
// Like `api/plugins.ts` this is a hand-written client rather than an orval-generated one — the
|
||||
// OpenAPI document is regenerated on a Linux box (punktfunk-host doesn't build on macOS), so the
|
||||
// console must not wait on a regen to talk to these endpoints. It rides the same `/api` BFF path as
|
||||
// every other call, so the bearer token is injected server-side and the browser only ever sends its
|
||||
// session cookie.
|
||||
import {
|
||||
type QueryClient,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { apiFetch } from "@/api/fetcher";
|
||||
|
||||
/**
|
||||
* How much a plugin's provenance is worth, from most to least trustworthy:
|
||||
*
|
||||
* - `verified` — from the built-in `unom` source; unom reviewed that exact tarball.
|
||||
* - `external` — from an operator-added source; pinned and integrity-checked, but curated by
|
||||
* somebody else. It is NOT reviewed by unom and never wears the verified badge.
|
||||
* - `unverified` — installed from a raw package spec through the high-friction dialog. No catalog,
|
||||
* no review, no pinning. Stays badged unverified forever.
|
||||
* - `cli` — installed with the CLI, so the host holds no provenance record at all.
|
||||
*/
|
||||
export type StoreTier = "verified" | "external" | "unverified" | "cli";
|
||||
|
||||
/** The tier a *catalog* entry can carry — the raw-spec/CLI tiers never appear in a catalog. */
|
||||
export type CatalogTier = Extract<StoreTier, "verified" | "external">;
|
||||
|
||||
export interface StoreHostInfo {
|
||||
version: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface StoreSource {
|
||||
name: string;
|
||||
url: string;
|
||||
/** The `unom` source ships with the host: it can't be edited or removed. */
|
||||
builtin: boolean;
|
||||
signed: boolean;
|
||||
/** The last fetch failed or is too old — entries may be out of date. */
|
||||
stale: boolean;
|
||||
/** Unix seconds of the last successful fetch. */
|
||||
fetched_at: number;
|
||||
error?: string;
|
||||
entry_count: number;
|
||||
public_key?: string;
|
||||
}
|
||||
|
||||
export interface StoreEntry {
|
||||
id: string;
|
||||
pkg: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
author: string;
|
||||
homepage?: string;
|
||||
license?: string;
|
||||
version: string;
|
||||
/** Name of the source this entry came from — the attribution an external entry shows. */
|
||||
source: string;
|
||||
tier: CatalogTier;
|
||||
reviewed_at?: string;
|
||||
platforms: string[];
|
||||
min_host?: string;
|
||||
compatible: boolean;
|
||||
incompatible_reason?: string;
|
||||
installed_version?: string;
|
||||
update_available: boolean;
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
export interface StoreCatalog {
|
||||
host: StoreHostInfo;
|
||||
sources: StoreSource[];
|
||||
plugins: StoreEntry[];
|
||||
/** An install/uninstall is already running — the host takes one at a time. */
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
pkg: string;
|
||||
version: string;
|
||||
tier: StoreTier;
|
||||
source?: string;
|
||||
entry_id?: string;
|
||||
/** The plugin's runtime id — how it's keyed in the plugin directory (`api/plugins.ts`). */
|
||||
plugin_id?: string;
|
||||
title?: string;
|
||||
installed_at?: string;
|
||||
running: boolean;
|
||||
/** The version available to update to, if any. */
|
||||
update_available?: string;
|
||||
blocked?: string;
|
||||
}
|
||||
|
||||
export type JobKind = "install" | "uninstall";
|
||||
export type JobState = "running" | "done" | "failed";
|
||||
|
||||
export interface StoreJob {
|
||||
id: string;
|
||||
kind: JobKind;
|
||||
target: string;
|
||||
state: JobState;
|
||||
phase: string;
|
||||
log: string[];
|
||||
error?: string;
|
||||
started_at: number;
|
||||
finished_at?: number;
|
||||
}
|
||||
|
||||
export interface RuntimeStatus {
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
unit: string;
|
||||
principal?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** What `POST /store/install` and `POST /store/uninstall` answer with (202). */
|
||||
export interface JobAccepted {
|
||||
job: string;
|
||||
}
|
||||
|
||||
/** Install a curated catalog entry, or — deliberately awkward — a raw package spec. */
|
||||
export type InstallBody =
|
||||
| { source: string; id: string }
|
||||
| { spec: string; accept_unverified: true };
|
||||
|
||||
export interface SourceBody {
|
||||
url: string;
|
||||
public_key?: string;
|
||||
}
|
||||
|
||||
const BASE = "/api/v1/store";
|
||||
|
||||
/** Query keys, in one place so any mutation can invalidate precisely. */
|
||||
export const storeKeys = {
|
||||
all: ["store"] as const,
|
||||
catalog: ["store", "catalog"] as const,
|
||||
installed: ["store", "installed"] as const,
|
||||
sources: ["store", "sources"] as const,
|
||||
runtime: ["store", "runtime"] as const,
|
||||
job: (id: string) => ["store", "job", id] as const,
|
||||
};
|
||||
|
||||
const json = (method: string, body: unknown): RequestInit => ({
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh everything a completed install/uninstall touches — the catalog (installed markers), the
|
||||
* installed list, the runner state (it restarts), and the plugin directory the nav is built from.
|
||||
*/
|
||||
export function invalidateStore(qc: QueryClient): Promise<void> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: storeKeys.catalog }),
|
||||
qc.invalidateQueries({ queryKey: storeKeys.installed }),
|
||||
qc.invalidateQueries({ queryKey: storeKeys.sources }),
|
||||
qc.invalidateQueries({ queryKey: storeKeys.runtime }),
|
||||
qc.invalidateQueries({ queryKey: ["plugins"] }),
|
||||
]).then(() => undefined);
|
||||
}
|
||||
|
||||
/** The merged catalog across every source, plus the sources' own health. */
|
||||
export function useStoreCatalog() {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.catalog,
|
||||
queryFn: () => apiFetch<StoreCatalog>(`${BASE}/catalog`),
|
||||
});
|
||||
}
|
||||
|
||||
/** What's installed right now, with each plugin's permanent provenance tier. */
|
||||
export function useInstalledPlugins() {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.installed,
|
||||
queryFn: () => apiFetch<InstalledPlugin[]>(`${BASE}/installed`),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStoreSources() {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.sources,
|
||||
queryFn: () => apiFetch<StoreSource[]>(`${BASE}/sources`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStoreRuntime() {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.runtime,
|
||||
queryFn: () => apiFetch<RuntimeStatus>(`${BASE}/runtime`),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A single install/uninstall job, polled once a second while it runs and left alone once it
|
||||
* settles. Pass `null` to park the query (no job in flight).
|
||||
*/
|
||||
export function useStoreJob(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: storeKeys.job(id ?? ""),
|
||||
queryFn: () =>
|
||||
apiFetch<StoreJob>(`${BASE}/jobs/${encodeURIComponent(id ?? "")}`),
|
||||
enabled: id !== null,
|
||||
refetchInterval: (q) => (q.state.data?.state === "running" ? 1_000 : false),
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-fetch every source's index; answers with the freshly merged catalog. */
|
||||
export function useRefreshCatalog() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<StoreCatalog>(`${BASE}/refresh`, { method: "POST" }),
|
||||
onSuccess: (catalog) => {
|
||||
qc.setQueryData(storeKeys.catalog, catalog);
|
||||
qc.setQueryData(storeKeys.sources, catalog.sources);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Start an install. Answers 202 with the job to poll; 409 means another op is already running. */
|
||||
export function useInstallPlugin() {
|
||||
return useMutation({
|
||||
mutationFn: (body: InstallBody) =>
|
||||
apiFetch<JobAccepted>(`${BASE}/install`, json("POST", body)),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallPlugin() {
|
||||
return useMutation({
|
||||
mutationFn: (pkg: string) =>
|
||||
apiFetch<JobAccepted>(`${BASE}/uninstall`, json("POST", { pkg })),
|
||||
});
|
||||
}
|
||||
|
||||
/** Add or update an operator source (the built-in one is not editable). */
|
||||
export function useSetSource() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, ...body }: SourceBody & { name: string }) =>
|
||||
apiFetch<void>(
|
||||
`${BASE}/sources/${encodeURIComponent(name)}`,
|
||||
json("PUT", body),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: storeKeys.sources });
|
||||
qc.invalidateQueries({ queryKey: storeKeys.catalog });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSource() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
apiFetch<void>(`${BASE}/sources/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: storeKeys.sources });
|
||||
qc.invalidateQueries({ queryKey: storeKeys.catalog });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Enable or disable the plugin/script runner service. */
|
||||
export function useSetRuntime() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
apiFetch<RuntimeStatus>(`${BASE}/runtime`, json("POST", { enabled })),
|
||||
onSuccess: (status) => {
|
||||
qc.setQueryData(storeKeys.runtime, status);
|
||||
qc.invalidateQueries({ queryKey: ["plugins"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LibraryBig,
|
||||
MonitorPlay,
|
||||
MoreHorizontal,
|
||||
Puzzle,
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -29,9 +30,15 @@ const NAV = [
|
||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
|
||||
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
|
||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||
] as const;
|
||||
|
||||
// "/plugins" is the store's index route and "/plugins/<id>" a plugin's own UI, so the store entry
|
||||
// only counts as active on an exact match — otherwise it would light up alongside the plugin's own
|
||||
// nav entry below. Same reason "/" is exact.
|
||||
const EXACT: readonly string[] = ["/", "/plugins"];
|
||||
|
||||
// On phones a flat 8-tab bar is too cramped, so the first four are pinned and the rest live behind a
|
||||
// "More" tab that opens a sheet above the bar. Keep it ≤ 5 slots including "More".
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 4);
|
||||
@@ -74,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
to={to}
|
||||
activeOptions={{ exact: to === "/" }}
|
||||
activeOptions={{ exact: EXACT.includes(to) }}
|
||||
className="group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
activeProps={{
|
||||
className: "bg-primary/15 text-foreground font-medium",
|
||||
@@ -200,6 +207,7 @@ function MobileNav() {
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
activeOptions={{ exact: EXACT.includes(to) }}
|
||||
className={cn(tab, "rounded-md")}
|
||||
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||
>
|
||||
@@ -232,7 +240,7 @@ function MobileNav() {
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
activeOptions={{ exact: to === "/" }}
|
||||
activeOptions={{ exact: EXACT.includes(to) }}
|
||||
className={tab}
|
||||
activeProps={{ className: "text-[var(--brand-light)]" }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// The console's Dialog IS @unom/ui's radix dialog — brand surface (material gloss + card radius)
|
||||
// with the shared close/overlay behaviour. @unom/ui ships the SURFACE only and leaves placement to
|
||||
// the app, so `DialogContent` here is the surface already wrapped in its portal + overlay and
|
||||
// centred in the viewport; everything else is re-exported unchanged.
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogContent as DialogSurface,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@unom/ui/dialog";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DialogContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof DialogSurface>) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogSurface
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-100 flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto p-6",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPortal>
|
||||
);
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// The console's Tabs ARE @unom/ui's radix tabs, with one correction: the inactive trigger colour.
|
||||
//
|
||||
// @unom/ui styles inactive triggers `text-secondary` and the active one `data-[state=active]:
|
||||
// text-main`. Those tokens come from @unom/ui's own palette; in the console's theme `text-secondary`
|
||||
// lands on (near) the tab strip's own background, so every inactive tab renders as an invisible
|
||||
// gap — the tab bar looks like a single lonely label with dead space beside it. Caught in a browser
|
||||
// pass, not by types: the markup and the a11y roles are entirely correct.
|
||||
//
|
||||
// Same shape as the other `components/ui/*` wrappers: adapt the shared primitive to this app's
|
||||
// tokens rather than restyling it at every call site.
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger as TabsTriggerBase,
|
||||
} from "@unom/ui/tabs";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TabsTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof TabsTriggerBase>) => (
|
||||
<TabsTriggerBase
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SectionStore } from "@/sections/Store";
|
||||
|
||||
// The plugin store. Sits at /plugins as an index route, so it coexists with the per-plugin UI route
|
||||
// (`plugins.$pluginId.$.tsx`) without either shadowing the other.
|
||||
export const Route = createFileRoute("/plugins/")({ component: SectionStore });
|
||||
@@ -8,9 +8,11 @@ import { getRouteApi, useNavigate } from "@tanstack/react-router";
|
||||
import { ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { type FC, useEffect, useMemo, useRef } from "react";
|
||||
import { pluginIcon, usePlugins } from "@/api/plugins";
|
||||
import { useInstalledPlugins } from "@/api/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { TierBadge } from "@/sections/Store/TierBadge";
|
||||
|
||||
const route = getRouteApi("/plugins/$pluginId/$");
|
||||
|
||||
@@ -26,6 +28,12 @@ export const SectionPlugin: FC = () => {
|
||||
const Icon = pluginIcon(meta?.ui?.icon);
|
||||
const title = meta?.title ?? pluginId;
|
||||
|
||||
// Provenance follows the plugin into its own page: an unverified plugin must stay visibly
|
||||
// unverified WHILE you use it, not only in the store listing. The store keys installations by
|
||||
// package, so the runtime id is matched through `plugin_id`.
|
||||
const { data: installed } = useInstalledPlugins();
|
||||
const provenance = installed?.find((p) => p.plugin_id === pluginId);
|
||||
|
||||
// Liveness: a 200 from /__health means the plugin is up. On failure we stop polling and show the
|
||||
// offline card (the manual Retry re-probes).
|
||||
const health = useQuery({
|
||||
@@ -77,6 +85,7 @@ export const SectionPlugin: FC = () => {
|
||||
v{meta.version}
|
||||
</span>
|
||||
)}
|
||||
{provenance && <TierBadge tier={provenance.tier} />}
|
||||
<a
|
||||
href={`/plugin-ui/${pluginId}/`}
|
||||
target="_blank"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Checkbox } from "@unom/ui/form/checkbox";
|
||||
import { BadgeCheck, ShieldAlert, ShieldQuestion } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { StoreEntry } from "@/api/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// Friction proportional to trust. The three install paths deliberately do NOT share a confirm
|
||||
// button: a verified install is one click on a plain dialog, an external one makes you read that
|
||||
// unom didn't review the code and names who did curate it, and a raw package spec makes you retype
|
||||
// the spec and tick a box. The last one is never reachable by accident — no card, no button on a
|
||||
// listing, only the small footer link on Browse.
|
||||
|
||||
/**
|
||||
* Tiers 1 and 2: install a curated catalog entry. Verified entries get an ordinary confirm; an
|
||||
* entry from an operator-added source gets the warning treatment and names its source, because
|
||||
* "pinned and integrity-checked" is not the same claim as "reviewed".
|
||||
*/
|
||||
export const InstallDialog: FC<{
|
||||
/** The entry being confirmed, or null when the dialog is closed. */
|
||||
entry: StoreEntry | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: (entry: StoreEntry) => void;
|
||||
isPending: boolean;
|
||||
}> = ({ entry, onCancel, onConfirm, isPending }) => {
|
||||
const external = entry?.tier === "external";
|
||||
return (
|
||||
<Dialog open={entry !== null} onOpenChange={(open) => !open && onCancel()}>
|
||||
{entry && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{external ? (
|
||||
<ShieldQuestion className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
||||
) : (
|
||||
<BadgeCheck className="size-5 shrink-0 text-[var(--success)]" />
|
||||
)}
|
||||
{external
|
||||
? m.store_install_external_title({ title: entry.title })
|
||||
: m.store_install_title({ title: entry.title })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{external
|
||||
? m.store_install_external_body({
|
||||
version: entry.version,
|
||||
source: entry.source,
|
||||
})
|
||||
: m.store_install_verified_body({ version: entry.version })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{entry.pkg}@{entry.version}
|
||||
</p>
|
||||
|
||||
{external && (
|
||||
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
|
||||
{m.store_install_external_note()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isPending}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
{/* Red is reserved for the raw-spec install; an external one escalates through
|
||||
its copy and its amber panel, not by borrowing the danger colour. */}
|
||||
<Button disabled={isPending} onClick={() => onConfirm(entry)}>
|
||||
{external
|
||||
? m.store_install_external_confirm()
|
||||
: m.store_install_confirm()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tier 3: install a raw package spec. No catalog, no review, no pinning — so the dialog spells out
|
||||
* exactly what that means and asks for two independent confirmations (retype the spec, tick the
|
||||
* box) before it will even enable its button.
|
||||
*/
|
||||
export const SpecInstallDialog: FC<{
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (spec: string) => void;
|
||||
isPending: boolean;
|
||||
}> = ({ open, onCancel, onConfirm, isPending }) => {
|
||||
const [spec, setSpec] = useState("");
|
||||
const [echo, setEcho] = useState("");
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
|
||||
// Both confirmations are cleared on every exit, cancel AND confirm alike: reopening this dialog
|
||||
// must never find it pre-armed with the last spec and a ticked box.
|
||||
const reset = () => {
|
||||
setSpec("");
|
||||
setEcho("");
|
||||
setAccepted(false);
|
||||
};
|
||||
const close = () => {
|
||||
reset();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const wanted = spec.trim();
|
||||
// Both gates must pass: the retyped spec matches exactly, AND the box is ticked.
|
||||
const ready = wanted.length > 0 && echo.trim() === wanted && accepted;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-5 shrink-0 text-destructive" />
|
||||
{m.store_spec_title()}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{m.store_spec_lead()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{m.store_spec_permanent()}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-spec">{m.store_spec_field()}</Label>
|
||||
<Input
|
||||
id="store-spec"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="@scope/plugin-name@1.2.3"
|
||||
value={spec}
|
||||
onChange={(e) => setSpec(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.store_spec_field_help()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-spec-echo">
|
||||
{m.store_spec_confirm_field()}
|
||||
</Label>
|
||||
<Input
|
||||
id="store-spec-echo"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={echo}
|
||||
onChange={(e) => setEcho(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Label className="flex items-start gap-3 text-sm font-normal">
|
||||
<Checkbox
|
||||
checked={accepted}
|
||||
onCheckedChange={(next) => setAccepted(next === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>{m.store_spec_checkbox()}</span>
|
||||
</Label>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={close} disabled={isPending}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!ready || isPending}
|
||||
onClick={() => {
|
||||
reset();
|
||||
onConfirm(wanted);
|
||||
}}
|
||||
>
|
||||
{m.store_spec_confirm()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ArrowUpCircle, Ban, Circle, Trash2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { type InstalledPlugin, useInstalledPlugins } from "@/api/store";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { RunnerCardSection } from "./Runner";
|
||||
import { SourceChip, TierBadge } from "./TierBadge";
|
||||
|
||||
/**
|
||||
* Container: what's installed. Owns the installed query; the runner switch above it owns its own.
|
||||
* Updating and uninstalling are escalated to the parent, which owns the confirm dialogs and the
|
||||
* resulting job card.
|
||||
*/
|
||||
export const InstalledTab: FC<{
|
||||
onUpdate: (plugin: InstalledPlugin) => void;
|
||||
onUninstall: (plugin: InstalledPlugin) => void;
|
||||
/** Package whose install/uninstall is in flight, or null — only that row's actions disable. */
|
||||
busyPkg: string | null;
|
||||
}> = ({ onUpdate, onUninstall, busyPkg }) => {
|
||||
const installed = useInstalledPlugins();
|
||||
return (
|
||||
<div className="flex flex-col gap-card">
|
||||
<RunnerCardSection />
|
||||
<InstalledList
|
||||
installed={installed}
|
||||
onUpdate={onUpdate}
|
||||
onUninstall={onUninstall}
|
||||
busyPkg={busyPkg}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* One row per installed plugin. The tier badge is permanent and non-negotiable: a plugin installed
|
||||
* from a raw spec stays marked unverified here for as long as it's on the host, no matter what it
|
||||
* later reports about itself.
|
||||
*/
|
||||
export const InstalledList: FC<{
|
||||
installed: Loadable<InstalledPlugin[]>;
|
||||
onUpdate: (plugin: InstalledPlugin) => void;
|
||||
onUninstall: (plugin: InstalledPlugin) => void;
|
||||
busyPkg: string | null;
|
||||
}> = ({ installed, onUpdate, onUninstall, busyPkg }) => {
|
||||
const rows = installed.data ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<CardHeader>
|
||||
<CardTitle>{m.store_installed_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<QueryState
|
||||
isLoading={installed.isLoading}
|
||||
error={installed.error}
|
||||
refetch={installed.refetch}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="p-card pt-0 text-sm text-muted-foreground">
|
||||
{m.store_installed_empty()}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableBody>
|
||||
{rows.map((p) => (
|
||||
<TableRow key={p.pkg} className="align-top">
|
||||
<TableCell className="py-4">
|
||||
<div className="font-medium">{p.title ?? p.pkg}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
{p.pkg}
|
||||
</div>
|
||||
{p.blocked !== undefined && (
|
||||
<p className="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">
|
||||
<Ban className="mt-px size-3.5 shrink-0" />
|
||||
<span>{m.store_blocked({ reason: p.blocked })}</span>
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<TierBadge tier={p.tier} />
|
||||
{p.tier === "external" && p.source && (
|
||||
<SourceChip source={p.source} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-4 text-sm tabular-nums text-muted-foreground">
|
||||
v{p.version}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Circle
|
||||
className={
|
||||
p.running
|
||||
? "size-2 fill-[var(--success)] text-[var(--success)]"
|
||||
: "size-2 fill-muted-foreground text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
{p.running ? m.store_running() : m.store_stopped()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{p.update_available !== undefined && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busyPkg === p.pkg}
|
||||
onClick={() => onUpdate(p)}
|
||||
>
|
||||
<ArrowUpCircle className="size-4" />
|
||||
{m.store_update_to({
|
||||
version: p.update_available,
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.store_uninstall()}
|
||||
disabled={busyPkg === p.pkg}
|
||||
onClick={() => onUninstall(p)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, RotateCw, X, XCircle } from "lucide-react";
|
||||
import { type FC, useEffect, useRef } from "react";
|
||||
import { invalidateStore, type StoreJob, useStoreJob } from "@/api/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// The host reports a job's progress as a free-form phase string; these are the ones it emits, in
|
||||
// order. Anything unrecognised falls through to the raw phase rather than an empty label, so a new
|
||||
// host phase degrades to English-but-visible instead of disappearing.
|
||||
const PHASES: Record<string, () => string> = {
|
||||
queued: () => m.store_phase_queued(),
|
||||
verifying: () => m.store_phase_verifying(),
|
||||
installing: () => m.store_phase_installing(),
|
||||
removing: () => m.store_phase_removing(),
|
||||
checking: () => m.store_phase_checking(),
|
||||
"rolling back": () => m.store_phase_rolling_back(),
|
||||
recording: () => m.store_phase_recording(),
|
||||
"restarting runner": () => m.store_phase_restarting(),
|
||||
done: () => m.store_phase_done(),
|
||||
};
|
||||
|
||||
const phaseLabel = (phase: string): string => PHASES[phase]?.() ?? phase;
|
||||
|
||||
/** Keep the tail — an install log can run long and only the end is ever interesting. */
|
||||
const LOG_TAIL = 200;
|
||||
|
||||
/**
|
||||
* Container: the in-flight install/uninstall. Polls the job once a second while it runs (the query
|
||||
* stops polling itself once the job settles), and refreshes everything the job touched — catalog,
|
||||
* installed list, runner state, plugin directory — the moment it finishes.
|
||||
*/
|
||||
export const JobProgressSection: FC<{
|
||||
jobId: string;
|
||||
onDismiss: () => void;
|
||||
}> = ({ jobId, onDismiss }) => {
|
||||
const qc = useQueryClient();
|
||||
const job = useStoreJob(jobId);
|
||||
const settled = job.data?.state === "done" || job.data?.state === "failed";
|
||||
// Refresh once per job, not on every re-render while the finished card sits there.
|
||||
const refreshed = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settled || refreshed.current === jobId) return;
|
||||
refreshed.current = jobId;
|
||||
invalidateStore(qc);
|
||||
}, [settled, jobId, qc]);
|
||||
|
||||
if (!job.data) return null;
|
||||
return <JobProgressCard job={job.data} onDismiss={onDismiss} />;
|
||||
};
|
||||
|
||||
/** The progress card: phase (or outcome), a collapsible log tail, and the failure reason if any. */
|
||||
export const JobProgressCard: FC<{
|
||||
job: StoreJob;
|
||||
onDismiss: () => void;
|
||||
}> = ({ job, onDismiss }) => {
|
||||
const running = job.state === "running";
|
||||
const failed = job.state === "failed";
|
||||
const log = job.log.slice(-LOG_TAIL);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={failed ? "ring-2 ring-destructive/60" : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
<CardContent className="space-y-3 p-card">
|
||||
<div className="flex items-start gap-3">
|
||||
{running ? (
|
||||
<Spinner className="mt-0.5 size-5 shrink-0" />
|
||||
) : failed ? (
|
||||
<XCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--success)]" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{job.kind === "uninstall"
|
||||
? m.store_job_uninstall({ target: job.target })
|
||||
: m.store_job_install({ target: job.target })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{running
|
||||
? phaseLabel(job.phase)
|
||||
: failed
|
||||
? m.store_job_failed()
|
||||
: job.kind === "uninstall"
|
||||
? m.store_job_done_uninstall()
|
||||
: m.store_job_done_install()}
|
||||
</p>
|
||||
</div>
|
||||
{!running && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.store_job_dismiss()}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failed && job.error && (
|
||||
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{job.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The runner is bounced at the end of every successful job, so the plugin's nav entry
|
||||
(and its UI) takes a moment longer to appear than the job's "done". Say so. */}
|
||||
{job.state === "done" && (
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<RotateCw className="size-3.5" />
|
||||
{m.store_job_restarting()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{log.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none text-xs text-muted-foreground transition-colors hover:text-foreground">
|
||||
{m.store_job_log()}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
|
||||
<code>{log.join("\n")}</code>
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Play, Power, PowerOff } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
type RuntimeStatus,
|
||||
useSetRuntime,
|
||||
useStoreRuntime,
|
||||
} from "@/api/store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// The plugin/script runner is the service every plugin actually executes inside. Installing a plugin
|
||||
// while it's switched off silently gets you nothing running, so Browse carries a banner and the
|
||||
// Installed tab carries the full switch.
|
||||
|
||||
/** Small helper both surfaces share: fire the toggle, surface a failure as a toast. */
|
||||
function useRunnerToggle() {
|
||||
const set = useSetRuntime();
|
||||
const toggle = (enabled: boolean) => {
|
||||
set.mutate(enabled, {
|
||||
onError: () => toast.error(m.store_runner_failed()),
|
||||
});
|
||||
};
|
||||
return { toggle, isPending: set.isPending };
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse-tab banner: the runner is installed but off, so nothing an operator installs here would
|
||||
* start. Renders nothing in every other state (including "not installed" — the Installed tab's card
|
||||
* explains that case properly).
|
||||
*/
|
||||
export const RunnerBanner: FC = () => {
|
||||
const runtime = useStoreRuntime();
|
||||
const { toggle, isPending } = useRunnerToggle();
|
||||
const s = runtime.data;
|
||||
if (!s?.installed || s.enabled) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-amber-600/40 bg-amber-500/10 p-4 text-sm text-amber-600 sm:flex-row sm:items-center dark:border-amber-500/40 dark:text-amber-500">
|
||||
<PowerOff className="size-5 shrink-0" />
|
||||
<p className="flex-1">{m.store_runner_banner()}</p>
|
||||
<Button size="sm" disabled={isPending} onClick={() => toggle(true)}>
|
||||
<Play className="size-4" />
|
||||
{m.store_runner_enable()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Container: the runner switch at the top of the Installed tab. Like the library's source toggles
|
||||
* this is a secondary control — it stays out of the way until the query resolves rather than
|
||||
* stacking a second error banner on top of the installed list's own.
|
||||
*/
|
||||
export const RunnerCardSection: FC = () => {
|
||||
const runtime = useStoreRuntime();
|
||||
const { toggle, isPending } = useRunnerToggle();
|
||||
if (!runtime.data) return null;
|
||||
return (
|
||||
<RunnerCard status={runtime.data} busy={isPending} onToggle={toggle} />
|
||||
);
|
||||
};
|
||||
|
||||
/** The runner card: what the service is, whether it's up, and the one switch that changes it. */
|
||||
export const RunnerCard: FC<{
|
||||
status: RuntimeStatus;
|
||||
busy: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}> = ({ status, busy, onToggle }) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between gap-3 text-base">
|
||||
<span>{m.store_runner_title()}</span>
|
||||
{!status.installed ? (
|
||||
<Badge variant="outline">{m.store_runner_state_missing()}</Badge>
|
||||
) : status.running ? (
|
||||
<Badge variant="success">{m.store_runner_state_running()}</Badge>
|
||||
) : status.enabled ? (
|
||||
<Badge variant="outline">{m.store_runner_state_stopped()}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{m.store_runner_state_disabled()}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{status.installed
|
||||
? m.store_runner_help()
|
||||
: m.store_runner_not_installed()}
|
||||
</p>
|
||||
<dl className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
|
||||
<div className="flex gap-2">
|
||||
<dt>{m.store_runner_unit()}</dt>
|
||||
<dd className="font-mono text-foreground">{status.unit}</dd>
|
||||
</div>
|
||||
{status.principal && (
|
||||
<div className="flex gap-2">
|
||||
<dt>{m.store_runner_principal()}</dt>
|
||||
<dd className="font-mono text-foreground">{status.principal}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
{status.detail && (
|
||||
<p className="text-xs text-muted-foreground">{status.detail}</p>
|
||||
)}
|
||||
{status.installed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={status.enabled ? "outline" : "default"}
|
||||
disabled={busy}
|
||||
onClick={() => onToggle(!status.enabled)}
|
||||
>
|
||||
{status.enabled ? (
|
||||
<PowerOff className="size-4" />
|
||||
) : (
|
||||
<Power className="size-4" />
|
||||
)}
|
||||
{status.enabled ? m.store_runner_disable() : m.store_runner_enable()}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -0,0 +1,347 @@
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Lock,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { type FC, type FormEvent, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import {
|
||||
type SourceBody,
|
||||
type StoreSource,
|
||||
useDeleteSource,
|
||||
useRefreshCatalog,
|
||||
useSetSource,
|
||||
useStoreSources,
|
||||
} from "@/api/store";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/** A source the operator has filled in but not yet agreed to trust. */
|
||||
type SourceDraft = SourceBody & { name: string };
|
||||
|
||||
/** Unix seconds → a locale date-time, or "never" for a source that has never fetched. */
|
||||
const fmtFetched = (secs: number): string =>
|
||||
secs > 0 ? new Date(secs * 1000).toLocaleString() : m.store_source_never();
|
||||
|
||||
/**
|
||||
* Container: the catalog sources. Owns the source listing, the refresh-all action, and add/remove.
|
||||
* Adding is a two-step: the form collects the source, and a one-time trust dialog states plainly
|
||||
* what trusting a third-party catalog means before anything is written to the host.
|
||||
*/
|
||||
export const SourcesTab: FC = () => {
|
||||
const sources = useStoreSources();
|
||||
const refresh = useRefreshCatalog();
|
||||
const save = useSetSource();
|
||||
const remove = useDeleteSource();
|
||||
// The draft waiting on the trust dialog, and a key that re-mounts (and so clears) the form.
|
||||
const [draft, setDraft] = useState<SourceDraft | null>(null);
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
|
||||
const onRefresh = () =>
|
||||
refresh.mutate(undefined, {
|
||||
onError: () => toast.error(m.store_refresh_failed()),
|
||||
});
|
||||
|
||||
const onConfirmAdd = async () => {
|
||||
if (!draft) return;
|
||||
try {
|
||||
await save.mutateAsync(draft);
|
||||
setDraft(null);
|
||||
setFormKey((k) => k + 1);
|
||||
} catch {
|
||||
toast.error(m.store_add_source_failed());
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = async (source: StoreSource) => {
|
||||
if (!confirm(m.store_source_remove_confirm({ name: source.name }))) return;
|
||||
try {
|
||||
await remove.mutateAsync(source.name);
|
||||
} catch (e) {
|
||||
// 403 is the host refusing to drop its built-in catalog — say exactly that.
|
||||
toast.error(
|
||||
e instanceof ApiError && e.status === 403
|
||||
? m.store_source_builtin_locked()
|
||||
: m.store_source_remove_failed(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-card">
|
||||
<SourceList
|
||||
sources={sources}
|
||||
busyName={remove.isPending ? (remove.variables ?? null) : null}
|
||||
isRefreshing={refresh.isPending}
|
||||
onRefresh={onRefresh}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
|
||||
<AddSourceForm
|
||||
key={formKey}
|
||||
onSubmit={setDraft}
|
||||
isSaving={save.isPending}
|
||||
/>
|
||||
|
||||
<TrustSourceDialog
|
||||
draft={draft}
|
||||
isSaving={save.isPending}
|
||||
onCancel={() => setDraft(null)}
|
||||
onConfirm={onConfirmAdd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The source table: health per source, with the built-in one locked. */
|
||||
export const SourceList: FC<{
|
||||
sources: {
|
||||
data?: StoreSource[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
refetch?: () => void;
|
||||
};
|
||||
/** Name of the source whose delete is in flight, or null. */
|
||||
busyName: string | null;
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onRemove: (source: StoreSource) => void;
|
||||
}> = ({ sources, busyName, isRefreshing, onRefresh, onRemove }) => {
|
||||
const rows = sources.data ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>{m.store_sources_title()}</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isRefreshing}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
className={isRefreshing ? "size-4 animate-spin" : "size-4"}
|
||||
/>
|
||||
{m.store_refresh_all()}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.store_sources_help()}
|
||||
</p>
|
||||
|
||||
<QueryState
|
||||
isLoading={sources.isLoading}
|
||||
error={sources.error}
|
||||
refetch={sources.refetch}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((s) => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-start"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
{s.builtin && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Lock className="size-3" />
|
||||
{m.store_source_builtin()}
|
||||
</Badge>
|
||||
)}
|
||||
{s.signed ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<ShieldCheck className="size-3" />
|
||||
{m.store_source_signed()}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500"
|
||||
>
|
||||
<ShieldOff className="size-3" />
|
||||
{m.store_source_unsigned()}
|
||||
</Badge>
|
||||
)}
|
||||
{s.stale && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500"
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
{m.store_source_stale()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{s.url}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.store_source_entries({ count: s.entry_count })} ·{" "}
|
||||
{m.store_source_fetched({ when: fmtFetched(s.fetched_at) })}
|
||||
</p>
|
||||
{s.error && (
|
||||
<p className="text-xs text-destructive">{s.error}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* The built-in catalog gets no delete button at all — not a disabled one. */}
|
||||
{!s.builtin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.store_source_remove()}
|
||||
disabled={busyName === s.name}
|
||||
onClick={() => onRemove(s)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</QueryState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** The add-source form. Reports a draft; the parent takes it through the trust dialog. */
|
||||
export const AddSourceForm: FC<{
|
||||
onSubmit: (draft: SourceDraft) => void;
|
||||
isSaving: boolean;
|
||||
}> = ({ onSubmit, isSaving }) => {
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const key = publicKey.trim();
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
public_key: key ? key : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{m.store_add_source_title()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-source-name">
|
||||
{m.store_field_source_name()}
|
||||
</Label>
|
||||
<Input
|
||||
id="store-source-name"
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-source-url">
|
||||
{m.store_field_source_url()}
|
||||
</Label>
|
||||
<Input
|
||||
id="store-source-url"
|
||||
required
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="store-source-key">
|
||||
{m.store_field_source_key()}
|
||||
</Label>
|
||||
<Input
|
||||
id="store-source-key"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="ed25519:…"
|
||||
value={publicKey}
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{m.store_field_source_key_help()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || !name.trim() || !url.trim()}
|
||||
>
|
||||
{m.store_add_source()}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** The one-time trust warning shown before a third-party catalog is written to the host. */
|
||||
export const TrustSourceDialog: FC<{
|
||||
draft: SourceDraft | null;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}> = ({ draft, isSaving, onCancel, onConfirm }) => (
|
||||
<Dialog open={draft !== null} onOpenChange={(open) => !open && onCancel()}>
|
||||
{draft && (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
||||
{m.store_source_trust_title()}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{m.store_source_trust_body({ name: draft.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="rounded-md bg-muted px-3 py-2 font-mono text-xs break-all text-muted-foreground">
|
||||
{draft.url}
|
||||
</p>
|
||||
|
||||
{!draft.public_key && (
|
||||
<p className="rounded-md border border-amber-600/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-600 dark:border-amber-500/40 dark:text-amber-500">
|
||||
{m.store_source_trust_unsigned()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
{m.common_cancel()}
|
||||
</Button>
|
||||
<Button disabled={isSaving} onClick={onConfirm}>
|
||||
{m.store_source_trust_confirm()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
BadgeCheck,
|
||||
ShieldAlert,
|
||||
ShieldQuestion,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { StoreTier } from "@/api/store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
// The trust model, rendered. These badges are the only place the console makes a claim about where
|
||||
// a plugin's code came from, so they are deliberately literal: ONLY the built-in `unom` source earns
|
||||
// the check mark, everything else says out loud that nobody at unom looked at the code. An entry
|
||||
// from an operator-added source additionally carries a <SourceChip> naming that source — attribution
|
||||
// instead of a verification it hasn't got.
|
||||
|
||||
/** The short, permanent provenance badge for a tier. */
|
||||
export const TierBadge: FC<{ tier: StoreTier; className?: string }> = ({
|
||||
tier,
|
||||
className,
|
||||
}) => {
|
||||
switch (tier) {
|
||||
case "verified":
|
||||
return (
|
||||
<Badge
|
||||
variant="success"
|
||||
className={cn("gap-1", className)}
|
||||
title={m.store_tier_verified_hint()}
|
||||
>
|
||||
<BadgeCheck className="size-3.5" />
|
||||
{m.store_tier_verified()}
|
||||
</Badge>
|
||||
);
|
||||
case "external":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"gap-1 border-amber-600/40 text-amber-600 dark:border-amber-500/40 dark:text-amber-500",
|
||||
className,
|
||||
)}
|
||||
title={m.store_tier_external_hint()}
|
||||
>
|
||||
<ShieldQuestion className="size-3.5" />
|
||||
{m.store_tier_external()}
|
||||
</Badge>
|
||||
);
|
||||
case "unverified":
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className={cn("gap-1", className)}
|
||||
title={m.store_tier_unverified_hint()}
|
||||
>
|
||||
<ShieldAlert className="size-3.5" />
|
||||
{m.store_tier_unverified()}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("gap-1", className)}
|
||||
title={m.store_tier_cli_hint()}
|
||||
>
|
||||
<Terminal className="size-3.5" />
|
||||
{m.store_tier_cli()}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** "from <source>" — who curated this entry. Shown for anything not from the built-in source. */
|
||||
export const SourceChip: FC<{ source: string; className?: string }> = ({
|
||||
source,
|
||||
className,
|
||||
}) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{m.store_from_source()}
|
||||
<span className="font-medium text-foreground">{source}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -0,0 +1,149 @@
|
||||
import Section from "@unom/ui/section";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { type FC, useState } from "react";
|
||||
import { ApiError } from "@/api/fetcher";
|
||||
import {
|
||||
type InstallBody,
|
||||
type InstalledPlugin,
|
||||
type StoreEntry,
|
||||
useInstallPlugin,
|
||||
useStoreCatalog,
|
||||
useUninstallPlugin,
|
||||
} from "@/api/store";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { BrowseTab } from "./Browse";
|
||||
import { InstallDialog, SpecInstallDialog } from "./InstallDialogs";
|
||||
import { InstalledTab } from "./Installed";
|
||||
import { JobProgressSection } from "./JobProgress";
|
||||
import { SourcesTab } from "./Sources";
|
||||
|
||||
type StoreTab = "browse" | "installed" | "sources";
|
||||
|
||||
/**
|
||||
* The plugin store: browse a catalog, manage what's installed, and choose which catalogs this host
|
||||
* trusts. Each tab owns its own queries; this container owns only what genuinely spans them — the
|
||||
* install/uninstall mutations, the confirm dialogs their trust tier dictates, and the job the host
|
||||
* hands back (which must stay visible whichever tab you switch to while it runs).
|
||||
*/
|
||||
export const SectionStore: FC = () => {
|
||||
useLocale();
|
||||
const [tab, setTab] = useState<StoreTab>("browse");
|
||||
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
|
||||
const [target, setTarget] = useState<StoreEntry | null>(null);
|
||||
const [specOpen, setSpecOpen] = useState(false);
|
||||
// The job the host is running for us, if any. Cleared by the operator, not by completion — a
|
||||
// finished job's log is the only record of what happened.
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
|
||||
const catalog = useStoreCatalog();
|
||||
const install = useInstallPlugin();
|
||||
const uninstall = useUninstallPlugin();
|
||||
|
||||
/** Turn a failed 202-request into a message: 409 means the host is busy, not that we're broken. */
|
||||
const failed = (e: unknown, fallback: string) =>
|
||||
toast.error(
|
||||
e instanceof ApiError && e.status === 409 ? m.store_busy() : fallback,
|
||||
);
|
||||
|
||||
const start = async (body: InstallBody) => {
|
||||
try {
|
||||
const { job } = await install.mutateAsync(body);
|
||||
setJobId(job);
|
||||
} catch (e) {
|
||||
failed(e, m.store_install_failed());
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmEntry = async (entry: StoreEntry) => {
|
||||
setTarget(null);
|
||||
await start({ source: entry.source, id: entry.id });
|
||||
};
|
||||
|
||||
const onConfirmSpec = async (spec: string) => {
|
||||
setSpecOpen(false);
|
||||
await start({ spec, accept_unverified: true });
|
||||
};
|
||||
|
||||
// An update from the Installed tab installs the CATALOG version — so it goes through the very
|
||||
// same tier-appropriate dialog a fresh install would, warning included.
|
||||
const onUpdate = (plugin: InstalledPlugin) => {
|
||||
const entry = catalog.data?.plugins.find((e) => e.pkg === plugin.pkg);
|
||||
if (!entry) {
|
||||
toast.error(m.store_update_no_entry());
|
||||
return;
|
||||
}
|
||||
setTarget(entry);
|
||||
};
|
||||
|
||||
const onUninstall = async (plugin: InstalledPlugin) => {
|
||||
if (
|
||||
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const { job } = await uninstall.mutateAsync(plugin.pkg);
|
||||
setJobId(job);
|
||||
} catch (e) {
|
||||
failed(e, m.store_uninstall_failed());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">{m.store_title()}</h1>
|
||||
<p className="text-sm text-muted-foreground">{m.store_subtitle()}</p>
|
||||
</div>
|
||||
|
||||
{jobId && (
|
||||
<JobProgressSection jobId={jobId} onDismiss={() => setJobId(null)} />
|
||||
)}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as StoreTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="browse">{m.store_tab_browse()}</TabsTrigger>
|
||||
<TabsTrigger value="installed">
|
||||
{m.store_tab_installed()}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sources">{m.store_tab_sources()}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="browse">
|
||||
<BrowseTab
|
||||
onInstall={setTarget}
|
||||
onInstallSpec={() => setSpecOpen(true)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="installed">
|
||||
<InstalledTab
|
||||
onUpdate={onUpdate}
|
||||
onUninstall={onUninstall}
|
||||
busyPkg={
|
||||
uninstall.isPending ? (uninstall.variables ?? null) : null
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="sources">
|
||||
<SourcesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<InstallDialog
|
||||
entry={target}
|
||||
isPending={install.isPending}
|
||||
onCancel={() => setTarget(null)}
|
||||
onConfirm={onConfirmEntry}
|
||||
/>
|
||||
<SpecInstallDialog
|
||||
open={specOpen}
|
||||
isPending={install.isPending}
|
||||
onCancel={() => setSpecOpen(false)}
|
||||
onConfirm={onConfirmSpec}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user