From 173bf8fc0f86310c71ded96144d20ecac8d14bb9 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 4 Mar 2026 14:38:25 +0000 Subject: [PATCH] fix: harden splash screen timer/listener leaks and origin validation - SplashScreen: track all scheduled timers, clear on unmount (prevents ghost callbacks) - SplashScreen: manage video pause listener lifecycle (add once, remove when done) - SplashScreen: clear videoTimeUpdateInterval on unmount - Chat.vue: validate postMessage origin before accepting ready signal - App.vue: remove shadowed variable re-declaration in onKeyDown Co-Authored-By: Claude Opus 4.6 --- neode-ui/src/App.vue | 2 - neode-ui/src/components/SplashScreen.vue | 78 +++++++++++++++--------- neode-ui/src/views/Chat.vue | 5 ++ 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 9de8b256..484140e9 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -142,8 +142,6 @@ function onKeyDown(e: KeyboardEvent) { } // 's' key activates screensaver when authenticated (skip if typing in input) if (e.key === 's' || e.key === 'S') { - const target = e.target as HTMLElement - const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive) { e.preventDefault() screensaverStore.activate() diff --git a/neode-ui/src/components/SplashScreen.vue b/neode-ui/src/components/SplashScreen.vue index 6c655fe5..86f9674f 100644 --- a/neode-ui/src/components/SplashScreen.vue +++ b/neode-ui/src/components/SplashScreen.vue @@ -191,23 +191,35 @@ const isTypingLine3 = ref(false) const isTypingLine4 = ref(false) const videoElement = ref(null) let introTypingTimeout: ReturnType | null = null +const pendingTimers: ReturnType[] = [] + +function scheduleTimer(fn: () => void, delay: number) { + const id = setTimeout(fn, delay) + pendingTimers.push(id) + return id +} // Ensure video plays continuously from Welcome Noderunner through logo +let videoPauseHandler: ((e: Event) => void) | null = null watch([showWelcome, showLogo], ([welcome, logo]) => { if ((welcome || logo) && videoElement.value) { - // Ensure video is playing and doesn't pause if (videoElement.value.paused) { videoElement.value.play().catch(err => { console.warn('Video autoplay failed:', err) }) } - // Keep video playing - prevent any pauses - videoElement.value.addEventListener('pause', (e) => { - if (welcome || logo) { - e.preventDefault() - videoElement.value?.play() + // Add pause prevention handler once, remove when no longer needed + if (!videoPauseHandler) { + videoPauseHandler = () => { + if ((showWelcome.value || showLogo.value) && videoElement.value) { + videoElement.value.play().catch(() => {}) + } } - }, { once: false }) + videoElement.value.addEventListener('pause', videoPauseHandler) + } + } else if (videoPauseHandler && videoElement.value) { + videoElement.value.removeEventListener('pause', videoPauseHandler) + videoPauseHandler = null } }) @@ -303,40 +315,38 @@ function skipIntro() { isTypingLine4.value = false // Start background fade in at 0.3 opacity when welcome appears - setTimeout(() => { + scheduleTimer(() => { backgroundOpacity.value = 0.3 }, 0) - + // Continue with welcome fade out after typing (2s) + cursor continues (1.5s) + 3 blinks (1.35s) - setTimeout(() => { + scheduleTimer(() => { fadeWelcome.value = true typingWelcome.value = false }, 4850) - + // Show logo - no zoom, just fade - setTimeout(() => { + scheduleTimer(() => { showLogo.value = true - // Keep background at 0.3 opacity during logo display }, 5500) // Hide welcome after logo starts appearing - setTimeout(() => { + scheduleTimer(() => { showWelcome.value = false }, 6000) - + // Fade background to full opacity just before completing (for smooth transition to modal) - setTimeout(() => { + scheduleTimer(() => { backgroundOpacity.value = 1 }, 9000) - - // Complete splash with smooth transition - wait for zoom to complete - setTimeout(() => { - // Add a small delay to ensure smooth transition - setTimeout(() => { - showSplash.value = false - document.body.classList.add('splash-complete') - localStorage.setItem('neode_intro_seen', '1') - emit('complete') + + // Complete splash with smooth transition + scheduleTimer(() => { + scheduleTimer(() => { + showSplash.value = false + document.body.classList.add('splash-complete') + localStorage.setItem('neode_intro_seen', '1') + emit('complete') }, 500) }, 9500) } @@ -409,24 +419,24 @@ function startAlienIntro() { } backgroundOpacity.value = 0.3 - introTypingTimeout = setTimeout(() => { + scheduleTimer(() => { fadeWelcome.value = true typingWelcome.value = false }, 4850) - introTypingTimeout = setTimeout(() => { + scheduleTimer(() => { showLogo.value = true }, 5500) - introTypingTimeout = setTimeout(() => { + scheduleTimer(() => { showWelcome.value = false }, 6000) - introTypingTimeout = setTimeout(() => { + scheduleTimer(() => { backgroundOpacity.value = 1 }, 9000) - introTypingTimeout = setTimeout(() => { + scheduleTimer(() => { if (videoElement.value && !videoElement.value.paused) { sessionStorage.setItem('video_intro_currentTime', videoElement.value.currentTime.toString()) sessionStorage.setItem('video_intro_wasPlaying', 'true') @@ -455,6 +465,14 @@ onBeforeUnmount(() => { clearTimeout(introTypingTimeout) introTypingTimeout = null } + // Clear all scheduled timers to prevent firing on unmounted component + for (const id of pendingTimers) clearTimeout(id) + pendingTimers.length = 0 + // Clear video time update interval + if (videoTimeUpdateInterval) { + clearInterval(videoTimeUpdateInterval) + videoTimeUpdateInterval = null + } }) diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index de841f72..81a51bd1 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -83,6 +83,11 @@ function closeChat() { function onAiuiMessage(event: MessageEvent) { if (!aiuiUrl.value) return + // Validate origin — only accept messages from AIUI + try { + const expected = new URL(aiuiUrl.value, window.location.origin).origin + if (event.origin !== expected) return + } catch { return } const msg = event.data if (msg && msg.type === 'ready') { aiuiConnected.value = true