chore(release): stage v1.7.52-alpha

This commit is contained in:
archipelago
2026-05-05 11:29:18 -04:00
parent 10fbb8f87c
commit 745cb1c626
86 changed files with 4084 additions and 966 deletions

View File

@@ -58,6 +58,11 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button class="app-session-bar-btn" aria-label="Refresh" @click="refresh">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" :class="{ 'animate-spin': isRefreshing }">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.64 15.36A8 8 0 0018.36 18M18.36 8.64A8 8 0 005.64 6" />
</svg>
</button>
<button class="app-session-bar-btn" aria-label="Open in new tab" @click="openNewTab">
<svg 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" />
@@ -139,9 +144,7 @@ const appId = computed(() => {
const appTitle = computed(() => resolveAppTitle(appId.value))
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
// On mobile (Android WebView), all apps load in the iframe — X-Frame-Options
// doesn't apply since the WebView is the top-level browsing context.
const mustOpenNewTab = computed(() => isMobile ? false : NEW_TAB_APPS.has(appId.value))
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
const appUrl = computed(() => {
return resolveAppUrl(appId.value, route.query.path as string | undefined)
@@ -501,6 +504,17 @@ onBeforeUnmount(() => {
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
@media (max-width: 767px) {
.app-session-root {
height: 100%;
}
.app-session-inline {
height: 100%;
}
.app-session-overlay,
.app-session-fullscreen {
height: 100vh;
height: 100dvh;
}
.app-session-panel.glass-card {
border: none !important;
border-radius: 0 !important;
@@ -511,14 +525,11 @@ onBeforeUnmount(() => {
backdrop-filter: none;
background: black;
}
/* Iframe frame: push content below status bar on mobile */
.app-session-frame-safe {
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
}
/* Iframe within padded container: fill remaining space */
.app-session-frame-safe iframe {
top: var(--safe-area-top, env(safe-area-inset-top, 0px));
height: calc(100% - var(--safe-area-top, env(safe-area-inset-top, 0px)));
flex: none !important;
height: calc(100vh - var(--app-session-mobile-bar-height, 84px));
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px));
padding-bottom: 0;
}
}
@@ -529,24 +540,38 @@ onBeforeUnmount(() => {
}
.app-session-mobile-bar {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2600;
justify-content: space-around;
align-items: center;
flex-shrink: 0;
padding: 12px 16px;
padding-bottom: calc(12px + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)));
min-height: var(--app-session-mobile-bar-height, 84px);
padding: 10px 16px;
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
transform: translateZ(0);
}
.app-session-inline .app-session-mobile-bar {
position: absolute;
z-index: 20;
}
.app-session-bar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
width: 52px;
height: 52px;
min-height: 52px;
border-radius: 13px;
color: rgba(255, 255, 255, 0.65);
transition: color 0.15s ease, background 0.15s ease;
}

View File

@@ -201,6 +201,8 @@ const appLauncher = useAppLauncherStore()
const selectedCategory = ref('all')
const searchQuery = ref('')
const bitcoinPruned = ref(false)
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const categories = computed(() => [
{ id: 'all', name: 'All' },
@@ -392,6 +394,11 @@ function launchInstalledApp(app: MarketplaceApp) {
}
function handleInstall(app: MarketplaceApp) {
const blocked = installBlockedReason(app.id)
if (blocked) {
toast.error(blocked)
return
}
if (app.source === 'local') {
installApp(app)
} else {
@@ -432,6 +439,23 @@ onBeforeUnmount(() => {
const toast = useToast()
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
if (!res.ok) return
const status = await res.json()
bitcoinPruned.value = status?.blockchain_info?.pruned === true
} catch (e) {
if (import.meta.env.DEV) console.warn('[Discover] Bitcoin prune status unavailable:', e)
}
}
function installBlockedReason(appId: string): string | undefined {
if (!bitcoinPruned.value) return undefined
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
return electrumxArchiveWarning
}
function queueInstall(app: MarketplaceApp) {
serverStore.setInstallProgress(app.id, {
id: app.id,
@@ -492,6 +516,7 @@ onMounted(() => {
if (communityApps.value.length === 0 && !loadingCommunity.value) {
loadCommunityMarketplace()
}
loadBitcoinPruneStatus()
})
const catalogFeatured = ref<CatalogFeatured | null>(null)
@@ -512,4 +537,3 @@ async function loadCommunityMarketplace() {
loadingCommunity.value = false
}
</script>

View File

@@ -70,6 +70,7 @@
:starting-up="isStartingUp(app.id)"
:containers-scanned="containersScanned"
:tier-label="getAppTier(app.id)"
:install-blocked-reason="installBlockedReason(app.id)"
@view="viewAppDetails"
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
@launch="launchInstalledApp"
@@ -157,6 +158,7 @@ const categories = computed(() => [
// Installation state — uses global store so it persists across navigation
const installingApps = server.installingApps
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
// Install progress tracking is now in serverStore (global watcher on WebSocket data)
// so it works regardless of which page is active
@@ -174,6 +176,7 @@ const loadingCommunity = ref(false)
const communityError = ref('')
const communityApps = ref<MarketplaceApp[]>([])
const searchQuery = ref('')
const bitcoinPruned = ref(false)
// Nostr community marketplace state
const nostrApps = ref<MarketplaceApp[]>([])
@@ -309,8 +312,26 @@ onMounted(() => {
if (communityApps.value.length === 0 && !loadingCommunity.value) {
loadCommunityMarketplace()
}
loadBitcoinPruneStatus()
})
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
if (!res.ok) return
const status = await res.json()
bitcoinPruned.value = status?.blockchain_info?.pruned === true
} catch (e) {
if (import.meta.env.DEV) console.warn('[Marketplace] Bitcoin prune status unavailable:', e)
}
}
function installBlockedReason(appId: string): string | undefined {
if (!bitcoinPruned.value) return undefined
if (appId !== 'electrumx' && appId !== 'electrs' && appId !== 'mempool-electrs') return undefined
return electrumxArchiveWarning
}
async function loadCommunityMarketplace() {
loadingCommunity.value = true
communityError.value = ''
@@ -379,6 +400,11 @@ function failInstall(app: MarketplaceApp, err: unknown) {
async function installApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
const blocked = installBlockedReason(app.id)
if (blocked) {
toast.error(blocked)
return
}
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
@@ -399,6 +425,11 @@ async function installApp(app: MarketplaceApp) {
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
const blocked = installBlockedReason(app.id)
if (blocked) {
toast.error(blocked)
return
}
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")

View File

@@ -84,7 +84,8 @@
<button
v-else
@click="installApp"
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="installBlockedReason || undefined"
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -94,7 +95,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ installing ? t('common.installing') : t('common.install') }}
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
</div>
@@ -149,7 +150,8 @@
<button
v-else
@click="installApp"
:disabled="installing || (!app.manifestUrl && !app.dockerImage)"
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:title="installBlockedReason || undefined"
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
>
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -159,7 +161,7 @@
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{{ installing ? t('common.installing') : t('common.install') }}
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
</button>
</div>
@@ -189,6 +191,10 @@
</div>
</div>
</div>
<div v-if="installBlockedReason" class="hidden md:block mt-4 p-4 bg-yellow-500/15 border border-yellow-500/30 rounded-lg">
<p class="text-yellow-100 font-medium">Bitcoin is in pruned mode</p>
<p class="text-yellow-200/80 text-sm mt-1">{{ installBlockedReason }}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -375,9 +381,11 @@ import { rpcClient } from '../api/rpc-client'
import { useMarketplaceApp, type MarketplaceAppInfo } from '../composables/useMarketplaceApp'
import { useMobileBackButton } from '../composables/useMobileBackButton'
import { useAppLauncherStore } from '../stores/appLauncher'
import { useToast } from '../composables/useToast'
const { t } = useI18n()
const { bottomPosition } = useMobileBackButton()
const toast = useToast()
const router = useRouter()
const route = useRoute()
@@ -389,6 +397,8 @@ const installing = ref(false)
const installingDeps = ref(false)
const installError = ref<string | null>(null)
const loading = ref(true)
const bitcoinPruned = ref(false)
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
const appId = computed(() => route.params.id as string)
@@ -471,6 +481,13 @@ const dependencies = computed(() => {
})
})
const installBlockedReason = computed(() => {
const id = app.value?.id
if (!bitcoinPruned.value || !id) return ''
if (id !== 'electrumx' && id !== 'electrs' && id !== 'mempool-electrs') return ''
return electrumxArchiveWarning
})
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
@@ -495,8 +512,20 @@ onMounted(() => {
router.push('/dashboard/marketplace').catch(() => {})
}, 500)
}
loadBitcoinPruneStatus()
})
async function loadBitcoinPruneStatus() {
try {
const res = await fetch('/bitcoin-status', { credentials: 'include', signal: AbortSignal.timeout(8000) })
if (!res.ok) return
const status = await res.json()
bitcoinPruned.value = status?.blockchain_info?.pruned === true
} catch (e) {
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Bitcoin prune status unavailable:', e)
}
}
onBeforeUnmount(() => {
if (pendingRedirect) { clearTimeout(pendingRedirect); pendingRedirect = null }
})
@@ -533,6 +562,11 @@ async function installDependencies() {
if (installingDeps.value) return
const missingDeps = dependencies.value.filter(d => d.status === 'missing')
if (!missingDeps.length) return
if (bitcoinPruned.value && missingDeps.some(d => d.id === 'electrumx' || d.id === 'electrs' || d.id === 'mempool-electrs')) {
installError.value = electrumxArchiveWarning
toast.error(electrumxArchiveWarning)
return
}
installingDeps.value = true
installError.value = null
@@ -561,6 +595,11 @@ async function installDependencies() {
async function installApp() {
if (installing.value || !app.value) return
if (installBlockedReason.value) {
installError.value = installBlockedReason.value
toast.error(installBlockedReason.value)
return
}
if (!app.value.manifestUrl && !app.value.dockerImage) {
if (import.meta.env.DEV) console.warn('[MarketplaceAppDetails] Cannot install - no manifestUrl or dockerImage:', app.value)
return

View File

@@ -3,7 +3,7 @@
<!-- Desktop: Single Row Layout -->
<div class="hidden md:flex items-center gap-6">
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:src="icon"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
@@ -117,7 +117,7 @@
<div class="md:hidden">
<div class="flex items-start gap-4 mb-4">
<img
:src="pkg['static-files']?.icon || `/assets/img/app-icons/${pkg.manifest?.id || appId}.png`"
:src="icon"
:alt="pkg.manifest.title"
class="w-20 h-20 rounded-xl shadow-xl flex-shrink-0"
@error="handleImageError"
@@ -226,18 +226,23 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import type { PackageDataEntry } from '@/types/api'
import { resolveAppIcon } from '@/views/apps/appsConfig'
import { getStatusClass, getStatusDotClass, getStatusLabel } from './appDetailsData'
const { t } = useI18n()
defineProps<{
pkg: Record<string, any>
const props = defineProps<{
pkg: PackageDataEntry
appId: string
packageKey: string
canLaunch: boolean
isWebOnly: boolean
}>()
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
defineEmits<{
launch: []
start: []

View File

@@ -16,7 +16,7 @@ export const WEB_ONLY_APP_URLS: Record<string, string> = {
/** Map route/marketplace app IDs to backend package keys (container names). */
export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
mempool: 'mempool-web',
mempool: 'mempool',
'mempool-electrs': 'mempool-electrs',
electrs: 'mempool-electrs',
btcpay: 'btcpay-server',
@@ -88,7 +88,7 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'lnd': { dev: 'http://localhost:18083', prod: 'http://localhost:18083' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'http://localhost:9100', prod: 'http://localhost:9100' },
'nwnn': { dev: 'https://nwnn.l484.com', prod: 'https://nwnn.l484.com' },

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { NEW_TAB_APPS, resolveAppUrl } from '../appSessionConfig'
describe('appSessionConfig', () => {
it('keeps 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)
})
it('resolves direct app ports against the current browser host', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
})
})

View File

@@ -14,8 +14,8 @@ export const APP_PORTS: Record<string, number> = {
'archy-electrs-ui': 50002,
'mempool-electrs': 50002,
'btcpay-server': 23000,
'lnd': 8081,
'archy-lnd-ui': 8081,
'lnd': 18083,
'archy-lnd-ui': 18083,
'mempool': 4080,
'mempool-web': 4080,
'archy-mempool-web': 4080,
@@ -34,6 +34,7 @@ export const APP_PORTS: Record<string, number> = {
'nginx-proxy-manager': 81,
'gitea': 3001,
'portainer': 9000,
'tailscale': 8240,
'uptime-kuma': 3002,
'fedimint': 8175,
'fedimintd': 8175,
@@ -52,40 +53,8 @@ export const PROXY_APPS: Record<string, string> = {
'uptime-kuma': '/app/uptime-kuma/',
}
/** Nginx proxy paths -- used on HTTPS to avoid mixed content (HTTPS parent + HTTP port iframe).
* On HTTP, direct port access is used instead (faster, no proxy). */
/** App launches use direct ports. Do not route through /app/... path proxies. */
export const HTTPS_PROXY_PATHS: Record<string, string> = {
'lnd': '/app/lnd/',
'electrumx': '/app/electrumx/',
'electrs': '/app/electrumx/',
'archy-electrs-ui': '/app/electrumx/',
'mempool-electrs': '/app/electrumx/',
'mempool': '/app/mempool/',
'mempool-web': '/app/mempool/',
'archy-mempool-web': '/app/mempool/',
'fedimint': '/app/fedimint/',
'fedimintd': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'jellyfin': '/app/jellyfin/',
'searxng': '/app/searxng/',
'filebrowser': '/app/filebrowser/',
'ollama': '/app/ollama/',
'onlyoffice': '/app/onlyoffice/',
'immich': '/app/immich/',
'immich_server': '/app/immich/',
'portainer': '/app/portainer/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'uptime-kuma': '/app/uptime-kuma/',
'homeassistant': '/app/homeassistant/',
'vaultwarden': '/app/vaultwarden/',
'photoprism': '/app/photoprism/',
'endurain': '/app/endurain/',
'dwn': '/app/dwn/',
'btcpay-server': '/app/btcpay/',
'nextcloud': '/app/nextcloud/',
'grafana': '/app/grafana/',
'botfights': '/app/botfights/',
'gitea': '/app/gitea/',
}
/** External HTTPS apps -- always loaded directly */
@@ -96,7 +65,6 @@ export const EXTERNAL_URLS: Record<string, string> = {
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
'nostrudel': 'https://nostrudel.ninja',
'tailscale': 'https://login.tailscale.com/admin/machines',
}
export const APP_TITLES: Record<string, string> = {
@@ -141,19 +109,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string): string {
return 'http://' + window.location.hostname + ':8334'
}
// HTTPS pages cannot embed plain HTTP port origins (mixed-content).
if (window.location.protocol === 'https:') {
const proxyPath = HTTPS_PROXY_PATHS[id]
if (proxyPath) {
return window.location.protocol + '//' + window.location.hostname + proxyPath
}
}
// Local apps on HTTP pages launch by host port.
// Local apps launch by host port.
const port = APP_PORTS[id]
if (!port) return ''
let base = window.location.protocol + '//' + window.location.hostname + ':' + String(port)
let base = 'http://' + window.location.hostname + ':' + String(port)
if (routeQueryPath) base += routeQueryPath
return base
}

View File

@@ -78,7 +78,8 @@ import { computed, ref } from 'vue'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { PackageDataEntry } from '@/types/api'
import { canLaunch, handleImageError, resolveAppIcon } from './appsConfig'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { canLaunch, handleImageError, opensInTab, resolveAppIcon } from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
@@ -119,6 +120,13 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
function handleTap(id: string, pkg: PackageDataEntry) {
if (canLaunch(pkg)) {
if (opensInTab(id)) {
const appUrl = resolveAppUrl(id)
if (appUrl) {
window.open(appUrl, '_blank', 'noopener,noreferrer')
return
}
}
appLauncher.openSession(id)
} else {
emit('goToApp', id)

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { useAppLauncherStore } from '@/stores/appLauncher'
import AppIconGrid from '../AppIconGrid.vue'
const mockWindowOpen = vi.fn()
vi.stubGlobal('open', mockWindowOpen)
function makePkg(id: string): PackageDataEntry {
return {
state: PackageState.Running,
manifest: {
id,
title: id,
version: '1.0.0',
description: { short: '', long: '' },
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
interfaces: { main: { ui: true } },
} as unknown as PackageDataEntry['manifest'],
'static-files': { license: '', instructions: '', icon: '' },
}
}
describe('AppIconGrid', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.198' },
writable: true,
configurable: true,
})
})
it('opens LND companion UI in the app panel', async () => {
const wrapper = mount(AppIconGrid, {
props: { apps: [['lnd', makePkg('lnd')]] },
global: {
plugins: [createPinia()],
},
})
await wrapper.get('.app-icon-item').trigger('click')
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(useAppLauncherStore().panelAppId).toBe('lnd')
})
})

View File

@@ -79,6 +79,6 @@ describe('appsConfig service filtering', () => {
it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch'
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.png')
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.svg')
})
})

View File

@@ -125,7 +125,9 @@ export function opensInTab(id: string): boolean {
return TAB_LAUNCH_APPS.has(id)
}
const APP_ICON_FALLBACKS: Record<string, string> = {
gitea: '/assets/img/app-icons/gitea.svg',
}
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
const icon = (pkg["static-files"]?.icon || "").trim()
@@ -137,7 +139,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
) {
return icon
}
return curatedIcon || `/assets/img/app-icons/${id}.png`
return curatedIcon || APP_ICON_FALLBACKS[id] || `/assets/img/app-icons/${id}.png`
}
export function canLaunch(pkg: PackageDataEntry): boolean {

View File

@@ -140,10 +140,9 @@ const uiMode = useUIModeStore()
const mobileTabBar = ref<HTMLElement | null>(null)
// Hide tab bar when an app session is open (fullscreen on mobile)
const isAppSessionActive = computed(() => {
return route.name === 'app-session' || !!appLauncher.panelAppId
})
// App sessions own their mobile controls. Normal mobile launches use the route
// session; keeping this guard also protects any desktop-panel state on resize.
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
// Show persistent tabs for Apps/Marketplace on mobile
const showAppsTabs = computed(() => {

View File

@@ -96,7 +96,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
{ id: 'portainer', title: 'Portainer', version: '2.19.4', description: 'Container management UI. Manage your containerized services through the web.', icon: '/assets/img/app-icons/portainer.webp', author: 'Portainer', dockerImage: `${R}/portainer:latest`, repoUrl: 'https://github.com/portainer/portainer' },
{ id: 'uptime-kuma', title: 'Uptime Kuma', version: '1.23.0', description: 'Self-hosted uptime monitoring. Track HTTP, TCP, DNS, and more.', icon: '/assets/img/app-icons/uptime-kuma.webp', author: 'Uptime Kuma', dockerImage: `${R}/uptime-kuma:1`, repoUrl: 'https://github.com/louislam/uptime-kuma' },
{ id: 'tailscale', title: 'Tailscale', version: '1.78.0', description: 'Zero-config VPN. Secure remote access with WireGuard mesh networking.', icon: '/assets/img/app-icons/tailscale.webp', author: 'Tailscale', dockerImage: `${R}/tailscale:stable`, repoUrl: 'https://github.com/tailscale/tailscale' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.webp', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'electrumx', title: 'ElectrumX', version: '1.18.0', description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.', icon: '/assets/img/app-icons/electrumx.png', author: 'Luke Childs', dockerImage: `${R}/electrumx:v1.18.0`, repoUrl: 'https://github.com/spesmilo/electrumx' },
{ id: 'fedimint', title: 'Fedimint', version: '0.10.0', description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.', icon: '/assets/img/app-icons/fedimint.png', author: 'Fedimint', dockerImage: `${R}/fedimintd:v0.10.0`, repoUrl: 'https://github.com/fedimint/fedimint' },
{ id: 'indeedhub', title: 'Indeehub', version: '1.0.0', description: 'Bitcoin documentary streaming with Nostr identity. Stream sovereignty content.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', dockerImage: `${R}/indeedhub:1.0.0`, repoUrl: 'https://github.com/indeedhub/indeedhub' },
{ id: 'dwn', title: 'Decentralized Web Node', version: '0.4.0', description: 'Own your data with DID-based access control. Sync across devices, sovereign.', icon: '/assets/img/app-icons/dwn.svg', author: 'TBD', dockerImage: `${R}/dwn-server:main`, repoUrl: 'https://github.com/TBD54566975/dwn-server' },

View File

@@ -1,7 +1,7 @@
<template>
<div
data-controller-container
:data-controller-install="!(installed || installing) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
:data-controller-install="!(installed || installing || installBlockedReason) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
tabindex="0"
role="link"
class="glass-card p-6 hover:bg-orange-500/5 hover:border-orange-500/15 transition-all cursor-pointer flex flex-col"
@@ -122,6 +122,14 @@
></div>
</div>
</div>
<button
v-else-if="!installed && installBlockedReason"
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-100 text-sm font-medium"
:title="installBlockedReason"
@click.stop="$emit('install', app)"
>
Bitcoin Pruned
</button>
<button
v-else-if="!installed && (app.source === 'local' || app.dockerImage)"
data-controller-install-btn
@@ -159,6 +167,7 @@ const props = defineProps<{
startingUp: boolean
containersScanned: boolean
tierLabel: string
installBlockedReason?: string
}>()
defineEmits<{

View File

@@ -297,7 +297,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
title: 'ElectrumX',
version: '1.18.0',
description: 'Electrum protocol server. Index the blockchain for fast wallet lookups, privately.',
icon: '/assets/img/app-icons/electrumx.webp',
icon: '/assets/img/app-icons/electrumx.png',
author: 'Luke Childs',
dockerImage: `${REGISTRY}/electrumx:v1.18.0`,
manifestUrl: undefined,