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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-17 19:21:42 -04:00
parent bd567cd165
commit 87769cbfbf
46 changed files with 1527 additions and 283 deletions

View File

@ -2159,6 +2159,7 @@ app.post('/rpc/v1', (req, res) => {
// Mesh Networking (LoRa radio via Meshcore)
// =====================================================================
case 'mesh.status': {
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
return res.json({
result: {
enabled: true,
@ -2173,6 +2174,8 @@ app.post('/rpc/v1', (req, res) => {
messages_sent: 23,
messages_received: 47,
detected_devices: ['/dev/ttyUSB0'],
announce_block_headers: globalThis.__meshHeaders.announce_block_headers,
receive_block_headers: globalThis.__meshHeaders.receive_block_headers,
},
})
}
@ -2278,7 +2281,10 @@ app.post('/rpc/v1', (req, res) => {
case 'mesh.configure': {
console.log(`[Mesh] Configure:`, params)
return res.json({ result: { configured: true } })
globalThis.__meshHeaders ||= { announce_block_headers: false, receive_block_headers: true }
if (params && typeof params.announce_block_headers === 'boolean') globalThis.__meshHeaders.announce_block_headers = params.announce_block_headers
if (params && typeof params.receive_block_headers === 'boolean') globalThis.__meshHeaders.receive_block_headers = params.receive_block_headers
return res.json({ result: { configured: true, ...globalThis.__meshHeaders } })
}
case 'mesh.send-invoice': {
@ -3015,6 +3021,55 @@ app.post('/rpc/v1', (req, res) => {
})
}
case 'seed.reveal': {
if (!params || !params.password) {
return res.json({ error: { code: -32000, message: 'Password is required to reveal the recovery phrase' } })
}
// Demo gate: accept any non-empty password; reject "wrong" to exercise the error path.
if (params.password === 'wrong') {
return res.json({ error: { code: -32000, message: 'Incorrect password' } })
}
const demo = 'legal winner thank year wave sausage worth useful legal winner thank yellow able cabin dad debris during dose talent layer crater proud drift movie'.split(' ')
return res.json({ result: { words: demo, word_count: demo.length } })
}
case 'update.list-mirrors': {
globalThis.__mockMirrors ||= [
{ url: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json', label: 'Origin (vps2)' },
]
return res.json({ result: { mirrors: globalThis.__mockMirrors } })
}
case 'update.get-source': {
globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true }
return res.json({
result: {
source: globalThis.__swarmPrefs.source,
provide_dht: globalThis.__swarmPrefs.provide_dht,
swarm_available: false, // default build has no iroh-swarm feature
swarm_enabled: false,
},
})
}
case 'update.set-source': {
globalThis.__swarmPrefs ||= { source: 'origin', provide_dht: true }
if (params && (params.source === 'origin' || params.source === 'swarm')) {
globalThis.__swarmPrefs.source = params.source
}
if (params && typeof params.provide === 'boolean') {
globalThis.__swarmPrefs.provide_dht = params.provide
}
return res.json({
result: {
source: globalThis.__swarmPrefs.source,
provide_dht: globalThis.__swarmPrefs.provide_dht,
swarm_available: false,
swarm_enabled: false,
},
})
}
case 'network.list-requests': {
return res.json({
result: {

View File

@ -290,6 +290,18 @@
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",

View File

@ -139,6 +139,41 @@ function deepElementFromPoint(x: number, y: number): Element | null {
return el
}
/**
* Find the nearest scrollable ancestor of `el` for the given delta, hopping out
* of same-origin iframes when needed. Synthetic WheelEvents are untrusted and
* never actually scroll the page, so two-finger scroll must call scrollBy on a
* real scroll container this locates it (e.g. the right-hand app frame). (#7)
*/
function findScrollable(el: Element | null, dx: number, dy: number): Element | null {
let node: Element | null = el
let guard = 0
while (node && guard++ < 60) {
const win = node.ownerDocument?.defaultView
const style = win?.getComputedStyle(node)
if (style) {
const oy = style.overflowY
const ox = style.overflowX
const isRoot = node === node.ownerDocument?.scrollingElement
const canY =
(oy === 'auto' || oy === 'scroll' || isRoot) &&
node.scrollHeight > node.clientHeight + 1
const canX =
(ox === 'auto' || ox === 'scroll' || isRoot) &&
node.scrollWidth > node.clientWidth + 1
if ((dy !== 0 && canY) || (dx !== 0 && canX)) return node
}
if (node.parentElement) {
node = node.parentElement
} else if (win?.frameElement) {
node = win.frameElement as Element // same-origin iframe → continue in parent doc
} else {
break
}
}
return null
}
/** The actually-focused element, descending through same-origin iframes. */
function deepActiveElement(): Element | null {
let el: Element | null = document.activeElement
@ -267,10 +302,19 @@ function handleMessage(data: string) {
break
}
case 's': {
const dy = msg.y ?? 0
document.dispatchEvent(new WheelEvent('wheel', {
bubbles: true, deltaY: dy * 100, deltaMode: WheelEvent.DOM_DELTA_PIXEL,
}))
// Scroll the element under the virtual cursor (incl. inside same-origin
// app frames like the right-hand panel), not the top document. A synthetic
// wheel event won't scroll — call scrollBy on a real scroll container. (#7)
const dy = (msg.y ?? 0) * 100
const dx = (msg.x ?? 0) * 100
const start = deepElementFromPoint(cursorX, cursorY)
const scroller = findScrollable(start, dx, dy)
if (scroller) {
scroller.scrollBy({ left: dx, top: dy })
} else {
const win = start?.ownerDocument?.defaultView ?? window
win.scrollBy(dx, dy)
}
break
}
}
@ -308,6 +352,30 @@ function doConnect() {
}
}
/**
* Ask the companion (phone) to open a URL in its own browser.
*
* "Open in external browser" apps can't be usefully opened on the kiosk when a
* companion is driving it `window.open` lands on the kiosk, which the phone
* user never sees. When a companion is active we forward the URL over the relay
* socket ({"t":"o","url"}); the backend routes it to the phone, which opens it.
*
* Returns true if the request was forwarded (caller should NOT open locally),
* false if there's no active companion (caller should open normally).
*/
export function requestExternalOpen(url: string): boolean {
if (!url || !/^https?:\/\//i.test(url)) return false
if (!companionActive.value) return false
if (!ws || ws.readyState !== WebSocket.OPEN) return false
try {
ws.send(JSON.stringify({ t: 'o', url }))
if (import.meta.env.DEV) console.log('[RemoteRelay] Forwarded external-open to companion:', url)
return true
} catch {
return false
}
}
/** Start the remote relay listener. Connects to /ws/remote-relay. */
export function startRemoteRelay() {
shouldReconnect = true

View File

@ -0,0 +1,49 @@
<template>
<!-- Desktop: subtle "frosted pill" link, sits at the top of the content flow
(the style from the Networking Profits page, now shared globally). -->
<button
type="button"
@click="$emit('click')"
:class="['hidden md:inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white text-sm transition-colors', desktopMargin]"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ label }}
</button>
<!-- Mobile: floating transparent button pinned 8px above the tab bar -->
<Teleport to="body">
<button
type="button"
@click="$emit('click')"
class="md:hidden mobile-back-btn back-button-glass px-6 py-3 rounded-xl font-medium flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ label }}</span>
</button>
</Teleport>
</template>
<script setup lang="ts">
/**
* Standard back button. Renders a transparent text link at the top on desktop
* and a floating transparent "glass" pill pinned above the tab bar on mobile
* the pattern set by the Cloud detail pages (PeerFiles/CloudFolder).
*
* Presentational only: it emits `click`; the parent keeps its own navigation
* logic (router.push / router.back / conditional goBack).
*/
withDefaults(
defineProps<{
label?: string
/** Desktop bottom-margin utility (views vary between mb-4 and mb-6). */
desktopMargin?: string
}>(),
{ label: 'Back', desktopMargin: 'mb-4' }
)
defineEmits<{ (e: 'click'): void }>()
</script>

View File

@ -0,0 +1,285 @@
<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>
<!-- ===================== Cashu Mints ===================== -->
<div v-show="activeTab === 'cashu'">
<p class="text-white/60 text-sm mb-4">
Cashu ecash tokens can only be received from mints in this list. Add a mint's URL to accept tokens issued by it.
</p>
<div v-if="loadingMints" class="py-6 text-center text-white/50 text-sm">Loading mints</div>
<template v-else>
<div class="space-y-2 mb-4">
<div
v-for="(mint, idx) in mints"
:key="mint + idx"
class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg class="w-5 h-5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm font-mono text-white/90 truncate">{{ mint }}</span>
</div>
<button
@click="removeMint(idx)"
:disabled="mints.length <= 1"
class="p-2 rounded-lg hover:bg-white/10 text-white/50 hover:text-red-400 transition-colors disabled:opacity-30 disabled:hover:text-white/50 disabled:hover:bg-transparent shrink-0"
aria-label="Remove mint"
title="Remove mint"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<p v-if="mints.length === 0" class="text-white/40 text-sm text-center py-2">No mints configured.</p>
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Add a mint</label>
<div class="flex gap-2">
<input
v-model="newMint"
type="text"
placeholder="https://mint.example.com"
class="flex-1 input-glass font-mono"
@keydown.enter.prevent="addMint"
/>
<button @click="addMint" class="glass-button px-4 py-2 rounded-lg text-sm font-medium shrink-0">Add</button>
</div>
</div>
<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>
<!-- ===================== Fedimint Federations ===================== -->
<div v-show="activeTab === 'fedimint'">
<div class="flex items-start gap-2 mb-4">
<p class="text-white/60 text-sm flex-1">
Join a Fedimint federation by pasting its invite code. Federated ecash is held by a group of guardians rather than a single mint.
</p>
<span v-if="!fedimintBackendReady" class="shrink-0 text-[10px] px-2 py-0.5 rounded-full font-medium bg-orange-500/15 text-orange-400">Coming soon</span>
</div>
<!-- Joined federations -->
<div class="space-y-2 mb-4">
<div
v-for="fed in federations"
:key="fed.federation_id"
class="flex items-center justify-between gap-3 p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<svg class="w-5 h-5 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm text-white/90 truncate">{{ fed.name || fed.federation_id }}</p>
<p class="text-[11px] text-white/40 font-mono truncate">{{ fed.federation_id }}</p>
</div>
</div>
<span class="text-sm text-blue-400 font-medium shrink-0">{{ fed.balance_sats.toLocaleString() }} sats</span>
</div>
<p v-if="federations.length === 0" class="text-white/40 text-sm text-center py-2">No federations joined yet.</p>
</div>
<!-- Join by invite code -->
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">Invite code</label>
<textarea
v-model="inviteCode"
rows="3"
:disabled="!fedimintBackendReady"
placeholder="fed11jpr3lgm8t…"
class="w-full input-glass font-mono disabled:opacity-50"
></textarea>
</div>
<div v-if="fedError" class="mb-3 alert-error">{{ fedError }}</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.
</p>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; changed: [] }>()
const tabs = [
{ key: 'cashu' as const, label: 'Cashu Mints' },
{ key: 'fedimint' as const, label: 'Fedimint Federations' },
]
const activeTab = ref<'cashu' | 'fedimint'>('cashu')
// Backed by wallet.fedimint-list / -join / -leave (fedimint-clientd HTTP bridge).
// Join degrades gracefully with a clear error if the Fedimint client app isn't installed.
const fedimintBackendReady = true
// ---- Cashu mints ----
const mints = ref<string[]>([])
const newMint = ref('')
const loadingMints = ref(false)
const savingMints = ref(false)
const mintError = ref('')
const mintsSavedOk = ref(false)
// ---- Fedimint federations ----
interface Federation {
federation_id: string
name?: string
balance_sats: number
}
const federations = ref<Federation[]>([])
const inviteCode = ref('')
const joiningFed = ref(false)
const fedError = ref('')
watch(
() => props.show,
(open) => {
if (open) {
loadMints()
if (fedimintBackendReady) loadFederations()
}
},
)
async function loadMints() {
loadingMints.value = true
mintError.value = ''
mintsSavedOk.value = false
newMint.value = ''
try {
const res = await rpcClient.call<{ mints: string[] }>({ method: 'streaming.list-mints' })
mints.value = res.mints || []
} catch (err: unknown) {
mintError.value = err instanceof Error ? err.message : 'Failed to load mints'
mints.value = []
} finally {
loadingMints.value = false
}
}
function addMint() {
mintError.value = ''
mintsSavedOk.value = false
const url = newMint.value.trim().replace(/\/+$/, '')
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
mintError.value = 'Mint URL must start with http:// or https://'
return
}
if (mints.value.some((m) => m.replace(/\/+$/, '') === url)) {
mintError.value = 'That mint is already in the list'
return
}
mints.value.push(url)
newMint.value = ''
}
function removeMint(idx: number) {
if (mints.value.length <= 1) return
mints.value.splice(idx, 1)
mintsSavedOk.value = false
}
async function saveMints() {
if (mints.value.length === 0) return
savingMints.value = true
mintError.value = ''
mintsSavedOk.value = false
try {
await rpcClient.call<{ mints: string[]; updated: boolean }>({
method: 'streaming.configure-mints',
params: { mints: mints.value },
})
mintsSavedOk.value = true
emit('changed')
} catch (err: unknown) {
mintError.value = err instanceof Error ? err.message : 'Failed to save mints'
} finally {
savingMints.value = false
}
}
async function loadFederations() {
fedError.value = ''
try {
const res = await rpcClient.call<{ federations: Federation[] }>({ method: 'wallet.fedimint-list' })
federations.value = res.federations || []
} catch {
federations.value = []
}
}
async function joinFederation() {
if (!fedimintBackendReady || !inviteCode.value.trim()) return
joiningFed.value = true
fedError.value = ''
try {
await rpcClient.call<{ federation_id: string }>({
method: 'wallet.fedimint-join',
params: { invite_code: inviteCode.value.trim() },
})
inviteCode.value = ''
await loadFederations()
emit('changed')
} catch (err: unknown) {
fedError.value = err instanceof Error ? err.message : 'Failed to join federation'
} finally {
joiningFed.value = false
}
}
function close() {
mintError.value = ''
mintsSavedOk.value = false
fedError.value = ''
emit('close')
}
</script>

View File

@ -4,6 +4,7 @@ import './style.css'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import { displayVersion } from '@/utils/version'
// Clipboard polyfill for HTTP (non-secure) contexts where navigator.clipboard is unavailable
if (!navigator.clipboard) {
@ -31,6 +32,11 @@ app.use(pinia)
app.use(router)
app.use(i18n)
// Global version formatter — normalizes version labels to a single "v" prefix
// (some sources already carry one, which produced "vv1.2.3"). Use `$ver(x)` in
// templates instead of hard-coding a `v` prefix.
app.config.globalProperties.$ver = displayVersion
app.config.errorHandler = (err, _instance, info) => {
console.error('[Vue Error]', err, info)
const { error } = useToast()

View File

@ -3,6 +3,26 @@ import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage'
import { requestExternalOpen } from '@/api/remote-relay'
/**
* Open a URL in a new browser tab but if a companion (phone) is currently
* driving this kiosk, hand the URL to the phone instead so it opens in the
* phone's browser rather than the (often headless / unattended) kiosk display.
* Falls back to a local `window.open` when no companion is active.
*/
function openExternal(launchUrl: string) {
// Resolve to an absolute URL so the phone can open it (window.open also
// handles absolute URLs fine).
let absolute = launchUrl
try {
absolute = new URL(launchUrl, window.location.origin).href
} catch {
/* keep as-is */
}
if (requestExternalOpen(absolute)) return
window.open(launchUrl, '_blank', 'noopener,noreferrer')
}
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
const NEW_TAB_PORTS = new Set([
@ -29,8 +49,16 @@ const NEW_TAB_APP_IDS = new Set([
'nginx-proxy-manager',
'uptime-kuma',
'gitea',
// netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
// PKCE), so it's served over HTTPS and must open in a real tab — a
// self-signed-HTTPS iframe is blocked by the browser (you can't accept the
// cert warning inside an iframe).
'netbird',
])
// Apps served over HTTPS (self-signed) rather than plain HTTP.
const HTTPS_APP_IDS = new Set(['netbird'])
function mustOpenInNewTab(url: string): boolean {
try {
const u = new URL(url)
@ -127,12 +155,16 @@ const APP_ID_TO_PORT: Record<string, string> = {
'nginx-proxy-manager': '8081',
'uptime-kuma': '3002',
gitea: '3001',
// Without this, directAppUrl('netbird') returns null and netbird falls
// through to the iframe (and never gets its https URL) — issue #15.
netbird: '8087',
}
function directAppUrl(appId: string): string | null {
const port = APP_ID_TO_PORT[appId]
if (!port || typeof window === 'undefined') return null
return `http://${window.location.hostname}:${port}`
const scheme = HTTPS_APP_IDS.has(appId) ? 'https' : 'http'
return `${scheme}://${window.location.hostname}:${port}`
}
@ -192,7 +224,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const mobile = isMobileViewport()
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl && !mobile) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
openExternal(launchUrl)
return
}
@ -212,12 +244,25 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Legacy: open app in iframe overlay (kept for backward compat) */
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
const titleHintId = inferAppIdFromTitle(payload.title)
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
// Apps served over HTTPS (e.g. netbird, which needs a secure context for
// its OIDC dashboard) must be launched over https — a stale http URL hits
// the TLS port and 400s. Upgrade the scheme defensively in every path.
if (resolvedId && HTTPS_APP_IDS.has(resolvedId)) {
try {
const u = new URL(launchUrl, window.location.origin)
if (u.protocol === 'http:') {
u.protocol = 'https:'
launchUrl = u.href
}
} catch { /* leave as-is */ }
}
if (!isMobileViewport() && payload.openInNewTab) {
if (resolvedId) recordAppLaunch(resolvedId)
window.open(launchUrl, '_blank', 'noopener,noreferrer')
openExternal(launchUrl)
return
}
@ -226,7 +271,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// native launchers and keep the user inside Archipelago.
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
recordAppLaunch(resolvedId)
window.open(launchUrl, '_blank', 'noopener,noreferrer')
openExternal(launchUrl)
return
}
@ -238,7 +283,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Unknown apps that block iframes — open directly in new tab
if (!isMobileViewport() && mustOpenInNewTab(launchUrl)) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
openExternal(launchUrl)
return
}

View File

@ -16,6 +16,9 @@ export interface MeshStatus {
messages_sent: number
messages_received: number
detected_devices?: string[]
/** Bitcoin block-header send/receive prefs (issue #28). */
announce_block_headers?: boolean
receive_block_headers?: boolean
}
export interface MeshPeer {

View File

@ -729,6 +729,36 @@ input[type="radio"]:active + * {
min-height: 36px;
}
/* Transparent "frosted" variant for back buttons the light counterpart to
the solid black .glass-button. Used by the floating mobile back pill so it
reads as transparent over content rather than a black slab. */
.back-button-glass {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid rgba(255, 255, 255, 0.16);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.92);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease, border-color 0.2s ease;
}
.back-button-glass:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.26);
}
.back-button-glass:active {
transform: translateY(1px);
}
/* Glass button color variants */
.glass-button-warning {
background: rgba(251, 146, 60, 0.2);

View File

@ -0,0 +1,22 @@
/**
* Format a version string for display with exactly one leading "v".
*
* Version strings reach the UI from several sources manifests (bare like
* "1.7.96"), node/federation state and the update RPC (sometimes already
* "v1.7.96"). Templates used to hard-code a `v` prefix (`v{{ version }}`),
* which produced "vv1.7.96" whenever the source already carried a "v". This
* normalizes both shapes to a single "v".
*/
export function displayVersion(v?: string | null): string {
if (v === null || v === undefined) return ''
const bare = String(v).trim().replace(/^v+/i, '')
return bare ? `v${bare}` : ''
}
// Exposed globally as `$ver` (see main.ts) so templates can normalize version
// labels without a per-file import.
declare module 'vue' {
interface ComponentCustomProperties {
$ver: (v?: string | null) => string
}
}

View File

@ -1,25 +1,6 @@
<template>
<div class="app-details-container pb-16 md:pb-16">
<!-- Desktop Back Button -->
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ backButtonText }}
</button>
<!-- Mobile Full-Width Back Button (teleported to escape CSS transform containing block) -->
<Teleport to="body">
<button
@click="goBack"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ backButtonText }}</span>
</button>
</Teleport>
<BackButton :label="backButtonText" desktop-margin="mb-6" @click="goBack" />
<div v-if="pkg">
<AppHeroSection
@ -101,6 +82,7 @@ import { useAppLauncherStore } from '../stores/appLauncher'
import { dummyApps } from '../utils/dummyApps'
import { rpcClient } from '@/api/rpc-client'
import type { AppCredentialsResponse } from '@/types/api'
import BackButton from '@/components/BackButton.vue'
import AppHeroSection from './appDetails/AppHeroSection.vue'
import AppContentSection from './appDetails/AppContentSection.vue'
import AppSidebar from './appDetails/AppSidebar.vue'

View File

@ -1,5 +1,6 @@
<template>
<div class="pb-6">
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
<p class="text-white/70">
@ -115,17 +116,6 @@
</ul>
</div>
<!-- Back link -->
<RouterLink
to="/dashboard/settings"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center justify-center gap-2 sm:w-auto"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Settings
</RouterLink>
<!-- Add-registry modal -->
<Teleport to="body">
<Transition name="fade">
@ -192,8 +182,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import BackButton from '@/components/BackButton.vue'
interface Registry {
url: string

View File

@ -2,25 +2,7 @@
<div class="cloud-folder-container flex flex-col h-full">
<!-- Desktop Back Button + Header -->
<div class="shrink-0 mb-4">
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ backLabel }}
</button>
<!-- Mobile Back Button (teleported to escape CSS transform containing block) -->
<Teleport to="body">
<button
@click="goBack"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ backLabel }}</span>
</button>
</Teleport>
<BackButton :label="backLabel" @click="goBack" />
<!-- Folder Header -->
<div class="flex items-center justify-between">
@ -172,6 +154,7 @@ import { ref, computed, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
import BackButton from '@/components/BackButton.vue'
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
import FileGrid from '../components/cloud/FileGrid.vue'
import ShareModal from '../components/cloud/ShareModal.vue'
@ -288,9 +271,17 @@ const section = computed(() => {
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
const iframeUrl = computed(() => section.value?.iframeUrl || '')
// Whether we're at the section's root folder. Derived from the route (the URL
// is the source of truth) rather than cloudStore.currentPath, which is async
// and still holds the previous/blank path on first entry that staleness is
// what made entering e.g. "Photos and videos" wrongly show "Back to Parent
// Folder" and break the back action.
const atSectionRoot = computed(() =>
!section.value || routeFolderPath.value === section.value.initialPath
)
const backLabel = computed(() => {
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
return cloudStore.currentPath !== section.value.initialPath ? 'Back to Parent Folder' : 'Back to Cloud'
return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder'
})
// Initialize native file browser when entering a native-UI section
@ -388,8 +379,8 @@ async function navigateCloudPath(path: string) {
}
function goBack() {
if (useNativeUI.value && section.value && cloudStore.currentPath !== section.value.initialPath) {
navigateCloudPath(parentCloudPath(cloudStore.currentPath))
if (useNativeUI.value && !atSectionRoot.value) {
navigateCloudPath(parentCloudPath(routeFolderPath.value))
return
}
router.push('/dashboard/cloud')

View File

@ -1,15 +1,7 @@
<template>
<div class="p-6">
<div class="mb-6">
<button
@click="$router.back()"
class="mb-4 flex items-center gap-2 text-white/70 hover:text-white transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ t('containerDetails.back') }}
</button>
<BackButton :label="t('containerDetails.back')" @click="$router.back()" />
<div class="flex items-start justify-between">
<div>
@ -135,6 +127,7 @@ import { useI18n } from 'vue-i18n'
import { useContainerStore } from '@/stores/container'
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
import ContainerStatus from '@/components/ContainerStatus.vue'
import BackButton from '@/components/BackButton.vue'
type ContainerStateValue =
| 'created'

View File

@ -1,12 +1,8 @@
<template>
<div class="pb-6">
<BackButton label="Back to Web5" @click="$router.push('/dashboard/web5')" />
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<button @click="$router.push('/dashboard/web5')" class="glass-button glass-button-sm px-3 py-1.5 text-sm">
Back
</button>
<h1 class="text-3xl font-bold text-white">Credentials</h1>
</div>
<h1 class="text-3xl font-bold text-white">Credentials</h1>
<p class="text-white/70">Issue, view, and verify W3C Verifiable Credentials</p>
</div>
@ -205,6 +201,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import BackButton from '@/components/BackButton.vue'
interface Identity {
id: string

View File

@ -174,8 +174,12 @@ const isGlitching = ref(false)
const backgroundImage = computed(() => {
const mapped = ROUTE_BACKGROUNDS[route.path]
if (mapped) return mapped
// Cloud subpages (folders) use the same background as Cloud
// Detail/sub pages inherit their parent tab's background so they stay
// visually "inside" the section instead of snapping to the home backdrop.
if (route.path.startsWith('/dashboard/cloud/')) return 'bg-cloud.jpg'
if (route.path.startsWith('/dashboard/web5/')) return 'bg-web5.jpg'
if (route.path.startsWith('/dashboard/server/')) return 'bg-web5.jpg'
if (route.path.startsWith('/dashboard/settings/')) return 'bg-settings.jpg'
if (isDetailRoute(route.path)) return 'bg-intro.jpg'
return 'bg-home.jpg'
})

View File

@ -143,7 +143,7 @@
<span v-if="installingApps.has(featuredBannerApp.id)">Installing...</span>
<span v-else>Install</span>
</button>
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} v{{ featuredBannerApp?.version }}</span>
<span class="text-white/40 text-sm">{{ featuredBannerApp?.title }} {{ $ver(featuredBannerApp?.version) }}</span>
</div>
</div>
</div>

View File

@ -1,24 +1,6 @@
<template>
<div class="pb-16 md:pb-6 mobile-scroll-pad">
<!-- Desktop Back Button -->
<button @click="router.push('/dashboard/web5')" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Web5
</button>
<!-- Mobile Back Button -->
<Teleport to="body">
<button
@click="router.push('/dashboard/web5')"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Web5</span>
</button>
</Teleport>
<BackButton label="Web5" desktop-margin="mb-6" @click="router.push('/dashboard/web5')" />
<!-- Header -->
<div class="hidden md:block mb-8">
@ -158,6 +140,7 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import BackButton from '@/components/BackButton.vue'
import FleetOverviewCards from './fleet/FleetOverviewCards.vue'
import FleetNodeGrid from './fleet/FleetNodeGrid.vue'
import FleetAlerts from './fleet/FleetAlerts.vue'

View File

@ -1,12 +1,6 @@
<template>
<div class="pb-6">
<!-- Back button -->
<button @click="goBack" class="flex items-center gap-2 text-white/60 hover:text-white mb-6 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ t('goalDetail.backToGoals') }}</span>
</button>
<BackButton :label="t('goalDetail.backToGoals')" desktop-margin="mb-6" @click="goBack" />
<!-- Goal not found -->
<div v-if="!goal" class="glass-card p-12 text-center">
@ -175,6 +169,7 @@ import { useGoalStore } from '@/stores/goals'
import { getGoalById } from '@/data/goals'
import type { GoalStep } from '@/types/goals'
import { goalStepTargetPath } from './goals/goalStepActions'
import BackButton from '@/components/BackButton.vue'
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {

View File

@ -132,11 +132,13 @@
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
:wallet-fedimint="walletFedimint"
:wallet-transactions="walletTransactions"
:is-dev="isDev"
@show-send="showSendModal = true"
@show-receive="showReceiveModal = true"
@show-transactions="showTransactionsModal = true"
@show-wallet-settings="showWalletSettingsModal = true"
@faucet="devFaucet"
@open-in-mempool="openInMempool"
/>
@ -274,6 +276,7 @@
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
</div>
</template>
@ -284,6 +287,7 @@ import { useI18n } from 'vue-i18n'
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
import TransactionsModal from '@/components/TransactionsModal.vue'
import WalletSettingsModal from '@/components/WalletSettingsModal.vue'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useLoginTransitionStore } from '../stores/loginTransition'
@ -486,11 +490,11 @@ onMounted(async () => {
})
// Wallet modals
const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false)
const showSendModal = ref(false); const showReceiveModal = ref(false); const showTransactionsModal = ref(false); const showWalletSettingsModal = ref(false)
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0)
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
const walletTransactions = ref<WalletTransaction[]>([])
function openInMempool(txHash: string) { router.push({ name: 'app-session', params: { appId: 'mempool' }, query: { path: `/tx/${txHash}` } }) }
@ -498,6 +502,7 @@ function openInMempool(txHash: string) { router.push({ name: 'app-session', para
async function loadWeb5Status() {
try { const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 }); walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true } catch { walletConnected.value = false; walletOnchain.value = 0; walletLightning.value = 0 }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 5000 }); walletEcash.value = res.balance_sats ?? 0 } catch { walletEcash.value = 0 }
try { const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance', timeout: 5000 }); walletFedimint.value = res.balance_sats ?? 0 } catch { walletFedimint.value = 0 }
try { const res = await rpcClient.call<{ transactions: WalletTransaction[]; incoming_pending_count: number }>({ method: 'lnd.gettransactions', timeout: 5000 }); walletTransactions.value = res.transactions || [] } catch { walletTransactions.value = [] }
}

View File

@ -1,27 +1,6 @@
<template>
<div class="app-details-container pb-16 md:pb-16">
<!-- Desktop Back Button -->
<button @click="goBack" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
{{ backButtonLabel }}
</button>
<!-- Mobile Full-Width Back Button -->
<button
@click="goBack"
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
:style="{
bottom: bottomPosition,
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
}"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>{{ backButtonLabel }}</span>
</button>
<BackButton :label="backButtonLabel" desktop-margin="mb-6" @click="goBack" />
<Transition name="content-fade" mode="out-in">
<!-- Loading State -->
@ -65,7 +44,7 @@
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5"></span>
{{ t('marketplaceDetails.installed') }}
</span>
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
</div>
</div>
@ -130,7 +109,7 @@
<span class="w-1.5 h-1.5 rounded-full bg-green-400 mr-1"></span>
{{ t('marketplaceDetails.installed') }}
</span>
<span class="text-white/50 text-xs">{{ app.version ? `v${app.version}` : 'latest' }}</span>
<span class="text-white/50 text-xs">{{ app.version ? $ver(app.version) : 'latest' }}</span>
</div>
</div>
</div>
@ -377,13 +356,12 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { rpcClient } from '../api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useMobileBackButton } from '../composables/useMobileBackButton'
import { useAppLauncherStore } from '../stores/appLauncher'
import BackButton from '@/components/BackButton.vue'
import { useToast } from '../composables/useToast'
import { handleImageError } from './apps/appsConfig'
const { t } = useI18n()
const { bottomPosition } = useMobileBackButton()
const toast = useToast()
const router = useRouter()

View File

@ -9,6 +9,7 @@ import MeshMap from '@/components/MeshMap.vue'
import MeshBitcoinPanel from '@/views/mesh/MeshBitcoinPanel.vue'
import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
import { rpcClient } from '@/api/rpc-client'
import { wsClient } from '@/api/websocket'
import '@/views/mesh/mesh-styles.css'
const mesh = useMeshStore()
@ -37,6 +38,7 @@ const connectingDevice = ref<string | null>(null)
const chatScrollEl = ref<HTMLElement | null>(null)
const mobileShowChat = ref(false)
let pollInterval: ReturnType<typeof setInterval> | null = null
let wsUnsub: (() => void) | null = null
// The Public channel (always available on Meshcore)
// "Public" maps to meshcore slot 1 the configured "archipelago" channel
@ -332,6 +334,14 @@ onMounted(async () => {
mesh.fetchDeadmanStatus()
mesh.fetchBlockHeaders()
}, 5000)
// Instant peer updates (#48): the backend nudges the data-model revision when
// it discovers/updates a mesh peer, so refetch peers on the WS push rather
// than waiting for the next 5s poll tick. The poll above stays as a backstop
// (e.g. for peers going offline, which isn't pushed).
wsUnsub = wsClient.subscribe(() => {
mesh.fetchPeers()
})
})
onUnmounted(() => {
@ -340,6 +350,7 @@ onUnmounted(() => {
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (pollInterval) clearInterval(pollInterval)
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
if (wsUnsub) { wsUnsub(); wsUnsub = null }
})
// Active chat name for the header

View File

@ -1,24 +1,6 @@
<template>
<div class="pb-16 md:pb-6">
<!-- Desktop Back Button -->
<button @click="router.push('/dashboard/web5')" class="hidden md:flex mb-6 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Web5
</button>
<!-- Mobile Back Button -->
<Teleport to="body">
<button
@click="router.push('/dashboard/web5')"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Web5</span>
</button>
</Teleport>
<BackButton :label="backLabel" desktop-margin="mb-6" @click="router.push(backTarget)" />
<div class="hidden md:block mb-8">
<div class="flex items-center justify-between">
@ -233,10 +215,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useHomeStatusStore } from '@/stores/homeStatus'
import BackButton from '@/components/BackButton.vue'
import LineChart from '@/components/LineChart.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import type { ChartDataset } from '@/components/LineChart.vue'
@ -297,7 +280,15 @@ interface FiredAlert {
}
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
// Monitoring is reachable from Web5 (the monitoring link) AND from the Home
// "System" card (which passes ?from=home). The back button follows the entry
// point so it returns where the user came from instead of always Web5.
const cameFromHome = computed(() => route.query.from === 'home')
const backTarget = computed(() => (cameFromHome.value ? '/dashboard' : '/dashboard/web5'))
const backLabel = computed(() => (cameFromHome.value ? t('common.back') : 'Web5'))
const homeStatus = useHomeStatusStore()
const current = ref<MetricSnapshot | null>(null)

View File

@ -42,6 +42,15 @@
</div>
</div>
<!-- FIPS activation status (non-blocking onboarding never waits on it) -->
<div v-if="fipsLabel" class="flex items-center justify-center gap-2 mb-4 text-xs sm:text-sm">
<span
class="w-2 h-2 rounded-full"
:class="fipsReady ? 'bg-green-400' : 'bg-orange-400 animate-pulse'"
></span>
<span class="text-white/60">{{ fipsLabel }}</span>
</div>
<!-- Set Password Button -->
<p class="text-xs text-white/50 mb-3">You'll create your node password next</p>
<button
@ -57,17 +66,56 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { playNavSound } from '@/composables/useNavSounds'
const router = useRouter()
const setPasswordButton = ref<HTMLButtonElement | null>(null)
// FIPS auto-activates in a detached task after the seed is written, so it can
// lag a few seconds behind onboarding on slow hardware. Surface a gentle status
// so the encrypted-transport bring-up reads as "in progress", never "stuck".
// This is purely informational it never blocks moving on to set a password.
const fipsLabel = ref('')
const fipsReady = ref(false)
let fipsTimer: ReturnType<typeof setTimeout> | null = null
let fipsTries = 0
async function pollFips() {
try {
const s = await rpcClient.call<{ key_present?: boolean; service_active?: boolean }>({
method: 'fips.status',
timeout: 8000,
})
// Only relevant once a seed-derived FIPS key exists; otherwise stay silent.
if (s.key_present) {
if (s.service_active) {
fipsReady.value = true
fipsLabel.value = 'Private connection ready'
return // settled stop polling
}
fipsLabel.value = 'Securing your private connection…'
}
} catch {
// Backend still booting ignore and keep polling.
}
fipsTries += 1
if (fipsTries < 20) {
fipsTimer = setTimeout(pollFips, 3000)
}
}
onMounted(() => {
setTimeout(() => {
setPasswordButton.value?.focus({ preventScroll: true })
}, 500)
pollFips()
})
onUnmounted(() => {
if (fipsTimer) clearTimeout(fipsTimer)
})
function goToLogin() {

View File

@ -31,8 +31,16 @@
<p v-else class="text-lg text-white/80">Generating your seed phrase...</p>
</div>
<!-- Error -->
<p v-if="errorMessage" class="text-red-400 text-sm">{{ errorMessage }}</p>
<!-- Error (genuine failure server-starting hiccups retry silently) -->
<div v-if="errorMessage" class="text-center">
<p class="text-red-400 text-sm mb-3">{{ errorMessage }}</p>
<button
@click="generateSeed"
class="path-action-button path-action-button--continue mx-auto"
>
Try again
</button>
</div>
<!-- Word Grid -->
<div v-if="words.length > 0" class="w-full max-w-[600px]">
@ -115,23 +123,42 @@ function stopTimers() {
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null }
}
// Transient errors mean the backend is still booting (slow first boot) we
// retry these silently. Anything else is a genuine failure the user should see.
function isServerStartingError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err)
return /502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)
}
async function generateSeed() {
loading.value = true
errorMessage.value = ''
try {
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate' })
// seed.generate is idempotent server-side, so retries are safe. Use a
// longer timeout than the default 15s key derivation + disk writes can be
// slow on first boot, and we don't want a spurious abort to look like a
// failure.
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.generate', timeout: 30000 })
stopTimers()
words.value = res.words
loading.value = false
waitingForServer.value = false
} catch {
} catch (err) {
loading.value = false
if (!waitingForServer.value) {
waitingForServer.value = true
startElapsedTimer()
if (isServerStartingError(err)) {
// Backend not ready yet keep waiting, retry silently.
if (!waitingForServer.value) {
waitingForServer.value = true
startElapsedTimer()
}
retryTimer = setTimeout(generateSeed, 4000)
} else {
// Genuine failure stop the silent loop and surface it with a manual retry.
stopTimers()
waitingForServer.value = false
errorMessage.value = err instanceof Error ? err.message : 'Failed to generate seed'
}
retryTimer = setTimeout(generateSeed, 4000)
}
}

View File

@ -258,7 +258,15 @@ async function verify() {
errorMessage.value = 'Verification failed. Please try again.'
}
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : 'Verification failed'
const msg = err instanceof Error ? err.message : ''
// Words already matched the local copy, so a network/server hiccup here is
// transient the backend just needs a moment. Don't alarm the user; let
// them tap Verify again.
if (/502|503|504|timeout|fetch|network|Failed to fetch|Request failed/i.test(msg)) {
errorMessage.value = 'Server is still starting. Please tap Verify again in a moment.'
} else {
errorMessage.value = msg || 'Verification failed'
}
} finally {
isVerifying.value = false
}

View File

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

View File

@ -1,5 +1,6 @@
<template>
<div class="pb-6">
<BackButton label="Back to Settings" desktop-margin="mb-6" @click="$router.push('/dashboard/settings')" />
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
@ -20,7 +21,7 @@
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">{{ t('common.version') }}</p>
<p class="text-xl font-bold text-white">v{{ currentVersion }}</p>
<p class="text-xl font-bold text-white">{{ $ver(currentVersion) }}</p>
</div>
<div class="p-4 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">{{ t('systemUpdate.lastChecked') }}</p>
@ -186,6 +187,62 @@
</div>
</div>
<!-- Update source (origin vs DHT swarm) -->
<div class="glass-card p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-1">Update &amp; app source</h2>
<p class="text-sm text-white/60 mb-4">
Where this node fetches updates and apps. <span class="text-white/80">Stable origin</span> is the
known-good default. <span class="text-white/80">DHT swarm</span> pulls content-addressed blobs from
peers first and falls back to the origin automatically use it to live-test the swarm; flip back any time.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
:disabled="sourceSaving"
class="text-left p-4 rounded-lg border transition-colors disabled:opacity-60"
:class="updateSource === 'origin' ? 'border-green-400/40 bg-green-500/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
@click="setSource('origin')"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-2.5 h-2.5 rounded-full" :class="updateSource === 'origin' ? 'bg-green-400' : 'bg-white/30'"></div>
<span class="text-sm font-medium text-white">Stable origin</span>
<span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/10 text-white/60">DEFAULT</span>
</div>
<p class="text-xs text-white/50">HTTP from the mirrors below. Safe, predictable, always available.</p>
</button>
<button
type="button"
:disabled="sourceSaving"
class="text-left p-4 rounded-lg border transition-colors disabled:opacity-60"
:class="updateSource === 'swarm' ? 'border-blue-400/40 bg-blue-500/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
@click="setSource('swarm')"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-2.5 h-2.5 rounded-full" :class="updateSource === 'swarm' ? 'bg-blue-400' : 'bg-white/30'"></div>
<span class="text-sm font-medium text-white">DHT swarm</span>
<span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300">EXPERIMENTAL</span>
</div>
<p class="text-xs text-white/50">Peer-to-peer first, origin fallback. For testing the distributed network.</p>
</button>
</div>
<p v-if="updateSource === 'swarm' && !swarmAvailable" class="mt-3 text-xs text-orange-300/90 flex items-start gap-1.5">
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>This build doesn't include the swarm engine, so DHT mode currently behaves exactly like origin. A swarm-enabled build is required for true peer fetching.</span>
</p>
<!-- Provide to the swarm (seed/serve to peers) -->
<div class="mt-5 pt-5 border-t border-white/10 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-white mb-0.5">Provide to the swarm</p>
<p class="text-xs text-white/50">
Let other nodes fetch updates and apps from this one (seeding). On by default so the network has providers.
Turn off if this node shouldn't serve content to peers — it won't change how this node fetches.
</p>
</div>
<ToggleSwitch :model-value="provideDht" :disabled="sourceSaving" @update:model-value="setProvide" />
</div>
</div>
<!-- Mirrors -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
@ -285,12 +342,6 @@
>
{{ t('systemUpdate.rollback') }}
</button>
<RouterLink
to="/dashboard/settings"
class="glass-button w-full rounded-lg px-5 py-2 text-sm font-medium text-center sm:w-auto"
>
{{ t('systemUpdate.backToSettings') }}
</RouterLink>
</div>
</div>
@ -451,9 +502,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import BitcoinFaceAscii from '@/views/discover/BitcoinFaceAscii.vue'
import BackButton from '@/components/BackButton.vue'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
interface UpdateDetail {
version: string
@ -491,6 +543,16 @@ const statusIsError = ref(false)
const downloadPercent = ref(0)
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
// Update/app fetch source: "origin" (HTTP mirrors, default/safe) vs "swarm"
// (DHT peer-assist with origin fallback). Lets us live-test the DHT on a node
// and flip back instantly. swarmAvailable reflects whether this binary was
// built with the iroh swarm engine at all.
const updateSource = ref<'origin' | 'swarm'>('origin')
const provideDht = ref(true)
const swarmAvailable = ref(false)
const swarmEnabled = ref(false)
const sourceSaving = ref(false)
// Mirrors servers this node tries for the manifest, in priority
// order. First entry is the primary. Add/remove/set-primary are wired
// to update.*-mirror RPCs; downloads automatically go to the mirror
@ -564,6 +626,52 @@ async function loadMirrors() {
}
}
interface SourceState { source: 'origin' | 'swarm'; provide_dht: boolean; swarm_available: boolean; swarm_enabled: boolean }
async function loadSource() {
try {
const res = await rpcClient.call<SourceState>({ method: 'update.get-source' })
updateSource.value = res.source
swarmAvailable.value = res.swarm_available
swarmEnabled.value = res.swarm_enabled
if (typeof res.provide_dht === 'boolean') provideDht.value = res.provide_dht
} catch (e) {
if (import.meta.env.DEV) console.warn('update.get-source failed', e)
}
}
async function setSource(source: 'origin' | 'swarm') {
if (sourceSaving.value || updateSource.value === source) return
sourceSaving.value = true
try {
const res = await rpcClient.call<SourceState>({ method: 'update.set-source', params: { source } })
updateSource.value = res.source
showStatus(source === 'swarm'
? 'Update source set to DHT swarm — origin still used as fallback.'
: 'Update source set to stable origin.')
} catch (e) {
showStatus(`Failed to change source: ${e instanceof Error ? e.message : e}`, true)
} finally {
sourceSaving.value = false
}
}
async function setProvide(provide: boolean) {
if (sourceSaving.value || provideDht.value === provide) return
sourceSaving.value = true
try {
const res = await rpcClient.call<SourceState>({ method: 'update.set-source', params: { provide } })
if (typeof res.provide_dht === 'boolean') provideDht.value = res.provide_dht
showStatus(provide
? 'This node will now seed updates/apps to peers.'
: 'This node will no longer provide to the swarm.')
} catch (e) {
showStatus(`Failed to change setting: ${e instanceof Error ? e.message : e}`, true)
} finally {
sourceSaving.value = false
}
}
async function submitMirror() {
const url = mirrorDraft.url.trim()
if (!url) return
@ -1013,7 +1121,7 @@ async function setSchedule(value: ScheduleValue) {
}
onMounted(async () => {
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), checkForUpdates()])
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), loadSource(), checkForUpdates()])
// If a download was already running when the user navigated here
// (or refreshed), pick up the progress bar where it is and keep
// polling until the backend reports done. No RPC call to start the

View File

@ -19,7 +19,7 @@
<span class="w-1.5 h-1.5 rounded-full mr-1 md:mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
<span class="text-white/50 text-xs">{{ $ver(pkg.manifest.version) }}</span>
</div>
</div>
@ -76,6 +76,7 @@ import type { PackageDataEntry } from '@/types/api'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
import { displayVersion } from '@/utils/version'
const { t } = useI18n()
@ -110,7 +111,7 @@ const actionItems = computed(() => {
actions.push({
key: 'update',
emit: 'update',
label: props.pendingAction === 'update' ? 'Updating...' : `Update to v${props.pkg['available-update']}`,
label: props.pendingAction === 'update' ? 'Updating...' : `Update to ${displayVersion(props.pkg['available-update'])}`,
class: 'bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30',
full: true,
})

View File

@ -7,9 +7,9 @@
<div class="flex items-center justify-between py-2 border-b border-white/10">
<span class="text-white/60 text-sm">{{ t('common.version') }}</span>
<div class="text-right">
<span class="text-white font-medium">v{{ pkg.manifest.version }}</span>
<span class="text-white font-medium">{{ $ver(pkg.manifest.version) }}</span>
<span v-if="pkg['available-update']" class="text-orange-300 text-xs ml-2">
v{{ pkg['available-update'] }} available
{{ $ver(pkg['available-update']) }} available
</span>
</div>
</div>

View File

@ -49,7 +49,7 @@
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-orange-500/20 text-orange-300 border border-orange-500/30"
>Update</span>
</div>
<p class="text-sm text-white/50">{{ version ? `v${version}` : '' }}</p>
<p class="text-sm text-white/50">{{ version ? $ver(version) : '' }}</p>
<p v-if="author" class="text-xs text-white/40 mt-0.5">{{ author }}</p>
</div>
</div>
@ -122,7 +122,7 @@
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click.stop="$emit('update', id)"
class="px-3 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-1.5 bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30 transition-colors"
:title="`Update to v${pkg['available-update']}`"
:title="`Update to ${$ver(pkg['available-update'])}`"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />

View File

@ -170,6 +170,8 @@ export const TAB_LAUNCH_APPS = new Set([
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
'cryptpad', 'nginx-proxy-manager', 'tailscale',
// netbird's dashboard needs HTTPS (secure context) so it opens in a new tab
'netbird',
])
export function opensInTab(id: string): boolean {

View File

@ -11,7 +11,7 @@
<AnimatedLogo />
<div class="min-w-0 flex-1">
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
<p class="text-xs text-white/60">v{{ version }}</p>
<p class="text-xs text-white/60">{{ $ver(version) }}</p>
</div>
</div>

View File

@ -91,6 +91,10 @@ export function useRouteTransitions() {
const wasFleet = previousPath === '/dashboard/fleet'
const isWeb5 = currentPath === '/dashboard/web5'
const wasWeb5 = previousPath === '/dashboard/web5'
// Any Web5 sub-detail (networking-profits, credentials, …) animates as a
// depth push from/back-to the Web5 tab — same feel as Find Nodes.
const isWeb5Detail = currentPath.startsWith('/dashboard/web5/')
const wasWeb5Detail = previousPath.startsWith('/dashboard/web5/')
let transitionName = 'fade'
@ -146,6 +150,10 @@ export function useRouteTransitions() {
transitionName = 'depth-forward'
} else if (wasFleet && isWeb5) {
transitionName = 'depth-back'
} else if (wasWeb5 && isWeb5Detail) {
transitionName = 'depth-forward'
} else if (wasWeb5Detail && isWeb5) {
transitionName = 'depth-back'
} else if (wasMarketplaceList && isAppDetails) {
transitionName = 'depth-forward'
} else if (wasAppDetails && isMarketplaceList) {

View File

@ -57,7 +57,7 @@
:class="getAppTier(app.id) === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ getAppTier(app.id) }}</span>
</div>
<p class="text-sm text-white/50">{{ app.version ? `v${app.version}` : 'latest' }}</p>
<p class="text-sm text-white/50">{{ app.version ? $ver(app.version) : 'latest' }}</p>
<p v-if="app.author" class="text-xs text-white/40 mt-0.5">{{ app.author }}</p>
</div>
</div>

View File

@ -37,7 +37,7 @@
>{{ getAppTier(app.id) }}</span>
<span v-if="isInstalled(app.id)" class="discover-installed-badge">installed</span>
</div>
<p class="text-white/50 text-sm mb-3">{{ app.author }} &middot; v{{ app.version }}</p>
<p class="text-white/50 text-sm mb-3">{{ app.author }} &middot; {{ $ver(app.version) }}</p>
<p class="text-white/80 text-sm leading-relaxed">{{ app.featuredDescription }}</p>
</div>
</div>

View File

@ -1,25 +1,6 @@
<template>
<div class="mb-6">
<button
@click="router.push('/dashboard/web5')"
class="hidden md:flex items-center gap-2 text-white/70 hover:text-white transition-colors mb-4"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Web5
</button>
<Teleport to="body">
<button
@click="router.push('/dashboard/web5')"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Web5</span>
</button>
</Teleport>
<BackButton label="Web5" @click="router.push('/dashboard/web5')" />
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Federation & Peers</h1>
@ -52,6 +33,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import BackButton from '@/components/BackButton.vue'
import { shortDid } from './utils'
import { safeClipboardWrite } from '../web5/utils'

View File

@ -19,7 +19,7 @@
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
<p class="text-lg font-bold text-white">v{{ node.version }}</p>
<p class="text-lg font-bold text-white">{{ $ver(node.version) }}</p>
</div>
<div class="monitoring-stat-card">
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>

View File

@ -36,7 +36,7 @@
></span>
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
</div>
<span class="fleet-version-badge">v{{ node.version }}</span>
<span class="fleet-version-badge">{{ $ver(node.version) }}</span>
</div>
<div class="mb-3 truncate text-xs text-white/40">
{{ fleetNodeSubtitle(node) }}

View File

@ -13,7 +13,7 @@
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.system') }}</h2>
<p class="text-sm text-white/70">{{ uptimeDisplay }}</p>
</div>
<RouterLink to="/dashboard/monitoring" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
<RouterLink to="/dashboard/monitoring?from=home" :aria-label="t('home.goToSettings')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>

View File

@ -40,11 +40,17 @@
<span>Transactions</span>
</template>
</button>
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
<button
@click="$emit('showWalletSettings')"
aria-label="Wallet settings"
title="Wallet settings"
class="text-white/60 hover:text-white transition-colors"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</RouterLink>
</button>
</div>
</div>
@ -119,10 +125,19 @@
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm text-white/80">{{ t('web5.ecash') }}</span>
<span class="text-sm text-white/80">Cashu</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
</svg>
<span class="text-sm text-white/80">Fedimint</span>
</div>
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
</div>
</div>
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
<button @click="$emit('showSend')" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
@ -145,7 +160,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@ -168,6 +182,7 @@ const props = defineProps<{
walletOnchain: number
walletLightning: number
walletEcash: number
walletFedimint: number
walletTransactions: WalletTransaction[]
isDev: boolean
}>()
@ -176,6 +191,7 @@ defineEmits<{
showSend: []
showReceive: []
showTransactions: []
showWalletSettings: []
faucet: []
openInMempool: [txHash: string]
}>()

View File

@ -26,7 +26,7 @@
:class="tierLabel === 'core' ? 'tier-badge-core' : 'tier-badge-recommended'"
>{{ tierLabel }}</span>
</h3>
<p class="text-sm text-white/60">{{ app.version ? `v${app.version}` : 'latest' }}</p>
<p class="text-sm text-white/60">{{ app.version ? $ver(app.version) : 'latest' }}</p>
<p v-if="app.author" class="text-xs text-white/50 mt-1">by {{ app.author }}</p>
</div>
</div>

View File

@ -1,10 +1,36 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useMeshStore } from '@/stores/mesh'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const mesh = useMeshStore()
// Bitcoin-headers-over-mesh send/receive toggles (issue #28). Initialized from
// mesh.status (which now carries the persisted prefs) and saved via mesh.configure.
const announceHeaders = ref(false)
const receiveHeaders = ref(true)
const headersSaving = ref(false)
watch(
() => mesh.status,
(s) => {
if (!s) return
if (typeof s.announce_block_headers === 'boolean') announceHeaders.value = s.announce_block_headers
if (typeof s.receive_block_headers === 'boolean') receiveHeaders.value = s.receive_block_headers
},
{ immediate: true, deep: true }
)
async function setAnnounceHeaders(v: boolean) {
announceHeaders.value = v
headersSaving.value = true
try { await mesh.configure({ announce_block_headers: v }) } catch { /* surfaced via store error */ } finally { headersSaving.value = false }
}
async function setReceiveHeaders(v: boolean) {
receiveHeaders.value = v
headersSaving.value = true
try { await mesh.configure({ receive_block_headers: v }) } catch { /* surfaced via store error */ } finally { headersSaving.value = false }
}
const txHexInput = ref('')
const bolt11Input = ref('')
const bolt11AmountInput = ref('')
@ -120,6 +146,22 @@ async function handleRelayLightning() {
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
</div>
</div>
<!-- Header send/receive toggles (issue #28) -->
<div class="flex items-center justify-between gap-3 pt-3 mt-2 border-t border-white/10">
<div class="min-w-0">
<span class="mesh-bitcoin-label">Send headers</span>
<small class="mesh-bitcoin-hint">Broadcast new block headers to mesh peers (needs internet)</small>
</div>
<ToggleSwitch :model-value="announceHeaders" :disabled="headersSaving" @update:model-value="setAnnounceHeaders" />
</div>
<div class="flex items-center justify-between gap-3 pt-3">
<div class="min-w-0">
<span class="mesh-bitcoin-label">Receive headers</span>
<small class="mesh-bitcoin-hint">Accept block headers relayed by peers</small>
</div>
<ToggleSwitch :model-value="receiveHeaders" :disabled="headersSaving" @update:model-value="setReceiveHeaders" />
</div>
</div>
<!-- On-Chain / Lightning tabs -->

View File

@ -126,6 +126,61 @@ async function deleteBackup(id: string) {
}
}
// Recovery phrase reveal re-auth gated (password + 2FA when enabled).
const showRevealModal = ref(false)
const revealPassword = ref('')
const revealCode = ref('')
const revealPassphrase = ref('')
const revealing = ref(false)
const revealError = ref('')
const revealedWords = ref<string[]>([])
const wordsHidden = ref(true)
const wordsCopied = ref(false)
function openReveal() {
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
revealError.value = ''
revealedWords.value = []
wordsHidden.value = true
showRevealModal.value = true
}
async function submitReveal() {
if (revealing.value || !revealPassword.value) return
revealing.value = true
revealError.value = ''
try {
const params: Record<string, string> = { password: revealPassword.value }
if (revealCode.value) params.code = revealCode.value
if (revealPassphrase.value) params.passphrase = revealPassphrase.value
const res = await rpcClient.call<{ words: string[] }>({ method: 'seed.reveal', params })
revealedWords.value = res.words || []
wordsHidden.value = true
} catch (e: unknown) {
revealError.value = e instanceof Error ? e.message : t('settings.revealSeedFailed')
} finally {
revealing.value = false
}
}
function closeReveal() {
showRevealModal.value = false
revealedWords.value = []
revealPassword.value = ''
revealCode.value = ''
revealPassphrase.value = ''
}
async function copyRevealedWords() {
try {
await navigator.clipboard.writeText(revealedWords.value.join(' '))
wordsCopied.value = true
setTimeout(() => { wordsCopied.value = false }, 2000)
} catch { /* clipboard unavailable */ }
}
// USB Drive Backup
interface UsbDriveInfo {
device: string
@ -198,6 +253,80 @@ defineExpose({ loadBackups })
</script>
<template>
<!-- Recovery phrase Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white/96 mb-1">Recovery phrase</h2>
<p class="text-sm text-white/60">
View this node's 24-word recovery phrase. You'll need to confirm your password
(and 2FA code, if enabled). Only reveal it somewhere private anyone with these
words controls this node.
</p>
</div>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="openReveal"
>Reveal</button>
</div>
</div>
<!-- Reveal recovery phrase modal -->
<Teleport to="body">
<div v-if="showRevealModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeReveal">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="reveal-seed-title">
<h3 id="reveal-seed-title" class="text-lg font-semibold text-white mb-1">Reveal recovery phrase</h3>
<template v-if="revealedWords.length === 0">
<p class="text-sm text-white/60 mb-4">Confirm your credentials to display the 24-word phrase.</p>
<form @submit.prevent="submitReveal" class="space-y-3">
<div>
<label class="block text-xs text-white/60 mb-1">Password</label>
<input v-model="revealPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label>
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" />
</div>
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
<div class="flex gap-2 pt-1">
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
<button type="submit" :disabled="revealing || !revealPassword" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50">
{{ revealing ? 'Verifying…' : 'Reveal' }}
</button>
</div>
</form>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="wordsHidden ? 'blur-md' : ''"
@click="wordsHidden = !wordsHidden"
>
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
</div>
<div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
</div>
</template>
</div>
</div>
</Teleport>
<!-- Backup & Restore Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="mb-4">

View File

@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
import BackButton from '@/components/BackButton.vue'
const router = useRouter()
@ -117,11 +118,7 @@ onMounted(load)
<template>
<div class="pb-6">
<button
type="button"
class="text-xs px-3 py-1.5 mb-4 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
@click="router.push('/dashboard/web5')"
> Back to Web5</button>
<BackButton label="Back to Web5" @click="router.push('/dashboard/web5')" />
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits Settings</h1>

View File

@ -115,6 +115,7 @@ export default defineConfig({
}
},
server: {
host: true, // listen on 0.0.0.0 so the dev UI is reachable over the LAN (e.g. http://192.168.1.116:8100)
port: 8100,
proxy: {
'/rpc/v1': {