Add signed node-scoped demo catalogs and retained app media sessions

This commit is contained in:
archipelago
2026-10-06 00:53:34 -04:00
parent 131c39cf74
commit e54f83df8f
18 changed files with 706 additions and 20 deletions
+18
View File
@@ -623,6 +623,24 @@ impl ApiHandler {
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream // (upstream Gitea has no ACAO header) or CSP (IP-port upstream
// falls outside `connect-src`). Session-authenticated so only // falls outside `connect-src`). Session-authenticated so only
// the logged-in node owner can spin up fetches. // the logged-in node owner can spin up fetches.
(Method::GET, "/api/node-app-catalog") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
let data_dir = self.config.data_dir.clone();
let result = tokio::task::spawn_blocking(move || {
crate::container::node_catalog::verified_body(&data_dir)
}).await.unwrap_or_else(|error| Err(anyhow::anyhow!(error)));
let (status, body) = match result {
Ok(Some(body)) => (StatusCode::OK, body),
Ok(None) => (StatusCode::NOT_FOUND, "{}".to_owned()),
Err(error) => {
tracing::warn!("Node demo catalog rejected: {error}");
(StatusCode::CONFLICT, "{\"error\":\"Node demo catalog is unavailable\"}".to_owned())
},
};
Ok(Response::builder().status(status).header("Content-Type", "application/json")
.header("Cache-Control", "private, no-store").body(hyper::Body::from(body))?)
}
(Method::GET, "/api/app-catalog") => { (Method::GET, "/api/app-catalog") => {
if !self.is_authenticated(&headers).await { if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized()); return Ok(Self::unauthorized());
+13 -1
View File
@@ -178,7 +178,7 @@ fn find_cache_file() -> Option<(PathBuf, SystemTime)> {
/// Load and cache the on-node catalog. Returns an empty catalog when absent — /// Load and cache the on-node catalog. Returns an empty catalog when absent —
/// callers then fall back to `image-versions.sh`. /// callers then fall back to `image-versions.sh`.
fn load_catalog() -> AppCatalog { fn load_global_catalog() -> AppCatalog {
let (path, mtime) = match find_cache_file() { let (path, mtime) = match find_cache_file() {
Some(v) => v, Some(v) => v,
None => return AppCatalog::default(), None => return AppCatalog::default(),
@@ -218,6 +218,18 @@ fn load_catalog() -> AppCatalog {
catalog catalog
} }
fn load_catalog() -> AppCatalog {
let mut catalog = load_global_catalog();
if let Some((path, _)) = find_cache_file() {
if let Some(data_dir) = path.parent() {
for (id, entry) in super::node_catalog::entries(data_dir) {
catalog.apps.entry(id).or_insert(entry);
}
}
}
catalog
}
fn entry_for(app_id: &str) -> Option<AppCatalogEntry> { fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
load_catalog().apps.get(app_id).cloned() load_catalog().apps.get(app_id).cloned()
} }
+1
View File
@@ -1,4 +1,5 @@
pub mod app_catalog; pub mod app_catalog;
pub mod node_catalog;
pub mod app_gate_config; pub mod app_gate_config;
pub mod bitcoin_ui; pub mod bitcoin_ui;
pub mod boot_reconciler; pub mod boot_reconciler;
@@ -0,0 +1,148 @@
//! Optional, release-signed app catalog restricted to exactly one node DID.
//! It is never fetched from public mirrors or merged into the global signed
//! bytes. Existing application IDs cannot be overridden by a demo catalog.
use super::app_catalog::{AppCatalog, AppCatalogEntry};
use anyhow::{Context, Result};
use serde_json::Value;
use std::{collections::HashMap, io::Read, os::unix::fs::OpenOptionsExt, path::Path};
pub const FILE: &str = "node-app-catalog.json";
const LIMIT: u64 = 1024 * 1024;
fn validate(raw: &Value, node_did: &str) -> Result<AppCatalog> {
anyhow::ensure!(
raw["schema"] == 1 && raw["scope"] == "single-node-demo",
"Unsupported node catalog scope"
);
anyhow::ensure!(
raw["target_node_did"].as_str() == Some(node_did),
"Catalog belongs to another node"
);
let expires = chrono::DateTime::parse_from_rfc3339(
raw["expires_at"]
.as_str()
.context("Missing catalog expiry")?,
)?;
anyhow::ensure!(expires > chrono::Utc::now(), "Node catalog has expired");
anyhow::ensure!(
matches!(
crate::trust::verify_detached(raw)?,
crate::trust::SignatureStatus::Verified { anchored: true, .. }
),
"Node catalog requires the pinned release-root signature"
);
let catalog: AppCatalog = serde_json::from_value(raw.clone())?;
anyhow::ensure!(
!catalog.apps.is_empty() && catalog.apps.len() <= 16,
"Invalid demo app count"
);
for (id, entry) in &catalog.apps {
anyhow::ensure!(
id.starts_with("node-demo-")
&& id.len() <= 64
&& id
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'),
"Invalid demo app ID"
);
let value = entry
.manifest
.clone()
.context("Node demo requires an embedded manifest")?;
anyhow::ensure!(
super::app_catalog::catalog_manifest_overlay(id, value).is_some(),
"Invalid node demo manifest"
);
}
Ok(catalog)
}
pub fn verified_body(data_dir: &Path) -> Result<Option<String>> {
let path = data_dir.join(FILE);
let file = match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(&path)
{
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let metadata = file.metadata()?;
anyhow::ensure!(
metadata.is_file() && metadata.len() <= LIMIT,
"Invalid node catalog file"
);
let mut body = String::new();
file.take(LIMIT + 1).read_to_string(&mut body)?;
anyhow::ensure!(
body.len() as u64 <= LIMIT,
"Node catalog exceeds size limit"
);
let public_key = std::fs::read(data_dir.join("identity/node_key.pub"))?;
anyhow::ensure!(public_key.len() == 32, "Invalid local node identity");
let node_did = crate::identity::did_key_from_pubkey_hex(&hex::encode(public_key))?;
validate(&serde_json::from_str(&body)?, &node_did)?;
Ok(Some(body))
}
pub fn entries(data_dir: &Path) -> HashMap<String, AppCatalogEntry> {
match verified_body(data_dir) {
Ok(Some(body)) => serde_json::from_str::<AppCatalog>(&body)
.map(|catalog| catalog.apps)
.unwrap_or_default(),
Ok(None) => HashMap::new(),
Err(error) => {
tracing::warn!("Ignoring invalid node demo catalog: {error}");
HashMap::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn signed(mut raw: Value, byte: u8) -> Value {
let anchor = ed25519_dalek::SigningKey::from_bytes(&[7; 32]);
std::env::set_var(
"ARCHY_RELEASE_ROOT_PUBKEY",
hex::encode(anchor.verifying_key().to_bytes()),
);
let key = ed25519_dalek::SigningKey::from_bytes(&[byte; 32]);
let (sig, did) = crate::trust::signed_doc::sign_detached(&key, &raw).unwrap();
raw["signature"] = sig.into();
raw["signed_by"] = did.into();
raw
}
fn fixture() -> Value {
serde_json::json!({"schema":1,"scope":"single-node-demo","target_node_did":"did:key:fixture",
"expires_at":(chrono::Utc::now()+chrono::Duration::days(1)).to_rfc3339(),
"apps":{"node-demo-v4v":{"version":"1","image":"docker.io/library/node:24-alpine",
"manifest":{"app":{"id":"node-demo-v4v","name":"Sovereign Music demo","version":"1",
"container":{"image":"docker.io/library/node:24-alpine"}}}}}})
}
#[test]
fn audience_signature_expiry_namespace_and_manifest_are_required() {
assert!(validate(&signed(fixture(), 7), "did:key:fixture").is_ok());
assert!(validate(&signed(fixture(), 7), "did:key:another").is_err());
assert!(validate(&fixture(), "did:key:fixture").is_err());
assert!(validate(&signed(fixture(), 11), "did:key:fixture").is_err());
let mut tampered = signed(fixture(), 7);
tampered["apps"]["node-demo-v4v"]["version"] = "tampered".into();
assert!(validate(&tampered, "did:key:fixture").is_err());
let mut expired = fixture();
expired["expires_at"] = "2020-01-01T00:00:00Z".into();
assert!(validate(&signed(expired, 7), "did:key:fixture").is_err());
let mut override_app = fixture();
let entry = override_app["apps"]
.as_object_mut()
.unwrap()
.remove("node-demo-v4v")
.unwrap();
override_app["apps"]["gitea"] = entry;
assert!(validate(&signed(override_app, 7), "did:key:fixture").is_err());
let mut wrong_manifest = fixture();
wrong_manifest["apps"]["node-demo-v4v"]["manifest"]["app"]["id"] = "node-demo-other".into();
assert!(validate(&signed(wrong_manifest, 7), "did:key:fixture").is_err());
}
}
+91
View File
@@ -0,0 +1,91 @@
# Intentionally outside apps/: this demo must never enter the public catalog.
# Publish only inside a release-signed single-node-demo catalog.
app:
id: node-demo-v4v
name: V4V · Sovereign Music
version: 0.6.7-alpha-archy1
description: Listen to the original V4V demo catalog and explore sovereign music on this node.
category: media
container:
image: source.archipelago-foundation.org/lfg2025/v4v-demo:0.6.7-alpha-archy1
generated_secrets:
- name: node-demo-v4v-session
kind: hex32
- name: node-demo-v4v-receipts
kind: hex32
secret_env:
- key: ALPHA_PASSWORD_HASH
secret_file: node-demo-v4v-password-hash
- key: SESSION_SECRET
secret_file: node-demo-v4v-session
- key: RECEIPT_SIGNING_SECRET
secret_file: node-demo-v4v-receipts
resources:
cpu_limit: 2
memory_limit: 768Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: bridge
ports:
- host: 7475
container: 5181
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: volume
source: node-demo-v4v-data
target: /app/data
- type: volume
source: node-demo-v4v-media
target: /app/media
- type: tmpfs
target: /tmp
tmpfs_options: rw,noexec,nosuid,size=64m,mode=1777
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=5181
- PULSEWIRE_COMMIT=5bc5f61b
- PULSEWIRE_PASSWORD_LOGIN=on
- PULSEWIRE_PASSWORD_OPERATOR=on
- PULSEWIRE_SECURE_COOKIES=off
- PULSEWIRE_NOSTR_REGISTRATION=closed
- PULSEWIRE_STORE=json
- PULSEWIRE_STATE_BACKUP_DIR=data/backups
- PULSEWIRE_RATE_LIMIT_DB_FILE=data/rate-limits.db
- PULSEWIRE_JOBS_DB_FILE=data/jobs.db
- PULSEWIRE_FEED_INDEX_FILE=data/feed-index.db
- PULSEWIRE_FEED_FRONTIER_FILE=data/feed-frontier.db
- PULSEWIRE_LN_MODE=mock
- PULSEWIRE_LOG_FORMAT=json
- PULSEWIRE_ACCESS_LOG=on
- PULSEWIRE_DISCOVERY_DEMO_FIXTURES=on
health_check:
type: http
endpoint: http://127.0.0.1:5181
path: /healthz
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: V4V
description: Sovereign music demo
type: ui
port: 7475
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/v4v-demo.svg
author: V4V contributors
tier: optional
launch:
requires_host_frame: true
media_controls: archipelago-v1
features:
- Original demo song catalog
- Background playback with dashboard controls
- Demo payments only
+84
View File
@@ -0,0 +1,84 @@
# Node-scoped demo apps and persistent media
Status: implementation under qualification; **not deployed or accepted**.
The V4V demo is restricted to Yaya. The global catalog and other nodes must not
receive an install button or banner for this prototype. Its manifest lives in
`demos/node-demo-v4v/`, deliberately outside public `apps/` generation.
## Catalog boundary
A node may load `node-app-catalog.json` beside its normal catalog. This document
must carry a valid pinned release-root signature, `schema: 1`,
`scope: "single-node-demo"`, the exact `target_node_did`, and an unexpired
`expires_at`. Entries use the reserved `node-demo-` namespace, include validated
image manifests, and cannot replace existing catalog IDs. It is a separate
file; global signed bytes remain intact. No public mirror fetch or peer
redistribution is implemented for this file.
The authenticated `/api/node-app-catalog` endpoint returns the original signed
bytes only after these checks. The dashboard combines these entries/promotions
for display without saving them into its normal browser fallback catalog.
Removal, invalid signatures, expiry or another node's DID remove that demo
listing. They do not erase installed app data. Installation still uses the
normal app manifest, image, port and lifecycle enforcement.
Current activation: stage the signed file atomically, then restart the backend
to reload its manifest overlay. Automatic delivery of private catalog revisions
is not claimed. Qualification must test copying the file to a different node,
expiry, signature tampering, backend restart and preservation of public entries.
## V4V app
Source baseline: private V4V `demo-portainer` commit
`3ae171d6b0c728665a860520fe393c0abb772798`. Retain the original demo songs,
attribution and destinations. The demo keeps `PULSEWIRE_LN_MODE=mock` and its
existing password login. It does **not** inject Archipelago's native Nostr signer.
The media bridge is a distinct, non-signing integration.
Candidate bridge source: `5bc5f61b`. Session and receipt secrets are generated
by the manifest only when missing. For this migration, preserve the existing
password hash and seed it privately as `node-demo-v4v-password-hash` before
installation. Missing credentials must fail installation; do not fall back to
the upstream shared password. General public first-run credential provisioning
is outside this node-only demo and must be implemented before a public listing.
The login backdrop was captured from the running app's actual canvas in an
isolated browser context, with the form hidden. Use this asset for the node-only
“Sovereign Music” promotion. The public artifact can include the image; visibility
of the app/promotion is controlled by the scoped catalog.
Before migration, back up the existing Portainer data/media volumes and secret
configuration privately. Qualify a separate copy first. Do not attach both live
containers to the same writable database. Keep the original stack and volumes
available for rollback until the managed replacement passes lifecycle checks.
No live V4V state or Portainer stack has been changed by this implementation yet.
## Persistent player contract
The app declares `metadata.launch.media_controls: archipelago-v1`. The dashboard
retains its iframe while hidden and controls that same player through messages;
it never copies a protected media URL into a second audio player. The app checks
the exact dashboard origin and parent window; the host checks the exact loaded
app origin/window and a per-session nonce. The protocol carries bounded title,
artist, time, duration and playback state, plus play/pause/seek/next/previous
controls. It contains no credentials, signer operations or payment commands.
The bottom bar is hidden while the app player is open. Closing the app shows the
bar while audio continues; Open app reveals the retained session. Closing the
bar pauses playback and releases the hidden frame. Starting a Cloud track pauses
the app player. Late state from the paused player must not steal playback back.
Logout/unmount must release the frame and its state.
Required remaining evidence: actual mounted iframe survives close/reopen,
mobile/desktop controls and layout, fresh install and copied-volume upgrade,
restart/rollback, native companion background/resume, real catalog audience
rejection on another node, and exact final artifact hashes. Unit tests alone
are insufficient for this acceptance.
Qualification checkpoint: the dashboard suite passed 1,241 tests in 155 files.
The app bridge/player tests passed four tests, including pre-login connection,
origin/nonce rejection, locked controls and removal of metadata after relocking.
The isolated container reached HTTP health 200, then exposed the management
reaper's separate-storage ownership bug. Runtime acceptance is blocked on its
tested deployment; the old V4V image and live Portainer volumes are untouched.
@@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="v4v">
<title>v4v</title>
<!-- Generated by scripts/generate-glass-logo.mjs; do not edit by hand.
Liquid-glass rendering of the brand mark (v4v-glyph.svg silhouette).
Palette: ink, white and canvas tokens (see tokens.css). -->
<circle cx="256" cy="256" r="240" fill="#0a0a0a"/><!-- color-ink token -->
<defs>
<linearGradient id="glass-body" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fafafa" stop-opacity="0.30"/><!-- color-white token -->
<stop offset="0.55" stop-color="#f0f4f8" stop-opacity="0.12"/><!-- color-canvas token -->
<stop offset="1" stop-color="#fafafa" stop-opacity="0.05"/>
</linearGradient>
<linearGradient id="glass-shade" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0a0a0a" stop-opacity="0.34"/><!-- color-ink token -->
<stop offset="0.6" stop-color="#0a0a0a" stop-opacity="0.06"/>
<stop offset="1" stop-color="#0a0a0a" stop-opacity="0"/>
</linearGradient>
<linearGradient id="glass-streak" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fafafa" stop-opacity="0.42"/>
<stop offset="0.42" stop-color="#fafafa" stop-opacity="0.05"/>
<stop offset="1" stop-color="#fafafa" stop-opacity="0"/>
</linearGradient>
<linearGradient id="glass-face0" x1="109.3" y1="102.4" x2="402.7" y2="281.4" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.55" stop-color="#fafafa"/><!-- color-white token -->
<stop offset="1" stop-color="#c9d5e1"/><!-- toward color-canvas token -->
</linearGradient>
<linearGradient id="glass-face1" x1="161.3" y1="280.8" x2="353.3" y2="409.6" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.55" stop-color="#fafafa"/><!-- color-white token -->
<stop offset="1" stop-color="#c9d5e1"/><!-- toward color-canvas token -->
</linearGradient>
<linearGradient id="glass-depth" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fafafa"/><!-- color-white token -->
<stop offset="1" stop-color="#9fb0c2"/><!-- toward color-canvas token, shaded -->
</linearGradient>
<clipPath id="glyph-clip"><path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z"/></clipPath>
</defs>
<!-- The depth layers sit behind everything, on a shaded ramp. -->
<g fill-opacity="0.21">
<path d="M256.96 280.64L224.62 248.31L129.47 343.47L161.80 375.80Z" fill="url(#glass-depth)"/>
<path d="M385.18 345.93L288.17 248.92L255.83 281.26L352.84 378.26Z" fill="url(#glass-depth)"/>
<path d="M370.46 148.06L370.46 102.33L141.04 102.33L141.04 148.06Z" fill="url(#glass-depth)"/>
</g>
<!-- Soft drop: stacked offsets stand in for a blur librsvg can trust. -->
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0 7.74319377949815)" fill="#0a0a0a" fill-opacity="0.28"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0 5.010301857322332)" fill="#0a0a0a" fill-opacity="0.2"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0 2.7328919221758174)" fill="#0a0a0a" fill-opacity="0.14"/>
<!-- Rim light: the silhouette dilated in eight directions. -->
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(-3.19 0.00)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(3.19 0.00)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0.00 -3.19)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(0.00 3.19)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(-2.23 -2.23)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(2.23 -2.23)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(-2.23 2.23)" fill="#fafafa" fill-opacity="0.22"/>
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(2.23 2.23)" fill="#fafafa" fill-opacity="0.22"/>
<!-- The white body: the mark as provided — solid white, with the bevel
shade and streak below giving it the 3D feel. -->
<!-- Gentle top-light over the body. -->
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74Z" fill="url(#glass-face0)"/>
<path d="M225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" fill="url(#glass-face1)"/>
<!-- Inner bevel shade, clipped to the pane. -->
<g clip-path="url(#glyph-clip)">
<path d="M402.98 134.63L370.64 102.29L255.80 217.13L288.13 249.47ZM288.66 249.41L141.29 102.04L108.96 134.38L256.32 281.74ZM225.86 376.16L193.52 343.83L161.18 376.16L193.52 408.50ZM257.45 344.57L225.11 312.23L192.78 344.57L225.11 376.91ZM289.04 312.98L256.71 280.64L224.37 312.98L256.71 345.31ZM321.25 345.18L288.91 312.85L256.58 345.18L288.91 377.52ZM353.46 377.39L321.12 345.06L288.78 377.39L321.12 409.73Z" transform="translate(3.64 5.01)" fill="url(#glass-shade)"/>
<rect x="0" y="0" width="512" height="198.05" fill="url(#glass-streak)" transform="rotate(-18 256 256)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

@@ -2,7 +2,7 @@
<Teleport to="body"> <Teleport to="body">
<Transition name="slide-up"> <Transition name="slide-up">
<div <div
v-if="audioPlayer.currentName.value" v-if="audioPlayer.currentName.value && !audioPlayer.externalVisible.value"
ref="barEl" ref="barEl"
class="fixed left-0 right-0 z-40 audio-player-bar" class="fixed left-0 right-0 z-40 audio-player-bar"
> >
@@ -42,6 +42,8 @@
<p class="text-xs text-white/40">{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}</p> <p class="text-xs text-white/40">{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}</p>
</div> </div>
<button v-if="audioPlayer.externalApp.value" class="glass-button px-3 py-2 rounded-lg text-sm text-white shrink-0" @click="audioPlayer.openExternal()" aria-label="Open playing app">Open app</button>
<!-- Close --> <!-- Close -->
<button <button
class="flex-shrink-0 w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center transition-colors" class="flex-shrink-0 w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center transition-colors"
@@ -81,7 +83,7 @@ function clearPlayerHeightVar() {
document.documentElement.classList.remove('audio-active') document.documentElement.classList.remove('audio-active')
} }
watch(() => audioPlayer.currentName.value, (name) => { watch(() => audioPlayer.currentName.value && !audioPlayer.externalVisible.value, (name) => {
if (name) { if (name) {
nextTick(setPlayerHeightVar) nextTick(setPlayerHeightVar)
} else { } else {
@@ -0,0 +1,48 @@
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick, ref, shallowRef } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fixture = vi.hoisted(() => ({
allowed: true,
player: { updateExternal: vi.fn(), detachExternal: vi.fn(), releaseExternal: vi.fn(), setExternalVisible: vi.fn() },
launcher: { mediaAppId: 'node-demo-v4v' as string | null, panelAppId: null as string | null, openSession: vi.fn() },
}))
vi.mock('../useAudioPlayer', () => ({ useAudioPlayer: () => fixture.player }))
vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => fixture.launcher }))
vi.mock('@/views/discover/curatedApps', () => ({ appHasMediaBridge: () => fixture.allowed }))
import { useAppMediaBridge } from '../useAppMediaBridge'
describe('app media session boundary', () => {
beforeEach(() => { vi.clearAllMocks(); fixture.allowed = true; fixture.launcher.mediaAppId = 'node-demo-v4v'; fixture.launcher.panelAppId = null })
it('checks frame, origin, nonce and finite state before attaching controls', async () => {
const child = { postMessage: vi.fn() }
const visible = ref(false)
let bridge!: ReturnType<typeof useAppMediaBridge>
const wrapper = mount(defineComponent({ setup() {
bridge = useAppMediaBridge(ref('node-demo-v4v'), ref('https://node.test:7475/'), shallowRef({ contentWindow: child } as unknown as HTMLIFrameElement), visible)
return () => null
} }))
bridge.connect()
const hello = child.postMessage.mock.calls[0]![0]
expect(child.postMessage.mock.calls[0]![1]).toBe('https://node.test:7475')
const state = { type: 'archipelago:media-state', version: 1, session: hello.session, available: true, title: 'Song', artist: 'Artist', playing: true, position: 22, duration: 100 }
const message = (data = state, origin = 'https://node.test:7475', source: unknown = child) => bridge.handle({ data, origin, source } as MessageEvent)
message(state, 'http://node.test:7475'); message(state, 'https://evil.test'); message(state, 'https://node.test:7475', {})
message({ ...state, session: 'wrong' }); message({ ...state, duration: NaN })
expect(fixture.player.updateExternal).not.toHaveBeenCalled()
message()
expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
const [controller, snapshot, shown] = fixture.player.updateExternal.mock.calls[0]!
expect(snapshot.position).toBe(22); expect(shown).toBe(false)
controller.seek(30)
expect(child.postMessage.mock.calls.at(-1)![0]).toMatchObject({ command: 'seek', position: 30, session: hello.session })
controller.open(); expect(fixture.launcher.openSession).toHaveBeenCalledWith('node-demo-v4v')
visible.value = true; await nextTick()
expect(fixture.player.setExternalVisible).toHaveBeenCalledWith(controller.id, true)
fixture.allowed = false; message()
expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
fixture.allowed = true
wrapper.unmount()
expect(fixture.player.releaseExternal).toHaveBeenCalledWith(controller.id)
expect(child.postMessage.mock.calls.at(-1)![0]).toMatchObject({ command: 'pause' })
})
})
@@ -61,6 +61,29 @@ describe('useAudioPlayer', () => {
if (lastMockAudio) lastMockAudio.shouldRejectPlay = false if (lastMockAudio) lastMockAudio.shouldRejectPlay = false
}) })
it('controls an app player without copying its stream or losing its position', () => {
const player = useAudioPlayer()
player.play('/local.mp3', 'Local')
const controller = { id: 'demo-session', play: vi.fn(), pause: vi.fn(), seek: vi.fn(), open: vi.fn(), close: vi.fn() }
player.updateExternal(controller, { title: 'Demo', artist: 'Artist', playing: true, position: 42, duration: 100 }, true)
expect(lastMockAudio?.paused).toBe(true)
expect(player.currentSrc.value).toBe('app:demo-session')
lastMockAudio?.simulateEvent('pause')
expect(player.playing.value).toBe(true)
expect(player.currentTime.value).toBe(42)
player.setExternalVisible('demo-session', false)
expect(player.externalVisible.value).toBe(false)
player.pause(); player.seek(55); player.openExternal()
expect(controller.pause).toHaveBeenCalledOnce()
expect(controller.seek).toHaveBeenCalledWith(55)
expect(controller.open).toHaveBeenCalledOnce()
player.play('app:demo-session', 'Demo')
expect(controller.play).toHaveBeenCalledOnce()
player.stop()
expect(controller.close).toHaveBeenCalledOnce()
expect(player.currentName.value).toBe('')
})
it('returns all expected properties', () => { it('returns all expected properties', () => {
const player = useAudioPlayer() const player = useAudioPlayer()
expect(player.play).toBeTypeOf('function') expect(player.play).toBeTypeOf('function')
@@ -0,0 +1,41 @@
import { onBeforeUnmount, watch, type Ref } from 'vue'
import { appHasMediaBridge } from '@/views/discover/curatedApps'
import { useAudioPlayer, type ExternalAudioState } from './useAudioPlayer'
import { useAppLauncherStore } from '@/stores/appLauncher'
export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: Ref<HTMLIFrameElement | null>, visible: Ref<boolean>) {
const player = useAudioPlayer()
const launcher = useAppLauncherStore()
const session = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('')
const id = `${appId.value}:${session}`
const origin = () => { try { return new URL(url.value, window.location.origin).origin } catch { return '' } }
function command(command: string, position?: number) {
if (!appHasMediaBridge(appId.value) || !origin()) return
frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-control', version: 1, session, command, position }, origin())
}
function connect() {
if (!appHasMediaBridge(appId.value) || !origin()) return
frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-connect', version: 1, session }, origin())
}
const controller = { id, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
open: () => launcher.openSession(appId.value), close: () => {
if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null
},
}
function handle(event: MessageEvent) {
if (!appHasMediaBridge(appId.value) || event.source !== frame.value?.contentWindow || event.origin !== origin()) return
const data = event.data
if (data?.version !== 1) return
if (data.type === 'archipelago:media-ready') { connect(); return }
if (data.type !== 'archipelago:media-state' || data.session !== session) return
if (data.available !== true) { player.detachExternal(id); return }
if (typeof data.title !== 'string' || typeof data.artist !== 'string' || typeof data.playing !== 'boolean'
|| !Number.isFinite(data.position) || !Number.isFinite(data.duration) || data.position < 0 || data.duration < 0) return
const state: ExternalAudioState = { title: data.title.slice(0, 256), artist: data.artist.slice(0, 256), playing: data.playing,
position: Math.min(data.position, data.duration), duration: data.duration }
player.updateExternal(controller, state, visible.value)
}
watch(visible, shown => player.setExternalVisible(id, shown))
onBeforeUnmount(() => { command('pause'); player.releaseExternal(id) })
return { connect, handle }
}
+73 -10
View File
@@ -10,43 +10,93 @@ const duration = ref(0)
const error = ref<string | null>(null) const error = ref<string | null>(null)
let initialized = false let initialized = false
export interface ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number }
export interface ExternalAudioController {
id: string
play: () => void
pause: () => void
seek: (position: number) => void
open: () => void
close: () => void
}
let external: ExternalAudioController | null = null
const suppressedExternal = new Set<string>()
const externalVisible = ref(false)
const externalApp = ref(false)
function updateExternal(controller: ExternalAudioController, state: ExternalAudioState, visible: boolean) {
if (suppressedExternal.has(controller.id)) { if (!state.playing) suppressedExternal.delete(controller.id); return }
if (external?.id !== controller.id) {
if (!state.playing) return
audio.value?.pause()
if (external) { suppressedExternal.add(external.id); external.pause() }
external = controller
}
externalApp.value = true
externalVisible.value = visible
currentSrc.value = `app:${controller.id}`
currentName.value = [state.title, state.artist].filter(Boolean).join(' · ')
playing.value = state.playing
currentTime.value = state.position
duration.value = state.duration
loading.value = false
error.value = null
}
function detachExternal(id: string) {
if (external?.id !== id) return
external = null
externalApp.value = false
externalVisible.value = false
currentSrc.value = null
currentName.value = ''
playing.value = false
currentTime.value = 0
duration.value = 0
loading.value = false
error.value = null
}
function releaseExternal(id: string) { detachExternal(id); suppressedExternal.delete(id) }
function setExternalVisible(id: string, visible: boolean) { if (external?.id === id) externalVisible.value = visible }
function openExternal() { external?.open() }
/** Create the Audio element and attach listeners once */ /** Create the Audio element and attach listeners once */
function init() { function init() {
if (initialized) return if (initialized) return
initialized = true initialized = true
audio.value = new Audio() audio.value = new Audio()
audio.value.addEventListener('timeupdate', () => { const listen = (event: string, callback: () => void) => audio.value!.addEventListener(event, () => { if (!external && currentSrc.value) callback() })
listen('timeupdate', () => {
currentTime.value = audio.value?.currentTime ?? 0 currentTime.value = audio.value?.currentTime ?? 0
}) })
audio.value.addEventListener('loadedmetadata', () => { listen('loadedmetadata', () => {
duration.value = audio.value?.duration ?? 0 duration.value = audio.value?.duration ?? 0
error.value = null error.value = null
}) })
// Buffering / connecting over mesh|Tor → show a loader until it can play. // Buffering / connecting over mesh|Tor → show a loader until it can play.
audio.value.addEventListener('loadstart', () => { listen('loadstart', () => {
loading.value = true loading.value = true
}) })
audio.value.addEventListener('waiting', () => { listen('waiting', () => {
loading.value = true loading.value = true
}) })
audio.value.addEventListener('canplay', () => { listen('canplay', () => {
loading.value = false loading.value = false
}) })
audio.value.addEventListener('playing', () => { listen('playing', () => {
loading.value = false loading.value = false
}) })
audio.value.addEventListener('ended', () => { listen('ended', () => {
playing.value = false playing.value = false
loading.value = false loading.value = false
}) })
audio.value.addEventListener('pause', () => { listen('pause', () => {
playing.value = false playing.value = false
}) })
audio.value.addEventListener('play', () => { listen('play', () => {
playing.value = true playing.value = true
error.value = null error.value = null
}) })
audio.value.addEventListener('error', () => { listen('error', () => {
playing.value = false playing.value = false
loading.value = false loading.value = false
error.value = 'Could not play this audio file. The peer may be offline, or the file may be unavailable.' error.value = 'Could not play this audio file. The peer may be offline, or the file may be unavailable.'
@@ -54,6 +104,8 @@ function init() {
} }
function play(src: string, name: string) { function play(src: string, name: string) {
if (external && currentSrc.value === src) { external.play(); return }
if (external) { suppressedExternal.add(external.id); external.pause(); detachExternal(external.id) }
init() init()
error.value = null error.value = null
@@ -64,6 +116,8 @@ function play(src: string, name: string) {
if (currentSrc.value !== src) { if (currentSrc.value !== src) {
loading.value = true loading.value = true
currentTime.value = 0
duration.value = 0
audio.value!.src = src audio.value!.src = src
currentSrc.value = src currentSrc.value = src
currentName.value = name currentName.value = name
@@ -75,22 +129,26 @@ function play(src: string, name: string) {
// already wired up there. The 'error' listener sets the same state, so // already wired up there. The 'error' listener sets the same state, so
// this just needs to stop the rejection from going unhandled. // this just needs to stop the rejection from going unhandled.
audio.value!.play().catch(() => { audio.value!.play().catch(() => {
if (external || currentSrc.value !== src) return
playing.value = false playing.value = false
loading.value = false loading.value = false
}) })
} }
function pause() { function pause() {
if (external) { external.pause(); return }
audio.value?.pause() audio.value?.pause()
} }
function seek(time: number) { function seek(time: number) {
if (external) { external.seek(time); return }
if (audio.value) { if (audio.value) {
audio.value.currentTime = time audio.value.currentTime = time
} }
} }
function stop() { function stop() {
if (external) { const current = external; suppressedExternal.add(current.id); current.pause(); detachExternal(current.id); current.close(); return }
if (audio.value) { if (audio.value) {
audio.value.pause() audio.value.pause()
audio.value.currentTime = 0 audio.value.currentTime = 0
@@ -98,6 +156,10 @@ function stop() {
playing.value = false playing.value = false
currentSrc.value = null currentSrc.value = null
currentName.value = '' currentName.value = ''
currentTime.value = 0
duration.value = 0
loading.value = false
error.value = null
} }
const progress = computed(() => { const progress = computed(() => {
@@ -107,6 +169,7 @@ const progress = computed(() => {
export function useAudioPlayer() { export function useAudioPlayer() {
return { return {
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
play, play,
pause, pause,
seek, seek,
+5 -1
View File
@@ -1,3 +1,4 @@
import { appHasMediaBridge } from '@/views/discover/curatedApps'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@@ -235,6 +236,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Active app in the store-driven session (no route change) */ /** Active app in the store-driven session (no route change) */
const panelAppId = ref<string | null>(null) const panelAppId = ref<string | null>(null)
const mediaAppId = ref<string | null>(null)
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */ /** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
const panelPath = ref<string | null>(null) const panelPath = ref<string | null>(null)
@@ -251,7 +253,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// phone controls and better performance. Apps with manifest-declared host // phone controls and better performance. Apps with manifest-declared host
// integrations stay in the dashboard frame so their parent bridge remains // integrations stay in the dashboard frame so their parent bridge remains
// connected (for example GitWorkshop's consent-gated NIP-07 provider). // connected (for example GitWorkshop's consent-gated NIP-07 provider).
if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId)) { if (!IS_DEMO && isCompanionApp() && !HOST_FRAME_APPS.has(appId) && !appHasMediaBridge(appId)) {
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl) const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
if (launchUrl) { if (launchUrl) {
@@ -288,6 +290,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// page never changes: panel mode renders beside the page, overlay and // page never changes: panel mode renders beside the page, overlay and
// fullscreen modes render above it (AppSession styles per display mode). // fullscreen modes render above it (AppSession styles per display mode).
// Closing always returns the user exactly where they launched from. // Closing always returns the user exactly where they launched from.
if (appHasMediaBridge(appId)) mediaAppId.value = appId
panelPath.value = opts.path ?? null panelPath.value = opts.path ?? null
panelAppId.value = appId panelAppId.value = appId
} }
@@ -712,6 +715,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
close, close,
closePanel, closePanel,
panelAppId, panelAppId,
mediaAppId,
panelPath, panelPath,
credentialPrompt, credentialPrompt,
cancelCredentialLaunch, cancelCredentialLaunch,
+10 -2
View File
@@ -131,6 +131,7 @@ import {
} from './appSession/appSessionConfig' } from './appSession/appSessionConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig' import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { PackageState } from '@/types/api' import { PackageState } from '@/types/api'
import { useAppMediaBridge } from '@/composables/useAppMediaBridge'
import { useAppIdentity } from './appSession/useAppIdentity' import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge' import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal' import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
@@ -141,6 +142,7 @@ const props = defineProps<{
appIdProp?: string appIdProp?: string
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */ /** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
pathProp?: string pathProp?: string
suspended?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -179,8 +181,8 @@ const appId = computed(() => {
// Display mode -- per-app user choice → per-app default → last global → panel // Display mode -- per-app user choice → per-app default → last global → panel
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value)) const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
const appTitle = computed(() => resolveAppTitle(appId.value))
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null) const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
const appTitle = computed(() => packageEntry.value?.manifest?.title || resolveAppTitle(appId.value))
const appIcon = computed(() => const appIcon = computed(() =>
packageEntry.value packageEntry.value
? resolveAppIcon(appId.value, packageEntry.value) ? resolveAppIcon(appId.value, packageEntry.value)
@@ -279,6 +281,7 @@ function closeRouteSession() {
// --- Identity & Nostr bridge --- // --- Identity & Nostr bridge ---
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null) const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const mediaBridge = useAppMediaBridge(appId, appUrl, iframeRef, computed(() => !props.suspended))
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker) const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, { const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
@@ -342,6 +345,7 @@ function syncInlineRect() {
} }
const inlineRectStyle = computed<Record<string, string> | undefined>(() => { const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
if (props.suspended) return { display: 'none' }
if (!inlinePanelMode.value) return undefined if (!inlinePanelMode.value) return undefined
const r = inlineRect.value const r = inlineRect.value
// Never paint the inline backdrop over the whole viewport while unmeasured. // Never paint the inline backdrop over the whole viewport while unmeasured.
@@ -393,6 +397,7 @@ watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
// --- Lifecycle handlers --- // --- Lifecycle handlers ---
function onLoad() { function onLoad() {
mediaBridge.connect()
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null } if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null } if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
loading.value = false loading.value = false
@@ -404,7 +409,7 @@ function onLoad() {
forgetAutoTabApp(appId.value) forgetAutoTabApp(appId.value)
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's // TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
// virtual keyboard) flow into the iframe without needing a pointer click. // virtual keyboard) flow into the iframe without needing a pointer click.
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ } try { if (!props.suspended) frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
// Check if iframe actually loaded content (same-origin only) // Check if iframe actually loaded content (same-origin only)
iframeCheckId = setTimeout(() => { iframeCheckId = setTimeout(() => {
try { try {
@@ -488,6 +493,7 @@ function closeSession() {
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (props.suspended) return
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (nostrBridge.showConsent.value) { if (nostrBridge.showConsent.value) {
nostrBridge.denyConsent() nostrBridge.denyConsent()
@@ -508,6 +514,8 @@ function onFullscreenChange() {
} }
function onMessage(e: MessageEvent) { function onMessage(e: MessageEvent) {
if (e.source !== iframeRef.value?.contentWindow) return
mediaBridge.handle(e)
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e) if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true) if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value) if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
+6 -1
View File
@@ -93,14 +93,19 @@
<!-- Panel mode app session — renders alongside current page content --> <!-- Panel mode app session — renders alongside current page content -->
<Transition name="panel-slide"> <Transition name="panel-slide">
<div v-if="appLauncher.panelAppId" class="app-panel-container"> <div v-if="appLauncher.panelAppId && appLauncher.panelAppId !== appLauncher.mediaAppId" class="app-panel-container">
<AppSession <AppSession
v-if="appLauncher.panelAppId !== appLauncher.mediaAppId"
:key="appLauncher.panelAppId"
:app-id-prop="appLauncher.panelAppId" :app-id-prop="appLauncher.panelAppId"
:path-prop="appLauncher.panelPath ?? undefined" :path-prop="appLauncher.panelPath ?? undefined"
@close="appLauncher.closePanel()" @close="appLauncher.closePanel()"
/> />
</div> </div>
</Transition> </Transition>
<div v-if="appLauncher.mediaAppId" v-show="appLauncher.panelAppId === appLauncher.mediaAppId" class="app-panel-container">
<AppSession :key="appLauncher.mediaAppId" :app-id-prop="appLauncher.mediaAppId" :suspended="appLauncher.panelAppId !== appLauncher.mediaAppId" @close="appLauncher.closePanel()" />
</div>
</main> </main>
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform --> <!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const demo = () => ({ scope: 'single-node-demo', expires_at: new Date(Date.now() + 60000).toISOString(), apps: {
'node-demo-v4v': { version: '1', manifest: { app: { id: 'node-demo-v4v', name: 'V4V demo', ports: [{ host: 7475, auth: 'gated' }] } } },
}, storefront: { promotions: [{ id: 'node-demo-v4v', headline: 'Sovereign Music' }] } })
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear() })
describe('node-scoped demo catalog', () => {
it.each([{}, [null]])('ignores malformed optional promotions: %j', async promotions => {
vi.resetModules()
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/node-app-catalog') return { ok: true, json: async () => ({ ...demo(), storefront: { promotions } }) }
if (url === '/api/app-catalog') return { ok: true, json: async () => ({ apps: { mempool: { version: '3' } } }) }
return { ok: false }
}))
const { fetchAppCatalog } = await import('../curatedApps')
const catalog = await fetchAppCatalog()
expect(catalog?.apps.map(app => app.id)).toContain('node-demo-v4v')
expect(catalog?.storefront?.promotions).toEqual([])
})
it('adds only the verified node response and never persists it into shared fallback', async () => {
vi.resetModules()
let available = true
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/node-app-catalog') return { ok: available, json: async () => demo() }
if (url === '/api/app-catalog') return { ok: true, json: async () => ({ apps: { mempool: { version: '3' } } }) }
return { ok: false }
}))
const { fetchAppCatalog, portIsGateFronted } = await import('../curatedApps')
const first = await fetchAppCatalog()
expect(first?.apps.map(app => app.id)).toEqual(['mempool', 'node-demo-v4v'])
expect(first?.storefront?.promotions[0]?.headline).toBe('Sovereign Music')
expect(localStorage.getItem('archy_catalog')).not.toContain('node-demo-v4v')
expect(portIsGateFronted('node-demo-v4v', 7475)).toBe(true)
available = false
const second = await fetchAppCatalog()
expect(second?.apps.map(app => app.id)).toEqual(['mempool'])
expect(second?.storefront?.promotions ?? []).toHaveLength(0)
expect(portIsGateFronted('node-demo-v4v', 7475)).toBe(false)
})
})
+31 -3
View File
@@ -68,7 +68,7 @@ export interface SignedAppEntry {
description?: string description?: string
category?: string category?: string
container?: { image?: string } container?: { image?: string }
metadata?: { icon?: string; author?: string; repo?: string } metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string } }
ports?: { host?: number | string; container?: number | string; auth?: string }[] ports?: { host?: number | string; container?: number | string; auth?: string }[]
} }
} }
@@ -104,6 +104,11 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups /** The daemon-verified signed catalog, kept for synchronous port-auth lookups
* after fetchAppCatalog() has run. Test-hookable. */ * after fetchAppCatalog() has run. Test-hookable. */
let signedCatalogCache: SignedAppCatalog | null = null let signedCatalogCache: SignedAppCatalog | null = null
let nodeCatalogCache: SignedAppCatalog | null = null
export function appHasMediaBridge(id: string): boolean {
return nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1'
}
/** Launch aliases → the catalog app id that OWNS the UI port. /** Launch aliases → the catalog app id that OWNS the UI port.
* *
@@ -141,7 +146,7 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
* answered `gated`, or an https frame URL would point at a port that never * answered `gated`, or an https frame URL would point at a port that never
* serves TLS. */ * serves TLS. */
export function portAuth(appId: string, hostPort: number | string): string | null { export function portAuth(appId: string, hostPort: number | string): string | null {
const apps = signedCatalogCache?.apps const apps = { ...signedCatalogCache?.apps, ...nodeCatalogCache?.apps }
if (!apps) return null if (!apps) return null
const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId] const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId]
const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias] const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias]
@@ -188,9 +193,32 @@ const CATALOG_URLS = [
'/catalog.json', '/catalog.json',
] ]
/** Node demos are requested separately and never persisted in browser fallback
* storage or the global release catalog. A copied catalog is rejected by the
* daemon unless its release signature and exact node audience match. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const nodeRequest = fetch('/api/node-app-catalog', { credentials: 'include', signal: AbortSignal.timeout(5000) })
.then(async response => response.ok ? await response.json() : null).catch(() => null)
const [base, node] = await Promise.all([fetchBaseAppCatalog(), nodeRequest])
nodeCatalogCache = null
if (!base || node?.scope !== 'single-node-demo' || !node.apps || Array.isArray(node.apps)
|| !Object.keys(node.apps).every(id => id.startsWith('node-demo-'))
|| !(Date.parse(node.expires_at) > Date.now())) return base
nodeCatalogCache = node as SignedAppCatalog
const known = new Set(base.apps.map(app => app.id))
const additions = signedCatalogToApps(node).filter(app => !known.has(app.id))
const demoIds = new Set(additions.map(app => app.id))
const promotions = (Array.isArray(node.storefront?.promotions) ? node.storefront.promotions : [])
.filter((promotion: CatalogPromotion | null) => promotion && demoIds.has(promotion.id))
return { ...base, apps: [...base.apps, ...additions], storefront: {
popular: base.storefront?.popular ?? [],
promotions: [...(base.storefront?.promotions ?? []), ...promotions],
} }
}
/** Fetch app catalog from remote registry, with local fallback. /** Fetch app catalog from remote registry, with local fallback.
* Caches for 1 hour. Returns null only if ALL sources fail. */ * Caches for 1 hour. Returns null only if ALL sources fail. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> { async function fetchBaseAppCatalog(): Promise<AppCatalog | null> {
// Return cache if fresh // Return cache if fresh
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) } if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) }