feat(ui): dual-ecash wallet settings, buy-peer-files, seed backup, assorted fixes

- Tabbed Wallet Settings modal (Cashu + Fedimint) and dual-balance wallet card
- Buy a peer's paid file (ecash / node Lightning / on-chain / external QR)
- Recovery-phrase reveal + backup section; onboarding seed retry resilience
- NetBird HTTPS launch, remote-control two-finger scroll + external-open
- Shared BackButton, single-v version label, mesh Bitcoin header toggles

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-17 19:21:42 -04:00
co-authored by Claude Opus 4.8
parent bd567cd165
commit 87769cbfbf
46 changed files with 1527 additions and 283 deletions
+444 -64
View File
@@ -1,26 +1,9 @@
<template>
<div class="pb-6">
<!-- Header with back button -->
<!-- Header with back button shared component so peer files match local
files (CloudFolder) on both desktop and mobile. -->
<div class="shrink-0 mb-4">
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Cloud
</button>
<!-- Mobile Back Button -->
<Teleport to="body">
<button
@click="goBack"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Back to Cloud</span>
</button>
</Teleport>
<BackButton label="Back to Cloud" @click="goBack" />
<!-- Peer Header -->
<div class="flex items-center gap-4">
@@ -276,14 +259,144 @@
</div>
</Transition>
</Teleport>
<!-- Payment method picker / Lightning invoice QR (#46) -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="payItem"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
@click.self="closePayModal"
>
<div class="glass-card w-full max-w-md p-5 rounded-2xl relative">
<button
class="absolute top-3 right-3 text-white/50 hover:text-white transition-colors"
@click="closePayModal"
>
<svg class="w-5 h-5" 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>
<h3 class="text-base font-semibold text-white mb-1">Buy this file</h3>
<p class="text-xs text-white/50 mb-4 truncate">
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
</p>
<!-- Step 1: choose a payment method -->
<div v-if="payMode === 'choose'" class="space-y-3">
<button
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="downloading === payItem.id"
@click="payWithEcash"
>
<svg class="w-6 h-6 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
<span>
<span class="block text-base text-white">{{ downloading === payItem.id ? 'Paying…' : 'Pay from this nodes ecash wallet' }}</span>
<span class="block text-sm text-white/50">Instant, using your ecash balance</span>
</span>
</button>
<button
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying"
@click="payWithLightning"
>
<svg class="w-6 h-6 text-yellow-400 shrink-0" 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>
<span>
<span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span>
<span class="block text-sm text-white/50">Pays the sellers invoice from your nodes Lightning wallet</span>
</span>
</button>
<button
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying"
@click="payWithInvoice"
>
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
<span>
<span class="block text-base text-white">Pay from another wallet (QR)</span>
<span class="block text-sm text-white/50">Scan a Lightning invoice with any wallet</span>
</span>
</button>
<button
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying"
@click="payOnchain"
>
<svg class="w-6 h-6 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span>
<span class="block text-base text-white">{{ onchainPaying ? 'Sending…' : 'Pay on-chain from my node' }}</span>
<span class="block text-sm text-white/50">Sends Bitcoin on-chain from your nodes wallet (slower)</span>
</span>
</button>
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
</div>
<!-- Step 2: Lightning invoice -->
<div v-else class="text-center">
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
<span class="text-sm text-white/70">Requesting invoice from seller</span>
</div>
<div v-else-if="invoiceData">
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
</div>
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
Waiting for payment
</p>
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
</button>
</div>
</div>
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
<button
v-if="invoiceError"
class="glass-button px-4 py-2 rounded-lg text-sm mt-3"
@click="payMode = 'choose'"
>
Back
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import BackButton from '@/components/BackButton.vue'
const props = defineProps<{
peerId?: string
@@ -335,6 +448,23 @@ const transportPill = computed(() => {
const previewUrls = reactive<Record<string, string>>({})
const audioPlayer = useAudioPlayer()
// ─── 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
// they can pay from any external wallet by scanning a QR.
const payItem = ref<CatalogItem | null>(null)
const payMode = ref<'choose' | 'invoice'>('choose')
const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null)
const invoiceQr = ref('')
const invoiceWaiting = ref(false)
const invoiceError = ref('')
const invoiceCopied = ref(false)
const lnPaying = ref(false)
const lnError = ref('')
const onchainPaying = ref(false)
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
// Video player modal state
const videoPlayerItem = ref<CatalogItem | null>(null)
const videoPlayerUrl = ref<string | null>(null)
@@ -496,54 +626,30 @@ function getItemPrice(access: CatalogItem['access']): number {
async function downloadFile(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
downloading.value = item.id
purchaseError.value = null
const price = getItemPrice(item.access)
if (price > 0) {
// Let the buyer choose how to pay (local ecash vs external-wallet QR).
openPayModal(item)
return
}
// Free / peers-only download: stream straight from the Range-capable proxy
// (B3) to disk instead of pulling the whole file as a base64 blob over RPC.
// The old base64-over-RPC path buffered the entire file in node memory AND
// the browser, which failed outright for large files (#38). A tiny probe
// first surfaces the real server error (peer unreachable on mesh and Tor)
// instead of a generic failure (#30).
downloading.value = item.id
try {
const price = getItemPrice(item.access)
if (price > 0) {
// Check ecash balance first
try {
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
method: 'wallet.ecash-balance',
})
const balance = balanceRes?.balance_sats ?? 0
if (balance < price) {
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet first.`
return
}
} catch {
// Balance check failed — try the purchase anyway
}
// Paid download: mint ecash + download atomically
const result = await rpcClient.call<{ data?: string; error?: string; price_sats?: number }>({
method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price },
timeout: 120000,
})
if (result?.data) {
triggerDownload(result.data, item)
} else if (result?.error) {
purchaseError.value = `Payment failed: ${result.error}`
}
} else {
// Free / peers-only download: stream straight from the Range-capable
// proxy (B3) to disk instead of pulling the whole file as a base64 blob
// over RPC. The old base64-over-RPC path buffered the entire file in node
// memory AND the browser, which failed outright for large files (#38).
// A tiny probe first surfaces the real server error (peer unreachable on
// mesh and Tor) instead of a generic failure (#30).
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
const probe = await probePeerContent(streamUrl)
if (probe !== true) {
purchaseError.value = probe
return
}
streamDownload(streamUrl, item)
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
const probe = await probePeerContent(streamUrl)
if (probe !== true) {
purchaseError.value = probe
return
}
streamDownload(streamUrl, item)
} catch (e: unknown) {
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
} finally {
@@ -551,6 +657,280 @@ async function downloadFile(item: CatalogItem) {
}
}
function openPayModal(item: CatalogItem) {
payItem.value = item
payMode.value = 'choose'
invoiceData.value = null
invoiceQr.value = ''
invoiceWaiting.value = false
invoiceError.value = ''
invoiceCopied.value = false
lnPaying.value = false
lnError.value = ''
onchainPaying.value = false
}
function closePayModal() {
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
payItem.value = null
invoiceWaiting.value = false
onchainPaying.value = false
}
/**
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
* the payment and release the file (address is the gate token). Slower than LN
* because the seller waits for the tx to appear/confirm.
*/
async function payOnchain() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || onchainPaying.value) return
onchainPaying.value = true
lnError.value = ''
try {
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
method: 'content.request-onchain',
params: { onion, content_id: item.id },
timeout: 60000,
})
if (!req?.address || !req?.amount_sats) {
lnError.value = req?.error || 'The seller could not provide an on-chain address.'
onchainPaying.value = false
return
}
const addr = req.address
const send = await rpcClient.call<{ txid?: string; error?: string }>({
method: 'lnd.sendcoins',
params: { addr, amount: req.amount_sats },
timeout: 60000,
})
if (!send?.txid) {
lnError.value = send?.error || 'On-chain send failed (insufficient on-chain balance?).'
onchainPaying.value = false
return
}
// Broadcast — now wait for the seller to see it and release.
pollOnchain(addr)
} catch (e: unknown) {
lnError.value = e instanceof Error ? e.message : 'Could not pay on-chain'
onchainPaying.value = false
}
}
async function pollOnchain(address: string) {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) { onchainPaying.value = false; return }
try {
const res = await rpcClient.call<{ paid?: boolean }>({
method: 'content.onchain-status',
params: { onion, content_id: item.id, address },
timeout: 30000,
})
if (res?.paid) {
const dl = await rpcClient.call<{ data?: string; error?: string }>({
method: 'content.download-peer-onchain',
params: { onion, content_id: item.id, address },
timeout: 120000,
})
onchainPaying.value = false
if (dl?.data) {
triggerDownload(dl.data, item)
closePayModal()
} else {
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
return
}
} catch {
// transient — keep polling
}
if (payItem.value && onchainPaying.value) {
onchainPollTimer = setTimeout(() => pollOnchain(address), 5000)
}
}
/** Pay the open item from the local ecash wallet (mint token + download). */
async function payWithEcash() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
const price = getItemPrice(item.access)
downloading.value = item.id
purchaseError.value = null
try {
// Check ecash balance first
try {
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
method: 'wallet.ecash-balance',
})
const balance = balanceRes?.balance_sats ?? 0
if (balance < price) {
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet, or pay from another wallet via QR.`
closePayModal()
return
}
} catch {
// Balance check failed — try the purchase anyway
}
const result = await rpcClient.call<{ data?: string; error?: string }>({
method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price },
timeout: 120000,
})
if (result?.data) {
triggerDownload(result.data, item)
closePayModal()
} else if (result?.error) {
purchaseError.value = `Payment failed: ${result.error}`
closePayModal()
}
} catch (e: unknown) {
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
closePayModal()
} finally {
downloading.value = null
}
}
/** Ask the seller for a Lightning invoice, render a QR, and poll for payment. */
async function payWithInvoice() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
payMode.value = 'invoice'
invoiceError.value = ''
invoiceWaiting.value = true
try {
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
method: 'content.request-invoice',
params: { onion, content_id: item.id },
timeout: 60000,
})
if (!res?.bolt11 || !res?.payment_hash) {
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
invoiceWaiting.value = false
return
}
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
try {
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
} catch {
invoiceQr.value = '' // fall back to showing the raw invoice string
}
scheduleInvoicePoll()
} catch (e: unknown) {
invoiceError.value = e instanceof Error ? e.message : 'Could not request an invoice'
invoiceWaiting.value = false
}
}
/**
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
* release the file. No QR/polling: lnd.payinvoice only returns once the payment
* settles, so the payment_hash is immediately valid as the download gate token.
*/
async function payWithLightning() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || lnPaying.value) return
lnPaying.value = true
lnError.value = ''
try {
// 1. Ask the seller to mint a bolt11 (also records the pending entitlement).
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
method: 'content.request-invoice',
params: { onion, content_id: item.id },
timeout: 60000,
})
if (!inv?.bolt11 || !inv?.payment_hash) {
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
return
}
// 2. Pay it from our own node. Returns only after settlement.
const pay = await rpcClient.call<{ payment_hash?: string; payment_error?: string }>({
method: 'lnd.payinvoice',
params: { payment_request: inv.bolt11 },
timeout: 120000,
})
if (pay?.payment_error) {
lnError.value = `Payment failed: ${pay.payment_error}`
return
}
// 3. Settled — pull the file using the payment hash as the gate token.
const dl = await rpcClient.call<{ data?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 120000,
})
if (dl?.data) {
triggerDownload(dl.data, item)
closePayModal()
} else {
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
} catch (e: unknown) {
lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node'
} finally {
lnPaying.value = false
}
}
function scheduleInvoicePoll() {
if (invoicePollTimer) clearTimeout(invoicePollTimer)
invoicePollTimer = setTimeout(pollInvoice, 3000)
}
async function pollInvoice() {
const item = payItem.value
const inv = invoiceData.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !inv || !onion) return
try {
const res = await rpcClient.call<{ paid?: boolean }>({
method: 'content.invoice-status',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 30000,
})
if (res?.paid) {
// Settled — pull the file using the payment hash as the gate token.
invoiceWaiting.value = false
const dl = await rpcClient.call<{ data?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 120000,
})
if (dl?.data) {
triggerDownload(dl.data, item)
closePayModal()
} else {
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
return
}
} catch {
// Transient — keep polling.
}
if (payItem.value && invoiceData.value) scheduleInvoicePoll()
}
async function copyInvoice() {
if (!invoiceData.value) return
try {
await navigator.clipboard.writeText(invoiceData.value.bolt11)
invoiceCopied.value = true
setTimeout(() => { invoiceCopied.value = false }, 2000)
} catch { /* clipboard blocked */ }
}
/** Play audio/video inline. For free items, downloads full file; for paid, uses the preview. */
async function playMedia(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion