Integrate mining launch handoff with follow-up app and media fixes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -164,6 +164,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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user