feat(content): owned-content persistence + Fedimint paid downloads, fmcd caps fix, FIPS warm-path perf
Buyer-side paid downloads now persist: purchases are cached on disk (content_owned.rs) keyed by (seller onion, content_id), the gallery shows an "Owned" badge unblurred, and items view/play in-app from the local cache with no re-payment or reliance on a browser download (which silently failed on the mobile companion). New RPCs content.owned-list / content.owned-get. Validated e2e .116<-.198 (paid 100 sats via Fedimint, 166KB jpeg returns, survives restart). fedimint-clientd manifest: restore the standard container capability set (CHOWN/DAC_OVERRIDE/FOWNER/SETUID/SETGID) so fmcd's startup chown of an existing-federation /data succeeds instead of dying EPERM (#7). Confirmed the orchestrator applies these to the running container. FIPS perf: tighten the supervisor warm-path keepalive 45s -> 25s so peer paths stay inside the ~30-60s NAT cold window. Dials now reliably land on FIPS instead of re-punching and falling back to Tor. Measured to the same peer: cloud browse 18-22s -> 0.4s; full Fedimint paid download 29s -> 11s (residual is the seller-side guardian reissue round-trip). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b0c9bd2a0c
commit
db7d424bff
@ -33,7 +33,14 @@ app:
|
|||||||
disk_limit: 2Gi
|
disk_limit: 2Gi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
capabilities: []
|
# fmcd's `fmcd-run` launcher chowns its /data (existing federation DB) on
|
||||||
|
# every start. With the default `cap_drop: ALL` and no caps added back, that
|
||||||
|
# chown fails and fmcd dies "Operation not permitted (os error 1)" — but ONLY
|
||||||
|
# once /data holds a joined federation (a fresh/empty dir needs no chown, so
|
||||||
|
# it appeared to work). Restore the standard container capability set so the
|
||||||
|
# startup chown succeeds (#7). Verified by bisection on .116: these caps make
|
||||||
|
# fmcd boot + serve /v2/*; DAC_OVERRIDE or SETUID/SETGID alone do NOT.
|
||||||
|
capabilities: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID"]
|
||||||
readonly_root: true
|
readonly_root: true
|
||||||
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
||||||
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
|
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
|
||||||
|
|||||||
@ -532,11 +532,49 @@ impl RpcHandler {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture the content type BEFORE consuming the body so the local cache
|
||||||
|
// can render the right viewer (image vs video) later.
|
||||||
|
let mime_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
|
||||||
let bytes = response
|
let bytes = response
|
||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.context("Failed to read response body")?;
|
.context("Failed to read response body")?;
|
||||||
|
|
||||||
|
// Persist the purchase so it "stays unlocked" for this buyer: cache the
|
||||||
|
// bytes + metadata keyed by (onion, content_id). The gallery then renders
|
||||||
|
// it unblurred and views it in-app from this cache — no re-payment and no
|
||||||
|
// reliance on a browser download (which silently fails on the mobile
|
||||||
|
// companion, the original "paid but never unlocked" report). Best-effort:
|
||||||
|
// a cache-write failure must not fail an already-paid download.
|
||||||
|
let filename = params
|
||||||
|
.get("filename")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(content_id)
|
||||||
|
.to_string();
|
||||||
|
let purchased_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
if let Err(e) = crate::content_owned::record_purchase(
|
||||||
|
&self.config.data_dir,
|
||||||
|
onion,
|
||||||
|
content_id,
|
||||||
|
&filename,
|
||||||
|
&mime_type,
|
||||||
|
&bytes,
|
||||||
|
price_sats,
|
||||||
|
used_backend,
|
||||||
|
&purchased_at,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||||
|
}
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
@ -546,6 +584,8 @@ impl RpcHandler {
|
|||||||
"size": bytes.len(),
|
"size": bytes.len(),
|
||||||
"paid_sats": price_sats,
|
"paid_sats": price_sats,
|
||||||
"ecash_backend": used_backend,
|
"ecash_backend": used_backend,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"owned": true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1017,4 +1057,43 @@ impl RpcHandler {
|
|||||||
"preview_mode": is_preview,
|
"preview_mode": is_preview,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `content.owned-list` — every paid item this node has purchased, so the
|
||||||
|
/// gallery can render owned items unblurred/viewable without re-payment.
|
||||||
|
pub(super) async fn handle_content_owned_list(&self) -> Result<serde_json::Value> {
|
||||||
|
let items = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||||
|
Ok(serde_json::json!({ "items": items }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `content.owned-get` — return a purchased item's bytes (base64) from the
|
||||||
|
/// local cache for in-app viewing/saving. No network, no re-payment.
|
||||||
|
pub(super) async fn handle_content_owned_get(
|
||||||
|
&self,
|
||||||
|
params: Option<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||||
|
let onion = params
|
||||||
|
.get("onion")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
|
||||||
|
let content_id = params
|
||||||
|
.get("content_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
|
||||||
|
|
||||||
|
match crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await {
|
||||||
|
Some((mime_type, bytes)) => {
|
||||||
|
use base64::Engine;
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"data": encoded,
|
||||||
|
"size": bytes.len(),
|
||||||
|
"mime_type": mime_type,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => Ok(serde_json::json!({
|
||||||
|
"error": "You don't own this item yet, or its cached copy is missing."
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -276,6 +276,8 @@ impl RpcHandler {
|
|||||||
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
"content.browse-peer" => self.handle_content_browse_peer(params).await,
|
||||||
"content.download-peer" => self.handle_content_download_peer(params).await,
|
"content.download-peer" => self.handle_content_download_peer(params).await,
|
||||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||||
|
"content.owned-list" => self.handle_content_owned_list().await,
|
||||||
|
"content.owned-get" => self.handle_content_owned_get(params).await,
|
||||||
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
||||||
"content.invoice-status" => self.handle_content_invoice_status(params).await,
|
"content.invoice-status" => self.handle_content_invoice_status(params).await,
|
||||||
"content.download-peer-invoice" => {
|
"content.download-peer-invoice" => {
|
||||||
|
|||||||
165
core/archipelago/src/content_owned.rs
Normal file
165
core/archipelago/src/content_owned.rs
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
//! Buyer-side store of paid content the node has purchased.
|
||||||
|
//!
|
||||||
|
//! A paid peer download used to be ephemeral: the bytes were handed to the
|
||||||
|
//! browser as a one-shot `<a download>` and then thrown away. On the mobile
|
||||||
|
//! companion that download silently fails, so the item appeared to never
|
||||||
|
//! "unlock" even though the ecash was spent. This module persists every
|
||||||
|
//! successful purchase — bytes + metadata — keyed by (seller onion, content_id),
|
||||||
|
//! so the gallery can render owned items unblurred and play/view them in-app
|
||||||
|
//! from the local cache, with no re-payment and no reliance on a browser
|
||||||
|
//! download. The buyer can still save the file later from the cached copy.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
const OWNED_DIR: &str = "purchased-content";
|
||||||
|
const OWNED_INDEX: &str = "owned.json";
|
||||||
|
|
||||||
|
/// One purchased item. `onion` + `content_id` are the identity; everything else
|
||||||
|
/// is display/metadata captured at purchase time.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OwnedItem {
|
||||||
|
pub onion: String,
|
||||||
|
pub content_id: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub paid_sats: u64,
|
||||||
|
pub ecash_backend: String,
|
||||||
|
/// RFC3339 timestamp; best-effort, empty if the clock was unavailable.
|
||||||
|
pub purchased_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct OwnedIndex {
|
||||||
|
items: Vec<OwnedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned_root(data_dir: &Path) -> PathBuf {
|
||||||
|
data_dir.join(OWNED_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_path(data_dir: &Path) -> PathBuf {
|
||||||
|
owned_root(data_dir).join(OWNED_INDEX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize an onion into a safe directory component (it's already [a-z2-7].onion
|
||||||
|
/// for valid v3, but be defensive against path traversal regardless).
|
||||||
|
fn sanitize(component: &str) -> String {
|
||||||
|
component
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
|
||||||
|
owned_root(data_dir)
|
||||||
|
.join(sanitize(onion))
|
||||||
|
.join(sanitize(content_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_index(data_dir: &Path) -> OwnedIndex {
|
||||||
|
match fs::read_to_string(index_path(data_dir)).await {
|
||||||
|
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
|
||||||
|
Err(_) => OwnedIndex::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
|
||||||
|
let root = owned_root(data_dir);
|
||||||
|
fs::create_dir_all(&root)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {}", root.display()))?;
|
||||||
|
let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
|
||||||
|
fs::write(index_path(data_dir), content)
|
||||||
|
.await
|
||||||
|
.context("writing owned index")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a successful purchase: write the bytes to disk and upsert the index
|
||||||
|
/// entry. Idempotent on (onion, content_id) — re-buying overwrites with the
|
||||||
|
/// latest copy/metadata rather than duplicating.
|
||||||
|
pub async fn record_purchase(
|
||||||
|
data_dir: &Path,
|
||||||
|
onion: &str,
|
||||||
|
content_id: &str,
|
||||||
|
filename: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
paid_sats: u64,
|
||||||
|
ecash_backend: &str,
|
||||||
|
purchased_at: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path = bytes_path(data_dir, onion, content_id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
fs::write(&path, bytes)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("writing purchased bytes to {}", path.display()))?;
|
||||||
|
|
||||||
|
let mut index = load_index(data_dir).await;
|
||||||
|
let entry = OwnedItem {
|
||||||
|
onion: onion.to_string(),
|
||||||
|
content_id: content_id.to_string(),
|
||||||
|
filename: filename.to_string(),
|
||||||
|
mime_type: mime_type.to_string(),
|
||||||
|
size_bytes: bytes.len() as u64,
|
||||||
|
paid_sats,
|
||||||
|
ecash_backend: ecash_backend.to_string(),
|
||||||
|
purchased_at: purchased_at.to_string(),
|
||||||
|
};
|
||||||
|
if let Some(existing) = index
|
||||||
|
.items
|
||||||
|
.iter_mut()
|
||||||
|
.find(|i| i.onion == onion && i.content_id == content_id)
|
||||||
|
{
|
||||||
|
*existing = entry;
|
||||||
|
} else {
|
||||||
|
index.items.push(entry);
|
||||||
|
}
|
||||||
|
save_index(data_dir, &index).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every item this node owns.
|
||||||
|
pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> {
|
||||||
|
load_index(data_dir).await.items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the node has already purchased this (onion, content_id).
|
||||||
|
#[allow(dead_code)] // used by the upcoming seller-side signed-entitlement path (#8)
|
||||||
|
pub async fn is_owned(data_dir: &Path, onion: &str, content_id: &str) -> bool {
|
||||||
|
bytes_path(data_dir, onion, content_id).exists()
|
||||||
|
&& load_index(data_dir)
|
||||||
|
.await
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|i| i.onion == onion && i.content_id == content_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a purchased item's bytes + mime type from the local cache, if present.
|
||||||
|
pub async fn read_owned(
|
||||||
|
data_dir: &Path,
|
||||||
|
onion: &str,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Option<(String, Vec<u8>)> {
|
||||||
|
let bytes = fs::read(bytes_path(data_dir, onion, content_id)).await.ok()?;
|
||||||
|
let mime = load_index(data_dir)
|
||||||
|
.await
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.find(|i| i.onion == onion && i.content_id == content_id)
|
||||||
|
.map(|i| i.mime_type)
|
||||||
|
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
|
Some((mime, bytes))
|
||||||
|
}
|
||||||
@ -60,14 +60,23 @@ pub async fn ensure_activated(data_dir: &std::path::Path) {
|
|||||||
tracing::info!("FIPS auto-activated");
|
tracing::info!("FIPS auto-activated");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the FIPS supervisor: every 45s it (1) auto-activates FIPS if onboarding
|
/// Spawn the FIPS supervisor: every 25s it (1) auto-activates FIPS if onboarding
|
||||||
/// is done but the service is down — so it comes up with zero user interaction,
|
/// is done but the service is down — so it comes up with zero user interaction,
|
||||||
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
|
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
|
||||||
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
|
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
|
||||||
/// so one slow/offline peer doesn't delay the rest.
|
/// so one slow/offline peer doesn't delay the rest.
|
||||||
|
///
|
||||||
|
/// The interval MUST be shorter than the NAT/hole-punch cold window
|
||||||
|
/// (`warm_path` docs it at ~30-60s). The previous 45s sat at the edge of that
|
||||||
|
/// window: a path that went cold at ~30s stayed cold until the next 45s tick,
|
||||||
|
/// so real peer dials in that gap hit a cold path and fell back to Tor (~18s
|
||||||
|
/// onion latency instead of FIPS's ~2-3s). 25s keeps every path refreshed
|
||||||
|
/// inside the minimum cold window, which is what actually makes FIPS — not Tor —
|
||||||
|
/// the transport peer requests land on. Measured: warm FIPS browse ~2.6s vs a
|
||||||
|
/// cold-path fallback browse ~18-22s over Tor to the same peer.
|
||||||
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(45));
|
let mut tick = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||||
loop {
|
loop {
|
||||||
tick.tick().await;
|
tick.tick().await;
|
||||||
// Bring FIPS up on its own once onboarding has materialised the key.
|
// Bring FIPS up on its own once onboarding has materialised the key.
|
||||||
|
|||||||
@ -39,6 +39,7 @@ mod constants;
|
|||||||
mod container;
|
mod container;
|
||||||
mod content_hash;
|
mod content_hash;
|
||||||
mod content_invoice;
|
mod content_invoice;
|
||||||
|
mod content_owned;
|
||||||
mod content_server;
|
mod content_server;
|
||||||
mod crash_recovery;
|
mod crash_recovery;
|
||||||
mod credentials;
|
mod credentials;
|
||||||
|
|||||||
@ -169,6 +169,34 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return Ok(()), // clientd not configured yet
|
Err(_) => return Ok(()), // clientd not configured yet
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fast path: if fmcd already reports a joined federation, do NOT re-issue the
|
||||||
|
// POST /v2/admin/join. That call re-syncs federation config against the
|
||||||
|
// guardians and adds seconds of latency — and ensure_default_federation runs
|
||||||
|
// on every wallet.fedimint-list / spend / reissue, so the join was being paid
|
||||||
|
// on each balance refresh (the "mints take ages to load" report). The cheap
|
||||||
|
// GET /v2/admin/info is enough to confirm membership; just reconcile the local
|
||||||
|
// registry against the live joined set and return.
|
||||||
|
let joined = client.joined_federation_ids().await;
|
||||||
|
if !joined.is_empty() {
|
||||||
|
let mut reg = load_registry(data_dir).await?;
|
||||||
|
let mut changed = false;
|
||||||
|
for id in joined {
|
||||||
|
if !reg.federations.iter().any(|f| f.federation_id == id) {
|
||||||
|
reg.federations.push(JoinedFederation {
|
||||||
|
federation_id: id,
|
||||||
|
name: Some("Archipelago Federation".to_string()),
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
save_registry(data_dir, ®).await?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cold start only: nothing joined yet, so join the default federation once.
|
||||||
let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await {
|
let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@ -110,10 +110,39 @@ function recordError(source: string, err: unknown, info?: string) {
|
|||||||
|
|
||||||
app.config.errorHandler = (err, _instance, info) => recordError('Vue Error', err, info)
|
app.config.errorHandler = (err, _instance, info) => recordError('Vue Error', err, info)
|
||||||
|
|
||||||
|
// After a frontend deploy the browser's cached index.html still points at the
|
||||||
|
// OLD hashed chunks (e.g. AppSession-Cq93o4ao.js), which 404 — Vite then throws
|
||||||
|
// "Failed to fetch dynamically imported module". The fix is to reload once so
|
||||||
|
// the browser pulls the fresh index + chunk map. Guard with sessionStorage so a
|
||||||
|
// genuinely-broken chunk can't trap us in a reload loop.
|
||||||
|
function isStaleChunkError(err: unknown): boolean {
|
||||||
|
const msg = (err as { message?: string })?.message ?? String(err ?? '')
|
||||||
|
return /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|ChunkLoadError|dynamically imported module/i.test(msg)
|
||||||
|
}
|
||||||
|
function reloadOnceForStaleChunk(err: unknown): boolean {
|
||||||
|
if (!isStaleChunkError(err)) return false
|
||||||
|
try {
|
||||||
|
const KEY = 'archy-chunk-reload-at'
|
||||||
|
const last = Number(sessionStorage.getItem(KEY) || '0')
|
||||||
|
// Only reload if we haven't already tried in the last 10s (loop guard).
|
||||||
|
if (Date.now() - last < 10_000) return false
|
||||||
|
sessionStorage.setItem(KEY, String(Date.now()))
|
||||||
|
} catch { /* sessionStorage unavailable — reload anyway, once is better than stuck */ }
|
||||||
|
// Cache-bust the navigation so the stale index isn't served again.
|
||||||
|
location.reload()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Vue's errorHandler only catches errors raised synchronously inside Vue's
|
// Vue's errorHandler only catches errors raised synchronously inside Vue's
|
||||||
// lifecycle/reactivity. Async rejections and plain runtime errors (e.g. a JS
|
// lifecycle/reactivity. Async rejections and plain runtime errors (e.g. a JS
|
||||||
// API missing in an older WebView) slip past it, so catch those too.
|
// API missing in an older WebView) slip past it, so catch those too.
|
||||||
window.addEventListener('error', (ev) => recordError('window.error', ev.error ?? ev.message))
|
window.addEventListener('error', (ev) => {
|
||||||
window.addEventListener('unhandledrejection', (ev) => recordError('unhandledrejection', ev.reason))
|
if (reloadOnceForStaleChunk(ev.error ?? ev.message)) return
|
||||||
|
recordError('window.error', ev.error ?? ev.message)
|
||||||
|
})
|
||||||
|
window.addEventListener('unhandledrejection', (ev) => {
|
||||||
|
if (reloadOnceForStaleChunk(ev.reason)) return
|
||||||
|
recordError('unhandledrejection', ev.reason)
|
||||||
|
})
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@ -413,4 +413,21 @@ router.afterEach((to) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A route whose lazy-loaded component chunk 404s (stale index after a deploy)
|
||||||
|
// rejects through router.onError rather than window.unhandledrejection. Reload
|
||||||
|
// once so the browser fetches the fresh index + chunk map; the sessionStorage
|
||||||
|
// guard (10s) prevents a reload loop if the chunk is genuinely broken.
|
||||||
|
router.onError((err) => {
|
||||||
|
const msg = (err as { message?: string })?.message ?? String(err ?? '')
|
||||||
|
if (!/Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|ChunkLoadError|dynamically imported module/i.test(msg)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const KEY = 'archy-chunk-reload-at'
|
||||||
|
if (Date.now() - Number(sessionStorage.getItem(KEY) || '0') < 10_000) return
|
||||||
|
sessionStorage.setItem(KEY, String(Date.now()))
|
||||||
|
} catch { /* sessionStorage unavailable — reload anyway */ }
|
||||||
|
location.reload()
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@ -500,10 +500,13 @@ const walletTransactions = ref<WalletTransaction[]>([])
|
|||||||
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
|
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
|
||||||
|
|
||||||
async function loadWeb5Status() {
|
async function loadWeb5Status() {
|
||||||
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false; walletOnchain.value = 0; walletLightning.value = 0 }
|
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
|
||||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { walletEcash.value = 0 }
|
// there is a balance"). On failure keep the last-known value — the refs start
|
||||||
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { walletFedimint.value = 0 }
|
// at 0, so only the very first load before any success shows 0.
|
||||||
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { walletTransactions.value = [] }
|
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false }
|
||||||
|
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||||
|
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { /* keep last-known balance */ }
|
||||||
|
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { /* keep last-known transactions */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// System stats
|
// System stats
|
||||||
|
|||||||
@ -79,14 +79,14 @@
|
|||||||
<div
|
<div
|
||||||
v-if="isMediaMime(item.mime_type)"
|
v-if="isMediaMime(item.mime_type)"
|
||||||
class="relative aspect-video overflow-hidden cursor-pointer group"
|
class="relative aspect-video overflow-hidden cursor-pointer group"
|
||||||
@click="isPlayable(item.mime_type) ? playMedia(item) : undefined"
|
@click="isOwned(item) ? viewOwned(item) : (isPlayable(item.mime_type) ? playMedia(item) : undefined)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="item.mime_type.startsWith('image/') && previewUrls[item.id]"
|
v-if="item.mime_type.startsWith('image/') && previewUrls[item.id]"
|
||||||
:src="previewUrls[item.id]"
|
:src="previewUrls[item.id]"
|
||||||
:alt="item.filename"
|
:alt="item.filename"
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
:style="isPaidItem(item.access) ? 'filter: blur(16px); transform: scale(1.15);' : ''"
|
:style="(isPaidItem(item.access) && !isOwned(item)) ? 'filter: blur(16px); transform: scale(1.15);' : ''"
|
||||||
/>
|
/>
|
||||||
<video
|
<video
|
||||||
v-else-if="item.mime_type.startsWith('video/') && previewUrls[item.id]"
|
v-else-if="item.mime_type.startsWith('video/') && previewUrls[item.id]"
|
||||||
@ -120,15 +120,22 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Paid badge (top-right) -->
|
<!-- Owned badge (top-right) — purchased, unlocked for this buyer -->
|
||||||
<div v-if="isPaidItem(item.access)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-sm">
|
<div v-if="isPaidItem(item.access) && isOwned(item)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-green-500/20 backdrop-blur-sm">
|
||||||
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-medium text-green-400">Owned</span>
|
||||||
|
</div>
|
||||||
|
<!-- Paid badge (top-right) — not yet purchased -->
|
||||||
|
<div v-else-if="isPaidItem(item.access)" class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-sm">
|
||||||
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs font-medium text-orange-400">{{ getItemPrice(item.access) }} sats</span>
|
<span class="text-xs font-medium text-orange-400">{{ getItemPrice(item.access) }} sats</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Preview badge for paid playable items -->
|
<!-- Preview badge for paid playable items (only before purchase) -->
|
||||||
<div v-if="isPaidItem(item.access) && isPlayable(item.mime_type)" class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm">
|
<div v-if="isPaidItem(item.access) && !isOwned(item) && isPlayable(item.mime_type)" class="absolute bottom-2 left-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-sm">
|
||||||
<span class="text-xs text-white/70">10% preview</span>
|
<span class="text-xs text-white/70">10% preview</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -153,9 +160,27 @@
|
|||||||
>
|
>
|
||||||
{{ accessLabel(item.access) }}
|
{{ accessLabel(item.access) }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Play button for audio/video -->
|
<!-- View button for owned content (image/video/audio), from cache -->
|
||||||
<button
|
<button
|
||||||
v-if="isPlayable(item.mime_type)"
|
v-if="isOwned(item) && isMediaMime(item.mime_type)"
|
||||||
|
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
|
||||||
|
:disabled="playing === item.id"
|
||||||
|
@click="viewOwned(item)"
|
||||||
|
>
|
||||||
|
<template v-if="playing === item.id">
|
||||||
|
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||||
|
<span>Opening...</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7L8 5z" />
|
||||||
|
</svg>
|
||||||
|
<span>View</span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
<!-- Preview/Play button — pre-purchase only -->
|
||||||
|
<button
|
||||||
|
v-else-if="isPlayable(item.mime_type)"
|
||||||
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
|
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5"
|
||||||
:disabled="playing === item.id"
|
:disabled="playing === item.id"
|
||||||
@click="playMedia(item)"
|
@click="playMedia(item)"
|
||||||
@ -178,9 +203,9 @@
|
|||||||
>
|
>
|
||||||
<template v-if="downloading === item.id">
|
<template v-if="downloading === item.id">
|
||||||
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
<div class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
|
||||||
<span>{{ isPaidItem(item.access) ? 'Paying...' : 'Loading...' }}</span>
|
<span>{{ isPaidItem(item.access) && !isOwned(item) ? 'Paying...' : 'Loading...' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isPaidItem(item.access)">
|
<template v-else-if="isPaidItem(item.access) && !isOwned(item)">
|
||||||
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -190,7 +215,7 @@
|
|||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Download</span>
|
<span>{{ isOwned(item) ? 'Save' : 'Download' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -260,6 +285,67 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Owned-content viewer — full file from the local cache (no re-payment) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="viewerUrl && viewerItem"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4"
|
||||||
|
@click.self="closeViewer"
|
||||||
|
>
|
||||||
|
<div class="relative w-full max-w-4xl mx-4">
|
||||||
|
<button
|
||||||
|
class="absolute -top-10 right-0 text-white/60 hover:text-white transition-colors"
|
||||||
|
@click="closeViewer"
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="viewerMime.startsWith('image/')"
|
||||||
|
:src="viewerUrl"
|
||||||
|
:alt="viewerItem.filename"
|
||||||
|
class="w-full max-h-[80vh] object-contain rounded-xl bg-black"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="viewerMime.startsWith('video/')"
|
||||||
|
:src="viewerUrl"
|
||||||
|
class="w-full max-h-[80vh] rounded-xl bg-black"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<audio
|
||||||
|
v-else-if="viewerMime.startsWith('audio/')"
|
||||||
|
:src="viewerUrl"
|
||||||
|
class="w-full"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
/>
|
||||||
|
<div v-else class="glass-card p-8 rounded-xl text-center">
|
||||||
|
<p class="text-sm text-white/70">This file type can't be previewed. Use Save to download it.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-white truncate">{{ viewerItem.filename.split('/').pop() }}</p>
|
||||||
|
<p class="text-xs text-green-400">Owned · unlocked</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5 shrink-0"
|
||||||
|
@click="saveOwned(viewerItem)"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Payment method picker / Lightning invoice QR (#46) -->
|
<!-- Payment method picker / Lightning invoice QR (#46) -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
@ -385,7 +471,7 @@
|
|||||||
class="flex-1 px-4 py-2.5 rounded-xl text-sm font-semibold text-black bg-green-400 hover:bg-green-300 transition-colors disabled:opacity-50"
|
class="flex-1 px-4 py-2.5 rounded-xl text-sm font-semibold text-black bg-green-400 hover:bg-green-300 transition-colors disabled:opacity-50"
|
||||||
:disabled="!ecashPlan.chosen || downloading === payItem.id"
|
:disabled="!ecashPlan.chosen || downloading === payItem.id"
|
||||||
@click="confirmEcashPay"
|
@click="confirmEcashPay"
|
||||||
>{{ downloading === payItem.id ? 'Paying…' : `Confirm · pay with ${ecashPlan.chosen === 'fedimint' ? 'Fedimint' : 'Cashu'}` }}</button>
|
>{{ downloading === payItem.id ? 'Paying…' : 'Pay' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -537,6 +623,81 @@ const transportPill = computed(() => {
|
|||||||
const previewUrls = reactive<Record<string, string>>({})
|
const previewUrls = reactive<Record<string, string>>({})
|
||||||
const audioPlayer = useAudioPlayer()
|
const audioPlayer = useAudioPlayer()
|
||||||
|
|
||||||
|
// ─── Owned (purchased) content ───────────────────────────────────────────
|
||||||
|
// A paid item the buyer has purchased stays unlocked for them: the backend
|
||||||
|
// caches the bytes keyed by (seller onion, content_id). We load that set so the
|
||||||
|
// gallery renders owned items unblurred and opens them in-app from the local
|
||||||
|
// cache — no re-payment, and no reliance on a browser download (which silently
|
||||||
|
// fails on the mobile companion, the "paid but never unlocked" report).
|
||||||
|
const ownedKeys = ref<Set<string>>(new Set())
|
||||||
|
function ownKey(onion: string, id: string): string { return `${onion}::${id}` }
|
||||||
|
function isOwned(item: CatalogItem): boolean {
|
||||||
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
|
return !!onion && ownedKeys.value.has(ownKey(onion, item.id))
|
||||||
|
}
|
||||||
|
async function loadOwned() {
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ items: { onion: string; content_id: string }[] }>({ method: 'content.owned-list', timeout: 10000 })
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const it of res?.items ?? []) set.add(ownKey(it.onion, it.content_id))
|
||||||
|
ownedKeys.value = set
|
||||||
|
} catch { /* keep last-known owned set on a transient failure */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBlob(base64: string, mime: string): Blob {
|
||||||
|
return new Blob([Uint8Array.from(atob(base64), c => c.charCodeAt(0))], { type: mime })
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-app viewer for owned content (image / video / audio), served from cache.
|
||||||
|
const viewerItem = ref<CatalogItem | null>(null)
|
||||||
|
const viewerUrl = ref<string | null>(null)
|
||||||
|
const viewerMime = ref<string>('')
|
||||||
|
async function viewOwned(item: CatalogItem) {
|
||||||
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
|
if (!onion) return
|
||||||
|
playing.value = item.id
|
||||||
|
purchaseError.value = null
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||||
|
method: 'content.owned-get',
|
||||||
|
params: { onion, content_id: item.id },
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
|
||||||
|
const mime = res.mime_type || item.mime_type
|
||||||
|
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||||
|
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||||
|
viewerMime.value = mime
|
||||||
|
viewerItem.value = item
|
||||||
|
} catch (e: unknown) {
|
||||||
|
purchaseError.value = e instanceof Error ? e.message : 'Could not open your purchased file'
|
||||||
|
} finally {
|
||||||
|
playing.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeViewer() {
|
||||||
|
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||||
|
viewerUrl.value = null
|
||||||
|
viewerItem.value = null
|
||||||
|
viewerMime.value = ''
|
||||||
|
}
|
||||||
|
// Save the owned file to disk from cache (the optional "downloadable" path).
|
||||||
|
async function saveOwned(item: CatalogItem) {
|
||||||
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
|
if (!onion) return
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
|
||||||
|
method: 'content.owned-get',
|
||||||
|
params: { onion, content_id: item.id },
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
if (res?.data) triggerDownload(res.data, item)
|
||||||
|
else purchaseError.value = res?.error || 'Could not save your purchased file'
|
||||||
|
} catch (e: unknown) {
|
||||||
|
purchaseError.value = e instanceof Error ? e.message : 'Could not save your purchased file'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Payment picker (#46) ────────────────────────────────────────────────
|
// ─── Payment picker (#46) ────────────────────────────────────────────────
|
||||||
// When buying a paid item the user chooses how to pay: their local ecash
|
// When buying a paid item the user chooses how to pay: their local ecash
|
||||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||||
@ -603,7 +764,7 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// Continue with just the onion address
|
// Continue with just the onion address
|
||||||
}
|
}
|
||||||
await loadCatalog()
|
await Promise.all([loadCatalog(), loadOwned()])
|
||||||
} else {
|
} else {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -737,6 +898,12 @@ async function downloadFile(item: CatalogItem) {
|
|||||||
if (!onion) return
|
if (!onion) return
|
||||||
purchaseError.value = null
|
purchaseError.value = null
|
||||||
|
|
||||||
|
// Already purchased: save from the local cache, no payment.
|
||||||
|
if (isOwned(item)) {
|
||||||
|
await saveOwned(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const price = getItemPrice(item.access)
|
const price = getItemPrice(item.access)
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
// Let the buyer choose how to pay (local ecash vs external-wallet QR).
|
// Let the buyer choose how to pay (local ecash vs external-wallet QR).
|
||||||
@ -1020,14 +1187,25 @@ async function confirmEcashPay() {
|
|||||||
downloading.value = item.id
|
downloading.value = item.id
|
||||||
purchaseError.value = null
|
purchaseError.value = null
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string }>({
|
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
|
||||||
method: 'content.download-peer-paid',
|
method: 'content.download-peer-paid',
|
||||||
params: { onion, content_id: item.id, price_sats: price, method },
|
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename },
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
})
|
})
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
triggerDownload(result.data, item)
|
// The purchase is now cached + owned by this node (backend persisted it).
|
||||||
|
// Mark it owned and open the in-app viewer rather than firing a browser
|
||||||
|
// download — the latter silently fails on the mobile companion, which is
|
||||||
|
// why a paid item used to never "unlock". The item stays unlocked going
|
||||||
|
// forward; the viewer offers a Save button for an explicit download.
|
||||||
|
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
||||||
|
const mime = result.mime_type || item.mime_type
|
||||||
|
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
||||||
|
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
|
||||||
|
viewerMime.value = mime
|
||||||
|
viewerItem.value = item
|
||||||
closePayModal()
|
closePayModal()
|
||||||
|
void loadOwned()
|
||||||
} else if (result?.error) {
|
} else if (result?.error) {
|
||||||
// Keep the confirm screen open so the user can switch backend and retry.
|
// Keep the confirm screen open so the user can switch backend and retry.
|
||||||
purchaseError.value = result.error
|
purchaseError.value = result.error
|
||||||
|
|||||||
@ -329,7 +329,7 @@ async function loadEcashBalance() {
|
|||||||
const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
|
const res = await rpcClient.call<{ balance_sats: number; token_count: number }>({ method: 'wallet.ecash-balance' })
|
||||||
ecashBalance.value = res.balance_sats ?? 0
|
ecashBalance.value = res.balance_sats ?? 0
|
||||||
} catch {
|
} catch {
|
||||||
ecashBalance.value = 0
|
// Keep last-known balance on a transient failure rather than flashing 0.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user