Keep slow app sessions alive and defer automatic companion prompts

This commit is contained in:
archipelago
2026-10-06 12:55:47 -04:00
parent a3f0bf0af7
commit a49d4d7128
10 changed files with 367 additions and 19 deletions
@@ -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<typeof setInterval> | null = null
let baseDelay: ReturnType<typeof setTimeout> | 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 {
@@ -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)
})
})
+34 -10
View File
@@ -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"
/>
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
@@ -162,6 +164,8 @@ const frameRef = ref<InstanceType<typeof AppSessionFrame> | 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)
}
+1 -1
View File
@@ -115,7 +115,7 @@
<HealthNotifications />
<!-- First-use companion intro overlay -->
<CompanionIntroOverlay />
<CompanionIntroOverlay :defer-automatic="!!appLauncher.panelAppId || appLauncher.isOpen || route.name === 'app-session'" />
</div>
</template>
@@ -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: '<div><iframe v-if="!iframeBlocked && !uiReadyBlocked" :key="refreshKey" /></div>' }, NostrIdentityPicker: true, NostrSignConsent: true, MobileGamepad: true, AppLoadingScreen: true } } })
return wrapper.getComponent({ name: 'AppSessionFrame' })
}
function readiness(value: boolean) { useAppStore().data!['package-data'].indeedhub!['ui-ready'] = value }
it('keeps a slow iframe alive past the deadline and accepts its eventual load', async () => {
const frame = render(), original = frame.get('iframe').element
await vi.advanceTimersByTimeAsync(13000); await nextTick()
expect(frame.props('iframeBlocked')).toBe(false)
expect(frame.props('slowLoad')).toBe(true)
expect(frame.get('iframe').element).toBe(original)
frame.vm.$emit('iframeLoad'); await nextTick()
expect(frame.props('slowLoad')).toBe(false)
expect(frame.props('loading')).toBe(false)
expect(frame.get('iframe').element).toBe(original)
})
it('retains a loaded app and frame key through a transient readiness failure', async () => {
const frame = render(); frame.vm.$emit('iframeLoad'); await nextTick()
const original = frame.get('iframe').element, key = frame.props('refreshKey')
readiness(false); await nextTick()
expect(frame.props('uiReadyBlocked')).toBe(false)
expect(frame.get('iframe').element).toBe(original)
readiness(true); await nextTick()
expect(frame.get('iframe').element).toBe(original)
expect(frame.props('refreshKey')).toBe(key)
})
it('still gates a cold app, then starts it once readiness arrives', async () => {
readiness(false); const frame = render()
expect(frame.find('iframe').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(13000); await nextTick()
expect(frame.props('slowLoad')).toBe(false)
readiness(true); await nextTick()
expect(frame.find('iframe').exists()).toBe(true)
expect(frame.props('loading')).toBe(true)
})
it('an explicit refresh rechecks readiness instead of bypassing a cold app', async () => {
const frame = render(); frame.vm.$emit('iframeLoad'); await nextTick()
readiness(false); await nextTick()
frame.vm.$emit('refresh'); await nextTick()
expect(frame.props('uiReadyBlocked')).toBe(true)
expect(frame.find('iframe').exists()).toBe(false)
})
})
@@ -61,6 +61,15 @@
/>
</div>
<div v-if="slowLoad && !iframeBlocked && !uiReadyBlocked" role="status"
class="absolute top-3 left-3 right-3 z-10 flex items-center gap-2 rounded-lg border border-white/10 bg-black/80 px-3 py-2 text-xs text-white/75 backdrop-blur-md">
<span class="min-w-0 flex-1">{{ appTitle }} is taking longer to load. You can keep waiting.</span>
<button type="button" class="shrink-0 rounded px-2 py-1 text-white hover:bg-white/10" @click="$emit('refresh')">Retry</button>
<button type="button" class="shrink-0 rounded p-1 text-white/60 hover:bg-white/10" aria-label="Dismiss loading notice" @click="$emit('dismissSlowLoad')">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M18 6 6 18" /></svg>
</button>
</div>
<!-- Iframe blocked fallback. Suppressed while the ElectrumX sync screen
(the "pre UI") is showing: a still-syncing Electrum server isn't
reachable yet, so the "App not reachable / retry" overlay would just
@@ -136,6 +145,7 @@ const props = defineProps<{
autoRetryCount: number
refreshKey: number
uiReadyBlocked?: boolean
slowLoad?: boolean
blockedReason?: string
blockedTitle?: string
// True while the container is up but its probe hasn't answered yet and the
@@ -151,6 +161,7 @@ const emit = defineEmits<{
iframeError: []
refresh: []
openNewTabAndBack: []
dismissSlowLoad: []
}>()
const iframeRef = ref<HTMLIFrameElement | null>(null)
@@ -68,6 +68,19 @@ describe('AppSessionFrame warm-up state', () => {
})
describe('HTTP readiness gate', () => {
it('keeps the same iframe while reporting a slow load and lets the user dismiss the notice', async () => {
const frame = mountFrame({ iframeBlocked: false, slowLoad: false });
const original = frame.get('iframe').element;
await frame.setProps({ slowLoad: true });
expect(frame.get('iframe').element).toBe(original);
expect(frame.get('[role="status"]').text()).toContain('Bitcoin is taking longer to load');
await frame.get('[aria-label="Dismiss loading notice"]').trigger('click');
expect(frame.emitted('dismissSlowLoad')).toHaveLength(1);
await frame.setProps({ slowLoad: false });
expect(frame.get('iframe').element).toBe(original);
expect(frame.find('[role="status"]').exists()).toBe(false);
frame.unmount();
})
it('does not show a missing-configuration error during initial installation', () => {
const frame = mountFrame({ appUrl: '', uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…' })
expect(frame.text()).not.toContain('App not configured')