mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Fix: SplashScreen MemoryLeak + WebUI Bypass
This commit is contained in:
@@ -1,9 +1,46 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
function getLiveSet(): Set<HTMLCanvasElement> {
|
||||||
|
const g = window as any
|
||||||
|
if (!g.__splashCanvasSet) g.__splashCanvasSet = new Set<HTMLCanvasElement>()
|
||||||
|
return g.__splashCanvasSet as Set<HTMLCanvasElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneSet(set: Set<HTMLCanvasElement>) {
|
||||||
|
for (const el of set) if (!el.isConnected) set.delete(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splashDevRegister(el: HTMLCanvasElement) {
|
||||||
|
const set = getLiveSet()
|
||||||
|
pruneSet(set)
|
||||||
|
set.add(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splashDevUnregister(el: HTMLCanvasElement) {
|
||||||
|
const set = getLiveSet()
|
||||||
|
set.delete(el)
|
||||||
|
pruneSet(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splashDevLiveCount(): number {
|
||||||
|
const set = getLiveSet()
|
||||||
|
pruneSet(set)
|
||||||
|
return set.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splashDevNextMount(): number {
|
||||||
|
const g = window as any
|
||||||
|
g.__splashTotalMounts = (g.__splashTotalMounts ?? 0) + 1
|
||||||
|
return g.__splashTotalMounts as number
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
|
|
||||||
const isTauri = platformService.platform === 'tauri'
|
const isTauri = platformService.platform === 'tauri'
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode?: 'loading' | 'idle' | 'locked'
|
mode?: 'loading' | 'idle' | 'locked'
|
||||||
@@ -80,7 +117,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
|
if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
|
||||||
if (!isTauri) return // no ring animation on web; probe outcome drives exit
|
if (!isTauri) return
|
||||||
animStart = null
|
animStart = null
|
||||||
animPhase = 1
|
animPhase = 1
|
||||||
animFrame = requestAnimationFrame(animateRing)
|
animFrame = requestAnimationFrame(animateRing)
|
||||||
@@ -280,23 +317,87 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DevMetrics {
|
||||||
|
totalMounts: number
|
||||||
|
resizeCount: number
|
||||||
|
stampCount: number
|
||||||
|
mountedAt: number
|
||||||
|
lastResizeAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let devMetrics = $state<DevMetrics | null>(null)
|
||||||
|
let uptimeSecs = $state(0)
|
||||||
|
let devLiveCount = $state(0)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isDev || mode !== 'idle' || !devMetrics) return
|
||||||
|
const start = Date.now()
|
||||||
|
const iv = setInterval(() => { uptimeSecs = Math.floor((Date.now() - start) / 1000) }, 1000)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtUptime(s: number): string {
|
||||||
|
if (s < 60) return `${s}s`
|
||||||
|
return `${Math.floor(s / 60)}m ${s % 60}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgo(ts: number | null): string {
|
||||||
|
if (ts === null) return '—'
|
||||||
|
const s = Math.floor((Date.now() - ts) / 1000)
|
||||||
|
if (s < 60) return `${s}s ago`
|
||||||
|
return `${Math.floor(s / 60)}m ago`
|
||||||
|
}
|
||||||
|
|
||||||
function mountCanvas(el: HTMLCanvasElement) {
|
function mountCanvas(el: HTMLCanvasElement) {
|
||||||
const ctx = el.getContext('2d')!
|
const ctx = el.getContext('2d')!
|
||||||
let live: RenderState | null = null
|
let live: RenderState | null = null
|
||||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0
|
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0
|
||||||
|
|
||||||
|
if (isDev && mode === 'idle') {
|
||||||
|
splashDevRegister(el)
|
||||||
|
devLiveCount = splashDevLiveCount()
|
||||||
|
uptimeSecs = 0
|
||||||
|
devMetrics = {
|
||||||
|
totalMounts: splashDevNextMount(),
|
||||||
|
resizeCount: 0,
|
||||||
|
stampCount: 0,
|
||||||
|
mountedAt: Date.now(),
|
||||||
|
lastResizeAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (live) {
|
||||||
|
live.stamps.forEach(c => { c.width = 0; c.height = 0 })
|
||||||
|
live.vignette.width = 0
|
||||||
|
live.vignette.height = 0
|
||||||
|
live = null
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, el.width, el.height)
|
||||||
|
}
|
||||||
|
|
||||||
function applySize(logW: number, logH: number, scale: number) {
|
function applySize(logW: number, logH: number, scale: number) {
|
||||||
const gen = ++buildGen
|
const gen = ++buildGen
|
||||||
if (logW <= 0 || logH <= 0) return
|
if (logW <= 0 || logH <= 0) return
|
||||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return
|
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return
|
||||||
lastLogW = logW; lastLogH = logH; lastScale = scale
|
lastLogW = logW; lastLogH = logH; lastScale = scale
|
||||||
if (live) cleanup() // release old offscreen canvases before rebuilding at new size
|
if (live) cleanup()
|
||||||
const built = buildCards(logW, logH)
|
const built = buildCards(logW, logH)
|
||||||
const stamps = built.cards.map(c => buildStamp(c, scale))
|
const stamps = built.cards.map(c => buildStamp(c, scale))
|
||||||
const vig = buildVignette(logW, logH, scale)
|
const vig = buildVignette(logW, logH, scale)
|
||||||
el.width = Math.round(logW * scale)
|
el.width = Math.round(logW * scale)
|
||||||
el.height = Math.round(logH * scale)
|
el.height = Math.round(logH * scale)
|
||||||
if (gen === buildGen) live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: el.width, CH: el.height, scale }
|
if (gen === buildGen) {
|
||||||
|
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: el.width, CH: el.height, scale }
|
||||||
|
if (isDev && mode === 'idle' && devMetrics) {
|
||||||
|
devMetrics = {
|
||||||
|
...devMetrics,
|
||||||
|
resizeCount: devMetrics.resizeCount + 1,
|
||||||
|
stampCount: stamps.length,
|
||||||
|
lastResizeAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let extraCleanup: (() => void) | undefined
|
let extraCleanup: (() => void) | undefined
|
||||||
@@ -340,20 +441,6 @@
|
|||||||
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame) }
|
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame) }
|
||||||
function onVis() { document.hidden ? pause() : resume() }
|
function onVis() { document.hidden ? pause() : resume() }
|
||||||
|
|
||||||
// clears all canvas contexts and nulls live state so the GC can collect the offscreen bitmaps
|
|
||||||
function cleanup() {
|
|
||||||
if (live) {
|
|
||||||
live.stamps.forEach(canvas => {
|
|
||||||
const c = canvas.getContext('2d')
|
|
||||||
if (c) c.clearRect(0, 0, canvas.width, canvas.height)
|
|
||||||
})
|
|
||||||
const vigCtx = live.vignette.getContext('2d')
|
|
||||||
if (vigCtx) vigCtx.clearRect(0, 0, live.vignette.width, live.vignette.height)
|
|
||||||
}
|
|
||||||
ctx.clearRect(0, 0, el.width, el.height)
|
|
||||||
live = null
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVis)
|
document.addEventListener('visibilitychange', onVis)
|
||||||
raf = requestAnimationFrame(frame)
|
raf = requestAnimationFrame(frame)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -361,6 +448,10 @@
|
|||||||
cleanup()
|
cleanup()
|
||||||
extraCleanup?.()
|
extraCleanup?.()
|
||||||
document.removeEventListener('visibilitychange', onVis)
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
|
if (isDev && mode === 'idle') {
|
||||||
|
splashDevUnregister(el)
|
||||||
|
devLiveCount = splashDevLiveCount()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -373,6 +464,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if isDev && mode === 'idle' && devMetrics}
|
||||||
|
<div class="dev-overlay">
|
||||||
|
<span class="dev-title">canvas · idle splash</span>
|
||||||
|
<div class="dev-grid">
|
||||||
|
<span class="dev-k">live</span> <span class="dev-v" class:dev-warn={devLiveCount > 1}>{devLiveCount}</span>
|
||||||
|
<span class="dev-k">total mounts</span> <span class="dev-v">{devMetrics.totalMounts}</span>
|
||||||
|
<span class="dev-k">stamps</span> <span class="dev-v">{devMetrics.stampCount}</span>
|
||||||
|
<span class="dev-k">resizes</span> <span class="dev-v">{devMetrics.resizeCount}</span>
|
||||||
|
<span class="dev-k">uptime</span> <span class="dev-v">{fmtUptime(uptimeSecs)}</span>
|
||||||
|
<span class="dev-k">last resize</span> <span class="dev-v">{fmtAgo(devMetrics.lastResizeAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if mode === 'idle'}
|
{#if mode === 'idle'}
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||||
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||||
@@ -465,4 +570,11 @@
|
|||||||
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
|
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
|
||||||
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
||||||
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
||||||
|
|
||||||
|
.dev-overlay { position:absolute; top:12px; left:12px; z-index:10; background:rgba(0,0,0,0.72); border:1px solid rgba(255,255,255,0.10); border-radius:6px; padding:8px 10px; pointer-events:none; backdrop-filter:blur(6px); }
|
||||||
|
.dev-title { display:block; font-family:var(--font-ui); font-size:9px; letter-spacing:0.14em; text-transform:uppercase; color:var(--accent); margin-bottom:6px; }
|
||||||
|
.dev-grid { display:grid; grid-template-columns:auto auto; column-gap:12px; row-gap:2px; }
|
||||||
|
.dev-k { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); white-space:nowrap; }
|
||||||
|
.dev-v { font-family:var(--font-ui); font-size:10px; color:var(--text-secondary); text-align:right; white-space:nowrap; }
|
||||||
|
.dev-warn { color:#f87171; }
|
||||||
</style>
|
</style>
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerSplash() {
|
function triggerSplash() {
|
||||||
|
if (appState.idleSplash) return
|
||||||
splashTriggered = true
|
splashTriggered = true
|
||||||
setTimeout(() => splashTriggered = false, 200)
|
setTimeout(() => splashTriggered = false, 200)
|
||||||
appState.idleSplash = true
|
appState.idleSplash = true
|
||||||
|
|||||||
+28
-63
@@ -7,7 +7,7 @@
|
|||||||
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { applyTheme, mountSystemThemeSync } from '$lib/core/theme'
|
import { applyTheme, mountSystemThemeSync } from '$lib/core/theme'
|
||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
import { initRpc, setIdle, destroyRpc } from '$lib/core/discord'
|
import * as discord from '$lib/core/discord'
|
||||||
import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
|
import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
|
||||||
import AuthGate from '$lib/components/chrome/AuthGate.svelte'
|
import AuthGate from '$lib/components/chrome/AuthGate.svelte'
|
||||||
import Sidebar from '$lib/components/chrome/Sidebar.svelte'
|
import Sidebar from '$lib/components/chrome/Sidebar.svelte'
|
||||||
@@ -24,7 +24,6 @@
|
|||||||
|
|
||||||
const POLL_MS = 1500
|
const POLL_MS = 1500
|
||||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let polling = false
|
let polling = false
|
||||||
|
|
||||||
async function pollLoop() {
|
async function pollLoop() {
|
||||||
@@ -35,13 +34,12 @@
|
|||||||
|
|
||||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
|
||||||
let _splashDismissed = $state(false)
|
let splashDismissed = $state(false)
|
||||||
let bypassed = $state(false)
|
|
||||||
let themeEditorOpen = $state(false)
|
let themeEditorOpen = $state(false)
|
||||||
let themeEditorId = $state<string | null>(null)
|
let themeEditorId = $state<string | null>(null)
|
||||||
|
|
||||||
const splashVisible = $derived(
|
const splashVisible = $derived(
|
||||||
!_splashDismissed ||
|
!splashDismissed ||
|
||||||
appState.status === 'booting' ||
|
appState.status === 'booting' ||
|
||||||
appState.status === 'locked' ||
|
appState.status === 'locked' ||
|
||||||
appState.status === 'error' ||
|
appState.status === 'error' ||
|
||||||
@@ -49,17 +47,16 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
const ringFull = $derived(appState.status === 'ready')
|
const ringFull = $derived(appState.status === 'ready')
|
||||||
|
const showApp = $derived(!splashVisible)
|
||||||
|
|
||||||
const showApp = $derived(
|
function onSplashReady() { splashDismissed = true }
|
||||||
!splashVisible && (
|
function onSplashUnlock() { appState.status = 'ready'; splashDismissed = true }
|
||||||
appState.status === 'ready' ||
|
function onSplashBypass() {
|
||||||
bypassed
|
import('$lib/state/boot.svelte').then(({ bypassBoot }) => {
|
||||||
)
|
bypassBoot(appState.authMode ?? 'NONE', appState.authUser ?? '', appState.authPass ?? '')
|
||||||
)
|
})
|
||||||
|
splashDismissed = true
|
||||||
function onSplashReady() { _splashDismissed = true }
|
}
|
||||||
function onSplashUnlock() { appState.status = 'ready'; _splashDismissed = true }
|
|
||||||
function onSplashBypass() { bypassed = true; _splashDismissed = true }
|
|
||||||
|
|
||||||
const isReaderRoute = $derived($page.url.pathname.startsWith('/reader'))
|
const isReaderRoute = $derived($page.url.pathname.startsWith('/reader'))
|
||||||
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
|
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
|
||||||
@@ -108,24 +105,19 @@
|
|||||||
isTauri && settingsState.settings.autoStartServer ? 2000 : 100,
|
isTauri && settingsState.settings.autoStartServer ? 2000 : 100,
|
||||||
)
|
)
|
||||||
|
|
||||||
let discordInitialized = false
|
|
||||||
if (settingsState.settings.discordRpc) {
|
if (settingsState.settings.discordRpc) {
|
||||||
await initRpc()
|
await discord.initRpc()
|
||||||
await setIdle()
|
await discord.setIdle()
|
||||||
discordInitialized = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
polling = true
|
polling = true
|
||||||
pollLoop()
|
pollLoop()
|
||||||
|
|
||||||
resetIdleTimer()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
polling = false
|
polling = false
|
||||||
if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null }
|
if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null }
|
||||||
if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null }
|
discord.destroyRpc()
|
||||||
if (discordInitialized) destroyRpc().catch(() => {})
|
platformService.destroy()
|
||||||
platformService.destroy().catch(() => {})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -147,49 +139,22 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (appState.status === 'booting') _splashDismissed = false
|
if (appState.status === 'booting') splashDismissed = false
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
let idleSplashLocked = false
|
||||||
if (appState.status === 'ready') resetIdleTimer()
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
function showIdleSplash() {
|
||||||
if (appState.idleSplash && settingsState.settings.discordRpc) setIdle().catch(() => {})
|
if (idleSplashLocked || appState.idleSplash) return
|
||||||
})
|
appState.idleSplash = true
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (appState.status !== 'ready') return
|
|
||||||
// capture phase so events from any component — including modals — reset the timer
|
|
||||||
const onActivity = () => resetIdleTimer()
|
|
||||||
document.addEventListener('mousemove', onActivity, true)
|
|
||||||
document.addEventListener('keydown', onActivity, true)
|
|
||||||
document.addEventListener('touchstart', onActivity, true)
|
|
||||||
// passive:true tells the browser it can render scroll frames without waiting for the handler
|
|
||||||
document.addEventListener('touchmove', onActivity, { capture: true, passive: true })
|
|
||||||
document.addEventListener('wheel', onActivity, { capture: true, passive: true })
|
|
||||||
document.addEventListener('click', onActivity, true)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', onActivity, true)
|
|
||||||
document.removeEventListener('keydown', onActivity, true)
|
|
||||||
document.removeEventListener('touchstart', onActivity, true)
|
|
||||||
document.removeEventListener('touchmove', onActivity, { capture: true })
|
|
||||||
document.removeEventListener('wheel', onActivity, { capture: true })
|
|
||||||
document.removeEventListener('click', onActivity, true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function resetIdleTimer() {
|
|
||||||
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null }
|
|
||||||
// 0 means "Never" — skip the timer entirely
|
|
||||||
const mins = settingsState.settings.idleTimeoutMin ?? 5
|
|
||||||
if (mins === 0) return
|
|
||||||
idleTimer = setTimeout(() => {
|
|
||||||
if (appState.status === 'ready') appState.idleSplash = true
|
|
||||||
}, mins * 60_000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onIdleDismiss() { appState.idleSplash = false; resetIdleTimer() }
|
function onIdleDismiss() {
|
||||||
|
if (idleSplashLocked) return
|
||||||
|
idleSplashLocked = true
|
||||||
|
appState.idleSplash = false
|
||||||
|
setTimeout(() => { idleSplashLocked = false }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
function onSplashRetry() {
|
function onSplashRetry() {
|
||||||
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
||||||
@@ -219,7 +184,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if appState.idleSplash}
|
{#if appState.idleSplash}
|
||||||
<SplashScreen mode="idle" onDismiss={onIdleDismiss} />
|
<SplashScreen mode="idle" showCards={settingsState.settings.splashCards ?? true} onDismiss={onIdleDismiss} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showApp}
|
{#if showApp}
|
||||||
|
|||||||
Reference in New Issue
Block a user