Fix paid-file recovery, app lifecycle regressions and wallet controls
Demo images / Build & push demo images (push) Failing after 1m10s

This commit is contained in:
archipelago
2026-10-01 10:31:55 -04:00
parent 227174e541
commit f4d3455496
31 changed files with 1638 additions and 269 deletions
+3
View File
@@ -19,6 +19,8 @@
<AppLauncherOverlay />
<AppCredentialInterstitial />
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
<!-- Global toast notifications -->
<ToastStack />
@@ -96,6 +98,7 @@
</template>
<script setup lang="ts">
import UploadProgress from '@/components/cloud/UploadProgress.vue'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue'
@@ -336,3 +336,45 @@ describe('sanitizePath', () => {
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
})
})
describe('upload transport progress and cancellation', () => {
class UploadXHR {
static instances: UploadXHR[] = []
upload = { onprogress: null as ((e: { loaded: number }) => void) | null }
onload: (() => void) | null = null
onabort: (() => void) | null = null
onerror: (() => void) | null = null
status = 200
withCredentials = false
contentType = 'application/json'
open = vi.fn(); setRequestHeader = vi.fn(); send = vi.fn()
abort = vi.fn(() => this.onabort?.())
getResponseHeader() { return this.contentType }
constructor() { UploadXHR.instances.push(this) }
}
beforeEach(() => { setAuthenticated(); UploadXHR.instances = []; vi.stubGlobal('XMLHttpRequest', UploadXHR) })
it('sends the file bytes, escapes folder names, and waits for server acceptance after 100%', async () => {
const controller = new AbortController(); const onProgress = vi.fn()
const file = new File(['data'], 'a #.txt')
let complete = false
const job = fileBrowserClient.upload('/folder #1', file, { signal: controller.signal, onProgress }).then(() => { complete = true })
await Promise.resolve(); await Promise.resolve()
const xhr = UploadXHR.instances[0]!
expect(xhr.open).toHaveBeenCalledWith('POST', expect.stringMatching(/\/app\/filebrowser\/api\/resources\/folder%20%231\/a%20%23.txt\?override=true$/))
expect(xhr.send).toHaveBeenCalledWith(file)
xhr.upload.onprogress?.({ loaded: 4 }); expect(onProgress).toHaveBeenCalledWith(4)
expect(complete).toBe(false)
xhr.onload?.(); await job; expect(complete).toBe(true)
})
it('cancels the actual request and refuses HTML masquerading as upload success', async () => {
const controller = new AbortController()
const job = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: controller.signal, onProgress: vi.fn() })
await Promise.resolve(); await Promise.resolve()
controller.abort(); await expect(job).rejects.toMatchObject({ name: 'AbortError' })
expect(UploadXHR.instances[0]!.abort).toHaveBeenCalled()
const next = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: new AbortController().signal, onProgress: vi.fn() })
await Promise.resolve(); await Promise.resolve()
const xhr = UploadXHR.instances[1]!; xhr.contentType = 'text/html'; xhr.onload?.()
await expect(next).rejects.toThrow('login page')
})
})
+37 -1
View File
@@ -201,7 +201,43 @@ class FileBrowserClient {
URL.revokeObjectURL(blobUrl)
}
async upload(dirPath: string, file: File): Promise<void> {
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
if (options) {
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
const folder = sanitizePath(dirPath).split('/').map(encodeURIComponent).join('/').replace(/\/$/, '')
const url = `${this.baseUrl}/api/resources${folder}/${encodeURIComponent(file.name)}?override=true`
const send = () => new Promise<number>((resolve, reject) => {
const xhr = new XMLHttpRequest()
const cleanup = () => options.signal.removeEventListener('abort', abort)
const abort = () => { xhr.abort(); cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
xhr.open('POST', url)
xhr.withCredentials = true
for (const [key, value] of Object.entries(this.headers())) xhr.setRequestHeader(key, value)
xhr.upload.onprogress = (event) => options.onProgress(Math.min(file.size, event.loaded))
xhr.onerror = () => { cleanup(); reject(new Error('Upload connection lost. Keep the companion open and check the server connection.')) }
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
xhr.onload = () => {
cleanup()
if (xhr.status === 401) { resolve(401); return }
if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); return }
if ((xhr.getResponseHeader('Content-Type') || '').includes('text/html')) {
reject(new Error('File Browser returned a login page instead of accepting the upload.')); return
}
resolve(xhr.status)
}
options.signal.addEventListener('abort', abort, { once: true })
if (options.signal.aborted) { abort(); return }
xhr.send(file)
})
if (await send() === 401) {
this._authenticated = false
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
if (await send() === 401) throw new Error('Upload authentication expired. Sign in again.')
}
return
}
const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name)
@@ -0,0 +1,37 @@
<template>
<div class="relative min-w-0 flex-1">
<input
ref="input"
v-model="query"
type="text"
:placeholder="placeholder"
:aria-label="label"
data-controller-no-submit
class="app-header-search w-full pr-10 text-white placeholder-white/50 focus:outline-none transition-colors"
@keydown.esc.prevent="clear"
/>
<button
v-if="query.length"
type="button"
aria-label="Clear search"
title="Clear search"
class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400/60 transition-colors"
@click="clear"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
defineProps<{ placeholder: string; label: string }>()
const query = defineModel<string>({ default: '' })
const input = ref<HTMLInputElement | null>(null)
async function clear() {
query.value = ''
await nextTick()
input.value?.focus()
}
</script>
@@ -353,15 +353,57 @@
<!-- Close Confirmation Modal -->
<Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null">
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="!closingChannel && (closeTarget = null)">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<!-- Fee selection -->
<div class="mb-4">
<label class="text-white/60 text-sm block mb-1">Fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in feePresets"
:key="preset.key"
@click="closeForm.feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="closeForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="closeForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ feePresets.find(p => p.key === closeForm.feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
<input
v-model.number="closeForm.customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="closeForm.customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
</div>
</div>
<div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p>
</div>
<div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="closeTarget = null" :disabled="closingChannel" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="closeChannel"
:disabled="closingChannel"
@@ -457,8 +499,8 @@ function closeTypeLabel(ch: ClosedChannel): string {
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'standard', label: 'Standard', hint: 'Targets ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Targets ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
{ key: 'custom', label: 'Custom' },
]
@@ -577,22 +619,24 @@ function loadChannels(): Promise<void> {
return main
}
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
const form = openForm.value
function feeParams(
form: { feePreset: FeePreset; customSatPerVbyte: number | null; customConfTarget: number | null } = openForm.value,
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 }
}
const rate = form.customSatPerVbyte
const conf = form.customConfTarget
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null }
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { setError('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) { openError.value = 'Target confirmations must be between 1 and 1008'; return null }
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { setError('Target confirmations must be a whole number between 1 and 1008'); return null }
return { target_conf: Math.floor(conf) }
}
openError.value = 'Custom fee requires target confirmations or sats per vByte'
setError('Custom fee requires target confirmations or sats per vByte')
return null
}
@@ -629,7 +673,12 @@ async function openChannel() {
}
}
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
const closeForm = ref(defaultCloseForm())
function confirmClose(ch: Channel) {
if (closingChannel.value) return
closeForm.value = defaultCloseForm()
closeTarget.value = ch
closeError.value = null
}
@@ -637,12 +686,15 @@ function confirmClose(ch: Channel) {
async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return
closeError.value = null
const fee = feeParams(closeForm.value, message => { closeError.value = message })
if (!fee) return
closingChannel.value = true
try {
await rpcClient.call({
method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point },
timeout: 30000,
params: { channel_point: closeTarget.value.channel_point, ...fee },
timeout: 45000,
maxRetries: 1,
})
closeTarget.value = null
await loadChannels()
@@ -9,11 +9,11 @@
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the
backdrop blur alone keeps the pinned tabs legible over scrolling
rows without painting an opaque container onto the modal. -->
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-white/5 backdrop-blur-md">
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-nowrap overflow-x-auto bg-transparent backdrop-blur-md">
<button
v-for="f in filters"
:key="f.key"
class="px-2.5 py-1 rounded-full text-xs transition-colors"
class="shrink-0 whitespace-nowrap px-2.5 py-1 rounded-full text-xs transition-colors"
:class="activeFilter === f.key
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
@@ -0,0 +1,53 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LightningChannelsPanel from '../LightningChannelsPanel.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: vi.fn() }) }))
vi.mock('@/composables/useCachedResource', async () => {
const { ref } = await import('vue')
return { useCachedResource: () => ({ data: ref(null), loadState: ref('ready'), error: ref(null), refresh: vi.fn().mockResolvedValue(undefined) }) }
})
const channel = { chan_id: 'test', remote_pubkey: '02' + 'a'.repeat(64), channel_point: 'b'.repeat(64) + ':0', capacity: 100000, local_balance: 50000, remote_balance: 50000, active: true }
function open() {
const wrapper = mount(LightningChannelsPanel, { global: { stubs: { Teleport: true } } })
const vm = (wrapper.vm as any).$.setupState
vm.confirmClose(channel)
return { wrapper, vm }
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
describe('channel closing fee choice', () => {
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()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.closechannel', params: { channel_point: channel.channel_point, target_conf: target }, maxRetries: 1 }))
expect(vm.closeTarget).toBeNull(); wrapper.unmount()
})
it('sends the custom rate instead of a confirmation target', async () => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = 25; vm.closeForm.customConfTarget = 3
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, sat_per_vbyte: 25 } }))
wrapper.unmount()
})
it.each([0.5, -1, 5001, NaN, Infinity])('rejects invalid custom rate %s before RPC', async rate => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = rate
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled(); expect(vm.closeError).toBeTruthy(); wrapper.unmount()
})
it('requires a custom value and accepts a custom confirmation target', async () => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled()
vm.closeForm.customConfTarget = 2; await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 2 } })); wrapper.unmount()
})
it('keeps the chosen fee and error visible when LND rejects a close', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Peer is offline'))
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'fast'
await vm.closeChannel(); expect(vm.closeTarget).not.toBeNull(); expect(vm.closeError).toBe('Peer is offline'); expect(vm.closeForm.feePreset).toBe('fast'); wrapper.unmount()
})
it('prevents duplicate submits while a close is pending', async () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(() => new Promise(resolve => { finish = resolve }) as never)
const { wrapper, vm } = open(); const pending = vm.closeChannel(); await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledTimes(1); finish({ success: true }); await pending; await flushPromises(); wrapper.unmount()
})
})
@@ -45,7 +45,7 @@
</button>
</div>
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
<button class="glass-button cloud-toolbar-btn" title="Upload file" :disabled="uploading" @click="triggerUpload">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
@@ -61,6 +61,7 @@
<input
ref="fileInput"
type="file"
:disabled="uploading"
class="hidden"
multiple
@change="handleFileSelect"
@@ -74,6 +75,7 @@ import { ref } from 'vue'
defineProps<{
breadcrumbs: { name: string; path: string }[]
viewMode: 'list' | 'grid'
uploading?: boolean
}>()
const emit = defineEmits<{
@@ -0,0 +1,29 @@
<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 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}`" />
<span class="relative min-w-0 flex-1 text-sm truncate" :class="task.error ? 'text-red-300' : 'text-white/80'" :title="label">{{ label }}</span>
<span v-if="task.active" class="relative shrink-0 text-xs tabular-nums text-white/60">{{ percent }}%</span>
<button class="relative shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10" :aria-label="task.active ? 'Cancel upload' : 'Dismiss upload'" @click="task.active ? store.cancelUpload() : store.dismissUpload()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" /></svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCloudStore } from '@/stores/cloud'
defineProps<{ floating?: boolean }>()
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)
const label = computed(() => {
const t = task.value
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`
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
})
</script>
@@ -231,3 +231,50 @@ describe('useCloudStore', () => {
expect(store.error).toBeNull()
})
})
describe('persistent upload batch', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('retains the destination and byte progress across folder navigation', async () => {
const store = useCloudStore(); store.authenticated = true; store.currentPath = '/original'
let release!: () => void
mockedClient.upload.mockImplementationOnce(async (_path, _file, options) => {
options!.onProgress(2)
await new Promise<void>(resolve => { release = resolve })
}).mockResolvedValueOnce(undefined)
mockedClient.listDirectory.mockResolvedValue([])
const job = store.uploadFiles([new File(['abcd'], 'one'), new File(['ef'], 'two')])
expect(store.upload?.sent).toBe(2)
expect(store.upload?.total).toBe(6)
await store.navigate('/elsewhere')
expect(useCloudStore().upload?.active).toBe(true)
release(); await job
expect(mockedClient.upload.mock.calls.map(call => call[0])).toEqual(['/original', '/original'])
expect(store.currentPath).toBe('/elsewhere')
expect(store.upload).toMatchObject({ active: false, sent: 6, completed: 2, error: null })
})
it('aborts the active request, stops the queue and preserves completed files', async () => {
const store = useCloudStore(); store.authenticated = true
mockedClient.listDirectory.mockResolvedValue([])
mockedClient.upload.mockResolvedValueOnce(undefined).mockImplementationOnce((_path, _file, options) => new Promise((_resolve, reject) => {
options!.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))
}))
const files = ['one', 'two', 'three'].map(name => new File(['abc'], name))
const job = store.uploadFiles(files)
await Promise.resolve(); await Promise.resolve()
store.cancelUpload(); await job
expect(mockedClient.upload).toHaveBeenCalledTimes(2)
expect(store.upload).toMatchObject({ active: false, completed: 1, cancelled: true, error: null })
store.dismissUpload(); expect(store.upload).toBeNull()
})
it('keeps failures visible and does not start a second overlapping batch', async () => {
const store = useCloudStore(); store.authenticated = true
mockedClient.listDirectory.mockResolvedValue([])
let fail!: (error: Error) => void
mockedClient.upload.mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject }))
const file = new File(['x'], 'one')
const job = store.uploadFiles([file]); await store.uploadFiles([file])
expect(mockedClient.upload).toHaveBeenCalledTimes(1)
fail(new Error('No space')); await job
expect(store.upload).toMatchObject({ active: false, error: 'No space', completed: 0 })
})
})
+36
View File
@@ -8,6 +8,41 @@ 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)
let uploadController: AbortController | null = null
function cancelUpload() { uploadController?.abort() }
function dismissUpload() { if (!upload.value?.active) upload.value = null }
async function uploadFiles(files: File[]) {
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 }
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) => {
if (upload.value?.active) upload.value.sent = completedBytes + sent
} })
completedBytes += file.size
upload.value.sent = completedBytes
upload.value.completed++
}
} catch (error) {
upload.value.cancelled = controller.signal.aborted
if (!upload.value.cancelled) upload.value.error = error instanceof Error ? error.message : 'Upload failed'
} finally {
upload.value.active = false
uploadController = null
// Navigation must never redirect subsequent files into the new folder.
pathCache.delete(destination)
if (currentPath.value === destination) await refresh()
}
}
// Per-path listing cache: re-entering a folder paints the last listing
// immediately (no spinner) while the fresh listing loads behind it.
const pathCache = new Map<string, FileBrowserItem[]>()
@@ -131,6 +166,7 @@ export const useCloudStore = defineStore('cloud', () => {
}
return {
upload, uploadFiles, cancelUpload, dismissUpload,
currentPath,
items,
loading,
+3 -16
View File
@@ -56,14 +56,7 @@
</button>
</div>
<div class="app-header-search-wrap flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
<button
type="button"
class="sideload-icon-btn"
@@ -106,14 +99,7 @@
>{{ category.name }}</button>
</div>
<div class="flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
<button
type="button"
class="sideload-icon-btn sideload-icon-btn-mobile"
@@ -374,6 +360,7 @@ let appsAnimationDone = false
</script>
<script setup lang="ts">
import AppSearchField from '@/components/AppSearchField.vue'
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
+6 -22
View File
@@ -39,6 +39,8 @@
</div>
</div>
<UploadProgress />
<!-- App Not Installed -->
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -75,19 +77,11 @@
<p class="text-sm text-white/50">Files will be added to the current folder</p>
</div>
</div>
<!-- Upload progress -->
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span class="text-sm text-white/70">Uploading...</span>
</div>
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
<span class="text-sm text-red-400">{{ uploadError }}</span>
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
</div>
<CloudToolbar
:breadcrumbs="cloudStore.breadcrumbs"
:view-mode="viewMode"
:uploading="!!cloudStore.upload?.active"
@navigate="navigateCloudPath"
@refresh="cloudStore.refresh()"
@upload="handleUpload"
@@ -105,6 +99,7 @@
:items="cloudStore.sortedItems"
:loading="cloudStore.loading"
:view-mode="viewMode"
:uploading="!!cloudStore.upload?.active"
@navigate="navigateCloudPath"
@delete="handleDelete"
@play="handlePlay"
@@ -159,6 +154,7 @@
</template>
<script setup lang="ts">
import UploadProgress from '@/components/cloud/UploadProgress.vue'
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
@@ -193,7 +189,6 @@ watch(() => cloudStore.currentPath, (path) => {
})
const iframeLoaded = ref(false)
const uploading = ref(false)
const folderId = computed(() => route.params.folderId as string)
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
@@ -348,7 +343,6 @@ function handleShare(path: string, name: string, isDir: boolean) {
shareTarget.value = { path, name, isDir }
}
const uploadError = ref<string | null>(null)
const draggingOver = ref(false)
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
@@ -372,17 +366,7 @@ function onDrop(e: DragEvent) {
}
async function handleUpload(files: File[]) {
uploading.value = true
uploadError.value = null
try {
for (const file of files) {
await cloudStore.uploadFile(file)
}
} catch (e) {
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
} finally {
uploading.value = false
}
await cloudStore.uploadFiles(files)
}
async function handleDelete(path: string) {
+3 -16
View File
@@ -49,14 +49,7 @@
{{ section.name }}
</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
data-controller-no-submit
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div>
@@ -82,14 +75,7 @@
type="button"
>{{ section.name }}</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
data-controller-no-submit
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
</div>
</div>
@@ -345,6 +331,7 @@ let discoverAnimationDone = false
</script>
<script setup lang="ts">
import AppSearchField from '@/components/AppSearchField.vue'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '@/stores/app'
+74 -50
View File
@@ -396,7 +396,7 @@
accepts for this item are offered -->
<div v-if="payMode === 'choose'" class="space-y-3">
<button
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
v-if="!lnReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="ecashPreparing || downloading === payItem.id"
@click="prepareEcashPay"
@@ -420,8 +420,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>
<span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span>
<span class="block text-sm text-white/50">Pays the seller’s invoice from your node’s Lightning wallet</span>
<span class="block text-base text-white">{{ lnPaying ? (lnReceipt ? 'Checking payment…' : 'Paying…') : (lnReceipt ? 'Retry paid download' : 'Pay with my Lightning node') }}</span>
<span class="block text-sm text-white/50">{{ lnReceipt ? 'Uses the saved payment; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span>
</span>
</button>
@@ -843,6 +843,22 @@ const onchainError = ref('')
const onchainCopied = ref(false)
const lnPaying = ref(false)
const lnError = ref('')
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number }
const lnReceipt = ref<LightningReceipt | null>(null)
function receiptKey(onion: string, id: string) { return `peer-file-lightning:${onion}:${id}` }
function readReceipt(onion: string, id: string): LightningReceipt | null {
const raw = localStorage.getItem(receiptKey(onion, id))
if (!raw) return null
const receipt = JSON.parse(raw) as LightningReceipt
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash)) throw new Error('Saved payment needs recovery. Do not pay again.')
return receipt
}
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
// Must succeed before handing an invoice to a payer. A failed transfer or
// navigation must never turn Retry into a second payment.
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
lnReceipt.value = receipt
}
const onchainPaying = ref(false)
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
@@ -1095,6 +1111,8 @@ function openPayModal(item: CatalogItem) {
onchainCopied.value = false
lnPaying.value = false
lnError.value = ''
try { lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id) }
catch { lnError.value = 'Saved payment could not be read. Do not pay again.' }
onchainPaying.value = false
}
@@ -1116,6 +1134,8 @@ function closePayModal() {
* immediately for any external wallet).
*/
function openQrPay() {
payMode.value = 'qr'
if (lnReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
payMode.value = 'qr'
invoiceData.value = null
invoiceQr.value = ''
@@ -1139,6 +1159,10 @@ function openQrPay() {
* forth doesn't silently stop watching for payment). */
function selectQrTab(tab: 'onchain' | 'lightning') {
if (qrTab.value === tab) return
if (tab === 'onchain' && lnReceipt.value) {
invoiceError.value = 'A Lightning payment is saved. Recover it before choosing another payment method.'
return
}
qrTab.value = tab
if (tab === 'onchain') {
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
@@ -1338,20 +1362,27 @@ async function prepareEcashPay() {
* mobile companion ("paid but never unlocked"); the viewer's Save button
* still offers an explicit download.
*/
function openPurchased(item: CatalogItem, base64Data: string, mimeType?: string) {
const onion = props.peerId || currentPeer.value?.onion
function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string) {
const onion = seller || props.peerId || currentPeer.value?.onion
const url = base64Data !== undefined
? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type))
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(item.id)}`
if (onion) {
try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ }
lnReceipt.value = null
}
if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
const mime = mimeType || item.mime_type
if (mime.startsWith('audio/')) {
// Straight to the bottom-bar player — the blob URL intentionally stays
// alive while the bar owns playback.
audioPlayer.play(
URL.createObjectURL(base64ToBlob(base64Data, mime)),
url,
item.filename.split('/').pop() || item.filename,
)
} else {
releaseViewerUrl()
viewerUrl.value = URL.createObjectURL(base64ToBlob(base64Data, mime))
viewerUrl.value = url
viewerMime.value = mime
viewerItem.value = item
}
@@ -1399,7 +1430,7 @@ async function payWithInvoice() {
invoiceError.value = ''
invoiceWaiting.value = true
try {
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
const res = readReceipt(onion, item.id) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
method: 'content.request-invoice',
params: { onion, content_id: item.id },
timeout: 45000,
@@ -1410,6 +1441,7 @@ async function payWithInvoice() {
return
}
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
keepReceipt(onion, item.id, invoiceData.value)
try {
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
} catch {
@@ -1424,54 +1456,46 @@ async function payWithInvoice() {
/**
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
* release the file. payLightningInvoice resolves to a real terminal state, so
* on success the payment_hash is immediately valid as the download gate token.
* release the file. Keep the invoice before payment so uncertain outcomes can
* retry seller verification and delivery without sending a second payment.
*/
async function payWithLightning() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || lnPaying.value) return
lnPaying.value = true
lnError.value = ''
try {
// 1. Ask the seller to mint a bolt11 (also records the pending entitlement).
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
method: 'content.request-invoice',
params: { onion, content_id: item.id },
timeout: 45000,
})
if (!inv?.bolt11 || !inv?.payment_hash) {
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
return
let inv = readReceipt(onion, item.id)
if (!inv) {
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000,
})
if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access) }
keepReceipt(onion, item.id, inv)
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
localStorage.removeItem(receiptKey(onion, item.id)); lnReceipt.value = null
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
return
}
}
// 2. Pay it from our own node. Tracked to a REAL terminal state — a slow
// multi-hop route resolves via status polling instead of a false failure.
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
return
}
// 3. Settled — pull the file using the payment hash as the gate token.
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
lnReceipt.value = inv
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 120000,
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 960000,
})
if (dl?.data) {
openPurchased(item, dl.data, dl.mime_type)
} else {
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
} catch (e: unknown) {
lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node'
} finally {
lnPaying.value = false
}
lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
} finally { lnPaying.value = false }
}
function scheduleInvoicePoll() {
@@ -1487,19 +1511,19 @@ async function pollInvoice() {
try {
const res = await rpcClient.call<{ paid?: boolean }>({
method: 'content.invoice-status',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 30000,
})
if (res?.paid) {
// Settled — pull the file using the payment hash as the gate token.
invoiceWaiting.value = false
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 120000,
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 960000,
})
if (dl?.data) {
openPurchased(item, dl.data, dl.mime_type)
if (dl?.data !== undefined || dl?.owned === true) {
openPurchased(item, dl.data, dl.mime_type, onion)
} else {
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
@@ -0,0 +1,92 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
const hash = 'a'.repeat(64)
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
const download = vi.fn()
async function open() {
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
await flushPromises()
// Drive the actual component payment handlers, asserting RPC effects rather
// than a duplicate implementation of the payment state machine.
const vm = (wrapper.vm as any).$.setupState
vm.openPayModal(item)
return { wrapper, vm }
}
beforeEach(() => {
localStorage.clear(); vi.clearAllMocks()
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
if (method === 'content.download-peer-invoice') return download()
return { items: [] }
})
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
})
describe('Lightning file delivery recovery', () => {
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
vm.closePayModal(); vm.openPayModal(item)
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
expect(download).toHaveBeenCalledTimes(2)
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ payment_hash: hash, filename: 'bought.txt', price_sats: 5 })
wrapper.unmount()
})
it('restores an uncertain payment on a newly mounted page and only checks/downloads', async () => {
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
download.mockResolvedValue({ error: 'Pending' })
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
const second = await open(); await second.vm.payWithLightning()
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
expect(download).toHaveBeenCalledTimes(1)
second.wrapper.unmount()
})
it('opens cached delivery through HTTP without a base64 file in the response', async () => {
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 206165161 })
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(localStorage.getItem(receiptKey)).toBeNull()
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ cache_only: true })
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('never pays again when the saved receipt is corrupt', async () => {
localStorage.setItem(receiptKey, '{broken')
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(0)
wrapper.unmount()
})
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
const { wrapper, vm } = await open()
vm.openQrPay(); await flushPromises()
expect(vm.payMode).toBe('qr')
expect(vm.qrTab).toBe('lightning')
vm.selectQrTab('onchain'); await flushPromises()
expect(vm.qrTab).toBe('lightning')
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.request-invoice', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
wrapper.unmount()
})
it('does not send payment if the recovery record cannot be saved', async () => {
const { wrapper, vm } = await open()
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
save.mockRestore(); wrapper.unmount()
})
})
@@ -246,3 +246,9 @@ it('does not display a stale Mempool frontend alias beside its live package', ()
expect(shown.map(([id]) => id)).toEqual(['mempool'])
expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web'])
})
it('shows Immich as one app without internal database/cache cards in either tab', () => {
const entries: [string, PackageDataEntry][] = ['immich', 'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis'].map(id => [id, makePkg(id, id, 'media')])
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['immich'])
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
})
+3
View File
@@ -29,6 +29,9 @@ export const isServiceContainer = sharedIsServiceContainer
const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default',
// Stack internals belong to their parent app, including cached inventories
// from nodes predating backend alias normalization.
'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis',
// 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.