fix: resolve node demo launch policy before opening its player
This commit is contained in:
@@ -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', () => {
|
it('blocks both browser and embedded launch while HTTP is unready', () => {
|
||||||
const app = useAppStore()
|
const app = useAppStore()
|
||||||
app.data = { 'package-data': { gitea: { state: 'running', 'ui-ready': false, health: 'healthy', manifest: { id: 'gitea', title: 'Gitea' } } } } as never
|
app.data = { 'package-data': { gitea: { state: 'running', 'ui-ready': false, health: 'healthy', manifest: { id: 'gitea', title: 'Gitea' } } } } as never
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { appHasMediaBridge } from '@/views/discover/curatedApps'
|
import { appHasMediaBridge, ensureNodeAppAvailable, nodeAppIsAvailable } from '@/views/discover/curatedApps'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
@@ -239,6 +239,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
const mediaAppId = ref<string | null>(null)
|
const mediaAppId = ref<string | null>(null)
|
||||||
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
|
/** Optional deep-link path inside the active app (e.g. /tx/<hash> for mempool) */
|
||||||
const panelPath = ref<string | null>(null)
|
const panelPath = ref<string | null>(null)
|
||||||
|
let launchGeneration = 0
|
||||||
|
|
||||||
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
|
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
|
||||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
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
|
* Previously each Apps view owned a private modal, so Home skipped the
|
||||||
* Portainer first-run token entirely. */
|
* Portainer first-run token entirely. */
|
||||||
function openSession(appId: string, opts: LaunchOptions = {}) {
|
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.
|
// Home/goal/deep-link launchers do not pass through AppCard.canLaunch.
|
||||||
// Apply the same readiness gate here so a container that has just entered
|
// 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.
|
// `running` cannot race nginx and show a transient 502 to the user.
|
||||||
@@ -392,6 +406,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
|
launchGeneration += 1
|
||||||
panelAppId.value = null
|
panelAppId.value = null
|
||||||
panelPath.value = null
|
panelPath.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,38 @@ const demo = () => ({ scope: 'single-node-demo', expires_at: new Date(Date.now()
|
|||||||
|
|
||||||
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear() })
|
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear() })
|
||||||
describe('node-scoped demo catalog', () => {
|
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 => {
|
it.each([{}, [null]])('ignores malformed optional promotions: %j', async promotions => {
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||||
|
|||||||
@@ -105,8 +105,41 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
|
|||||||
* after fetchAppCatalog() has run. Test-hookable. */
|
* after fetchAppCatalog() has run. Test-hookable. */
|
||||||
let signedCatalogCache: SignedAppCatalog | null = null
|
let signedCatalogCache: SignedAppCatalog | null = null
|
||||||
let nodeCatalogCache: SignedAppCatalog | null = null
|
let nodeCatalogCache: SignedAppCatalog | null = null
|
||||||
|
let nodeCatalogExpires = 0
|
||||||
|
let nodeCatalogRequest: Promise<SignedAppCatalog | null> | null = null
|
||||||
|
export function nodeAppIsAvailable(id: string): boolean {
|
||||||
|
return nodeCatalogExpires > Date.now() && Boolean(nodeCatalogCache?.apps[id])
|
||||||
|
}
|
||||||
export function appHasMediaBridge(id: string): boolean {
|
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<SignedAppCatalog | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
* storage or the global release catalog. A copied catalog is rejected by the
|
||||||
* daemon unless its release signature and exact node audience match. */
|
* daemon unless its release signature and exact node audience match. */
|
||||||
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||||
const nodeRequest = fetch('/api/node-app-catalog', { credentials: 'include', signal: AbortSignal.timeout(5000) })
|
const [base, node] = await Promise.all([fetchBaseAppCatalog(), fetchNodeCatalog()])
|
||||||
.then(async response => response.ok ? await response.json() : null).catch(() => null)
|
if (!base || !node) return base
|
||||||
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 known = new Set(base.apps.map(app => app.id))
|
||||||
const additions = signedCatalogToApps(node).filter(app => !known.has(app.id))
|
const additions = signedCatalogToApps(node).filter(app => !known.has(app.id))
|
||||||
const demoIds = new Set(additions.map(app => app.id))
|
const demoIds = new Set(additions.map(app => app.id))
|
||||||
|
|||||||
Reference in New Issue
Block a user