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
|
||||
// falls outside `connect-src`). Session-authenticated so only
|
||||
// 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") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
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 —
|
||||
/// callers then fall back to `image-versions.sh`.
|
||||
fn load_catalog() -> AppCatalog {
|
||||
fn load_global_catalog() -> AppCatalog {
|
||||
let (path, mtime) = match find_cache_file() {
|
||||
Some(v) => v,
|
||||
None => return AppCatalog::default(),
|
||||
@@ -218,6 +218,18 @@ fn load_catalog() -> AppCatalog {
|
||||
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> {
|
||||
load_catalog().apps.get(app_id).cloned()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app_catalog;
|
||||
pub mod node_catalog;
|
||||
pub mod app_gate_config;
|
||||
pub mod bitcoin_ui;
|
||||
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">
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="audioPlayer.currentName.value"
|
||||
v-if="audioPlayer.currentName.value && !audioPlayer.externalVisible.value"
|
||||
ref="barEl"
|
||||
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>
|
||||
</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 -->
|
||||
<button
|
||||
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')
|
||||
}
|
||||
|
||||
watch(() => audioPlayer.currentName.value, (name) => {
|
||||
watch(() => audioPlayer.currentName.value && !audioPlayer.externalVisible.value, (name) => {
|
||||
if (name) {
|
||||
nextTick(setPlayerHeightVar)
|
||||
} 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
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const player = useAudioPlayer()
|
||||
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)
|
||||
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 */
|
||||
function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
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
|
||||
})
|
||||
audio.value.addEventListener('loadedmetadata', () => {
|
||||
listen('loadedmetadata', () => {
|
||||
duration.value = audio.value?.duration ?? 0
|
||||
error.value = null
|
||||
})
|
||||
// Buffering / connecting over mesh|Tor → show a loader until it can play.
|
||||
audio.value.addEventListener('loadstart', () => {
|
||||
listen('loadstart', () => {
|
||||
loading.value = true
|
||||
})
|
||||
audio.value.addEventListener('waiting', () => {
|
||||
listen('waiting', () => {
|
||||
loading.value = true
|
||||
})
|
||||
audio.value.addEventListener('canplay', () => {
|
||||
listen('canplay', () => {
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('playing', () => {
|
||||
listen('playing', () => {
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('ended', () => {
|
||||
listen('ended', () => {
|
||||
playing.value = false
|
||||
loading.value = false
|
||||
})
|
||||
audio.value.addEventListener('pause', () => {
|
||||
listen('pause', () => {
|
||||
playing.value = false
|
||||
})
|
||||
audio.value.addEventListener('play', () => {
|
||||
listen('play', () => {
|
||||
playing.value = true
|
||||
error.value = null
|
||||
})
|
||||
audio.value.addEventListener('error', () => {
|
||||
listen('error', () => {
|
||||
playing.value = false
|
||||
loading.value = false
|
||||
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) {
|
||||
if (external && currentSrc.value === src) { external.play(); return }
|
||||
if (external) { suppressedExternal.add(external.id); external.pause(); detachExternal(external.id) }
|
||||
init()
|
||||
error.value = null
|
||||
|
||||
@@ -64,6 +116,8 @@ function play(src: string, name: string) {
|
||||
|
||||
if (currentSrc.value !== src) {
|
||||
loading.value = true
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
audio.value!.src = src
|
||||
currentSrc.value = src
|
||||
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
|
||||
// this just needs to stop the rejection from going unhandled.
|
||||
audio.value!.play().catch(() => {
|
||||
if (external || currentSrc.value !== src) return
|
||||
playing.value = false
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (external) { external.pause(); return }
|
||||
audio.value?.pause()
|
||||
}
|
||||
|
||||
function seek(time: number) {
|
||||
if (external) { external.seek(time); return }
|
||||
if (audio.value) {
|
||||
audio.value.currentTime = time
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (external) { const current = external; suppressedExternal.add(current.id); current.pause(); detachExternal(current.id); current.close(); return }
|
||||
if (audio.value) {
|
||||
audio.value.pause()
|
||||
audio.value.currentTime = 0
|
||||
@@ -98,6 +156,10 @@ function stop() {
|
||||
playing.value = false
|
||||
currentSrc.value = null
|
||||
currentName.value = ''
|
||||
currentTime.value = 0
|
||||
duration.value = 0
|
||||
loading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
const progress = computed(() => {
|
||||
@@ -107,6 +169,7 @@ const progress = computed(() => {
|
||||
|
||||
export function useAudioPlayer() {
|
||||
return {
|
||||
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { appHasMediaBridge } from '@/views/discover/curatedApps'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
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) */
|
||||
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) */
|
||||
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
|
||||
// integrations stay in the dashboard frame so their parent bridge remains
|
||||
// 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 launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
||||
if (launchUrl) {
|
||||
@@ -288,6 +290,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// page never changes: panel mode renders beside the page, overlay and
|
||||
// fullscreen modes render above it (AppSession styles per display mode).
|
||||
// Closing always returns the user exactly where they launched from.
|
||||
if (appHasMediaBridge(appId)) mediaAppId.value = appId
|
||||
panelPath.value = opts.path ?? null
|
||||
panelAppId.value = appId
|
||||
}
|
||||
@@ -712,6 +715,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
close,
|
||||
closePanel,
|
||||
panelAppId,
|
||||
mediaAppId,
|
||||
panelPath,
|
||||
credentialPrompt,
|
||||
cancelCredentialLaunch,
|
||||
|
||||
@@ -131,6 +131,7 @@ import {
|
||||
} from './appSession/appSessionConfig'
|
||||
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
||||
import { PackageState } from '@/types/api'
|
||||
import { useAppMediaBridge } from '@/composables/useAppMediaBridge'
|
||||
import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
||||
@@ -141,6 +142,7 @@ const props = defineProps<{
|
||||
appIdProp?: string
|
||||
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
|
||||
pathProp?: string
|
||||
suspended?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -179,8 +181,8 @@ const appId = computed(() => {
|
||||
// Display mode -- per-app user choice → per-app default → last global → panel
|
||||
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
|
||||
|
||||
const appTitle = computed(() => resolveAppTitle(appId.value))
|
||||
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
||||
const appTitle = computed(() => packageEntry.value?.manifest?.title || resolveAppTitle(appId.value))
|
||||
const appIcon = computed(() =>
|
||||
packageEntry.value
|
||||
? resolveAppIcon(appId.value, packageEntry.value)
|
||||
@@ -279,6 +281,7 @@ function closeRouteSession() {
|
||||
// --- Identity & Nostr bridge ---
|
||||
|
||||
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
||||
const mediaBridge = useAppMediaBridge(appId, appUrl, iframeRef, computed(() => !props.suspended))
|
||||
|
||||
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
|
||||
const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
|
||||
@@ -342,6 +345,7 @@ function syncInlineRect() {
|
||||
}
|
||||
|
||||
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
|
||||
if (props.suspended) return { display: 'none' }
|
||||
if (!inlinePanelMode.value) return undefined
|
||||
const r = inlineRect.value
|
||||
// Never paint the inline backdrop over the whole viewport while unmeasured.
|
||||
@@ -393,6 +397,7 @@ watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
|
||||
// --- Lifecycle handlers ---
|
||||
|
||||
function onLoad() {
|
||||
mediaBridge.connect()
|
||||
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
||||
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
||||
loading.value = false
|
||||
@@ -404,7 +409,7 @@ function onLoad() {
|
||||
forgetAutoTabApp(appId.value)
|
||||
// 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.
|
||||
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)
|
||||
iframeCheckId = setTimeout(() => {
|
||||
try {
|
||||
@@ -488,6 +493,7 @@ function closeSession() {
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (props.suspended) return
|
||||
if (e.key === 'Escape') {
|
||||
if (nostrBridge.showConsent.value) {
|
||||
nostrBridge.denyConsent()
|
||||
@@ -508,6 +514,8 @@ function onFullscreenChange() {
|
||||
}
|
||||
|
||||
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 === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
|
||||
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
|
||||
|
||||
@@ -93,14 +93,19 @@
|
||||
|
||||
<!-- Panel mode app session — renders alongside current page content -->
|
||||
<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
|
||||
v-if="appLauncher.panelAppId !== appLauncher.mediaAppId"
|
||||
:key="appLauncher.panelAppId"
|
||||
:app-id-prop="appLauncher.panelAppId"
|
||||
:path-prop="appLauncher.panelPath ?? undefined"
|
||||
@close="appLauncher.closePanel()"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 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
|
||||
category?: 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 }[]
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,11 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
|
||||
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
|
||||
* after fetchAppCatalog() has run. Test-hookable. */
|
||||
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.
|
||||
*
|
||||
@@ -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
|
||||
* serves TLS. */
|
||||
export function portAuth(appId: string, hostPort: number | string): string | null {
|
||||
const apps = signedCatalogCache?.apps
|
||||
const apps = { ...signedCatalogCache?.apps, ...nodeCatalogCache?.apps }
|
||||
if (!apps) return null
|
||||
const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId]
|
||||
const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias]
|
||||
@@ -188,9 +193,32 @@ const CATALOG_URLS = [
|
||||
'/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.
|
||||
* 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
|
||||
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user