diff --git a/apps/stage-tamagotchi/src/main/libs/electron/renderer-loop.ts b/apps/stage-tamagotchi/src/main/libs/electron/renderer-loop.ts new file mode 100644 index 000000000..9bc359296 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/libs/electron/renderer-loop.ts @@ -0,0 +1,75 @@ +import type { BrowserWindow } from 'electron' + +import { attemptAsync } from 'es-toolkit' + +import { useLoop } from '../event-loop' + +const rendererDisposedMessage = 'Render frame was disposed before WebFrameMain could be accessed' + +export function isRendererUnavailable(window: BrowserWindow) { + return window.isDestroyed() || window.webContents.isDestroyed() || window.webContents.isCrashed() +} + +export function shouldStopForRendererError(error: unknown) { + if (!(error instanceof Error) || !error.message) { + return false + } + + return error.message.includes(rendererDisposedMessage) +} + +export function stopLoopWhenRendererIsGone(window: BrowserWindow, stop: () => void) { + window.on('closed', stop) + window.webContents.on('destroyed', stop) + window.webContents.on('render-process-gone', stop) +} + +function ensureRendererIsAvailable(window: BrowserWindow, stop: () => void) { + if (isRendererUnavailable(window)) { + stop() + return false + } + + return true +} + +export function createRendererLoop(params: { window: BrowserWindow, run: () => Promise | void, interval?: number, autoStart?: boolean }) { + const { start, stop } = useLoop(async () => { + if (!ensureRendererIsAvailable(params.window, stop)) { + return + } + + const [error] = await attemptAsync(async () => { + await params.run() + }) + + if (!error) { + return + } + + if (shouldStopForRendererError(error)) { + stop() + return + } + + throw error + }, { + autoStart: params.autoStart ?? false, + interval: params.interval, + }) + + stopLoopWhenRendererIsGone(params.window, stop) + + const startLoop = () => { + if (!ensureRendererIsAvailable(params.window, stop)) { + return + } + + start() + } + + return { + start: startLoop, + stop, + } +} diff --git a/apps/stage-tamagotchi/src/main/libs/event-loop/loop.ts b/apps/stage-tamagotchi/src/main/libs/event-loop/loop.ts index efbe69e2c..2668853f4 100644 --- a/apps/stage-tamagotchi/src/main/libs/event-loop/loop.ts +++ b/apps/stage-tamagotchi/src/main/libs/event-loop/loop.ts @@ -1,47 +1,68 @@ -export function useLoop(fn: () => Promise | void, options?: { interval?: number, autoStart?: boolean }) { - let timer: NodeJS.Timeout | null = null +import { clearClockInterval, setClockInterval } from '@moeru/std' +import { Mutex } from 'es-toolkit/promise' + +interface LoopOptions { + interval?: number + autoStart?: boolean +} + +export function useLoop(fn: () => Promise | void, options?: LoopOptions) { + const mutex = new Mutex() + const interval = options?.interval ?? 1000 / 60 + let timerId: number | null = null let shouldRun = options?.autoStart ?? true - const loopIteration = async () => { - if (!shouldRun) { + const tick = async () => { + if (!shouldRun || mutex.isLocked) { return } + await mutex.acquire() try { await fn() } finally { - timer = setTimeout(loopIteration, options?.interval ?? 1000 / 60) // Default to ~60 FPS + mutex.release() } } + const startTimer = () => { + if (!shouldRun || timerId !== null) { + return + } + + timerId = setClockInterval(() => { + void tick() + }, interval) + } + + const stopTimer = () => { + if (timerId === null) { + return + } + + clearClockInterval(timerId) + timerId = null + } + + const toggleRunState = (next: boolean) => { + shouldRun = next + if (shouldRun) { + startTimer() + return + } + + stopTimer() + } + if (shouldRun) { - loopIteration() + startTimer() } return { - start: () => { - shouldRun = true - if (!timer) { - loopIteration() - } - }, - resume: () => { - shouldRun = true - if (!timer) { - loopIteration() - } - }, - pause: () => { - shouldRun = false - }, - stop: () => { - shouldRun = false - - if (timer) { - clearTimeout(timer) - timer = null - } - }, + start: () => toggleRunState(true), + resume: () => toggleRunState(true), + pause: () => toggleRunState(false), + stop: () => toggleRunState(false), } } diff --git a/apps/stage-tamagotchi/src/main/services/electron/screen.ts b/apps/stage-tamagotchi/src/main/services/electron/screen.ts index 42b99fd58..20e27d827 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/screen.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/screen.ts @@ -7,14 +7,15 @@ import { screen } from 'electron' import { cursorScreenPoint, startLoopGetCursorScreenPoint } from '../../../shared/electron/screen' import { electron } from '../../../shared/eventa' import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle' -import { useLoop } from '../../libs/event-loop' +import { createRendererLoop } from '../../libs/electron/renderer-loop' export function createScreenService(params: { context: ReturnType['context'], window: BrowserWindow }) { - const { start, stop } = useLoop(() => { - const dipPos = screen.getCursorScreenPoint() - params.context.emit(cursorScreenPoint, dipPos) - }, { - autoStart: false, + const { start, stop } = createRendererLoop({ + window: params.window, + run: () => { + const dipPos = screen.getCursorScreenPoint() + params.context.emit(cursorScreenPoint, dipPos) + }, }) onAppWindowAllClosed(() => stop()) diff --git a/apps/stage-tamagotchi/src/main/services/electron/window.ts b/apps/stage-tamagotchi/src/main/services/electron/window.ts index b9e0ed2cc..44d6e0855 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/window.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/window.ts @@ -6,18 +6,15 @@ import { defineInvokeHandler } from '@moeru/eventa' import { bounds, startLoopGetBounds } from '../../../shared/electron/window' import { electron } from '../../../shared/eventa' import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle' -import { useLoop } from '../../libs/event-loop' +import { createRendererLoop } from '../../libs/electron/renderer-loop' import { resizeWindowByDelta } from '../../windows/shared/window' export function createWindowService(params: { context: ReturnType['context'], window: BrowserWindow }) { - const { start, stop } = useLoop(() => { - if (params.window.isDestroyed()) { - return - } - - params.context.emit(bounds, params.window.getBounds()) - }, { - autoStart: false, + const { start, stop } = createRendererLoop({ + window: params.window, + run: () => { + params.context.emit(bounds, params.window.getBounds()) + }, }) onAppWindowAllClosed(() => stop()) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f60970927..77e6c1737 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2922,7 +2922,7 @@ importers: version: 66.5.11 '@wxt-dev/module-vue': specifier: ^1.0.3 - version: 1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vue-tsc: specifier: ^3.1.8 version: 3.2.1(typescript@5.9.3) @@ -22392,12 +22392,6 @@ snapshots: vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.25(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': - dependencies: - '@rolldown/pluginutils': 1.0.0-beta.53 - vite: 8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - vue: 3.5.25(typescript@5.9.3) - '@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)': dependencies: '@vitest/browser': 4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) @@ -23144,9 +23138,9 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@wxt-dev/module-vue@1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) wxt: 0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - vite