Add signed node-scoped demo catalogs and retained app media sessions
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user