feat(orchestrator): complete container migration and release hardening
This commit is contained in:
@@ -59,6 +59,72 @@ describe('useAppLauncherStore', () => {
|
||||
expect(store.panelAppId).toBe('btcpay-server')
|
||||
})
|
||||
|
||||
it('opens Nginx Proxy Manager in new tab even when URL resolves', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:81', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:81',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://192.168.1.228/app/nginx-proxy-manager/', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://192.168.1.228/app/nginx-proxy-manager/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
})
|
||||
|
||||
it('normalizes legacy Nginx Proxy Manager port 8181 to 81', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:8181', title: 'Nginx Proxy Manager' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:81',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes legacy Uptime Kuma port 3001 to 3002', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:3001', title: 'Uptime Kuma' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228:3002',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(store.isOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens Uptime Kuma in new tab using title hint when URL is path-only', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'https://192.168.1.228/app/uptime-kuma/', title: 'Uptime Kuma' })
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'https://192.168.1.228/app/uptime-kuma/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
})
|
||||
|
||||
it('routes Home Assistant (port 8123) to full-page session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
@@ -77,6 +143,29 @@ describe('useAppLauncherStore', () => {
|
||||
expect(store.panelAppId).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens Gitea path URL in new tab', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228/app/gitea/', title: 'Gitea' })
|
||||
|
||||
expect(store.isOpen).toBe(false)
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
'http://192.168.1.228/app/gitea/',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not map raw port 3001 to gitea session', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
store.open({ url: 'http://192.168.1.228:3001', title: 'Unknown 3001' })
|
||||
|
||||
expect(store.panelAppId).toBe(null)
|
||||
expect(store.isOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('opens in new tab when openInNewTab flag is set for unknown URL', () => {
|
||||
const store = useAppLauncherStore()
|
||||
|
||||
|
||||
@@ -11,11 +11,17 @@ const NEW_TAB_PORTS = new Set([
|
||||
'8123', // Home Assistant — X-Frame-Options: SAMEORIGIN
|
||||
'8082', // Vaultwarden — X-Frame-Options: SAMEORIGIN
|
||||
'8085', // Nextcloud — X-Frame-Options: SAMEORIGIN
|
||||
'3001', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
'3002', // Uptime Kuma — X-Frame-Options: SAMEORIGIN
|
||||
'9001', // Penpot — not reachable
|
||||
// IndeedHub (7777) uses proxy path for NIP-07 nostr-provider.js — NOT new tab
|
||||
])
|
||||
|
||||
const NEW_TAB_APP_IDS = new Set([
|
||||
'nginx-proxy-manager',
|
||||
'uptime-kuma',
|
||||
'gitea',
|
||||
])
|
||||
|
||||
function mustOpenInNewTab(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
@@ -25,11 +31,42 @@ function mustOpenInNewTab(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function inferAppIdFromTitle(title?: string): string | null {
|
||||
const t = (title || '').toLowerCase()
|
||||
if (!t) return null
|
||||
if ((t.includes('uptime') && t.includes('kuma')) || t.includes('uptime-kuma')) return 'uptime-kuma'
|
||||
if ((t.includes('nginx') && t.includes('proxy') && t.includes('manager')) || t.includes('nginx-proxy-manager')) return 'nginx-proxy-manager'
|
||||
if (t.includes('gitea')) return 'gitea'
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
const sameHost = u.hostname === window.location.hostname
|
||||
const normalizedPath = u.pathname === '/' ? '' : u.pathname
|
||||
const rebuilt = (port: string) => `${u.protocol}//${u.hostname}:${port}${normalizedPath}${u.search}${u.hash}`
|
||||
|
||||
if (sameHost && appIdHint === 'uptime-kuma' && u.port === '3001') {
|
||||
return rebuilt('3002')
|
||||
}
|
||||
|
||||
if (sameHost && appIdHint === 'nginx-proxy-manager' && u.port === '8181') {
|
||||
return rebuilt('81')
|
||||
}
|
||||
|
||||
return urlStr
|
||||
} catch {
|
||||
return urlStr
|
||||
}
|
||||
}
|
||||
|
||||
/** Port → app ID for resolving URLs to AppSession routes */
|
||||
const PORT_TO_APP_ID: Record<string, string> = {
|
||||
'81': 'nginx-proxy-manager',
|
||||
'8181': 'nginx-proxy-manager',
|
||||
'3000': 'grafana',
|
||||
'3001': 'uptime-kuma',
|
||||
'3002': 'uptime-kuma',
|
||||
'8080': 'endurain',
|
||||
'8081': 'lnd',
|
||||
'8082': 'vaultwarden',
|
||||
@@ -115,19 +152,30 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
|
||||
/** Legacy: open app in iframe overlay (kept for backward compat) */
|
||||
function open(payload: { url: string; title: string; openInNewTab?: boolean }) {
|
||||
const titleHintId = inferAppIdFromTitle(payload.title)
|
||||
const launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||
|
||||
// Force selected apps to open directly in new tab
|
||||
if (resolvedId && NEW_TAB_APP_IDS.has(resolvedId)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Route to full-page session if we can resolve an app ID from the URL
|
||||
const resolvedId = resolveAppIdFromUrl(payload.url)
|
||||
if (resolvedId) {
|
||||
openSession(resolvedId)
|
||||
return
|
||||
}
|
||||
// Apps that block iframes — open directly in new tab
|
||||
if (payload.openInNewTab || mustOpenInNewTab(payload.url)) {
|
||||
window.open(payload.url, '_blank', 'noopener,noreferrer')
|
||||
|
||||
// Unknown apps that block iframes — open directly in new tab
|
||||
if (payload.openInNewTab || mustOpenInNewTab(launchUrl)) {
|
||||
window.open(launchUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
previousActiveElement = (document.activeElement as HTMLElement) || null
|
||||
url.value = payload.url
|
||||
url.value = launchUrl
|
||||
title.value = payload.title
|
||||
isOpen.value = true
|
||||
}
|
||||
@@ -136,6 +184,9 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
function resolveAppIdFromUrl(urlStr: string): string | null {
|
||||
try {
|
||||
const u = new URL(urlStr)
|
||||
// Check /app/{id}/ path-style routes first (HTTPS proxy mode)
|
||||
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
|
||||
if (m?.[1]) return m[1].toLowerCase()
|
||||
// Check port-based apps
|
||||
const appId = PORT_TO_APP_ID[u.port]
|
||||
if (appId) return appId
|
||||
|
||||
@@ -160,7 +160,7 @@ import AppIconGrid from './apps/AppIconGrid.vue'
|
||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||
import { useAppsActions } from './apps/useAppsActions'
|
||||
import {
|
||||
isServiceContainer, isWebOnlyApp, getAppCategory,
|
||||
filterEntriesForTab, isWebOnlyApp,
|
||||
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
|
||||
} from './apps/appsConfig'
|
||||
|
||||
@@ -244,14 +244,7 @@ onBeforeUnmount(() => {
|
||||
// Sorted entries: web-only first, then alphabetical by title
|
||||
const sortedPackageEntries = computed(() => {
|
||||
const entries = Object.entries(packages.value)
|
||||
const filtered = entries.filter(([id, pkg]) => {
|
||||
const isSvc = isServiceContainer(id)
|
||||
if (activeTab.value === 'services' ? !isSvc : isSvc) return false
|
||||
if (activeTab.value === 'apps' && selectedCategory.value !== 'all') {
|
||||
return getAppCategory(id, pkg) === selectedCategory.value
|
||||
}
|
||||
return true
|
||||
})
|
||||
const filtered = filterEntriesForTab(entries, activeTab.value, selectedCategory.value)
|
||||
return filtered.sort(([idA, a], [idB, b]) => {
|
||||
const aWeb = isWebOnlyApp(idA) ? 0 : 1
|
||||
const bWeb = isWebOnlyApp(idB) ? 0 : 1
|
||||
|
||||
@@ -41,6 +41,7 @@ export const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
immich: 'immich',
|
||||
filebrowser: 'filebrowser',
|
||||
'nginx-proxy-manager': 'nginx-proxy-manager',
|
||||
'gitea': 'gitea',
|
||||
portainer: 'portainer',
|
||||
'uptime-kuma': 'uptime-kuma',
|
||||
tailscale: 'tailscale',
|
||||
@@ -85,8 +86,9 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
|
||||
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
|
||||
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
|
||||
'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' },
|
||||
'gitea': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
|
||||
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
|
||||
'uptime-kuma': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
|
||||
'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' },
|
||||
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
|
||||
|
||||
@@ -31,15 +31,15 @@ export const APP_PORTS: Record<string, number> = {
|
||||
'immich': 2283,
|
||||
'immich_server': 2283,
|
||||
'filebrowser': 8083,
|
||||
'nginx-proxy-manager': 8181,
|
||||
'nginx-proxy-manager': 81,
|
||||
'gitea': 3001,
|
||||
'portainer': 9000,
|
||||
'uptime-kuma': 3001,
|
||||
'uptime-kuma': 3002,
|
||||
'fedimint': 8175,
|
||||
'fedimintd': 8175,
|
||||
'fedimint-gateway': 8176,
|
||||
'indeedhub': 7778,
|
||||
'botfights': 9100,
|
||||
'gitea': 3000,
|
||||
'dwn': 3100,
|
||||
'endurain': 8080,
|
||||
}
|
||||
@@ -47,7 +47,11 @@ export const APP_PORTS: Record<string, number> = {
|
||||
/** Apps that need nginx proxy for iframe embedding.
|
||||
* IndeedHub loads via /app/indeedhub/ proxy for nostr-provider.js injection
|
||||
* from the container's internal nginx so iframe works on all servers. */
|
||||
export const PROXY_APPS: Record<string, string> = {}
|
||||
export const PROXY_APPS: Record<string, string> = {
|
||||
'gitea': '/app/gitea/',
|
||||
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
|
||||
'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). */
|
||||
@@ -121,42 +125,24 @@ export const NEW_TAB_APPS = new Set([
|
||||
'portainer',
|
||||
'onlyoffice',
|
||||
'nginx-proxy-manager',
|
||||
'gitea',
|
||||
'tailscale',
|
||||
])
|
||||
|
||||
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
|
||||
export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
/** Resolve the app URL given its ID and current route query */
|
||||
/** Resolve app URL using direct port mapping (source of truth) */
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string): string {
|
||||
// External HTTPS apps
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
// Apps that need nginx proxy (nostr-provider.js injection for NIP-07)
|
||||
const proxyPath = PROXY_APPS[id]
|
||||
if (proxyPath) return `${window.location.origin}${proxyPath}`
|
||||
|
||||
// IndeedHub: direct port access (nostr-provider.js baked into container image)
|
||||
if (id === 'indeedhub') {
|
||||
const port = APP_PORTS[id]
|
||||
if (port) {
|
||||
let base = `${window.location.protocol}//${window.location.hostname}:${port}`
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS: use nginx proxy to avoid mixed content
|
||||
if (window.location.protocol === 'https:') {
|
||||
const httpsProxy = HTTPS_PROXY_PATHS[id]
|
||||
if (httpsProxy) return `${window.location.origin}${httpsProxy}`
|
||||
}
|
||||
|
||||
// HTTP: direct port access
|
||||
// Local apps: always launch by host port
|
||||
const port = APP_PORTS[id]
|
||||
if (!port) return ''
|
||||
let base = `http://${window.location.hostname}:${port}`
|
||||
|
||||
let base = `${window.location.protocol}//${window.location.hostname}:${port}`
|
||||
if (routeQueryPath) base += routeQueryPath
|
||||
return base
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import {
|
||||
isWebOnlyApp, opensInTab, canLaunch,
|
||||
isWebOnlyApp, opensInTab, canLaunch, resolveAppIcon,
|
||||
getStatusClass, getStatusLabel, handleImageError,
|
||||
} from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
@@ -256,10 +256,7 @@ const description = computed(() => {
|
||||
const d = props.pkg.manifest?.description?.short
|
||||
return (d && d !== 'Installing...') ? d : (curated.value?.description || d || '')
|
||||
})
|
||||
const icon = computed(() => {
|
||||
const i = props.pkg['static-files']?.icon
|
||||
return i || curated.value?.icon || `/assets/img/app-icons/${props.id}.png`
|
||||
})
|
||||
const icon = computed(() => resolveAppIcon(props.id, props.pkg, curated.value?.icon))
|
||||
const version = computed(() => {
|
||||
const v = props.pkg.manifest?.version
|
||||
return v || curated.value?.version || ''
|
||||
|
||||
@@ -78,7 +78,7 @@ import { computed, ref } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { canLaunch, handleImageError } from './appsConfig'
|
||||
import { canLaunch, handleImageError, resolveAppIcon } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
@@ -114,8 +114,7 @@ function getTitle(id: string, pkg: PackageDataEntry): string {
|
||||
}
|
||||
|
||||
function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
const i = pkg['static-files']?.icon
|
||||
return i || curatedMap.get(id)?.icon || `/assets/img/app-icons/${id}.png`
|
||||
return resolveAppIcon(id, pkg, curatedMap.get(id)?.icon)
|
||||
}
|
||||
|
||||
function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
|
||||
84
neode-ui/src/views/apps/__tests__/appsConfig.test.ts
Normal file
84
neode-ui/src/views/apps/__tests__/appsConfig.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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'
|
||||
|
||||
function makePkg(id: string, title: string, category: string): PackageDataEntry {
|
||||
return {
|
||||
state: PackageState.Running,
|
||||
manifest: {
|
||||
id,
|
||||
title,
|
||||
version: '1.0.0',
|
||||
description: { short: '', long: '' },
|
||||
'release-notes': '',
|
||||
license: '',
|
||||
'wrapper-repo': '',
|
||||
'upstream-repo': '',
|
||||
'support-site': '',
|
||||
'marketing-site': '',
|
||||
'donation-url': null,
|
||||
category,
|
||||
} as unknown as PackageDataEntry['manifest'],
|
||||
'static-files': { license: '', instructions: '', icon: '' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('appsConfig service filtering', () => {
|
||||
it('treats bitcoin stack UI sidecars as services', () => {
|
||||
expect(isServiceContainer('bitcoin-ui')).toBe(true)
|
||||
expect(isServiceContainer('lnd-ui')).toBe(true)
|
||||
expect(isServiceContainer('electrs-ui')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats container aliases as services even with non-service keys', () => {
|
||||
const aliasPkg = makePkg('bitcoin-ui', 'Bitcoin UI', 'money')
|
||||
expect(isServicePackage('core-lnd-ui', aliasPkg)).toBe(true)
|
||||
})
|
||||
|
||||
it('removes service-only categories from app category tabs', () => {
|
||||
const packages = ref<Record<string, PackageDataEntry>>({
|
||||
'core-bitcoin-ui': makePkg('bitcoin-ui', 'Bitcoin UI', 'money'),
|
||||
'filebrowser': makePkg('filebrowser', 'File Browser', 'data'),
|
||||
})
|
||||
|
||||
const allCategories = ref([
|
||||
{ id: 'all', name: 'All' },
|
||||
{ id: 'money', name: 'Money' },
|
||||
{ id: 'data', name: 'Data' },
|
||||
])
|
||||
|
||||
const visible = useCategoriesWithApps(packages, allCategories)
|
||||
expect(visible.value.map(c => c.id)).toEqual(['all', 'data'])
|
||||
})
|
||||
|
||||
it('filters apps tab by category using manifest-aware service checks', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['core-bitcoin-ui', makePkg('bitcoin-ui', 'Bitcoin UI', 'money')],
|
||||
['filebrowser', makePkg('filebrowser', 'File Browser', 'data')],
|
||||
['btcpay-server', makePkg('btcpay-server', 'BTCPay', 'commerce')],
|
||||
]
|
||||
|
||||
const appsAll = filterEntriesForTab(entries, 'apps', 'all')
|
||||
expect(appsAll.map(([id]) => id)).toEqual(['filebrowser', 'btcpay-server'])
|
||||
|
||||
const appsData = filterEntriesForTab(entries, 'apps', 'data')
|
||||
expect(appsData.map(([id]) => id)).toEqual(['filebrowser'])
|
||||
})
|
||||
|
||||
it('routes service aliases into services tab and excludes user apps', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['core-lnd-ui', makePkg('lnd-ui', 'LND UI', 'money')],
|
||||
['grafana', makePkg('grafana', 'Grafana', 'data')],
|
||||
]
|
||||
|
||||
const services = filterEntriesForTab(entries, 'services', 'all')
|
||||
expect(services.map(([id]) => id)).toEqual(['core-lnd-ui'])
|
||||
})
|
||||
|
||||
it('falls back to packaged app icon when static icon token is not a path', () => {
|
||||
const pkg = makePkg('gitea', 'Gitea', 'dev')
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
expect(resolveAppIcon('gitea', pkg)).toBe('/assets/img/app-icons/gitea.png')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ export const SERVICE_NAMES = new Set([
|
||||
'immich_postgres', 'immich_redis',
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
@@ -28,6 +29,12 @@ export function isServiceContainer(id: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
if (isServiceContainer(id)) return true
|
||||
const manifestId = pkg?.manifest?.id
|
||||
return !!manifestId && isServiceContainer(manifestId)
|
||||
}
|
||||
|
||||
// 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',
|
||||
@@ -50,6 +57,21 @@ export function getAppCategory(id: string, pkg: PackageDataEntry): string {
|
||||
return cat || 'other'
|
||||
}
|
||||
|
||||
export function filterEntriesForTab(
|
||||
entries: Array<[string, PackageDataEntry]>,
|
||||
activeTab: 'apps' | 'services',
|
||||
selectedCategory: string,
|
||||
): Array<[string, PackageDataEntry]> {
|
||||
return entries.filter(([id, pkg]) => {
|
||||
const isSvc = isServicePackage(id, pkg)
|
||||
if (activeTab === 'services' ? !isSvc : isSvc) return false
|
||||
if (activeTab === 'apps' && selectedCategory !== 'all') {
|
||||
return getAppCategory(id, pkg) === selectedCategory
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Web-only app IDs and their URLs
|
||||
export const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
@@ -95,7 +117,7 @@ export const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||
/** Apps that open in a new browser tab (X-Frame-Options blocks iframe) */
|
||||
export const TAB_LAUNCH_APPS = new Set([
|
||||
'btcpay-server', 'grafana', 'photoprism', 'homeassistant',
|
||||
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer',
|
||||
'vaultwarden', 'nextcloud', 'uptime-kuma', 'portainer', 'gitea',
|
||||
'cryptpad', 'nginx-proxy-manager', 'tailscale',
|
||||
])
|
||||
|
||||
@@ -103,6 +125,21 @@ export function opensInTab(id: string): boolean {
|
||||
return TAB_LAUNCH_APPS.has(id)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?: string): string {
|
||||
const icon = (pkg["static-files"]?.icon || "").trim()
|
||||
if (
|
||||
icon.startsWith("/") ||
|
||||
icon.startsWith("http://") ||
|
||||
icon.startsWith("https://") ||
|
||||
icon.startsWith("data:image")
|
||||
) {
|
||||
return icon
|
||||
}
|
||||
return curatedIcon || `/assets/img/app-icons/${id}.png`
|
||||
}
|
||||
|
||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||
const hasUI = pkg.manifest.interfaces?.main?.ui || pkg.installed?.['interface-addresses']?.main
|
||||
@@ -171,7 +208,7 @@ export function useCategoriesWithApps(
|
||||
allCategories: Ref<Array<{ id: string; name: string }>>,
|
||||
) {
|
||||
return computed(() => {
|
||||
const entries = Object.entries(packages.value).filter(([id]) => !isServiceContainer(id))
|
||||
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isServicePackage(id, pkg))
|
||||
return allCategories.value.filter(cat => {
|
||||
if (cat.id === 'all') return true
|
||||
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)
|
||||
@@ -182,6 +219,13 @@ export function useCategoriesWithApps(
|
||||
export function handleImageError(e: Event) {
|
||||
const target = e.target as HTMLImageElement
|
||||
const currentSrc = target.src
|
||||
|
||||
if (target.dataset.fallbackTried !== "1" && currentSrc.endsWith(".png")) {
|
||||
target.dataset.fallbackTried = "1"
|
||||
target.src = currentSrc.replace(/\.png($|\?)/, ".svg$1")
|
||||
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)"/>
|
||||
@@ -189,7 +233,7 @@ export function handleImageError(e: Event) {
|
||||
<path d="M20 44H44V48H20V44Z" fill="rgba(255,255,255,0.4)"/>
|
||||
</svg>
|
||||
`)}`
|
||||
if (!currentSrc.includes('data:image')) {
|
||||
if (!currentSrc.includes("data:image")) {
|
||||
target.src = placeholderSvg
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user