Keep embedded app URL stable as initial runtime state arrives

This commit is contained in:
archipelago
2026-10-06 10:56:42 -04:00
parent 715e86c901
commit cc9f02dfd2
5 changed files with 53 additions and 2 deletions
+5 -2
View File
@@ -127,7 +127,7 @@ import AppSessionFrame from './appSession/AppSessionFrame.vue'
import MobileGamepad from './appSession/MobileGamepad.vue'
import {
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
initialDisplayMode, resolveAppUrl, resolveAppTitle,
initialDisplayMode, resolveAppUrl, resolveAppTitle, canonicalAppUrl,
} from './appSession/appSessionConfig'
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
import { PackageState } from '@/types/api'
@@ -267,7 +267,7 @@ const screensaverSuppressedApps = new Set([
const appUrl = computed(() => {
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
const deepPath = props.pathProp ?? (route.query.path as string | undefined)
return resolveAppUrl(appId.value, deepPath, runtimeUrl)
return canonicalAppUrl(resolveAppUrl(appId.value, deepPath, runtimeUrl))
})
function closeRouteSession() {
@@ -291,6 +291,9 @@ const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
frameWindow: () => iframeRef.value?.contentWindow ?? null,
})
// An actual destination change invalidates consent queued for the previous app page.
watch(appUrl, () => nostrBridge.cancelPending())
// --- Display mode ---
function setMode(mode: DisplayMode) {
@@ -49,6 +49,8 @@ vi.mock('../appSession/useNostrBridge', () => ({
consentError: { value: '' },
approveConsent: vi.fn(),
denyConsent: vi.fn(),
cancelPending: vi.fn(),
dispose: vi.fn(),
}),
}))
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { computed, ref, watch, nextTick } from 'vue'
import { canonicalAppUrl } from '../appSessionConfig'
describe('stable app iframe URL', () => {
it('does not replace the iframe source when runtime discovery adds only a root slash', async () => {
const reported = ref('http://node.test:7778')
const frameUrl = computed(() => canonicalAppUrl(reported.value))
let changes = 0
const stop = watch(frameUrl, () => { changes++ })
expect(frameUrl.value).toBe('http://node.test:7778/')
reported.value = 'http://node.test:7778/'
await nextTick()
expect(changes).toBe(0)
reported.value = 'http://node.test:7778/project/demo'
await nextTick()
expect(changes).toBe(1)
stop()
})
it('preserves deep paths, query strings and fragments', () => {
expect(canonicalAppUrl('https://node.test:7778/project/demo?view=preview#player'))
.toBe('https://node.test:7778/project/demo?view=preview#player')
})
it('keeps an absent app URL absent instead of opening the dashboard inside itself', () => {
expect(canonicalAppUrl('')).toBe('')
})
})
@@ -119,6 +119,12 @@ export const HOST_FRAME_APPS = new Set([
/** Sites known to block iframes -- skip the timeout and go straight to fallback */
export const IFRAME_BLOCKED_APPS = new Set<string>([])
/** Stable iframe src while the initial runtime state replaces the fallback URL. */
export function canonicalAppUrl(url: string): string {
if (!url) return ''
try { return new URL(url, window.location.origin).href } catch { return url }
}
/** Resolve app URL using direct port mapping (source of truth) */
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
// Demo: route to the app's mock UI or real external site (mempool.space,