diff --git a/docs/post-1.9.0-progress-20261006.md b/docs/post-1.9.0-progress-20261006.md index 11f8e453..9b76b2af 100644 --- a/docs/post-1.9.0-progress-20261006.md +++ b/docs/post-1.9.0-progress-20261006.md @@ -708,3 +708,36 @@ RPC tests and the complete isolated suite (**1,749 pass, zero fail, five existin ignores**) pass. No real payment or live wallet mutation was used. This is not yet in the running backend. Durable pre-mint purchase journaling and seller receipt recovery remain open in [the recovery follow-up](paid-content-recovery-followup.md). + +## Payment selection deployed; slow-app launch regression under qualification + +The production backend at `a3f0bf0a` built successfully and is deployed on dev +and Yaya. SHA256 `2c881f68592f7a395cc53c641cc936217426893a7f9f994253026ce36cda79f3`. +Both nodes pass health and authenticated RPC; persistent session keys, UI and +all app container identities/start times remained unchanged. Rollback paths are +recorded in the private artifact receipt and `/tmp/archy-payment-selection-{dev,yaya}-deploy.log`. +No payment was made for this deployment. This closes backend-selection rollout, +not the durable initial-payment recovery gate. + +Actual Yaya mobile-width IndeeHub native login returned session201 and profile200, +and retained login after reload (`/tmp/archy-indeehub-live-login.log`). A broader +attempt then failed on dev/Yaya: the 12-second AppSession load timeout destroys +the iframe, and the automatic companion introduction can cover login. These +failed runs are retained, not counted as acceptance. Source now preserves a +slow iframe, offers a compact dismissible notice, retains a loaded app through +transient readiness failure, and defers automatic companion prompts while an +app is active. Four parent lifecycle tests and the frame/intro tests pass; +full-suite/build and actual served-browser checks are still in progress. + +V4V preparation on Yaya is complete without replacing the original Portainer +app: consistent private backup of 28 data files and 170 media files, independently +populated managed volumes, preserved existing password hash and pinned managed +image pulled. The original container and volumes remain the rollback path. The +single-node catalog awaits the operator root signature at +`/tmp/archy-yaya-v4v-catalog.json`; no managed app installation is claimed yet. + +Launcher qualification update: production build passes. The first full UI run +passed 1,299 tests but timed out in a Bitcoin modal case and then failed its next +case; all 20 targeted tests including both modal cases pass on rerun. A new full +run with reduced concurrency and no simultaneous build is required and running. +No failing full run is recorded as passed. diff --git a/neode-ui/src/components/CompanionIntroOverlay.vue b/neode-ui/src/components/CompanionIntroOverlay.vue index 0a00fadb..18dcfbba 100644 --- a/neode-ui/src/components/CompanionIntroOverlay.vue +++ b/neode-ui/src/components/CompanionIntroOverlay.vue @@ -153,6 +153,8 @@ import { useLoginTransitionStore } from '@/stores/loginTransition' import { useServerStore } from '@/stores/server' import { rpcClient } from '@/api/rpc-client' +const props = defineProps<{ deferAutomatic?: boolean }>() + const STORAGE_KEY = 'neode_companion_intro_seen' // Absolute URL so the QR works when scanned by a phone (a relative path has no // host to resolve). Points at the companion APK on the release server's https @@ -200,6 +202,16 @@ const BASE_DELAY_MS = 5000 const POST_INTRO_GRACE_MS = 2000 let calmTicker: ReturnType | null = null +let baseDelay: ReturnType | null = null +let disposed = false +let automaticDisplay = false + +function clearAutomaticTimers() { + if (baseDelay) clearTimeout(baseDelay) + if (calmTicker) clearInterval(calmTicker) + baseDelay = null + calmTicker = null +} // Running inside the companion app's own WebView (it injects the JS bridge — // detected with the canonical helper, not a raw window check, so the gate @@ -213,9 +225,10 @@ onMounted(async () => { // The prompt is remembered per APK build, not forever. A browser that saw // 0.5.28 should be told once when this node begins serving 0.5.32. await loadCompanionRelease() + if (disposed) return try { if (localStorage.getItem(STORAGE_KEY) !== companionReleaseMarker(companionVersion.value)) { - setTimeout(maybeShow, BASE_DELAY_MS) + baseDelay = setTimeout(maybeShow, BASE_DELAY_MS) } } catch { // localStorage unavailable @@ -223,18 +236,21 @@ onMounted(async () => { }) onUnmounted(() => { - if (calmTicker) clearInterval(calmTicker) + disposed = true + clearAutomaticTimers() }) function maybeShow() { + if (disposed) return + clearAutomaticTimers() // Show only after the scene has been CONTINUOUSLY calm (no reveal // cinematic) for the full grace window. The previous point-in-time check // raced a slow-starting reveal — on a cold cache the entrance video can // begin buffering after the 5s base delay, so the flag was still false // when sampled and the popup cut straight into the cinematic. - let calmSince = loginTransition.introCinematicPlaying ? null : Date.now() + let calmSince = loginTransition.introCinematicPlaying || props.deferAutomatic ? null : Date.now() calmTicker = setInterval(() => { - if (loginTransition.introCinematicPlaying) { + if (loginTransition.introCinematicPlaying || props.deferAutomatic) { calmSince = null return } @@ -242,11 +258,21 @@ function maybeShow() { if (Date.now() - calmSince >= POST_INTRO_GRACE_MS) { if (calmTicker) clearInterval(calmTicker) calmTicker = null + automaticDisplay = true visible.value = true } }, 250) } +// A route change can open an app after the automatic offer was displayed. +// Defer that offer until the app closes; never cover its identity/consent UI. +watch(() => props.deferAutomatic, active => { + if (active && visible.value && automaticDisplay) { + visible.value = false + maybeShow() + } +}) + // Manual open (App Store banner etc.) — ignores the once-per-browser gate. // The trigger itself is already a no-op inside the companion (useCompanionIntro), // and this watcher refuses to open there too, so no caller can ever pop the @@ -255,10 +281,8 @@ watch(companionIntroRequested, (requested) => { if (!requested) return companionIntroRequested.value = false if (IN_COMPANION_APP) return - if (calmTicker) { - clearInterval(calmTicker) - calmTicker = null - } + clearAutomaticTimers() + automaticDisplay = false step.value = 'download' visible.value = true }) @@ -449,6 +473,7 @@ function showDownloadScreen() { } function dismiss() { + clearAutomaticTimers() visible.value = false step.value = 'download' try { diff --git a/neode-ui/src/components/__tests__/CompanionIntroDeferral.test.ts b/neode-ui/src/components/__tests__/CompanionIntroDeferral.test.ts new file mode 100644 index 00000000..9101d34a --- /dev/null +++ b/neode-ui/src/components/__tests__/CompanionIntroDeferral.test.ts @@ -0,0 +1,41 @@ +import { mount, type VueWrapper } from '@vue/test-utils' +import { nextTick } from 'vue' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import CompanionIntroOverlay from '../CompanionIntroOverlay.vue' +import { companionIntroRequested } from '@/composables/useCompanionIntro' +vi.mock('qrcode', () => ({ toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,fixture') })) +vi.mock('@/utils/openExternal', () => ({ isCompanionApp: () => false })) +vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false, DEMO_PASSWORD: '' })) +vi.mock('@/composables/useCompanionRelease', async () => { + const { ref } = await import('vue') + return { companionRelease: ref({ versionName: 'fixture', versionCode: 1 }), companionReleaseMarker: () => 'fixture', loadCompanionRelease: vi.fn().mockResolvedValue(undefined) } +}) +vi.mock('@/stores/loginTransition', () => ({ useLoginTransitionStore: () => ({ introCinematicPlaying: false }) })) +vi.mock('@/stores/server', () => ({ useServerStore: () => ({}) })) +vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) +describe('automatic companion offer during app use', () => { + let wrapper: VueWrapper + beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); companionIntroRequested.value = false }) + afterEach(() => { wrapper?.unmount(); vi.useRealTimers() }) + function render(deferAutomatic = false) { wrapper = mount(CompanionIntroOverlay, { props: { deferAutomatic }, global: { stubs: { Teleport: true, Transition: false } } }) } + it('waits until the app closes before showing its automatic offer', async () => { + render(true); await nextTick(); await vi.advanceTimersByTimeAsync(10000) + expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false) + await wrapper.setProps({ deferAutomatic: false }); await vi.advanceTimersByTimeAsync(2500) + expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(true) + await wrapper.setProps({ deferAutomatic: true }); await vi.advanceTimersByTimeAsync(3000) + expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false) + }) + it('honors manual opening while an app is active, without reopening after dismissal', async () => { + render(true); await nextTick() + companionIntroRequested.value = true; await nextTick() + await wrapper.get('[aria-label="Close companion modal"]').trigger('click') + await vi.advanceTimersByTimeAsync(12000) + expect(wrapper.find('[aria-label="Close companion modal"]').exists()).toBe(false) + }) + it('clears deferred timers when the dashboard is unmounted', async () => { + render(true); await nextTick(); await vi.advanceTimersByTimeAsync(1000) + wrapper.unmount(); await vi.advanceTimersByTimeAsync(15000) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 02bd5093..80a3cc41 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -39,7 +39,8 @@ :must-open-new-tab="mustOpenNewTab" :auto-retry-count="autoRetryCount" :refresh-key="refreshKey" - :ui-ready-blocked="packageEntry?.['ui-ready'] === false" + :ui-ready-blocked="uiReadyBlocked" + :slow-load="slowLoad" :blocked-reason="blockedReason" :blocked-title="blockedTitle" :warming-up="warmingUp" @@ -48,6 +49,7 @@ @iframe-error="onError" @refresh="refresh" @open-new-tab-and-back="openNewTabAndBack" + @dismiss-slow-load="slowLoad = false" /> @@ -162,6 +164,8 @@ const frameRef = ref | null>(null) const loading = ref(true) const isRefreshing = ref(false) const iframeBlocked = ref(false) +const slowLoad = ref(false) +const loadedAppUrl = ref('') const refreshKey = ref(0) const showIdentityPicker = ref(false) const autoRetryCount = ref(0) @@ -224,7 +228,7 @@ const mustOpenNewTab = computed(() => (IS_DEMO && isDemoExternal(appId.value)) ) -// The auto-tab detector: the load-timeout marked the frame blocked, the +// The auto-tab detector: an actual frame error marked the frame blocked, the // warming-up retry loop has given up, and the backend says the app is // actually RUNNING — that combination is the embed-refusal signature (a // down app is "warming up" or shows a blocked reason instead). Remember it @@ -270,6 +274,12 @@ const appUrl = computed(() => { return canonicalAppUrl(resolveAppUrl(appId.value, deepPath, runtimeUrl)) }) +// Readiness protects a first load. A transient background probe must not tear +// down an already loaded login, upload or player and discard its session state. +const uiReadyBlocked = computed(() => + packageEntry.value?.['ui-ready'] === false && loadedAppUrl.value !== appUrl.value +) + function closeRouteSession() { const fallback = route.query.returnTo const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard') @@ -292,7 +302,11 @@ const nostrBridge = useNostrBridge(identity.getStoredIdentity, { }) // An actual destination change invalidates consent queued for the previous app page. -watch(appUrl, () => nostrBridge.cancelPending()) +watch(appUrl, () => { + loadedAppUrl.value = '' + slowLoad.value = false + nostrBridge.cancelPending() +}) // --- Display mode --- @@ -385,13 +399,13 @@ const panelClasses = computed(() => { // A cold/restarting upstream is held outside the iframe. Start one fresh // load when the scanner observes HTTP readiness; no manual refresh required. -watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => { - if (ready === false) { +watch(uiReadyBlocked, (blocked, previous) => { + if (blocked) { if (loadTimeoutId) clearTimeout(loadTimeoutId) if (autoRetryId) clearTimeout(autoRetryId) if (iframeCheckId) clearTimeout(iframeCheckId) loading.value = false - } else if (previous === false && ready === true) { + } else if (previous === true && !blocked) { autoRetryCount.value = 0 refresh() } @@ -400,6 +414,9 @@ watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => { // --- Lifecycle handlers --- function onLoad() { + loadedAppUrl.value = appUrl.value + slowLoad.value = false + iframeBlocked.value = false mediaBridge.connect() if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null } if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null } @@ -432,6 +449,8 @@ function onLoad() { } function onError() { + loadedAppUrl.value = '' + slowLoad.value = false if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null } loading.value = false isRefreshing.value = false @@ -446,6 +465,8 @@ function onError() { } function refresh() { + loadedAppUrl.value = '' + slowLoad.value = false if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null } isRefreshing.value = true loading.value = true @@ -456,23 +477,26 @@ function refresh() { function startLoadTimeout() { if (loadTimeoutId) clearTimeout(loadTimeoutId) - if (packageEntry.value?.['ui-ready'] === false) return + if (uiReadyBlocked.value) return loadTimeoutId = setTimeout(() => { if (loading.value) { loading.value = false - iframeBlocked.value = true + isRefreshing.value = false + // Removing the iframe here aborted slow navigation before its load event + // could recover it. Keep the actual frame and offer non-blocking feedback. + slowLoad.value = true } }, 12000) } function openNewTabAndBack() { - if (packageEntry.value?.['ui-ready'] === false) return + if (uiReadyBlocked.value) return if (appUrl.value) openExternalUrl(appUrl.value) closeSession() } function openNewTab() { - if (packageEntry.value?.['ui-ready'] === false) return + if (uiReadyBlocked.value) return if (appUrl.value) openExternalUrl(appUrl.value) } diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index 8559b69e..d1b2f56a 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -115,7 +115,7 @@ - + diff --git a/neode-ui/src/views/__tests__/AppSessionSlowLoad.test.ts b/neode-ui/src/views/__tests__/AppSessionSlowLoad.test.ts new file mode 100644 index 00000000..a647ea83 --- /dev/null +++ b/neode-ui/src/views/__tests__/AppSessionSlowLoad.test.ts @@ -0,0 +1,70 @@ +import { mount, type VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { nextTick } from 'vue' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import AppSession from '../AppSession.vue' +import { useAppStore } from '@/stores/app' +vi.mock('vue-router', () => ({ useRoute: () => ({ params: { appId: 'indeedhub' }, query: {}, fullPath: '/dashboard/app-session/indeedhub' }), useRouter: () => ({ replace: vi.fn(() => Promise.resolve()), push: vi.fn(() => Promise.resolve()) }) })) +vi.mock('@/stores/appLauncher', () => ({ useAppLauncherStore: () => ({ panelAppId: null }) })) +vi.mock('@/stores/app', async () => { + const { reactive } = await import('vue') + const state = reactive({ data: { 'package-data': {} } }) + return { useAppStore: () => state } +}) +vi.mock('@/stores/screensaver', () => ({ useScreensaverStore: () => ({ suppress: vi.fn(), resume: vi.fn() }) })) +vi.mock('../appSession/useAppIdentity', () => ({ useAppIdentity: () => ({ onIdentitySelected: vi.fn(), onIframeLoadIdentity: vi.fn(), handleIdentityRequest: vi.fn(), getStoredIdentity: () => null, cancelIdentitySelection: vi.fn() }) })) +vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) +describe('AppSession slow navigation and readiness changes', () => { + let wrapper: VueWrapper + beforeEach(() => { + vi.useFakeTimers(); setActivePinia(createPinia()); localStorage.clear() + Object.defineProperty(window, 'innerWidth', { value: 390, configurable: true }) + Object.defineProperty(window, 'location', { value: { hostname: '192.0.2.10', origin: 'http://192.0.2.10', protocol: 'http:' }, configurable: true }) + useAppStore().data = { 'package-data': { indeedhub: { state: 'running', 'ui-ready': true, manifest: { id: 'indeedhub', title: 'IndeeHub' } } } } as never + }) + afterEach(() => { wrapper?.unmount(); vi.useRealTimers() }) + function render() { + // Keep real Teleport: its test stub freezes the dynamic slot props here. + // Control frame load events separately; AppSessionFrame tests cover its DOM. + wrapper = mount(AppSession, { global: { stubs: { Teleport: false, AppSessionHeader: true, AppSessionFrame: { name: 'AppSessionFrame', props: ['appUrl','loading','iframeBlocked','slowLoad','uiReadyBlocked','refreshKey'], emits: ['iframeLoad','iframeError','refresh'], template: '