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:
co-authored by
Claude Opus 4.8
parent
b0c9bd2a0c
commit
db7d424bff
+31
-2
@@ -110,10 +110,39 @@ function recordError(source: string, err: unknown, info?: string) {
|
||||
|
||||
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
|
||||
// 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.
|
||||
window.addEventListener('error', (ev) => recordError('window.error', ev.error ?? ev.message))
|
||||
window.addEventListener('unhandledrejection', (ev) => recordError('unhandledrejection', ev.reason))
|
||||
window.addEventListener('error', (ev) => {
|
||||
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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -500,10 +500,13 @@ const walletTransactions = ref<WalletTransaction[]>([])
|
||||
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
|
||||
|
||||
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 }
|
||||
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 }
|
||||
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 }
|
||||
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { walletTransactions.value = [] }
|
||||
// A transient RPC timeout must NOT flash the balance to 0 ("wallet says 0 when
|
||||
// there is a balance"). On failure keep the last-known value — the refs start
|
||||
// at 0, so only the very first load before any success shows 0.
|
||||
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
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
<div
|
||||
v-if="isMediaMime(item.mime_type)"
|
||||
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
|
||||
v-if="item.mime_type.startsWith('image/') && previewUrls[item.id]"
|
||||
:src="previewUrls[item.id]"
|
||||
:alt="item.filename"
|
||||
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
|
||||
v-else-if="item.mime_type.startsWith('video/') && previewUrls[item.id]"
|
||||
@@ -120,15 +120,22 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Paid badge (top-right) -->
|
||||
<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">
|
||||
<!-- Owned badge (top-right) — purchased, unlocked for this buyer -->
|
||||
<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">
|
||||
<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>
|
||||
<span class="text-xs font-medium text-orange-400">{{ getItemPrice(item.access) }} sats</span>
|
||||
</div>
|
||||
<!-- Preview badge for paid playable items -->
|
||||
<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">
|
||||
<!-- Preview badge for paid playable items (only before purchase) -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,9 +160,27 @@
|
||||
>
|
||||
{{ accessLabel(item.access) }}
|
||||
</span>
|
||||
<!-- Play button for audio/video -->
|
||||
<!-- View button for owned content (image/video/audio), from cache -->
|
||||
<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"
|
||||
:disabled="playing === item.id"
|
||||
@click="playMedia(item)"
|
||||
@@ -178,9 +203,9 @@
|
||||
>
|
||||
<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>
|
||||
<span>{{ isPaidItem(item.access) ? 'Paying...' : 'Loading...' }}</span>
|
||||
<span>{{ isPaidItem(item.access) && !isOwned(item) ? 'Paying...' : 'Loading...' }}</span>
|
||||
</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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
@@ -190,7 +215,7 @@
|
||||
<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>Download</span>
|
||||
<span>{{ isOwned(item) ? 'Save' : 'Download' }}</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
@@ -260,6 +285,67 @@
|
||||
</Transition>
|
||||
</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) -->
|
||||
<Teleport to="body">
|
||||
<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"
|
||||
:disabled="!ecashPlan.chosen || downloading === payItem.id"
|
||||
@click="confirmEcashPay"
|
||||
>{{ downloading === payItem.id ? 'Paying…' : `Confirm · pay with ${ecashPlan.chosen === 'fedimint' ? 'Fedimint' : 'Cashu'}` }}</button>
|
||||
>{{ downloading === payItem.id ? 'Paying…' : 'Pay' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -537,6 +623,81 @@ const transportPill = computed(() => {
|
||||
const previewUrls = reactive<Record<string, string>>({})
|
||||
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) ────────────────────────────────────────────────
|
||||
// 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
|
||||
@@ -603,7 +764,7 @@ onMounted(async () => {
|
||||
} catch {
|
||||
// Continue with just the onion address
|
||||
}
|
||||
await loadCatalog()
|
||||
await Promise.all([loadCatalog(), loadOwned()])
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -737,6 +898,12 @@ async function downloadFile(item: CatalogItem) {
|
||||
if (!onion) return
|
||||
purchaseError.value = null
|
||||
|
||||
// Already purchased: save from the local cache, no payment.
|
||||
if (isOwned(item)) {
|
||||
await saveOwned(item)
|
||||
return
|
||||
}
|
||||
|
||||
const price = getItemPrice(item.access)
|
||||
if (price > 0) {
|
||||
// Let the buyer choose how to pay (local ecash vs external-wallet QR).
|
||||
@@ -1020,14 +1187,25 @@ async function confirmEcashPay() {
|
||||
downloading.value = item.id
|
||||
purchaseError.value = null
|
||||
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',
|
||||
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,
|
||||
})
|
||||
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()
|
||||
void loadOwned()
|
||||
} else if (result?.error) {
|
||||
// Keep the confirm screen open so the user can switch backend and retry.
|
||||
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' })
|
||||
ecashBalance.value = res.balance_sats ?? 0
|
||||
} catch {
|
||||
ecashBalance.value = 0
|
||||
// Keep last-known balance on a transient failure rather than flashing 0.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user