Integrate mining launch handoff with follow-up app and media fixes

This commit is contained in:
archipelago
2026-10-06 04:13:08 -04:00
9 changed files with 254 additions and 55 deletions
+2
View File
@@ -91,3 +91,5 @@ Adding a new app requires updates in multiple places:
## Port Assignments ## Port Assignments
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000. 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.
@@ -398,53 +398,12 @@ impl RpcHandler {
/// Matches what the inner handler's `set_install_progress` would do on first /// 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. /// call, but fires before the spawn so the UI sees it immediately.
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) { async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
state_manager state_manager
.mutate_data(|data| { .mutate_data(|data| {
let entry = data let entry = data
.package_data .package_data
.entry(package_id.to_string()) .entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry { .or_insert_with(|| super::progress::create_installing_entry(package_id));
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 `<id>.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,
});
entry.ui_ready = Some(false); entry.ui_ready = Some(false);
entry.state = PackageState::Installing; entry.state = PackageState::Installing;
}) })
@@ -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" { if app_id == "photoprism" {
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"title": "PhotoPrism credentials", "title": "PhotoPrism credentials",
@@ -145,9 +145,9 @@ impl RpcHandler {
} }
} }
/// Create a minimal PackageDataEntry for a package being installed. /// Seed an Installing entry from its manifest before any container exists.
fn create_installing_entry(package_id: &str) -> PackageDataEntry { pub(super) fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry { let mut entry = PackageDataEntry {
ui_ready: None, ui_ready: None,
state: PackageState::Installing, state: PackageState::Installing,
health: None, health: None,
@@ -155,10 +155,8 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
static_files: StaticFiles { static_files: StaticFiles {
license: String::new(), license: String::new(),
instructions: String::new(), instructions: String::new(),
// Empty icon: hardcoding `<id>.png` is wrong for apps that use // Filled below from the manifest, without guessing the extension.
// `.svg` or `.webp` assets and produces a broken-image flicker. // Unmanifested apps can still use the frontend catalog fallback.
// The frontend's `icon` computed falls through to the curated
// map which has correct extensions for known apps.
icon: String::new(), icon: String::new(),
}, },
manifest: Manifest { manifest: Manifest {
@@ -185,7 +183,9 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
install_progress: None, install_progress: None,
uninstall_stage: None, uninstall_stage: None,
available_update: None, available_update: None,
} };
crate::container::docker_packages::apply_manifest_presentation(package_id, &mut entry);
entry
} }
/// Parse podman pull progress output. /// Parse podman pull progress output.
@@ -208,7 +208,7 @@ impl DockerPackageScanner {
crate::container::app_catalog::available_update_for_app(&app_id, &container.image) crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
}; };
let package = PackageDataEntry { let mut package = PackageDataEntry {
ui_ready: Some(false), ui_ready: Some(false),
state: package_state.clone(), state: package_state.clone(),
health: container.health.clone(), health: container.health.clone(),
@@ -298,6 +298,7 @@ impl DockerPackageScanner {
uninstall_stage: None, uninstall_stage: None,
}; };
apply_manifest_presentation(&app_id, &mut package);
packages.insert(app_id.clone(), package); packages.insert(app_id.clone(), package);
info!( info!(
"Detected container: {} ({})", "Detected container: {} ({})",
@@ -446,6 +447,71 @@ mod lifecycle_regression_tests {
use super::*; use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; 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] #[test]
fn btcpay_aliases_share_one_package_without_promoting_dependencies() { fn btcpay_aliases_share_one_package_without_promoting_dependencies() {
for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] { 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 /// every surface (My Apps, Services, launcher, companion) instead of the
/// generic A-mark — the exact regression Cuprate exposed on install. /// generic A-mark — the exact regression Cuprate exposed on install.
fn real_manifest_metadata(app_id: &str) -> Option<serde_json::Value> { fn real_manifest_metadata(app_id: &str) -> Option<serde_json::Value> {
real_manifest_value(app_id)?
.get("app")?
.get("metadata")
.cloned()
}
fn real_manifest_value(app_id: &str) -> Option<serde_json::Value> {
for (id, value) in crate::container::app_catalog::catalog_manifest_values() { for (id, value) in crate::container::app_catalog::catalog_manifest_values() {
if id == app_id { if id == app_id {
return value.get("app").and_then(|a| a.get("metadata")).cloned(); return Some(value);
} }
} }
let mut candidates = Vec::new(); 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") { if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
candidates.push( candidates.push(
std::path::PathBuf::from(dir) std::path::PathBuf::from(dir)
@@ -639,14 +721,75 @@ fn real_manifest_metadata(app_id: &str) -> Option<serde_json::Value> {
let Ok(value) = serde_yaml::from_str::<serde_json::Value>(&content) else { let Ok(value) = serde_yaml::from_str::<serde_json::Value>(&content) else {
continue; continue;
}; };
let meta = value.get("app").and_then(|a| a.get("metadata")).cloned(); if value.get("app").is_some() {
if meta.is_some() { return Some(value);
return meta;
} }
} }
None 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 { fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id { let mut meta = match app_id {
"bitcoin-core" => AppMetadata { "bitcoin-core" => AppMetadata {
+56
View File
@@ -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 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/<app-id>/`
directory, so boot sync can promote it to `/opt/archipelago/docker/<app-id>/`.
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 Watch `journalctl -u archipelago` after the restart — the orchestrator
validates every manifest on load and tells you about problems immediately validates every manifest on load and tells you about problems immediately
(for example a host-port collision with another installed app). (for example a host-port collision with another installed app).
@@ -775,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 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 Mempool app), shares that index and declares only an `api` interface. API-only
interfaces belong in Services and do not generate browser launch buttons. 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.
+2
View File
@@ -1,5 +1,7 @@
# Archipelago Developer Guide # 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 ## Project Structure
``` ```
@@ -164,6 +164,22 @@ describe('useAppLauncherStore', () => {
expect(mockWindowOpen).not.toHaveBeenCalled() 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 () => { it('gates a Home-style Portainer launch until its first-run token is shown', async () => {
mockRpcCall.mockResolvedValueOnce({ mockRpcCall.mockResolvedValueOnce({
title: 'Portainer first-run token', title: 'Portainer first-run token',
+1
View File
@@ -93,6 +93,7 @@ const NEW_TAB_APP_IDS = new Set([
* original synchronous user gesture. Portainer is dynamic (first-run only); * original synchronous user gesture. Portainer is dynamic (first-run only);
* File Browser and PhotoPrism have stable fallback credentials. */ * File Browser and PhotoPrism have stable fallback credentials. */
export const CREDENTIAL_INTERSTITIAL_APPS = new Set([ export const CREDENTIAL_INTERSTITIAL_APPS = new Set([
'datum',
'filebrowser', 'filebrowser',
'photoprism', 'photoprism',
'portainer', 'portainer',