diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 68bf8ee7..72c22381 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -55,6 +55,27 @@ describe('useAppLauncherStore', () => { }) }) + it.each(['close', 'another-app', 'none'])('waits for node demo launch policy, cancellation: %s', async cancel => { + const id = `node-demo-delayed-${cancel}` + const previousFetch = globalThis.fetch + let release!: (value: unknown) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => { release = resolve }))) + const launcher = useAppLauncherStore() + launcher.openSession(id) + expect(launcher.panelAppId).toBeNull() + if (cancel === 'close') launcher.closePanel() + if (cancel === 'another-app') launcher.openSession('mempool') + release({ ok: true, json: async () => ({ scope: 'single-node-demo', expires_at: new Date(Date.now() + 60000).toISOString(), apps: { + [id]: { version: '1', manifest: { app: { metadata: { launch: { media_controls: 'archipelago-v1' } } } } }, + } }) }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(launcher.panelAppId).toBe(cancel === 'none' ? id : cancel === 'another-app' ? 'mempool' : null) + launcher.openSession(id) + expect(launcher.panelAppId).toBe(id) + expect(launcher.mediaAppId).toBe(id) + vi.stubGlobal('fetch', previousFetch) + }) + it('blocks both browser and embedded launch while HTTP is unready', () => { const app = useAppStore() app.data = { 'package-data': { gitea: { state: 'running', 'ui-ready': false, health: 'healthy', manifest: { id: 'gitea', title: 'Gitea' } } } } as never diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index bb8e94fd..16478e10 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -1,4 +1,4 @@ -import { appHasMediaBridge } from '@/views/discover/curatedApps' +import { appHasMediaBridge, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps' import { defineStore } from 'pinia' import { ref, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' @@ -239,6 +239,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const mediaAppId = ref(null) /** Optional deep-link path inside the active app (e.g. /tx/ for mempool) */ const panelPath = ref(null) + let launchGeneration = 0 function openSessionNow(appId: string, opts: LaunchOptions = {}) { const pkg = useAppStore().data?.['package-data']?.[appId] @@ -299,6 +300,19 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { * Previously each Apps view owned a private modal, so Home skipped the * Portainer first-run token entirely. */ function openSession(appId: string, opts: LaunchOptions = {}) { + const generation = ++launchGeneration + if (appId.startsWith('node-demo-') && !nodeAppIsAvailable(appId)) { + useToast().info('Loading this node’s demo app…') + void ensureNodeAppAvailable(appId).then(available => { + if (generation !== launchGeneration) return + if (!available) { + useToast().error('This node’s demo catalog is unavailable. Please retry.') + return + } + openSession(appId, opts) + }) + return + } // Home/goal/deep-link launchers do not pass through AppCard.canLaunch. // Apply the same readiness gate here so a container that has just entered // `running` cannot race nginx and show a transient 502 to the user. @@ -392,6 +406,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { } function closePanel() { + launchGeneration += 1 panelAppId.value = null panelPath.value = null } diff --git a/neode-ui/src/views/discover/__tests__/nodeCatalog.test.ts b/neode-ui/src/views/discover/__tests__/nodeCatalog.test.ts index 33afc47e..de9e6049 100644 --- a/neode-ui/src/views/discover/__tests__/nodeCatalog.test.ts +++ b/neode-ui/src/views/discover/__tests__/nodeCatalog.test.ts @@ -6,6 +6,38 @@ const demo = () => ({ scope: 'single-node-demo', expires_at: new Date(Date.now() afterEach(() => { vi.unstubAllGlobals(); localStorage.clear() }) describe('node-scoped demo catalog', () => { + it('resolves launch policy before a slow public storefront and deduplicates requests', async () => { + vi.resetModules() + let release!: (value: unknown) => void + const pending = new Promise(resolve => { release = resolve }) + const fetcher = vi.fn((_url: string) => pending) + vi.stubGlobal('fetch', fetcher) + const { ensureNodeAppAvailable, appHasMediaBridge } = await import('../curatedApps') + const first = ensureNodeAppAvailable('node-demo-v4v') + const second = ensureNodeAppAvailable('node-demo-v4v') + expect(fetcher).toHaveBeenCalledTimes(1) + expect(fetcher.mock.calls[0]?.[0]).toBe('/api/node-app-catalog') + expect(appHasMediaBridge('node-demo-v4v')).toBe(false) + const payload = demo() + Object.assign(payload.apps['node-demo-v4v'].manifest.app, { metadata: { launch: { media_controls: 'archipelago-v1' } } }) + release({ ok: true, json: async () => payload }) + expect(await first).toBe(true) + expect(await second).toBe(true) + expect(appHasMediaBridge('node-demo-v4v')).toBe(true) + expect(await ensureNodeAppAvailable('node-demo-v4v')).toBe(true) + expect(fetcher).toHaveBeenCalledTimes(1) + }) + it('fails closed for unavailable and expired launch policy', async () => { + vi.resetModules() + const fetcher = vi.fn(async () => ({ ok: true, json: async () => ({ ...demo(), expires_at: '2000-01-01T00:00:00Z' }) })) + vi.stubGlobal('fetch', fetcher) + const { ensureNodeAppAvailable, appHasMediaBridge } = await import('../curatedApps') + expect(await ensureNodeAppAvailable('node-demo-v4v')).toBe(false) + expect(appHasMediaBridge('node-demo-v4v')).toBe(false) + fetcher.mockRejectedValueOnce(new Error('offline')) + expect(await ensureNodeAppAvailable('node-demo-v4v')).toBe(false) + }) + it.each([{}, [null]])('ignores malformed optional promotions: %j', async promotions => { vi.resetModules() vi.stubGlobal('fetch', vi.fn(async (url: string) => { diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index e805f09e..7c3d9d48 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -105,8 +105,41 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] * after fetchAppCatalog() has run. Test-hookable. */ let signedCatalogCache: SignedAppCatalog | null = null let nodeCatalogCache: SignedAppCatalog | null = null +let nodeCatalogExpires = 0 +let nodeCatalogRequest: Promise | null = null +export function nodeAppIsAvailable(id: string): boolean { + return nodeCatalogExpires > Date.now() && Boolean(nodeCatalogCache?.apps[id]) +} export function appHasMediaBridge(id: string): boolean { - return nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1' + return nodeAppIsAvailable(id) && nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1' +} + +/** Resolve node-owned launch policy independently of the public storefront. + * Home/companion launches must not depend on visiting Discover first. */ +async function fetchNodeCatalog(): Promise { + if (nodeCatalogRequest) return nodeCatalogRequest + nodeCatalogRequest = (async () => { + try { + const response = await fetch('/api/node-app-catalog', { credentials: 'include', signal: AbortSignal.timeout(5000) }) + const node = response.ok ? await response.json() : null + if (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())) throw new Error('Unavailable node catalog') + nodeCatalogCache = node as SignedAppCatalog + nodeCatalogExpires = Date.parse(node.expires_at) + return nodeCatalogCache + } catch { + nodeCatalogCache = null + nodeCatalogExpires = 0 + return null + } finally { nodeCatalogRequest = null } + })() + return nodeCatalogRequest +} + +export async function ensureNodeAppAvailable(id: string): Promise { + if (!nodeAppIsAvailable(id)) await fetchNodeCatalog() + return nodeAppIsAvailable(id) } @@ -197,14 +230,8 @@ const CATALOG_URLS = [ * 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 { - 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 [base, node] = await Promise.all([fetchBaseAppCatalog(), fetchNodeCatalog()]) + if (!base || !node) return base 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))