Add signed node-scoped demo catalogs and retained app media sessions

This commit is contained in:
archipelago
2026-10-06 00:53:34 -04:00
parent 131c39cf74
commit e54f83df8f
18 changed files with 706 additions and 20 deletions
+10 -2
View File
@@ -131,6 +131,7 @@ import {
} from './appSession/appSessionConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { PackageState } from '@/types/api'
import { useAppMediaBridge } from '@/composables/useAppMediaBridge'
import { useAppIdentity } from './appSession/useAppIdentity'
import { useNostrBridge } from './appSession/useNostrBridge'
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
@@ -141,6 +142,7 @@ const props = defineProps<{
appIdProp?: string
/** Deep-link path inside the app (store-driven sessions), e.g. /tx/<hash> */
pathProp?: string
suspended?: boolean
}>()
const emit = defineEmits<{
@@ -179,8 +181,8 @@ const appId = computed(() => {
// Display mode -- per-app user choice → per-app default → last global → panel
const displayMode = ref<DisplayMode>(initialDisplayMode(appId.value))
const appTitle = computed(() => resolveAppTitle(appId.value))
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
const appTitle = computed(() => packageEntry.value?.manifest?.title || resolveAppTitle(appId.value))
const appIcon = computed(() =>
packageEntry.value
? resolveAppIcon(appId.value, packageEntry.value)
@@ -279,6 +281,7 @@ function closeRouteSession() {
// --- Identity & Nostr bridge ---
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
const mediaBridge = useAppMediaBridge(appId, appUrl, iframeRef, computed(() => !props.suspended))
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
@@ -342,6 +345,7 @@ function syncInlineRect() {
}
const inlineRectStyle = computed<Record<string, string> | undefined>(() => {
if (props.suspended) return { display: 'none' }
if (!inlinePanelMode.value) return undefined
const r = inlineRect.value
// Never paint the inline backdrop over the whole viewport while unmeasured.
@@ -393,6 +397,7 @@ watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
// --- Lifecycle handlers ---
function onLoad() {
mediaBridge.connect()
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
loading.value = false
@@ -404,7 +409,7 @@ function onLoad() {
forgetAutoTabApp(appId.value)
// TV/keyboard: hand focus to the app so keys (incl. the gamepad bridge's
// virtual keyboard) flow into the iframe without needing a pointer click.
try { frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
try { if (!props.suspended) frameRef.value?.iframeRef?.focus() } catch { /* cross-origin is fine */ }
// Check if iframe actually loaded content (same-origin only)
iframeCheckId = setTimeout(() => {
try {
@@ -488,6 +493,7 @@ function closeSession() {
}
function onKeyDown(e: KeyboardEvent) {
if (props.suspended) return
if (e.key === 'Escape') {
if (nostrBridge.showConsent.value) {
nostrBridge.denyConsent()
@@ -508,6 +514,8 @@ function onFullscreenChange() {
}
function onMessage(e: MessageEvent) {
if (e.source !== iframeRef.value?.contentWindow) return
mediaBridge.handle(e)
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
+6 -1
View File
@@ -93,14 +93,19 @@
<!-- Panel mode app session — renders alongside current page content -->
<Transition name="panel-slide">
<div v-if="appLauncher.panelAppId" class="app-panel-container">
<div v-if="appLauncher.panelAppId && appLauncher.panelAppId !== appLauncher.mediaAppId" class="app-panel-container">
<AppSession
v-if="appLauncher.panelAppId !== appLauncher.mediaAppId"
:key="appLauncher.panelAppId"
:app-id-prop="appLauncher.panelAppId"
:path-prop="appLauncher.panelPath ?? undefined"
@close="appLauncher.closePanel()"
/>
</div>
</Transition>
<div v-if="appLauncher.mediaAppId" v-show="appLauncher.panelAppId === appLauncher.mediaAppId" class="app-panel-container">
<AppSession :key="appLauncher.mediaAppId" :app-id-prop="appLauncher.mediaAppId" :suspended="appLauncher.panelAppId !== appLauncher.mediaAppId" @close="appLauncher.closePanel()" />
</div>
</main>
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const demo = () => ({ scope: 'single-node-demo', expires_at: new Date(Date.now() + 60000).toISOString(), apps: {
'node-demo-v4v': { version: '1', manifest: { app: { id: 'node-demo-v4v', name: 'V4V demo', ports: [{ host: 7475, auth: 'gated' }] } } },
}, storefront: { promotions: [{ id: 'node-demo-v4v', headline: 'Sovereign Music' }] } })
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear() })
describe('node-scoped demo catalog', () => {
it.each([{}, [null]])('ignores malformed optional promotions: %j', async promotions => {
vi.resetModules()
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/node-app-catalog') return { ok: true, json: async () => ({ ...demo(), storefront: { promotions } }) }
if (url === '/api/app-catalog') return { ok: true, json: async () => ({ apps: { mempool: { version: '3' } } }) }
return { ok: false }
}))
const { fetchAppCatalog } = await import('../curatedApps')
const catalog = await fetchAppCatalog()
expect(catalog?.apps.map(app => app.id)).toContain('node-demo-v4v')
expect(catalog?.storefront?.promotions).toEqual([])
})
it('adds only the verified node response and never persists it into shared fallback', async () => {
vi.resetModules()
let available = true
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/node-app-catalog') return { ok: available, json: async () => demo() }
if (url === '/api/app-catalog') return { ok: true, json: async () => ({ apps: { mempool: { version: '3' } } }) }
return { ok: false }
}))
const { fetchAppCatalog, portIsGateFronted } = await import('../curatedApps')
const first = await fetchAppCatalog()
expect(first?.apps.map(app => app.id)).toEqual(['mempool', 'node-demo-v4v'])
expect(first?.storefront?.promotions[0]?.headline).toBe('Sovereign Music')
expect(localStorage.getItem('archy_catalog')).not.toContain('node-demo-v4v')
expect(portIsGateFronted('node-demo-v4v', 7475)).toBe(true)
available = false
const second = await fetchAppCatalog()
expect(second?.apps.map(app => app.id)).toEqual(['mempool'])
expect(second?.storefront?.promotions ?? []).toHaveLength(0)
expect(portIsGateFronted('node-demo-v4v', 7475)).toBe(false)
})
})
+31 -3
View File
@@ -68,7 +68,7 @@ export interface SignedAppEntry {
description?: string
category?: string
container?: { image?: string }
metadata?: { icon?: string; author?: string; repo?: string }
metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string } }
ports?: { host?: number | string; container?: number | string; auth?: string }[]
}
}
@@ -104,6 +104,11 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
* after fetchAppCatalog() has run. Test-hookable. */
let signedCatalogCache: SignedAppCatalog | null = null
let nodeCatalogCache: SignedAppCatalog | null = null
export function appHasMediaBridge(id: string): boolean {
return nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1'
}
/** Launch aliases → the catalog app id that OWNS the UI port.
*
@@ -141,7 +146,7 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
* answered `gated`, or an https frame URL would point at a port that never
* serves TLS. */
export function portAuth(appId: string, hostPort: number | string): string | null {
const apps = signedCatalogCache?.apps
const apps = { ...signedCatalogCache?.apps, ...nodeCatalogCache?.apps }
if (!apps) return null
const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId]
const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias]
@@ -188,9 +193,32 @@ const CATALOG_URLS = [
'/catalog.json',
]
/** Node demos are requested separately and never persisted in browser fallback
* storage or the global release catalog. A copied catalog is rejected by the
* daemon unless its release signature and exact node audience match. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const nodeRequest = fetch('/api/node-app-catalog', { credentials: 'include', signal: AbortSignal.timeout(5000) })
.then(async response => response.ok ? await response.json() : null).catch(() => null)
const [base, node] = await Promise.all([fetchBaseAppCatalog(), nodeRequest])
nodeCatalogCache = null
if (!base || node?.scope !== 'single-node-demo' || !node.apps || Array.isArray(node.apps)
|| !Object.keys(node.apps).every(id => id.startsWith('node-demo-'))
|| !(Date.parse(node.expires_at) > Date.now())) return base
nodeCatalogCache = node as SignedAppCatalog
const known = new Set(base.apps.map(app => app.id))
const additions = signedCatalogToApps(node).filter(app => !known.has(app.id))
const demoIds = new Set(additions.map(app => app.id))
const promotions = (Array.isArray(node.storefront?.promotions) ? node.storefront.promotions : [])
.filter((promotion: CatalogPromotion | null) => promotion && demoIds.has(promotion.id))
return { ...base, apps: [...base.apps, ...additions], storefront: {
popular: base.storefront?.popular ?? [],
promotions: [...(base.storefront?.promotions ?? []), ...promotions],
} }
}
/** Fetch app catalog from remote registry, with local fallback.
* Caches for 1 hour. Returns null only if ALL sources fail. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
async function fetchBaseAppCatalog(): Promise<AppCatalog | null> {
// Return cache if fresh
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) }