From cc9f02dfd29882a13d512b84f94cafa5e0515959 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 10:56:42 -0400 Subject: [PATCH] Keep embedded app URL stable as initial runtime state arrives --- docs/post-1.9.0-progress-20261006.md | 13 +++++++++ neode-ui/src/views/AppSession.vue | 7 +++-- .../__tests__/AppSessionMobileNewTab.test.ts | 2 ++ .../appSession/__tests__/stableAppUrl.test.ts | 27 +++++++++++++++++++ .../src/views/appSession/appSessionConfig.ts | 6 +++++ 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 neode-ui/src/views/appSession/__tests__/stableAppUrl.test.ts diff --git a/docs/post-1.9.0-progress-20261006.md b/docs/post-1.9.0-progress-20261006.md index 2e593a93..19ff3466 100644 --- a/docs/post-1.9.0-progress-20261006.md +++ b/docs/post-1.9.0-progress-20261006.md @@ -591,3 +591,16 @@ the approved consent presentation. Dashboard typecheck passes. Logs: `/tmp/archy-signer-queue-related-tests.log`, `/tmp/archy-signer-queue-typecheck.log`. Production UI build/deployment still pending; physical companion causality remains unverified. + +The signer UI candidate `715e86c9` was deployed to dev only, with UI backup; +backend/session secret/app containers were unchanged. Live served-browser checks +with isolated signing RPC fixtures passed at 390/1440px, but an earlier run saw +two first-request responses after iframe startup. Do not erase that failed run. +Inspection found fallback `http://host:7778` versus runtime `http://host:7778/` +changes the raw iframe src during initial state discovery. Canonicalizing the +computed URL prevents this semantically identical destination from reloading. +The new regression observes no reactive iframe-source change for this update, +while real path changes still propagate and cancel prior pending consent. +29 focused routing/session/signer tests pass; final rebuild/redeployment remains +pending. The first browser attempt was also missing the signed-in local marker +and redirected to login; this fixture error was corrected separately. diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index c88c08a4..02bd5093 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -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) { diff --git a/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts b/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts index 15352bd6..04534898 100644 --- a/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts +++ b/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts @@ -49,6 +49,8 @@ vi.mock('../appSession/useNostrBridge', () => ({ consentError: { value: '' }, approveConsent: vi.fn(), denyConsent: vi.fn(), + cancelPending: vi.fn(), + dispose: vi.fn(), }), })) diff --git a/neode-ui/src/views/appSession/__tests__/stableAppUrl.test.ts b/neode-ui/src/views/appSession/__tests__/stableAppUrl.test.ts new file mode 100644 index 00000000..3aa8377f --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/stableAppUrl.test.ts @@ -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('') + }) +}) diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index 675996c3..7e094271 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -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([]) +/** 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,