Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,185 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useServerStore } from '@/stores/server'
import AppIconGrid from '../AppIconGrid.vue'
const mockWindowOpen = vi.fn()
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn().mockResolvedValue({ credentials: [] }),
},
}))
vi.stubGlobal('open', mockWindowOpen)
function makePkg(id: string): PackageDataEntry {
return {
state: PackageState.Running,
manifest: {
id,
title: id,
version: '1.0.0',
description: { short: '', long: '' },
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
interfaces: { main: { ui: true } },
} as unknown as PackageDataEntry['manifest'],
'static-files': { license: '', instructions: '', icon: '' },
}
}
describe('AppIconGrid', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.useRealTimers()
pinia = createPinia()
setActivePinia(pinia)
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
value: 1024,
writable: true,
configurable: true,
})
Object.defineProperty(window, 'location', {
value: { hostname: '192.0.2.11' },
writable: true,
configurable: true,
})
})
it('opens LND companion UI in the app panel', async () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['lnd', makePkg('lnd')]] },
global: {
plugins: [pinia],
},
})
await wrapper.get('.app-icon-item').trigger('click')
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore(pinia).panelAppId).toBe('lnd')
})
it('shows File Browser credentials before launch even when backend returns no credentials', async () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['filebrowser', makePkg('filebrowser')]] },
global: {
plugins: [pinia],
// The credential modal is <Teleport to="body">'d (so its full-screen
// backdrop isn't clipped by the dashboard's transformed layout) —
// stub it to render inline so wrapper.text() still sees it.
stubs: { teleport: true },
},
})
await wrapper.get('.app-icon-item').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('File Browser credentials')
expect(wrapper.text()).toContain('Username')
expect(wrapper.text()).toContain('admin')
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
})
it('opens unresolved new-tab apps externally on mobile', async () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
const wrapper = mount(AppIconGrid, {
props: { apps: [['gitea', makePkg('gitea')]] },
global: {
plugins: [pinia],
},
})
await wrapper.get('.app-icon-item').trigger('click')
await flushPromises()
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.0.2.11:3001',
'_blank',
'noopener,noreferrer',
)
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
})
it('shows backend uninstall stage while an app is removing', () => {
const pkg = makePkg('indeedhub')
pkg.state = PackageState.Removing
pkg['uninstall-stage'] = 'Stopping containers (2/7)'
useServerStore(pinia).uninstallingApps.add('indeedhub')
const wrapper = mount(AppIconGrid, {
props: { apps: [['indeedhub', pkg]] },
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).toContain('Stopping containers (2/7)')
})
it('supports legacy underscore uninstall stage data', () => {
const pkg = makePkg('indeedhub')
pkg.state = PackageState.Removing
;(pkg as PackageDataEntry & { uninstall_stage?: string }).uninstall_stage = 'Removing app data'
useServerStore(pinia).uninstallingApps.add('indeedhub')
const wrapper = mount(AppIconGrid, {
props: { apps: [['indeedhub', pkg]] },
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).toContain('Removing app data')
})
it('opens app details on long press without launching the app', async () => {
vi.useFakeTimers()
const wrapper = mount(AppIconGrid, {
props: { apps: [['lnd', makePkg('lnd')]] },
global: {
plugins: [pinia],
},
})
const icon = wrapper.get('.app-icon-item')
await icon.trigger('pointerdown')
vi.advanceTimersByTime(550)
await icon.trigger('click')
await flushPromises()
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
})
it('opens app details from the keyboard options shortcut', async () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['lnd', makePkg('lnd')]] },
global: {
plugins: [pinia],
},
})
await wrapper.get('.app-icon-item').trigger('keydown.space')
expect(wrapper.emitted('goToApp')).toEqual([['lnd']])
})
})
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AppsUninstallModal from '../AppsUninstallModal.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, string>) => {
if (params?.name) return `${key} ${params.name}`
return key
},
}),
}))
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
describe('AppsUninstallModal', () => {
it('emits the delete-data choice when uninstall is confirmed', async () => {
const wrapper = mount(AppsUninstallModal, {
props: {
show: true,
appTitle: 'File Browser',
uninstalling: false,
},
})
const checkbox = document.body.querySelector<HTMLInputElement>('input[type="checkbox"]')
expect(checkbox).not.toBeNull()
checkbox?.click()
const confirmButton = document.body.querySelector<HTMLButtonElement>('button.glass-button-danger')
expect(confirmButton).not.toBeNull()
confirmButton?.click()
expect(wrapper.emitted('confirm')?.[0]).toEqual([true])
})
})
@@ -0,0 +1,75 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import LightningChannels from '@/components/LightningChannelsPanel.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeChannel() {
return {
chan_id: '123',
remote_pubkey: 'peer-pubkey',
capacity: 100_000,
local_balance: 60_000,
remote_balance: 40_000,
active: true,
status: 'active',
channel_point: 'txid:0',
}
}
describe('LightningChannels', () => {
it('keeps channels visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({
channels: [makeChannel()],
total_inbound: 40_000,
total_outbound: 60_000,
})
// The panel's setup pulls a Pinia store via useTxExplorer — mount with a
// fresh Pinia or setup throws before the first render.
const wrapper = mount(LightningChannels, {
global: { plugins: [createPinia()] },
})
await flushPromises()
expect(wrapper.text()).toContain('peer-pubkey')
expect(wrapper.text()).toContain('100.0k sats')
const pending = deferred<{ channels: []; total_inbound: number; total_outbound: number }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadChannels: () => Promise<void> }).loadChannels()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('peer-pubkey')
expect(wrapper.text()).toContain('Refreshing channels...')
expect(wrapper.text()).not.toContain('Loading channels...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('peer-pubkey')
expect(wrapper.text()).toContain('offline')
})
})
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { resolveAppCredentials } from '../appCredentials'
describe('resolveAppCredentials', () => {
it('uses backend credentials when they are available', () => {
expect(resolveAppCredentials('filebrowser', {
title: 'Backend credentials',
credentials: [{ label: 'Password', value: 'secret' }],
})?.credentials[0]?.value).toBe('secret')
})
it('falls back to File Browser default credentials when backend data is not available', () => {
const result = resolveAppCredentials('filebrowser', { credentials: [] })
expect(result?.title).toBe('File Browser credentials')
expect(result?.credentials).toEqual([
{ label: 'Username', value: 'admin' },
{ label: 'Password', value: 'admin', sensitive: true },
])
})
it('falls back to PhotoPrism manifest credentials when backend data is not available', () => {
const result = resolveAppCredentials('photoprism', { credentials: [] })
expect(result?.title).toBe('PhotoPrism credentials')
expect(result?.credentials).toEqual([
{ label: 'Username', value: 'admin' },
{ label: 'Password', value: 'archipelago', sensitive: true },
])
})
it('does not invent credentials for unknown apps', () => {
expect(resolveAppCredentials('unknown', { credentials: [] })).toBeNull()
})
})
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { ref, nextTick } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { useLastKnownPackages, type PackageMap } from '../appPackageCache'
function makePkg(id: string): PackageDataEntry {
return {
state: PackageState.Running,
manifest: {
id,
title: id,
version: '1.0.0',
description: { short: '', long: '' },
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
},
'static-files': { license: '', instructions: '', icon: '' },
}
}
describe('useLastKnownPackages', () => {
it('keeps the last package list visible while the scanner reports not ready', async () => {
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
const containersScanned = ref(true)
const cache = useLastKnownPackages(livePackages, containersScanned)
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
expect(cache.isUsingLastKnownPackages.value).toBe(false)
containersScanned.value = false
livePackages.value = {}
await nextTick()
expect(Object.keys(cache.packages.value)).toEqual(['filebrowser'])
expect(cache.isUsingLastKnownPackages.value).toBe(true)
})
it('accepts an empty list once the scanner has completed', async () => {
const livePackages = ref<PackageMap>({ filebrowser: makePkg('filebrowser') })
const containersScanned = ref(true)
const cache = useLastKnownPackages(livePackages, containersScanned)
livePackages.value = {}
await nextTick()
expect(cache.packages.value).toEqual({})
expect(cache.lastKnownPackages.value).toEqual({})
expect(cache.isUsingLastKnownPackages.value).toBe(false)
})
})
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
function makePkg(id: string, title: string, category: string): PackageDataEntry {
return {
state: PackageState.Running,
manifest: {
id,
title,
version: '1.0.0',
description: { short: '', long: '' },
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
category,
} as unknown as PackageDataEntry['manifest'],
'static-files': { license: '', instructions: '', icon: '' },
}
}
describe('appsConfig service filtering', () => {
it('treats bitcoin stack UI sidecars as services', () => {
expect(isServiceContainer('bitcoin-ui')).toBe(true)
expect(isServiceContainer('lnd-ui')).toBe(true)
expect(isServiceContainer('electrs-ui')).toBe(true)
})
it('treats container aliases as services even with non-service keys', () => {
const aliasPkg = makePkg('bitcoin-ui', 'Bitcoin UI', 'money')
expect(isServicePackage('core-lnd-ui', aliasPkg)).toBe(true)
})
it('removes service-only categories from app category tabs', () => {
const packages = ref<Record<string, PackageDataEntry>>({
'core-bitcoin-ui': makePkg('bitcoin-ui', 'Bitcoin UI', 'money'),
'filebrowser': makePkg('filebrowser', 'File Browser', 'data'),
})
const allCategories = ref([
{ id: 'all', name: 'All' },
{ id: 'money', name: 'Money' },
{ id: 'data', name: 'Data' },
])
const visible = useCategoriesWithApps(packages, allCategories)
expect(visible.value.map(c => c.id)).toEqual(['all', 'data'])
})
it('filters apps tab by category using manifest-aware service checks', () => {
const entries: Array<[string, PackageDataEntry]> = [
['core-bitcoin-ui', makePkg('bitcoin-ui', 'Bitcoin UI', 'money')],
['filebrowser', makePkg('filebrowser', 'File Browser', 'data')],
['btcpay-server', makePkg('btcpay-server', 'BTCPay', 'commerce')],
]
const appsAll = filterEntriesForTab(entries, 'apps', 'all')
expect(appsAll.map(([id]) => id)).toEqual(['filebrowser', 'btcpay-server'])
const appsData = filterEntriesForTab(entries, 'apps', 'data')
expect(appsData.map(([id]) => id)).toEqual(['filebrowser'])
})
it('routes service aliases into services tab and excludes user apps', () => {
const entries: Array<[string, PackageDataEntry]> = [
['core-lnd-ui', makePkg('lnd-ui', 'LND UI', 'money')],
['grafana', makePkg('grafana', 'Grafana', 'data')],
]
const services = filterEntriesForTab(entries, 'services', 'all')
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
})
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'
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
})
it('an unmapped id gets the A mark, not a guessed png that 404s', () => {
// strfry 404'd live on 2026-08-07: no curated entry, no fallback entry,
// no service prefix — the old `${id}.png` guess produced a broken tile.
const pkg = makePkg('strfry', 'strfry', 'nostr')
expect(resolveAppIcon('strfry', pkg)).toBe(DEFAULT_APP_ICON)
})
it('classifies an unknown app by whether its manifest declares a UI (#45)', () => {
// Headless: a LAN address but no declared UI → Website.
const headless = makePkg('some-backend', 'Some Backend', 'other')
headless.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9000' } } } as unknown as PackageDataEntry['installed']
expect(hasFrontendUi(headless)).toBe(false)
expect(isWebsitePackage('some-backend', headless)).toBe(true)
// Front-end app: declares interfaces.main.ui → My Apps even when not in the
// curated category map.
const uiApp = makePkg('some-ui-app', 'Some UI App', 'other')
;(uiApp.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:9001' } }
uiApp.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9001' } } } as unknown as PackageDataEntry['installed']
expect(hasFrontendUi(uiApp)).toBe(true)
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
})
it('never offers Launch for an unknown container with a bare exposed port', () => {
// A self-deployed compose stack (e.g. podsteadr) publishes a port, so it
// has a runtime lan-address — but no manifest-declared or probed UI. It
// must classify as a service and must NOT get a Launch button.
const selfDeployed = makePkg('podsteadr', 'podsteadr', 'other')
selfDeployed.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(isWebsitePackage('podsteadr', selfDeployed)).toBe(true)
expect(canLaunch(selfDeployed)).toBe(false)
})
it('offers Launch for an unknown container once the backend confirms a UI', () => {
const confirmedUi = makePkg('podsteadr', 'podsteadr', 'other')
;(confirmedUi.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
confirmedUi.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(confirmedUi)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
service.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9100' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(service)).toBe(false)
})
it('keeps Launch for curated apps that rely on a runtime address alone', () => {
const known = makePkg('jellyfin', 'Jellyfin', 'media')
known.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8096' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(known)).toBe(true)
})
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
const pkg = makePkg('fedimint', 'Fedimint', 'money')
pkg.state = PackageState.Starting
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8175' } } } as unknown as PackageDataEntry['installed']
expect(launchBlockedReason('fedimint', pkg)).toContain('Bitcoin')
expect(canLaunch(pkg)).toBe(true)
})
})
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { parseSideloadPortMapping, validateSideloadRequest } from '../sideloadValidation'
function makePkg(id: string, title: string, lanAddress?: string): PackageDataEntry {
return {
state: PackageState.Running,
manifest: {
id,
title,
version: '1.0.0',
description: { short: '', long: '' },
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
},
installed: lanAddress
? {
'current-dependents': {},
'current-dependencies': {},
'last-backup': null,
status: 'running',
'interface-addresses': {
main: {
'lan-address': lanAddress,
'tor-address': '',
},
},
}
: undefined,
}
}
describe('sideloadValidation', () => {
it('parses host and container port mappings', () => {
expect(parseSideloadPortMapping('3009:80')).toEqual({ host: 3009, container: 80 })
expect(parseSideloadPortMapping('')).toBeNull()
})
it('rejects malformed port mappings', () => {
expect(() => parseSideloadPortMapping('3009')).toThrow('host:container')
expect(() => parseSideloadPortMapping('99999:80')).toThrow('between 1 and 65535')
})
it('rejects duplicate app IDs', () => {
const packages = { excalidraw: makePkg('excalidraw', 'Excalidraw') }
expect(validateSideloadRequest('excalidraw', '3009:80', packages)).toContain('already installed')
})
it('rejects reserved host ports', () => {
expect(validateSideloadRequest('demo', '9000:80', {})).toContain('reserved')
})
it('rejects host ports already used by installed apps', () => {
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
expect(validateSideloadRequest('demo', '8083:80', packages)).toContain('File Browser')
})
it('accepts available host ports', () => {
const packages = { filebrowser: makePkg('filebrowser', 'File Browser', 'http://localhost:8083') }
expect(validateSideloadRequest('demo', '3018:80', packages)).toBeNull()
})
})