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) <noreply@anthropic.com>
This commit is contained in:
parent
edd03e542d
commit
aa9e0f02b7
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.96-alpha"
|
||||
version = "1.7.97-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@ -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,
|
||||
|
||||
|
||||
@ -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<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
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.
|
||||
|
||||
343
core/archipelago/src/container/app_catalog.rs
Normal file
343
core/archipelago/src/container/app_catalog.rs
Normal file
@ -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<Option<CacheEntry>> = 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<String, AppCatalogEntry>,
|
||||
/// 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<String>,
|
||||
/// DHT-plan forward-compat: publisher identity (did:key / npub).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub signed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<HashMap<String, String>>,
|
||||
/// DHT-plan forward-compat: content address of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub digest: Option<String>,
|
||||
/// DHT-plan forward-compat: size in bytes of the primary image (unused now).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// Optional human-readable changelog lines for this version.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub changelog: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<AppCatalogEntry> {
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
let mut urls: Vec<String> = 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
|
||||
/// `<data_dir>/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<usize> {
|
||||
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<anyhow::Error> = 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<AppCatalog> {
|
||||
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()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -213,7 +213,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
|
||||
available_update_for_images(&pinned, running_image)
|
||||
}
|
||||
|
||||
fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
|
||||
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))
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod app_catalog;
|
||||
pub mod bitcoin_ui;
|
||||
pub mod boot_reconciler;
|
||||
pub mod companion;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 `<script src="/nostr-provider.js"></script>` before
|
||||
`</head>` 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.
|
||||
|
||||
@ -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<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'marketplace.get',
|
||||
|
||||
@ -90,7 +90,7 @@
|
||||
<div
|
||||
v-for="item in catalogItems"
|
||||
:key="item.id"
|
||||
class="glass-card overflow-hidden"
|
||||
class="glass-card overflow-hidden flex flex-col h-full"
|
||||
>
|
||||
<!-- Media preview (images / videos / audio) -->
|
||||
<div
|
||||
@ -150,8 +150,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card body -->
|
||||
<div class="p-4 flex items-center gap-4">
|
||||
<!-- Card body — pinned to the bottom so the filename + action buttons
|
||||
line up across cards of differing preview heights. -->
|
||||
<div class="p-4 flex items-center gap-4 mt-auto">
|
||||
<div v-if="!isMediaMime(item.mime_type)" class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
|
||||
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
|
||||
|
||||
134
scripts/generate-app-catalog.sh
Executable file
134
scripts/generate-app-catalog.sh
Executable file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user