frontend: polish app launch and release experience

This commit is contained in:
archipelago 2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -103,6 +103,47 @@ const walletState = {
}
function randomHex(bytes) { return Array.from({length: bytes}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join('') }
const bitcoinRelayMockState = {
settings: {
enabled_for_peers: true,
allow_peer_requests: true,
allow_http: false,
allow_https: true,
allow_tor: true,
selected_peer_pubkey: '03d9b8a8db6b4f4d8b8d04c7a467c101f04c0ecbabc0e29e4dcb812a3b1c5f8f04',
http_endpoint: '',
https_endpoint: 'https://shard.tx1138.com/',
tor_endpoint: 'http://btc-relay-demoabcdefghijklmnop.onion/',
},
trusted_nodes: [
{
pubkey: '03d9b8a8db6b4f4d8b8d04c7a467c101f04c0ecbabc0e29e4dcb812a3b1c5f8f04',
onion: 'trustedalphaabcdefghijklmnop.onion',
name: 'Trusted Alpha',
relay_approved: true,
},
{
pubkey: '02f6ab6c88037cd527a92f3a016a7bd18bb2ebd91d5a3efb1161481b5cf7d9ea2a',
onion: 'trustedbetabcdefghijklmnopq.onion',
name: 'Trusted Beta',
relay_approved: false,
},
],
requests: [
{
id: 'relay-demo-incoming',
direction: 'incoming',
status: 'pending',
peer_pubkey: '02f6ab6c88037cd527a92f3a016a7bd18bb2ebd91d5a3efb1161481b5cf7d9ea2a',
peer_onion: 'trustedbetabcdefghijklmnopq.onion',
peer_name: 'Trusted Beta',
message: 'Can I use your node for a Wasabi broadcast?',
created_at: new Date(Date.now() - 12 * 60 * 1000).toISOString(),
updated_at: new Date(Date.now() - 12 * 60 * 1000).toISOString(),
},
],
}
// User state (simulated file-based storage)
let userState = {
setupComplete: false,
@ -719,6 +760,16 @@ function staticApp({ id, title, version, shortDesc, longDesc, license, state, la
// Static dev apps (always shown in My Apps when using mock backend)
const staticDevApps = {
'bitcoin-knots': staticApp({
id: 'bitcoin-knots',
title: 'Bitcoin Knots',
version: '27.1',
shortDesc: 'Full Bitcoin node',
longDesc: 'Validate and relay Bitcoin blocks and transactions with the Archipelago custom node UI.',
state: 'running',
lanPort: 8334,
icon: '/assets/img/app-icons/bitcoin-knots.webp',
}),
bitcoin: staticApp({
id: 'bitcoin',
title: 'Bitcoin Core',
@ -765,6 +816,16 @@ const staticDevApps = {
state: 'running',
lanPort: null,
}),
meshtastic: staticApp({
id: 'meshtastic',
title: 'Meshtastic',
version: '2-daily-alpine',
shortDesc: 'LoRa mesh networking',
longDesc: 'Open-source mesh networking for LoRa radios. Create decentralized communication networks.',
state: 'running',
lanPort: 4403,
icon: '/assets/img/app-icons/meshcore.svg',
}),
filebrowser: staticApp({
id: 'filebrowser',
title: 'File Browser',
@ -826,6 +887,200 @@ app.options('/rpc/v1', (req, res) => {
res.status(200).end()
})
app.get('/', (_req, res) => {
const uiPort = process.env.VITE_DEV_SERVER_PORT || '8102'
res
.status(200)
.type('html')
.send(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=http://localhost:${uiPort}/">
<title>Archipelago dev backend</title>
</head>
<body>
<p>This is the mock JSON-RPC backend. Open the dashboard at
<a href="http://localhost:${uiPort}/">http://localhost:${uiPort}/</a>.
</p>
</body>
</html>`)
})
app.get('/rpc/v1', (_req, res) => {
const uiPort = process.env.VITE_DEV_SERVER_PORT || '8102'
res
.status(405)
.type('text/plain')
.send(`JSON-RPC is available at /rpc/v1 for POST requests only.\nOpen the dashboard at http://localhost:${uiPort}/.\n`)
})
function mockBitcoinBlockchainInfo() {
return {
chain: 'main',
blocks: 902418,
headers: 902418,
bestblockhash: randomHex(32),
difficulty: 126_984_812_384_099.3,
verificationprogress: 0.999998,
initialblockdownload: false,
size_on_disk: 678_000_000_000,
pruned: false,
chainwork: '00000000000000000000000000000000000000008d4b42d8b9b0000000000000',
}
}
function mockBitcoinNetworkInfo() {
return {
version: 270100,
subversion: '/Satoshi:27.1/Knots:20240513/',
protocolversion: 70016,
localservices: '0000000000000409',
localrelay: true,
timeoffset: 0,
networkactive: true,
connections: 18,
networks: [
{ name: 'ipv4', limited: false, reachable: true, proxy: '', proxy_randomize_credentials: false },
{ name: 'onion', limited: false, reachable: true, proxy: '127.0.0.1:9050', proxy_randomize_credentials: true },
],
}
}
function bitcoinRelayStatusPayload() {
return {
settings: bitcoinRelayMockState.settings,
trusted_nodes: bitcoinRelayMockState.trusted_nodes,
requests: bitcoinRelayMockState.requests,
local_node: {
synced: true,
blocks: 902418,
headers: 902418,
chain: 'main',
status_ok: true,
status_stale: false,
error: null,
},
credentials: {
username: 'txrelay',
available: true,
password_available: true,
rpcauth_available: true,
client_env_available: true,
client_env_path: '/var/lib/archipelago/secrets/bitcoin-rpc-txrelay-client.env',
restart_hint: 'If this was just generated, restart Bitcoin Core/Knots so bitcoind loads the txrelay rpcauth whitelist.',
},
}
}
app.get('/app/bitcoin-ui/', async (_req, res) => {
try {
const html = await fs.readFile(path.join(__dirname, '..', 'docker', 'bitcoin-ui', 'index.html'), 'utf8')
res.type('html').send(html)
} catch (error) {
res.status(500).type('text/plain').send(`Unable to load Bitcoin UI mock: ${error.message}`)
}
})
app.get('/app/bitcoin-ui/bitcoin-status', (_req, res) => {
res.json({
ok: true,
stale: false,
updated_at_ms: Date.now(),
error: null,
blockchain_info: mockBitcoinBlockchainInfo(),
network_info: mockBitcoinNetworkInfo(),
index_info: {
txindex: { synced: true, best_block_height: 902418 },
blockfilterindex: { synced: true, best_block_height: 902418 },
},
zmq_notifications: [
{ type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 },
{ type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 },
],
})
})
app.post('/app/bitcoin-ui/bitcoin-rpc/', (req, res) => {
const { id = 'bitcoin-ui', method } = req.body || {}
const results = {
getblockchaininfo: mockBitcoinBlockchainInfo(),
getnetworkinfo: mockBitcoinNetworkInfo(),
getindexinfo: {
txindex: { synced: true, best_block_height: 902418 },
blockfilterindex: { synced: true, best_block_height: 902418 },
},
getzmqnotifications: [
{ type: 'pubhashblock', address: 'tcp://127.0.0.1:28332', hwm: 1000 },
{ type: 'pubrawtx', address: 'tcp://127.0.0.1:28333', hwm: 1000 },
],
getmempoolinfo: { loaded: true, size: 18452, bytes: 62400000, usage: 143000000, maxmempool: 300000000 },
getpeerinfo: Array.from({ length: 18 }, (_, index) => ({ id: index, addr: `198.51.100.${index + 10}:8333`, inbound: index % 3 === 0 })),
}
if (!Object.prototype.hasOwnProperty.call(results, method)) {
return res.json({ id, result: null, error: { code: -32601, message: `Method not found: ${method}` } })
}
res.json({ id, result: results[method], error: null })
})
app.post('/app/bitcoin-ui/rpc/v1', (req, res) => {
const { method, params = {} } = req.body || {}
const now = new Date().toISOString()
switch (method) {
case 'bitcoin.relay-status':
return res.json({ result: bitcoinRelayStatusPayload(), error: null })
case 'bitcoin.relay-update-settings':
bitcoinRelayMockState.settings = {
...bitcoinRelayMockState.settings,
...params,
}
return res.json({ result: bitcoinRelayStatusPayload(), error: null })
case 'bitcoin.relay-request-peer': {
const peer = bitcoinRelayMockState.trusted_nodes.find(p => p.pubkey === params.peer_pubkey)
if (!peer) return res.status(400).json({ error: { message: 'Peer is not in trusted nodes' } })
const request = {
id: `relay-demo-${Date.now()}`,
direction: 'outbound',
status: 'pending',
peer_pubkey: peer.pubkey,
peer_onion: peer.onion,
peer_name: peer.name,
message: params.message || '',
created_at: now,
updated_at: now,
}
bitcoinRelayMockState.requests.push(request)
return res.json({ result: { ok: true, request_id: request.id }, error: null })
}
case 'bitcoin.relay-approve-request':
case 'bitcoin.relay-reject-request': {
const requestId = params.id || params.request_id
const request = bitcoinRelayMockState.requests.find(r => r.id === requestId)
if (!request) return res.status(404).json({ error: { message: `Request not found: ${requestId}` } })
request.status = method.endsWith('approve-request') ? 'approved' : 'rejected'
request.updated_at = now
if (request.status === 'approved') {
request.approved_endpoint = bitcoinRelayMockState.settings.https_endpoint || bitcoinRelayMockState.settings.tor_endpoint || bitcoinRelayMockState.settings.http_endpoint
request.credential_secret_path = '/var/lib/archipelago/secrets/bitcoin-relay-peer-demo.env'
}
return res.json({ result: { ok: true, request_id: request.id }, error: null })
}
case 'bitcoin.relay-create-tor-service':
bitcoinRelayMockState.settings.allow_tor = true
bitcoinRelayMockState.settings.tor_endpoint = 'http://btc-relay-demoabcdefghijklmnop.onion/'
return res.json({
result: {
created: true,
name: 'bitcoin-rpc',
onion_address: 'btc-relay-demoabcdefghijklmnop.onion',
},
error: null,
})
default:
return res.status(404).json({ error: { message: `Unknown mock Bitcoin UI RPC method: ${method}` } })
}
})
// RPC endpoint
app.post('/rpc/v1', (req, res) => {
const { method, params } = req.body

Binary file not shown.

View File

@ -89,6 +89,7 @@ import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import { useUIModeStore } from '@/stores/uiMode'
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
import { shouldShowIntroSplash } from '@/utils/introSplash'
const router = useRouter()
const screensaverStore = useScreensaverStore()
@ -176,6 +177,129 @@ const route = useRoute()
// Start with splash hidden onMounted decides whether to show it
const showSplash = ref(false)
const isReady = ref(false)
let modalOverlayObserver: MutationObserver | null = null
let lockedScrollY = 0
let previousBodyStyles: Partial<CSSStyleDeclaration> = {}
let bodyLockedForModal = false
let modalTouchY: number | null = null
function hasBlockingOverlay() {
if (typeof document === 'undefined') return false
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
.some((el) => {
const style = window.getComputedStyle(el)
const rect = el.getBoundingClientRect()
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.pointerEvents !== 'none'
&& rect.width > 0
&& rect.height > 0
})
}
function visibleBlockingOverlays() {
if (typeof document === 'undefined') return []
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
.filter((el) => {
const style = window.getComputedStyle(el)
const rect = el.getBoundingClientRect()
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.pointerEvents !== 'none'
&& rect.width > 0
&& rect.height > 0
})
}
function closestOverlay(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return null
return visibleBlockingOverlays().find((overlay) => overlay.contains(target)) || null
}
function canScrollInsideOverlay(target: EventTarget | null, overlay: HTMLElement, deltaY: number) {
if (!(target instanceof HTMLElement)) return false
let el: HTMLElement | null = target
while (el && overlay.contains(el)) {
const style = window.getComputedStyle(el)
const canScrollY = /(auto|scroll)/.test(style.overflowY)
&& el.scrollHeight > el.clientHeight
if (canScrollY) {
if (deltaY < 0 && el.scrollTop > 0) return true
if (deltaY > 0 && el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true
}
if (el === overlay) break
el = el.parentElement
}
return false
}
function containModalWheel(ev: WheelEvent) {
if (!bodyLockedForModal) return
const overlay = closestOverlay(ev.target)
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, ev.deltaY)) {
ev.preventDefault()
}
}
function containModalTouchStart(ev: TouchEvent) {
modalTouchY = ev.touches[0]?.clientY ?? null
}
function containModalTouchMove(ev: TouchEvent) {
if (!bodyLockedForModal) return
const currentY = ev.touches[0]?.clientY
if (currentY === undefined || modalTouchY === null) {
ev.preventDefault()
return
}
const deltaY = modalTouchY - currentY
modalTouchY = currentY
const overlay = closestOverlay(ev.target)
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, deltaY)) {
ev.preventDefault()
}
}
function lockBodyForModal() {
if (bodyLockedForModal || typeof document === 'undefined') return
lockedScrollY = window.scrollY || document.documentElement.scrollTop || 0
previousBodyStyles = {
position: document.body.style.position,
top: document.body.style.top,
left: document.body.style.left,
right: document.body.style.right,
width: document.body.style.width,
overflow: document.body.style.overflow,
}
document.body.style.position = 'fixed'
document.body.style.top = `-${lockedScrollY}px`
document.body.style.left = '0'
document.body.style.right = '0'
document.body.style.width = '100%'
document.body.style.overflow = 'hidden'
document.documentElement.classList.add('modal-scroll-locked')
bodyLockedForModal = true
}
function unlockBodyForModal() {
if (!bodyLockedForModal || typeof document === 'undefined') return
document.body.style.position = previousBodyStyles.position || ''
document.body.style.top = previousBodyStyles.top || ''
document.body.style.left = previousBodyStyles.left || ''
document.body.style.right = previousBodyStyles.right || ''
document.body.style.width = previousBodyStyles.width || ''
document.body.style.overflow = previousBodyStyles.overflow || ''
document.documentElement.classList.remove('modal-scroll-locked')
window.scrollTo(0, lockedScrollY)
previousBodyStyles = {}
bodyLockedForModal = false
modalTouchY = null
}
function syncModalBodyLock() {
if (hasBlockingOverlay()) lockBodyForModal()
else unlockBodyForModal()
}
/**
* Determine if splash screen should be shown
@ -213,18 +337,51 @@ onMounted(async () => {
window.addEventListener('keydown', onUserActivity)
window.addEventListener('touchstart', onUserActivity)
window.addEventListener('message', onShareToMeshMessage)
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const isDirectRoute = route.path !== '/'
document.addEventListener('wheel', containModalWheel, { capture: true, passive: false })
document.addEventListener('touchstart', containModalTouchStart, { capture: true, passive: true })
document.addEventListener('touchmove', containModalTouchMove, { capture: true, passive: false })
modalOverlayObserver = new MutationObserver(() => {
requestAnimationFrame(syncModalBodyLock)
})
modalOverlayObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style'],
})
syncModalBodyLock()
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot)
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
if (fromBoot && !seenIntro) {
if (splashCandidate && onboardingComplete !== true) {
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
onboardingComplete = await checkOnboardingStatus()
} catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
}
}
if (!seenIntro && onboardingComplete === true) {
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
seenIntro = true
}
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot, 'onboardingComplete:', onboardingComplete)
if (shouldShowIntroSplash({
seenIntro,
routePath: route.path,
fromBoot,
devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete,
})) {
// Coming from boot screen show the full splash intro (Enter to Exit typing logo)
showSplash.value = true
} else if (!seenIntro && !isDirectRoute && import.meta.env.VITE_DEV_MODE !== 'boot') {
// Normal first visit (not boot mode) show splash intro
showSplash.value = true
} else {
// Already seen intro, direct route, or boot mode (boot screen handles intro)
// Set isReady BEFORE hiding splash to prevent flash of partial content
@ -243,6 +400,12 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', onUserActivity)
window.removeEventListener('touchstart', onUserActivity)
window.removeEventListener('message', onShareToMeshMessage)
document.removeEventListener('wheel', containModalWheel, { capture: true })
document.removeEventListener('touchstart', containModalTouchStart, { capture: true })
document.removeEventListener('touchmove', containModalTouchMove, { capture: true })
modalOverlayObserver?.disconnect()
modalOverlayObserver = null
unlockBodyForModal()
})
/**

View File

@ -450,6 +450,12 @@ describe('RPCClient convenience methods', () => {
expect(getLastMethod()).toBe('package.uninstall')
})
it('uninstallPackage forwards preserve_data when requested', async () => {
mockSuccess(undefined)
await rpcClient.uninstallPackage('btc', { preserveData: true })
expect(getLastParams()).toEqual({ id: 'btc', preserve_data: true })
})
it('startPackage calls package.start', async () => {
mockSuccess(undefined)
await rpcClient.startPackage('btc')

View File

@ -201,7 +201,7 @@ class RPCClient {
currentPassword: string
newPassword: string
alsoChangeSsh?: boolean
}): Promise<{ success: boolean }> {
}): Promise<{ success: boolean; ssh_updated?: boolean; ssh_error?: string | null }> {
return this.call({
method: 'auth.changePassword',
params: {
@ -536,14 +536,17 @@ class RPCClient {
})
}
async uninstallPackage(id: string): Promise<{ status: string; package_id: string }> {
async uninstallPackage(
id: string,
options: { preserveData?: boolean } = {},
): Promise<{ status: string; package_id: string }> {
// Backend is async — returns { status: 'removing' } immediately after
// flipping state. Graceful stop (up to 600s for bitcoin) and data wipe
// (up to minutes for large chainstate) run in a background task.
// Progress shown via uninstall_stage field on the package entry.
return this.call({
method: 'package.uninstall',
params: { id },
params: { id, ...(options.preserveData !== undefined ? { preserve_data: options.preserveData } : {}) },
timeout: 15000,
})
}

View File

@ -39,6 +39,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
const props = withDefaults(defineProps<{
show: boolean
@ -65,6 +66,7 @@ function close() {
}
useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show))
</script>
<style scoped>

View File

@ -11,10 +11,18 @@
class="glass-card p-5 w-full max-w-sm relative z-10 mb-20 sm:mb-0"
@click.stop
>
<!-- Icon -->
<div class="flex justify-center mb-3">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button
type="button"
class="absolute right-3 top-3 h-8 w-8 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white hover:bg-white/10 transition-colors"
aria-label="Close companion modal"
@click="dismiss"
>
&times;
</button>
<div class="flex items-start gap-4 mb-4">
<div class="w-12 h-12 rounded-xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="7" width="18" height="11" rx="3" stroke-width="1.5" />
<rect x="7.5" y="10" width="2" height="5" rx="0.5" fill="currentColor" />
<rect x="6" y="11.5" width="5" height="2" rx="0.5" fill="currentColor" />
@ -22,38 +30,44 @@
<circle cx="14" cy="13.5" r="1.2" fill="currentColor" />
</svg>
</div>
</div>
<h3 class="text-lg font-semibold text-white text-center mb-2">Remote Companion</h3>
<p class="text-sm text-white/60 text-center mb-4 leading-relaxed">
Control your node from another device. Install the
<span class="text-orange-400 font-medium">Archipelago</span>
companion app on your phone, connect to the same network, and use it as a
gamepad or keyboard.
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-white mb-1">Remote Companion</h3>
<p class="text-sm text-white/60 leading-relaxed">
Install the Archipelago companion app on your phone, scan the code, and connect to the same node.
</p>
<div class="flex flex-col gap-2 text-xs text-white/40">
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">1</span>
<span>Install the APK on your phone</span>
</div>
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">2</span>
<span>Enter your node address and password</span>
</div>
<div class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-[10px] font-bold">3</span>
<span>Use D-pad &amp; buttons or keyboard to control apps</span>
</div>
</div>
<button
class="mt-4 w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30
text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors"
@click="dismiss"
<div class="mb-4">
<a
:href="companionDownloadUrl"
class="md:hidden inline-flex w-full items-center justify-center rounded-lg bg-orange-500/20 border border-orange-500/30 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/30 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
Got it
</button>
Download companion app
</a>
<div class="hidden md:flex justify-center">
<div class="w-[128px] rounded-2xl border border-white/10 bg-white/[0.03] p-1 overflow-hidden">
<img
v-if="qrDataUrl"
:src="qrDataUrl"
alt="Companion app download QR code"
class="block w-full max-w-full h-auto rounded-lg bg-white"
/>
<div v-else class="w-full aspect-square rounded-lg bg-white/5"></div>
</div>
</div>
</div>
<a
:href="companionDownloadUrl"
class="hidden md:block w-full py-2.5 rounded-lg bg-orange-500/20 border border-orange-500/30 text-orange-400 text-sm font-medium hover:bg-orange-500/30 transition-colors text-center"
target="_blank"
rel="noopener noreferrer"
>
Download companion app
</a>
</div>
</div>
</Transition>
@ -61,11 +75,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import * as QRCode from 'qrcode'
const STORAGE_KEY = 'neode_companion_intro_seen'
const DEFAULT_DOWNLOAD_URL = '/packages/archipelago-companion.apk.zip'
const visible = ref(false)
const qrDataUrl = ref('')
const companionDownloadUrl = import.meta.env.VITE_COMPANION_APK_URL || DEFAULT_DOWNLOAD_URL
onMounted(() => {
try {
@ -78,6 +96,19 @@ onMounted(() => {
}
})
watch(visible, async (isVisible) => {
if (!isVisible) return
qrDataUrl.value = await QRCode.toDataURL(companionDownloadUrl, {
width: 112,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111111',
light: '#ffffff',
},
})
}, { immediate: true, flush: 'post' })
function dismiss() {
visible.value = false
try {

View File

@ -20,6 +20,7 @@ const sharingLocation = ref(false)
const locationSource = ref<'browser' | 'device'>('browser')
const locationError = ref('')
const hasDeviceGps = computed(() => mesh.deadmanStatus?.has_gps ?? false)
const locationPermissionDenied = computed(() => locationError.value === 'Location permission denied')
let geoWatchId: number | null = null
function toggleLocationSharing() {
@ -337,7 +338,7 @@ onUnmounted(() => {
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<span>Enable location sharing to see nodes on the map</span>
<span>{{ locationPermissionDenied ? 'Local location is off. Other device positions will appear when received.' : 'Waiting for mesh device positions.' }}</span>
</div>
<!-- Location sharing overlay -->
@ -373,7 +374,9 @@ onUnmounted(() => {
>Mesh Radio GPS</button>
</div>
<div v-if="locationError" class="mesh-map-location-error">{{ locationError }}</div>
<div v-if="locationError" class="mesh-map-location-error">
{{ locationPermissionDenied ? 'Location permission denied. Peer locations can still appear on the map.' : locationError }}
</div>
</div>
</div>
</template>

View File

@ -39,6 +39,7 @@
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</div>
@ -67,6 +68,7 @@ import { ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
const { t } = useI18n()
@ -124,6 +126,9 @@ async function receive() {
nextTick(() => renderQr(res.payment_request, lightningQrCanvas.value, 'lightning:'))
} else if (receiveMethod.value === 'onchain') {
const res = await rpcClient.call<{ address: string }>({ method: 'lnd.newaddress' })
if (!res.address) {
throw new Error('LND did not return a Bitcoin address')
}
onchainAddress.value = res.address
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else {
@ -136,7 +141,9 @@ async function receive() {
emit('received')
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed'
error.value = receiveMethod.value === 'onchain'
? explainReceiveAddressFailure(err)
: err instanceof Error ? err.message : 'Failed'
} finally {
processing.value = false
}

View File

@ -93,14 +93,6 @@
</button>
</div>
</template>
<!-- AI Assistant placeholder -->
<div class="p-2 border-t border-white/10">
<div class="px-3 py-2 text-xs font-medium text-white/50 uppercase tracking-wider">AI Assistant</div>
<div class="px-3 py-3 rounded-lg bg-white/5 text-white/50 text-sm">
Coming soon ask questions about your node, apps, and Bitcoin.
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,24 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseModal from '../BaseModal.vue'
describe('BaseModal', () => {
afterEach(() => {
document.body.style.overflow = ''
})
it('locks page scroll while open and restores it when closed', async () => {
const wrapper = mount(BaseModal, {
props: { show: true, title: 'Test modal' },
slots: { default: '<p>Modal content</p>' },
attachTo: document.body,
})
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ show: false })
expect(document.body.style.overflow).toBe('')
wrapper.unmount()
})
})

View File

@ -0,0 +1,72 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MeshMap from '../MeshMap.vue'
const meshState = vi.hoisted(() => ({
nodePositions: new Map(),
peers: [],
status: null,
deadmanStatus: null,
updateSelfPosition: vi.fn(),
}))
vi.mock('@/stores/mesh', () => ({
useMeshStore: () => meshState,
}))
vi.mock('leaflet', () => ({
default: {
map: vi.fn(() => ({
invalidateSize: vi.fn(),
fitBounds: vi.fn(),
remove: vi.fn(),
})),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
divIcon: vi.fn((opts) => opts),
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
polyline: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({ pad: vi.fn() })),
},
}))
describe('MeshMap', () => {
beforeEach(() => {
meshState.nodePositions.clear()
meshState.peers = []
meshState.status = null
meshState.deadmanStatus = null
meshState.updateSelfPosition.mockClear()
})
it('treats denied browser location as optional for peer positions', async () => {
let errorHandler!: (error: { code: number; message: string }) => void
const watchPosition = vi.fn((_success, error) => {
errorHandler = error
return 7
})
const clearWatch = vi.fn()
const resizeObserver = vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
}))
vi.stubGlobal('navigator', {
geolocation: { watchPosition, clearWatch },
})
vi.stubGlobal('ResizeObserver', resizeObserver)
const wrapper = mount(MeshMap)
expect(wrapper.text()).toContain('Waiting for mesh device positions.')
await wrapper.get('[role="switch"]').trigger('click')
errorHandler({ code: 1, message: 'denied' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Location permission denied. Peer locations can still appear on the map.')
expect(wrapper.text()).toContain('Local location is off. Other device positions will appear when received.')
expect(wrapper.text()).not.toContain('location sharing is required')
vi.unstubAllGlobals()
})
})

View File

@ -94,4 +94,20 @@ describe('useMarketplaceApp', () => {
const app = getCurrentApp()
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
})
it('preserves real screenshot metadata', () => {
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
setCurrentApp({
id: 'test',
screenshots: [
'/screenshots/test-dashboard.png',
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
],
})
expect(getCurrentApp()!.screenshots).toEqual([
'/screenshots/test-dashboard.png',
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
])
})
})

View File

@ -77,10 +77,10 @@ describe('useOnboarding', () => {
localStorage.setItem('neode_onboarding_complete', '1')
const promise = isOnboardingComplete()
await vi.advanceTimersByTimeAsync(2000)
await vi.advanceTimersByTimeAsync(8000)
const result = await promise
expect(result).toBe(true)
})
}, 10000)
})
describe('completeOnboarding', () => {

View File

@ -0,0 +1,37 @@
import { onBeforeUnmount, watch, type Ref } from 'vue'
let activeLocks = 0
let previousOverflow = ''
function lock() {
if (typeof document === 'undefined') return
if (activeLocks === 0) {
previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
activeLocks += 1
}
function unlock() {
if (typeof document === 'undefined' || activeLocks === 0) return
activeLocks -= 1
if (activeLocks === 0) {
document.body.style.overflow = previousOverflow
previousOverflow = ''
}
}
export function useBodyScrollLock(active: Ref<boolean>) {
watch(
active,
(isActive, wasActive) => {
if (isActive && !wasActive) lock()
if (!isActive && wasActive) unlock()
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (active.value) unlock()
})
}

View File

@ -0,0 +1,42 @@
import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
export function useCollapsingHeaderTabs(
headerRef: Ref<HTMLElement | null>,
primaryRef: Ref<HTMLElement | null>,
tabsProbeRef: Ref<HTMLElement | null>,
minSearchWidth = 176
) {
const collapsed = ref(false)
let resizeObserver: ResizeObserver | null = null
function measure() {
const header = headerRef.value
const probe = tabsProbeRef.value
if (!header || !probe) return
const primaryWidth = primaryRef.value?.getBoundingClientRect().width ?? 0
const tabsWidth = probe.getBoundingClientRect().width
const gapWidth = 48
collapsed.value = primaryWidth + tabsWidth + minSearchWidth + gapWidth > header.clientWidth
}
function scheduleMeasure() {
nextTick(() => requestAnimationFrame(measure))
}
onMounted(() => {
scheduleMeasure()
resizeObserver = new ResizeObserver(scheduleMeasure)
if (headerRef.value) resizeObserver.observe(headerRef.value)
if (primaryRef.value) resizeObserver.observe(primaryRef.value)
if (tabsProbeRef.value) resizeObserver.observe(tabsProbeRef.value)
window.addEventListener('resize', scheduleMeasure)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', scheduleMeasure)
})
return { collapsed, measure: scheduleMeasure }
}

View File

@ -16,6 +16,7 @@ export interface MarketplaceAppInfo {
dockerImage: string
/** External web URL for iframe-based web apps (no container needed) */
webUrl?: string
screenshots?: AppScreenshot[]
containerConfig?: {
ports?: string[]
volumes?: string[]
@ -25,6 +26,11 @@ export interface MarketplaceAppInfo {
}
}
export type AppScreenshot = string | {
src: string
alt?: string
}
// Simple in-memory store for the current marketplace app
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
@ -46,6 +52,7 @@ export function useMarketplaceApp() {
s9pkUrl: app.s9pkUrl ?? '',
dockerImage: app.dockerImage ?? '',
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
screenshots: Array.isArray(app.screenshots) ? app.screenshots : undefined,
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
}
}

View File

@ -37,6 +37,7 @@ export const GOALS: GoalDefinition[] = [
id: 'configure-store',
title: 'Set Up Your Store',
description: 'Create your store, set your currency, and customize your payment page. BTCPay will open so you can configure everything.',
appId: 'btcpay-server',
action: 'configure',
isAutomatic: false,
},
@ -72,6 +73,7 @@ export const GOALS: GoalDefinition[] = [
id: 'open-channel',
title: 'Open a Lightning Channel',
description: 'Open your first payment channel to start sending and receiving Lightning payments. LND will guide you through it.',
appId: 'lnd',
action: 'configure',
isAutomatic: false,
},
@ -99,6 +101,7 @@ export const GOALS: GoalDefinition[] = [
id: 'configure-filebrowser',
title: 'Log In',
description: 'Open FileBrowser and log in. Change your password on first login, then start managing your files.',
appId: 'filebrowser',
action: 'configure',
isAutomatic: false,
},
@ -126,6 +129,7 @@ export const GOALS: GoalDefinition[] = [
id: 'configure-nextcloud',
title: 'Set Up Your Cloud',
description: 'Create your admin account and configure storage. Nextcloud will open for you to complete setup.',
appId: 'nextcloud',
action: 'configure',
isAutomatic: false,
},
@ -168,6 +172,7 @@ export const GOALS: GoalDefinition[] = [
id: 'open-channels',
title: 'Open Payment Channels',
description: 'Open channels with well-connected nodes to start routing payments. More channels means more routing opportunities.',
appId: 'lnd',
action: 'configure',
isAutomatic: false,
},
@ -210,6 +215,7 @@ export const GOALS: GoalDefinition[] = [
id: 'configure-guardian',
title: 'Set Up Guardian UI',
description: 'Open the Guardian UI (port 8175) to configure your federation name, set the guardian threshold, and initialize the mint.',
appId: 'fedimint',
action: 'configure',
isAutomatic: false,
},

View File

@ -112,10 +112,12 @@
"setupTab": "Setup",
"myApps": "My Apps",
"myAppsDesc": "Manage your installed applications",
"recommendedApps": "Recommended Apps",
"recommendedAppsDesc": "Add useful App Store apps to your Home screen",
"cloud": "Cloud",
"cloudDesc": "Cloud services and storage",
"network": "Network",
"networkDesc": "Network infrastructure and Web3 services",
"networkDesc": "Network infrastructure and mesh services",
"web5": "Web5",
"web5Desc": "Decentralized identity and data protocols",
"system": "System",
@ -147,6 +149,7 @@
"updateAvailable": "Update Available: v{version}",
"updateNow": "Update Now",
"goToApps": "Go to Apps",
"goToAppStore": "Go to App Store",
"goToCloud": "Go to Cloud",
"goToNetwork": "Go to Network",
"goToWeb5": "Go to Web5",
@ -162,6 +165,8 @@
"noResults": "No apps matching \"{query}\"",
"uninstallTitle": "Uninstall App?",
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
"deleteAppDataLabel": "Delete app data and reset it",
"deleteAppDataHelp": "Check this to remove the app's data, backups, and persistent files. Leave it off to reinstall later without starting over.",
"dismissError": "Dismiss error",
"searchLabel": "Search installed apps"
},
@ -172,7 +177,7 @@
"interfaceMode": "Interface Mode",
"claudeAuth": "Claude Authentication",
"aiDataAccess": "AI Data Access",
"serverName": "Server Name",
"serverName": "Hostname",
"sessionStatus": "Session Status",
"yourDid": "Your DID",
"onionAddress": "Node .onion Address",
@ -301,7 +306,8 @@
"webhookSendFailed": "Failed to send test webhook",
"passwordAllFieldsRequired": "All fields are required",
"passwordMismatch": "New passwords do not match",
"passwordUpdatedSuccess": "Password updated successfully. Use the new password for login and SSH.",
"passwordUpdatedSuccess": "Web password updated successfully.",
"passwordUpdatedSshFailed": "SSH password update failed:",
"passwordChangeFailed": "Failed to change password",
"passwordMinLength": "Password must be at least 12 characters",
"passwordNeedUppercase": "Password must contain at least one uppercase letter",
@ -520,6 +526,10 @@
"registerNewName": "Register New Name",
"verifyNip05": "Verify NIP-05",
"peers": "Peers",
"trusted": "Trusted",
"observer": "Observer",
"observers": "Observers",
"noObservers": "No observers yet.",
"messages": "Messages",
"requests": "Requests",
"myContent": "My Content",
@ -538,6 +548,7 @@
"features": "Features",
"information": "Information",
"requirements": "Requirements",
"setupInstructions": "Setup Instructions",
"ram": "RAM",
"ramDesc": "Minimum 512MB",
"storage": "Storage",
@ -611,6 +622,7 @@
"syncMessage": "Your Bitcoin node is syncing the entire blockchain so you don't have to trust anyone else. This takes 2-3 days on first run. Meanwhile, you can explore your node, set up your identity, or back up your keys.",
"installApp": "Install {name}",
"openAndConfigure": "Open & Configure",
"checkAndContinue": "Check & Continue",
"iveDoneThis": "I've Done This",
"complete": "Complete",
"allSet": "All Set!",

View File

@ -112,10 +112,12 @@
"setupTab": "Configuraci\u00f3n",
"myApps": "Mis aplicaciones",
"myAppsDesc": "Administre sus aplicaciones instaladas",
"recommendedApps": "Aplicaciones recomendadas",
"recommendedAppsDesc": "Agregue aplicaciones utiles de la tienda a su pantalla de inicio",
"cloud": "Nube",
"cloudDesc": "Servicios en la nube y almacenamiento",
"network": "Red",
"networkDesc": "Infraestructura de red y servicios Web3",
"networkDesc": "Infraestructura de red y servicios mesh",
"web5": "Web5",
"web5Desc": "Identidad descentralizada y protocolos de datos",
"system": "Sistema",
@ -147,6 +149,7 @@
"updateAvailable": "Actualizaci\u00f3n disponible: v{version}",
"updateNow": "Actualizar ahora",
"goToApps": "Ir a aplicaciones",
"goToAppStore": "Ir a la tienda de aplicaciones",
"goToCloud": "Ir a la nube",
"goToNetwork": "Ir a la red",
"goToWeb5": "Ir a Web5",
@ -162,6 +165,8 @@
"noResults": "No se encontraron aplicaciones para \"{query}\"",
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
"uninstallConfirm": "\u00bfEst\u00e1 seguro de que desea desinstalar {name}? Esto eliminar\u00e1 la aplicaci\u00f3n y detendr\u00e1 su contenedor.",
"deleteAppDataLabel": "Eliminar los datos de la aplicaci\u00f3n y restablecerla",
"deleteAppDataHelp": "Marque esto para eliminar los datos, respaldos y archivos persistentes de la aplicaci\u00f3n. Desm\u00e1rquelo para reinstalar m\u00e1s tarde sin empezar de cero.",
"dismissError": "Descartar error",
"searchLabel": "Buscar aplicaciones instaladas"
},
@ -172,7 +177,7 @@
"interfaceMode": "Modo de interfaz",
"claudeAuth": "Autenticaci\u00f3n de Claude",
"aiDataAccess": "Acceso a datos de IA",
"serverName": "Nombre del servidor",
"serverName": "Hostname",
"sessionStatus": "Estado de la sesi\u00f3n",
"yourDid": "Su DID",
"onionAddress": "Direcci\u00f3n .onion del nodo",
@ -301,7 +306,8 @@
"webhookSendFailed": "Error al enviar el webhook de prueba",
"passwordAllFieldsRequired": "Todos los campos son obligatorios",
"passwordMismatch": "Las nuevas contrase\u00f1as no coinciden",
"passwordUpdatedSuccess": "Contrase\u00f1a actualizada exitosamente. Use la nueva contrase\u00f1a para iniciar sesi\u00f3n y SSH.",
"passwordUpdatedSuccess": "Contrase\u00f1a web actualizada exitosamente.",
"passwordUpdatedSshFailed": "Error al actualizar la contrase\u00f1a SSH:",
"passwordChangeFailed": "Error al cambiar la contrase\u00f1a",
"passwordMinLength": "La contrase\u00f1a debe tener al menos 12 caracteres",
"passwordNeedUppercase": "La contrase\u00f1a debe contener al menos una letra may\u00fascula",
@ -520,6 +526,10 @@
"registerNewName": "Registrar nuevo nombre",
"verifyNip05": "Verificar NIP-05",
"peers": "Pares",
"trusted": "Confiables",
"observer": "Observador",
"observers": "Observadores",
"noObservers": "A\u00fan no hay observadores.",
"messages": "Mensajes",
"requests": "Solicitudes",
"myContent": "Mi contenido",
@ -538,6 +548,7 @@
"features": "Caracter\u00edsticas",
"information": "Informaci\u00f3n",
"requirements": "Requisitos",
"setupInstructions": "Instrucciones de configuraci\u00f3n",
"ram": "RAM",
"ramDesc": "M\u00ednimo 512MB",
"storage": "Almacenamiento",
@ -610,6 +621,7 @@
"syncMessage": "Su nodo Bitcoin est\u00e1 sincronizando toda la cadena de bloques para que no tenga que confiar en nadie m\u00e1s. Esto toma 2\u20133 d\u00edas en la primera ejecuci\u00f3n. Mientras tanto, puede explorar su nodo, configurar su identidad o respaldar sus claves.",
"installApp": "Instalar {name}",
"openAndConfigure": "Abrir y configurar",
"checkAndContinue": "Verificar y continuar",
"iveDoneThis": "Ya lo hice",
"complete": "Completar",
"allSet": "\u00a1Todo listo!",

View File

@ -12,7 +12,7 @@ vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/router', () => ({
default: { push: mockPush },
default: { push: mockPush, currentRoute: { value: { fullPath: '/dashboard/apps', name: 'apps' } } },
}))
vi.stubGlobal('open', mockWindowOpen)
@ -66,7 +66,7 @@ describe('useAppLauncherStore', () => {
store.openSession('indeedhub')
expect(store.panelAppId).toBe(null)
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' } })
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'indeedhub' }, query: { returnTo: '/dashboard/apps' } })
})
it('normalizes localhost launch URLs to current host before resolving', () => {
@ -95,7 +95,12 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe('btcpay-server')
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:23000',
'_blank',
'noopener,noreferrer',
)
})
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
@ -125,7 +130,7 @@ describe('useAppLauncherStore', () => {
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' } })
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'nginx-proxy-manager' }, query: { returnTo: '/dashboard/apps' } })
})
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
@ -186,7 +191,12 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBeTruthy()
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8123',
'_blank',
'noopener,noreferrer',
)
})
it('routes Grafana (port 3000) to full-page session', () => {
@ -195,7 +205,12 @@ describe('useAppLauncherStore', () => {
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBeTruthy()
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:3000',
'_blank',
'noopener,noreferrer',
)
})
it('opens Gitea path URL in new tab', () => {
@ -261,7 +276,7 @@ describe('useAppLauncherStore', () => {
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' } })
expect(mockPush).toHaveBeenCalledWith({ name: 'app-session', params: { appId: 'arch-presentation' }, query: { returnTo: '/dashboard/apps' } })
})
it('routes HTTPS same-host apps via session view', () => {

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import router from '@/router'
import { recordAppLaunch } from '@/utils/appUsage'
/** Ports of apps that set X-Frame-Options (can't iframe, must open in new tab) */
const NEW_TAB_PORTS = new Set([
@ -102,8 +103,6 @@ const PORT_TO_APP_ID: Record<string, string> = {
'8334': 'bitcoin-knots',
'8888': 'searxng',
'9000': 'portainer',
'9010': 'saleor',
'9011': 'saleor',
'8087': 'netbird',
'8086': 'netbird',
'9980': 'onlyoffice',
@ -186,21 +185,24 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
/** Open app in session view — panel mode uses store, overlay/fullscreen uses route */
function dashboardReturnPath(): string {
const current = router.currentRoute.value
const current = router.currentRoute?.value
if (!current) return '/dashboard/apps'
const fullPath = current.fullPath || '/dashboard/apps'
if (!fullPath.startsWith('/dashboard') || current.name === 'app-session') return '/dashboard/apps'
return fullPath
}
function openSession(appId: string) {
recordAppLaunch(appId)
const mobile = isMobileViewport()
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
if (launchUrl) {
if (launchUrl && !mobile) {
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
const mode = localStorage.getItem(DISPLAY_MODE_KEY) || 'panel'
if (mode === 'panel' && !isMobileViewport()) {
if (mode === 'panel' && !mobile) {
panelAppId.value = appId
} else {
panelAppId.value = null
@ -219,6 +221,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
if (!isMobileViewport() && payload.openInNewTab) {
if (resolvedId) recordAppLaunch(resolvedId)
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}
@ -227,6 +230,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// phones, route through the app session/webview so app icons behave like
// native launchers and keep the user inside Archipelago.
if (!isMobileViewport() && resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
recordAppLaunch(resolvedId)
window.open(launchUrl, '_blank', 'noopener,noreferrer')
return
}

View File

@ -27,7 +27,7 @@ const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; stat
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
'waiting-healthy': { progress: 88, message: 'Waiting for container…', status: 'starting' },
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
'post-install': { progress: 95, message: 'Finalizing…', status: 'installing' },
'done': { progress: 100, message: 'Installed', status: 'complete' },
}
@ -126,6 +126,12 @@ export const useServerStore = defineStore('server', () => {
installingApps.value.delete(appId)
}
}
if ((pkg.state as string) === 'removing') {
uninstallingApps.value.add(appId)
} else if (uninstallingApps.value.has(appId)) {
uninstallingApps.value.delete(appId)
}
}
// Clear installingApps entries for apps that vanished from backend data
// Only clean up entries that have errored — active installs may take minutes to pull images
@ -183,8 +189,11 @@ export const useServerStore = defineStore('server', () => {
return rpcClient.installPackage(id, marketplaceUrl, version)
}
async function uninstallPackage(id: string): Promise<{ status: string; package_id: string }> {
return rpcClient.uninstallPackage(id)
async function uninstallPackage(
id: string,
options: { preserveData?: boolean } = {},
): Promise<{ status: string; package_id: string }> {
return rpcClient.uninstallPackage(id, options)
}
async function startPackage(id: string): Promise<void> {

View File

@ -80,6 +80,50 @@ select:focus-visible {
border-color: rgba(251, 146, 60, 0.4);
}
/* Card action placement: keep compact header buttons for genuinely wide layouts. */
.responsive-card-actions-top,
.web5-card-actions-top {
display: none;
}
.responsive-card-actions-bottom,
.web5-card-actions-bottom {
display: flex;
}
.responsive-card-actions-bottom-grid,
.web5-card-actions-bottom-grid {
display: grid;
}
.mobile-card-action {
display: inline-flex;
width: 100%;
min-width: 0;
min-height: 44px;
aspect-ratio: auto;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
text-align: center;
white-space: nowrap;
}
@media (min-width: 1800px) {
.responsive-card-actions-top,
.web5-card-actions-top {
display: flex;
}
.responsive-card-actions-bottom,
.responsive-card-actions-bottom-grid,
.web5-card-actions-bottom,
.web5-card-actions-bottom-grid {
display: none;
}
}
/* Mobile touch targets — ensure tappable elements meet 44px minimum */
@media (max-width: 767px) {
button:not(.mode-switcher-btn):not(.sidebar-nav-item):not([class*="w-9"]):not([class*="w-8"]):not([class*="w-7"]):not([class*="w-10"]):not([class*="w-11"]):not([class*="w-12"]) {
@ -140,7 +184,7 @@ input[type="radio"]:active + * {
[data-controller-container]:focus-visible,
[data-controller-container]:focus {
outline: none;
transform: translateY(-4px) scale(1.01) translateZ(0);
transform: translateY(-4px) scale(1.01);
box-shadow:
0 0 6px 2px rgba(251, 146, 60, 0.35),
0 0 20px rgba(251, 146, 60, 0.15),
@ -178,16 +222,18 @@ input[type="radio"]:active + * {
border-radius: 1rem;
overflow-x: hidden;
overflow-y: visible;
/* Fix Chromium compositor bug: backdrop-filter + fixed animated overlays
causes cards to render as black rectangles on scroll/tab-switch.
Own layer + isolation prevents stacking context confusion. */
transform: translateZ(0);
isolation: isolate;
}
/* The Apps grid has many backdrop-filter cards inside an animated dashboard
viewport. Chromium/Brave can corrupt those layers into black rectangles,
so keep the translucency but avoid per-card backdrop compositor layers. */
/* Dashboard content lives inside animated perspective/scroll containers.
Chromium/Brave can corrupt backdrop-filter + transformed cards into black
square/rectangle layers, so use translucent fills there instead. */
body.dashboard-active .dashboard-scroll-panel .glass-card,
body.dashboard-active .dashboard-scroll-panel .glass,
body.dashboard-active .dashboard-scroll-panel .mode-switcher,
body.dashboard-active .dashboard-scroll-panel .glass-button,
body.dashboard-active .dashboard-scroll-panel input,
body.dashboard-active .dashboard-scroll-panel textarea,
body.dashboard-active .dashboard-scroll-panel select,
.apps-view .glass-card,
.apps-view .glass,
.apps-view .mode-switcher,
@ -209,6 +255,84 @@ input[type="radio"]:active + * {
border: 1px solid rgba(255, 255, 255, 0.08);
}
.segmented-select {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
max-width: min(260px, 24vw);
}
.segmented-select-with-discover {
max-width: min(360px, 34vw);
}
.category-tabs-wide {
flex: 0 1 auto;
min-width: 0;
width: max-content;
max-width: 100%;
overflow: visible;
}
.category-tabs-wide .mode-switcher-btn {
flex: 0 0 auto;
}
.category-tabs-probe {
position: absolute;
left: -10000px;
top: -10000px;
visibility: hidden;
pointer-events: none;
width: max-content;
max-width: none;
}
.app-header-search,
.app-header-search-wrap {
flex: 1 1 auto;
min-width: 9rem;
max-width: none;
}
.segmented-select .mode-switcher-btn {
flex: 0 0 auto;
}
.segmented-select-control {
width: clamp(132px, 18vw, 220px);
appearance: none;
-webkit-appearance: none;
background:
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.68) 50%) right 12px center / 6px 6px no-repeat,
linear-gradient(135deg, rgba(255, 255, 255, 0.68) 50%, transparent 50%) right 8px center / 6px 6px no-repeat,
transparent;
border: 1px solid transparent;
border-radius: 0.375rem;
color: rgba(255, 255, 255, 0.95);
cursor: pointer;
font-size: 0.75rem;
font-weight: 500;
padding-right: 1.75rem;
padding: 0.375rem 1.75rem 0.375rem 0.75rem;
text-align: left;
white-space: nowrap;
}
.segmented-select-control:focus {
outline: none;
border-color: rgba(251, 146, 60, 0.35);
background-color: rgba(251, 146, 60, 0.12);
}
.segmented-select-control option {
background: #111827;
color: #fff;
}
/* Full-width mode switcher variant (sidebar, mobile settings) */
.mode-switcher-full {
display: flex;
@ -1305,6 +1429,32 @@ html:has(body.video-background-active)::before {
display: none !important;
}
/* Full-screen modal overlays should freeze the page underneath, including
custom Teleport modals that do not go through BaseModal. */
html:has(body .fixed.inset-0:not(.pointer-events-none)),
body:has(.fixed.inset-0:not(.pointer-events-none)) {
overflow: hidden;
}
html.modal-scroll-locked .fixed.inset-0 {
overscroll-behavior: contain;
touch-action: none;
}
html.modal-scroll-locked .fixed.inset-0 .overflow-y-auto,
html.modal-scroll-locked .fixed.inset-0 .overflow-auto,
html.modal-scroll-locked .fixed.inset-0 [style*="overflow-y: auto"],
html.modal-scroll-locked .fixed.inset-0 [style*="overflow: auto"] {
overscroll-behavior: contain;
touch-action: pan-y;
}
html.modal-scroll-locked .dashboard-scroll-panel {
overflow: hidden !important;
overscroll-behavior: none;
touch-action: none;
}
/* Repeating glitch animations - every 5 seconds (subtle) */
@keyframes bg-glitch-shift-repeat {
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
@ -1677,6 +1827,16 @@ html:has(body.video-background-active)::before {
scrollbar-width: none;
}
.marketplace-container {
padding-bottom: 6rem;
}
@media (max-width: 767px) {
.marketplace-container {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 2rem);
}
}
.mobile-category-strip::-webkit-scrollbar {
display: none;
}
@ -2085,11 +2245,9 @@ html:has(body.video-background-active)::before {
@keyframes card-stagger-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@ -2139,14 +2297,27 @@ html:has(body.video-background-active)::before {
height: 60px;
border-radius: 18px;
overflow: visible;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
background: rgba(0, 0, 0, 0.72);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.38);
}
.archy-app-icon {
display: block;
box-sizing: border-box;
object-fit: cover;
background:
radial-gradient(circle at 35% 28%, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0) 42%),
linear-gradient(145deg, rgba(22, 22, 24, 0.96), rgba(0, 0, 0, 0.96));
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
inset 0 -10px 24px rgba(0, 0, 0, 0.34),
0 8px 18px rgba(0, 0, 0, 0.35);
}
.app-icon-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 18px;
}
@ -2202,6 +2373,19 @@ html:has(body.video-background-active)::before {
white-space: nowrap;
}
.app-icon-progress-label {
display: -webkit-box;
max-width: 84px;
min-height: 22px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: rgba(255, 255, 255, 0.58);
font-size: 9px;
line-height: 1.2;
text-align: center;
}
/* Page indicator dots */
.app-icon-dots {
display: flex;
@ -2237,10 +2421,15 @@ html:has(body.video-background-active)::before {
/* Monitoring dashboard */
.monitoring-stat-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.58);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34);
}
.monitoring-stat-card-compact {
padding: 0 1rem;
}
.monitoring-chart {
@ -2370,10 +2559,12 @@ html:has(body.video-background-active)::before {
}
.fleet-sort-btn {
padding: 4px 10px;
padding: 5px 11px;
border-radius: 6px;
font-size: 0.625rem;
font-weight: 500;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: rgba(255, 255, 255, 0.5);
background: transparent;
border: 1px solid transparent;

View File

@ -87,6 +87,7 @@ export interface PackageDataEntry {
license: string
instructions: string
icon: string
screenshots?: AppScreenshot[]
}
manifest: Manifest
installed?: InstalledPackageDataEntry
@ -121,6 +122,12 @@ export interface Manifest {
'lan-config'?: string
}
}
screenshots?: AppScreenshot[]
}
export type AppScreenshot = string | {
src: string
alt?: string
}
export interface InstalledPackageDataEntry {

View File

@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getAppUsage, recordAppLaunch } from '../appUsage'
describe('appUsage', () => {
beforeEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
it('starts empty when no usage has been recorded', () => {
expect(getAppUsage()).toEqual({})
})
it('records launch count and latest launch time', () => {
recordAppLaunch('filebrowser', 1000)
recordAppLaunch('filebrowser', 2000)
expect(getAppUsage()).toEqual({
filebrowser: {
count: 2,
lastLaunchedAt: 2000,
},
})
})
it('ignores corrupt stored usage', () => {
localStorage.setItem('archipelago-app-usage', 'not-json')
expect(getAppUsage()).toEqual({})
})
})

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { explainReceiveAddressFailure } from '../bitcoinReceive'
describe('explainReceiveAddressFailure', () => {
it('explains locked wallet failures', () => {
expect(explainReceiveAddressFailure(new Error('wallet locked'))).toContain('wallet is locked')
})
it('explains sync failures', () => {
expect(explainReceiveAddressFailure(new Error('chain backend is still syncing'))).toContain('still syncing')
})
it('explains empty address responses', () => {
expect(explainReceiveAddressFailure(new Error('LND did not return a Bitcoin address'))).toContain('did not return an address')
})
it('explains lnd transport failures', () => {
expect(explainReceiveAddressFailure(new Error('LND REST connection failed'))).toContain('not responding cleanly')
})
})

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { shouldShowIntroSplash } from '../introSplash'
describe('shouldShowIntroSplash', () => {
it('skips intro on an already-onboarded node even without a browser intro flag', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/',
fromBoot: false,
onboardingComplete: true,
})).toBe(false)
})
it('shows intro for a fresh root visit when onboarding is not complete', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/',
fromBoot: false,
onboardingComplete: false,
})).toBe(true)
})
it('does not interrupt direct routes', () => {
expect(shouldShowIntroSplash({
seenIntro: false,
routePath: '/dashboard/web5',
fromBoot: false,
onboardingComplete: null,
})).toBe(false)
})
})

View File

@ -0,0 +1,43 @@
const STORAGE_KEY = 'archipelago-app-usage'
export interface AppUsageEntry {
count: number
lastLaunchedAt: number
}
export type AppUsageMap = Record<string, AppUsageEntry>
function readUsage(): AppUsageMap {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as AppUsageMap
if (!parsed || typeof parsed !== 'object') return {}
return parsed
} catch {
return {}
}
}
function writeUsage(usage: AppUsageMap) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(usage))
} catch {
// Ignore unavailable or full localStorage.
}
}
export function recordAppLaunch(appId: string, now = Date.now()) {
if (!appId) return
const usage = readUsage()
const current = usage[appId]
usage[appId] = {
count: (current?.count || 0) + 1,
lastLaunchedAt: now,
}
writeUsage(usage)
}
export function getAppUsage(): AppUsageMap {
return readUsage()
}

View File

@ -0,0 +1,25 @@
export function explainReceiveAddressFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error || '')
const lower = message.toLowerCase()
if (lower.includes('wallet') && (lower.includes('locked') || lower.includes('unlock'))) {
return 'Bitcoin address is not ready because the Lightning wallet is locked. Unlock or initialize LND first.'
}
if (lower.includes('uninitialized') || lower.includes('not initialized') || lower.includes('initwallet')) {
return 'Bitcoin address is not ready because the Lightning wallet has not been initialized yet.'
}
if (lower.includes('sync') || lower.includes('chain backend') || lower.includes('neutrino')) {
return 'Bitcoin address is not ready while Bitcoin or LND is still syncing. Try again once sync has progressed.'
}
if (lower.includes('rest connection failed') || lower.includes('failed to parse newaddress response')) {
return 'Bitcoin address is not ready because LND is not responding cleanly yet. Check that the Lightning app is healthy and retry.'
}
if (lower.includes('connection') || lower.includes('connect') || lower.includes('unavailable') || lower.includes('refused')) {
return 'Bitcoin address is not ready because LND is not reachable yet. Check that the Lightning app is running.'
}
if (lower.includes('did not return') || lower.includes('empty address')) {
return 'Bitcoin address is not ready because LND did not return an address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync.'
}
return message || 'Bitcoin address is not ready yet. Check Bitcoin and LND status, then try again.'
}

View File

@ -0,0 +1,17 @@
export interface IntroSplashDecisionInput {
seenIntro: boolean
routePath: string
fromBoot: boolean
devMode?: string
onboardingComplete: boolean | null
}
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
if (input.seenIntro) return false
if (input.onboardingComplete === true) return false
const isDirectRoute = input.routePath !== '/'
if (input.fromBoot) return true
if (input.devMode === 'boot') return false
return !isDirectRoute
}

View File

@ -28,6 +28,7 @@
:package-key="packageKey"
:can-launch="canLaunch"
:is-web-only="isWebOnly"
:pending-action="pendingAction"
@launch="launchApp"
@start="startApp"
@stop="stopApp"
@ -57,6 +58,7 @@
:tor-url="torUrl"
:show-tor-address="showTorAddress"
:credentials="credentials"
:credentials-loading="credentialsLoading"
/>
</div>
</div>
@ -70,52 +72,13 @@
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
</div>
<!-- Uninstall Confirmation Modal -->
<Teleport to="body">
<Transition name="modal">
<div
v-if="uninstallModal.show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="closeUninstallModal()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="uninstallModalRef"
@click.stop
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
>
<div class="flex items-start gap-4 mb-6">
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
<p class="text-white/70 text-sm">
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
</p>
</div>
</div>
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
<button
@click="closeUninstallModal()"
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
>
{{ t('common.cancel') }}
</button>
<button
@click="confirmUninstall"
class="w-full md:w-auto px-6 py-3 glass-button glass-button-danger rounded-lg text-sm font-medium"
>
{{ t('common.uninstall') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<AppsUninstallModal
:show="uninstallModal.show"
:app-title="uninstallModal.appTitle"
:uninstalling="pendingAction === 'uninstall'"
@close="closeUninstallModal"
@confirm="confirmUninstall"
/>
<!-- Action error toast -->
<Transition name="fade">
@ -135,14 +98,15 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '../stores/appLauncher'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { dummyApps } from '../utils/dummyApps'
import { rpcClient } from '@/api/rpc-client'
import type { AppCredentialsResponse } from '@/types/api'
import AppHeroSection from './appDetails/AppHeroSection.vue'
import AppContentSection from './appDetails/AppContentSection.vue'
import AppSidebar from './appDetails/AppSidebar.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { resolveAppUrl } from './appSession/appSessionConfig'
import { resolveAppCredentials } from './apps/appCredentials'
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
import {
WEB_ONLY_APP_URLS,
@ -217,6 +181,8 @@ const bitcoinSyncPercent = ref(0)
const bitcoinBlockHeight = ref(0)
const bitcoinSynced = computed(() => bitcoinSyncPercent.value >= 99.9)
const credentials = ref<AppCredentialsResponse | null>(null)
const credentialsLoading = ref(false)
const pendingAction = ref<'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null>(null)
async function loadBitcoinSync() {
if (!needsBitcoinSync.value) return
@ -235,15 +201,18 @@ async function loadBitcoinSync() {
async function loadCredentials() {
if (!appId.value) return
credentialsLoading.value = true
try {
const result = await rpcClient.call<AppCredentialsResponse>({
method: 'package.credentials',
params: { app_id: packageKey.value },
timeout: 5000,
})
credentials.value = result.credentials?.length ? result : null
credentials.value = resolveAppCredentials(packageKey.value, result)
} catch {
credentials.value = null
credentials.value = resolveAppCredentials(packageKey.value, null)
} finally {
credentialsLoading.value = false
}
}
@ -262,21 +231,11 @@ function showActionError(msg: string) {
}
const uninstallModal = ref({ show: false, appTitle: '' })
const uninstallModalRef = ref<HTMLElement | null>(null)
const uninstallRestoreFocusRef = ref<HTMLElement | null>(null)
function closeUninstallModal() {
uninstallRestoreFocusRef.value?.focus?.()
uninstallModal.value.show = false
}
useModalKeyboard(
uninstallModalRef,
computed(() => uninstallModal.value.show),
closeUninstallModal,
{ restoreFocusRef: uninstallRestoreFocusRef }
)
const backButtonText = computed(() => {
if (route.query.from === 'discover') return 'Back to Discover'
if (route.query.from === 'marketplace') return t('appDetails.backToStore')
@ -300,7 +259,15 @@ const features = computed(() => [
])
function goBack() {
router.back()
if (route.query.from === 'discover') {
router.push('/dashboard/discover').catch(() => {})
return
}
if (route.query.from === 'marketplace') {
router.push('/dashboard/marketplace').catch(() => {})
return
}
router.push('/dashboard/apps').catch(() => {})
}
function launchApp() {
@ -335,34 +302,46 @@ function launchApp() {
async function startApp() {
pendingAction.value = 'start'
try {
await store.startPackage(appId.value)
} catch (err) {
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
pendingAction.value = null
}
}
async function stopApp() {
pendingAction.value = 'stop'
try {
await store.stopPackage(appId.value)
} catch (err) {
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
pendingAction.value = null
}
}
async function restartApp() {
pendingAction.value = 'restart'
try {
await store.restartPackage(appId.value)
} catch (err) {
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
pendingAction.value = null
}
}
async function updateApp() {
pendingAction.value = 'update'
try {
await store.updatePackage(appId.value)
} catch (err) {
showActionError(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
pendingAction.value = null
}
}
@ -371,13 +350,16 @@ function showUninstallModal() {
uninstallModal.value = { show: true, appTitle: pkg.value.manifest.title }
}
async function confirmUninstall() {
async function confirmUninstall(deleteAppData: boolean) {
uninstallModal.value.show = false
pendingAction.value = 'uninstall'
try {
await store.uninstallPackage(appId.value)
await store.uninstallPackage(appId.value, { preserveData: !deleteAppData })
router.push('/dashboard/apps').catch(() => {})
} catch (err) {
showActionError(`Failed to uninstall: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
pendingAction.value = null
}
}

View File

@ -131,7 +131,7 @@
<Transition name="fade">
<div
v-if="addingRegistry"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
@click.self="cancelAddRegistry"
>
<div class="glass-card p-6 max-w-md w-full mx-4">

View File

@ -32,6 +32,8 @@
:must-open-new-tab="mustOpenNewTab"
:auto-retry-count="autoRetryCount"
:refresh-key="refreshKey"
:blocked-reason="blockedReason"
:blocked-title="blockedTitle"
@iframe-load="onLoad"
@iframe-error="onError"
@refresh="refresh"
@ -101,6 +103,7 @@ import {
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
resolveAppUrl, resolveAppTitle,
} from './appSession/appSessionConfig'
import { launchBlockedReason } from './apps/appsConfig'
import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
@ -147,6 +150,9 @@ const appId = computed(() => {
})
const appTitle = computed(() => resolveAppTitle(appId.value))
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const screensaverReason = computed(() => `app-session:${appId.value}`)
@ -344,8 +350,9 @@ watch(displayMode, (mode) => {
})
onMounted(() => {
// Apps that block iframes open externally instead of landing in a broken webview.
if (mustOpenNewTab.value && appUrl.value) {
// Apps that block iframes open externally on desktop. On mobile, keep the
// session surface visible so launcher taps do not bounce straight out.
if (mustOpenNewTab.value && appUrl.value && !isMobile) {
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
if (isInlinePanel.value) emit('close')
else closeRouteSession()
@ -355,7 +362,7 @@ onMounted(() => {
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('message', onMessage)
document.addEventListener('fullscreenchange', onFullscreenChange)
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
if (IFRAME_BLOCKED_APPS.has(appId.value) || (mustOpenNewTab.value && isMobile)) {
loading.value = false
iframeBlocked.value = true
} else {

View File

@ -4,12 +4,12 @@
<div class="mb-4">
<!-- Desktop: page tabs + category tabs + search -->
<div class="hidden md:flex items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<div class="mode-switcher hidden md:inline-flex flex-shrink-0">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@ -18,7 +18,7 @@
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
>{{ category.name }}</button>
</div>
<div class="flex-1 flex items-center gap-2">
<div class="app-header-search-wrap flex items-center gap-2">
<input
v-model="searchQuery"
type="text"
@ -88,6 +88,18 @@
</div>
</div>
<!-- Container scanner still warming up -->
<div v-else-if="isCheckingContainers" class="text-center py-16 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-xl font-semibold text-white mb-2">Checking containers</h3>
<p class="text-white/70">Archipelago is scanning installed apps. Your apps will appear here as soon as the container list is ready.</p>
</div>
</div>
<!-- Empty State -->
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
<div class="glass-card p-12 max-w-md mx-auto">
@ -134,6 +146,17 @@
</div>
</div>
<div
v-if="isUsingLastKnownPackages && filteredPackageEntries.length > 0"
class="mb-4 rounded-lg border border-yellow-400/20 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100/85 flex items-center gap-2"
>
<svg class="animate-spin h-4 w-4 flex-shrink-0 text-yellow-200/80" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Refreshing container state. Showing the last known app list until the scan finishes.</span>
</div>
<!-- Mobile: iPhone-style icon grid -->
<div class="md:hidden">
<AppIconGrid
@ -176,7 +199,7 @@
<Transition name="fade">
<div
v-if="credentialModal.show"
class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6"
class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6"
@click.self="closeCredentialModal"
>
<div class="sideload-modal credential-modal">
@ -290,7 +313,10 @@ import { type AppCredential, type AppCredentialsResponse, type PackageDataEntry,
import AppCard from './apps/AppCard.vue'
import AppIconGrid from './apps/AppIconGrid.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { resolveAppCredentials } from './apps/appCredentials'
import { useLastKnownPackages } from './apps/appPackageCache'
import { useAppsActions } from './apps/useAppsActions'
import { validateSideloadRequest } from './apps/sideloadValidation'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import {
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
@ -351,9 +377,16 @@ const selectedCategory = ref('all')
const ALL_CATEGORIES = computed(() => buildAllCategories(t))
const livePackages = computed(() => store.packages || {})
const containersScanned = computed(() => store.data?.['server-info']?.['status-info']?.['containers-scanned'] !== false)
const {
packages: stablePackages,
isUsingLastKnownPackages,
} = useLastKnownPackages(livePackages, containersScanned)
// Merge real packages from store with web-only app bookmarks + installing placeholders
const packages = computed(() => {
const realPackages = store.packages || {}
const realPackages = stablePackages.value
const merged: Record<string, PackageDataEntry> = { ...WEB_ONLY_APPS, ...realPackages }
// Inject placeholder entries for apps being installed that aren't in backend data yet
@ -393,6 +426,14 @@ const marketplaceMatches = computed(() => {
})
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
const isCheckingContainers = computed(() => (
store.hasLoadedInitialData &&
Object.keys(livePackages.value).length === 0 &&
!isUsingLastKnownPackages.value &&
sortedPackageEntries.value.length === 0 &&
!searchQuery.value &&
!containersScanned.value
))
// Connection error state
const connectionError = ref('')
@ -457,13 +498,13 @@ function closeUninstallModal() {
uninstallModal.value.show = false
}
async function onConfirmUninstall() {
async function onConfirmUninstall(deleteAppData: boolean) {
const { appId } = uninstallModal.value
// Close the modal immediately so the user can fire off concurrent
// uninstalls. Each AppCard surfaces its own live stage label while
// its uninstall is in flight.
uninstallModal.value.show = false
await actions.confirmUninstall(appId)
await actions.confirmUninstall(appId, { preserveData: !deleteAppData })
}
function goToApp(id: string) {
@ -508,18 +549,29 @@ async function maybeShowCredentialsBeforeLaunch(id: string): Promise<boolean> {
params: { app_id: id },
timeout: 5000,
})
if (!result.credentials?.length) return false
const credentials = resolveAppCredentials(id, result)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: result.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: result.description || 'Use these credentials when the app asks you to sign in.',
credentials: result.credentials,
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
} catch {
return false
const credentials = resolveAppCredentials(id, null)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: credentials.title || `${packages.value[id]?.manifest.title || id} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
}
}
@ -587,6 +639,11 @@ async function submitSideload() {
sideloadError.value = 'Enter a full image name, for example docker.io/library/nginx:alpine.'
return
}
const validationError = validateSideloadRequest(id, port, store.packages)
if (validationError) {
sideloadError.value = validationError
return
}
sideloading.value = true
sideloadError.value = ''
const containerConfig: Record<string, unknown> = {}
@ -693,6 +750,10 @@ async function submitSideload() {
.credential-modal {
display: flex;
flex-direction: column;
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
border-radius: 1.25rem;
padding-bottom: 1.25rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
.credential-modal-body {
min-height: 0;

View File

@ -83,6 +83,17 @@
</div>
</div>
<div
v-if="peersLoading && peerNodes.length > 0"
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
>
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing peer nodes...
</div>
<!-- No Peers placeholder (only if no peers found) -->
<div
v-if="!peersLoading && peerNodes.length === 0"
@ -263,12 +274,13 @@ onMounted(() => {
})
async function loadPeers() {
const hadPeers = peerNodes.value.length > 0
peersLoading.value = true
try {
const result = await rpcClient.federationListNodes()
peerNodes.value = result?.nodes ?? []
} catch (e) {
peerNodes.value = []
if (!hadPeers) peerNodes.value = []
loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes'
} finally {
peersLoading.value = false
@ -283,4 +295,6 @@ function peerDisplayName(did: string): string {
function openSection(section: ContentSection) {
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
}
defineExpose({ loadPeers })
</script>

View File

@ -6,7 +6,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Cloud
{{ backLabel }}
</button>
<!-- Mobile Back Button (teleported to escape CSS transform containing block) -->
@ -18,7 +18,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Back to Cloud</span>
<span>{{ backLabel }}</span>
</button>
</Teleport>
@ -106,7 +106,7 @@
<CloudToolbar
:breadcrumbs="cloudStore.breadcrumbs"
:view-mode="viewMode"
@navigate="cloudStore.navigate($event)"
@navigate="navigateCloudPath"
@refresh="cloudStore.refresh()"
@upload="handleUpload"
@update:view-mode="viewMode = $event"
@ -115,7 +115,7 @@
:items="cloudStore.sortedItems"
:loading="cloudStore.loading"
:view-mode="viewMode"
@navigate="cloudStore.navigate($event)"
@navigate="navigateCloudPath"
@delete="handleDelete"
@play="handlePlay"
@share="handleShare"
@ -178,6 +178,7 @@ import ShareModal from '../components/cloud/ShareModal.vue'
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
import { useAudioPlayer } from '../composables/useAudioPlayer'
import { getFileCategory } from '../composables/useFileType'
import { normalizeCloudPath, parentCloudPath } from './cloudPath'
const router = useRouter()
const route = useRoute()
@ -189,6 +190,7 @@ const audioPlayer = useAudioPlayer()
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 || '/'))
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
@ -286,14 +288,20 @@ const section = computed(() => {
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
const iframeUrl = computed(() => section.value?.iframeUrl || '')
const backLabel = computed(() => {
if (!useNativeUI.value || !section.value) return 'Back to Cloud'
return cloudStore.currentPath !== section.value.initialPath ? 'Back to Parent Folder' : 'Back to Cloud'
})
// Initialize native file browser when entering a native-UI section
watch([useNativeUI, section], async ([native, sec]) => {
watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => {
if (native && sec) {
if (cloudStore.currentPath !== path) {
cloudStore.reset()
}
const ok = await cloudStore.init()
if (ok) {
await cloudStore.navigate(sec.initialPath)
await cloudStore.navigate(path)
}
}
}, { immediate: true })
@ -370,7 +378,20 @@ function openExternal() {
}
}
async function navigateCloudPath(path: string) {
const target = normalizeCloudPath(path, section.value?.initialPath || '/')
await router.push({
name: 'cloud-folder',
params: { folderId: folderId.value },
query: target === section.value?.initialPath ? {} : { path: target },
})
}
function goBack() {
if (useNativeUI.value && section.value && cloudStore.currentPath !== section.value.initialPath) {
navigateCloudPath(parentCloudPath(cloudStore.currentPath))
return
}
router.push('/dashboard/cloud')
}
</script>

View File

@ -63,6 +63,13 @@
No credentials yet. Issue one above or receive one from a peer.
</div>
<div v-else class="space-y-3">
<div v-if="loadingCreds" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing credentials...
</div>
<div
v-for="cred in credentials"
:key="cred.id"
@ -115,7 +122,7 @@
<!-- Credential Detail Modal -->
<Teleport to="body">
<div v-if="selectedCredential" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="selectedCredential = null">
<div class="fixed inset-0 bg-black/10 backdrop-blur-md"></div>
<div class="fixed inset-0 bg-black/60 backdrop-blur-md"></div>
<div class="relative glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Credential Details</h3>
@ -403,6 +410,8 @@ async function copyCredentialJson() {
onMounted(async () => {
await Promise.all([loadIdentities(), loadCredentials()])
})
defineExpose({ credentials, loadCredentials })
</script>
<style scoped>

View File

@ -85,7 +85,7 @@
<div :key="route.path" class="view-wrapper">
<div
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
:class="['h-full', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
:class="['h-full dashboard-scroll-panel mobile-scroll-pad', mobileTabPaddingTop ? 'overflow-y-auto' : '']"
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"
class="mobile-safe-top"
>
@ -94,7 +94,7 @@
<div
v-else
:class="[
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top',
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto mobile-safe-top dashboard-scroll-panel',
needsMobileBackButtonSpace
? 'mobile-scroll-pad-back'
: 'mobile-scroll-pad'

View File

@ -3,25 +3,50 @@
<!-- Navigation Bar (always at top) -->
<div>
<!-- Desktop: tabs + categories + search -->
<div class="hidden md:flex mb-6 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<div ref="discoverHeaderRef" class="hidden md:flex mb-6 items-center gap-4 relative">
<div ref="discoverPrimaryRef" class="flex-shrink-0">
<div class="mode-switcher hidden md:inline-flex">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink
to="/dashboard/discover"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
>Discover</RouterLink>
</div>
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="navigateToMarketplace(category.id)"
v-for="section in appStoreSections"
:key="section.id"
@click="selectDiscoverCategory(section.id)"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': section.id === 'discover' }"
>
{{ category.name }}
{{ section.name }}
</button>
</div>
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
<label class="sr-only" for="discover-category-select">App Store category</label>
<select
id="discover-category-select"
class="segmented-select-control"
value="discover"
@change="selectDiscoverCategory(($event.target as HTMLSelectElement).value)"
>
<option
v-for="section in appStoreSections"
:key="section.id"
:value="section.id"
>
{{ section.name }}
</option>
</select>
</div>
<div ref="discoverCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
<button
v-for="section in appStoreSections"
:key="section.id"
class="mode-switcher-btn"
type="button"
>
{{ section.name }}
</button>
</div>
<input
@ -29,7 +54,7 @@
type="text"
placeholder="Search apps..."
aria-label="Search apps"
class="flex-1 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"
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"
/>
</div>
@ -40,14 +65,14 @@
<h1 class="text-lg font-bold text-white">App Store</h1>
</div>
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
<button class="mobile-category-pill mobile-category-pill-active" type="button">Discover</button>
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="navigateToMarketplace(category.id)"
v-for="section in appStoreSections"
:key="section.id"
@click="selectDiscoverCategory(section.id)"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': section.id === 'discover' }"
type="button"
>{{ category.name }}</button>
>{{ section.name }}</button>
</div>
<input
v-model="searchQuery"
@ -193,6 +218,8 @@ import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import AppGrid from './discover/AppGrid.vue'
@ -211,19 +238,18 @@ const selectedCategory = ref('all')
const searchQuery = ref('')
const bitcoinPruned = ref(false)
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const discoverHeaderRef = ref<HTMLElement | null>(null)
const discoverPrimaryRef = ref<HTMLElement | null>(null)
const discoverCategoryProbeRef = ref<HTMLElement | null>(null)
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
discoverHeaderRef,
discoverPrimaryRef,
discoverCategoryProbeRef,
144
)
const categories = computed(() => [
{ id: 'all', name: 'All' },
{ id: 'community', name: 'Community' },
{ id: 'nostr', name: 'Nostr' },
{ id: 'commerce', name: 'Commerce' },
{ id: 'money', name: 'Money' },
{ id: 'data', name: 'Data' },
{ id: 'home', name: 'Home' },
{ id: 'networking', name: 'Networking' },
{ id: 'l484', name: 'L484' },
{ id: 'other', name: 'Other' }
])
const categories = computed(() => APP_STORE_CATEGORIES)
const appStoreSections = computed(() => APP_STORE_SECTIONS)
// Installation state uses global store so it persists across navigation.
// The store's watcher (stores/server.ts) handles install-progress updates
@ -236,6 +262,14 @@ function navigateToMarketplace(categoryId: string) {
router.push({ name: 'marketplace', query: { category: categoryId } })
}
function selectDiscoverCategory(categoryId: string) {
if (categoryId === 'discover') {
router.push('/dashboard/discover')
return
}
navigateToMarketplace(categoryId)
}
// Community & Nostr marketplace state
const loadingCommunity = ref(false)
const communityError = ref('')
@ -303,14 +337,6 @@ const allApps = computed(() => {
return base
})
const categoriesWithApps = computed(() => {
const apps = allApps.value
return categories.value.filter(cat => {
if (cat.id === 'all') return apps.length > 0
return apps.some(app => app.category === cat.id)
})
})
const filteredApps = computed(() => {
let apps = allApps.value
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {

View File

@ -371,14 +371,23 @@ function isOnlineCheck(node: FederatedNode): boolean {
}
async function loadNodes() {
return loadNodesWithOptions()
}
async function loadNodesWithOptions(options: { showLoader?: boolean; surfaceErrors?: boolean } = {}) {
const showLoader = options.showLoader ?? nodes.value.length === 0
const surfaceErrors = options.surfaceErrors ?? true
try {
loading.value = true
if (showLoader) loading.value = true
const result = await rpcClient.federationListNodes()
nodes.value = result.nodes
error.value = ''
} catch (e) {
if (surfaceErrors) {
error.value = e instanceof Error ? e.message : 'Failed to load nodes'
}
} finally {
loading.value = false
if (showLoader) loading.value = false
}
}
@ -540,7 +549,7 @@ async function rotateDid(password: string) {
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
loadNodes()
loadNodesWithOptions({ showLoader: true })
loadDwnStatus()
loadDiscoveryState()
loadPendingRequests()
@ -552,7 +561,7 @@ onMounted(async () => {
// Self DID not available
}
autoRefreshTimer = setInterval(() => {
loadNodes()
loadNodesWithOptions({ showLoader: false, surfaceErrors: false })
loadPendingRequests()
}, 5000)
})

View File

@ -22,18 +22,27 @@
<!-- Header -->
<div class="hidden md:block mb-8">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-4 min-[1440px]:flex-row min-[1440px]:items-center min-[1440px]:justify-between">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Fleet Dashboard</h1>
<p class="text-white/70">Beta Telemetry monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
</div>
<div class="flex gap-2 items-center">
<span v-if="fleet.autoRefresh.value" class="text-xs text-white/40">Auto-refresh 60s</span>
<div class="grid grid-cols-3 gap-2 min-[1440px]:flex min-[1440px]:items-center">
<div class="monitoring-stat-card monitoring-stat-card-compact col-span-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap min-[1440px]:col-span-1 min-[1440px]:min-w-[220px]">
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
<span
class="inline-block h-1.5 w-1.5 rounded-full"
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
></span>
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
</div>
</div>
<button class="glass-button text-sm px-4 py-2" @click="fleet.toggleAutoRefresh">
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
</button>
<button class="glass-button text-sm px-4 py-2" @click="fleet.refreshAll">
Refresh
<button class="glass-button text-sm px-4 py-2 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
</button>
<button class="glass-button text-sm px-4 py-2" @click="fleet.exportFleetData">
Export JSON
@ -46,15 +55,37 @@
<div class="md:hidden mb-6">
<h1 class="text-2xl font-bold text-white mb-1">Fleet Dashboard</h1>
<p class="text-white/60 text-sm mb-3">Monitoring {{ fleet.nodes.value.length }} node{{ fleet.nodes.value.length !== 1 ? 's' : '' }}</p>
<div class="monitoring-stat-card monitoring-stat-card-compact mb-3 flex h-10 min-h-10 items-center justify-between gap-3 whitespace-nowrap">
<p class="text-[11px] font-medium uppercase tracking-wide text-white/50">Auto Refresh</p>
<div class="flex min-w-0 items-center gap-2 whitespace-nowrap">
<span
class="inline-block h-1.5 w-1.5 rounded-full"
:class="fleet.autoRefresh.value ? 'bg-emerald-300' : 'bg-white/35'"
></span>
<p class="text-sm font-bold text-white">{{ fleet.autoRefresh.value ? '60s' : 'Paused' }}</p>
</div>
</div>
<div class="flex gap-2">
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.refreshAll">Refresh</button>
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.toggleAutoRefresh">
{{ fleet.autoRefresh.value ? 'Pause' : 'Resume' }}
</button>
<button class="glass-button text-xs px-3 py-2 flex-1 disabled:opacity-50" :disabled="fleet.refreshing.value" @click="fleet.refreshAll">
{{ fleet.refreshing.value ? 'Refreshing...' : 'Refresh' }}
</button>
<button class="glass-button text-xs px-3 py-2 flex-1" @click="fleet.exportFleetData">Export</button>
</div>
</div>
<!-- Loading State -->
<div v-if="fleet.loading.value" class="flex items-center justify-center py-20">
<div class="text-white/50 text-sm">Loading fleet data...</div>
<div class="glass-card p-8 max-w-md text-center">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-white/70" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h3 class="text-lg font-semibold text-white mb-2">Loading fleet data</h3>
<p class="text-white/60 text-sm">Checking beta telemetry reports from connected nodes.</p>
</div>
</div>
<!-- Error State -->
@ -76,6 +107,14 @@
:avg-disk="fleet.avgDisk.value"
/>
<div v-if="fleet.refreshing.value && fleet.nodes.value.length > 0" class="glass-card p-3 mb-4 text-sm text-white/60 flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4 text-white/50" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing fleet telemetry...
</div>
<FleetNodeGrid
:nodes="fleet.nodes.value"
:sorted-nodes="fleet.sortedNodes.value"

View File

@ -111,6 +111,13 @@
>
{{ t('goalDetail.openAndConfigure') }}
</button>
<button
v-else-if="step.action === 'verify'"
@click="completeVerifyStep(step)"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
{{ t('goalDetail.checkAndContinue') }}
</button>
<button
v-else-if="step.action === 'info'"
@click="completeInfoStep(step)"
@ -167,6 +174,7 @@ import { useAppStore } from '@/stores/app'
import { useGoalStore } from '@/stores/goals'
import { getGoalById } from '@/data/goals'
import type { GoalStep } from '@/types/goals'
import { goalStepTargetPath } from './goals/goalStepActions'
/** Map appId to its icon file path under /assets/img/app-icons/ */
const APP_ICON_MAP: Record<string, string> = {
@ -287,10 +295,7 @@ async function installApp(step: GoalStep) {
if (!step.appId) return
isInstalling.value = true
// Start goal tracking if not already started
if (!goalStore.progress[goalId.value]) {
goalStore.startGoal(goalId.value)
}
ensureGoalStarted()
try {
await appStore.installPackage(step.appId, '', 'latest')
@ -303,19 +308,28 @@ async function installApp(step: GoalStep) {
}
function openConfigureStep(step: GoalStep) {
// Mark step as complete when user acknowledges they've configured
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
// If there's an app to open, navigate to it
if (step.appId) {
router.push(`/dashboard/apps/${step.appId}`)
const targetPath = goalStepTargetPath(step)
if (targetPath) {
router.push(targetPath)
}
}
function completeVerifyStep(step: GoalStep) {
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
}
function completeInfoStep(step: GoalStep) {
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
}
function ensureGoalStarted() {
if (!goalStore.progress[goalId.value]) {
goalStore.startGoal(goalId.value)
}
goalStore.completeStep(goalId.value, step.id)
}
function goBack() {

View File

@ -86,8 +86,8 @@
</div>
<div class="p-4 bg-white/5 rounded-lg flex items-center justify-around">
<button v-for="app in quickLaunchApps" :key="app.id" @click="useAppLauncherStore().openSession(app.id)" class="group" :title="app.name">
<div class="w-14 h-14 rounded-xl overflow-hidden border border-white/10 transition-all group-hover:-translate-y-1 group-hover:border-white/25 group-hover:shadow-lg flex items-center justify-center" :style="app.bg ? { background: app.bg } : {}" :class="{ 'bg-white/5': !app.bg }">
<img :src="app.icon" :alt="app.name" :class="app.padded ? 'w-10 h-10 object-contain' : 'w-full h-full object-cover'" />
<div class="w-14 h-14 rounded-xl overflow-hidden transition-all group-hover:-translate-y-1 group-hover:shadow-lg flex items-center justify-center">
<img :src="app.icon" :alt="app.name" class="w-full h-full rounded-xl archy-app-icon" @error="handleImageError" />
</div>
</button>
</div>
@ -125,8 +125,58 @@
</div>
</div>
<!-- App Store Recommendations -->
<div
v-if="homeRecommendedApps.length > 0"
data-controller-container
tabindex="0"
class="home-card controller-focusable"
:class="{ 'home-card-animate': animateCards }"
style="--card-stagger: 2"
>
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
<div class="home-card-text">
<h2 class="text-xl font-semibold text-white mb-1">{{ t('home.recommendedApps') }}</h2>
<p class="text-sm text-white/70">{{ t('home.recommendedAppsDesc') }}</p>
</div>
<RouterLink to="/dashboard/marketplace" :aria-label="t('home.goToAppStore')" class="text-white/60 hover:text-white transition-colors">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</RouterLink>
</div>
<div class="home-card-stats space-y-3 mb-4 flex-1 min-h-0">
<button
v-for="app in homeRecommendedApps"
:key="app.id"
type="button"
class="w-full flex items-center gap-3 p-3 bg-white/5 rounded-lg text-left transition-colors hover:bg-white/10"
@click="viewRecommendedApp(app)"
>
<img
v-if="app.icon"
:src="app.icon"
:alt="app.title || app.id"
class="w-10 h-10 rounded-lg archy-app-icon shrink-0"
@error="handleImageError"
/>
<div v-else class="w-10 h-10 rounded-lg bg-white/10 shrink-0"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ app.title || app.id }}</p>
<p class="text-xs text-white/55 truncate">{{ marketplaceDescription(app) }}</p>
</div>
<span class="text-xs text-white/45 capitalize">{{ getAppTier(app.id) }}</span>
</button>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
<RouterLink to="/dashboard/marketplace" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">{{ t('home.browseStore') }}</RouterLink>
</div>
</div>
</div>
</div>
<!-- Network Overview -->
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 2">
<div data-controller-container tabindex="0" class="home-card controller-focusable" :class="{ 'home-card-animate': animateCards }" style="--card-stagger: 3">
<div class="home-card-shell">
<div class="home-card-inner p-6 flex flex-col h-full min-h-0">
<div class="home-card-header flex items-start justify-between mb-4 shrink-0">
@ -179,25 +229,16 @@
@open-in-mempool="openInMempool"
/>
<!-- System Stats -->
<HomeSystemCard
:animate="animateCards"
:loaded="systemStatsLoaded"
:stats="systemStats"
:uptime-display="systemUptimeDisplay"
/>
</div>
<!-- Quick Start Goals -->
<div
v-if="homeTab === 'dashboard' && showQuickStart"
v-if="showQuickStart"
class="home-card transition-opacity duration-300"
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
style="--card-stagger: 5"
>
<div class="home-card-shell">
<div class="home-card-inner px-6 py-6">
<div class="flex items-start justify-between mb-2">
<div class="home-card-inner px-6 py-6 flex flex-col h-full min-h-0">
<div class="flex items-start justify-between mb-2 shrink-0">
<div>
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('home.quickStartGoals') }}</h2>
<p class="text-sm text-white/60 mb-4">{{ t('home.quickStartDesc') }}</p>
@ -206,7 +247,7 @@
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="grid grid-cols-1 gap-3 mt-auto">
<RouterLink v-for="goal in topGoals" :key="goal.id" :to="`/dashboard/goals/${goal.id}`" class="home-card-btn path-action-button path-action-button--continue flex items-center justify-center gap-3">
<span>{{ goal.title }}</span>
</RouterLink>
@ -214,6 +255,15 @@
</div>
</div>
</div>
<!-- System Stats -->
<HomeSystemCard
:animate="animateCards"
:loaded="systemStatsLoaded"
:stats="systemStats"
:uptime-display="systemUptimeDisplay"
/>
</div>
</template>
<!-- Chat Mode -->
@ -246,6 +296,11 @@ import { GOALS } from '@/data/goals'
import EasyHome from '@/components/EasyHome.vue'
import { fileBrowserClient } from '@/api/filebrowser-client'
import { rpcClient } from '@/api/rpc-client'
import { getAppUsage } from '@/utils/appUsage'
import { handleImageError, isServicePackage, isWebsitePackage, resolveAppIcon } from './apps/appsConfig'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { getAppTier, getCuratedAppList, type MarketplaceApp } from './marketplace/marketplaceData'
import { getHomeRecommendedApps } from './home/homeRecommendations'
import HomeWalletCard from './home/HomeWalletCard.vue'
import HomeSystemCard from './home/HomeSystemCard.vue'
import type { WalletTransaction } from './home/HomeWalletCard.vue'
@ -264,6 +319,7 @@ const QUICK_START_RESHOW_LOGINS = 5
const store = useAppStore()
const homeStatus = useHomeStatusStore()
const loginTransition = useLoginTransitionStore()
const { setCurrentApp } = useMarketplaceApp()
const LINE1 = t('home.title')
const LINE2 = t('home.subtitle')
@ -306,11 +362,40 @@ const packages = computed(() => store.packages)
const appCount = computed(() => Object.keys(packages.value || {}).length)
const runningCount = computed(() => Object.values(packages.value || {}).filter(pkg => pkg.state === PackageState.Running).length)
const quickLaunchApps = [
{ id: 'indeedhub', name: 'Indeehub', icon: '/assets/img/app-icons/indeedhub.png', bg: '#0a0a0a', padded: true },
{ id: 'botfights', name: 'BotFights', icon: '/assets/img/app-icons/botfights.svg', bg: '', padded: false },
{ id: '484-kitchen', name: '484 Kitchen', icon: '/assets/img/app-icons/484-kitchen.png', bg: '', padded: false },
]
const quickLaunchApps = computed(() => {
const usage = getAppUsage()
return Object.entries(packages.value || {})
.filter(([id, pkg]) => !isServicePackage(id, pkg) && !isWebsitePackage(id, pkg))
.map(([id, pkg]) => ({
id,
name: pkg.manifest?.title || id,
icon: resolveAppIcon(id, pkg),
state: pkg.state,
usage: usage[id]?.count || 0,
lastLaunchedAt: usage[id]?.lastLaunchedAt || 0,
}))
.sort((a, b) => {
if (b.usage !== a.usage) return b.usage - a.usage
if (b.lastLaunchedAt !== a.lastLaunchedAt) return b.lastLaunchedAt - a.lastLaunchedAt
if ((b.state === PackageState.Running ? 1 : 0) !== (a.state === PackageState.Running ? 1 : 0)) {
return (b.state === PackageState.Running ? 1 : 0) - (a.state === PackageState.Running ? 1 : 0)
}
return a.name.localeCompare(b.name)
})
.slice(0, 3)
})
const homeRecommendedApps = computed(() => getHomeRecommendedApps(getCuratedAppList(), packages.value, 3))
function viewRecommendedApp(app: MarketplaceApp) {
setCurrentApp(app)
router.push({ name: 'marketplace-app-detail', params: { id: app.id } }).catch(() => {})
}
function marketplaceDescription(app: MarketplaceApp) {
if (typeof app.description === 'string') return app.description
return app.description?.short || app.description?.long || ''
}
// Network card data
const torConnected = computed(() => {

View File

@ -3,27 +3,51 @@
<!-- Header Section -->
<div>
<!-- Desktop: tabs + categories + search -->
<div class="hidden md:flex mb-4 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<div ref="marketplaceHeaderRef" class="hidden md:flex mb-4 items-center gap-4 relative">
<div ref="marketplacePrimaryRef" class="flex-shrink-0">
<div class="mode-switcher hidden md:inline-flex">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink
to="/dashboard/discover"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': $route.path === '/dashboard/discover' }"
>Discover</RouterLink>
</div>
<div v-show="!collapseCategories" class="mode-switcher category-tabs-wide hidden md:inline-flex flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectCategory(category.id)"
v-for="section in appStoreSections"
:key="section.id"
@click="selectAppStoreSection(section.id)"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': selectedCategory === category.id }"
:class="{ 'mode-switcher-btn-active': selectedCategory === section.id }"
>
{{ category.name }}
<span v-if="category.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
{{ section.name }}
<span v-if="section.id === 'nostr' && nostrApps.length > 0" class="ml-1 text-xs px-1.5 py-0.5 rounded-full bg-white/10">+{{ nostrApps.length }}</span>
</button>
</div>
<div v-show="collapseCategories" class="segmented-select flex-shrink-0">
<label class="sr-only" for="marketplace-category-select">App Store category</label>
<select
id="marketplace-category-select"
:value="selectedCategory"
class="segmented-select-control"
@change="selectAppStoreSection(($event.target as HTMLSelectElement).value)"
>
<option
v-for="section in appStoreSections"
:key="section.id"
:value="section.id"
>
{{ section.name }}{{ section.id === 'nostr' && nostrApps.length > 0 ? ` +${nostrApps.length}` : '' }}
</option>
</select>
</div>
<div ref="marketplaceCategoryProbeRef" class="mode-switcher category-tabs-probe" aria-hidden="true">
<button
v-for="section in appStoreSections"
:key="section.id"
class="mode-switcher-btn"
type="button"
>
{{ section.name }}
</button>
</div>
<input
@ -31,7 +55,7 @@
type="text"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="flex-1 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"
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"
/>
</div>
@ -43,18 +67,13 @@
</div>
<div class="mobile-category-strip mb-3" aria-label="App Store categories">
<button
@click="router.push({ name: 'discover' })"
v-for="section in appStoreSections"
:key="section.id"
@click="selectAppStoreSection(section.id)"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': selectedCategory === section.id }"
type="button"
>Discover</button>
<button
v-for="category in categoriesWithApps"
:key="category.id"
@click="selectCategory(category.id)"
class="mobile-category-pill"
:class="{ 'mobile-category-pill-active': selectedCategory === category.id }"
type="button"
>{{ category.name }}</button>
>{{ section.name }}</button>
</div>
<input
v-model="searchQuery"
@ -76,6 +95,26 @@
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template v-if="(loadingCommunity || nostrLoading) && filteredApps.length === 0">
<div
v-for="index in 6"
:key="`loading-${index}`"
class="glass-card p-6 flex flex-col app-card-skeleton"
aria-hidden="true"
>
<div class="flex items-start gap-4 mb-4">
<div class="app-card-skeleton-icon"></div>
<div class="flex-1 min-w-0 pt-1">
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
<div class="app-card-skeleton-line w-1/3"></div>
</div>
</div>
<div class="app-card-skeleton-line w-full mb-2"></div>
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
<div class="app-card-skeleton-button mt-auto"></div>
</div>
</template>
<MarketplaceAppCard
v-for="(app, index) in filteredApps"
:key="app.id"
@ -97,15 +136,8 @@
</div>
<!-- Empty State -->
<div v-if="filteredApps.length === 0" class="text-center py-12">
<div v-if="loadingCommunity || nostrLoading" class="flex flex-col items-center gap-4">
<svg class="animate-spin h-12 w-12 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">{{ nostrLoading ? t('marketplace.queryingRelays') : t('common.loading') }}</p>
</div>
<div v-else-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
<div v-if="filteredApps.length === 0 && !(loadingCommunity || nostrLoading)" class="text-center py-12">
<div v-if="nostrError && selectedCategory === 'nostr'" class="flex flex-col items-center gap-4">
<p class="text-white/70">{{ t('marketplace.noCommunityApps') }}</p>
<p class="text-white/40 text-sm">{{ nostrError }}</p>
<button @click="nostrApps = []; loadNostrMarketplace()" class="px-4 py-2 glass-button rounded-lg text-sm">{{ t('common.retry') }}</button>
@ -132,6 +164,8 @@ import { rpcClient } from '@/api/rpc-client'
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useToast } from '@/composables/useToast'
import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import {
type MarketplaceApp,
@ -155,19 +189,8 @@ const toast = useToast()
// Category state read initial value from query param (set by Discover page navigation)
const selectedCategory = ref((route.query.category as string) || 'all')
const categories = computed(() => [
{ id: 'all', name: t('marketplace.all') },
{ id: 'community', name: t('marketplace.community') },
{ id: 'nostr', name: 'Nostr' },
{ id: 'commerce', name: t('marketplace.commerce') },
{ id: 'money', name: t('marketplace.money') },
{ id: 'data', name: t('marketplace.data') },
{ id: 'home', name: t('marketplace.homeCategory') },
{ id: 'car', name: t('marketplace.auto') },
{ id: 'networking', name: t('marketplace.networking') },
{ id: 'l484', name: 'L484' },
{ id: 'other', name: t('marketplace.other') }
])
const categories = computed(() => APP_STORE_CATEGORIES)
const appStoreSections = computed(() => APP_STORE_SECTIONS)
// Installation state uses global store so it persists across navigation
const installingApps = server.installingApps
@ -186,6 +209,14 @@ function selectCategory(id: string) {
}
}
function selectAppStoreSection(id: string) {
if (id === 'discover') {
router.push('/dashboard/discover')
return
}
selectCategory(id)
}
watch(() => route.query.category, (category) => {
const next = typeof category === 'string' && category ? category : 'all'
selectedCategory.value = next
@ -200,6 +231,15 @@ const communityError = ref('')
const communityApps = ref<MarketplaceApp[]>([])
const searchQuery = ref('')
const bitcoinPruned = ref(false)
const marketplaceHeaderRef = ref<HTMLElement | null>(null)
const marketplacePrimaryRef = ref<HTMLElement | null>(null)
const marketplaceCategoryProbeRef = ref<HTMLElement | null>(null)
const { collapsed: collapseCategories } = useCollapsingHeaderTabs(
marketplaceHeaderRef,
marketplacePrimaryRef,
marketplaceCategoryProbeRef,
144
)
// Nostr community marketplace state
const nostrApps = ref<MarketplaceApp[]>([])
@ -270,14 +310,6 @@ const allApps = computed(() => {
return base
})
const categoriesWithApps = computed(() => {
const apps = allApps.value
return categories.value.filter(cat => {
if (cat.id === 'all') return apps.length > 0
return apps.some(app => app.category === cat.id)
})
})
const filteredApps = computed(() => {
let apps = allApps.value
@ -500,4 +532,49 @@ async function installCommunityApp(app: MarketplaceApp) {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
}
.app-card-skeleton {
min-height: 255px;
pointer-events: none;
}
.app-card-skeleton-icon,
.app-card-skeleton-line,
.app-card-skeleton-button {
position: relative;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
}
.app-card-skeleton-icon::after,
.app-card-skeleton-line::after,
.app-card-skeleton-button::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
.app-card-skeleton-icon {
width: 4rem;
height: 4rem;
border-radius: 0.5rem;
flex: 0 0 auto;
}
.app-card-skeleton-line {
height: 0.75rem;
border-radius: 999px;
}
.app-card-skeleton-button {
height: 2.5rem;
border-radius: 0.5rem;
}
@keyframes skeleton-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
</style>

View File

@ -201,21 +201,19 @@
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Screenshots Gallery -->
<div class="glass-card p-6">
<div v-if="screenshots.length > 0" class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('marketplaceDetails.screenshots') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="i in 4"
:key="i"
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
>
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<img
v-for="screenshot in screenshots"
:key="screenshot.src"
:src="screenshot.src"
:alt="screenshot.alt"
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
loading="lazy"
/>
</div>
</div>
<p class="text-white/60 text-sm mt-3 text-center">{{ t('marketplaceDetails.screenshotPlaceholder') }}</p>
</div>
<!-- Description -->
<div class="glass-card p-6">
@ -443,6 +441,26 @@ const longDescription = computed(() => {
}
})
const screenshots = computed(() => normalizeScreenshots(app.value?.screenshots))
function normalizeScreenshots(items: MarketplaceAppInfo['screenshots'] | undefined) {
if (!Array.isArray(items)) return []
return items
.map((item, index) => {
if (typeof item === 'string') {
const src = item.trim()
return src ? { src, alt: `${app.value?.title || 'App'} screenshot ${index + 1}` } : null
}
const src = item.src?.trim()
if (!src) return null
return {
src,
alt: item.alt?.trim() || `${app.value?.title || 'App'} screenshot ${index + 1}`,
}
})
.filter((item): item is { src: string; alt: string } => item !== null)
}
// Placeholder features
const features = computed(() => {
return [

View File

@ -15,10 +15,12 @@ const transport = useTransportStore()
// Responsive layout breakpoints
const isWideDesktop = ref(window.innerWidth >= 1536)
const isVeryWideDesktop = ref(window.innerWidth >= 2560 && window.innerHeight >= 1200)
const isMobile = ref(window.innerWidth < 1280)
function handleResize() {
isWideDesktop.value = window.innerWidth >= 1536
isVeryWideDesktop.value = window.innerWidth >= 2560 && window.innerHeight >= 1200
isMobile.value = window.innerWidth < 1280
}
@ -251,15 +253,21 @@ const showChatPanel = computed(() =>
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
)
const showBitcoinPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'bitcoin'
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'bitcoin'
return activeTab.value === 'bitcoin'
})
const showDeadmanPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'deadman'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'deadman'
return activeTab.value === 'deadman'
})
const showMapPanel = computed(() => {
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'map'
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'map'
if (isMobile.value && !mobileShowChat.value) return toolsTab.value === 'map'
return activeTab.value === 'map'
})
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
@ -1270,7 +1278,7 @@ function isImageMime(mime?: string): boolean {
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
<!-- Responsive column layout -->
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop, 'mesh-columns-very-wide': isVeryWideDesktop }">
<!-- LEFT COLUMN: Status + Peers -->
<div class="mesh-left" data-controller-zone="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
<!-- Device Status -->
@ -1727,8 +1735,7 @@ function isImageMime(mime?: string): boolean {
<!-- Tools panels (3rd column on wide screens) -->
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
<!-- Tools tab bar (wide desktop only) -->
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
Off-Grid Bitcoin
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
@ -1742,10 +1749,9 @@ function isImageMime(mime?: string): boolean {
<span v-if="mesh.nodePositions.size > 0" class="mesh-tab-badge">{{ mesh.nodePositions.size }}</span>
</button>
</div>
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
<MeshBitcoinPanel v-if="showBitcoinPanel" />
<MeshDeadmanPanel v-if="showDeadmanPanel" />
<div v-if="showMapPanel" class="glass-card mesh-map-panel"><MeshMap /></div>
</div>
</div>

View File

@ -14,7 +14,7 @@
<p class="text-base sm:text-xl text-white/80">How would you like to get started?</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-6 px-3 sm:px-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-6 px-3 sm:px-4 max-w-[760px] mx-auto w-full">
<!-- Fresh Start -->
<button
@click="selectOption('fresh')"
@ -52,22 +52,6 @@
Enter your 24-word recovery phrase
</p>
</button>
<!-- Connect Existing (Coming Soon) -->
<div class="path-option-card text-center opacity-40 cursor-not-allowed">
<div class="mb-3 sm:mb-4">
<div class="w-12 h-12 sm:w-16 sm:h-16 mx-auto bg-white/10 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 sm:w-8 sm:h-8 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
</div>
<h3 class="text-lg sm:text-xl font-semibold text-white mb-1 sm:mb-2">Connect Existing</h3>
<p class="text-white/70 text-xs sm:text-sm">
Connect to an existing Archipelago server
</p>
<span class="text-xs text-white/50 mt-1 block">(Coming Soon)</span>
</div>
</div>
</div>

View File

@ -38,7 +38,7 @@
</div>
<!-- Loading -->
<div v-if="loading" class="glass-card p-8 text-center">
<div v-if="loading && catalogItems.length === 0" class="glass-card p-8 text-center">
<svg class="animate-spin h-6 w-6 text-purple-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@ -47,7 +47,7 @@
</div>
<!-- Error -->
<div v-else-if="catalogError" class="glass-card p-6">
<div v-else-if="catalogError && catalogItems.length === 0" class="glass-card p-6">
<div class="alert-error mb-4">{{ catalogError }}</div>
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
</div>
@ -67,7 +67,18 @@
</div>
<!-- File Grid -->
<div v-else-if="catalogItems.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-if="catalogItems.length > 0" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing peer files...
</div>
<div v-else-if="catalogError" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
{{ catalogError }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="item in catalogItems"
:key="item.id"
@ -194,6 +205,7 @@
</div>
</div>
</div>
</div>
<!-- Video player modal -->
<Teleport to="body">
@ -313,9 +325,9 @@ onMounted(async () => {
async function loadCatalog() {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
const hadItems = catalogItems.value.length > 0
loading.value = true
catalogError.value = ''
catalogItems.value = []
try {
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
method: 'content.browse-peer',
@ -325,6 +337,7 @@ async function loadCatalog() {
catalogItems.value = result?.items ?? []
} catch (e: unknown) {
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
if (!hadItems) catalogItems.value = []
} finally {
loading.value = false
}
@ -563,6 +576,8 @@ function triggerDownload(base64Data: string, item: CatalogItem) {
a.click()
URL.revokeObjectURL(url)
}
defineExpose({ loadCatalog })
</script>
<style scoped>

View File

@ -80,6 +80,13 @@
</template>
<template v-else>
<div v-if="networkRefreshing" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing network...
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
@ -135,46 +142,14 @@
</div>
</template>
</div>
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
Manage Local Network
</button>
</div>
<!-- Web3 Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
</div>
<div class="flex-1">
<h2 class="text-xl font-semibold text-white mb-2">Web3</h2>
<p class="text-white/70 text-sm mb-4">Decentralized web hosting and services</p>
</div>
</div>
<div class="space-y-3 flex-1 min-h-0">
<div v-for="item in ['Hosted Websites', 'SSL Certificates', 'IPFS Storage', 'ENS Domains']" :key="item" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
<span class="text-white/80 text-sm">{{ item }}</span>
</div>
<span class="text-white/40 text-xs px-2 py-0.5 bg-white/5 rounded-full">Coming Soon</span>
</div>
</div>
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
Manage Web3 Services
</button>
</div>
</div>
<!-- FIPS Mesh (full card) -->
<div class="mb-8">
<FipsNetworkCard />
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<!-- VPN Card -->
<div class="glass-card p-6 transition-all hover:-translate-y-1">
<div class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
@ -185,7 +160,7 @@
<p class="text-xs text-white/50">Standalone WireGuard VPN</p>
</div>
</div>
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="glass-button px-4 py-2 text-sm">Add Device</button>
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-top glass-button px-4 py-2 text-sm">Add Device</button>
</div>
<!-- WireGuard Status -->
@ -221,10 +196,14 @@
</div>
<div v-else class="text-xs text-white/30 py-2">No devices added yet</div>
</div>
<button @click="showAddDeviceModal = true; showingNewDevice = true" class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
Add Device
</button>
</div>
<!-- Network Interfaces (second column on desktop) -->
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>
@ -233,7 +212,7 @@
<button
v-if="wifiAvailable"
@click="showWifiModal = true"
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="responsive-card-actions-top px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Scan WiFi
</button>
@ -246,6 +225,13 @@
</template>
<template v-else>
<div class="space-y-3">
<div v-if="interfacesRefreshing" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing interfaces...
</div>
<div
v-for="iface in physicalInterfaces"
:key="iface.name"
@ -266,6 +252,14 @@
<p v-if="physicalInterfaces.length === 0" class="text-sm text-white/50 text-center py-4">No physical interfaces detected</p>
</div>
</template>
<button
v-if="wifiAvailable"
@click="showWifiModal = true"
class="responsive-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
>
Scan WiFi
</button>
</div>
</div><!-- close VPN+Network 2-col grid -->
@ -413,6 +407,8 @@ const logCount = ref(0)
// Network data
const networkLoading = ref(true)
const networkRefreshing = ref(false)
const networkHasLoaded = ref(false)
const networkData = ref({
wifiCount: 'N/A', wifiSsid: null as string | null, torConnected: false, forwardCount: 'N/A',
vpnConnected: false, vpnProvider: '', vpnIp: '', wgIp: '', wgPubkey: '', vpnHostname: '', vpnPeers: 0,
@ -449,7 +445,9 @@ async function loadFipsSummary() {
}
async function loadNetworkData() {
networkLoading.value = true
const initialLoad = !networkHasLoaded.value
networkLoading.value = initialLoad
networkRefreshing.value = !initialLoad
try {
const [diagRes, fwdRes, vpnRes, dnsRes] = await Promise.allSettled([
rpcClient.call<{ wan_ip: string | null; nat_type: string; upnp_available: boolean; tor_connected: boolean; wifi_count?: number }>({ method: 'network.diagnostics' }),
@ -461,7 +459,11 @@ async function loadNetworkData() {
if (fwdRes.status === 'fulfilled') { const c = fwdRes.value.forwards?.length ?? 0; networkData.value.forwardCount = `${c} rule${c !== 1 ? 's' : ''}` }
if (vpnRes.status === 'fulfilled') { networkData.value.vpnConnected = vpnRes.value.connected; networkData.value.vpnProvider = vpnRes.value.provider ?? ''; networkData.value.vpnIp = (vpnRes.value.ip_address ?? '').replace(/\/\d+$/, ''); networkData.value.wgIp = vpnRes.value.wg_ip ?? ''; networkData.value.wgPubkey = (vpnRes.value as Record<string, unknown>).wg_pubkey as string ?? '' }
if (dnsRes.status === 'fulfilled') { networkData.value.dnsProvider = dnsRes.value.provider; networkData.value.dnsServers = dnsRes.value.resolv_conf_servers ?? []; networkData.value.dnsDoH = dnsRes.value.doh_enabled }
} catch { /* keep defaults */ } finally { networkLoading.value = false }
} catch { /* keep existing/default values */ } finally {
networkHasLoaded.value = true
networkLoading.value = false
networkRefreshing.value = false
}
}
// VPN peer management
@ -548,6 +550,8 @@ interface NetworkInterface { name: string; type: string; state: string; mac: str
interface WifiNetwork { ssid: string; signal: number; security: string }
const interfacesLoading = ref(true)
const interfacesRefreshing = ref(false)
const interfacesHaveLoaded = ref(false)
const allInterfaces = ref<NetworkInterface[]>([])
const physicalInterfaces = computed(() => allInterfaces.value.filter(i => i.type === 'ethernet' || i.type === 'wifi'))
const wifiAvailable = computed(() => allInterfaces.value.some(i => i.type === 'wifi'))
@ -599,8 +603,11 @@ async function applyDnsConfig(customServers: string) {
}
async function loadInterfaces() {
interfacesLoading.value = true
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { allInterfaces.value = [] } finally { interfacesLoading.value = false }
const initialLoad = !interfacesHaveLoaded.value
const hadInterfaces = allInterfaces.value.length > 0
interfacesLoading.value = initialLoad
interfacesRefreshing.value = !initialLoad
try { const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({ method: 'network.list-interfaces' }); allInterfaces.value = res.interfaces } catch { if (!hadInterfaces) allInterfaces.value = [] } finally { interfacesHaveLoaded.value = true; interfacesLoading.value = false; interfacesRefreshing.value = false }
}
async function scanWifi() {
@ -670,9 +677,10 @@ const availableAppsForTor = computed(() => {
})
async function loadTorServices() {
const hadServices = torServices.value.length > 0
torServicesLoading.value = true
try { const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' }); torServices.value = res.services || []; torDaemonRunning.value = res.tor_running ?? false }
catch { torServices.value = []; torDaemonRunning.value = false } finally { torServicesLoading.value = false }
catch { if (!hadServices) { torServices.value = []; torDaemonRunning.value = false } } finally { torServicesLoading.value = false }
}
async function copyTorAddress(address: string) {
@ -753,4 +761,16 @@ async function checkTorStatus() {
const logsToast = ref('')
function viewLogs() { logCount.value = 0; logsToast.value = 'Server logs are available via SSH: journalctl -u archipelago -f'; setTimeout(() => { logsToast.value = '' }, 6000) }
defineExpose({
allInterfaces,
interfacesRefreshing,
loadInterfaces,
loadNetworkData,
loadTorServices,
networkData,
networkRefreshing,
torServices,
torServicesLoading,
})
</script>

View File

@ -0,0 +1,83 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppSession from '../AppSession.vue'
const { mockReplace, mockPush, mockWindowOpen, mockSuppress, mockResume } = vi.hoisted(() => ({
mockReplace: vi.fn(),
mockPush: vi.fn(),
mockWindowOpen: vi.fn(),
mockSuppress: vi.fn(),
mockResume: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
params: { appId: 'gitea' },
query: { returnTo: '/dashboard/apps' },
fullPath: '/dashboard/apps/session/gitea',
}),
useRouter: () => ({ replace: mockReplace, push: mockPush }),
}))
vi.mock('@/stores/appLauncher', () => ({
useAppLauncherStore: () => ({ panelAppId: null }),
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ data: { 'package-data': {} } }),
}))
vi.mock('@/stores/screensaver', () => ({
useScreensaverStore: () => ({ suppress: mockSuppress, resume: mockResume }),
}))
vi.mock('../appSession/useAppIdentity', () => ({
useAppIdentity: () => ({
onIdentitySelected: vi.fn(),
onIframeLoadIdentity: vi.fn(),
handleIdentityRequest: vi.fn(),
getStoredIdentity: vi.fn(),
}),
}))
vi.mock('../appSession/useNostrBridge', () => ({
useNostrBridge: () => ({ handleNostrRequest: vi.fn() }),
}))
vi.stubGlobal('open', mockWindowOpen)
describe('AppSession mobile new-tab apps', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
configurable: true,
})
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
})
it('keeps iframe-blocked apps inside the mobile session instead of auto-opening a tab', async () => {
const wrapper = mount(AppSession, {
global: {
stubs: {
Teleport: true,
AppSessionHeader: true,
NostrIdentityPicker: true,
MobileGamepad: true,
},
},
})
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockReplace).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('This app opens in a new tab')
expect(wrapper.text()).toContain('Open in new tab')
})
})

View File

@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Cloud from '../Cloud.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
RouterLink: { name: 'RouterLink', props: ['to'], template: '<a><slot /></a>' },
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
federationListNodes: 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 makePeer() {
return {
did: 'did:key:peer',
pubkey: 'peer',
onion: 'peer.onion',
name: 'Peer Alpha',
trust_level: 'trusted',
}
}
describe('Cloud peer list', () => {
it('keeps peer nodes visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValueOnce({ nodes: [makePeer()] })
const wrapper = mount(Cloud)
await flushPromises()
expect(wrapper.text()).toContain('Peer Alpha')
expect(wrapper.text()).not.toContain('No peers yet')
const pending = deferred<{ nodes: [] }>()
vi.mocked(rpcClient.federationListNodes).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadPeers: () => Promise<void> }).loadPeers()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Peer Alpha')
expect(wrapper.text()).toContain('Refreshing peer nodes...')
expect(wrapper.text()).not.toContain('No peers yet')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Peer Alpha')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing peer nodes...')
expect(wrapper.text()).not.toContain('No peers yet')
})
})

View File

@ -0,0 +1,76 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Credentials from '../Credentials.vue'
import { rpcClient } from '@/api/rpc-client'
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 makeCredential(id: string) {
return {
id,
type: ['VerifiableCredential', 'NodeOperator'],
issuer: 'did:key:issuer',
credentialSubject: { id: 'did:key:subject' },
issuanceDate: '2026-06-10T10:00:00Z',
status: 'active',
}
}
describe('Credentials', () => {
it('keeps credentials visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'identity.list') return Promise.resolve({ identities: [] })
if (request.method === 'identity.list-credentials') {
return Promise.resolve({ credentials: [makeCredential('cred-one')] })
}
return Promise.resolve({})
})
const wrapper = mount(Credentials, {
global: {
mocks: {
$router: { push: vi.fn() },
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('cred-one')
const pending = deferred<{ credentials: [] }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'identity.list-credentials') return pending.promise
return Promise.resolve({ identities: [] })
})
const refresh = (wrapper.vm as unknown as { loadCredentials: () => Promise<void> }).loadCredentials()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('cred-one')
expect(wrapper.text()).toContain('Refreshing credentials...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('NodeOperator')
expect(wrapper.text()).toContain('cred-one')
expect(wrapper.text()).not.toContain('Refreshing credentials...')
})
})

View File

@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import OnboardingOptions from '../OnboardingOptions.vue'
const push = vi.fn(() => Promise.resolve())
vi.mock('vue-router', () => ({
useRouter: () => ({ push }),
}))
vi.mock('@/composables/useNavSounds', () => ({
playNavSound: vi.fn(),
}))
describe('OnboardingOptions', () => {
it('shows only usable setup paths', () => {
const wrapper = mount(OnboardingOptions)
expect(wrapper.text()).toContain('Fresh Start')
expect(wrapper.text()).toContain('Restore from Seed')
expect(wrapper.text()).not.toContain('Connect Existing')
expect(wrapper.text()).not.toContain('Coming Soon')
})
it('continues to fresh seed generation by default', async () => {
push.mockClear()
const wrapper = mount(OnboardingOptions)
await wrapper.get('button.path-action-button').trigger('click')
expect(push).toHaveBeenCalledWith('/onboarding/seed')
})
it('routes restore choice to seed restore', async () => {
push.mockClear()
const wrapper = mount(OnboardingOptions)
const restoreButton = wrapper.findAll('button').find((button) => button.text().includes('Restore from Seed'))
expect(restoreButton).toBeDefined()
await restoreButton!.trigger('click')
await wrapper.get('button.path-action-button').trigger('click')
expect(push).toHaveBeenCalledWith('/onboarding/seed-restore')
})
})

View File

@ -0,0 +1,85 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
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(),
},
}))
vi.mock('@/composables/useAudioPlayer', () => ({
useAudioPlayer: () => ({ play: 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 makeCatalogItem() {
return {
id: 'file-1',
filename: 'notes.txt',
mime_type: 'text/plain',
size_bytes: 128,
description: '',
access: 'free',
}
}
describe('PeerFiles', () => {
it('keeps peer catalog items visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
nodes: [{
did: 'did:key:peer',
pubkey: 'peer',
onion: 'peer.onion',
name: 'Peer',
trust_level: 'trusted',
}],
} as never)
vi.mocked(rpcClient.call).mockResolvedValueOnce({ items: [makeCatalogItem()] })
const wrapper = mount(PeerFiles, {
props: { peerId: 'peer.onion' },
global: {
stubs: {
Teleport: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('notes.txt')
const pending = deferred<{ items: [] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadCatalog: () => Promise<void> }).loadCatalog()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('notes.txt')
expect(wrapper.text()).toContain('Refreshing peer files...')
expect(wrapper.text()).not.toContain('Connecting via Tor')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('notes.txt')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing peer files...')
})
})

View File

@ -0,0 +1,216 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Server from '../Server.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ packages: {} }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
vpnStatus: vi.fn(),
dnsStatus: vi.fn(),
diskStatus: 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 mountServer(options: { renderTorServices?: boolean } = {}) {
return mount(Server, {
global: {
stubs: {
QuickActionsCard: true,
TorServicesCard: options.renderTorServices ? false : true,
ServerModals: true,
FipsNetworkCard: true,
},
},
})
}
describe('Server network refresh states', () => {
it('keeps network overview visible while refresh is pending', async () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.diagnostics') {
return Promise.resolve({ tor_connected: true, wifi_count: 2, wifi_ssid: 'Lab WiFi' })
}
if (request.method === 'router.list-forwards') {
return Promise.resolve({ forwards: [{}, {}] })
}
if (request.method === 'network.list-interfaces') {
return Promise.resolve({ interfaces: [] })
}
if (request.method === 'tor.list-services') {
return Promise.resolve({ services: [], tor_running: false })
}
if (request.method === 'vpn.list-peers') {
return Promise.resolve({ peers: [] })
}
if (request.method === 'fips.status') {
return Promise.resolve({ installed: false, service_active: false, key_present: false })
}
return Promise.resolve({})
})
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: true, provider: 'wireguard', ip_address: '10.0.0.2/32', wg_ip: '10.0.0.1/24' } as never)
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'cloudflare', resolv_conf_servers: ['1.1.1.1'], doh_enabled: true } as never)
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
const wrapper = mountServer()
await flushPromises()
expect(wrapper.text()).toContain('Lab WiFi')
expect(wrapper.text()).toContain('2 rules')
const pendingDiagnostics = deferred<{ tor_connected: boolean; wifi_count: number; wifi_ssid: string }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.diagnostics') return pendingDiagnostics.promise
if (request.method === 'router.list-forwards') return Promise.reject(new Error('offline'))
return Promise.resolve({})
})
vi.mocked(rpcClient.vpnStatus).mockRejectedValueOnce(new Error('offline') as never)
vi.mocked(rpcClient.dnsStatus).mockRejectedValueOnce(new Error('offline') as never)
const refresh = (wrapper.vm as unknown as { loadNetworkData: () => Promise<void> }).loadNetworkData()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Lab WiFi')
expect(wrapper.text()).toContain('2 rules')
expect(wrapper.text()).toContain('Refreshing network...')
pendingDiagnostics.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Lab WiFi')
expect(wrapper.text()).toContain('2 rules')
})
it('keeps network interfaces visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.list-interfaces') {
return Promise.resolve({
interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.168.1.10'] }],
})
}
if (request.method === 'network.diagnostics') {
return Promise.resolve({ tor_connected: false })
}
if (request.method === 'router.list-forwards') {
return Promise.resolve({ forwards: [] })
}
if (request.method === 'tor.list-services') {
return Promise.resolve({ services: [], tor_running: false })
}
if (request.method === 'vpn.list-peers') {
return Promise.resolve({ peers: [] })
}
if (request.method === 'fips.status') {
return Promise.resolve({ installed: false, service_active: false, key_present: false })
}
return Promise.resolve({})
})
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
const wrapper = mountServer()
await flushPromises()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
const pendingInterfaces = deferred<{ interfaces: [] }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.list-interfaces') return pendingInterfaces.promise
return Promise.resolve({})
})
const refresh = (wrapper.vm as unknown as { loadInterfaces: () => Promise<void> }).loadInterfaces()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
expect(wrapper.text()).toContain('Refreshing interfaces...')
pendingInterfaces.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
})
it('keeps Tor services visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'tor.list-services') {
return Promise.resolve({
services: [{
name: 'filebrowser',
local_port: 8080,
onion_address: 'filebrowser123456789.onion',
enabled: true,
unauthenticated: false,
protocol: false,
}],
tor_running: true,
})
}
if (request.method === 'network.diagnostics') {
return Promise.resolve({ tor_connected: true })
}
if (request.method === 'router.list-forwards') {
return Promise.resolve({ forwards: [] })
}
if (request.method === 'network.list-interfaces') {
return Promise.resolve({ interfaces: [] })
}
if (request.method === 'vpn.list-peers') {
return Promise.resolve({ peers: [] })
}
if (request.method === 'fips.status') {
return Promise.resolve({ installed: false, service_active: false, key_present: false })
}
return Promise.resolve({})
})
vi.mocked(rpcClient.vpnStatus).mockResolvedValue({ connected: false } as never)
vi.mocked(rpcClient.dnsStatus).mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false } as never)
vi.mocked(rpcClient.diskStatus).mockResolvedValue({ encrypted: false, warnings: [] } as never)
const wrapper = mountServer({ renderTorServices: true })
await flushPromises()
expect(wrapper.text()).toContain('filebrowser')
expect(wrapper.text()).toContain('filebrowser123456789.onion')
const pendingTor = deferred<{ services: []; tor_running: boolean }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'tor.list-services') return pendingTor.promise
return Promise.resolve({})
})
const refresh = (wrapper.vm as unknown as { loadTorServices: () => Promise<void> }).loadTorServices()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('filebrowser')
expect(wrapper.text()).toContain('filebrowser123456789.onion')
expect(wrapper.text()).toContain('Refreshing Tor services...')
pendingTor.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('filebrowser')
expect(wrapper.text()).toContain('filebrowser123456789.onion')
})
})

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { normalizeCloudPath, parentCloudPath } from '../cloudPath'
describe('cloudPath helpers', () => {
it('normalizes query paths', () => {
expect(normalizeCloudPath('Photos/Trips')).toBe('/Photos/Trips')
expect(normalizeCloudPath('/Photos//Trips')).toBe('/Photos/Trips')
expect(normalizeCloudPath('', '/Photos')).toBe('/Photos')
})
it('walks to the parent folder without leaving root', () => {
expect(parentCloudPath('/Photos/Trips/Day 1')).toBe('/Photos/Trips')
expect(parentCloudPath('/Photos')).toBe('/')
expect(parentCloudPath('/')).toBe('/')
})
})

View File

@ -1,31 +1,29 @@
<template>
<div class="lg:col-span-2 space-y-6">
<!-- Screenshots Gallery -->
<div class="glass-card p-6">
<div v-if="screenshots.length > 0" class="glass-card p-6">
<h2 class="text-2xl font-bold text-white mb-4">{{ t('appDetails.screenshots') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="i in 4"
:key="i"
class="aspect-video rounded-xl bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 transition-colors cursor-pointer"
>
<svg class="w-16 h-16 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<img
v-for="screenshot in screenshots"
:key="screenshot.src"
:src="screenshot.src"
:alt="screenshot.alt"
class="aspect-video w-full rounded-xl border border-white/10 object-cover"
loading="lazy"
/>
</div>
</div>
<p class="text-white/60 text-sm mt-3 text-center">{{ t('appDetails.screenshotPlaceholder') }}</p>
</div>
<!-- Bitcoin Sync Warning (for dependent apps) -->
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-5 border border-orange-500/30">
<div class="flex items-center gap-3 mb-3">
<svg class="w-6 h-6 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div v-if="needsBitcoinSync && !bitcoinSynced" class="glass-card p-6 border border-orange-500/30">
<div class="flex items-start gap-3 mb-4">
<svg class="w-6 h-6 text-orange-400 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1">
<p class="text-orange-300 font-semibold text-sm">Bitcoin is syncing</p>
<p class="text-white/60 text-xs mt-0.5">
<p class="text-orange-300 font-semibold text-xl">Bitcoin is syncing</p>
<p class="text-white/70 mt-2 leading-relaxed">
Some features may be unavailable until Bitcoin finishes syncing.
Wallet connections and block data require a fully synced node.
</p>
@ -37,7 +35,7 @@
:style="{ width: Math.min(bitcoinSyncPercent, 100) + '%' }"
></div>
</div>
<p class="text-xs text-white/40 mt-1.5">
<p class="text-sm text-white/55 mt-2">
{{ bitcoinSyncPercent.toFixed(1) }}% synced Block {{ bitcoinBlockHeight.toLocaleString() }}
</p>
</div>
@ -70,11 +68,13 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AppScreenshot } from '@/types/api'
const { t } = useI18n()
defineProps<{
const props = defineProps<{
pkg: Record<string, any>
features: string[]
needsBitcoinSync: boolean
@ -82,4 +82,28 @@ defineProps<{
bitcoinSyncPercent: number
bitcoinBlockHeight: number
}>()
const screenshots = computed(() => {
const manifestScreenshots = props.pkg.manifest?.screenshots
const staticScreenshots = props.pkg['static-files']?.screenshots
return normalizeScreenshots(Array.isArray(staticScreenshots) ? staticScreenshots : manifestScreenshots)
})
function normalizeScreenshots(items: AppScreenshot[] | undefined) {
if (!Array.isArray(items)) return []
return items
.map((item, index) => {
if (typeof item === 'string') {
const src = item.trim()
return src ? { src, alt: `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}` } : null
}
const src = item.src?.trim()
if (!src) return null
return {
src,
alt: item.alt?.trim() || `${props.pkg.manifest?.title || 'App'} screenshot ${index + 1}`,
}
})
.filter((item): item is { src: string; alt: string } => item !== null)
}
</script>

View File

@ -1,226 +1,71 @@
<template>
<div class="glass-card p-6 mb-6">
<!-- Desktop: Single Row Layout -->
<div class="hidden md:flex items-center gap-6">
<div class="flex items-start md:items-center gap-4 md:gap-6">
<img
:src="icon"
:alt="pkg.manifest.title"
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
class="app-detail-icon archy-app-icon w-20 h-20 shadow-xl flex-shrink-0"
@error="handleImageError"
/>
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
<p class="text-white/70 text-sm mb-2">{{ pkg.manifest.description.short }}</p>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
>
<span class="w-1.5 h-1.5 rounded-full mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Update available -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click="$emit('update')"
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update to v{{ pkg['available-update'] }}
</button>
<!-- Updating in progress -->
<span
v-if="pkg.state === 'updating'"
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center gap-2"
>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Updating...
</span>
<button
v-if="packageKey === 'lnd'"
@click="$emit('channels')"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-medium flex items-center gap-2"
>
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{{ t('appDetails.channels') }}
</button>
<button
v-if="canLaunch"
@click="$emit('launch')"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{{ t('common.launch') }}
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="$emit('start')"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
>
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button>
<button
@click="$emit('restart')"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.restart') }}
</button>
<button
v-if="pkg.state === 'running'"
@click="$emit('stop')"
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center gap-2"
>
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
{{ t('common.stop') }}
</button>
<button
@click="$emit('uninstall')"
class="px-4 py-2.5 glass-button glass-button-danger rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ t('common.uninstall') }}
</button>
</template>
</div>
</div>
<!-- Mobile: Two Column Grid Layout -->
<div class="md:hidden">
<div class="flex items-start gap-4 mb-4">
<img
:src="icon"
:alt="pkg.manifest.title"
class="app-detail-icon w-20 h-20 shadow-xl flex-shrink-0"
@error="handleImageError"
/>
<div class="flex-1 min-w-0">
<h1 class="text-xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
<p class="text-white/70 text-xs mb-2 line-clamp-2">{{ pkg.manifest.description.short }}</p>
<h1 class="text-xl md:text-2xl font-bold text-white mb-1">{{ pkg.manifest.title }}</h1>
<p class="text-white/70 text-xs md:text-sm mb-2 line-clamp-2 md:line-clamp-none">{{ pkg.manifest.description.short }}</p>
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
class="inline-flex items-center px-2 py-0.5 md:px-2.5 md:py-1 rounded-lg text-xs font-medium"
:class="getStatusClass(pkg.state, pkg.health, pkg['exit-code'])"
>
<span class="w-1.5 h-1.5 rounded-full mr-1" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
<span class="w-1.5 h-1.5 rounded-full mr-1 md:mr-1.5" :class="getStatusDotClass(pkg.state, pkg.health, pkg['exit-code'])"></span>
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
</span>
<span class="text-white/50 text-xs">v{{ pkg.manifest.version }}</span>
</div>
</div>
<button
v-if="!isWebOnly"
@click="$emit('uninstall')"
class="flex-shrink-0 w-10 h-10 rounded-lg glass-button glass-button-danger transition-colors flex items-center justify-center"
:title="t('common.uninstall')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<!-- Action Buttons (Auto Grid) -->
<div class="grid grid-cols-2 gap-2">
<!-- Update available (mobile) -->
<button
v-if="pkg['available-update'] && pkg.state !== 'updating'"
@click="$emit('update')"
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium hover:bg-orange-500/30 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Update to v{{ pkg['available-update'] }}
</button>
<!-- Updating in progress (mobile) -->
<div class="app-detail-actions-top items-center gap-2 flex-shrink-0">
<span
v-if="pkg.state === 'updating'"
class="col-span-2 px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium flex items-center justify-center gap-2"
class="px-4 py-2.5 bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Updating...
</span>
<button
v-if="canLaunch"
@click="$emit('launch')"
:class="isWebOnly ? 'col-span-2' : ''"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2"
v-for="action in actionItems"
:key="`top-${action.key}`"
type="button"
:disabled="controlsDisabled"
:class="['app-detail-action-btn px-4 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed', action.class]"
@click="emitAction(action.emit)"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
{{ t('common.launch') }}
{{ action.label }}
</button>
<template v-if="!isWebOnly">
<button
v-if="pkg.state === 'stopped' || pkg.state === 'exited'"
@click="$emit('start')"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2"
:class="pkg.state === 'exited' ? 'glass-button-danger' : 'glass-button-success'"
>
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
</svg>
{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}
</button>
<button
v-if="pkg.state === 'running'"
@click="$emit('stop')"
class="px-4 py-2.5 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-center gap-2"
>
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
{{ t('common.stop') }}
</button>
<button
@click="$emit('restart')"
:class="[canLaunch && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'running') ? 'col-span-2' : '']"
class="px-4 py-2.5 glass-button rounded-lg text-sm font-medium hover:bg-white/15 transition-colors flex items-center justify-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('common.restart') }}
</button>
</template>
</div>
</div>
<div class="app-detail-actions-bottom mt-5 grid grid-cols-2 gap-3">
<template v-if="pkg.state === 'updating'">
<span
class="col-span-2 mobile-card-action bg-orange-500/20 border border-orange-500/40 rounded-lg text-orange-200 text-sm font-medium"
>
Updating...
</span>
</template>
<button
v-for="action in actionItems"
:key="`bottom-${action.key}`"
type="button"
:disabled="controlsDisabled"
:class="[
'mobile-card-action rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed',
action.class,
actionItems.length === 1 || action.full ? 'col-span-2' : '',
]"
@click="emitAction(action.emit)"
>
{{ action.label }}
</button>
</div>
</div>
</template>
@ -229,6 +74,7 @@ import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import type { PackageDataEntry } from '@/types/api'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { DEFAULT_APP_ICON } from '@/views/apps/appsConfig'
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
const { t } = useI18n()
@ -239,11 +85,13 @@ const props = defineProps<{
packageKey: string
canLaunch: boolean
isWebOnly: boolean
pendingAction: 'start' | 'stop' | 'restart' | 'update' | 'uninstall' | null
}>()
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
const controlsDisabled = computed(() => props.pendingAction !== null || props.pkg.state === 'updating')
defineEmits<{
const emit = defineEmits<{
launch: []
start: []
stop: []
@ -253,10 +101,110 @@ defineEmits<{
channels: []
}>()
type ActionEmit = 'launch' | 'start' | 'stop' | 'restart' | 'uninstall' | 'update' | 'channels'
const actionItems = computed(() => {
const actions: Array<{ key: string; emit: ActionEmit; label: string; class: string; full?: boolean }> = []
if (props.pkg['available-update'] && props.pkg.state !== 'updating') {
actions.push({
key: 'update',
emit: 'update',
label: props.pendingAction === 'update' ? 'Updating...' : `Update to v${props.pkg['available-update']}`,
class: 'bg-orange-500/20 border border-orange-500/40 text-orange-200 hover:bg-orange-500/30',
full: true,
})
}
if (props.packageKey === 'lnd') {
actions.push({
key: 'channels',
emit: 'channels',
label: t('appDetails.channels'),
class: 'glass-button',
})
}
if (props.canLaunch) {
actions.push({
key: 'launch',
emit: 'launch',
label: t('common.launch'),
class: 'glass-button font-semibold',
})
}
if (!props.isWebOnly) {
if (props.pkg.state === 'stopped' || props.pkg.state === 'exited') {
actions.push({
key: 'start',
emit: 'start',
label: props.pendingAction === 'start' ? 'Starting...' : props.pkg.state === 'exited' ? 'Restart' : t('common.start'),
class: props.pkg.state === 'exited' ? 'glass-button glass-button-danger' : 'glass-button glass-button-success',
})
}
if (props.pkg.state === 'running') {
actions.push({
key: 'stop',
emit: 'stop',
label: props.pendingAction === 'stop' ? 'Stopping...' : t('common.stop'),
class: 'glass-button text-yellow-200 border-yellow-500/30 hover:bg-yellow-500/10',
})
}
actions.push({
key: 'restart',
emit: 'restart',
label: props.pendingAction === 'restart' ? 'Restarting...' : t('common.restart'),
class: 'glass-button',
})
actions.push({
key: 'uninstall',
emit: 'uninstall',
label: props.pendingAction === 'uninstall' ? 'Uninstalling...' : t('common.uninstall'),
class: 'glass-button text-red-300 border-red-500/30 hover:bg-red-500/10',
})
}
return actions
})
function emitAction(action: ActionEmit) {
emit(action)
}
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement
if (!target.src.includes('data:image') && !target.src.includes('logo-archipelago')) {
target.src = '/assets/img/logo-archipelago.svg'
if (!target.src.includes(DEFAULT_APP_ICON)) {
target.src = DEFAULT_APP_ICON
target.dataset.defaultIcon = '1'
}
}
</script>
<style scoped>
.app-detail-actions-top {
display: none;
}
.app-detail-actions-bottom {
display: grid;
}
.app-detail-action-btn {
min-height: 44px;
white-space: nowrap;
}
@media (min-width: 1280px) {
.app-detail-actions-top {
display: flex;
}
.app-detail-actions-bottom {
display: none;
}
}
</style>

View File

@ -86,10 +86,25 @@
</div>
</div>
<div v-if="credentials?.credentials?.length" class="glass-card p-6">
<!-- Setup Instructions -->
<div v-if="setupInstructions" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-3">{{ t('appDetails.setupInstructions') }}</h3>
<p class="text-sm text-white/70 leading-relaxed whitespace-pre-line">
{{ setupInstructions }}
</p>
</div>
<div v-if="credentialsLoading || credentials?.credentials?.length" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-2">Credentials</h3>
<p v-if="credentials.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
<div class="space-y-3">
<p v-if="credentials?.description" class="text-sm text-white/60 mb-4">{{ credentials.description }}</p>
<div v-if="credentialsLoading" class="space-y-3" aria-live="polite">
<div class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="h-3 w-28 rounded bg-white/10 mb-3"></div>
<div class="h-4 w-full rounded bg-white/10"></div>
</div>
<p class="text-xs text-white/50">Loading credentials...</p>
</div>
<div v-else class="space-y-3">
<div v-for="cred in credentials.credentials" :key="cred.label" class="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div class="flex items-center justify-between gap-3 mb-1">
<span class="text-white/60 text-xs uppercase tracking-wide">{{ cred.label }}</span>
@ -128,38 +143,44 @@
</div>
<!-- Links Card -->
<div class="glass-card p-6">
<div v-if="links.length > 0" class="glass-card p-6">
<h3 class="text-lg font-bold text-white mb-4">{{ t('appDetails.links') }}</h3>
<div class="space-y-2">
<a
v-if="pkg.manifest.website"
:href="pkg.manifest.website"
v-for="link in links"
:key="link.kind"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
v-if="link.kind === 'website'"
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
{{ t('appDetails.website') }}
</a>
<a
href="#"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
<svg
v-else-if="link.kind === 'source'"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
{{ t('appDetails.sourceCode') }}
</a>
<a
href="#"
class="flex items-center gap-2 text-blue-400 hover:text-blue-300 transition-colors"
<svg
v-else
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ t('appDetails.documentation') }}
{{ link.label }}
</a>
</div>
</div>
@ -167,7 +188,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AppCredentialsResponse } from '@/types/api'
@ -191,7 +212,7 @@ async function copyCredential(label: string, value: string) {
}, 1800)
}
defineProps<{
const props = defineProps<{
pkg: Record<string, any>
packageKey: string
isWebOnly: boolean
@ -201,5 +222,40 @@ defineProps<{
torUrl: string
showTorAddress: boolean
credentials: AppCredentialsResponse | null
credentialsLoading: boolean
}>()
type LinkKind = 'website' | 'source' | 'documentation'
interface SidebarLink {
kind: LinkKind
label: string
url: string
}
function normalizeHttpUrl(value: unknown): string {
const url = typeof value === 'string' ? value.trim() : ''
if (!url || url === '#') return ''
if (/^https?:\/\//i.test(url)) return url
return ''
}
const links = computed<SidebarLink[]>(() => {
const manifest = props.pkg.manifest || {}
const website = normalizeHttpUrl(manifest.website || manifest['marketing-site'])
const source = normalizeHttpUrl(manifest['upstream-repo'] || manifest['wrapper-repo'])
const documentation = normalizeHttpUrl(manifest['support-site'])
return [
website ? { kind: 'website' as const, label: t('appDetails.website'), url: website } : null,
source ? { kind: 'source' as const, label: t('appDetails.sourceCode'), url: source } : null,
documentation ? { kind: 'documentation' as const, label: t('appDetails.documentation'), url: documentation } : null,
].filter((link): link is SidebarLink => link !== null)
})
const setupInstructions = computed(() => {
const raw = props.pkg['static-files']?.instructions
const instructions = typeof raw === 'string' ? raw.trim() : ''
return instructions ? instructions : ''
})
</script>

View File

@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import AppContentSection from '../AppContentSection.vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
appDetails: {
about: 'About {name}',
features: 'Features',
screenshots: 'Screenshots',
},
},
},
})
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
return {
state: PackageState.Running,
health: 'healthy',
manifest: {
id: 'example',
title: 'Example',
version: '1.0.0',
description: {
short: 'Example app',
long: 'Example app details',
},
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
},
...overrides,
}
}
function mountContent(pkg: PackageDataEntry) {
return mount(AppContentSection, {
props: {
pkg,
features: [],
needsBitcoinSync: false,
bitcoinSynced: true,
bitcoinSyncPercent: 100,
bitcoinBlockHeight: 100,
},
global: {
plugins: [i18n],
},
})
}
describe('AppContentSection', () => {
it('does not show screenshot placeholders when no screenshots exist', () => {
const wrapper = mountContent(packageData())
expect(wrapper.text()).not.toContain('Screenshots')
expect(wrapper.findAll('img')).toHaveLength(0)
})
it('renders real screenshot metadata when provided', () => {
const wrapper = mountContent(packageData({
manifest: {
...packageData().manifest,
screenshots: [
'/assets/screenshots/example-dashboard.png',
{ src: '/assets/screenshots/example-settings.png', alt: 'Settings screen' },
' ',
],
},
}))
const images = wrapper.findAll('img')
expect(wrapper.text()).toContain('Screenshots')
expect(images).toHaveLength(2)
expect(images[0].attributes('src')).toBe('/assets/screenshots/example-dashboard.png')
expect(images[0].attributes('alt')).toBe('Example screenshot 1')
expect(images[1].attributes('src')).toBe('/assets/screenshots/example-settings.png')
expect(images[1].attributes('alt')).toBe('Settings screen')
})
})

View File

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import AppHeroSection from '../AppHeroSection.vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
common: {
launch: 'Launch',
restart: 'Restart',
start: 'Start',
stop: 'Stop',
uninstall: 'Uninstall',
},
appDetails: {
channels: 'Channels',
},
},
},
})
function packageData(overrides: Partial<PackageDataEntry> = {}): PackageDataEntry {
return {
state: PackageState.Running,
health: 'healthy',
manifest: {
id: 'example',
title: 'Example',
version: '1.0.0',
description: {
short: 'Example app',
long: 'Example app',
},
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
},
...overrides,
}
}
function mountHero(props: Partial<InstanceType<typeof AppHeroSection>['$props']> = {}) {
return mount(AppHeroSection, {
props: {
pkg: packageData(),
appId: 'example',
packageKey: 'example',
canLaunch: true,
isWebOnly: false,
pendingAction: null,
...props,
},
global: {
plugins: [i18n],
},
})
}
describe('AppHeroSection', () => {
it('disables app controls while a container action is running', () => {
const wrapper = mountHero({ pendingAction: 'restart' })
expect(wrapper.text()).toContain('Restarting...')
expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true)
})
it('labels update progress and disables update controls', () => {
const wrapper = mountHero({
pendingAction: 'update',
pkg: packageData({ 'available-update': '1.1.0' }),
})
expect(wrapper.text()).toContain('Updating...')
const updateButtons = wrapper.findAll('button').filter(button => button.text().includes('Updating...'))
expect(updateButtons.length).toBeGreaterThan(0)
expect(updateButtons.every(button => button.attributes('disabled') !== undefined)).toBe(true)
})
})

View File

@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import AppSidebar from '../AppSidebar.vue'
import { PackageState } from '@/types/api'
function mountSidebar(manifestOverrides: Record<string, unknown>, propOverrides: Record<string, unknown> = {}) {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
appDetails: {
access: 'Access',
documentation: 'Documentation',
gateway: 'Gateway',
information: 'Information',
lan: 'LAN',
links: 'Links',
ram: 'RAM',
ramDesc: 'Memory required',
requirements: 'Requirements',
setupInstructions: 'Setup Instructions',
requiresTor: 'Requires Tor',
services: 'Services',
sourceCode: 'Source Code',
storage: 'Storage',
storageDesc: 'Storage required',
tor: 'Tor',
website: 'Website',
},
common: {
category: 'Category',
developer: 'Developer',
license: 'License',
status: 'Status',
version: 'Version',
},
},
},
})
return mount(AppSidebar, {
props: {
pkg: {
state: PackageState.Running,
manifest: {
id: 'example',
title: 'Example',
version: '1.0.0',
license: '',
...manifestOverrides,
},
'static-files': { license: '', instructions: 'Follow step 1.\nThen step 2.', icon: '' },
},
packageKey: 'example',
isWebOnly: false,
gatewayState: 'stopped',
interfaceAddresses: null,
lanUrl: '',
torUrl: '',
showTorAddress: false,
credentials: null,
credentialsLoading: false,
...propOverrides,
},
global: {
plugins: [i18n],
},
})
}
describe('AppSidebar', () => {
it('renders manifest links with real URLs', () => {
const wrapper = mountSidebar({
website: 'https://example.com',
'upstream-repo': 'https://github.com/example/app',
'support-site': 'https://docs.example.com',
})
const hrefs = wrapper.findAll('a').map(anchor => anchor.attributes('href'))
expect(hrefs).toEqual([
'https://example.com',
'https://github.com/example/app',
'https://docs.example.com',
])
expect(wrapper.text()).toContain('Website')
expect(wrapper.text()).toContain('Source Code')
expect(wrapper.text()).toContain('Documentation')
})
it('does not render dead placeholder links', () => {
const wrapper = mountSidebar({
website: '#',
'upstream-repo': '',
'support-site': undefined,
})
expect(wrapper.findAll('a')).toHaveLength(0)
expect(wrapper.text()).not.toContain('Links')
})
it('shows a credentials loading state before credentials arrive', () => {
const wrapper = mountSidebar({}, { credentialsLoading: true })
expect(wrapper.text()).toContain('Credentials')
expect(wrapper.text()).toContain('Loading credentials...')
})
it('renders setup instructions when provided', () => {
const wrapper = mountSidebar({})
expect(wrapper.text()).toContain('Setup Instructions')
expect(wrapper.text()).toContain('Follow step 1.')
expect(wrapper.text()).toContain('Then step 2.')
})
it('hides setup instructions when empty', () => {
const wrapper = mountSidebar({}, {
pkg: {
state: PackageState.Running,
manifest: {
id: 'example',
title: 'Example',
version: '1.0.0',
license: '',
},
'static-files': { license: '', instructions: ' ', icon: '' },
},
})
expect(wrapper.text()).not.toContain('Setup Instructions')
})
})

View File

@ -37,9 +37,10 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable' }}</h3>
<h3 class="text-lg font-semibold text-white mb-2">{{ blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
<p class="text-white/50 text-sm mb-6">
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
</p>
<div class="flex flex-wrap items-center justify-center gap-3">
@ -88,6 +89,8 @@ const props = defineProps<{
mustOpenNewTab: boolean
autoRetryCount: number
refreshKey: number
blockedReason?: string
blockedTitle?: string
}>()
const emit = defineEmits<{

View File

@ -1,11 +1,17 @@
import { describe, expect, it } from 'vitest'
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig'
describe('appSessionConfig', () => {
it('keeps new-tab apps marked on every viewport', () => {
it('keeps manifest-owned new-tab apps marked on every viewport', () => {
expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true)
expect(NEW_TAB_APPS.has('grafana')).toBe(true)
expect(NEW_TAB_APPS.has('vaultwarden')).toBe(true)
expect(NEW_TAB_APPS.has('photoprism')).toBe(true)
expect(GENERATED_NEW_TAB_APPS.has('photoprism')).toBe(true)
})
it('keeps frontend-only new-tab overrides for apps without generated metadata', () => {
expect(NEW_TAB_APPS.has('tailscale')).toBe(true)
expect(GENERATED_NEW_TAB_APPS.has('tailscale')).toBe(false)
})
it('resolves direct app ports against the current browser host', () => {
@ -17,7 +23,17 @@ describe('appSessionConfig', () => {
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
expect(resolveAppUrl('saleor')).toBe('http://192.168.1.228:9011')
expect(resolveAppUrl('botfights')).toBe('http://192.168.1.228:9100')
})
it('uses manifest-generated launch ports for apps outside the manual override list', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('meshtastic')).toBe('http://192.168.1.228:4403')
})
it('keeps NetBird on the unified dashboard proxy port', () => {
@ -29,4 +45,14 @@ describe('appSessionConfig', () => {
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
})
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.168.1.228:18083')
})
})

View File

@ -1,11 +1,14 @@
/** Static configuration maps for app session routing and display */
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
export const DISPLAY_MODE_KEY = 'archipelago_app_display_mode'
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
/** Container apps: manifest-generated launch ports plus overrides for companions and aliases. */
export const APP_PORTS: Record<string, number> = {
...GENERATED_APP_PORTS,
'bitcoin-knots': 8334,
'bitcoin-core': 8334,
'bitcoin-ui': 8334,
@ -14,7 +17,6 @@ export const APP_PORTS: Record<string, number> = {
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'saleor': 9011,
'lnd': 18083,
'archy-lnd-ui': 18083,
'mempool': 4080,
@ -71,8 +73,9 @@ export const EXTERNAL_URLS: Record<string, string> = {
}
export const APP_TITLES: Record<string, string> = {
...GENERATED_APP_TITLES,
'bitcoin-knots': 'Bitcoin Knots', 'bitcoin-core': 'Bitcoin Core',
'btcpay-server': 'BTCPay Server', 'saleor': 'Saleor', 'indeedhub': 'Indeehub',
'btcpay-server': 'BTCPay Server', 'indeedhub': 'Indeehub',
'botfights': 'BotFights', 'gitea': 'Gitea', '484-kitchen': '484 Kitchen', 'arch-presentation': 'Presentation',
'homeassistant': 'Home Assistant', 'uptime-kuma': 'Uptime Kuma',
'nginx-proxy-manager': 'Nginx Proxy Manager',
@ -82,17 +85,8 @@ export const APP_TITLES: Record<string, string> = {
/** Apps that set X-Frame-Options and MUST open in a new tab (can't iframe) */
export const NEW_TAB_APPS = new Set([
'btcpay-server',
'grafana',
'photoprism',
'homeassistant',
'vaultwarden',
'nextcloud',
'uptime-kuma',
'portainer',
'onlyoffice',
...GENERATED_NEW_TAB_APPS,
'nginx-proxy-manager',
'gitea',
'tailscale',
])
@ -100,7 +94,7 @@ export const NEW_TAB_APPS = new Set([
export const IFRAME_BLOCKED_APPS = new Set<string>([])
/** Resolve app URL using direct port mapping (source of truth) */
export function resolveAppUrl(id: string, routeQueryPath?: string, _runtimeUrl?: string): string {
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
// External HTTPS apps
const ext = EXTERNAL_URLS[id]
if (ext) return ext
@ -109,9 +103,16 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, _runtimeUrl?:
// /app/bitcoin-ui/: the static UI is built for root and renders a blank
// shell when proxied under a path prefix on some nodes.
if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') {
if (import.meta.env.DEV) return '/app/bitcoin-ui/'
return 'http://' + window.location.hostname + ':8334'
}
if (runtimeUrl && id !== 'netbird') {
let base = runtimeUrl.replace(/localhost/i, window.location.hostname)
if (routeQueryPath) base += routeQueryPath
return base
}
// Local apps launch by host port.
const port = APP_PORTS[id]
if (!port) return ''

View File

@ -0,0 +1,17 @@
export const APP_STORE_CATEGORIES = [
{ id: 'all', name: 'All' },
{ id: 'community', name: 'Community' },
{ id: 'nostr', name: 'Nostr' },
{ id: 'commerce', name: 'Commerce' },
{ id: 'money', name: 'Money' },
{ id: 'data', name: 'Data' },
{ id: 'home', name: 'Home' },
{ id: 'networking', name: 'Networking' },
{ id: 'l484', name: 'L484' },
{ id: 'other', name: 'Other' },
] as const
export const APP_STORE_SECTIONS = [
{ id: 'discover', name: 'Discover' },
...APP_STORE_CATEGORIES,
] as const

View File

@ -31,7 +31,7 @@
<img
:src="icon"
:alt="title"
class="app-card-icon w-14 h-14 object-cover bg-white/10"
class="app-card-icon archy-app-icon w-14 h-14"
@error="handleImageError"
/>
<div class="flex-1 min-w-0 overflow-hidden">
@ -77,6 +77,9 @@
{{ getStatusLabel(pkg.state, pkg.health, pkg['exit-code']) }}
</span>
</div>
<p v-if="blockedReason" class="mt-2 text-xs leading-snug text-yellow-200/80">
{{ blockedReason }}
</p>
<!-- Quick Actions icon buttons in uniform dark containers -->
<!-- Installing progress replaces action buttons -->
@ -207,7 +210,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PackageDataEntry } from '@/types/api'
import {
isWebOnlyApp, opensInTab, canLaunch, resolveAppIcon,
isWebOnlyApp, opensInTab, canLaunch, launchBlockedReason, resolveAppIcon,
getStatusClass, getStatusLabel, handleImageError,
} from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
@ -284,6 +287,7 @@ const isTransitioning = computed(() => {
const h = props.pkg.health
return s === 'starting' || s === 'installing' || s === 'stopping' || s === 'restarting' || s === 'updating' || (s === 'running' && h === 'starting')
})
const blockedReason = computed(() => launchBlockedReason(props.id, props.pkg))
</script>
<style scoped>

View File

@ -18,15 +18,21 @@
role="button"
:tabindex="0"
:aria-label="getTitle(id, pkg)"
@pointerdown="startLongPress(id)"
@pointerup="clearLongPress"
@pointercancel="clearLongPress"
@pointerleave="clearLongPress"
@contextmenu.prevent="openAppOptions(id)"
@click="handleTap(id, pkg)"
@keydown.enter="handleTap(id, pkg)"
@keydown.space.prevent="openAppOptions(id)"
>
<!-- Icon with status indicator -->
<div class="app-icon-frame">
<img
:src="getIcon(id, pkg)"
:alt="getTitle(id, pkg)"
class="app-icon-img"
class="app-icon-img archy-app-icon"
@error="handleImageError"
/>
<!-- Status dot -->
@ -44,7 +50,7 @@
></span>
<!-- Installing overlay -->
<div
v-if="serverStore.isInstalling(id)"
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
class="app-icon-installing"
>
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
@ -55,6 +61,13 @@
</div>
<!-- Label -->
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
<span
v-if="serverStore.isInstalling(id) || serverStore.uninstallingApps.has(id)"
class="app-icon-progress-label"
:title="progressLabel(id, pkg)"
>
{{ progressLabel(id, pkg) }}
</span>
</div>
</div>
</div>
@ -72,7 +85,7 @@
</div>
<Transition name="fade">
<div v-if="credentialModal.show" class="fixed inset-0 z-[2700] flex items-end justify-center bg-black/60 backdrop-blur-md p-0 md:items-center md:p-6" @click.self="closeCredentialModal">
<div v-if="credentialModal.show" class="credential-modal-overlay fixed inset-0 z-[2700] flex items-center justify-center bg-black/60 backdrop-blur-md p-4 md:p-6" @click.self="closeCredentialModal">
<div class="sideload-modal credential-modal">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
@ -107,6 +120,7 @@ import { useAppLauncherStore } from '@/stores/appLauncher'
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
import { rpcClient } from '@/api/rpc-client'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { resolveAppCredentials } from './appCredentials'
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl, WEB_ONLY_APP_URLS } from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
@ -135,6 +149,8 @@ const emit = defineEmits<{
const scrollContainer = ref<HTMLElement | null>(null)
const activePage = ref(0)
const longPressTriggered = ref(false)
let longPressTimer: ReturnType<typeof setTimeout> | null = null
const pages = computed(() => {
const result: [string, PackageDataEntry][][] = []
@ -154,7 +170,22 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
}
function progressLabel(id: string, pkg: PackageDataEntry): string {
const install = serverStore.installingApps.get(id)
if (install) {
return `${install.message || 'Installing...'} ${Math.round(install.progress || 0)}%`
}
if (serverStore.uninstallingApps.has(id)) {
return pkg['uninstall-stage'] || ((pkg as unknown as Record<string, unknown>).uninstall_stage as string | undefined) || 'Removing...'
}
return ''
}
async function handleTap(id: string, pkg: PackageDataEntry) {
if (longPressTriggered.value) {
longPressTriggered.value = false
return
}
if (canLaunch(pkg)) {
const shown = await maybeShowCredentialsBeforeLaunch(id, pkg)
if (shown) return
@ -164,6 +195,26 @@ async function handleTap(id: string, pkg: PackageDataEntry) {
}
}
function startLongPress(id: string) {
clearLongPress()
longPressTriggered.value = false
longPressTimer = setTimeout(() => {
longPressTriggered.value = true
openAppOptions(id)
}, 550)
}
function clearLongPress() {
if (!longPressTimer) return
clearTimeout(longPressTimer)
longPressTimer = null
}
function openAppOptions(id: string) {
clearLongPress()
emit('goToApp', id)
}
function launchNow(id: string, pkg: PackageDataEntry) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
@ -191,18 +242,29 @@ function launchNow(id: string, pkg: PackageDataEntry) {
async function maybeShowCredentialsBeforeLaunch(id: string, pkg: PackageDataEntry): Promise<boolean> {
try {
const result = await rpcClient.call<AppCredentialsResponse>({ method: 'package.credentials', params: { app_id: id }, timeout: 5000 })
if (!result.credentials?.length) return false
const credentials = resolveAppCredentials(id, result)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: result.title || `${getTitle(id, pkg)} credentials`,
description: result.description || 'Use these credentials when the app asks you to sign in.',
credentials: result.credentials,
title: credentials.title || `${getTitle(id, pkg)} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
} catch {
return false
const credentials = resolveAppCredentials(id, null)
if (!credentials) return false
credentialModal.value = {
show: true,
appId: id,
title: credentials.title || `${getTitle(id, pkg)} credentials`,
description: credentials.description || 'Use these credentials when the app asks you to sign in.',
credentials: credentials.credentials,
copied: '',
}
return true
}
}
@ -270,6 +332,12 @@ function scrollToPage(index: number) {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.credential-modal {
max-height: calc(100dvh - var(--safe-area-top, env(safe-area-inset-top, 0px)) - var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) - 2rem);
border-radius: 1.25rem;
padding-bottom: 1.25rem;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.55);
}
.credential-modal-actions {
flex-shrink: 0;
}

View File

@ -26,6 +26,19 @@
<p class="text-white/70">
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
</p>
<div class="mt-4 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input
v-model="deleteAppData"
type="checkbox"
class="mt-1 h-4 w-4 rounded border-white/30 bg-black/30 text-red-500 focus:ring-red-500 focus:ring-offset-0"
/>
<span class="min-w-0">
<span class="block text-sm font-medium text-white">{{ t('apps.deleteAppDataLabel') }}</span>
<span class="block text-xs text-white/60 mt-1">{{ t('apps.deleteAppDataHelp') }}</span>
</span>
</label>
</div>
</div>
</div>
@ -37,7 +50,7 @@
{{ t('common.cancel') }}
</button>
<button
@click="$emit('confirm')"
@click="$emit('confirm', deleteAppData)"
:disabled="uninstalling"
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
@ -61,7 +74,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
@ -75,11 +88,12 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
confirm: []
confirm: [deleteAppData: boolean]
}>()
const modalRef = ref<HTMLElement | null>(null)
const restoreFocusRef = ref<HTMLElement | null>(null)
const deleteAppData = ref(false)
useModalKeyboard(
modalRef,
@ -87,4 +101,13 @@ useModalKeyboard(
() => emit('close'),
{ restoreFocusRef },
)
watch(
() => props.show,
(show) => {
if (show) {
deleteAppData.value = false
}
},
)
</script>

View File

@ -1,7 +1,7 @@
<template>
<div class="pb-16 md:pb-4">
<!-- Back Button -->
<button @click="router.push('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white transition-colors">
<button @click="router.replace('/dashboard/apps/lnd')" class="mb-6 flex items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
@ -38,7 +38,7 @@
<Transition name="content-fade" mode="out-in">
<!-- Loading -->
<div v-if="loading" key="loading" class="glass-card p-12 text-center">
<div v-if="loading && channels.length === 0" key="loading" class="glass-card p-12 text-center">
<svg class="animate-spin h-8 w-8 text-blue-400 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@ -47,7 +47,7 @@
</div>
<!-- Error -->
<div v-else-if="error" key="error" class="glass-card p-6 text-center">
<div v-else-if="error && channels.length === 0" key="error" class="glass-card p-6 text-center">
<p class="text-red-300 mb-4">{{ error }}</p>
<button @click="loadChannels" class="glass-button px-4 py-2 rounded-lg text-sm">Retry</button>
</div>
@ -63,6 +63,16 @@
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing channels...
</div>
<div v-else-if="error" class="p-3 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-sm">
{{ error }}
</div>
<div
v-for="ch in channels"
:key="ch.chan_id || ch.channel_point"
@ -119,7 +129,7 @@
</Transition>
<!-- Open Channel Modal -->
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="showOpenModal = false">
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
@ -165,7 +175,7 @@
</div>
<!-- Close Confirmation Modal -->
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="closeTarget = null">
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="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>
@ -232,6 +242,7 @@ function capacityPercent(amount: number, capacity: number): number {
}
async function loadChannels() {
const hadChannels = channels.value.length > 0
loading.value = true
error.value = null
try {
@ -246,6 +257,7 @@ async function loadChannels() {
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
@ -305,4 +317,6 @@ async function closeChannel() {
}
onMounted(loadChannels)
defineExpose({ channels, loadChannels })
</script>

View File

@ -1,12 +1,19 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
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 {
@ -31,8 +38,12 @@ function makePkg(id: string): PackageDataEntry {
}
describe('AppIconGrid', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
setActivePinia(createPinia())
vi.useRealTimers()
pinia = createPinia()
setActivePinia(pinia)
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
@ -51,14 +62,32 @@ describe('AppIconGrid', () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['lnd', makePkg('lnd')]] },
global: {
plugins: [createPinia()],
plugins: [pinia],
},
})
await wrapper.get('.app-icon-item').trigger('click')
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBe('lnd')
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],
},
})
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('routes desktop new-tab apps through app session on mobile', async () => {
@ -71,13 +100,78 @@ describe('AppIconGrid', () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['gitea', makePkg('gitea')]] },
global: {
plugins: [createPinia()],
plugins: [pinia],
},
})
await wrapper.get('.app-icon-item').trigger('click')
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBeNull()
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']])
})
})

View File

@ -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])
})
})

View File

@ -0,0 +1,70 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import LightningChannels from '../LightningChannels.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
useRouter: () => ({ 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,
})
const wrapper = mount(LightningChannels)
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')
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { filterEntriesForTab, isServiceContainer, isServicePackage, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
import { canLaunch, filterEntriesForTab, isServiceContainer, isServicePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps } from '../appsConfig'
function makePkg(id: string, title: string, category: string): PackageDataEntry {
return {
@ -81,4 +81,12 @@ describe('appsConfig service filtering', () => {
pkg['static-files']!.icon = 'git-branch'
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
})
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)
})
})

View File

@ -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()
})
})

View File

@ -0,0 +1,25 @@
import type { AppCredentialsResponse } from '@/types/api'
const FALLBACK_CREDENTIALS: Record<string, AppCredentialsResponse> = {
filebrowser: {
title: 'File Browser credentials',
description: 'Use these credentials when File Browser asks you to sign in.',
credentials: [
{ label: 'Username', value: 'admin' },
{ label: 'Password', value: 'admin', sensitive: true },
],
},
photoprism: {
title: 'PhotoPrism credentials',
description: 'Use these credentials when PhotoPrism asks you to sign in.',
credentials: [
{ label: 'Username', value: 'admin' },
{ label: 'Password', value: 'archipelago', sensitive: true },
],
},
}
export function resolveAppCredentials(appId: string, response?: AppCredentialsResponse | null): AppCredentialsResponse | null {
if (response?.credentials?.length) return response
return FALLBACK_CREDENTIALS[appId] ?? null
}

View File

@ -0,0 +1,38 @@
import { computed, ref, watch, type Ref } from 'vue'
import type { PackageDataEntry } from '@/types/api'
export type PackageMap = Record<string, PackageDataEntry>
export function useLastKnownPackages(
livePackages: Ref<PackageMap>,
containersScanned: Ref<boolean>,
) {
const lastKnownPackages = ref<PackageMap>({})
watch(
livePackages,
(packages) => {
const hasPackages = Object.keys(packages).length > 0
if (hasPackages || containersScanned.value) {
lastKnownPackages.value = { ...packages }
}
},
{ immediate: true, deep: true },
)
const isUsingLastKnownPackages = computed(() => (
!containersScanned.value &&
Object.keys(livePackages.value).length === 0 &&
Object.keys(lastKnownPackages.value).length > 0
))
const packages = computed<PackageMap>(() => (
isUsingLastKnownPackages.value ? lastKnownPackages.value : livePackages.value
))
return {
packages,
isUsingLastKnownPackages,
lastKnownPackages,
}
}

View File

@ -50,7 +50,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-knots': 'money', 'bitcoin-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'saleor': 'commerce',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
@ -165,6 +165,8 @@ const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg',
}
export const DEFAULT_APP_ICON = '/assets/icon/favico-black-v2.svg'
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
const rawIcon = (pkg["static-files"]?.icon || "").trim()
const icon = rawIcon === '/assets/img/favico.png' ? '' : rawIcon
@ -184,9 +186,23 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
}
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
}
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
const appId = pkg?.manifest?.id || id
if (
(appId === 'fedimint' || appId === 'fedimintd') &&
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
) {
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
}
return ''
}
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
const addr = runtimeLanAddress(pkg)
if (!addr || typeof window === 'undefined') return addr
@ -272,14 +288,8 @@ export function handleImageError(e: Event) {
return
}
const placeholderSvg = `data:image/svg+xml,${encodeURIComponent(`
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="rgba(255,255,255,0.1)"/>
<path d="M32 20L40 28H36V40H28V28H24L32 20Z" fill="rgba(255,255,255,0.6)"/>
<path d="M20 44H44V48H20V44Z" fill="rgba(255,255,255,0.4)"/>
</svg>
`)}`
if (!currentSrc.includes("data:image")) {
target.src = placeholderSvg
if (!currentSrc.includes(DEFAULT_APP_ICON)) {
target.src = DEFAULT_APP_ICON
target.dataset.defaultIcon = "1"
}
}

View File

@ -0,0 +1,85 @@
import type { PackageDataEntry } from '@/types/api'
const RESERVED_HOST_PORTS = new Set([
80, 443, 81,
8332, 8333, 8334,
9735, 10009, 8080,
18083,
4080, 8999, 50001,
23000,
8173, 8174, 8175,
8123,
3000,
11434,
9980, 9001,
8240,
9000,
3001, 3002,
8888,
8096, 2342, 2283,
8443,
])
export interface ParsedPortMapping {
host: number
container: number
}
export function parseSideloadPortMapping(value: string): ParsedPortMapping | null {
const trimmed = value.trim()
if (!trimmed) return null
const match = trimmed.match(/^(\d{1,5}):(\d{1,5})$/)
if (!match) {
throw new Error('Port mapping must use host:container format, for example 3009:80.')
}
const host = Number(match[1])
const container = Number(match[2])
if (!Number.isInteger(host) || host < 1 || host > 65535 || !Number.isInteger(container) || container < 1 || container > 65535) {
throw new Error('Ports must be between 1 and 65535.')
}
return { host, container }
}
export function packageUsesHostPort(pkg: PackageDataEntry, hostPort: number): boolean {
const addresses = pkg.installed?.['interface-addresses'] || {}
return Object.values(addresses).some((addr) => {
const lan = addr?.['lan-address']
if (!lan) return false
try {
const parsed = new URL(lan)
return Number(parsed.port || (parsed.protocol === 'https:' ? '443' : '80')) === hostPort
} catch {
const match = lan.match(/:(\d+)(?:\/|$)/)
return match ? Number(match[1]) === hostPort : false
}
})
}
export function validateSideloadRequest(
id: string,
portMapping: string,
packages: Record<string, PackageDataEntry>,
): string | null {
if (packages[id]) return `An app with ID "${id}" is already installed.`
let parsed: ParsedPortMapping | null = null
try {
parsed = parseSideloadPortMapping(portMapping)
} catch (err) {
return err instanceof Error ? err.message : 'Invalid port mapping.'
}
if (!parsed) return null
if (RESERVED_HOST_PORTS.has(parsed.host)) {
return `Host port ${parsed.host} is reserved by Archipelago or a packaged app. Choose another host port.`
}
const existing = Object.entries(packages).find(([, pkg]) => packageUsesHostPort(pkg, parsed.host))
if (existing) {
const title = existing[1].manifest?.title || existing[0]
return `Host port ${parsed.host} is already used by ${title}. Choose another host port.`
}
return null
}

View File

@ -70,11 +70,11 @@ export function useAppsActions() {
}
}
async function confirmUninstall(appId: string) {
async function confirmUninstall(appId: string, options: { preserveData?: boolean } = {}) {
uninstalling.value = true
try {
uninstallingApps.add(appId)
await store.uninstallPackage(appId)
await store.uninstallPackage(appId, options)
// Don't clear uninstallingApps here — let the WebSocket watcher clear it
// when the container actually disappears from backend data
} catch (err) {

View File

@ -0,0 +1,13 @@
export function normalizeCloudPath(path: unknown, fallback = '/'): string {
if (typeof path !== 'string' || !path.trim()) return fallback
const trimmed = path.trim()
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
return withSlash.replace(/\/+/g, '/')
}
export function parentCloudPath(path: string): string {
const normalized = normalizeCloudPath(path)
if (normalized === '/') return '/'
const parent = normalized.slice(0, normalized.lastIndexOf('/')) || '/'
return parent
}

View File

@ -41,11 +41,30 @@ import { useAppStore } from '@/stores/app'
const store = useAppStore()
const HEALTH_NOTIFICATION_MAX_AGE_MS = 30 * 60 * 1000
const GENERIC_NOTIFICATION_MAX_AGE_MS = 10 * 60 * 1000
const dismissedNotifications = ref<Set<string>>(new Set())
const healthNotifications = computed(() => {
const notifs = store.data?.notifications ?? []
const visible = notifs.filter(n => !dismissedNotifications.value.has(n.id))
const packages = store.data?.['package-data'] ?? {}
const visible = notifs.filter((n) => {
if (dismissedNotifications.value.has(n.id)) return false
const appId = n.app_id || appIdFromNotificationTitle(n.title)
if (appId) {
if (isOlderThan(n.timestamp, HEALTH_NOTIFICATION_MAX_AGE_MS)) return false
const pkg = packages[appId]
if (!pkg) return false
if (pkg.health !== 'unhealthy') return false
if (pkg.state === 'removing' || pkg.state === 'stopped' || pkg.state === 'exited') return false
} else if (isOlderThan(n.timestamp, GENERIC_NOTIFICATION_MAX_AGE_MS)) {
return false
}
return true
})
// Deduplicate: keep only the latest notification per container/title
const seen = new Map<string, typeof visible[0]>()
for (const n of visible) {
@ -64,4 +83,14 @@ function dismissNotification(id: string) {
}
dismissedNotifications.value.add(id)
}
function appIdFromNotificationTitle(title: string): string | undefined {
const suffix = ' is unhealthy'
return title.endsWith(suffix) ? title.slice(0, -suffix.length) : undefined
}
function isOlderThan(timestamp: string, maxAgeMs: number): boolean {
const ts = Date.parse(timestamp)
return Number.isFinite(ts) && Date.now() - ts > maxAgeMs
}
</script>

View File

@ -0,0 +1,137 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/stores/app'
import { PackageState, type DataModel, type PackageDataEntry } from '@/types/api'
import HealthNotifications from '../HealthNotifications.vue'
function makePkg(id: string, state: PackageState = PackageState.Running, health: string | null = 'healthy'): PackageDataEntry {
return {
state,
health,
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'],
}
}
function makeData(pkg?: PackageDataEntry, timestamp = new Date().toISOString()): DataModel {
return {
'server-info': {
id: 'node',
version: '1.0.0',
name: null,
pubkey: '',
'status-info': {
restarting: false,
'shutting-down': false,
updated: false,
'backup-progress': null,
'update-progress': null,
},
'lan-address': null,
'tor-address': null,
unread: 0,
'wifi-ssids': [],
'zram-enabled': false,
'seed-backed': false,
},
'package-data': pkg ? { indeedhub: pkg } : {},
notifications: [{
id: 'health-1',
level: 'error',
title: 'indeedhub is unhealthy',
message: 'indeedhub health check failed',
timestamp,
app_id: 'indeedhub',
}],
ui: {
name: null,
'ack-welcome': '',
marketplace: {
'selected-hosts': [],
'known-hosts': {},
},
theme: 'dark',
},
}
}
describe('HealthNotifications', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
vi.useRealTimers()
})
it('shows active unhealthy package notifications', () => {
const store = useAppStore(pinia)
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'unhealthy'))
const wrapper = mount(HealthNotifications, {
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).toContain('indeedhub is unhealthy')
})
it('hides stale package notifications once health recovers', () => {
const store = useAppStore(pinia)
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'healthy'))
const wrapper = mount(HealthNotifications, {
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
})
it('hides package notifications while an app is being removed', () => {
const store = useAppStore(pinia)
store.data = makeData(makePkg('indeedhub', PackageState.Removing, 'unhealthy'))
const wrapper = mount(HealthNotifications, {
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
})
it('hides old package health notifications on reload even if the app is still unhealthy', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
const store = useAppStore(pinia)
store.data = makeData(
makePkg('indeedhub', PackageState.Running, 'unhealthy'),
new Date('2026-06-10T11:20:00Z').toISOString(),
)
const wrapper = mount(HealthNotifications, {
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
})
})

View File

@ -2,6 +2,26 @@
<div>
<!-- Apps Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pb-8">
<template v-if="isLoading && filteredApps.length === 0">
<div
v-for="index in 6"
:key="`loading-${index}`"
class="glass-card p-5 flex flex-col app-card-skeleton"
aria-hidden="true"
>
<div class="flex items-start gap-4 mb-3">
<div class="app-card-skeleton-icon"></div>
<div class="flex-1 min-w-0 pt-1">
<div class="app-card-skeleton-line w-3/4 mb-3"></div>
<div class="app-card-skeleton-line w-1/3"></div>
</div>
</div>
<div class="app-card-skeleton-line w-full mb-2"></div>
<div class="app-card-skeleton-line w-5/6 mb-2"></div>
<div class="app-card-skeleton-line w-2/3 mb-5"></div>
<div class="app-card-skeleton-button mt-auto"></div>
</div>
</template>
<div
v-for="(app, index) in filteredApps"
:key="app.id"
@ -124,15 +144,8 @@
</div>
<!-- Empty State -->
<div v-if="filteredApps.length === 0" class="text-center py-12">
<div v-if="isLoading" class="flex flex-col items-center gap-4">
<svg class="animate-spin h-12 w-12 text-blue-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/70">{{ loadingMessage }}</p>
</div>
<div v-else-if="nostrError && isNostrCategory" class="flex flex-col items-center gap-4">
<div v-if="filteredApps.length === 0 && !isLoading" class="text-center py-12">
<div v-if="nostrError && isNostrCategory" class="flex flex-col items-center gap-4">
<p class="text-white/70">No community apps found</p>
<p class="text-white/40 text-sm">{{ nostrError }}</p>
<button @click="$emit('retry-nostr')" class="px-4 py-2 glass-button rounded-lg text-sm">Retry</button>
@ -196,4 +209,44 @@ function handleImageError(event: Event) {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.app-card-skeleton {
min-height: 245px;
pointer-events: none;
}
.app-card-skeleton-icon,
.app-card-skeleton-line,
.app-card-skeleton-button {
position: relative;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
}
.app-card-skeleton-icon::after,
.app-card-skeleton-line::after,
.app-card-skeleton-button::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.12), transparent);
animation: shimmer 1.8s ease-in-out infinite;
}
.app-card-skeleton-icon {
width: 3.5rem;
height: 3.5rem;
border-radius: 0.5rem;
flex: 0 0 auto;
}
.app-card-skeleton-line {
height: 0.75rem;
border-radius: 999px;
}
.app-card-skeleton-button {
height: 2.25rem;
border-radius: 0.5rem;
}
</style>

View File

@ -16,7 +16,7 @@
<Transition name="modal">
<div
v-if="showFilter"
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/60 backdrop-blur-md"
@click.self="closeFilter"
>
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">

View File

@ -79,7 +79,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'saleor', title: 'Saleor', version: '3.23', category: 'commerce', description: 'Composable commerce platform with customer storefront, GraphQL API, dashboard, worker, mail testing, and tracing. Storefront opens on port 9011; admin dashboard remains on 9010.', icon: '/assets/img/app-icons/saleor.svg', author: 'Saleor', dockerImage: 'ghcr.io/saleor/saleor:3.23', repoUrl: 'https://github.com/saleor/saleor' },
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.svg', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
@ -87,7 +86,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/img/app-icons/cryptpad.webp', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
{ id: 'nextcloud', title: 'Nextcloud', version: '28', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:28`, repoUrl: 'https://github.com/nextcloud/server' },
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
{ id: 'photoprism', title: 'PhotoPrism', version: '240915', description: 'AI-powered photo management. Organize photos with facial recognition, privately.', icon: '/assets/img/app-icons/photoprism.svg', author: 'PhotoPrism', dockerImage: `${R}/photoprism:240915`, repoUrl: 'https://github.com/photoprism/photoprism' },
@ -121,7 +120,6 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'],
saleor: ['saleor'],
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@ -191,7 +189,7 @@ export function categorizeCommunityApp(app: MarketplaceApp): string {
const combined = `${id} ${title} ${description}`
if (id.includes('bitcoin') || id.includes('btc') || id.includes('lightning') || id.includes('lnd') || id.includes('electr') || id.includes('fedimint') || id.includes('cashu') || combined.includes('wallet')) return 'money'
if (id.includes('btcpay') || id.includes('saleor') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
if (id.includes('btcpay') || id.includes('commerce') || id.includes('shop') || id.includes('pos') || combined.includes('merchant')) return 'commerce'
if (id.includes('cloud') || id.includes('nextcloud') || id.includes('storage') || id.includes('file') || id.includes('photo') || id.includes('immich') || id.includes('jellyfin') || id.includes('media') || id.includes('vault') || combined.includes('password manager')) return 'data'
if (id.includes('home-assistant') || id.includes('homeassistant') || combined.includes('home automation')) return 'home'
if (id.includes('nostr') || combined.includes('nostr relay')) return 'nostr'

View File

@ -67,6 +67,13 @@
</div>
<div v-else class="space-y-2">
<div v-if="loading && nodes.length > 0" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Searching relays...
</div>
<div
v-for="node in nodes"
:key="node.nostr_pubkey"
@ -189,4 +196,6 @@ function shortNpub(npub: string): string {
if (!npub || npub.length < 16) return npub
return `${npub.slice(0, 14)}${npub.slice(-8)}`
}
defineExpose({ refresh })
</script>

View File

@ -1,5 +1,5 @@
<template>
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="handleClose">
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="handleClose">
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Node Details</h2>

View File

@ -28,7 +28,7 @@
<div class="glass-card p-6 max-h-[60vh] flex flex-col">
<h2 class="text-lg font-semibold text-white mb-4">Your Nodes <span v-if="trustedNodes.length > 0" class="text-sm font-normal text-white/50">({{ trustedNodes.length }})</span></h2>
<div v-if="loading" class="flex items-center gap-3 py-8 justify-center">
<div v-if="loading && nodes.length === 0" class="flex items-center gap-3 py-8 justify-center">
<div class="w-5 h-5 border-2 border-white/20 border-t-orange-400 rounded-full animate-spin"></div>
<span class="text-white/60 text-sm">Loading nodes...</span>
</div>

View File

@ -0,0 +1,69 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import DiscoverModal from '../DiscoverModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
handshakeDiscover: vi.fn(),
handshakeConnect: 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 makeNode() {
return {
nostr_pubkey: 'pubkey-one',
nostr_npub: 'npub1abcdefghijklmnopqrstuvwxyz',
did: 'did:key:node',
version: '1.8-alpha',
}
}
describe('DiscoverModal', () => {
it('keeps discoverable nodes visible while relay refresh is pending or fails', async () => {
vi.mocked(rpcClient.handshakeDiscover).mockResolvedValueOnce({ nodes: [makeNode()] })
const wrapper = mount(DiscoverModal, {
props: { visible: false, outboundSent: [] },
global: {
stubs: {
Teleport: true,
},
},
})
await wrapper.setProps({ visible: true })
await flushPromises()
expect(wrapper.text()).toContain('did:key:node')
expect(wrapper.text()).toContain('version 1.8-alpha')
const pending = deferred<{ nodes: [] }>()
vi.mocked(rpcClient.handshakeDiscover).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { refresh: () => Promise<void> }).refresh()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('did:key:node')
expect(wrapper.text()).toContain('Searching relays...')
expect(wrapper.text()).not.toContain('No discoverable nodes found')
pending.reject(new Error('relay offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('did:key:node')
expect(wrapper.text()).toContain('relay offline')
expect(wrapper.text()).not.toContain('Searching relays...')
expect(wrapper.text()).not.toContain('No discoverable nodes found')
})
})

View File

@ -0,0 +1,32 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import NodeList from '../NodeList.vue'
import type { FederatedNode } from '../types'
const trustedNode: FederatedNode = {
did: 'did:key:z6MkTrustedNode',
pubkey: 'trusted-pubkey',
onion: 'trusted.onion',
trust_level: 'trusted',
added_at: '2026-06-10T00:00:00Z',
name: 'Trusted Node',
last_seen: '2026-06-10T00:00:00Z',
}
describe('NodeList', () => {
it('keeps existing nodes visible during background refresh loading', () => {
const wrapper = mount(NodeList, {
props: {
nodes: [trustedNode],
loading: true,
error: '',
syncResults: [],
dwnSyncDotClass: 'bg-green-400',
cleaningNodes: false,
},
})
expect(wrapper.text()).toContain('Trusted Node')
expect(wrapper.text()).not.toContain('Loading nodes...')
})
})

View File

@ -28,9 +28,12 @@
</div>
<!-- History Charts -->
<div v-if="historyLoading" class="text-white/40 text-sm py-4 text-center mb-4">
<div v-if="historyLoading && !historyLabels.length" class="text-white/40 text-sm py-4 text-center mb-4">
Loading history...
</div>
<div v-else-if="historyLoading" class="text-white/40 text-xs text-center mb-4">
Refreshing history...
</div>
<div v-else-if="historyLabels.length" class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div class="glass-card p-4">
<h4 class="text-xs font-medium text-white/60 mb-2">CPU History</h4>

View File

@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import { isOnline, normalizeFleetNode, normalizeNodeHistoryResponse, sortFleetNodes, type FleetNode } from '../useFleetData'
function node(id: string, reportedAt: string): FleetNode {
return {
node_id: id,
version: '1.8-alpha',
uptime_secs: 60,
cpu_cores: 4,
cpu_pct: 10,
mem_pct: 20,
disk_pct: 30,
container_count: 2,
running_count: 2,
federation_peers: 1,
recent_alerts: [],
containers: [],
reported_at: reportedAt,
}
}
describe('fleet data helpers', () => {
it('treats nodes reported within 30 minutes as online', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
expect(isOnline('2026-06-10T11:45:00Z')).toBe(true)
expect(isOnline('2026-06-10T11:20:00Z')).toBe(false)
vi.useRealTimers()
})
it('sorts status with online nodes first, then latest report', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
const nodes = [
node('offline', '2026-06-10T10:00:00Z'),
node('online-old', '2026-06-10T11:45:00Z'),
node('online-new', '2026-06-10T11:59:00Z'),
]
expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
'online-new',
'online-old',
'offline',
])
vi.useRealTimers()
})
it('sorts by name alphabetically', () => {
expect(sortFleetNodes([
node('zulu', '2026-06-10T11:59:00Z'),
node('alpha', '2026-06-10T11:59:00Z'),
], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
})
it('normalizes older telemetry reports with missing metric and container fields', () => {
const normalized = normalizeFleetNode({
node_id: 'legacy-node',
version: '1.8-alpha',
reported_at: '2026-06-10T11:59:00Z',
})
expect(normalized.node_id).toBe('legacy-node')
expect(normalized.cpu_pct).toBe(0)
expect(normalized.mem_pct).toBe(0)
expect(normalized.disk_pct).toBe(0)
expect(normalized.containers).toEqual([])
expect(normalized.recent_alerts).toEqual([])
})
it('normalizes node history responses from backend entries or legacy history fields', () => {
const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }
expect(normalizeNodeHistoryResponse({ entries: [entry] })).toEqual([entry])
expect(normalizeNodeHistoryResponse({ history: [entry] })).toEqual([entry])
expect(normalizeNodeHistoryResponse({})).toEqual([])
})
})

View File

@ -118,6 +118,58 @@ export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
{ label: 'Name', value: 'name' },
]
export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNode[] {
const sorted = [...nodes]
switch (sortBy) {
case 'status':
sorted.sort((a, b) => {
const aOnline = isOnline(a.reported_at)
const bOnline = isOnline(b.reported_at)
if (aOnline !== bOnline) return aOnline ? -1 : 1
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
})
break
case 'last-seen':
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
break
case 'name':
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
break
}
return sorted
}
function numberOrZero(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
return {
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
version: typeof node.version === 'string' ? node.version : 'unknown',
uptime_secs: numberOrZero(node.uptime_secs),
cpu_cores: numberOrZero(node.cpu_cores),
cpu_pct: numberOrZero(node.cpu_pct),
mem_pct: numberOrZero(node.mem_pct),
disk_pct: numberOrZero(node.disk_pct),
container_count: numberOrZero(node.container_count),
running_count: numberOrZero(node.running_count),
federation_peers: numberOrZero(node.federation_peers),
recent_alerts: Array.isArray(node.recent_alerts) ? node.recent_alerts : [],
containers: Array.isArray(node.containers) ? node.containers : [],
reported_at: typeof node.reported_at === 'string' ? node.reported_at : new Date(0).toISOString(),
}
}
export function normalizeNodeHistoryResponse(data: {
history?: NodeHistoryEntry[]
entries?: NodeHistoryEntry[]
} | null | undefined): NodeHistoryEntry[] {
if (Array.isArray(data?.history)) return data.history
if (Array.isArray(data?.entries)) return data.entries
return []
}
// --- Composable ---
export function useFleetData() {
@ -125,6 +177,7 @@ export function useFleetData() {
const errorMessage = ref('')
const nodes = ref<FleetNode[]>([])
const fleetAlerts = ref<FleetAlert[]>([])
const refreshing = ref(false)
const alertsLoading = ref(false)
const selectedNodeId = ref<string | null>(null)
const nodeHistory = ref<NodeHistoryEntry[]>([])
@ -166,26 +219,7 @@ export function useFleetData() {
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
})
const sortedNodes = computed(() => {
const sorted = [...nodes.value]
switch (sortBy.value) {
case 'status':
sorted.sort((a, b) => {
const aOnline = isOnline(a.reported_at)
const bOnline = isOnline(b.reported_at)
if (aOnline !== bOnline) return aOnline ? 1 : -1
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
})
break
case 'last-seen':
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
break
case 'name':
sorted.sort((a, b) => a.node_id.localeCompare(b.node_id))
break
}
return sorted
})
const sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value))
const allAppIds = computed(() => {
const appSet = new Set<string>()
@ -227,11 +261,11 @@ export function useFleetData() {
async function fetchFleetStatus() {
try {
const data = await rpcClient.call<{ nodes: FleetNode[] }>({
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
method: 'telemetry.fleet-status',
})
if (data?.nodes) {
nodes.value = data.nodes
nodes.value = data.nodes.map(normalizeFleetNode)
lastRefreshed.value = new Date().toISOString()
}
} catch (err) {
@ -259,15 +293,12 @@ export function useFleetData() {
async function fetchNodeHistory(nodeId: string) {
nodeHistoryLoading.value = true
nodeHistory.value = []
try {
const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({
const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[] }>({
method: 'telemetry.fleet-node-history',
params: { node_id: nodeId },
})
if (data?.history) {
nodeHistory.value = data.history
}
nodeHistory.value = normalizeNodeHistoryResponse(data)
} catch {
// Non-critical
} finally {
@ -277,9 +308,14 @@ export function useFleetData() {
async function refreshAll() {
loading.value = !nodes.value.length
refreshing.value = true
errorMessage.value = ''
try {
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
} finally {
loading.value = false
refreshing.value = false
}
}
function selectNode(nodeId: string) {
@ -288,7 +324,6 @@ export function useFleetData() {
nodeHistory.value = []
} else {
selectedNodeId.value = nodeId
fetchNodeHistory(nodeId)
}
}
@ -367,7 +402,7 @@ export function useFleetData() {
})
return {
loading, errorMessage, nodes, fleetAlerts, alertsLoading,
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
autoRefresh, lastRefreshed, sortBy, chartWidth,
onlineCount, offlineCount, healthyCount, fleetHealthPct,

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { GOALS } from '@/data/goals'
import { goalStepTargetPath } from '../goalStepActions'
import type { GoalStep } from '@/types/goals'
describe('goalStepActions', () => {
it('routes app-backed steps to their app details page', () => {
expect(goalStepTargetPath(step({ id: 'configure-filebrowser', appId: 'filebrowser' }))).toBe('/dashboard/apps/filebrowser')
})
it('routes built-in identity and backup steps to their owning screens', () => {
expect(goalStepTargetPath(step({ id: 'setup-nostr' }))).toBe('/dashboard/web5')
expect(goalStepTargetPath(step({ id: 'export-identity' }))).toBe('/dashboard/web5/credentials')
expect(goalStepTargetPath(step({ id: 'create-passphrase' }))).toBe('/dashboard/settings')
})
it('keeps passive info steps without a target route', () => {
expect(goalStepTargetPath(step({ id: 'sync-setup' }))).toBeNull()
})
it('gives every configure step in the shipped goals a destination', () => {
const configureSteps = GOALS.flatMap((goal) => goal.steps.filter((candidate) => candidate.action === 'configure'))
expect(configureSteps.map((candidate) => [candidate.id, goalStepTargetPath(candidate)])).toEqual(
configureSteps.map((candidate) => [candidate.id, expect.any(String)]),
)
})
})
function step(overrides: Partial<GoalStep>): GoalStep {
return {
id: 'step',
title: 'Step',
description: '',
action: 'configure',
isAutomatic: false,
...overrides,
}
}

View File

@ -0,0 +1,14 @@
import type { GoalStep } from '@/types/goals'
const STEP_ROUTE_OVERRIDES: Record<string, string> = {
'setup-nostr': '/dashboard/web5',
'export-identity': '/dashboard/web5/credentials',
'create-passphrase': '/dashboard/settings',
'create-backup': '/dashboard/settings',
'save-backup': '/dashboard/settings',
}
export function goalStepTargetPath(step: GoalStep): string | null {
if (step.appId) return `/dashboard/apps/${step.appId}`
return STEP_ROUTE_OVERRIDES[step.id] ?? null
}

Some files were not shown because too many files have changed in this diff Show More