fix(apps): preserve manifest presentation during installation

This commit is contained in:
yaya
2026-10-06 08:13:20 +01:00
parent 57923b0a5f
commit 3fc37642cd
4 changed files with 171 additions and 55 deletions
@@ -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 `<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,
});
.or_insert_with(|| super::progress::create_installing_entry(package_id));
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
})
@@ -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 `<id>.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.
@@ -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<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() {
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<serde_json::Value> {
let Ok(value) = serde_yaml::from_str::<serde_json::Value>(&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 {
+14
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
```
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
validates every manifest on load and tells you about problems immediately
(for example a host-port collision with another installed app).