From aa9e0f02b7bc1b148be7088c56aa47b0fb2dd111 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 16 Jun 2026 09:27:29 -0400 Subject: [PATCH] fix(cloud): pin peer file-card filename + action buttons to the bottom (#11) Make each peer file card a flex column filling its grid cell (flex flex-col h-full) and pin the body row (filename + Play/Download) with mt-auto, so cards with a media preview and cards without line their footers up across the row. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/Cargo.lock | 2 +- core/archipelago/src/api/rpc/dispatcher.rs | 1 + .../archipelago/src/api/rpc/package/update.rs | 50 ++- core/archipelago/src/container/app_catalog.rs | 343 ++++++++++++++++++ .../src/container/docker_packages.rs | 4 +- .../src/container/image_versions.rs | 4 +- core/archipelago/src/container/mod.rs | 1 + .../src/container/prod_orchestrator.rs | 24 +- core/archipelago/src/update.rs | 17 + docs/app-developer-guide.md | 44 +++ neode-ui/src/api/rpc-client.ts | 15 + neode-ui/src/views/PeerFiles.vue | 7 +- scripts/generate-app-catalog.sh | 134 +++++++ 13 files changed, 631 insertions(+), 15 deletions(-) create mode 100644 core/archipelago/src/container/app_catalog.rs create mode 100755 scripts/generate-app-catalog.sh diff --git a/core/Cargo.lock b/core/Cargo.lock index 7f6045f7..b1d65f8f 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "archipelago" -version = "1.7.96-alpha" +version = "1.7.97-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 1e3b624d..88f1fd70 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -55,6 +55,7 @@ impl RpcHandler { "package.restart" => self.handle_package_restart(params).await, "package.uninstall" => self.clone().spawn_package_uninstall(params).await, "package.update" => self.clone().spawn_package_update(params).await, + "package.check-updates" => self.handle_package_check_updates(params).await, "package.credentials" => self.handle_package_credentials(params).await, "app.filebrowser-token" => self.handle_filebrowser_token().await, diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs index f48044a6..af5203b4 100644 --- a/core/archipelago/src/api/rpc/package/update.rs +++ b/core/archipelago/src/api/rpc/package/update.rs @@ -32,8 +32,11 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; validate_app_id(package_id)?; - // Verify an update is actually available - let pinned = image_versions::pinned_image_for_app(package_id) + // Verify an update is actually available. Prefer the remote app catalog + // (decoupled from the binary OTA), falling back to the image-versions.sh + // pin when the catalog is absent or doesn't cover this app. + let pinned = crate::container::app_catalog::catalog_primary_image(package_id) + .or_else(|| image_versions::pinned_image_for_app(package_id)) .ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?; // Note: the `already updating` guard lives in `spawn_package_update` @@ -149,6 +152,28 @@ impl RpcHandler { } } + /// Manual "check for updates": refresh the remote app catalog now. The + /// package scanner recomputes each app's `available-update` from the fresh + /// catalog on its next cycle and pushes it to the UI. Best-effort — a fetch + /// failure leaves the cached catalog in place and reports `refreshed: false`. + pub(in crate::api::rpc) async fn handle_package_check_updates( + &self, + _params: Option, + ) -> Result { + match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await { + Ok(count) => Ok(serde_json::json!({ + "status": "ok", + "refreshed": true, + "catalog_apps": count, + })), + Err(e) => Ok(serde_json::json!({ + "status": "ok", + "refreshed": false, + "error": e.to_string(), + })), + } + } + /// Core update execution: stop → pull → remove → recreate → verify. async fn execute_update( &self, @@ -385,13 +410,24 @@ impl RpcHandler { package_id: &str, pinned_primary: &str, ) -> Vec<(String, String)> { - let stack_images = image_versions::pinned_images_for_stack(package_id); + let mut stack_images = image_versions::pinned_images_for_stack(package_id); if stack_images.is_empty() { - // Single container app - vec![(package_id.to_string(), pinned_primary.to_string())] - } else { - stack_images + // Single container app — pinned_primary already prefers the catalog. + return vec![(package_id.to_string(), pinned_primary.to_string())]; } + // Stack app: override per-container images with the catalog where it + // provides them; components the catalog omits keep the image-versions.sh + // pin. This lets a single component (e.g. the IndeeHub frontend) be + // bumped without touching the rest of the stack. + let catalog_images = crate::container::app_catalog::catalog_stack_images(package_id); + if !catalog_images.is_empty() { + for (name, image) in stack_images.iter_mut() { + if let Some(catalog_image) = catalog_images.get(name) { + *image = catalog_image.clone(); + } + } + } + stack_images } /// Rollback: restart old containers if they still exist. diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs new file mode 100644 index 00000000..9253070e --- /dev/null +++ b/core/archipelago/src/container/app_catalog.rs @@ -0,0 +1,343 @@ +//! Remote app version catalog — DECOUPLES per-app updates from the binary OTA. +//! +//! Background: `image_versions.rs` reads the pinned image tags from +//! `image-versions.sh`, which is deployed *with the archipelago binary*. That +//! coupled every app update to a full node release. This module adds a remote +//! catalog (`app-catalog.json`) fetched over HTTP from the same origin as the +//! OTA manifest, refreshed periodically and on demand. Bumping an app's version +//! is then a JSON edit + push — no binary release. +//! +//! Resolution order (origin-always-wins, matching the DHT design's posture): +//! 1. Remote catalog (this module) — the live source of "available update". +//! 2. `image-versions.sh` pin — offline/baseline fallback when the catalog is +//! missing or doesn't cover the app. +//! +//! ## Forward-compatibility with the DHT distribution plan +//! (`docs/dht-distribution-design.md`) +//! This catalog IS the "discovery / authenticity" layer of that plan. The schema +//! is deliberately extensible so the later phases bolt on WITHOUT a breaking +//! change: +//! - `signature` / `signed_by` (top level) — Phase 0 seed-derived release-root +//! signature over the canonical JSON. Absent today; verified when present. +//! - per-image `digest` / `size` — BLAKE3/SHA-256 content address + length, so +//! the iroh swarm can fetch images by hash with the registry as origin. +//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the +//! publisher side never breaks older nodes. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; +use tracing::{debug, info, warn}; + +/// Filename for both the published catalog and the on-node cache. +pub const APP_CATALOG_FILE: &str = "app-catalog.json"; + +/// Cache of the parsed catalog, invalidated when the cache file mtime changes. +static CACHE: Mutex> = Mutex::new(None); + +struct CacheEntry { + mtime: SystemTime, + catalog: AppCatalog, +} + +/// Top-level catalog document. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AppCatalog { + /// Schema version. 1 = current. Bump only on incompatible changes. + #[serde(default)] + pub schema: u32, + /// Publish date (RFC 3339 or YYYY-MM-DD). Informational. + #[serde(default)] + pub updated: String, + /// app_id -> entry. + #[serde(default)] + pub apps: HashMap, + /// DHT-plan forward-compat: detached signature over the canonical JSON, + /// produced by the seed-derived release-root key. Absent today. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, + /// DHT-plan forward-compat: publisher identity (did:key / npub). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signed_by: Option, +} + +/// Per-app catalog entry. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AppCatalogEntry { + /// User-facing version string (drives the "Update available" badge text). + pub version: String, + /// Primary single-container image reference (`registry/repo:tag`). For stack + /// apps this is the primary container's image (the one whose version the + /// badge tracks — e.g. the IndeeHub frontend). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image: Option, + /// Stack apps only: container_name -> image reference. Components omitted here + /// fall back to the `image-versions.sh` pin during an update. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub images: Option>, + /// DHT-plan forward-compat: content address of the primary image (unused now). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option, + /// DHT-plan forward-compat: size in bytes of the primary image (unused now). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Optional human-readable changelog lines for this version. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub changelog: Vec, +} + +/// Read-side cache file search order. Mirrors `image_versions.rs`: the running +/// daemon's data dir first (via env for dev), then the canonical runtime path. +fn cache_paths() -> Vec { + let mut paths = Vec::new(); + if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") { + paths.push(Path::new(&dir).join(APP_CATALOG_FILE)); + } + paths.push(Path::new("/var/lib/archipelago").join(APP_CATALOG_FILE)); + paths +} + +fn find_cache_file() -> Option<(PathBuf, SystemTime)> { + for p in cache_paths() { + if let Ok(meta) = p.metadata() { + if let Ok(mtime) = meta.modified() { + return Some((p, mtime)); + } + } + } + None +} + +/// Load and cache the on-node catalog. Returns an empty catalog when absent — +/// callers then fall back to `image-versions.sh`. +fn load_catalog() -> AppCatalog { + let (path, mtime) = match find_cache_file() { + Some(v) => v, + None => return AppCatalog::default(), + }; + + { + let cache = CACHE.lock().unwrap(); + if let Some(ref entry) = *cache { + if entry.mtime == mtime { + return entry.catalog.clone(); + } + } + } + + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + debug!("app-catalog: failed to read {}: {}", path.display(), e); + return AppCatalog::default(); + } + }; + let catalog: AppCatalog = match serde_json::from_str(&content) { + Ok(c) => c, + Err(e) => { + warn!("app-catalog: invalid JSON at {}: {}", path.display(), e); + return AppCatalog::default(); + } + }; + + { + let mut cache = CACHE.lock().unwrap(); + *cache = Some(CacheEntry { + mtime, + catalog: catalog.clone(), + }); + } + catalog +} + +fn entry_for(app_id: &str) -> Option { + load_catalog().apps.get(app_id).cloned() +} + +/// Primary image for an app per the remote catalog, if covered. +pub fn catalog_primary_image(app_id: &str) -> Option { + entry_for(app_id).and_then(|e| e.image) +} + +/// Per-container stack image overrides from the catalog (container_name -> image). +pub fn catalog_stack_images(app_id: &str) -> HashMap { + entry_for(app_id).and_then(|e| e.images).unwrap_or_default() +} + +/// Image override for the orchestrator's install/upgrade path. Returns the +/// catalog's primary image for `app_id` ONLY when it refers to the same +/// repository as the manifest's current image — a guard so a catalog typo can +/// never redirect an app to an unrelated image. `None` means "use the manifest +/// image as-is" (catalog absent, app uncovered, or repo mismatch). +pub fn catalog_image_override(app_id: &str, manifest_image: &str) -> Option { + let candidate = catalog_primary_image(app_id)?; + let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate) + == crate::container::image_versions::image_without_registry_or_tag(manifest_image); + if same_repo { + Some(candidate) + } else { + warn!( + "app-catalog: ignoring image for {} — repo mismatch (catalog={}, manifest={})", + app_id, candidate, manifest_image + ); + None + } +} + +/// Decoupled "available update" check for ALL apps. +/// +/// Prefers the remote catalog; when the catalog covers the app, its verdict is +/// authoritative (so we never advertise a stale `image-versions.sh` pin over a +/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the +/// catalog is missing or doesn't cover the app. +pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option { + if let Some(catalog_image) = catalog_primary_image(app_id) { + // Catalog covers this app with a concrete image -> authoritative. + return crate::container::image_versions::available_update_for_images( + &catalog_image, + running_image, + ); + } + // Not covered by the catalog -> baseline pin from image-versions.sh. + crate::container::image_versions::available_update_for_app(app_id, running_image) +} + +/// Derive candidate catalog URLs from the OTA mirror list by swapping the +/// manifest filename for the catalog filename. Falls back to the default +/// manifest origin when no mirrors are configured. +fn catalog_urls_from_mirrors(mirrors: &[crate::update::UpdateMirror]) -> Vec { + let mut urls: Vec = mirrors + .iter() + .filter_map(|m| { + // mirror.url ends with ".../releases/manifest.json" + if m.url.ends_with("manifest.json") { + Some(m.url.replace("manifest.json", APP_CATALOG_FILE)) + } else { + None + } + }) + .collect(); + urls.dedup(); + urls +} + +/// Fetch the catalog from the first reachable mirror and atomically write it to +/// `/app-catalog.json`. Returns the number of apps in the catalog on +/// success. Best-effort: a fetch failure leaves the existing cache untouched +/// (origin-always-wins; updates simply aren't refreshed this cycle). +pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result { + let mirrors = crate::update::load_mirrors(data_dir) + .await + .unwrap_or_default(); + let urls = catalog_urls_from_mirrors(&mirrors); + if urls.is_empty() { + debug!("app-catalog: no mirror-derived URLs to fetch from"); + return Ok(0); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build()?; + + let mut last_err: Option = None; + for url in &urls { + match fetch_one(&client, url).await { + Ok(catalog) => { + let count = catalog.apps.len(); + write_cache(data_dir, &catalog)?; + // Invalidate the in-process cache so the next read re-parses. + *CACHE.lock().unwrap() = None; + info!("app-catalog: refreshed from {} ({} apps)", url, count); + return Ok(count); + } + Err(e) => { + debug!("app-catalog: fetch {} failed: {}", url, e); + last_err = Some(e); + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable"))) +} + +async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result { + let resp = client.get(url).send().await?; + if !resp.status().is_success() { + anyhow::bail!("HTTP {}", resp.status()); + } + let body = resp.text().await?; + let catalog: AppCatalog = serde_json::from_str(&body)?; + // NOTE (DHT Phase 0): when `catalog.signature` is present, verify it against + // the seed-derived release-root pubkey here before accepting. Until signing + // ships we accept unsigned catalogs (same trust level as today's manifest). + Ok(catalog) +} + +fn write_cache(data_dir: &Path, catalog: &AppCatalog) -> anyhow::Result<()> { + let dest = data_dir.join(APP_CATALOG_FILE); + let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE)); + let json = serde_json::to_string_pretty(catalog)?; + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, &dest)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_ignores_unknown_fields() { + let json = r#"{ + "schema": 1, + "updated": "2026-06-16", + "future_field": "ignored", + "signature": "sig123", + "signed_by": "did:key:zABC", + "apps": { + "indeedhub": { + "version": "1.0.1", + "image": "146.59.87.168:3000/lfg2025/indeedhub:1.0.1", + "digest": "blake3:deadbeef", + "size": 12345, + "another_future_field": true + } + } + }"#; + let cat: AppCatalog = serde_json::from_str(json).unwrap(); + assert_eq!(cat.schema, 1); + assert_eq!(cat.signature.as_deref(), Some("sig123")); + let e = cat.apps.get("indeedhub").unwrap(); + assert_eq!(e.version, "1.0.1"); + assert_eq!( + e.image.as_deref(), + Some("146.59.87.168:3000/lfg2025/indeedhub:1.0.1") + ); + assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef")); + } + + #[test] + fn empty_catalog_when_absent_is_default() { + let cat = AppCatalog::default(); + assert!(cat.apps.is_empty()); + assert!(cat.signature.is_none()); + } + + #[test] + fn catalog_url_derived_from_mirror() { + let mirrors = vec![crate::update::UpdateMirror { + url: "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json" + .to_string(), + label: "Server 1".to_string(), + }]; + let urls = catalog_urls_from_mirrors(&mirrors); + assert_eq!( + urls, + vec![ + "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json" + .to_string() + ] + ); + } +} diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 7a0c87fa..12816d48 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -172,8 +172,10 @@ impl DockerPackageScanner { // Extract actual version from container image tag let running_version = image_versions::extract_version_from_image(&container.image); + // Decoupled from the binary OTA: prefer the remote app catalog, + // falling back to the image-versions.sh pin when uncovered/offline. let available_update = - image_versions::available_update_for_app(&app_id, &container.image); + crate::container::app_catalog::available_update_for_app(&app_id, &container.image); let package = PackageDataEntry { state: package_state.clone(), diff --git a/core/archipelago/src/container/image_versions.rs b/core/archipelago/src/container/image_versions.rs index 6d9a8221..cf543aed 100644 --- a/core/archipelago/src/container/image_versions.rs +++ b/core/archipelago/src/container/image_versions.rs @@ -213,7 +213,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option Option { +pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option { let pinned_version = extract_version_from_image(&pinned); if is_floating_tag(&pinned_version) { return None; @@ -255,7 +255,7 @@ fn is_floating_tag(tag: &str) -> bool { matches!(tag, "latest" | "stable" | "release" | "main") } -fn image_without_registry_or_tag(image: &str) -> &str { +pub fn image_without_registry_or_tag(image: &str) -> &str { let without_tag = strip_tag(image); match without_tag.split_once('/') { Some((first, rest)) diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index c72ce626..43a57ee5 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -1,3 +1,4 @@ +pub mod app_catalog; pub mod bitcoin_ui; pub mod boot_reconciler; pub mod companion; diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 6d52bdfc..83abfb9c 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1385,7 +1385,29 @@ impl ProdContainerOrchestrator { let mut resolved_manifest = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved_manifest)?; - let resolved = lm.manifest.app.container.resolve().ok_or_else(|| { + // Decouple the app image from the shipped manifest: prefer the remote + // app catalog when it covers this app with a same-repo image. This makes + // both the pull below and create_container() below use the catalog tag, + // so an app update no longer requires a binary/runtime release. Falls + // back to the manifest image when the catalog is absent/uncovered. + if let Some(current) = resolved_manifest.app.container.image.clone() { + if let Some(catalog_image) = crate::container::app_catalog::catalog_image_override( + &resolved_manifest.app.id, + ¤t, + ) { + if catalog_image != current { + tracing::info!( + app_id = %resolved_manifest.app.id, + from = %current, + to = %catalog_image, + "app-catalog: overriding manifest image" + ); + resolved_manifest.app.container.image = Some(catalog_image); + } + } + } + + let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| { anyhow::anyhow!( "manifest for {} has invalid container source (neither image nor build)", lm.manifest.app.id diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index b3806126..03bfae1f 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -1507,9 +1507,26 @@ pub async fn run_update_scheduler(data_dir: std::path::PathBuf) { // Check every hour; act based on schedule setting let mut tick = interval(Duration::from_secs(3600)); + // Refresh the app catalog once at startup so per-app "update available" + // badges appear without waiting for the first hourly tick. + if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await { + debug!( + "Update scheduler: initial app-catalog refresh failed: {}", + e + ); + } + loop { tick.tick().await; + // App-catalog refresh is INDEPENDENT of the OTA schedule below: it only + // populates per-app update availability (the "Update" button still has + // to be clicked — nothing auto-applies). Best-effort; on failure the + // previously cached catalog stays in place (origin-always-wins). + if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await { + debug!("Update scheduler: app-catalog refresh failed: {}", e); + } + let state = match load_state(&data_dir).await { Ok(s) => s, Err(e) => { diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index 202143cc..86e0785f 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -156,6 +156,50 @@ underscores. Supported interface types are `ui`, `api`, and `metrics`; only `type: ui` is treated as a launchable app surface. Supported protocols are `http` and `https`, and `path` must start with `/`. +### Nostr Signer Bridge (NIP-07) + +Apps embedded in the Archipelago iframe can use the node's Nostr identity to sign +events without managing their own keys. Archipelago injects a **NIP-07 provider** +(`window.nostr` with `getPublicKey()` / `signEvent()` / `nip04` / `nip44`) that bridges +to the host. Your app code uses standard NIP-07 — no Archipelago-specific API. + +**How injection works.** After install, the host copies `nostr-provider.js` into the +app container and patches the app's web server so every page loads it and the app is +iframe-embeddable. This is **best-effort** and depends on your server config exposing +the right hooks. For an **nginx-served SPA** (the supported reference shape, e.g. +IndeeHub) your `nginx.conf` must satisfy this contract: + +1. **Be iframe-embeddable.** Do not send a hard `X-Frame-Options: DENY`. The host + strips a `SAMEORIGIN`/`DENY` `X-Frame-Options` header line if present; restrictive + CSP `frame-ancestors` will still block embedding. +2. **Keep an exact-match `location = /sw.js {` block.** The provider's no-cache + `location = /nostr-provider.js` block is inserted immediately before it. +3. **Keep an SPA fallback line `try_files $uri $uri/ /index.html;`.** A + `sub_filter` that injects `` before + `` is inserted right after it. (nginx must have `ngx_http_sub_module` — + stock `nginx:alpine` does.) +4. **If you proxy an API that does NIP-98 URL verification**, expose + `proxy_set_header X-Forwarded-Prefix /api;`; the host rewrites it to honor the + outer reverse proxy's prefix. + +The patch is **idempotent** (it checks for an existing `nostr-provider` reference +before editing) and re-runs on reinstall. If you rename or remove any of the anchor +strings above, injection silently no-ops and `window.nostr` will be undefined in your +app — so guard those lines in your config (see the contract comment block at the top of +IndeeHub's `nginx.conf` for a template). + +> Non-nginx servers (Next.js `node server.js`, etc.) are not auto-patched today. Either +> serve via nginx, or ship `nostr-provider.js` yourself and reference it in your HTML; +> the canonical script lives at `/opt/archipelago/web-ui/nostr-provider.js` on the node. + +Declare iframe intent in the manifest so the launcher embeds (vs. opens a new tab): + +```yaml +metadata: + launch: + open_in_new_tab: false # default; set true only if the app cannot be iframed +``` + ## Security Requirements These are enforced by the marketplace/catalog pipeline and the node. Non-compliant apps are flagged. diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index c55363ff..1857ea07 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -586,6 +586,21 @@ class RPCClient { }) } + async checkPackageUpdates(): Promise<{ + status: string + refreshed: boolean + catalog_apps?: number + error?: string + }> { + // Refreshes the remote app catalog now (decoupled from the binary OTA). + // Per-app `available-update` badges repopulate on the next package scan + // and arrive via the usual WebSocket push. + return this.call({ + method: 'package.check-updates', + timeout: 25000, + }) + } + async getMarketplace(url: string): Promise> { return this.call({ method: 'marketplace.get', diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 05558b4f..08c34242 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -90,7 +90,7 @@
- -
+ +
diff --git a/scripts/generate-app-catalog.sh b/scripts/generate-app-catalog.sh new file mode 100755 index 00000000..f3648c54 --- /dev/null +++ b/scripts/generate-app-catalog.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Generate releases/app-catalog.json — the REMOTE per-app version catalog that +# decouples app updates from the binary OTA (see +# core/.../container/app_catalog.rs and docs/dht-distribution-design.md). +# +# Nodes fetch this file over HTTP from the OVH origin (same host as the OTA +# manifest), compare each app's catalog version against the running container +# tag, and light up the per-app "Update" button — no node release required. +# +# The app_id -> image-variable mapping below MIRRORS +# core/archipelago/src/container/image_versions.rs (image_var_for_app + +# containers_for_stack). image_versions.rs is the canonical mapping; keep this in +# sync when you add an app there. +# +# Usage: +# scripts/generate-app-catalog.sh [output-path] +# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="${1:-$ROOT/releases/app-catalog.json}" + +# Export every *_IMAGE var (and ARCHY_REGISTRY) so python can read them. +set -a +# shellcheck disable=SC1091 +source "$ROOT/scripts/image-versions.sh" +set +a + +UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" python3 - <<'PY' +import json, os + +def img(var): + v = os.environ.get(var) + return v if v else None + +def tag(image): + # version = tag after the LAST colon that follows the last slash + if not image: + return None + tail = image.rsplit('/', 1)[-1] + return tail.rsplit(':', 1)[1] if ':' in tail else 'latest' + +# Single-container apps: app_id -> primary image variable. +SINGLE = { + "bitcoin-knots": "BITCOIN_KNOTS_IMAGE", + "lnd": "LND_IMAGE", + "electrumx": "ELECTRUMX_IMAGE", + "bitcoin-ui": "BITCOIN_UI_IMAGE", + "lnd-ui": "LND_UI_IMAGE", + "electrs-ui": "ELECTRS_UI_IMAGE", + "homeassistant": "HOMEASSISTANT_IMAGE", + "grafana": "GRAFANA_IMAGE", + "uptime-kuma": "UPTIME_KUMA_IMAGE", + "jellyfin": "JELLYFIN_IMAGE", + "photoprism": "PHOTOPRISM_IMAGE", + "ollama": "OLLAMA_IMAGE", + "vaultwarden": "VAULTWARDEN_IMAGE", + "nextcloud": "NEXTCLOUD_IMAGE", + "searxng": "SEARXNG_IMAGE", + "cryptpad": "CRYPTPAD_IMAGE", + "filebrowser": "FILEBROWSER_IMAGE", + "nginx-proxy-manager": "NPM_IMAGE", + "portainer": "PORTAINER_IMAGE", + "tailscale": "TAILSCALE_IMAGE", + "fedimint": "FEDIMINT_IMAGE", + "fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE", + "nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE", + "nostr-vpn": "NOSTR_VPN_IMAGE", + "fips": "FIPS_IMAGE", + "routstr": "ROUTSTR_IMAGE", + "adguardhome": "ADGUARDHOME_IMAGE", +} + +# Stack apps: app_id -> {container_name: image variable}. The FIRST entry is the +# primary (its version drives the badge); it is also emitted as `image`. +STACK = { + "indeedhub": { + "indeedhub": "INDEEDHUB_IMAGE", + "indeedhub-api": "INDEEDHUB_API_IMAGE", + "indeedhub-ffmpeg": "INDEEDHUB_FFMPEG_IMAGE", + }, + "immich": { + "immich_server": "IMMICH_SERVER_IMAGE", + "immich_postgres": "IMMICH_POSTGRES_IMAGE", + "immich_redis": "REDIS_IMAGE", + }, + "penpot": { + "penpot-frontend": "PENPOT_FRONTEND_IMAGE", + "penpot-backend": "PENPOT_BACKEND_IMAGE", + "penpot-exporter": "PENPOT_EXPORTER_IMAGE", + "penpot-postgres": "PENPOT_POSTGRES_IMAGE", + "penpot-valkey": "PENPOT_VALKEY_IMAGE", + }, + "mempool": { + "archy-mempool-web": "MEMPOOL_WEB_IMAGE", + "mempool-api": "MEMPOOL_BACKEND_IMAGE", + "archy-mempool-db": "MARIADB_IMAGE", + }, + "btcpay": { + "btcpay-server": "BTCPAY_IMAGE", + "archy-nbxplorer": "NBXPLORER_IMAGE", + "archy-btcpay-db": "BTCPAY_POSTGRES_IMAGE", + }, +} + +apps = {} +for app_id, var in SINGLE.items(): + image = img(var) + if image: + apps[app_id] = {"version": tag(image), "image": image} + +for app_id, comps in STACK.items(): + images = {name: img(var) for name, var in comps.items() if img(var)} + if not images: + continue + primary_name = next(iter(comps)) # first listed = primary + primary_image = img(comps[primary_name]) + entry = {"version": tag(primary_image)} + if primary_image: + entry["image"] = primary_image + entry["images"] = images + apps[app_id] = entry + +catalog = { + "schema": 1, + "updated": os.environ["UPDATED"], + "apps": dict(sorted(apps.items())), +} + +with open(os.environ["OUT"], "w") as f: + json.dump(catalog, f, indent=2) + f.write("\n") +print(f"Wrote {os.environ['OUT']} with {len(apps)} apps") +PY