Merge remote-tracking branch 'origin/main' into archy-hwconfig

This commit is contained in:
2026-07-23 21:40:06 +00:00
89 changed files with 3564 additions and 359 deletions
+43 -40
View File
@@ -89,19 +89,43 @@ class FileBrowserClient {
return h
}
/** Don't hammer app.filebrowser-token after a failed login — one attempt
* per cooldown window, so a broken/missing filebrowser doesn't turn every
* poll of the Files card into a fresh login + 401 pair (console spam). */
private _lastLoginFailure = 0
private static readonly LOGIN_RETRY_COOLDOWN_MS = 60_000
/** Ensure we're authenticated before making a request. Auto-logins if needed. */
private async ensureAuth(): Promise<void> {
if (this._authenticated && this.getAuthCookie()) return
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
const ok = await this.login()
if (!ok) throw new Error('FileBrowser authentication failed — please open Cloud to log in')
if (!ok) {
this._lastLoginFailure = Date.now()
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
}
/** fetch() with auth headers + ONE transparent re-login on 401. The JWT
* from app.filebrowser-token is short-lived; before this, an expired
* cookie kept `_authenticated` true and every Files-card poll 401'd
* forever (the console-spam bug, 2026-07-22). */
private async authedFetch(url: string, init?: RequestInit): Promise<Response> {
await this.ensureAuth()
let res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
if (res.status === 401) {
this._authenticated = false
await this.ensureAuth()
res = await fetch(url, { ...init, headers: { ...(init?.headers as Record<string, string> | undefined), ...this.headers() } })
}
return res
}
async listDirectory(path: string): Promise<FileBrowserItem[]> {
await this.ensureAuth()
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
headers: this.headers(),
})
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
// When File Browser isn't installed, nginx falls through to the SPA and
// returns index.html (200, text/html); when it's down it returns 502.
@@ -133,11 +157,8 @@ class FileBrowserClient {
* For large files (video/audio), prefer streamUrl() instead.
*/
async fetchBlobUrl(path: string): Promise<string> {
await this.ensureAuth()
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
headers: this.headers(),
})
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob()
return URL.createObjectURL(blob)
@@ -171,17 +192,12 @@ class FileBrowserClient {
}
async upload(dirPath: string, file: File): Promise<void> {
await this.ensureAuth()
const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)
const res = await fetch(
const res = await this.authedFetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
{
method: 'POST',
headers: this.headers(),
body: file,
},
{ method: 'POST', body: file },
)
if (!res.ok) {
const text = await res.text().catch(() => '')
@@ -190,35 +206,31 @@ class FileBrowserClient {
}
async createFolder(parentPath: string, name: string): Promise<void> {
await this.ensureAuth()
const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
method: 'POST',
headers: this.headers(),
})
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
}
async deleteItem(path: string): Promise<void> {
await this.ensureAuth()
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'DELETE',
headers: this.headers(),
})
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
}
async getUsage(): Promise<{ totalSize: number; folderCount: number; fileCount: number }> {
if (!this._authenticated || !this.getAuthCookie()) {
const ok = await this.login()
if (!ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
let res: Response
try {
res = await this.authedFetch(`${this.baseUrl}/api/resources/`)
} catch {
// Not installed / login cooling down — the Files card shows zeros.
return { totalSize: 0, folderCount: 0, fileCount: 0 }
}
const res = await fetch(`${this.baseUrl}/api/resources/`, {
headers: this.headers(),
})
if (!res.ok) return { totalSize: 0, folderCount: 0, fileCount: 0 }
const data: FileBrowserListResponse = await res.json()
const items = data.items || []
@@ -242,17 +254,11 @@ class FileBrowserClient {
}
async readFileAsText(path: string, maxBytes = 102400): Promise<{ content: string; truncated: boolean; size: number }> {
if (!this._authenticated || !this.getAuthCookie()) {
const ok = await this.login()
if (!ok) throw new Error('FileBrowser authentication failed')
}
if (!this.isTextFile(path)) {
throw new Error(`Cannot read binary file: ${path}`)
}
const safePath = sanitizePath(path)
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
headers: this.headers(),
})
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
const blob = await res.blob()
const size = blob.size
@@ -266,12 +272,9 @@ class FileBrowserClient {
const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'PATCH',
headers: {
...this.headers(),
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
})
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
+2 -1
View File
@@ -447,7 +447,8 @@ class RPCClient {
return this.call({
method: 'node-check-peer',
params: { onion },
timeout: 35000,
// Backend health-dial timeout is 12s — 15s here covers RPC overhead.
timeout: 15000,
})
}
+2 -2
View File
@@ -104,11 +104,11 @@ function handleClickOutside(e: MouseEvent) {
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('pointerdown', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('pointerdown', handleClickOutside)
})
</script>
+24 -4
View File
@@ -8,10 +8,16 @@
@click.self="close"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<!-- Column layout (2026-07-22 modal contract): title row and the
optional #header slot (tabs) stay pinned at the top, the #footer
slot (action buttons) stays pinned at the bottom, and ONLY the
default slot scrolls. Callers that previously made the whole
card scroll via contentClass keep working the inner region
simply never lets the card overflow. -->
<div
ref="modalRef"
class="glass-card p-6 w-full relative z-10"
:class="[maxWidth, contentClass]"
class="glass-card p-6 w-full relative z-10 flex flex-col"
:class="[maxWidth, contentClass, defaultMaxH]"
role="dialog"
aria-modal="true"
@click.stop
@@ -28,8 +34,15 @@
</svg>
</button>
</div>
<slot />
<slot name="footer" />
<div v-if="$slots.header" class="shrink-0">
<slot name="header" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto">
<slot />
</div>
<div v-if="$slots.footer" class="shrink-0 pt-4">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
@@ -60,6 +73,13 @@ const emit = defineEmits<{
const modalRef = ref<HTMLElement | null>(null)
const zClass = computed(() => props.zIndex)
// The pinned-footer layout needs a height bound or tall content pushes the
// footer off-screen anyway. Callers that set their own max-h (e.g. the
// Transactions modal's visual-viewport calc on mobile) keep authority —
// adding a second max-h class would make the CSS winner order-dependent.
const defaultMaxH = computed(() =>
props.contentClass.includes('max-h-') ? '' : 'max-h-[90vh]'
)
function close() {
emit('close')
@@ -252,21 +252,39 @@ watch(visible, async (isVisible) => {
})
}, { immediate: true, flush: 'post' })
/** Tailscale/CGNAT range 100.64.0.0/10 — reachable only inside the tailnet. */
function isTailnetIp(host: string): boolean {
const m = host.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127
}
/**
* The server URL the companion app should connect to. The demo advertises its
* public https origin; a real node advertises whatever address the browser is
* already using — except on the kiosk, where that is localhost and useless to
* a phone, so fall back to the node's mDNS .local name.
* public https origin; a real node advertises the browser's own origin ONLY
* when a phone could plausibly reach it too. Two origins that a phone on the
* LAN can never dial get substituted with the node's real LAN address:
* - localhost/127.0.0.1 (the kiosk browses itself)
* - a tailnet 100.x address (operator browsing over Tailscale — a scanned
* QR carried one of these on 2026-07-22 and the companion sat there
* dialing an IP the phone had no route to)
*/
async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL
const { hostname, origin } = window.location
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
const phoneUnreachable =
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
if (!phoneUnreachable) return origin
try {
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
method: 'system.get-hostname',
})
// Prefer the LAN IP (phones resolve it without mDNS support — Android
// notoriously lacks .local); fall back to the mDNS name.
if (res?.lan_ip) return `http://${res.lan_ip}`
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
} catch {
// RPC unavailable — fall through to the (localhost) origin
// RPC unavailable — fall through to the (unreachable) origin; the app
// still lets the user edit the address by hand.
}
return origin
}
@@ -330,9 +348,24 @@ async function buildPairingUrl(): Promise<string> {
if (info.udp_port) params.set('fudp', String(info.udp_port))
if (info.tcp_port) params.set('ftcp', String(info.tcp_port))
// Rendezvous anchors (compact: npub@addr/transport, comma-joined).
// Cap keeps the QR at a camera-friendly density; the node lists its
// most reachable anchors first.
const anchors = (info.anchors || []).slice(0, 4)
// FIRST entry is the paired node ITSELF: the phone peers with it by
// npub (the fhost addr is only a dial hint), so LAN contact is direct
// p2p over FIPS and survives DHCP renumbering; the node's public
// anchors follow for reaching it away from home. Cap keeps the QR at a
// camera-friendly density; the node lists its most reachable anchors
// first.
const selfAnchor = info.tcp_port
? [{ npub: info.npub, addr: `${params.get('fhost')}:${info.tcp_port}`, transport: 'tcp' }]
: []
// HARD CAP at 2 anchors: every extra npub adds ~100 URL-encoded chars
// and pushed the QR past what phone cameras decode off a screen
// (3 anchors ≈ 600 chars ≈ QR v23 — observed unscannable 2026-07-23).
// Self + one public rendezvous is all the phone needs; prefer the
// Archipelago-operated vps2 anchor as the public one.
const others = (info.anchors || []).filter((a) => a.npub !== info.npub)
const publicAnchor =
others.find((a) => a.addr.startsWith('146.59.87.168')) ?? others[0]
const anchors = [...selfAnchor, ...(publicAnchor ? [publicAnchor] : [])].slice(0, 2)
if (anchors.length) {
params.set(
'fanchors',
@@ -357,10 +390,13 @@ async function showPairScreen() {
pairingUrl.value = await buildPairingUrl()
// Large source + a real quiet zone; this QR is scanned by the companion
// app's camera, so give it every advantage (see download QR note above).
// EC level L: the payload is long (token + npub + anchors) and screen
// scans don't suffer the damage EC-M protects against — L drops the
// module count a full version tier, which is what makes it scannable.
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
width: 512,
width: 768,
margin: 3,
errorCorrectionLevel: 'M',
errorCorrectionLevel: 'L',
color: {
dark: '#111111',
light: '#ffffff',
@@ -1,8 +1,12 @@
<template>
<!-- z-3600: this consent prompt can be triggered from INSIDE another
modal (e.g. Wallet Settings Channels funding tx), so it must sit
above the standard modal layer (3000) but below the app overlay (4000). -->
<BaseModal
:show="!!explorer.pendingTx.value"
title="Open on an external explorer?"
max-width="max-w-md"
z-index="z-[3600]"
@close="explorer.cancelPending()"
>
<!-- Same visual language as the uninstall keep-your-data warning: amber
+2 -2
View File
@@ -131,10 +131,10 @@ async function loadIdentities() {
onMounted(() => {
loadIdentities()
document.addEventListener('click', onClickOutside)
document.addEventListener('pointerdown', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
document.removeEventListener('pointerdown', onClickOutside)
})
</script>
@@ -301,7 +301,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useTxExplorer } from '@/composables/useTxExplorer'
defineProps<{ compact?: boolean }>()
@@ -390,10 +390,14 @@ function fundingTxid(ch: Channel): string {
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
}
const txExplorer = useTxExplorer()
function openInMempool(txid: string) {
if (!txid) return
// Overlay the explorer above the current page — never navigate away.
useAppLauncherStore().openSession('mempool', { path: `/tx/${txid}` })
// Same routing as every other tx link (missed in the first pass —
// 2026-07-22): local Mempool app when it's running, otherwise the saved
// external explorer, with the first-time consent modal setting it up and
// then opening the tx.
txExplorer.openTx(txid)
}
function capacityPercent(amount: number, capacity: number): number {
+219 -13
View File
@@ -1,14 +1,63 @@
<template>
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
<template v-if="confirming">
<div class="mb-3 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/50">Method</span>
<span class="text-sm font-medium" :class="methodColor">{{ methodLabel }}</span>
</div>
<div v-if="dest.trim()" class="mb-1">
<p class="text-xs text-white/50 mb-1">{{ effectiveMethod === 'lightning' ? 'Invoice' : effectiveMethod === 'ark' ? 'Destination' : 'Address' }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ destDisplay }}</p>
</div>
<p v-else class="text-xs text-white/60">Creates a {{ methodLabel }} token you can share sats leave your balance when it's redeemed.</p>
</div>
<!-- Live balance impact -->
<div class="mb-3 p-3 bg-white/5 rounded-lg space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">{{ methodLabel }} balance</span>
<span class="text-sm font-medium text-white/80">{{ confirmBalance === null ? '…' : confirmBalance.toLocaleString() + ' sats' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50 flex items-center gap-2">
Amount
<span v-if="invoiceAmountSats !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
</span>
<span class="text-sm font-medium text-white/80">{{ confirmAmount.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
{{ confirmBalance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ methodLabel }} balance for this amount.</p>
<p v-else-if="isSweep" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
<p v-else-if="effectiveMethod === 'onchain'" class="text-xs text-white/50 mb-3">Network fees are deducted on top of the amount.</p>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<button @click="confirming = false" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
<button @click="send" :disabled="processing || insufficient || (confirmAmount <= 0 && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('common.sending') : 'Confirm & Send' }}
</button>
</div>
</template>
<template v-else>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['auto', 'lightning', 'onchain', 'ecash', 'ark'] as const)"
v-for="m in (['lightning', 'onchain', 'ecash', 'fedimint', 'ark'] as const)"
:key="m"
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? t('sendBitcoin.ecash') : m === 'ark' ? 'Ark' : t('sendBitcoin.auto') }}</button>
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? 'Cashu' : m === 'fedimint' ? 'Fedi' : 'Ark' }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
@@ -18,6 +67,7 @@
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
<button
v-if="sendMethod === 'onchain'"
@click="toggleSendAll"
@@ -33,24 +83,44 @@
v-model.number="amount"
type="number"
min="1"
:placeholder="sendAll ? '' : '1000'"
:disabled="sendAll"
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
:disabled="sendAll || pastedInvoiceAmount !== null"
class="w-full input-glass disabled:opacity-50"
/>
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
</p>
<p v-else-if="pastedInvoiceAmount !== null" class="text-xs text-white/50 mt-1">
This invoice fixes the amount nothing to type, just review and send.
</p>
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
Zero-amount invoice enter how many sats to pay.
</p>
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
</label>
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
</label>
<button
v-if="canReadClipboard"
@click="pasteFromClipboard"
class="text-xs px-2 py-0.5 rounded border bg-white/5 border-white/15 text-white/60 hover:text-white/90 transition-colors"
>
{{ effectiveMethod === 'lightning' ? 'Paste invoice' : 'Paste' }}
</button>
</div>
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1… / lnbc… / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<div v-if="ecashToken && effectiveMethod === 'ecash'" class="mb-3 p-2 bg-white/5 rounded-lg">
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<!-- QR so the recipient can scan the token straight off this screen
(animated multi-frame not needed: qrcode handles these sizes). -->
<div class="flex justify-center my-2">
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
@@ -75,15 +145,16 @@
</svg>
Scan
</button>
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('common.sending') : t('common.send') }}
<button @click="review" :disabled="processing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ t('common.send') }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
@@ -93,7 +164,9 @@ const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'ark'>('auto')
// 'auto' remains in the type for the effectiveMethod logic but is no longer
// offered as a tab (hidden per operator request 2026-07-22).
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
const amount = ref<number>(0)
const dest = ref('')
const processing = ref(false)
@@ -120,6 +193,35 @@ function toggleSendAll() {
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats make the user type the new amount.
else if (prev !== null) amount.value = 0
})
// Clipboard read needs a secure context (or the companion bridge); hide the
// button where it can't work the textarea still accepts a manual paste.
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
async function pasteFromClipboard() {
try {
const text = (await navigator.clipboard.readText()).trim()
if (text) dest.value = text
} catch {
// Permission denied user can long-press/Ctrl+V into the field instead.
}
}
const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0
@@ -129,12 +231,95 @@ const effectiveMethod = computed(() => {
return 'lightning'
})
// --- Second-step confirmation (parity with the scan flow): review shows the
// --- balance reduction before anything is sent or any token is minted.
const confirming = ref(false)
const confirmBalance = ref<number | null>(null)
const invoiceAmountSats = ref<number | null>(null)
const methodLabel = computed(() => ({
auto: 'Auto', lightning: 'Lightning', onchain: 'On-chain',
ecash: 'Cashu', fedimint: 'Fedimint', ark: 'Ark',
}[effectiveMethod.value]))
const methodColor = computed(() => ({
auto: 'text-white/80', lightning: 'text-yellow-400', onchain: 'text-orange-500',
ecash: 'text-purple-400', fedimint: 'text-blue-400', ark: 'text-teal-400',
}[effectiveMethod.value]))
const destDisplay = computed(() => {
const d = dest.value.trim()
return d.length > 64 ? `${d.slice(0, 38)}${d.slice(-18)}` : d
})
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
function parseBolt11AmountSats(invoice: string): number | null {
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
if (!m || !m[1]) return null
const value = Number(m[1])
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
return Math.round(value * mult * 1e8)
}
// An invoice-fixed amount always wins over the typed one; a sweep is priced
// at the whole balance.
const confirmAmount = computed(() => {
if (isSweep.value) return confirmBalance.value ?? 0
if (effectiveMethod.value === 'lightning' && invoiceAmountSats.value !== null) return invoiceAmountSats.value
return amount.value > 0 ? Math.floor(amount.value) : 0
})
const balanceAfter = computed(() => (confirmBalance.value ?? 0) - confirmAmount.value)
const insufficient = computed(() =>
confirmBalance.value !== null && confirmAmount.value > confirmBalance.value && !isSweep.value
)
async function loadConfirmBalance() {
confirmBalance.value = null
try {
if (effectiveMethod.value === 'ecash') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
confirmBalance.value = res.balance_sats ?? 0
} else if (effectiveMethod.value === 'fedimint') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
confirmBalance.value = res.balance_sats ?? 0
} else {
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
confirmBalance.value = effectiveMethod.value === 'onchain'
? (res.balance_sats ?? 0)
: (res.channel_balance_sats ?? 0)
}
} catch {
confirmBalance.value = null // balance preview is best-effort; send still guarded server-side
}
}
function review() {
error.value = ''
const method = effectiveMethod.value
const d = dest.value.trim()
if (method === 'lightning') {
if (!d) { error.value = t('web5.pasteInvoice'); return }
invoiceAmountSats.value = parseBolt11AmountSats(d)
} else {
invoiceAmountSats.value = null
if (method === 'ark' && !d) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
if (method === 'onchain' && !d) { error.value = t('web5.enterBitcoinAddress'); return }
}
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
error.value = t('sendBitcoin.amountSats'); return
}
void loadConfirmBalance()
confirming.value = true
}
function close() {
error.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
ecashToken.value = ''
confirming.value = false
emit('close')
}
@@ -142,9 +327,21 @@ function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
watch(ecashToken, async (token) => {
if (!token) return
await nextTick()
if (!tokenQrCanvas.value) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(tokenQrCanvas.value, token, { width: 220, margin: 1 })
} catch { /* QR is a convenience — the copyable text is authoritative */ }
})
async function send() {
if (processing.value) return
if (!amount.value && !isSweep.value) return
// Zero typed amount is fine when the invoice fixes the amount or we sweep.
if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return
processing.value = true
error.value = ''
ecashToken.value = ''
@@ -169,6 +366,13 @@ async function send() {
params: { amount_sats: amount.value },
})
ecashToken.value = res.token
} else if (method === 'fedimint') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.fedimint-send',
params: { amount_sats: amount.value },
timeout: 60000,
})
ecashToken.value = res.token
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
const res = await rpcClient.call<{ payment_hash: string }>({
@@ -187,6 +391,8 @@ async function send() {
resultTxid.value = res.txid
}
emit('sent')
// Back to the form pane so the success/token panes are visible.
confirming.value = false
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
} finally {
+113 -13
View File
@@ -43,7 +43,24 @@
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
<!-- ============ SCAN PANE ============ -->
<div v-if="pane === 'scan'" key="scan">
<div class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
<!-- Chooser interstitial every open: the user picks live camera
or a photo upload; neither starts until chosen. -->
<div v-if="scanChoice === 'unset'" class="w-full rounded-xl bg-black/30 border border-white/10 mb-4 p-6 flex flex-col items-center gap-3">
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
the native bridge still provides a live camera -->
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Scan with camera
</button>
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Upload / take a photo of the QR
</button>
</div>
<div v-else class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
source-less <video> flashes a native play glyph in Android WebViews -->
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
@@ -73,17 +90,20 @@
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
Take photo of QR
</button>
<input
ref="photoInput"
type="file"
accept="image/*"
capture="environment"
class="hidden"
@change="onPhotoPicked"
/>
</div>
</div>
<!-- Single always-mounted picker input, shared by the chooser and
the in-camera fallback button -->
<input
ref="photoInput"
type="file"
accept="image/*"
capture="environment"
class="hidden"
@change="onPhotoPicked"
/>
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
@@ -253,6 +273,23 @@ type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
type Pane = 'scan' | 'amount' | 'success'
// JS bridge the Android companion injects: when present, live scanning is
// delegated to a native camera modal (styled like this one) the WebView's
// getUserMedia preview lags, and over plain http it doesn't exist at all.
// Decodes come back through the window.__archyQr* callbacks; status lines
// (animated-QR progress, errors) mirror out to the native modal's strip.
interface ArchipelagoQrBridge {
open(): void
setStatus(message: string, isError: boolean): void
close(): void
}
interface NativeWindow extends Window {
ArchipelagoQr?: ArchipelagoQrBridge
__archyQrResult?: (text: string) => void
__archyQrCancelled?: () => void
}
const nativeWin = window as NativeWindow
const PRESETS = [21, 2100, 21000, 100000]
const props = defineProps<{ show: boolean }>()
@@ -282,7 +319,9 @@ function goBack() {
if (pane.value === 'amount') {
error.value = ''
goTo('scan', 'back')
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
// Only relight the camera when that's what the user chose; otherwise
// they land back on the scan/upload chooser.
nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() })
} else if (pane.value === 'success') {
close()
}
@@ -295,6 +334,31 @@ const isScanning = ref(false)
// open) the fallback buttons stay hidden until it resolves, so they don't
// flash for a second on every open.
const autoStarting = ref(false)
// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23):
// nothing starts until the user picks, and Back from a later pane returns to
// the live camera only if that's what they chose.
const scanChoice = ref<'unset' | 'camera'>('unset')
function chooseCamera() {
if (startNativeScan()) return
scanChoice.value = 'camera'
void nextTick(() => startScanning())
}
// --- Native scanner (companion app) ---
const hasNativeQr = !!nativeWin.ArchipelagoQr
const nativeScanActive = ref(false)
function startNativeScan(): boolean {
const bridge = nativeWin.ArchipelagoQr
if (!bridge) return false
nativeWin.__archyQrResult = (text: string) => handleScanned(text)
nativeWin.__archyQrCancelled = () => { nativeScanActive.value = false }
nativeScanActive.value = true
bridge.open()
return true
}
const scanStatus = ref('')
const scanStatusIsError = ref(false)
const cameraError = ref(false)
@@ -344,8 +408,20 @@ function stopScanning() {
qrScanner.value?.destroy()
qrScanner.value = null
isScanning.value = false
if (nativeScanActive.value) {
nativeScanActive.value = false
nativeWin.ArchipelagoQr?.close()
}
}
// Mirror status lines onto the native modal while it's up it covers the
// page, so this strip is the only feedback the user can see.
watch([scanStatus, scanStatusIsError], () => {
if (nativeScanActive.value) {
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
}
})
function submitPaste() {
const text = pasteInput.value.trim()
if (!text) return
@@ -363,9 +439,10 @@ async function onPhotoPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
scanStatusIsError.value = false
scanStatus.value = 'Reading photo…'
try {
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
handleScanned(result.data)
handleScanned(await decodePhotoRobust(file))
} catch {
scanStatusIsError.value = true
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
@@ -374,6 +451,28 @@ async function onPhotoPicked(e: Event) {
}
}
/** Decode a QR photo with every engine we have. On the companion app the
* photo path IS the scan path (plain-http LAN = no secure context = no
* live camera), and Lightning invoices make DENSE codes that the wasm
* engine's single pass often misses (reported 2026-07-22: "camera not
* picking up the invoice"). Android WebView's native BarcodeDetector is
* far stronger on dense codes, so try it first; fall back to qr-scanner. */
async function decodePhotoRobust(file: File): Promise<string> {
try {
const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise<Array<{ rawValue: string }>> } }).BarcodeDetector
if (Detector) {
const bmp = await createImageBitmap(file)
const codes = await new Detector({ formats: ['qr_code'] }).detect(bmp)
const hit = codes.find(c => c.rawValue)
if (hit) return hit.rawValue
}
} catch {
// Native detector unavailable/failed wasm engine below.
}
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
return result.data
}
// --- Detection (ported from k484's scanner) ---
const rail = ref<Rail>('lightning')
const action = ref<Action>('pay-invoice')
@@ -686,7 +785,8 @@ function close() {
watch(() => props.show, (open) => {
if (open) {
resetAll()
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
// No auto-start: the chooser interstitial owns the first move every time.
scanChoice.value = 'unset'
} else {
stopScanning()
}
+39 -45
View File
@@ -1,15 +1,18 @@
<template>
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Protocol tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<BaseModal :show="show" title="Wallet Settings" max-width="max-w-2xl" content-class="max-h-[90vh]" @close="close">
<!-- Protocol tabs pinned via the header slot; only the pane below
scrolls (2026-07-22 modal contract). -->
<template #header>
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
</template>
<!-- ===================== Lightning Channels ===================== -->
<div v-show="activeTab === 'channels'">
@@ -17,9 +20,6 @@
Lightning channels on this node. Open a channel to a peer to send and receive Lightning payments.
</p>
<LightningChannelsPanel v-if="show" compact />
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
</div>
</div>
<!-- ===================== Cashu Mints ===================== -->
@@ -75,16 +75,6 @@
<div v-if="mintError" class="mb-3 alert-error">{{ mintError }}</div>
<div v-if="mintsSavedOk" class="mb-3 text-xs text-green-400">Accepted mints saved.</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="saveMints"
:disabled="savingMints || mints.length === 0"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ savingMints ? 'Saving…' : 'Save' }}
</button>
</div>
</template>
</div>
@@ -133,16 +123,6 @@
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</div>
<div v-if="fedJoinedOk" class="mb-3 text-xs text-green-400">Federation joined.</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="joinFederation"
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ joiningFed ? 'Joining…' : 'Join federation' }}
</button>
</div>
<p v-if="!fedimintBackendReady" class="text-[11px] text-white/40 text-center mt-3">
Joining federations lands with the Fedimint client backend.
@@ -179,9 +159,6 @@
Don't warn me each time before opening the external explorer
</label>
<div class="flex gap-3 mt-6">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
</div>
</div>
<!-- ===================== Ark ===================== -->
@@ -269,16 +246,33 @@
<div v-if="arkError" class="mb-3 alert-error">{{ arkError }}</div>
<div v-if="arkOk" class="mb-3 text-xs text-green-400">{{ arkOk }}</div>
<div class="flex gap-3 mt-4">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
@click="saveArkConfig"
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
</div>
</template>
</div>
<!-- Pinned footer (2026-07-22 modal contract): Close always, plus the
active tab's primary action the buttons never scroll away. -->
<template #footer>
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button
v-if="activeTab === 'cashu'"
@click="saveMints"
:disabled="savingMints || mints.length === 0"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingMints ? 'Saving…' : 'Save' }}</button>
<button
v-else-if="activeTab === 'fedimint'"
@click="joinFederation"
:disabled="!fedimintBackendReady || joiningFed || !inviteCode.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ joiningFed ? 'Joining…' : 'Join federation' }}</button>
<button
v-else-if="activeTab === 'ark' && arkStatus?.available"
@click="saveArkConfig"
:disabled="arkBusy || !arkConfig.ark_server.trim() || !arkConfig.esplora.trim()"
class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>{{ savingArk ? 'Saving…' : 'Save' }}</button>
</div>
</template>
</BaseModal>
</template>
@@ -2,6 +2,7 @@
<button
class="cloud-file-item group"
data-controller-container
data-controller-primary
tabindex="0"
@click="handleClick"
>
@@ -17,11 +17,24 @@
</span>
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
</div>
<button class="lightbox-btn" @click="close">
<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>
<div class="flex items-center gap-1">
<button
v-if="pipSupported && currentItem && isVideoFile(currentItem)"
class="lightbox-btn"
title="Picture-in-picture"
@click.stop="togglePip(videoEl)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<button class="lightbox-btn" @click="close">
<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>
</div>
</div>
<!-- Navigation arrows -->
@@ -111,6 +124,7 @@
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { getFileCategory } from '@/composables/useFileType'
import { pipSupported, togglePip } from '@/utils/pip'
const props = defineProps<{
items: FileBrowserItem[]
+152 -5
View File
@@ -87,6 +87,47 @@
/>
<span class="share-price-unit">sats</span>
</div>
<!-- Accepted payment methods (only for paid) gated on what this
node can actually receive; unavailable rails are disabled with
an that explains how to enable them. -->
<div v-if="accessType === 'paid'" class="mt-3">
<p class="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Payments you accept</p>
<div class="space-y-2">
<div v-for="m in PAY_METHODS" :key="m.key" class="share-modal-row">
<div class="flex-1 flex items-center gap-2 min-w-0">
<p class="text-sm text-white/90">{{ m.label }}</p>
<span v-if="capability[m.key] === undefined" class="text-[10px] text-white/40">checking</span>
<button
v-else-if="!capability[m.key]"
class="w-4 h-4 rounded-full bg-white/10 text-white/60 hover:text-white text-[10px] leading-4 text-center shrink-0"
title="Why is this unavailable?"
@click="adviceFor = m.key"
>i</button>
</div>
<ToggleSwitch
:model-value="acceptedSet.has(m.key)"
:disabled="!capability[m.key]"
:aria-label="`Accept ${m.label}`"
@update:model-value="toggleMethod(m.key, $event)"
/>
</div>
</div>
<p v-if="acceptedSet.size === 0" class="text-xs text-red-400 mt-2">
Pick at least one payment method buyers can use.
</p>
</div>
</div>
<!-- Advice modal for an unavailable payment method -->
<div v-if="adviceFor" class="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-medium text-white">{{ PAY_METHODS.find(m => m.key === adviceFor)?.label }} isn't ready on this node</p>
<button class="text-white/50 hover:text-white text-xs" @click="adviceFor = null">Dismiss</button>
</div>
<ul class="text-xs text-white/60 list-disc pl-4 space-y-1">
<li v-for="line in adviceLines[adviceFor] || []" :key="line">{{ line }}</li>
</ul>
</div>
<!-- Status messages -->
@@ -109,7 +150,7 @@
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
<button
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1))"
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1 || acceptedSet.size === 0))"
@click="save"
>
{{ shared ? 'Share' : 'Stop Sharing' }}
@@ -144,17 +185,113 @@ const saving = ref(false)
const errorMsg = ref<string | null>(null)
const successMsg = ref<string | null>(null)
// --- Accepted payment methods, gated on what this node can actually receive ---
const PAY_METHODS = [
{ key: 'lightning', label: 'Lightning' },
{ key: 'onchain', label: 'On-chain' },
{ key: 'ecash', label: 'Cashu ecash' },
{ key: 'fedimint', label: 'Fedimint' },
] as const
type PayMethod = (typeof PAY_METHODS)[number]['key']
// undefined = probe in flight; then true/false per method.
const capability = ref<Partial<Record<PayMethod, boolean>>>({})
const adviceLines = ref<Partial<Record<PayMethod, string[]>>>({})
const acceptedSet = ref<Set<PayMethod>>(new Set())
const adviceFor = ref<PayMethod | null>(null)
// Only default-select capable methods when the item had no saved list.
let acceptedLoadedFromItem = false
function toggleMethod(key: PayMethod, on: boolean) {
const next = new Set(acceptedSet.value)
if (on) next.add(key)
else next.delete(key)
acceptedSet.value = next
}
/** Probe the node's rails and build advice for the unavailable ones. */
async function probeCapabilities() {
// Lightning + on-chain both live on LND.
try {
const info = await rpcClient.call<{ num_active_channels?: number; synced_to_chain?: boolean }>({
method: 'lnd.getinfo', timeout: 8000,
})
capability.value.onchain = true
const channels = info?.num_active_channels ?? 0
capability.value.lightning = channels > 0
if (channels === 0) {
adviceLines.value.lightning = [
'Your Lightning node is running but has no active channel — buyers cannot pay you over Lightning yet.',
'Open a channel from Wallet → Lightning Channels (funds on your on-chain balance can back it).',
'Once the channel is active, come back and enable Lightning here.',
]
}
} catch {
capability.value.lightning = false
capability.value.onchain = false
adviceLines.value.lightning = [
'The Lightning (LND) app isn\'t running on this node.',
'Install/start Lightning from the App Store, let it sync, then open a channel.',
]
adviceLines.value.onchain = [
'On-chain receiving uses the Lightning (LND) app\'s wallet, which isn\'t running.',
'Install/start Lightning from the App Store — no channel needed for on-chain.',
]
}
try {
await rpcClient.call({ method: 'wallet.ecash-balance', timeout: 8000 })
capability.value.ecash = true
} catch {
capability.value.ecash = false
adviceLines.value.ecash = [
'The Cashu ecash wallet isn\'t set up on this node.',
'Open Wallet → Ecash to connect a mint, then enable Cashu here.',
]
}
try {
await rpcClient.call({ method: 'wallet.fedimint-balance', timeout: 8000 })
capability.value.fedimint = true
} catch {
capability.value.fedimint = false
adviceLines.value.fedimint = [
'This node hasn\'t joined a Fedimint federation.',
'Install the Fedimint app and join (or create) a federation, then enable it here.',
]
}
// Defaults: everything the node can receive unless the item already
// carried an explicit list. Never auto-enable an incapable rail.
if (!acceptedLoadedFromItem) {
acceptedSet.value = new Set(PAY_METHODS.filter((m) => capability.value[m.key]).map((m) => m.key))
} else {
acceptedSet.value = new Set([...acceptedSet.value].filter((k) => capability.value[k]))
}
}
// If we have an existing item, load its state
/** Catalog entries store the slash-stripped path; props carry a leading
* slash (filepath) or just the basename (filename). Normalize both sides
* the old exact compare never matched, so every re-share created a brand
* new priced entry and buyers could pay twice for one file (2026-07-22). */
function matchesThisFile(catalogFilename: string): boolean {
const strip = (v: string) => v.replace(/^\/+/, '')
return (
strip(catalogFilename) === strip(props.filepath || '') ||
strip(catalogFilename) === strip(props.filename || '')
)
}
onMounted(async () => {
try {
const res = await rpcClient.call<{ items: Array<{
id: string
filename: string
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number } } | string
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number; accepted?: string[] } } | string
availability: string | { allpeers?: unknown; nobody?: unknown }
}> }>({ method: 'content.list-mine' })
const match = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath
(i) => matchesThisFile(i.filename)
)
if (match) {
shared.value = true
@@ -166,6 +303,14 @@ onMounted(async () => {
if ('paid' in access && access.paid) {
accessType.value = 'paid'
priceSats.value = access.paid.price_sats || 100
if (Array.isArray(access.paid.accepted) && access.paid.accepted.length) {
acceptedLoadedFromItem = true
acceptedSet.value = new Set(
access.paid.accepted.filter((m): m is PayMethod =>
PAY_METHODS.some((p) => p.key === m),
),
)
}
} else if ('peersonly' in access) {
accessType.value = 'peers_only'
}
@@ -174,6 +319,7 @@ onMounted(async () => {
} catch (e) {
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
}
void probeCapabilities()
})
async function save() {
@@ -188,7 +334,7 @@ async function save() {
method: 'content.list-mine',
})
const match = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath
(i) => matchesThisFile(i.filename)
)
if (match) {
await rpcClient.call({ method: 'content.remove', params: { id: match.id } })
@@ -200,7 +346,7 @@ async function save() {
method: 'content.list-mine',
})
let itemId = res.items.find(
(i) => i.filename === props.filename || i.filename === props.filepath
(i) => matchesThisFile(i.filename)
)?.id
// Add if not in catalog
@@ -227,6 +373,7 @@ async function save() {
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
if (accessType.value === 'paid') {
pricingParams.price_sats = priceSats.value
pricingParams.accepted_methods = [...acceptedSet.value]
}
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
@@ -76,6 +76,11 @@
</p>
</div>
<!-- INTEGRATION POINT (other developer, in progress): the web flasher
for all three firmwares (MeshCore / Meshtastic / RNode) belongs
HERE as a third action on this screen e.g. "Flash different
firmware" driven by the probe result above. Per the operator:
the flasher must live in this detection UI. -->
<div class="flex gap-2 mt-5">
<button
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50"
@@ -105,6 +105,14 @@ function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
return !!el.closest(`[data-controller-zone="${zone}"]`)
}
/** Topmost open modal dialog, if any — it owns navigation while visible. */
function getOpenModal(): HTMLElement | null {
const dialogs = Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'),
).filter(el => el.offsetParent !== null)
return dialogs[dialogs.length - 1] ?? null
}
function isInsideContainer(el: HTMLElement | null): boolean {
if (!el) return false
const container = el.closest('[data-controller-container]')
@@ -250,6 +258,42 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const target = e.target as HTMLElement
const activeEl = document.activeElement as HTMLElement
// ── MODAL SCOPE ──────────────────────────────────────────
// An open dialog owns navigation: focus is pulled inside, arrows move
// spatially between its controls, Enter activates. Escape stays with the
// modal's own close handling. Standard mapping, no per-modal code.
const modal = getOpenModal()
if (modal) {
if (e.key === 'Escape') return
const focusables = getFocusableElements(modal)
if (!focusables.length) return
if (!activeEl || !modal.contains(activeEl)) {
e.preventDefault()
const first = focusables[0]
if (first) focusEl(first)
return
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
// Typing keys stay with the field; Enter keeps the form-submit
// behavior below; only Up/Down leave the field spatially.
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') return
} else if (e.key === 'Enter') {
e.preventDefault()
playNavSound('action')
activeEl.click()
return
}
e.preventDefault()
const dir =
e.key === 'ArrowDown' ? ('down' as const)
: e.key === 'ArrowUp' ? ('up' as const)
: e.key === 'ArrowLeft' ? ('left' as const)
: ('right' as const)
const nearest = findNearestInDirection(activeEl, focusables.filter(el => el !== activeEl), dir)
if (nearest) focusEl(nearest)
return
}
// ── TEXT INPUT HANDLING ──────────────────────────────────
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
if (
@@ -364,6 +408,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
e.preventDefault()
if (isContainer(activeEl)) {
// Container declares its own click as THE action (e.g. a media file
// card whose click plays it) — without this the a[href] fallback
// below hits the card's download link and gamepad-select downloads
// a song instead of playing it.
if (activeEl.hasAttribute('data-controller-primary')) {
playNavSound('action')
activeEl.click()
return
}
// Prioritised action: install button
if (activeEl.hasAttribute('data-controller-install')) {
const btn = activeEl.querySelector<HTMLButtonElement>('[data-controller-install-btn]:not([disabled])')
+29 -14
View File
@@ -34,6 +34,8 @@ export interface MeshStatus {
pid?: string | null
product?: string | null
manufacturer?: string | null
/** Epoch seconds the /dev node appeared — changes on every replug. */
plugged_at?: number | null
}>
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
manage_radio?: boolean
@@ -305,10 +307,20 @@ export const useMeshStore = defineStore('mesh', () => {
// Dismissals are cleared the moment the port disappears (unplug), so the
// modal re-appears on EVERY plug-in — including swapping a different stick
// into the same /dev path — per the hot-swap UX (2026-07-22).
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1'
const dismissedDetectedPaths = ref<Set<string>>(new Set(
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[]
))
// v2: dismissals key on (path → plugged_at). udev recreates the /dev node
// on every plug, so plugged_at changes on each replug — an old "Not Now"
// can never suppress a NEWLY plugged stick, even when the swap happens
// faster than a status poll (which absence-pruning alone missed) or when
// the same /dev path is reused. v1 (a bare path set) is intentionally
// abandoned: stale one-time dismissals from before 2026-07-22 shouldn't
// suppress anything either.
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v2'
const dismissedDetected = ref<Record<string, number>>(
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '{}') as Record<string, number>
)
function pluggedAt(s: MeshStatus, path: string): number {
return s.detected_device_info?.find(d => d.path === path)?.plugged_at ?? 0
}
// Consecutive polls each candidate port has been present-but-not-connected.
// The modal waits for 2 sightings so it doesn't flash during the couple of
// seconds an ordinary reconnect (same radio, transient blip) needs.
@@ -317,15 +329,15 @@ export const useMeshStore = defineStore('mesh', () => {
const s = status.value
if (!s) return []
return (s.detected_devices || []).filter(p =>
!dismissedDetectedPaths.value.has(p) &&
dismissedDetected.value[p] !== pluggedAt(s, p) &&
// The port the live session occupies is not a candidate…
!(s.device_connected && s.device_path === p) &&
// …and a port only qualifies once it has survived the blip debounce.
(detectSightings.value[p] ?? 0) >= 2
)
})
/** Called after every status fetch: advance sighting counters and clear
* dismissals/counters for ports that vanished (unplug replug reshows). */
/** Called after every status fetch: advance sighting counters and drop
* dismissals for ports that vanished (belt-and-braces with plugged_at). */
function trackDetectedDevices(s: MeshStatus) {
const present = new Set(s.detected_devices || [])
const next: Record<string, number> = {}
@@ -336,21 +348,24 @@ export const useMeshStore = defineStore('mesh', () => {
}
detectSightings.value = next
let dirty = false
for (const p of [...dismissedDetectedPaths.value]) {
for (const p of Object.keys(dismissedDetected.value)) {
if (!present.has(p)) {
dismissedDetectedPaths.value.delete(p)
delete dismissedDetected.value[p]
dirty = true
}
}
if (dirty) {
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
dismissedDetected.value = { ...dismissedDetected.value }
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
}
}
function dismissDetectedDevice(path: string) {
dismissedDetectedPaths.value.add(path)
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
const s = status.value
dismissedDetected.value = {
...dismissedDetected.value,
[path]: s ? pluggedAt(s, path) : 0,
}
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify(dismissedDetected.value))
}
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
+16
View File
@@ -2,6 +2,22 @@
@tailwind components;
@tailwind utilities;
/* The app is dark-only NEVER let the OS/browser theme leak into native
controls. Without this, select popups / scrollbars / date pickers follow
the user's OS setting (a light-mode ThinkPad rendered white dropdown
lists inside the dark UI, 2026-07-22). color-scheme pins Chromium's
native select popup dark; the explicit select/option rules cover
Firefox and the closed control itself. */
:root {
color-scheme: dark;
}
select,
select option,
select optgroup {
background-color: #16181d;
color: #fff;
}
/* Montserrat - header font (used in neode present) */
@font-face {
font-family: 'Montserrat';
+19
View File
@@ -0,0 +1,19 @@
/** Video Picture-in-Picture helpers (Chromium/Safari; no-ops elsewhere).
* Kiosk note: PiP is skipped on the WM-less kiosk X session by callers that
* care an unmanaged popup there is unusable (docs/tv-input-iframe-apps.md
* sibling investigation, task #18). */
export const pipSupported =
typeof document !== 'undefined' &&
'pictureInPictureEnabled' in document &&
document.pictureInPictureEnabled
export async function togglePip(video: HTMLVideoElement | null | undefined): Promise<void> {
if (!video || !pipSupported) return
try {
if (document.pictureInPictureElement === video) await document.exitPictureInPicture()
else await video.requestPictureInPicture()
} catch {
// Permission/transient failure — the button is best-effort.
}
}
+73 -12
View File
@@ -1,8 +1,14 @@
<template>
<div class="app-session-root">
<Teleport to="body" :disabled="inlinePanelMode">
<!-- The root stays in the layout as a rect placeholder; the session itself
ALWAYS lives under <body>. Toggling Teleport's disabled re-parented the
subtree on panel<->overlay switches, and moving an iframe node reloads
it inline mode is now emulated with a fixed-position rect synced from
this placeholder, so the iframe never moves in the DOM. -->
<div class="app-session-root" ref="rootRef">
<Teleport to="body">
<div
:class="backdropClasses"
:style="inlineRectStyle"
@click.self="handleBackdropClick"
>
<div
@@ -103,7 +109,7 @@ import AppSessionFrame from './appSession/AppSessionFrame.vue'
import MobileGamepad from './appSession/MobileGamepad.vue'
import {
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
resolveAppUrl, resolveAppTitle,
initialDisplayMode, resolveAppUrl, resolveAppTitle,
} from './appSession/appSessionConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { useAppIdentity } from './appSession/useAppIdentity'
@@ -142,11 +148,6 @@ let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
let autoRetryId: ReturnType<typeof setTimeout> | null = null
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
// Display mode -- persisted in localStorage
const displayMode = ref<DisplayMode>(
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
)
const appId = computed(() => {
const id = props.appIdProp || (route.params.appId as string)
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
@@ -156,6 +157,9 @@ const appId = computed(() => {
return id
})
// 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 appIcon = computed(() =>
@@ -230,7 +234,9 @@ function setMode(mode: DisplayMode) {
document.exitFullscreen().catch(() => {})
}
displayMode.value = mode
localStorage.setItem(DISPLAY_MODE_KEY, mode)
// Strictly per-app: the pick is remembered for THIS app only (no global
// key one app's mode must never change how another opens).
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, mode)
// Route-based sessions (deep links) hand off to the store-driven session so
// the app keeps floating above the dashboard instead of owning the route.
@@ -251,12 +257,52 @@ function setMode(mode: DisplayMode) {
// Reactive classes based on display mode. The store-driven session honors the
// selected display mode in place: panel renders inline beside the page,
// overlay/fullscreen render above it (teleported to body) the underlying
// route never changes. Mobile always uses the full overlay.
// overlay/fullscreen render above it the underlying route never changes.
// Mobile always uses the full overlay.
const inlinePanelMode = computed(() =>
isInlinePanel.value && !isMobile.value && displayMode.value === 'panel'
)
// Inline-mode rect emulation: the always-body-teleported backdrop pins itself
// to the placeholder's box so "inline" looks identical to the old in-place
// render while the iframe stays put in the DOM across mode switches.
const rootRef = ref<HTMLElement | null>(null)
const inlineRect = ref<{ top: number; left: number; width: number; height: number } | null>(null)
let rectObserver: ResizeObserver | null = null
function syncInlineRect() {
const el = rootRef.value
if (!el) return
const r = el.getBoundingClientRect()
// A hidden/unmounted placeholder measures 0x0 keep the last good rect.
if (r.width > 0 && r.height > 0) {
inlineRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
}
}
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
if (!inlinePanelMode.value) return undefined
const r = inlineRect.value
// Never paint the inline backdrop over the whole viewport while unmeasured.
if (!r) {
const hidden: Record<string, string> = { visibility: 'hidden' }
return hidden
}
const style: Record<string, string> = {
position: 'fixed',
top: `${r.top}px`,
left: `${r.left}px`,
width: `${r.width}px`,
height: `${r.height}px`,
zIndex: '100',
}
return style
})
watch(inlinePanelMode, (on) => {
if (on) void nextTick(syncInlineRect)
})
const backdropClasses = computed(() => {
if (inlinePanelMode.value) return 'app-session-backdrop-inline'
return 'app-session-backdrop-overlay'
@@ -277,6 +323,9 @@ function onLoad() {
loading.value = false
isRefreshing.value = false
autoRetryCount.value = 0
// 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 */ }
// Check if iframe actually loaded content (same-origin only)
iframeCheckId = setTimeout(() => {
try {
@@ -366,7 +415,7 @@ function onKeyDown(e: KeyboardEvent) {
function onFullscreenChange() {
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
displayMode.value = 'overlay'
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
if (appId.value) localStorage.setItem(`${DISPLAY_MODE_KEY}:${appId.value}`, 'overlay')
}
}
@@ -405,7 +454,16 @@ onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
window.addEventListener('resize', updateIsMobile)
window.addEventListener('resize', syncInlineRect)
document.addEventListener('fullscreenchange', onFullscreenChange)
// Track the placeholder's box (sidebar collapse, layout shifts) for the
// inline-mode fixed-position emulation. Sync before first paint so the
// inline backdrop never flashes at the wrong rect.
syncInlineRect()
if (rootRef.value && typeof ResizeObserver !== 'undefined') {
rectObserver = new ResizeObserver(syncInlineRect)
rectObserver.observe(rootRef.value)
}
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
loading.value = false
iframeBlocked.value = true
@@ -429,7 +487,10 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('message', onMessage)
window.removeEventListener('resize', updateIsMobile)
window.removeEventListener('resize', syncInlineRect)
document.removeEventListener('fullscreenchange', onFullscreenChange)
rectObserver?.disconnect()
rectObserver = null
screensaverStore.resume(screensaverReason.value)
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
})
+79 -1
View File
@@ -148,6 +148,36 @@
</div>
</div>
<!-- Paid Files tab everything this node has purchased
Source of truth is the purchase cache (content.owned-list): filename,
type, price paid and when the file itself was also auto-filed into
Photos/Music/Documents at purchase time (2026-07-22). -->
<div v-else-if="activeTab === 'paid'">
<div v-if="paidLoading" class="glass-card p-8 text-center text-white/50 text-sm">Loading purchases</div>
<div v-else-if="paidItems.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
Nothing purchased yet files you buy from peers appear here and are saved into your folders automatically.
</div>
<div v-else class="space-y-2">
<div
v-for="it in paidItems"
:key="it.onion + it.content_id"
class="glass-card p-3 flex items-center gap-3 cursor-pointer hover:bg-white/5 transition-colors"
@click="viewPaidItem(it)"
>
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-white/90 truncate">{{ it.filename.split('/').pop() }}</p>
<p class="text-[11px] text-white/40">
{{ (it.size_bytes / 1024).toFixed(0) }} KB ·
<span class="text-orange-300/80">{{ it.paid_sats.toLocaleString() }} sats</span>
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
</p>
</div>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
</div>
</div>
</div>
<!-- Peer Files tab every file shared by every peer -->
<div v-else-if="activeTab === 'peers'">
<div v-if="peerFilesLoading" class="glass-card p-8 text-center text-white/50 text-sm flex items-center justify-center gap-3">
@@ -374,13 +404,14 @@ const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false)
// Tabs / categories / search state
type TabId = 'folders' | 'mine' | 'peers'
type TabId = 'folders' | 'mine' | 'peers' | 'paid'
type CategoryId = 'all' | 'photos' | 'music' | 'documents'
const TABS: Array<{ id: TabId; name: string }> = [
{ id: 'folders', name: 'Folders' },
{ id: 'mine', name: 'My Files' },
{ id: 'peers', name: 'Peer Files' },
{ id: 'paid', name: 'Paid Files' },
]
const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
{ id: 'all', name: 'All' },
@@ -390,6 +421,44 @@ const CATEGORIES: Array<{ id: CategoryId; name: string }> = [
]
const activeTab = ref<TabId>('folders')
// Paid Files tab
interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string }
const paidItems = ref<PaidItem[]>([])
const paidLoading = ref(false)
async function loadPaidItems() {
paidLoading.value = true
try {
const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' })
paidItems.value = (res.items || []).slice().reverse()
} catch { paidItems.value = [] } finally { paidLoading.value = false }
}
async function viewPaidItem(it: PaidItem) {
try {
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
method: 'content.owned-get',
params: { onion: it.onion, content_id: it.content_id },
timeout: 60000,
})
const b64 = res.data_base64 || res.data
if (!b64) return
const bin = atob(b64)
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
const mime = res.mime_type || it.mime_type
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
// Music ALWAYS plays in the global bottom-bar player never a popup/
// lightbox (blob URL stays alive for the bar; it owns playback now).
if (mime.startsWith('audio/')) {
audioPlayer.play(url, it.filename.split('/').pop() || it.filename)
return
}
window.open(url, '_blank', 'noopener')
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
}
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
const selectedCategory = ref<CategoryId>('all')
const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim().length > 0)
@@ -576,6 +645,15 @@ async function handlePlay(path: string, name: string) {
}
function handlePreview(path: string, context: FileBrowserItem[]) {
// Audio never opens the lightbox it belongs to the bottom-bar player.
const clicked = context.find(item => item.path === path)
if (clicked) {
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
if (getFileCategory(ext, clicked.isDir) === 'audio') {
void handlePlay(path, clicked.name)
return
}
}
// MediaLightbox filters to media internally; index within that filtered list.
const mediaItems = context.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
+10 -1
View File
@@ -323,9 +323,18 @@ const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(n
const lightboxIndex = ref<number | null>(null)
function handlePreview(path: string) {
const items = cloudStore.sortedItems
// Audio never opens the lightbox it belongs to the bottom-bar player.
const clicked = items.find(item => item.path === path)
if (clicked) {
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
if (getFileCategory(ext, clicked.isDir) === 'audio') {
void handlePlay(path, clicked.name)
return
}
}
// MediaLightbox internally filters items to media only, so startIndex
// must be the index within that filtered list
const items = cloudStore.sortedItems
const mediaItems = items.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir)
+4 -4
View File
@@ -350,7 +350,7 @@ function updateKeyboardInset() {
onMounted(async () => {
window.addEventListener('resize', handleResize)
document.addEventListener('click', handleDocClickForMenu)
document.addEventListener('pointerdown', handleDocClickForMenu)
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateKeyboardInset)
@@ -403,7 +403,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', handleDocClickForMenu)
document.removeEventListener('pointerdown', handleDocClickForMenu)
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateKeyboardInset)
@@ -1337,8 +1337,8 @@ function closeAttachMenuOnOutsideClick(ev: MouseEvent) {
const target = ev.target as HTMLElement
if (!target.closest('.mesh-attach-menu-anchor')) showAttachMenu.value = false
}
onMounted(() => document.addEventListener('click', closeAttachMenuOnOutsideClick))
onUnmounted(() => document.removeEventListener('click', closeAttachMenuOnOutsideClick))
onMounted(() => document.addEventListener('pointerdown', closeAttachMenuOnOutsideClick))
onUnmounted(() => document.removeEventListener('pointerdown', closeAttachMenuOnOutsideClick))
const attachError = ref<string | null>(null)
const fetchingCids = ref<Set<string>>(new Set())
const fetchedUrls = ref<Map<string, string>>(new Map())
+61 -9
View File
@@ -251,9 +251,22 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Picture-in-picture -->
<button
v-if="pipSupported"
class="absolute -top-10 right-10 text-white/60 hover:text-white transition-colors"
title="Picture-in-picture"
@click="togglePip(peerVideoRef)"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<!-- Video element -->
<div class="relative">
<video
ref="peerVideoRef"
:src="videoPlayerUrl"
class="w-full rounded-xl bg-black"
controls
@@ -378,9 +391,11 @@
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
</p>
<!-- Step 1: choose a payment method -->
<!-- Step 1: choose a payment method only the methods the SELLER
accepts for this item are offered -->
<div v-if="payMode === 'choose'" class="space-y-3">
<button
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="ecashPreparing || downloading === payItem.id"
@click="prepareEcashPay"
@@ -395,6 +410,7 @@
</button>
<button
v-if="acceptsMethod(payItem.access, 'lightning')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying"
@click="payWithLightning"
@@ -409,6 +425,7 @@
</button>
<button
v-if="acceptsMethod(payItem.access, 'lightning') || acceptsMethod(payItem.access, 'onchain')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying"
@click="openQrPay"
@@ -423,6 +440,7 @@
</button>
<button
v-if="acceptsMethod(payItem.access, 'onchain')"
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"
@@ -486,10 +504,11 @@
<!-- Step 2: pay from another wallet tabbed QR (on-chain default) -->
<div v-else>
<!-- Method tabs, styled like the wallet Send/Receive modal -->
<!-- Method tabs, styled like the wallet Send/Receive modal;
only tabs the seller accepts are shown -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['onchain', 'lightning'] as const)"
v-for="m in (['onchain', 'lightning'] as const).filter(m => acceptsMethod(payItem!.access, m))"
:key="m"
@click="selectQrTab(m)"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
@@ -580,6 +599,7 @@ import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { pipSupported, togglePip } from '@/utils/pip'
import BackButton from '@/components/BackButton.vue'
const props = defineProps<{
@@ -674,6 +694,13 @@ async function viewOwned(item: CatalogItem) {
})
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
const mime = res.mime_type || item.mime_type
// Audio always plays in the global bottom-bar player never the lightbox
// (the blob URL is intentionally not revoked while the bar plays it).
if (mime.startsWith('audio/')) {
const url = URL.createObjectURL(base64ToBlob(res.data, mime))
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
return
}
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
viewerMime.value = mime
@@ -746,6 +773,7 @@ let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
// Video player modal state
const peerVideoRef = ref<HTMLVideoElement | null>(null)
const videoPlayerItem = ref<CatalogItem | null>(null)
const videoPlayerUrl = ref<string | null>(null)
const videoPlayerPaid = ref(false)
@@ -903,6 +931,15 @@ function getItemPrice(access: CatalogItem['access']): number {
return 0
}
/** Payment methods the seller accepts for this item. Missing/empty = all
* (items shared before sellers could restrict methods). */
function acceptsMethod(access: CatalogItem['access'], method: string): boolean {
if (typeof access !== 'object' || !('paid' in access)) return true
const accepted = (access.paid as { accepted?: string[] }).accepted
if (!Array.isArray(accepted) || accepted.length === 0) return true
return accepted.includes(method)
}
async function downloadFile(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
@@ -981,7 +1018,6 @@ function closePayModal() {
*/
function openQrPay() {
payMode.value = 'qr'
qrTab.value = 'onchain'
invoiceData.value = null
invoiceQr.value = ''
invoiceError.value = ''
@@ -989,7 +1025,14 @@ function openQrPay() {
onchainData.value = null
onchainQr.value = ''
onchainError.value = ''
loadOnchainQr()
// Start on the first tab the seller actually accepts.
if (payItem.value && !acceptsMethod(payItem.value.access, 'onchain')) {
qrTab.value = 'lightning'
payWithInvoice()
} else {
qrTab.value = 'onchain'
loadOnchainQr()
}
}
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
@@ -1212,10 +1255,19 @@ async function confirmEcashPay() {
// 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
// A just-bought song goes straight to the bottom-bar player the
// owned-content lightbox is for images/video only.
if (mime.startsWith('audio/')) {
audioPlayer.play(
URL.createObjectURL(base64ToBlob(result.data, mime)),
item.filename.split('/').pop() || item.filename,
)
} else {
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) {
@@ -7,6 +7,22 @@ export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
/** Per-app default display mode. Used when the user hasn't explicitly picked
* a mode for that app (an explicit pick is remembered per app and wins).
* Apps not listed default to 'panel'. */
export const APP_DEFAULT_DISPLAY_MODE: Record<string, DisplayMode> = {
'indeedhub': 'fullscreen',
}
/** Initial display mode for an app session: per-app user choice per-app
* default panel. Strictly per-app deliberately NO global fallback, so
* one app's mode change can never affect how another app opens. */
export function initialDisplayMode(id: string): DisplayMode {
const perApp = localStorage.getItem(`${DISPLAY_MODE_KEY}:${id}`) as DisplayMode | null
if (perApp === 'panel' || perApp === 'overlay' || perApp === 'fullscreen') return perApp
return APP_DEFAULT_DISPLAY_MODE[id] ?? 'panel'
}
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
export const APP_PORTS: Record<string, number> = {
...GENERATED_APP_PORTS,
+6
View File
@@ -181,6 +181,12 @@ export function opensInTab(id: string): boolean {
// are explicit because icon extensions vary (.png / .webp / .svg).
const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg',
// Apps whose icon extension isn't .png: without an explicit entry the
// default `<id>.png` guess 404s on every render (console spam, and for
// mempool the .png→.svg fallback chain 404s TWICE before giving up).
pine: '/assets/img/app-icons/pine.svg',
mempool: '/assets/img/app-icons/mempool.webp',
'mempool-web': '/assets/img/app-icons/mempool.webp',
'fedimint-gateway': '/assets/img/app-icons/fedimint.png',
'fedimint-clientd': '/assets/img/app-icons/fedimint.png',
// immich stack
@@ -362,6 +362,33 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.112-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.112-alpha</span>
<span class="text-xs text-white/40">July 22, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Your mesh messages now survive restarts. Chat history channels and DMs alike used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably.</p>
<p>Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works including Reticulum RNodes, which fresh installer images now support out of the box.</p>
<p>Incoming bitcoin appears in your wallet within seconds of being sent balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll.</p>
<p>The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast.</p>
<p>Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead.</p>
<p>Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings the new On-chain tab.</p>
<p>Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands.</p>
<p>Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine.</p>
<p>The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address.</p>
<p>Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked.</p>
<p>The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately.</p>
<p>Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath.</p>
<p>Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit.</p>
<p>On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore dropdowns and other native controls stay dark on every device.</p>
<p>Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs."</p>
<p>Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy.</p>
<p>Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts.</p>
<p>Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release).</p>
</div>
</div>
<!-- v1.7.111-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+21 -13
View File
@@ -379,19 +379,27 @@ async function loadPeers() {
peers.value = peerList
observers.value = observerList
for (const p of [...peers.value, ...observers.value]) {
try {
const check = await rpcClient.checkPeerReachable(p.onion)
peerReachableLocal.value[p.onion] = check.reachable
} catch {
peerReachableLocal.value[p.onion] = false
}
}
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
// The list is ready render it and re-enable Refresh NOW. The
// reachability dots fill in as probes resolve. Before 2026-07-22 the
// probes ran ONE AT A TIME with a 30s Tor timeout each, so Refresh sat
// disabled for N-offline-peers × 30s ("takes ages").
loadingPeers.value = false
void Promise.allSettled(
[...peers.value, ...observers.value].map(async (p) => {
try {
const check = await rpcClient.checkPeerReachable(p.onion)
peerReachableLocal.value[p.onion] = check.reachable
} catch {
peerReachableLocal.value[p.onion] = false
}
}),
).then(() => {
writeConnectedNodesCache({
peers: peers.value,
observers: observers.value,
peerReachable: peerReachableLocal.value,
connectionRequests: connectionRequests.value,
})
})
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to load peers:', e)
@@ -73,7 +73,9 @@
<div v-else-if="discoveredNodes.length === 0" class="py-4 text-center text-white/40 text-xs">
No discoverable nodes found yet. Nodes appear here as relays gossip their presence.
</div>
<div v-else class="space-y-2 max-h-56 overflow-y-auto pr-1">
<!-- min 14rem, otherwise scale with the viewport so a long discovery
list uses the screen instead of cramming into a fixed 224px box -->
<div v-else class="space-y-2 max-h-[max(14rem,45vh)] overflow-y-auto pr-1">
<div
v-for="node in discoveredNodes"
:key="node.nostr_pubkey"
+56 -13
View File
@@ -235,7 +235,7 @@
</button>
<button
v-else-if="getItemPrice(pItem.access) > 0"
@click="purchaseAndDownload(pItem)"
@click="requestPurchase(pItem)"
:disabled="purchasingId === pItem.id"
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
>
@@ -305,6 +305,41 @@
</div>
</Teleport>
<!-- Purchase confirmation payment NEVER starts before the user confirms -->
<Teleport to="body">
<div v-if="pendingPurchase" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="pendingPurchase = null">
<div class="glass-card p-6 w-full max-w-sm mx-4" role="dialog" aria-modal="true">
<h3 class="text-base font-semibold text-white mb-1">Buy this file</h3>
<p class="text-sm text-white/70 truncate mb-4">{{ pendingPurchase.filename.split('/').pop() }}</p>
<div class="p-3 bg-white/5 rounded-lg space-y-1.5 mb-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Ecash balance</span>
<span class="text-sm font-medium text-white/80">{{ pendingBalance === null ? '…' : pendingBalance.toLocaleString() + ' sats' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Price</span>
<span class="text-sm font-medium text-white/80">{{ getItemPrice(pendingPurchase.access).toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access) ? 'text-red-400' : 'text-white/80'">
{{ pendingBalance === null ? '…' : (pendingBalance - getItemPrice(pendingPurchase.access)).toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)" class="text-xs text-red-400 mb-3">Not enough ecash fund your wallet, or buy from the Peers page to pay another way.</p>
<div class="flex gap-3">
<button @click="pendingPurchase = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="confirmPendingPurchase"
:disabled="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>Pay from node ecash</button>
</div>
</div>
</div>
</Teleport>
<!-- Add Content Modal -->
<Teleport to="body">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
@@ -550,6 +585,26 @@ function downloadPeerContent(item: PeerContentItem) {
safeClipboardWrite(url)
}
// Purchase is strictly two-step: the Buy click only opens the confirmation
// (with the balance impact); nothing is paid until the user confirms there.
const pendingPurchase = ref<PeerContentItem | null>(null)
const pendingBalance = ref<number | null>(null)
function requestPurchase(item: PeerContentItem) {
if (purchasingId.value) return
pendingPurchase.value = item
pendingBalance.value = null
rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
.then((r) => { pendingBalance.value = r?.balance_sats ?? 0 })
.catch(() => { pendingBalance.value = null })
}
async function confirmPendingPurchase() {
const item = pendingPurchase.value
pendingPurchase.value = null
if (item) await purchaseAndDownload(item)
}
async function purchaseAndDownload(item: PeerContentItem) {
if (!browsePeerOnion.value || purchasingId.value) return
const price = getItemPrice(item.access)
@@ -557,18 +612,6 @@ async function purchaseAndDownload(item: PeerContentItem) {
purchasingId.value = item.id
try {
// Check balance first
try {
const balRes = await rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
const balance = balRes?.balance_sats ?? 0
if (balance < price) {
emit('toast', `Insufficient ecash balance (${balance} sats). Need ${price} sats.`)
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: browsePeerOnion.value, content_id: item.id, price_sats: price },