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 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-04 14:38:25 +00:00
parent 7b56927c3c
commit 173bf8fc0f
3 changed files with 53 additions and 32 deletions

View File

@ -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()

View File

@ -191,23 +191,35 @@ const isTypingLine3 = ref(false)
const isTypingLine4 = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
let introTypingTimeout: ReturnType<typeof setTimeout> | null = null
const pendingTimers: ReturnType<typeof setTimeout>[] = []
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
}
})
</script>

View File

@ -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