Add signed node-scoped demo catalogs and retained app media sessions
This commit is contained in:
@@ -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());
|
||||||
|
|||||||
@@ -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,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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) }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user