From 3fc37642cd5ecfa24ae44b839ffdd1729e27a465 Mon Sep 17 00:00:00 2001 From: yaya Date: Tue, 6 Oct 2026 07:25:24 +0100 Subject: [PATCH 1/2] fix(apps): preserve manifest presentation during installation --- .../src/api/rpc/package/async_lifecycle.rs | 43 +---- .../src/api/rpc/package/progress.rs | 16 +- .../src/container/docker_packages.rs | 153 +++++++++++++++++- docs/app-developer-guide.md | 14 ++ 4 files changed, 171 insertions(+), 55 deletions(-) diff --git a/core/archipelago/src/api/rpc/package/async_lifecycle.rs b/core/archipelago/src/api/rpc/package/async_lifecycle.rs index 36172e87..80678e92 100644 --- a/core/archipelago/src/api/rpc/package/async_lifecycle.rs +++ b/core/archipelago/src/api/rpc/package/async_lifecycle.rs @@ -398,53 +398,12 @@ impl RpcHandler { /// Matches what the inner handler's `set_install_progress` would do on first /// call, but fires before the spawn so the UI sees it immediately. async fn flip_to_installing(state_manager: &StateManager, package_id: &str) { - use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles}; state_manager .mutate_data(|data| { let entry = data .package_data .entry(package_id.to_string()) - .or_insert_with(|| PackageDataEntry { - ui_ready: None, - state: PackageState::Installing, - health: None, - exit_code: None, - static_files: StaticFiles { - license: String::new(), - instructions: String::new(), - // Leave icon empty during the transient Installing window: - // hardcoding `.png` is wrong for ~half our apps (many use - // `.svg` / `.webp`), producing a broken-image flicker until - // the scanner refreshes the entry. The frontend's `icon` - // computed falls through to `curatedMap.get(id)?.icon` which - // has the correct extensions for known apps. - icon: String::new(), - }, - manifest: Manifest { - id: package_id.to_string(), - title: package_id.to_string(), - version: String::new(), - description: Description { - short: "Installing...".to_string(), - long: String::new(), - }, - release_notes: String::new(), - license: String::new(), - wrapper_repo: String::new(), - upstream_repo: String::new(), - support_site: String::new(), - marketing_site: String::new(), - donation_url: None, - author: None, - website: None, - interfaces: None, - tier: None, - }, - installed: None, - install_progress: None, - uninstall_stage: None, - available_update: None, - }); + .or_insert_with(|| super::progress::create_installing_entry(package_id)); entry.ui_ready = Some(false); entry.state = PackageState::Installing; }) diff --git a/core/archipelago/src/api/rpc/package/progress.rs b/core/archipelago/src/api/rpc/package/progress.rs index 248c0428..53a9018d 100644 --- a/core/archipelago/src/api/rpc/package/progress.rs +++ b/core/archipelago/src/api/rpc/package/progress.rs @@ -145,9 +145,9 @@ impl RpcHandler { } } -/// Create a minimal PackageDataEntry for a package being installed. -fn create_installing_entry(package_id: &str) -> PackageDataEntry { - PackageDataEntry { +/// Seed an Installing entry from its manifest before any container exists. +pub(super) fn create_installing_entry(package_id: &str) -> PackageDataEntry { + let mut entry = PackageDataEntry { ui_ready: None, state: PackageState::Installing, health: None, @@ -155,10 +155,8 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry { static_files: StaticFiles { license: String::new(), instructions: String::new(), - // Empty icon: hardcoding `.png` is wrong for apps that use - // `.svg` or `.webp` assets and produces a broken-image flicker. - // The frontend's `icon` computed falls through to the curated - // map which has correct extensions for known apps. + // Filled below from the manifest, without guessing the extension. + // Unmanifested apps can still use the frontend catalog fallback. icon: String::new(), }, manifest: Manifest { @@ -185,7 +183,9 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry { install_progress: None, uninstall_stage: None, available_update: None, - } + }; + crate::container::docker_packages::apply_manifest_presentation(package_id, &mut entry); + entry } /// Parse podman pull progress output. diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 8d9a9820..eddf3821 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -208,7 +208,7 @@ impl DockerPackageScanner { crate::container::app_catalog::available_update_for_app(&app_id, &container.image) }; - let package = PackageDataEntry { + let mut package = PackageDataEntry { ui_ready: Some(false), state: package_state.clone(), health: container.health.clone(), @@ -298,6 +298,7 @@ impl DockerPackageScanner { uninstall_stage: None, }; + apply_manifest_presentation(&app_id, &mut package); packages.insert(app_id.clone(), package); info!( "Detected container: {} ({})", @@ -446,6 +447,71 @@ mod lifecycle_regression_tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + fn installing_fixture() -> PackageDataEntry { + serde_json::from_value(serde_json::json!({ + "state": "installing", "ui-ready": false, + "static-files": {"license":"", "instructions":"", "icon":""}, + "manifest": { + "id":"new-app", "title":"new-app", "version":"running-version", + "description":{"short":"Installing...", "long":""}, + "release-notes":"", "license":"", "wrapper-repo":"", + "upstream-repo":"", "support-site":"", "marketing-site":"" + } + })) + .unwrap() + } + + #[test] + fn manifest_presentation_keeps_unpublished_ui_in_apps_during_install() { + let mut entry = installing_fixture(); + let manifest = serde_json::json!({"app": { + "name":"New App", "version":"different-catalog-version", "description":"Setup and monitor", + "metadata":{"icon":"/assets/img/app-icons/new-app.svg", "tier":"optional"}, + "interfaces":{"main":{"type":"ui", "port":7152}} + }}); + apply_manifest_value(&manifest, &mut entry); + assert_eq!(entry.manifest.title, "New App"); + assert_eq!(entry.static_files.icon, "/assets/img/app-icons/new-app.svg"); + assert_eq!(entry.manifest.description.short, "Setup and monitor"); + assert_eq!( + entry + .manifest + .interfaces + .unwrap() + .main + .unwrap() + .ui + .as_deref(), + Some("true") + ); + assert_eq!(entry.ui_ready, Some(false)); + assert_eq!(entry.state, PackageState::Installing); + assert_eq!(entry.manifest.version, "running-version"); + assert!(entry.installed.is_none()); + } + + #[test] + fn manifest_presentation_does_not_promote_api_to_ui_or_discard_addresses() { + let mut entry = installing_fixture(); + entry.manifest.interfaces = Some(Interfaces { + main: Some(MainInterface { + ui: Some("true".to_owned()), + tor_config: Some("kept.onion".to_owned()), + lan_config: Some("kept".to_owned()), + }), + }); + apply_manifest_value( + &serde_json::json!({"app":{ + "interfaces":{"main":{"type":"api", "port":3000}} + }}), + &mut entry, + ); + let main = entry.manifest.interfaces.unwrap().main.unwrap(); + assert_eq!(main.ui, None); + assert_eq!(main.tor_config.as_deref(), Some("kept.onion")); + assert_eq!(main.lan_config.as_deref(), Some("kept")); + } + #[test] fn btcpay_aliases_share_one_package_without_promoting_dependencies() { for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] { @@ -613,12 +679,28 @@ fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool { /// every surface (My Apps, Services, launcher, companion) instead of the /// generic A-mark — the exact regression Cuprate exposed on install. fn real_manifest_metadata(app_id: &str) -> Option { + real_manifest_value(app_id)? + .get("app")? + .get("metadata") + .cloned() +} + +fn real_manifest_value(app_id: &str) -> Option { for (id, value) in crate::container::app_catalog::catalog_manifest_values() { if id == app_id { - return value.get("app").and_then(|a| a.get("metadata")).cloned(); + return Some(value); } } let mut candidates = Vec::new(); + if let Ok(dir) = std::env::var("ARCHIPELAGO_APPS_DIR") { + if !dir.trim().is_empty() { + candidates.push( + std::path::PathBuf::from(dir) + .join(app_id) + .join("manifest.yml"), + ); + } + } if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") { candidates.push( std::path::PathBuf::from(dir) @@ -639,14 +721,75 @@ fn real_manifest_metadata(app_id: &str) -> Option { let Ok(value) = serde_yaml::from_str::(&content) else { continue; }; - let meta = value.get("app").and_then(|a| a.get("metadata")).cloned(); - if meta.is_some() { - return meta; + if value.get("app").is_some() { + return Some(value); } } None } +/// Keep disk-only apps presentable before their first container exists. +/// The signed catalog wins wherever it supplies a manifest. +pub(crate) fn apply_manifest_presentation(app_id: &str, entry: &mut PackageDataEntry) { + if let Some(value) = real_manifest_value(app_id) { + apply_manifest_value(&value, entry); + } +} + +fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry) { + let Some(app) = value.get("app") else { return }; + let text = |value: Option<&serde_json::Value>| { + value + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + }; + if let Some(name) = text(app.get("name")) { + entry.manifest.title = name; + } + if let Some(description) = text(app.get("description")) { + entry.manifest.description.short = description.clone(); + entry.manifest.description.long = description.clone(); + entry.static_files.instructions = description; + } + if let Some(metadata) = app.get("metadata") { + if let Some(icon) = text(metadata.get("icon")) { + entry.static_files.icon = icon; + } + if let Some(tier) = text(metadata.get("tier")) { + entry.manifest.tier = Some(tier); + } + } + // Once installed, the scanner owns UI detection (including companion UIs). + // Only seed classification while there is no observed runtime package. + if entry.installed.is_some() { + return; + } + if let Some(interfaces) = app.get("interfaces").and_then(|v| v.as_object()) { + if !interfaces.is_empty() { + let has_ui = interfaces.values().any(|interface| { + interface + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("ui") + == "ui" + }); + // Preserve scanner-owned addresses; a declared UI does not imply readiness. + let interfaces = entry + .manifest + .interfaces + .get_or_insert(Interfaces { main: None }); + let main = interfaces.main.get_or_insert(MainInterface { + ui: None, + tor_config: None, + lan_config: None, + }); + main.ui = has_ui.then(|| "true".to_owned()); + } + } +} + fn get_app_metadata(app_id: &str) -> AppMetadata { let mut meta = match app_id { "bitcoin-core" => AppMetadata { diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index 12c09369..d53372f0 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -665,6 +665,20 @@ sudo cp apps/my-app/manifest.yml /opt/archipelago/web-ui/archipelago-runtime/app sudo systemctl restart archipelago # manifests are loaded at startup ``` +Stage the other files declared by the manifest too. A local-build app needs its +build context under the corresponding runtime payload `docker//` +directory, so boot sync can promote it to `/opt/archipelago/docker//`. +Copy the normalized icon to its declared public path under +`/opt/archipelago/web-ui/` for a local test; the normal frontend bundle must carry +that asset for release. Do not replace the signed catalog to make a local test +app appear in the store. + +Check the **My Apps** tile while installation is in progress and after it +finishes: name, icon and UI classification come from the manifest. An API-only +app belongs in Services. A UI app must remain in My Apps while installing, with +launch disabled until it is ready. Verify the real Launch button opens the +embedded app, rather than testing only its direct port URL. + Watch `journalctl -u archipelago` after the restart — the orchestrator validates every manifest on load and tells you about problems immediately (for example a host-port collision with another installed app). From 2fad10c8de58a29275cecac0e5bf98be898efcc8 Mon Sep 17 00:00:00 2001 From: yaya Date: Tue, 6 Oct 2026 08:00:08 +0100 Subject: [PATCH 2/2] Show DATUM login credentials and document complete app launch requirements --- apps/DEVELOPMENT.md | 2 + .../src/api/rpc/package/install.rs | 20 +++++++++ docs/app-developer-guide.md | 42 +++++++++++++++++++ docs/developer-guide.md | 2 + .../src/stores/__tests__/appLauncher.test.ts | 16 +++++++ neode-ui/src/stores/appLauncher.ts | 1 + 6 files changed, 83 insertions(+) diff --git a/apps/DEVELOPMENT.md b/apps/DEVELOPMENT.md index 53a364ed..0e37f8e7 100644 --- a/apps/DEVELOPMENT.md +++ b/apps/DEVELOPMENT.md @@ -91,3 +91,5 @@ Adding a new app requires updates in multiple places: ## Port Assignments See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000. + +Before submitting an app, complete **Launch acceptance: credentials, signer, and HTTP nodes** in `docs/app-developer-guide.md`. A generated password needs an authenticated credential interstitial; native Nostr login needs a tested first-launch chooser. Container health alone is not launch acceptance. diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index ae2570e2..419c92c0 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -1892,6 +1892,26 @@ autopilot.active=false\n", })); } + if app_id == "datum" { + // This is the same platform-owned secret injected into DATUM and + // Gashboard. Never publish it in the manifest or a UI fallback. + let password = tokio::fs::read_to_string( + self.config.data_dir.join("secrets/datum-admin-password"), + ) + .await + .context("DATUM credentials are not available yet; wait for installation to finish")?; + let password = password.trim(); + anyhow::ensure!(!password.is_empty(), "DATUM administrator password is empty"); + return Ok(serde_json::json!({ + "title": "DATUM Gateway login", + "description": "Use this password when DATUM asks you to unlock configuration. In Config, set your Bitcoin payout address. Point miners at this node's IP address on Stratum port 23334 (stratum+tcp://NODE-IP:23334). Gashboard connects automatically.", + "credentials": [ + { "label": "Username", "value": "admin" }, + { "label": "Password", "value": password, "sensitive": true } + ] + })); + } + if app_id == "photoprism" { return Ok(serde_json::json!({ "title": "PhotoPrism credentials", diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index d53372f0..a8464413 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -789,3 +789,45 @@ adapter instead of reporting a successful installation with no usable backend. For example, Angor Indexer requires `mempool-api` (shown to users as its owning Mempool app), shares that index and declares only an `api` interface. API-only interfaces belong in Services and do not generate browser launch buttons. + + +## Launch acceptance: credentials, signer, and HTTP nodes + +An app is not ready just because its container is healthy. Before submission, +verify its first launch from My Apps, app details, a browser tab and Companion: + +- Declare a real UI interface and stage the app icon in the web UI assets. Check + the installing tile as well as the completed installation: a UI app belongs + in My Apps and must not appear as an iconless service. +- If the app needs a password or first-run token, provide the shared credential + interstitial **before** launch, with copy controls and setup instructions. + Generating a secret in the manifest does not register this screen. Implement + `package.credentials` in `core/archipelago/src/api/rpc/package/install.rs` + and register the app in `CREDENTIAL_INTERSTITIAL_APPS` in + `neode-ui/src/stores/appLauncher.ts`. Both changes require a platform update; + app-only sideloads cannot add this RPC integration. File Browser and DATUM + are examples. Read generated secrets from the node's configured data directory; + never put them in a manifest, static browser bundle, default-password fallback, + logs, screenshots, or test reports. Keep the RPC dashboard-authenticated. +- Explain initial configuration and client connection details. For DATUM this + includes its administrator password, Bitcoin payout address, and the node's + Stratum address on port 23334. App-to-app connections use container DNS + (`http://datum:7152`), never a container IP address. +- Native Nostr apps should open the host identity chooser once on an explicit + app launch when unauthenticated, then finish the app's ordinary NIP-07 login. + The app may call `archipelagoNostr.selectIdentity()` at initial mount for this + first-launch flow; this is the exception to the routine-signing rule above. + Do not assume the platform's eager-picker app list contains a new app ID. + Consume an already selected identity through `getSelectedIdentity()` or the + sticky `onIdentitySelected()` subscription to avoid a second chooser. + Preserve manual login/account switching, external extensions and remote + signers. Cancellation must leave a usable login screen without reopening a + prompt loop; signing still requires the platform's normal consent. +- Test the actual HTTP LAN/Tailscale address, not only localhost or HTTPS. + `crypto.subtle` and clipboard APIs may be unavailable on those addresses. + Keep authenticated encryption: use a vetted compatible implementation when + WebCrypto is absent, and secure randomness (`crypto.getRandomValues`). Test + existing-message decryption, tamper rejection and an HTTP round trip. +- Verify first launch, cancellation/retry, reload, owner/viewer authorization, + and persisted data after app recreation. Use the shared browser-check suite + outside the repository; record which nodes and browser engines were tested. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 184d5233..43313c58 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -1,5 +1,7 @@ # Archipelago Developer Guide +For new apps, start with `docs/app-developer-guide.md` and complete its **Launch acceptance: credentials, signer, and HTTP nodes** checklist. Packaging includes My Apps presentation, login/first-run credential handoff, native signer startup, and real HTTP-node testing—not only a working container. + ## Project Structure ``` diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 68bf8ee7..5ced5414 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -143,6 +143,22 @@ describe('useAppLauncherStore', () => { expect(mockWindowOpen).not.toHaveBeenCalled() }) + it('shows DATUM generated credentials before opening its embedded UI', async () => { + mockRpcCall.mockResolvedValueOnce({ + title: 'DATUM Gateway login', + credentials: [{ label: 'Password', value: 'fixture-only-password', sensitive: true }], + }) + const store = useAppLauncherStore() + store.openSession('datum') + await vi.waitFor(() => expect(store.credentialPrompt.loading).toBe(false)) + expect(store.credentialPrompt.show).toBe(true) + expect(store.credentialPrompt.credentials[0]?.value).toBe('fixture-only-password') + expect(store.panelAppId).toBeNull() + expect(mockWindowOpen).not.toHaveBeenCalled() + store.continueCredentialLaunch() + expect(store.panelAppId).toBe('datum') + }) + it('gates a Home-style Portainer launch until its first-run token is shown', async () => { mockRpcCall.mockResolvedValueOnce({ title: 'Portainer first-run token', diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index bb8e94fd..27493fef 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -93,6 +93,7 @@ const NEW_TAB_APP_IDS = new Set([ * original synchronous user gesture. Portainer is dynamic (first-run only); * File Browser and PhotoPrism have stable fallback credentials. */ export const CREDENTIAL_INTERSTITIAL_APPS = new Set([ + 'datum', 'filebrowser', 'photoprism', 'portainer',