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
+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"