fix: harden node upgrades and prepare 1.9.0-alpha

This commit is contained in:
archipelago
2026-10-05 12:43:49 -04:00
parent 138a541d01
commit daac47cac4
129 changed files with 9910 additions and 794 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.22-alpha",
"version": "1.9.0-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.22-alpha",
"version": "1.9.0-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.22-alpha",
"version": "1.9.0-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -10,7 +10,7 @@
"test:watch": "vitest",
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
"dev": "vite",
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI \u2014 chat will show placeholder'\"",
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
"backend:mock": "node mock-backend.js",
+23
View File
@@ -0,0 +1,23 @@
<template>
<main class="min-h-screen text-white p-6">
<div class="max-w-md mx-auto py-8 space-y-4">
<p class="text-xs text-orange-200">UI PREVIEW · SAMPLE DATA · NO WALLET ACCESS</p>
<h1 class="text-2xl font-semibold">Bump a transaction</h1>
<p class="text-sm text-white/55">Open the transaction list and tap the small Bump button. You can try both supported methods and the custom fee review without spending anything.</p>
<div class="flex gap-2">
<button class="rounded-lg px-4 py-2 bg-white/10 text-sm" @click="open('cpfp')">Preview CPFP</button>
<button class="rounded-lg px-4 py-2 bg-white/10 text-sm" @click="open('rbf')">Preview RBF</button>
</div>
</div>
<TransactionsModal :key="revision" :show="show" :transactions="transactions" @close="show = false" />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TransactionsModal from '@/components/TransactionsModal.vue'
import { choose } from './rpc'
const show = ref(false)
const revision = ref(0)
const transactions = [{ tx_hash: 'a'.repeat(64), amount_sats: 161794, direction: 'outgoing' as const, num_confirmations: 0, time_stamp: Math.floor(Date.now()/1000)-120, total_fees: 144, dest_addresses: [], label: '', block_height: 0 }]
function open(method: string) { choose(method); revision.value++; show.value = true }
</script>
+1
View File
@@ -0,0 +1 @@
export function useTxExplorer() { return { openTx() {} } }
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Archy · Bump preview</title></head><body style="background:#080808"><div id="app"></div><script type="module" src="./main.ts"></script></body></html>
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { createRouter, createWebHashHistory } from 'vue-router'
import '../../src/style.css'
import Preview from './Preview.vue'
const router = createRouter({ history: createWebHashHistory(), routes: [{path: '/:pathMatch(.*)*', component: {template: '<div />'}}] })
createApp(Preview).use(router).use(createI18n({ legacy: false, locale: 'en', messages: {en: {common: {done: 'Done', copy: 'Copy'}, transactions: {title: 'Transactions', unconfirmed: 'Pending', minutesAgo: '{count}m ago', confirmations: '{count} confirmations'}}} })).mount('#app')
+19
View File
@@ -0,0 +1,19 @@
// Standalone preview only. No network calls and no wallet access.
export let method = 'cpfp'
let submitted = false
let sweepFee = 618
export function choose(value: string) { method = value; submitted = false }
export const rpcClient = { async call(request: { method: string; params?: Record<string, unknown> }) {
if (request.method === 'lnd.bump-status') return submitted
? { status: 'mempool', message: 'Preview: fee bump accepted. No real transaction was sent.', bump_txid: 'b'.repeat(64), actual_sweep_fee_sats: sweepFee, quote: { method } }
: { status: 'none' }
if (request.method === 'lnd.bump-submit') { submitted = true; return { status: 'registered', message: 'Preview: bump registered. No real transaction was sent.' } }
if (request.method !== 'lnd.bump-quote') throw new Error('Wallet access is disabled in this preview')
const rate = Number(request.params?.sat_per_vbyte || 3)
const budget = Math.max(rate * 254 - 144, method === 'rbf' ? 763 : 112)
sweepFee = budget
return { quote_id: 'preview', txid: request.params?.txid, expires_at: Math.floor(Date.now()/1000)+60,
method, recipient_sats: 161650, current_fee_sats: method === 'rbf' ? 794 : 144,
additional_fee_sats: budget - (method === 'rbf' ? 650 : 0), total_fee_sats: 144 + budget,
budget_sats: budget, rate_sat_vb: rate }
} }
+6 -6
View File
@@ -436,13 +436,13 @@
{
"id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager",
"version": "2.12.1",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"version": "2.14.0",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.",
"icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08",
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
},
{
@@ -648,9 +648,9 @@
{
"id": "angor-indexer",
"title": "Angor Indexer",
"version": "1.0.1",
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
"version": "1.0.2",
"description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
"author": "Angor / Archipelago",
"requires": [
"Mempool API",
+2 -3
View File
@@ -19,8 +19,6 @@
<AppLauncherOverlay />
<AppCredentialInterstitial />
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
<!-- Global toast notifications -->
<ToastStack />
@@ -98,7 +96,7 @@
</template>
<script setup lang="ts">
import UploadProgress from '@/components/cloud/UploadProgress.vue'
import { useUploadNotifications } from '@/composables/useUploadNotifications'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue'
@@ -248,6 +246,7 @@ function onKeyDown(e: KeyboardEvent) {
}
const route = useRoute()
useUploadNotifications()
const isSignerBroker = computed(() => route.meta.signerBroker === true)
// Start with splash hidden — onMounted decides whether to show it
const showSplash = ref(false)
@@ -46,9 +46,45 @@ describe('FileBrowserClient', () => {
beforeEach(() => {
mockFetch.mockReset()
;(fileBrowserClient as any)._authenticated = false
;(fileBrowserClient as any)._lastLoginFailure = 0
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
})
it('allows a failed network login to recover immediately instead of caching an auth rejection', async () => {
;(fileBrowserClient as any)._lastLoginFailure = 0
mockFetch.mockRejectedValueOnce(new TypeError('Network disconnected'))
await expect(fileBrowserClient.listDirectory('/')).rejects.toBeInstanceOf(TypeError)
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'reconnected-token' } }))
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [] }))
await expect(fileBrowserClient.listDirectory('/')).resolves.toEqual([])
})
it('keeps an interrupted refresh retryable when another screen requests login concurrently', async () => {
let disconnect!: (error: Error) => void
mockFetch.mockImplementationOnce(() => new Promise((_resolve, reject) => { disconnect = reject }))
const listing = fileBrowserClient.listDirectory('/')
const assertion = expect(listing).rejects.toBeInstanceOf(TypeError)
disconnect(new TypeError('Connection reset'))
let second!: Promise<boolean>
mockFetch.mockResolvedValue(jsonResponse({ result: { token: 'reconnected-token' } }))
queueMicrotask(() => { second = fileBrowserClient.login() })
await assertion
await second
})
it('encodes literal filename punctuation for listing, reading and deleting', async () => {
setAuthenticated()
mockFetch.mockResolvedValue(jsonResponse({ items: [] }))
const path = '/a + 100% ? # ü.txt'
const encoded = '/a%20%2B%20100%25%20%3F%20%23%20%C3%BC.txt'
await fileBrowserClient.listDirectory(path)
expect(mockFetch.mock.calls[0]![0]).toContain('/app/filebrowser/api/resources' + encoded)
await fileBrowserClient.deleteItem(path)
expect(mockFetch.mock.calls[1]![0]).toContain('/app/filebrowser/api/resources' + encoded)
expect(fileBrowserClient.downloadUrl(path)).toContain('/app/filebrowser/api/raw' + encoded)
expect(await fileBrowserClient.streamUrl(path)).toContain('/app/filebrowser/api/raw' + encoded)
})
describe('login', () => {
it('authenticates via backend RPC and stores token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } }))
@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resumableUpload } from '../resumable-upload'
const MiB = 1024 * 1024
const hash = 'a'.repeat(64)
function fixture(size = 5 * MiB, name = 'resume.txt') {
const file = new File([new Uint8Array(size)], name)
const controller = new AbortController()
const progress: number[] = []
const paused: boolean[] = []
let stage = false, active = false, bytes = 0, target = false
const patches: number[] = []
const methods: string[] = []
let hook: ((url: URL, init: RequestInit) => Response | void | Promise<Response | void>) | undefined
const response = (status: number, data?: unknown, headers?: Record<string, string>) => new Response(data === undefined ? null : JSON.stringify(data), { status, headers: { ...(data ? { 'content-type': 'application/json' } : {}), ...headers } })
const transport = vi.fn(async (input: string, init: RequestInit = {}) => {
const url = new URL(input)
methods.push(init.method || 'GET')
const special = await hook?.(url, init)
if (special) return special
if (url.pathname.includes('/api/tus/')) {
if (init.method === 'HEAD') return active ? response(200, undefined, { 'Upload-Offset': String(bytes), 'Upload-Length': String(size) }) : response(404)
if (init.method === 'POST') {
expect(url.searchParams.get('override')).not.toBe('true')
if (stage) return response(409)
stage = active = true; return response(201)
}
if (init.method === 'PATCH') {
const offset = Number((init.headers as Record<string, string>)['Upload-Offset'])
patches.push(offset)
if (!active) return response(404)
if (offset !== bytes) return response(409)
bytes += (init.body as Blob).size
if (bytes === size) active = false
return response(204, undefined, { 'Upload-Offset': String(bytes) })
}
if (init.method === 'DELETE') { if (!active) return response(404); stage = active = false; return response(204) }
}
if (init.method === 'PATCH') { expect(decodeURIComponent(url.searchParams.get('destination')!)).toBe('/folder/' + name); target = true; stage = false; return response(200) }
if (init.method === 'DELETE') { expect(url.pathname).toContain('.archy-upload-'); stage = false; return response(200) }
if (url.pathname.includes('.archy-upload-') ? stage : target) return response(200, { size: bytes, isDir: false, checksums: { sha256: hash } })
return response(404)
})
return {
file, controller, progress, paused, patches, methods, transport, response,
setHook: (fn: typeof hook) => { hook = fn },
createStage: () => { stage = active = true },
setBytes: (n: number) => { bytes = n },
finishStage: () => { bytes = size; active = false },
rename: () => { target = true; stage = false },
hasStage: () => stage, hasTarget: () => target,
run: () => resumableUpload('https://node/app/filebrowser', '/folder', file, transport, { signal: controller.signal, onProgress: n => progress.push(n), onPaused: p => paused.push(p) }),
}
}
afterEach(() => vi.useRealTimers())
describe('resumable Cloud upload', () => {
it('uploads bounded chunks and publishes only complete verified bytes', async () => {
const f = fixture(); await f.run()
expect(f.patches).toEqual([0, 2 * MiB, 4 * MiB])
expect(f.hasStage()).toBe(false); expect(f.hasTarget()).toBe(true)
expect(f.progress[f.progress.length - 1]).toBe(f.file.size)
})
it('resumes after partial network write at the server offset, never truncating', async () => {
vi.useFakeTimers(); const f = fixture(); let lost = false
f.setHook(async (url, init) => {
if (url.pathname.includes('/api/tus/') && init.method === 'PATCH' && !lost) {
lost = true; f.setBytes(123456); throw new TypeError('Network lost')
}
})
const run = f.run(); await vi.runAllTimersAsync(); await run
expect(f.patches[0]).toBe(123456)
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
expect(f.paused).toContain(true); expect(f.paused[f.paused.length - 1]).toBe(false)
})
it('recovers a lost final chunk response after TUS completion removes the session', async () => {
vi.useFakeTimers(); const f = fixture(1024); let lost = false
f.setHook(async (url, init) => {
if (url.pathname.includes('/api/tus/') && init.method === 'PATCH' && !lost) {
lost = true; f.finishStage(); throw new TypeError('Reply lost')
}
})
const run = f.run(); await vi.runAllTimersAsync(); await run
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1); expect(f.hasTarget()).toBe(true)
})
it('confirms a lost rename response using exact checksum', async () => {
vi.useFakeTimers(); const f = fixture(1024); let lost = false
f.setHook(async (url, init) => {
if (url.pathname.includes('/api/resources/') && init.method === 'PATCH' && !lost) {
lost = true; f.rename(); throw new TypeError('Reply lost')
}
})
const run = f.run(); await vi.runAllTimersAsync(); await run
expect(f.hasTarget()).toBe(true)
expect(f.transport.mock.calls.some(([url]) => url.includes('resume.txt?checksum=sha256'))).toBe(true)
})
it('never accepts a same-size destination with different content after ambiguous rename', async () => {
vi.useFakeTimers(); const f = fixture(1024)
f.setHook(async (url, init) => {
if (url.pathname.includes('/api/resources/') && init.method === 'PATCH') { f.rename(); throw new TypeError('Reply lost') }
if (url.pathname.endsWith('/resume.txt')) return f.response(200, { size: 1024, checksums: { sha256: 'b'.repeat(64) } })
})
const result = expect(f.run()).rejects.toThrow('Cannot confirm'); await vi.runAllTimersAsync(); await result
})
it('reconciles a lost creation reply without creating or truncating a second upload', async () => {
vi.useFakeTimers(); const f = fixture(1024); let created = false
f.setHook(async (_, init) => {
if (init.method === 'POST' && !created) { created = true; f.createStage(); throw new TypeError('Creation reply lost') }
})
const run = f.run(); await vi.runAllTimersAsync(); await run
expect(f.hasTarget()).toBe(true); expect(f.patches).toEqual([0]); expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
})
it('does not silently restart an expired partially saved session', async () => {
const f = fixture(); let patched = false
f.setHook(async (_, init) => {
if (init.method === 'PATCH') patched = true
if (init.method === 'HEAD' && patched) return f.response(404)
})
await expect(f.run()).rejects.toThrow('session expired')
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
expect(f.hasTarget()).toBe(false)
})
it('wakes immediately when the network returns and cleans up wake listeners', async () => {
vi.useFakeTimers(); const f = fixture(1024); let offline = true
f.setHook(async (_, init) => { if (init.method === 'PATCH' && offline) throw new TypeError('Offline') })
const run = f.run(); await vi.advanceTimersByTimeAsync(0)
expect(f.paused).toContain(true)
offline = false; window.dispatchEvent(new Event('online'))
await vi.advanceTimersByTimeAsync(0); await run
expect(vi.getTimerCount()).toBe(0); expect(f.hasTarget()).toBe(true)
})
it('handles empty files and encoded filenames', async () => {
const f = fixture(0, 'a + 100% ? ü.txt'); await f.run(); expect(f.hasTarget()).toBe(true); expect(f.patches).toEqual([])
})
it.each([401, 403, 507])('stops on permanent HTTP %s without endless retry', async status => {
const f = fixture(); f.setHook(async () => f.response(status))
await expect(f.run()).rejects.toThrow(status === 507 ? 'storage' : 'access denied')
expect(f.paused).not.toContain(true)
})
it('rejects HTML login interception', async () => {
const f = fixture(); f.setHook(async () => new Response('<html>', { headers: { 'content-type': 'text/html' } }))
await expect(f.run()).rejects.toThrow('working File Browser')
})
it('rejects corrupt server offsets', async () => {
const f = fixture(); f.setHook(async (_, init) => init.method === 'HEAD' ? f.response(200, undefined, { 'Upload-Offset': '-1', 'Upload-Length': String(f.file.size) }) : undefined)
await expect(f.run()).rejects.toThrow('invalid upload position')
})
it('cancels during retry and removes only its staging file', async () => {
const f = fixture(); f.setHook(async (_, init) => {
if (init.method === 'PATCH') { queueMicrotask(() => f.controller.abort()); throw new TypeError('Offline') }
})
await expect(f.run()).rejects.toMatchObject({ name: 'AbortError' })
expect(f.hasStage()).toBe(false); expect(f.hasTarget()).toBe(false)
})
})
+11 -1
View File
@@ -320,7 +320,17 @@ describe('RPCClient convenience methods', () => {
mockSuccess({ psbt_base64: 'psbt', change_output_index: 0, total_amount_sats: 1000, fee_rate_sat_per_vbyte: 10 })
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] })
expect(getLastMethod()).toBe('lnd.create-psbt')
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(10)
expect(getLastParams()).not.toHaveProperty('fee_rate_sat_per_vbyte')
})
it('preserves the explicit hardware-wallet fee rate', async () => {
mockSuccess({ psbt_base64: 'psbt', fee_rate_sat_per_vbyte: 17 })
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }], feeRateSatPerVbyte: 17 })
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(17)
})
it.each([NaN, Infinity, 0, -1, 0.5, 5001])('rejects invalid PSBT rate %s rather than silently selecting a default', async rate => {
await expect(rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }], feeRateSatPerVbyte: rate })).rejects.toThrow('whole number')
})
it('finalizePsbt calls lnd.finalize-psbt', async () => {
+45 -16
View File
@@ -1,3 +1,5 @@
import { resumableUpload, type ResumableUploadOptions } from './resumable-upload'
export interface FileBrowserItem {
name: string
path: string
@@ -35,8 +37,11 @@ export function sanitizePath(path: string): string {
return '/' + resolved.join('/')
}
const encodeFilePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
class FileBrowserClient {
private _authenticated = false
private _loginPromise: Promise<{ authenticated: boolean; retryable: boolean }> | null = null
private baseUrl: string
constructor() {
@@ -53,6 +58,20 @@ class FileBrowserClient {
}
async login(): Promise<boolean> {
return (await this.authenticate()).authenticated
}
private authenticate(): Promise<{ authenticated: boolean; retryable: boolean }> {
// Folder polling and uploads can refresh together after returning online.
// Share one request and keep its failure classification in its result;
// another refresh must not turn a disconnected request into an auth denial.
if (!this._loginPromise) {
this._loginPromise = this.performLogin().finally(() => { this._loginPromise = null })
}
return this._loginPromise
}
private async performLogin(): Promise<{ authenticated: boolean; retryable: boolean }> {
try {
// Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser)
// Use credentials: 'include' and CSRF token for proper auth
@@ -67,18 +86,21 @@ class FileBrowserClient {
body: JSON.stringify({ method: 'app.filebrowser-token' }),
credentials: 'include',
})
if (!rpcRes.ok) return false
if (!rpcRes.ok) {
return { authenticated: false, retryable: rpcRes.status >= 500 || rpcRes.status === 408 || rpcRes.status === 429 }
}
const rpcData = await rpcRes.json()
const token = rpcData?.result?.token
if (!token) return false
if (!token) return { authenticated: false, retryable: false }
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = `auth=${token}; path=/; SameSite=Lax${secure}; expires=${expires}`
this._authenticated = true
return true
this._lastLoginFailure = 0
return { authenticated: true, retryable: false }
} catch {
return false
return { authenticated: false, retryable: true }
}
}
@@ -101,8 +123,11 @@ class FileBrowserClient {
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
const ok = await this.login()
if (!ok) {
const outcome = await this.authenticate()
if (!outcome.authenticated) {
// An interrupted token refresh is a transport failure, not a rejected
// credential. Resumable uploads must be able to retry it on reconnect.
if (outcome.retryable) throw new TypeError('Cloud login connection interrupted')
this._lastLoginFailure = Date.now()
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
}
@@ -125,7 +150,7 @@ class FileBrowserClient {
async listDirectory(path: string): Promise<FileBrowserItem[]> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`)
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
// When File Browser isn't installed, nginx falls through to the SPA and
// returns index.html (200, text/html); when it's down it returns 502.
@@ -148,7 +173,7 @@ class FileBrowserClient {
*/
downloadUrl(path: string): string {
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}`
return `${this.baseUrl}/api/raw${encodeFilePath(safePath)}`
}
/**
@@ -158,7 +183,7 @@ class FileBrowserClient {
*/
async fetchBlobUrl(path: string): Promise<string> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${encodeFilePath(safePath)}`)
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob()
return URL.createObjectURL(blob)
@@ -183,7 +208,7 @@ class FileBrowserClient {
async streamUrl(path: string): Promise<string> {
await this.ensureAuth()
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}`
return `${this.baseUrl}/api/raw${encodeFilePath(safePath)}`
}
/**
@@ -201,7 +226,11 @@ class FileBrowserClient {
URL.revokeObjectURL(blobUrl)
}
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
async upload(dirPath: string, file: File, options?: ResumableUploadOptions & { resumable?: boolean }): Promise<void> {
if (options?.resumable) {
await this.ensureAuth()
return resumableUpload(this.baseUrl, sanitizePath(dirPath), file, (url, init) => this.authedFetch(url, init), options)
}
if (options) {
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
@@ -242,7 +271,7 @@ class FileBrowserClient {
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)
const res = await this.authedFetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
`${this.baseUrl}/api/resources${encodeFilePath(safePath)}${encodedName}?override=true`,
{ method: 'POST', body: file },
)
if (!res.ok) {
@@ -255,7 +284,7 @@ class FileBrowserClient {
const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}${encodeURIComponent(sanitizedName)}/`, {
method: 'POST',
})
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
@@ -263,7 +292,7 @@ class FileBrowserClient {
async deleteItem(path: string): Promise<void> {
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
@@ -304,7 +333,7 @@ class FileBrowserClient {
throw new Error(`Cannot read binary file: ${path}`)
}
const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${encodeFilePath(safePath)}`)
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
const blob = await res.blob()
const size = blob.size
@@ -318,7 +347,7 @@ class FileBrowserClient {
const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, {
const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
+170
View File
@@ -0,0 +1,170 @@
/** File Browser 2.63.23 TUS uploads. Keep partial data under a unique name;
* only publish the requested filename after the server has all the bytes.
* The File remains in memory: this recovers a suspended page, not a killed page.
*/
export interface ResumableUploadOptions {
signal: AbortSignal
onProgress: (bytes: number) => void
onPaused?: (paused: boolean) => void
}
type Transport = (url: string, init?: RequestInit) => Promise<Response>
class Retryable extends Error {}
const abortError = () => new DOMException('Upload cancelled', 'AbortError')
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
function waitForConnection(signal: AbortSignal, delay: number): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer)
window.removeEventListener('online', wake)
document.removeEventListener('visibilitychange', visible)
signal.removeEventListener('abort', abort)
}
const wake = () => { cleanup(); resolve() }
const visible = () => { if (document.visibilityState === 'visible') wake() }
const abort = () => { cleanup(); reject(abortError()) }
const timer = setTimeout(wake, delay)
window.addEventListener('online', wake)
document.addEventListener('visibilitychange', visible)
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) abort()
})
}
export async function resumableUpload(
baseUrl: string, folder: string, file: File, transport: Transport,
options: ResumableUploadOptions,
): Promise<void> {
if (!file.name || /[/\\\0]/.test(file.name) || file.name === '.' || file.name === '..') {
throw new Error('Invalid upload filename')
}
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), n => n.toString(16).padStart(2, '0')).join('')
const stage = `${folder.replace(/\/$/, '')}/.archy-upload-${nonce}.part`
const destination = `${folder.replace(/\/$/, '')}/${file.name}`
const tus = `${baseUrl}/api/tus${encodePath(stage)}`
const resource = (path: string) => `${baseUrl}/api/resources${encodePath(path)}`
let created = false
let checksum: string | undefined
let retry = 0
let finished = false
const request = async (url: string, init: RequestInit = {}, signal = options.signal) => {
if (signal.aborted) throw abortError()
const controller = new AbortController()
const abort = () => controller.abort()
signal.addEventListener('abort', abort, { once: true })
const timer = setTimeout(abort, 60_000)
try {
const response = await transport(url, { ...init, signal: controller.signal, cache: 'no-store' })
if (response.status === 408 || response.status === 429 || (response.status >= 500 && response.status !== 507)) {
throw new Retryable('Server temporarily unavailable')
}
if (response.headers.get('content-type')?.includes('text/html')) {
throw new Error('Upload needs a working File Browser connection. Reopen Cloud and try again.')
}
if (response.status === 401 || response.status === 403) throw new Error('Upload access denied. Sign in again or check folder permissions.')
if (response.status === 507) throw new Error('Upload stopped: the server has insufficient storage.')
return response
} catch (error) {
if (signal.aborted) throw abortError()
if (controller.signal.aborted || error instanceof TypeError) throw new Retryable('Connection interrupted')
throw error
} finally {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
}
}
const metadata = async (path: string, hash = false) => {
const response = await request(`${resource(path)}${hash ? '?checksum=sha256' : ''}`)
if (response.status === 404) return null
if (response.status !== 200) throw new Error(`Cannot verify uploaded file (HTTP ${response.status})`)
const data = await response.json()
if (data.isDir || !Number.isSafeInteger(data.size) || data.size < 0) throw new Error('Invalid upload verification response')
return data as { size: number; checksums?: Record<string, string> }
}
try {
while (!finished) {
if (options.signal.aborted) throw abortError()
try {
if (checksum) {
// A rename can succeed while its response is lost. Verify exact server
// content, not just size (an old destination might have the same size).
const source = await metadata(stage)
if (!source) {
const target = await metadata(destination, true)
if (target?.size !== file.size || target.checksums?.sha256 !== checksum) {
throw new Error('Cannot confirm the saved upload. Check the destination before trying again.')
}
finished = true
} else {
if (source.size !== file.size) throw new Error('Upload changed before it could be saved')
// File Browser decodes destination twice: protect literal %, + and ?.
const query = new URLSearchParams({ action: 'rename', destination: encodeURIComponent(destination), override: 'true', rename: 'false' })
const saved = await request(`${resource(stage)}?${query}`, { method: 'PATCH' })
if (saved.status === 404) throw new Retryable('Checking completed upload')
if (!saved.ok) throw new Error(`Could not save uploaded file (HTTP ${saved.status})`)
finished = true
}
} else {
const head = await request(tus, { method: 'HEAD' })
let offset: number
if (head.status === 404) {
const existing = await metadata(stage)
if (existing?.size === file.size) {
offset = file.size // final PATCH acknowledged on server, reply lost
} else if (existing || created) {
throw new Error('The server upload session expired. Please select the file again.')
} else {
const post = await request(tus, { method: 'POST', headers: { 'Upload-Length': String(file.size), 'Tus-Resumable': '1.0.0' } })
if (post.status === 409) throw new Retryable('Checking existing upload')
if (post.status !== 201) throw new Error(`Could not start resumable upload (HTTP ${post.status})`)
created = true
offset = 0
}
} else {
const rawOffset = head.headers.get('Upload-Offset')
const rawLength = head.headers.get('Upload-Length')
offset = Number(rawOffset)
if (head.status !== 200 || rawOffset === null || rawLength === null || Number(rawLength) !== file.size || !Number.isSafeInteger(offset) || offset < 0 || offset > file.size) {
throw new Error('The server returned an invalid upload position')
}
created = true
}
options.onProgress(offset)
options.onPaused?.(false)
if (offset < file.size) {
const end = Math.min(offset + 2 * 1024 * 1024, file.size)
const patch = await request(tus, { method: 'PATCH', headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': String(offset), 'Tus-Resumable': '1.0.0' }, body: file.slice(offset, end) })
if (patch.status === 409) throw new Retryable('Checking saved upload position')
if (patch.status !== 204 || Number(patch.headers.get('Upload-Offset')) !== end) throw new Error(`Server did not confirm the upload chunk (HTTP ${patch.status})`)
options.onProgress(end)
} else {
const saved = await metadata(stage, true)
checksum = saved?.checksums?.sha256
if (saved?.size !== file.size || !checksum || !/^[a-f0-9]{64}$/i.test(checksum)) throw new Error('Could not verify the completed upload')
}
}
retry = 0
} catch (error) {
if (!(error instanceof Retryable)) throw error
options.onPaused?.(true)
await waitForConnection(options.signal, Math.min(20_000, 1000 * 2 ** Math.min(retry++, 5)))
}
}
options.onProgress(file.size)
options.onPaused?.(false)
} finally {
if (!finished) {
// Never delete the destination. TUS deletion also removes its cache entry;
// resource deletion covers an already-completed staging file. Offline
// partial sessions are subsequently removed by File Browser's expiry.
const cleanup = new AbortController()
const timer = setTimeout(() => cleanup.abort(), 5000)
try {
const deleted = await request(tus, { method: 'DELETE' }, cleanup.signal)
if (deleted.status === 404) await request(resource(stage), { method: 'DELETE' }, cleanup.signal)
} catch { /* original error remains the user-visible result */ }
finally { clearTimeout(timer) }
}
}
}
+5 -1
View File
@@ -432,11 +432,15 @@ class RPCClient {
total_amount_sats: number
fee_rate_sat_per_vbyte: number
}> {
if (params.feeRateSatPerVbyte !== undefined &&
(!Number.isInteger(params.feeRateSatPerVbyte) || params.feeRateSatPerVbyte < 1 || params.feeRateSatPerVbyte > 5000)) {
throw new Error('Fee rate must be a whole number from 1 to 5000 sat/vB')
}
return this.call({
method: 'lnd.create-psbt',
params: {
outputs: params.outputs,
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
...(params.feeRateSatPerVbyte === undefined ? {} : { fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte }),
},
})
}
+164
View File
@@ -0,0 +1,164 @@
<template>
<BaseModal :show="show" title="Bump transaction" max-width="max-w-md" z-index="z-[3100]" @close="close">
<PaymentSuccessPane v-if="accepted" :amount="0"
:verb="operation?.status === 'confirmed' ? 'CONFIRMED' : 'BUMP BROADCAST'"
:method-label="operation?.quote?.method === 'rbf' ? 'Replace by fee · RBF' : 'Child pays for parent · CPFP'"
:rows="successRows"
:note="operation?.status === 'confirmed' ? 'Your fee bump is confirmed on-chain.' : 'Accepted in the node’s mempool. Awaiting confirmation; next-block inclusion is not guaranteed.'"
:again-label="operation?.status === 'confirmed' ? '' : 'Check status'"
@again="checkStatus" @done="close" />
<div v-else class="space-y-4">
<p class="text-xs font-mono text-white/45 break-all">{{ txid }}</p>
<template v-if="!operation">
<div class="flex gap-2" role="group" aria-label="Fee target">
<button v-for="option in ['next', 'custom'] as const" :key="option" type="button"
:disabled="submitting" :aria-pressed="mode === option"
class="flex-1 rounded-lg px-3 py-2 text-sm border transition-colors"
:class="mode === option ? 'bg-orange-500/20 border-orange-400/40 text-orange-200' : 'border-white/10 text-white/60'"
@click="mode = option">{{ option === 'next' ? 'Next block' : 'Custom' }}</button>
</div>
<label v-if="mode === 'custom'" class="block text-sm text-white/70">
Fee rate (sat/vB)
<input v-model.number="customRate" :disabled="submitting" type="number" min="1" max="5000" step="1"
class="mt-1 block w-full rounded-lg bg-white/5 border border-white/15 px-3 py-2 text-white" />
</label>
<p class="text-xs text-white/45">Targets faster confirmation. Next-block confirmation is not guaranteed.</p>
<p v-if="loading" role="status" class="text-sm text-white/60">Checking the transaction and current fees…</p>
<template v-if="quote">
<div class="rounded-lg bg-white/5 p-3 space-y-2">
<p class="text-sm font-medium text-white">{{ quote.method === 'rbf' ? 'RBF · Replace sweep' : 'CPFP · Spend change' }}</p>
<p class="text-xs text-white/55">{{ quote.method === 'rbf'
? 'Replaces the wallet’s fee-bump transaction with a higher-fee version. The original payment stays unchanged.'
: 'RBF is unavailable for this payment. A child transaction spends your change to accelerate the original payment.' }}</p>
<dl class="text-sm space-y-2 pt-2">
<div class="flex justify-between gap-3"><dt class="text-white/50">Recipient amount</dt><dd class="text-white">{{ sats(quote.recipient_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Current total fee</dt><dd class="text-white">{{ sats(quote.current_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Additional fee · up to</dt><dd class="text-orange-200">{{ sats(quote.additional_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">New total fee · up to</dt><dd class="text-white font-medium">{{ sats(quote.total_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Package target</dt><dd class="text-white">{{ quote.rate_sat_vb }} sat/vB</dd></div>
</dl>
</div>
<p class="text-xs text-white/45">The wallet may use the full {{ sats(quote.budget_sats) }} sweep budget by the next block. The fee cap applies only to this bump.</p>
<p v-if="expired" role="status" class="text-xs text-amber-300">Fee quote expired. Refresh before confirming.</p>
</template>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<div class="flex gap-2">
<button type="button" class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white/75 text-sm" :disabled="loading || submitting" @click="getQuote">{{ quote ? 'Refresh quote' : 'Preview fee' }}</button>
<button v-if="quote" type="button" class="flex-1 px-3 py-2 rounded-lg bg-orange-500 text-white text-sm font-medium disabled:opacity-40"
:disabled="submitting || loading || expired" @click="submit">{{ submitting ? 'Submitting…' : 'Confirm bump' }}</button>
</div>
</template>
<template v-else>
<p role="status" class="text-sm text-white/80">{{ operation.message }}</p>
<p v-if="operation.bump_txid" class="text-xs text-white/50 break-all">{{ operation.quote?.method === 'rbf' ? 'Replacement' : 'Child' }}: {{ operation.bump_txid }}</p>
<p v-if="operation.actual_sweep_fee_sats !== undefined" class="text-sm text-white/70">Sweep fee: {{ sats(operation.actual_sweep_fee_sats) }}</p>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<button v-if="operation.status !== 'confirmed'" type="button" class="w-full px-3 py-2 rounded-lg bg-white/10 text-white text-sm" :disabled="loading" @click="checkStatus">Check status</button>
</template>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import BaseModal from './BaseModal.vue'
import PaymentSuccessPane, { type SuccessRow } from './PaymentSuccessPane.vue'
import { rpcClient } from '@/api/rpc-client'
interface Quote {
quote_id: string; txid: string; expires_at: number; method: 'rbf' | 'cpfp'
recipient_sats: number; current_fee_sats: number; additional_fee_sats: number
total_fee_sats: number; budget_sats: number; rate_sat_vb: number
}
interface Operation {
status: 'none' | 'registered' | 'unknown' | 'mempool' | 'confirmed'
message: string; bump_txid?: string; actual_sweep_fee_sats?: number; quote?: Quote
}
const props = defineProps<{ show: boolean; txid: string }>()
const emit = defineEmits<{ close: []; updated: [] }>()
const mode = ref<'next' | 'custom'>('next')
const customRate = ref<number | ''>('')
const quote = ref<Quote | null>(null)
const operation = ref<Operation | null>(null)
const loading = ref(false)
const submitting = ref(false)
const error = ref('')
const time = ref(Date.now())
let generation = 0
let lastStatusCheck = 0
const timer = setInterval(() => {
time.value = Date.now()
if (props.show && operation.value && operation.value.status !== 'confirmed' && time.value - lastStatusCheck > 5000) void checkStatus()
}, 1000)
onUnmounted(() => { clearInterval(timer); generation++ })
const expired = computed(() => !quote.value || quote.value.expires_at * 1000 <= time.value)
const sats = (n: number) => `${n.toLocaleString()} sats`
const accepted = computed(() => operation.value?.status === 'mempool' || operation.value?.status === 'confirmed')
const successRows = computed<SuccessRow[]>(() => {
const op = operation.value
if (!op) return []
const rows: SuccessRow[] = [{ label: 'Original transaction', value: props.txid }]
if (op.bump_txid) rows.push({ label: op.quote?.method === 'rbf' ? 'Replacement transaction' : 'Child transaction', value: op.bump_txid })
if (op.actual_sweep_fee_sats !== undefined) rows.push({ label: 'Sweep fee', value: sats(op.actual_sweep_fee_sats) })
return rows
})
const message = (e: unknown) => e instanceof Error ? e.message : String(e)
function close() { if (!submitting.value) emit('close') }
watch([mode, customRate], () => { generation++; loading.value = false; quote.value = null; error.value = '' })
watch(() => [props.show, props.txid] as const, async ([show]) => {
generation++; quote.value = null; operation.value = null; error.value = ''; loading.value = false
mode.value = 'next'; customRate.value = ''
if (show && props.txid) {
// Let mode/reset watchers settle before the first request.
await Promise.resolve()
await checkStatus()
if (!operation.value && !error.value && props.show) await getQuote()
}
}, { immediate: true })
async function getQuote() {
if (loading.value || submitting.value || operation.value) return
if (mode.value === 'custom' && (!Number.isInteger(customRate.value) || Number(customRate.value) < 1 || Number(customRate.value) > 5000)) {
error.value = 'Enter a whole-number fee rate from 1 to 5000 sat/vB.'; return
}
const request = ++generation
loading.value = true; error.value = ''; quote.value = null
try {
const result = await rpcClient.call<Quote>({ method: 'lnd.bump-quote', params: { txid: props.txid, ...(mode.value === 'custom' ? { sat_per_vbyte: customRate.value } : {}) }, maxRetries: 1, timeout: 60000 })
if (request === generation) quote.value = result
} catch (e) { if (request === generation) error.value = message(e) }
finally { if (request === generation) loading.value = false }
}
async function submit() {
if (submitting.value || loading.value || !quote.value || expired.value) return
submitting.value = true; error.value = ''
const id = props.txid
try {
operation.value = await rpcClient.call<Operation>({ method: 'lnd.bump-submit', params: { txid: id, quote_id: quote.value.quote_id }, maxRetries: 1, timeout: 90000 })
emit('updated')
} catch (e) {
// An interrupted response may conceal success. A status check must happen
// before showing another quote/submit action.
operation.value = { status: 'unknown', message: 'Submission response was not confirmed. Check status before taking another action.' }
error.value = message(e)
} finally { submitting.value = false }
await checkStatus()
}
async function checkStatus() {
if (loading.value || submitting.value) return
lastStatusCheck = Date.now()
const request = ++generation
loading.value = true
try {
const result = await rpcClient.call<Operation>({ method: 'lnd.bump-status', params: { txid: props.txid }, maxRetries: 1, timeout: 60000 })
if (request !== generation) return
error.value = ''
if (result.status === 'none') {
operation.value = null
quote.value = null
} else {
operation.value = result
if (result.status === 'confirmed' || result.status === 'mempool') emit('updated')
}
} catch (e) { if (request === generation) error.value = message(e) }
finally { if (request === generation) loading.value = false }
}
</script>
@@ -553,7 +553,7 @@ const defaultOpenForm = () => ({
peerUri: '',
amount: 100000,
private: false,
feePreset: 'standard' as FeePreset,
feePreset: 'fast' as FeePreset,
customConfTarget: null as number | null,
customSatPerVbyte: null as number | null,
})
@@ -624,7 +624,7 @@ function feeParams(
setError: (message: string) => void = message => { openError.value = message },
): { target_conf?: number; sat_per_vbyte?: number } | null {
if (form.feePreset !== 'custom') {
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 1 }
}
const rate = form.customSatPerVbyte
const conf = form.customConfTarget
@@ -673,7 +673,7 @@ async function openChannel() {
}
}
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
const defaultCloseForm = () => ({ feePreset: 'fast' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
const closeForm = ref(defaultCloseForm())
function confirmClose(ch: Channel) {
+11 -6
View File
@@ -337,7 +337,7 @@ watch(() => props.show, (shown) => {
successInfo.value = null
sendAll.value = false
onchainBalance.value = null
feePreset.value = 'standard'
feePreset.value = 'fast'
customConfTarget.value = null
customSatPerVbyte.value = null
resolvedFeeParams.value = {}
@@ -359,7 +359,7 @@ const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string;
{ key: 'custom', label: 'Custom' },
]
const feePreset = ref<OnchainFeePreset>('standard')
const feePreset = ref<OnchainFeePreset>('fast')
const customConfTarget = ref<number | null>(null)
const customSatPerVbyte = ref<number | null>(null)
// Resolved at review time so confirm + send use the same params.
@@ -367,16 +367,16 @@ const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>(
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
if (feePreset.value !== 'custom') {
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 1 }
}
const rate = customSatPerVbyte.value
const conf = customConfTarget.value
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null }
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be a whole number between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null }
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { error.value = 'Target blocks must be a whole number between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) }
}
error.value = 'Custom fee requires target blocks or sats per vByte'
@@ -409,7 +409,7 @@ async function loadFeeEstimate() {
try {
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
method: 'lnd.estimatefee',
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 1 },
timeout: 10000,
})
if (res.fee_sat > 0) feeEstimate.value = res
@@ -569,6 +569,11 @@ function sendAnother() {
dest.value = ''
amount.value = 0
sendAll.value = false
feePreset.value = 'fast'
customConfTarget.value = null
customSatPerVbyte.value = null
resolvedFeeParams.value = {}
feeEstimate.value = null
error.value = ''
}
+26 -6
View File
@@ -61,9 +61,9 @@
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<span
class="text-sm font-medium"
class="text-sm font-medium whitespace-nowrap"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
>
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats
@@ -92,10 +92,22 @@
<span v-if="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div>
<details v-if="tx.fee_bump_history?.length" class="mt-1 text-[11px] text-white/50" @click.stop>
<summary class="cursor-pointer">Fee history</summary>
<p>Original fee {{ tx.total_fees.toLocaleString() }} sats</p>
<button v-for="bump in tx.fee_bump_history" :key="bump.tx_hash" type="button"
class="block max-w-full truncate text-left hover:text-white/80"
@click.stop="txExplorer.openTx(bump.tx_hash)">
{{ bump.status === 'replaced' ? 'Replaced bump' : 'Fee bump' }} · {{ bump.fee_sats.toLocaleString() }} sats · {{ bump.status }} · {{ bump.tx_hash }}
</button>
</details>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<button v-if="isOnchain(tx) && tx.direction === 'outgoing' && (tx.num_confirmations === 0 || (tx.fee_bump_txid && tx.fee_bump_confirmations === 0))" type="button"
class="px-2 py-1 rounded-md border border-orange-400/25 text-orange-200 text-[11px] hover:bg-orange-500/15"
:aria-label="`Bump transaction ${tx.tx_hash}`" @click.stop="bumpTxid = tx.fee_bump_txid || tx.tx_hash">Bump</button>
<span class="hidden sm:inline text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
@@ -103,12 +115,14 @@
</div>
</div>
</BaseModal>
<BumpFeeModal :show="show && !!bumpTxid" :txid="bumpTxid" @close="bumpTxid = ''" @updated="emit('updated')" />
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue'
import BumpFeeModal from '@/components/BumpFeeModal.vue'
import { useTxExplorer } from '@/composables/useTxExplorer'
interface WalletTransaction {
@@ -118,6 +132,10 @@ interface WalletTransaction {
num_confirmations: number
time_stamp: number
total_fees: number
bump_fee_sats?: number
fee_bump_txid?: string
fee_bump_confirmations?: number
fee_bump_history?: Array<{ tx_hash: string; fee_sats: number; status: string }>
dest_addresses: string[]
label: string
block_height: number
@@ -130,7 +148,8 @@ const props = defineProps<{
transactions: WalletTransaction[]
}>()
const emit = defineEmits<{ close: [] }>()
const emit = defineEmits<{ close: []; updated: [] }>()
const bumpTxid = ref('')
const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
@@ -160,6 +179,7 @@ function countFor(f: FilterKey): number {
}
function close() {
bumpTxid.value = ''
emit('close')
}
@@ -169,14 +189,14 @@ function isOnchain(tx: WalletTransaction): boolean {
}
function feeFor(tx: WalletTransaction): number {
return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
return tx.direction === 'outgoing' ? (tx.total_fees || 0) + (tx.bump_fee_sats || 0) : 0
}
/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee
* itself is broken out on its own tag. Incoming rows are untouched. */
function displayAmount(tx: WalletTransaction): number {
const gross = Math.abs(tx.amount_sats)
const fee = feeFor(tx)
const fee = tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
return fee > 0 && gross > fee ? gross - fee : gross
}
@@ -0,0 +1,123 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BumpFeeModal from '../BumpFeeModal.vue'
import TransactionsModal from '../TransactionsModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
const explorer = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: explorer }) }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (s: string) => s }) }))
const quote = { quote_id: 'q', txid: 'a'.repeat(64), expires_at: Math.floor(Date.now() / 1000) + 60, method: 'cpfp', recipient_sats: 161650, current_fee_sats: 144, additional_fee_sats: 618, total_fee_sats: 762, budget_sats: 618, rate_sat_vb: 3 }
const stubs = { BaseModal: { template: '<div><slot /></div>' } }
function open() {
const wrapper = mount(BumpFeeModal, { props: { show: true, txid: quote.txid }, global: { stubs } })
return { wrapper, vm: (wrapper.vm as any).$.setupState }
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.bump-status') return { status: 'none' } as never
return { ...quote } as never
})
})
describe('Bump review', () => {
it('defaults to next block and clearly explains CPFP with additional/total preview', async () => {
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('CPFP · Spend change')
expect(wrapper.text()).toContain('RBF is unavailable')
expect(wrapper.text()).toContain('618 sats')
expect(wrapper.text()).toContain('762 sats')
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit' }))
wrapper.unmount()
})
it('shows RBF only for a backend-supported sweep', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => (method === 'lnd.bump-status' ? { status: 'none' } : { ...quote, method: 'rbf' }) as never)
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('RBF · Replace sweep')
expect(wrapper.text()).not.toContain('RBF is unavailable')
wrapper.unmount()
})
it('invalidates the old quote when custom settings change', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.mode = 'custom'; await flushPromises()
expect(vm.quote).toBeNull()
vm.customRate = 0.5; await flushPromises(); await vm.getQuote()
expect(vm.error).toContain('whole-number')
vm.customRate = 10; await flushPromises(); await vm.getQuote()
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'lnd.bump-quote', params: { txid: quote.txid, sat_per_vbyte: 10 } }))
wrapper.unmount()
})
it('blocks expired quotes and duplicate clicks; never retries a mutation', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.quote.expires_at = 1; await vm.submit()
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit' }))
vm.quote.expires_at = Math.floor(Date.now()/1000) + 60
let finish!: (value: any) => void
vi.mocked(rpcClient.call).mockImplementation(({ method }) => method === 'lnd.bump-submit' ? new Promise(resolve => { finish = resolve }) as never : Promise.resolve({ status: 'registered', message: 'Waiting for broadcast' }) as never)
const pending = vm.submit(); await vm.submit()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'lnd.bump-submit')).toHaveLength(1)
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit', maxRetries: 1 }))
finish({ status: 'registered', message: 'Waiting for broadcast' }); await pending
expect(wrapper.text()).toContain('Waiting for broadcast')
wrapper.unmount()
})
it('keeps unknown submission outcome from becoming a second payment', async () => {
const { wrapper, vm } = open(); await flushPromises()
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Network timeout'))
await vm.submit()
expect(vm.operation.status).toBe('unknown')
expect(wrapper.text()).not.toContain('Confirm bump')
expect(wrapper.text()).toContain('Check status')
wrapper.unmount()
})
it('reconciles a previous submission when reopening', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ status: 'mempool', message: 'Accepted, awaiting confirmation', bump_txid: 'b'.repeat(64), actual_sweep_fee_sats: 618 } as never)
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('BUMP BROADCAST')
expect(wrapper.text()).toContain('Awaiting confirmation')
expect(rpcClient.call).toHaveBeenCalledTimes(1)
expect(wrapper.text()).not.toContain('Confirm bump')
wrapper.unmount()
})
it('uses the existing green success animation only after verified acceptance', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.operation = { status: 'registered', message: 'Waiting for broadcast' }; await flushPromises()
expect(wrapper.find('.send-success-badge').exists()).toBe(false)
vm.operation = { status: 'mempool', message: 'Accepted', bump_txid: 'b', quote: { method: 'rbf' } }; await flushPromises()
expect(wrapper.find('.send-success-badge').exists()).toBe(true)
expect(wrapper.text()).toContain('BUMP BROADCAST')
expect(wrapper.text()).toContain('Awaiting confirmation')
vm.operation.status = 'confirmed'; await flushPromises()
expect(wrapper.text()).toContain('CONFIRMED')
expect(wrapper.text()).not.toContain('Awaiting confirmation')
wrapper.unmount()
})
it('offers Bump only on pending on-chain rows and stops explorer navigation', async () => {
const tx = { tx_hash: quote.txid, amount_sats: 161794, direction: 'outgoing', num_confirmations: 0, time_stamp: 1, total_fees: 144, dest_addresses: [], label: '', block_height: 0 }
const wrapper = mount(TransactionsModal, { props: { show: true, transactions: [tx, { ...tx, tx_hash: 'b', num_confirmations: 1 }, { ...tx, tx_hash: 'c', kind: 'lightning' }] as any }, global: { stubs: { ...stubs, BumpFeeModal: true } } })
const buttons = wrapper.findAll('button').filter(b => b.text() === 'Bump')
expect(buttons).toHaveLength(1)
await buttons[0]!.trigger('click')
expect(explorer).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BumpFeeModal' }).props('txid')).toBe(quote.txid)
wrapper.unmount()
})
})
describe('Grouped fee-bump history', () => {
it('preserves recipient amount, counts only active extra fees, and opens the replacement bump', async () => {
const parent = { tx_hash: 'a'.repeat(64), amount_sats: 100144, total_fees: 144, direction: 'outgoing' as const,
num_confirmations: 0, time_stamp: 1, dest_addresses: [], label: '', block_height: 0,
bump_fee_sats: 600, fee_bump_txid: 'c'.repeat(64), fee_bump_confirmations: 0,
fee_bump_history: [{ tx_hash: 'b'.repeat(64), fee_sats: 300, status: 'replaced' },
{ tx_hash: 'c'.repeat(64), fee_sats: 600, status: 'mempool' }] }
const wrapper = mount(TransactionsModal, { props: { show: true, transactions: [parent] }, global: { stubs } })
expect(wrapper.text()).toContain('-100,000 sats')
expect(wrapper.text()).toContain('fee 744 sats')
expect(wrapper.text()).toContain('Replaced bump')
await wrapper.get(`button[aria-label="Bump transaction ${parent.tx_hash}"]`).trigger('click')
await flushPromises()
expect(wrapper.findComponent(BumpFeeModal).props('txid')).toBe(parent.fee_bump_txid)
wrapper.unmount()
})
})
@@ -17,6 +17,20 @@ function open() {
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
describe('channel closing fee choice', () => {
it('defaults to next block, preserves explicit choices, and resets a new close to Fast', async () => {
const { wrapper, vm } = open()
expect(vm.closeForm.feePreset).toBe('fast')
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 1 } }))
vm.confirmClose(channel)
vm.closeForm.feePreset = 'standard'
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 6 } }))
vm.confirmClose(channel)
expect(vm.closeForm.feePreset).toBe('fast')
expect(vm.openForm.feePreset).toBe('fast')
wrapper.unmount()
})
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
await vm.closeChannel()
@@ -0,0 +1,62 @@
import { mount, flushPromises } from '@vue/test-utils'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MeshDeviceSetupModal from '../mesh/MeshDeviceSetupModal.vue'
const state = reactive({
flashFlowPath: null as string | null,
undismissedDetectedDevices: [] as string[],
status: {
device_type: 'meshcore',
detected_device_info: [{ path: '/dev/fixture', vid: '10c4', pid: 'ea60' }],
},
flashDevice: vi.fn(),
flashStatus: vi.fn(),
closeFlashFlow: vi.fn(),
})
vi.mock('@/stores/mesh', () => ({ useMeshStore: () => state }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ serverName: 'Fixture' }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
async function open() {
const wrapper = mount(MeshDeviceSetupModal, {
global: { stubs: { BaseModal: { template: '<div><slot /></div>' } } },
})
state.flashFlowPath = '/dev/fixture'
await flushPromises()
return wrapper
}
describe('LoRa firmware board selection', () => {
beforeEach(() => {
state.flashFlowPath = null
state.flashDevice.mockReset()
})
it('does not infer Heltec V3 from a generic CP2102 adapter', async () => {
const wrapper = await open()
const board = wrapper.findAll('select')[1]!
expect((board.element as HTMLSelectElement).value).toBe('')
await wrapper.find('input[type="checkbox"]').setValue(true)
const flash = wrapper.findAll('button').find(button => button.text().includes('Erase & Flash Now'))!
expect((flash.element as HTMLButtonElement).disabled).toBe(true)
expect(state.flashDevice).not.toHaveBeenCalled()
await board.setValue('heltec-v3')
await flash.trigger('click')
expect(state.flashDevice).toHaveBeenCalledExactlyOnceWith('/dev/fixture', 'meshcore', 'heltec-v3')
wrapper.unmount()
})
it('shows a failed tool preflight without entering flashing progress', async () => {
state.flashDevice.mockRejectedValueOnce(new Error('Radio flashing tools are missing'))
const wrapper = await open()
await wrapper.findAll('select')[1]!.setValue('heltec-v4')
await wrapper.find('input[type="checkbox"]').setValue(true)
await wrapper.findAll('button').find(button => button.text().includes('Erase & Flash Now'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Radio flashing tools are missing')
expect(wrapper.text()).toContain('Erase & Flash Now')
expect(state.flashStatus).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -0,0 +1,56 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SendBitcoinModal from '../SendBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (s: string) => s }) }))
vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) }))
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.getinfo') return { balance_sats: 100000 } as never
if (method === 'lnd.estimatefee') return { fee_sat: 500, sat_per_vbyte: 3 } as never
return { txid: 'a'.repeat(64) } as never
})
})
function open() {
const wrapper = mount(SendBitcoinModal, { props: { show: true }, global: { stubs: { BaseModal: { template: '<div><slot /></div>' } } } })
const vm = (wrapper.vm as any).$.setupState
vm.sendMethod = 'onchain'; vm.dest = 'bc1qtestaddress'; vm.amount = 1000
return { wrapper, vm }
}
describe('on-chain fee defaults', () => {
it.each([NaN, Infinity, 0.5, -1, 5001])('rejects invalid custom rate %s before preview or submission', async rate => {
const { wrapper, vm } = open()
vm.feePreset = 'custom'; vm.customSatPerVbyte = rate
await vm.review()
expect(vm.confirming).toBe(false)
expect(vm.error).toContain('whole number')
expect(rpcClient.call).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each([['fast', 1], ['standard', 6], ['slow', 144]])('uses %s consistently in preview and submission', async (preset, target) => {
const { wrapper, vm } = open()
expect(vm.feePreset).toBe('fast')
vm.feePreset = preset
await vm.review(); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.estimatefee', params: expect.objectContaining({ target_conf: target }) }))
await vm.send()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.sendcoins', params: expect.objectContaining({ target_conf: target }) }))
wrapper.unmount()
})
it('preserves an explicit custom rate and resets reopened/new sends to Fast', async () => {
const { wrapper, vm } = open()
vm.feePreset = 'custom'; vm.customSatPerVbyte = 17
await vm.review(); await flushPromises(); await vm.send()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.sendcoins', params: expect.objectContaining({ sat_per_vbyte: 17 }) }))
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.estimatefee' }))
vm.sendAnother(); expect(vm.feePreset).toBe('fast'); expect(vm.customSatPerVbyte).toBeNull()
vm.feePreset = 'slow'
await wrapper.setProps({ show: false }); await wrapper.setProps({ show: true })
expect(vm.feePreset).toBe('fast'); expect(vm.resolvedFeeParams).toEqual({})
wrapper.unmount()
})
})
@@ -1,5 +1,5 @@
<template>
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'">
<div v-if="task && route.fullPath === task.originRoute" class="mb-3 shrink-0">
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
@@ -13,8 +13,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useCloudStore } from '@/stores/cloud'
defineProps<{ floating?: boolean }>()
const route = useRoute()
const store = useCloudStore()
const task = computed(() => store.upload)
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
@@ -23,7 +24,8 @@ const label = computed(() => {
if (!t) return ''
if (t.error) return t.error
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded`
if (!t.active) return `Complete · ${t.count === 1 ? t.filename : `${t.count} files`}`
if (t.paused) return `Connection interrupted · resuming ${t.filename}`
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
})
</script>
@@ -149,9 +149,8 @@
<option value="heltec-v3">Heltec LoRa 32 V3</option>
<option value="heltec-v4">Heltec LoRa 32 V4</option>
</select>
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
Couldn't confirm the board automatically — double check before flashing.
Flashing the wrong board's image can brick it.
<p class="text-[11px] text-amber-400/80 mt-1">
Select the model printed on your board. USB adapters are shared across models.
</p>
</div>
</div>
@@ -300,7 +299,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
@@ -536,31 +535,11 @@ const starting = ref(false)
const flashJob = ref<FlashJobStatus | null>(null)
let flashPollTimer: ReturnType<typeof setInterval> | null = null
const detectedInfo = computed(() =>
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
// exactly — matching on the display label was wrong: a Heltec V3's CP2102
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
// "couldn't confirm automatically" warning even though the backend CAN
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
// safely auto-matched and always requires manual selection.
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
const info = detectedInfo.value
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
return ''
})
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
const flashStageLabel = computed(() => {
switch (flashJob.value?.stage) {
case 'downloading': return 'Downloading firmware…'
case 'preparing': return 'Preparing radio…'
case 'erasing': return 'Erasing chip…'
case 'writing': return 'Writing firmware…'
case 'autoinstalling': return 'Installing (rnodeconf)…'
@@ -572,7 +551,7 @@ const flashStageLabel = computed(() => {
function openFlashStep() {
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
flashBoard.value = resolvedFlashBoard.value
flashBoard.value = ''
flashConfirmed.value = false
flashJob.value = null
error.value = ''
@@ -635,6 +614,11 @@ async function cancelFlash() {
}
}
onBeforeUnmount(() => {
stopFlashPoll()
stopProbeProgress()
})
function closeFlashStep() {
stopFlashPoll()
if (mesh.flashFlowPath) {
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, nextTick } from 'vue'
import { createRouter, createMemoryHistory } from 'vue-router'
import { useCloudStore } from '@/stores/cloud'
import { useUploadNotifications } from '../useUploadNotifications'
import UploadProgress from '@/components/cloud/UploadProgress.vue'
const action = vi.hoisted(() => vi.fn())
vi.mock('../useToast', () => ({ useToast: () => ({ action }) }))
const origin = '/dashboard/cloud/documents?path=/uploads'
async function setup() {
const pinia = createPinia(); setActivePinia(pinia)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }] })
await router.push(origin); await router.isReady()
const wrapper = mount(defineComponent({ components: { UploadProgress }, setup() { useUploadNotifications() }, template: '<UploadProgress />' }), { global: { plugins: [pinia, router] } })
const store = useCloudStore()
store.upload = { active: true, paused: false, filename: 'photo.png', destination: '/uploads', originRoute: origin, sent: 100, total: 100, completed: 0, count: 1, error: null, cancelled: false }
await nextTick()
return { store, router, wrapper }
}
beforeEach(() => action.mockClear())
describe('upload screen and completion notification', () => {
it('shows Saving until the server finishes, then Complete with dismiss on the origin only', async () => {
const { store, wrapper } = await setup()
expect(wrapper.text()).toContain('Saving photo.png'); expect(action).not.toHaveBeenCalled()
store.upload!.completed = 1; store.upload!.active = false; await nextTick()
expect(wrapper.text()).toContain('Complete · photo.png'); expect(action).not.toHaveBeenCalled()
await wrapper.get('button[aria-label="Dismiss upload"]').trigger('click')
expect(store.upload).toBeNull(); wrapper.unmount()
})
it('hides progress on another screen, notifies once on completion and returns to the origin', async () => {
const { store, router, wrapper } = await setup()
await router.push('/dashboard/apps'); expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(store.upload!.active).toBe(true)
store.upload!.completed = 1; store.upload!.active = false; await nextTick()
expect(action).toHaveBeenCalledTimes(1)
expect(action.mock.calls[0]![0]).toBe('Upload complete · photo.png')
action.mock.calls[0]![1].onClick(); await router.isReady(); await new Promise(resolve => setTimeout(resolve, 0))
expect(router.currentRoute.value.fullPath).toBe(origin)
expect(wrapper.text()).toContain('Complete · photo.png')
await router.push('/dashboard'); expect(action).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('also hides on other Cloud folders and shows progress again on return', async () => {
const { router, wrapper } = await setup()
await router.push('/dashboard/cloud/documents?path=/different')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
await router.push(origin); expect(wrapper.text()).toContain('Saving'); wrapper.unmount()
})
it.each(['error', 'cancelled'] as const)('never calls a %s outcome complete', async field => {
const { store, router, wrapper } = await setup(); await router.push('/dashboard')
if (field === 'error') store.upload!.error = 'No space'; else store.upload!.cancelled = true
store.upload!.active = false; await nextTick()
expect(action).toHaveBeenCalledTimes(1); expect(action.mock.calls[0]![0]).not.toContain('complete')
expect(action.mock.calls[0]![0]).toContain('0/1 saved'); wrapper.unmount()
})
})
@@ -0,0 +1,25 @@
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCloudStore } from '@/stores/cloud'
import { useToast } from './useToast'
/** Keep progress on its originating screen; announce completion elsewhere once. */
export function useUploadNotifications() {
const store = useCloudStore()
const route = useRoute()
const router = useRouter()
const toast = useToast()
watch(() => store.upload?.active, (active, wasActive) => {
const task = store.upload
if (wasActive !== true || active !== false || !task || route.fullPath === task.originRoute) return
const message = task.error
? `Upload failed · ${task.completed}/${task.count} saved: ${task.error}`
: task.cancelled
? `Upload stopped · ${task.completed}/${task.count} saved`
: `Upload complete · ${task.count === 1 ? task.filename : `${task.count} files`}`
toast.action(message, {
label: 'View upload',
onClick: () => { void router.push(task.originRoute || '/dashboard/cloud') },
}, { variant: task.error ? 'error' : task.cancelled ? 'info' : 'success', duration: 8000 })
}, { flush: 'sync' })
}
+4 -4
View File
@@ -8,23 +8,23 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false)
const error = ref<string | null>(null)
const authenticated = ref(false)
const upload = ref<{ active: boolean; filename: string; destination: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
const upload = ref<{ active: boolean; paused: boolean; filename: string; destination: string; originRoute: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
let uploadController: AbortController | null = null
function cancelUpload() { uploadController?.abort() }
function dismissUpload() { if (!upload.value?.active) upload.value = null }
async function uploadFiles(files: File[]) {
async function uploadFiles(files: File[], originRoute = '') {
if (!files.length || upload.value?.active) return
const destination = currentPath.value
const controller = new AbortController()
uploadController = controller
const task = { active: true, filename: files[0]!.name, destination, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
const task = { active: true, paused: false, filename: files[0]!.name, destination, originRoute, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
upload.value = task
let completedBytes = 0
try {
for (const file of files) {
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
upload.value.filename = file.name
await fileBrowserClient.upload(destination, file, { signal: controller.signal, onProgress: (sent) => {
await fileBrowserClient.upload(destination, file, { resumable: true, signal: controller.signal, onPaused: (paused) => { if (upload.value?.active) upload.value.paused = paused }, onProgress: (sent) => {
if (upload.value?.active) upload.value.sent = completedBytes + sent
} })
completedBytes += file.size
+1 -1
View File
@@ -59,7 +59,7 @@ export interface MeshDeviceProbe {
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
export type FlashBoard = 'heltec-v3' | 'heltec-v4'
export type FlashStage = 'downloading' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
export type FlashStage = 'downloading' | 'preparing' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
/** Live progress for the one flash job that can run at a time. */
export interface FlashJobStatus {
+3 -1
View File
@@ -120,7 +120,9 @@ export interface MeshcoreRfPreset {
* and operator-attested deployment plans.
*/
export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [
{ id: 'eu_868', label: 'Europe / UK — 869.525 MHz, 250 kHz, SF11, CR4/5', freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
// MeshCore app maintainer's presets: MeshCore discussion #1650 (2026-02-15).
{ id: 'eu_uk_narrow', label: 'Europe / UK (Narrow) — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 },
{ id: 'eu_868', label: 'Europe / UK (Long Range) — 869.525 MHz, 250 kHz, SF11, CR4/5', freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
{ id: 'pt', label: 'Portugal — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 },
{ id: 'fw_default', label: 'MeshCore firmware default — 869.618 MHz, 62.5 kHz, SF8, CR4/5', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 5 },
{ id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 },
+1 -1
View File
@@ -366,7 +366,7 @@ function onDrop(e: DragEvent) {
}
async function handleUpload(files: File[]) {
await cloudStore.uploadFiles(files)
await cloudStore.uploadFiles(files, route.fullPath)
}
async function handleDelete(path: string) {
+4 -3
View File
@@ -331,6 +331,7 @@ let discoverAnimationDone = false
</script>
<script setup lang="ts">
import { normalizeStoreApps } from './apps/serviceNames'
import AppSearchField from '@/components/AppSearchField.vue'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
@@ -407,7 +408,7 @@ const catalogStorefront = useCachedResource<CatalogStorefront | null>({
ttlMs: 300_000,
persist: true,
}).data
const communityApps = computed(() => catalogResource.data.value ?? [])
const communityApps = computed(() => normalizeStoreApps(catalogResource.data.value ?? []))
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
// raises a toast, it only surfaces here — content stays on screen either way.
@@ -535,9 +536,9 @@ const allApps = computed(() => {
const nostrMerged = nostrApps.value
.filter(app => !existingIds.has(app.id))
.map(app => ({ ...app, category: app.category || categorizeCommunityApp(app), source: 'nostr' }))
return [...base, ...nostrMerged]
return normalizeStoreApps([...base, ...nostrMerged])
}
return base
return normalizeStoreApps(base)
})
const filteredApps = computed(() => {
+1 -1
View File
@@ -288,7 +288,7 @@
<WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" />
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" @scan="showSendModal = false; showScanModal = true" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" @scan="showReceiveModal = false; showScanModal = true" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @updated="loadWeb5Status" @close="showTransactionsModal = false" />
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
</div>
</template>
+9 -11
View File
@@ -50,12 +50,10 @@
{{ section.name }}
</button>
</div>
<input
<AppSearchField
v-model="searchQuery"
type="text"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
:label="t('marketplace.searchApps')"
/>
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div>
@@ -82,12 +80,10 @@
type="button"
>{{ section.name }}</button>
</div>
<input
<AppSearchField
v-model="searchQuery"
type="text"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
:label="t('marketplace.searchApps')"
/>
</div>
</div>
@@ -168,6 +164,7 @@ let marketplaceAnimationDone = false
</script>
<script setup lang="ts">
import { normalizeStoreApps } from './apps/serviceNames'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -181,6 +178,7 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import AppSearchField from '@/components/AppSearchField.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
@@ -275,7 +273,7 @@ const catalogResource = useCachedResource<MarketplaceApp[]>({
ttlMs: 300_000,
persist: true,
})
const communityApps = computed(() => catalogResource.data.value ?? [])
const communityApps = computed(() => normalizeStoreApps(catalogResource.data.value ?? []))
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
// raises a toast, it only surfaces here — content stays on screen either way.
@@ -383,10 +381,10 @@ const allApps = computed(() => {
const category = app.category || categorizeCommunityApp(app)
return { ...app, category, source: 'nostr' }
})
return [...base, ...nostrMerged]
return normalizeStoreApps([...base, ...nostrMerged])
}
return base
return normalizeStoreApps(base)
})
const filteredApps = computed(() => {
@@ -89,11 +89,25 @@ describe('appsConfig service filtering', () => {
const entries: Array<[string, PackageDataEntry]> = [
['cuprate', makePkg('cuprate', 'Cuprate', 'money')],
['archy-cuprate-ui', makePkg('archy-cuprate-ui', 'Cuprate UI companion', 'money')],
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')],
]
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate'])
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual([])
})
it('groups NetBird parts and BTCPay aliases without hiding a legacy-only installation', () => {
const entries: Array<[string, PackageDataEntry]> = [
['netbird', makePkg('netbird', 'NetBird', 'networking')],
['netbird-dashboard', makePkg('netbird-dashboard', 'NetBird Dashboard', 'networking')],
['netbird-server', makePkg('netbird-server', 'NetBird Server', 'networking')],
['btcpay', makePkg('btcpay', 'BTCPay', 'money')],
['btcpay-server', makePkg('btcpay-server', 'BTCPay Server', 'commerce')],
]
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['netbird', 'btcpay-server'])
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
expect(filterEntriesForTab([entries[3]!], 'apps', 'all').map(([id]) => id)).toEqual(['btcpay'])
})
it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch'
+4 -2
View File
@@ -35,7 +35,7 @@ const INTERNAL_TOOLING_NAMES = new Set([
// Cuprate's dashboard is bundled as a companion of the primary cuprate
// package; showing the generated container as a second Services entry
// defeats the one-app presentation.
'archy-cuprate-ui',
'archy-cuprate-ui', 'cuprate-ui', 'netbird-dashboard', 'netbird-server',
])
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
@@ -54,7 +54,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'btcpay': 'commerce', 'btcpayserver': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
@@ -115,11 +115,13 @@ export function filterEntriesForTab(
selectedCategory: string,
): Array<[string, PackageDataEntry]> {
const hasMempool = entries.some(([id]) => id === 'mempool')
const hasBtcpay = entries.some(([id]) => id === 'btcpay-server')
return entries.filter(([id, pkg]) => {
// Older daemons can retain the frontend manifest alias during a restart.
// Keep one tile while the updated scanner converges; a legacy-only node
// must still be able to see and operate its sole Mempool entry.
if (hasMempool && ['mempool-web', 'mempool-frontend', 'archy-mempool-web'].includes(id)) return false
if (hasBtcpay && ['btcpay', 'btcpayserver'].includes(id)) return false
if (isInternalToolingPackage(id, pkg)) return false
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
const isWebsite = isWebsitePackage(id, pkg)
+13
View File
@@ -29,6 +29,7 @@ export const SERVICE_NAMES = new Set([
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'cuprate-ui', 'netbird-dashboard', 'netbird-server',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
@@ -64,3 +65,15 @@ export function isServiceContainer(id: string): boolean {
export function isStoreListedApp(id: string): boolean {
return !isServiceContainer(id) && !NODE_INTERNAL_IDS.has(id)
}
/** Normalize every catalog source, including persisted caches from older UIs. */
export function normalizeStoreApps<T extends { id: string }>(apps: readonly T[]): T[] {
const result = new Map<string, T>()
for (const app of apps) {
if (!isStoreListedApp(app.id)) continue
const id = ['btcpay', 'btcpayserver'].includes(app.id) ? 'btcpay-server' : app.id
// Prefer the real app's metadata to a legacy image-pin-only alias.
if (!result.has(id) || app.id === id) result.set(id, { ...app, id })
}
return [...result.values()]
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { signedCatalogToApps } from '../curatedApps'
import { normalizeStoreApps } from '../../apps/serviceNames'
describe('one App Store card per product', () => {
it('keeps Cuprate and NetBird components out and prefers canonical BTCPay metadata', () => {
const entries = {
btcpay: { version: '2.4.3' },
'btcpay-server': { version: '2.4.3', manifest: { app: { name: 'BTCPay Server', category: 'commerce', metadata: { icon: '/btcpay.svg' } } } },
cuprate: { version: '0.1' }, 'cuprate-ui': { version: '1.7' },
netbird: { version: '2.38' }, 'netbird-dashboard': { version: '2.38' }, 'netbird-server': { version: '0.71' },
}
for (const apps of [entries, Object.fromEntries(Object.entries(entries).reverse())]) {
const result = signedCatalogToApps({ apps })
expect(result.map(x => x.id).sort()).toEqual(['btcpay-server', 'cuprate', 'netbird'])
expect(result.find(x => x.id === 'btcpay-server')).toMatchObject({ title: 'BTCPay Server', category: 'commerce', icon: '/btcpay.svg', version: '2.4.3' })
}
})
it('normalizes persisted/community entries repeatedly without hiding independent services', () => {
const stale = ['cuprate-ui', 'netbird-server', 'netbird-dashboard', 'btcpay', 'btcpay-server', 'angor-indexer', 'angor-relay', 'electrumx'].map(id => ({ id }))
const result = normalizeStoreApps(stale)
expect(result.map(x => x.id)).toEqual(['btcpay-server', 'angor-indexer', 'angor-relay', 'electrumx'])
expect(normalizeStoreApps(result)).toEqual(result)
expect(normalizeStoreApps([{ id: 'btcpay' }])).toEqual([{ id: 'btcpay-server' }])
})
})
+8 -6
View File
@@ -1,5 +1,5 @@
import type { MarketplaceApp } from './types'
import { isStoreListedApp } from '../apps/serviceNames'
import { isStoreListedApp, normalizeStoreApps } from '../apps/serviceNames'
const R = 'source.archipelago-foundation.org/lfg2025'
@@ -98,7 +98,7 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
source: 'signed-catalog',
})
}
return out
return normalizeStoreApps(out)
}
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
@@ -192,7 +192,7 @@ const CATALOG_URLS = [
* Caches for 1 hour. Returns null only if ALL sources fail. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// Return cache if fresh
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) }
// The daemon-verified signed catalog first (release-root signature checked
// server-side): it is what makes a newly published app appear without a
@@ -239,16 +239,16 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// apps); signed entries fill version/image gaps and append brand-new apps.
const byId = new Map<string, MarketplaceApp>()
for (const app of signedApps) byId.set(app.id, app)
for (const app of community?.apps ?? []) {
for (const app of normalizeStoreApps(community?.apps ?? [])) {
const existing = byId.get(app.id)
byId.set(app.id, existing ? { ...app, version: app.version || existing.version, dockerImage: app.dockerImage || existing.dockerImage } : app)
byId.set(app.id, existing ? { ...app, version: existing.version || app.version, dockerImage: existing.dockerImage || app.dockerImage } : app)
}
const merged: AppCatalog = {
version: community?.version ?? 1,
registry: community?.registry ?? R,
featured: signedFeatured ?? community?.featured,
storefront: signedStorefront ?? community?.storefront,
apps: [...byId.values()],
apps: normalizeStoreApps([...byId.values()]),
}
cachedCatalog = merged
catalogFetchedAt = Date.now()
@@ -261,6 +261,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const stored = localStorage.getItem('archy_catalog')
if (stored) {
cachedCatalog = JSON.parse(stored) as AppCatalog
cachedCatalog.apps = normalizeStoreApps(cachedCatalog.apps)
catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min
return cachedCatalog
}
@@ -314,6 +315,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'],
'btcpay-server': ['btcpay-server', 'btcpay', 'btcpayserver'],
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -72,6 +72,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
'btcpay-server': ['btcpay-server', 'btcpay', 'btcpayserver'],
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -362,16 +362,27 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.23-alpha -->
<!-- v1.9.0-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.23-alpha</span>
<span class="text-xs text-white/40">October 1, 2026</span>
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.9.0-alpha</span>
<span class="text-xs text-white/40">October 5, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce.</p>
<p>Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices.</p>
<p>Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions.</p>
<p>Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations.</p>
<p>Restrict public management access while retaining configured public apps and ACME certificate validation.</p>
<p>Serve the Mempool explorer on the Angor indexer origin alongside its API.</p>
<p>Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads.</p>
<p>Preserve paid-file Lightning entitlements across restarts and recover settled invoices from LND. Retry delivery without paying again and retain purchased files in the owned cache.</p>
<p>Return explicit payment-status errors with safe retry guidance when verification is unavailable.</p>
<p>Show compact upload progress across screens, retain the original destination, and cancel active and queued uploads.</p>
<p>Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads.</p>
<p>Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion.</p>
<p>Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files.</p>
<p>Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility.</p>
<p>Allow apps to opt in to a validated public-key list of user identities without granting signing access.</p>
<p>Make transaction filters transparent and horizontally scrollable on mobile.</p>
<p>Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.</p>
<p>Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.</p>
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
root: path.resolve(__dirname, 'previews/fee-bump'),
base: '/fee-bump-preview/', publicDir: false, plugins: [vue()],
resolve: { alias: [
{find: '@/api/rpc-client', replacement: path.resolve(__dirname, 'previews/fee-bump/rpc.ts')},
{find: '@/composables/useTxExplorer', replacement: path.resolve(__dirname, 'previews/fee-bump/explorer.ts')},
{find: '@', replacement: path.resolve(__dirname, 'src')},
] },
build: { outDir: '/tmp/archy-fee-bump-preview', emptyOutDir: true },
})