From 715f99bfdd5da30b3bdad59dab9be609b4c023f6 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Fri, 27 Feb 2026 10:37:44 +0800 Subject: [PATCH] feat(electron-vueuse): new package --- packages/electron-vueuse/README.md | 25 ++++ packages/electron-vueuse/package.json | 48 +++++++ .../composables/use-electron-all-displays.ts | 17 +++ .../composables/use-electron-auto-updater.ts | 52 +++++++ .../use-electron-eventa-context.ts | 40 ++++++ ...use-electron-mouse-around-window-border.ts | 62 ++++++++ .../use-electron-mouse-in-element.ts | 135 ++++++++++++++++++ .../use-electron-mouse-in-window.ts | 9 ++ .../src/composables/use-electron-mouse.ts | 36 +++++ .../use-electron-relative-mouse.ts | 21 +++ .../composables/use-electron-window-bounds.ts | 44 ++++++ .../composables/use-electron-window-resize.ts | 44 ++++++ packages/electron-vueuse/src/index.ts | 25 ++++ packages/electron-vueuse/src/main/index.ts | 8 ++ packages/electron-vueuse/src/main/loop.ts | 68 +++++++++ .../electron-vueuse/src/main/renderer-loop.ts | 75 ++++++++++ packages/electron-vueuse/tsconfig.json | 19 +++ packages/electron-vueuse/tsdown.config.ts | 11 ++ 18 files changed, 739 insertions(+) create mode 100644 packages/electron-vueuse/README.md create mode 100644 packages/electron-vueuse/package.json create mode 100644 packages/electron-vueuse/src/composables/use-electron-all-displays.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-auto-updater.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-eventa-context.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-mouse-around-window-border.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-mouse-in-element.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-mouse-in-window.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-mouse.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-relative-mouse.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-window-bounds.ts create mode 100644 packages/electron-vueuse/src/composables/use-electron-window-resize.ts create mode 100644 packages/electron-vueuse/src/index.ts create mode 100644 packages/electron-vueuse/src/main/index.ts create mode 100644 packages/electron-vueuse/src/main/loop.ts create mode 100644 packages/electron-vueuse/src/main/renderer-loop.ts create mode 100644 packages/electron-vueuse/tsconfig.json create mode 100644 packages/electron-vueuse/tsdown.config.ts diff --git a/packages/electron-vueuse/README.md b/packages/electron-vueuse/README.md new file mode 100644 index 000000000..6f27f5dd7 --- /dev/null +++ b/packages/electron-vueuse/README.md @@ -0,0 +1,25 @@ +# @proj-airi/electron-vueuse + +VueUse-like composables and helpers shared across AIRI Electron apps. + +## What it provides + +- Renderer composables for common Electron behaviors (`mouse`, `window bounds`, `auto updater`, etc.) +- A reusable Eventa context/invoke pattern (`useElectronEventaContext`, `useElectronEventaInvoke`) +- Eventa context/invoke ergonomics for renderer code +- Main-process loop utilities (`useLoop`, `createRendererLoop`) + +For IPC contract definitions, use `@proj-airi/electron-eventa`. + +## Usage + +```ts +import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse' +import { electron } from '@proj-airi/electron-eventa' + +const openSettings = useElectronEventaInvoke(electron.window.getBounds) +``` + +```ts +import { createRendererLoop } from '@proj-airi/electron-vueuse/main' +``` diff --git a/packages/electron-vueuse/package.json b/packages/electron-vueuse/package.json new file mode 100644 index 000000000..79288c0c9 --- /dev/null +++ b/packages/electron-vueuse/package.json @@ -0,0 +1,48 @@ +{ + "name": "@proj-airi/electron-vueuse", + "type": "module", + "version": "0.8.5-beta.3", + "private": true, + "description": "VueUse-like composables and helpers for Electron apps", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/moeru-ai/airi.git", + "directory": "packages/electron-vueuse" + }, + "exports": { + ".": "./dist/index.mjs", + "./main": "./dist/main/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "README.md", + "dist", + "package.json" + ], + "scripts": { + "dev": "pnpm run build", + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "electron": ">=39 <41", + "vue": ">=3" + }, + "dependencies": { + "@moeru/eventa": "^1.0.0-beta.1", + "@moeru/std": "catalog:", + "@vueuse/core": "catalog:", + "es-toolkit": "catalog:", + "std-env": "catalog:" + }, + "devDependencies": { + "@proj-airi/electron-eventa": "workspace:^" + } +} diff --git a/packages/electron-vueuse/src/composables/use-electron-all-displays.ts b/packages/electron-vueuse/src/composables/use-electron-all-displays.ts new file mode 100644 index 000000000..a7dec1d2d --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-all-displays.ts @@ -0,0 +1,17 @@ +import { defineInvoke } from '@moeru/eventa' +import { electron } from '@proj-airi/electron-eventa' +import { useAsyncState, useIntervalFn } from '@vueuse/core' + +import { useElectronEventaContext } from './use-electron-eventa-context' + +export function useElectronAllDisplays() { + const context = useElectronEventaContext() + const getAllDisplays = defineInvoke(context.value, electron.screen.getAllDisplays) + const { state: allDisplays, execute } = useAsyncState(() => getAllDisplays(), []) + + useIntervalFn(() => { + void execute() + }, 5000) + + return allDisplays +} diff --git a/packages/electron-vueuse/src/composables/use-electron-auto-updater.ts b/packages/electron-vueuse/src/composables/use-electron-auto-updater.ts new file mode 100644 index 000000000..162005dc8 --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-auto-updater.ts @@ -0,0 +1,52 @@ +import type { AutoUpdaterState } from '@proj-airi/electron-eventa/electron-updater' + +import { + autoUpdater, + electronAutoUpdaterStateChanged, +} from '@proj-airi/electron-eventa/electron-updater' +import { computed, onMounted, ref } from 'vue' + +import { useElectronEventaContext, useElectronEventaInvoke } from './use-electron-eventa-context' + +export function useElectronAutoUpdater() { + const context = useElectronEventaContext() + + const state = ref({ status: 'idle' }) + + const getState = useElectronEventaInvoke(autoUpdater.getState, context.value) + const checkForUpdates = useElectronEventaInvoke(autoUpdater.checkForUpdates, context.value) + const downloadUpdate = useElectronEventaInvoke(autoUpdater.downloadUpdate, context.value) + const quitAndInstall = useElectronEventaInvoke(autoUpdater.quitAndInstall, context.value) + + const isBusy = computed(() => ['checking', 'downloading'].includes(state.value.status)) + const canDownload = computed(() => state.value.status === 'available') + const canRestartToUpdate = computed(() => state.value.status === 'downloaded') + + onMounted(async () => { + try { + const current = await getState() + if (current) + state.value = current + } + catch {} + + try { + context.value.on(electronAutoUpdaterStateChanged, (evt) => { + if (evt.body) + state.value = evt.body + }) + } + catch {} + }) + + return { + state, + isBusy, + canDownload, + canRestartToUpdate, + + checkForUpdates, + downloadUpdate, + quitAndInstall, + } +} diff --git a/packages/electron-vueuse/src/composables/use-electron-eventa-context.ts b/packages/electron-vueuse/src/composables/use-electron-eventa-context.ts new file mode 100644 index 000000000..10a2fe40b --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-eventa-context.ts @@ -0,0 +1,40 @@ +import type { InvokeEventa } from '@moeru/eventa' + +import { defineInvoke } from '@moeru/eventa' +import { createContext } from '@moeru/eventa/adapters/electron/renderer' +import { ref } from 'vue' + +type EventaContext = ReturnType['context'] +type IpcRendererLike = Parameters[0] + +let sharedContext: EventaContext | undefined + +function resolveIpcRenderer(ipcRenderer?: IpcRendererLike): IpcRendererLike { + if (ipcRenderer) { + return ipcRenderer + } + + const globalIpcRenderer = (globalThis as { window?: { electron?: { ipcRenderer?: IpcRendererLike } } }).window?.electron?.ipcRenderer + if (!globalIpcRenderer) { + throw new Error('Electron ipcRenderer is not available. Pass it explicitly to useElectronEventaContext().') + } + + return globalIpcRenderer +} + +export function getElectronEventaContext(ipcRenderer?: IpcRendererLike): EventaContext { + sharedContext ??= createContext(resolveIpcRenderer(ipcRenderer)).context + return sharedContext +} + +export function useElectronEventaContext(ipcRenderer?: IpcRendererLike) { + return ref(getElectronEventaContext(ipcRenderer)) +} + +export function useElectronEventaInvoke(invoke: InvokeEventa, context?: EventaContext) { + return defineInvoke(context ?? getElectronEventaContext(), invoke) +} + +export function resetElectronEventaContextForTesting() { + sharedContext = undefined +} diff --git a/packages/electron-vueuse/src/composables/use-electron-mouse-around-window-border.ts b/packages/electron-vueuse/src/composables/use-electron-mouse-around-window-border.ts new file mode 100644 index 000000000..e49b5a67b --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-mouse-around-window-border.ts @@ -0,0 +1,62 @@ +import { computed } from 'vue' + +import { useElectronRelativeMouse } from './use-electron-relative-mouse' +import { useElectronWindowBounds } from './use-electron-window-bounds' + +export interface UseElectronMouseAroundWindowBorderOptions { + /** Pixel distance from the window edge to consider as "near". */ + threshold?: number + /** Allow a small overshoot outside the window and still count as near. Defaults to threshold. */ + overshoot?: number +} + +/** + * Detect when the cursor is near the window border using window-relative mouse coords. + * Fast path: no extra listeners; reuses existing mouse and window bounds streams. + */ +export function useElectronMouseAroundWindowBorder( + options: UseElectronMouseAroundWindowBorderOptions = {}, +) { + const threshold = options.threshold ?? 8 + const overshoot = options.overshoot ?? threshold + + const { x, y } = useElectronRelativeMouse() + const { width, height } = useElectronWindowBounds() + + // Helpers to determine proximity to each edge. We allow a small overshoot so + // users hovering slightly outside still get feedback to find the edge. + const nearLeft = computed(() => Math.abs(x.value) <= threshold && y.value > -overshoot && y.value < height.value + overshoot) + const nearRight = computed(() => Math.abs(x.value - width.value) <= threshold && y.value > -overshoot && y.value < height.value + overshoot) + const nearTop = computed(() => Math.abs(y.value) <= threshold && x.value > -overshoot && x.value < width.value + overshoot) + const nearBottom = computed(() => Math.abs(y.value - height.value) <= threshold && x.value > -overshoot && x.value < width.value + overshoot) + + const nearTopLeft = computed(() => nearTop.value && nearLeft.value) + const nearTopRight = computed(() => nearTop.value && nearRight.value) + const nearBottomLeft = computed(() => nearBottom.value && nearLeft.value) + const nearBottomRight = computed(() => nearBottom.value && nearRight.value) + + const isNearAnyBorder = computed(() => + nearLeft.value + || nearRight.value + || nearTop.value + || nearBottom.value, + ) + + return { + x, + y, + width, + height, + nearLeft, + nearRight, + nearTop, + nearBottom, + nearTopLeft, + nearTopRight, + nearBottomLeft, + nearBottomRight, + isNearAnyBorder, + } +} + +export type UseElectronMouseAroundWindowBorderReturn = ReturnType diff --git a/packages/electron-vueuse/src/composables/use-electron-mouse-in-element.ts b/packages/electron-vueuse/src/composables/use-electron-mouse-in-element.ts new file mode 100644 index 000000000..50b863e04 --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-mouse-in-element.ts @@ -0,0 +1,135 @@ +import type { MaybeElementRef, MouseInElementOptions } from '@vueuse/core' + +import { defaultWindow, tryOnMounted, unrefElement, useEventListener, useMutationObserver, useResizeObserver } from '@vueuse/core' +import { shallowRef, watch } from 'vue' + +import { useElectronRelativeMouse } from './use-electron-relative-mouse' + +/** + * Reactive mouse position related to an element. + * + * @see https://vueuse.org/useMouseInElement + * @param target + * @param options + */ +export function useElectronMouseInElement( + target?: MaybeElementRef, + options: MouseInElementOptions = {}, +) { + const { + windowResize = true, + windowScroll = true, + handleOutside = true, + window = defaultWindow, + } = options + const type = options.type || 'page' + + const { x, y, sourceType } = useElectronRelativeMouse(options) + + const targetRef = shallowRef(target ?? window?.document.body) + const elementX = shallowRef(0) + const elementY = shallowRef(0) + const elementPositionX = shallowRef(0) + const elementPositionY = shallowRef(0) + const elementHeight = shallowRef(0) + const elementWidth = shallowRef(0) + const isOutside = shallowRef(true) + + function update() { + if (!window) + return + + const el = unrefElement(targetRef) + if (!el || !(el instanceof Element)) + return + + const { + left, + top, + width, + height, + } = el.getBoundingClientRect() + + elementPositionX.value = left + (type === 'page' ? window.pageXOffset : 0) + elementPositionY.value = top + (type === 'page' ? window.pageYOffset : 0) + elementHeight.value = height + elementWidth.value = width + + const elX = x.value - elementPositionX.value + const elY = y.value - elementPositionY.value + isOutside.value = width === 0 || height === 0 + || elX < 0 || elY < 0 + || elX > width || elY > height + + if (handleOutside || !isOutside.value) { + elementX.value = elX + elementY.value = elY + } + } + + const stopFnList: Array<() => void> = [] + function stop() { + stopFnList.forEach(fn => fn()) + stopFnList.length = 0 + } + + tryOnMounted(() => { + update() + }) + + if (window) { + const { + stop: stopResizeObserver, + } = useResizeObserver(targetRef, update) + const { + stop: stopMutationObserver, + } = useMutationObserver(targetRef, update, { + attributeFilter: ['style', 'class'], + }) + + const stopWatch = watch( + [targetRef, x, y], + update, + ) + + stopFnList.push( + stopResizeObserver, + stopMutationObserver, + stopWatch, + ) + + useEventListener( + document, + 'mouseleave', + () => isOutside.value = true, + { passive: true }, + ) + + if (windowScroll) { + stopFnList.push( + useEventListener('scroll', update, { capture: true, passive: true }), + ) + } + if (windowResize) { + stopFnList.push( + useEventListener('resize', update, { passive: true }), + ) + } + } + + return { + x, + y, + sourceType, + elementX, + elementY, + elementPositionX, + elementPositionY, + elementHeight, + elementWidth, + isOutside, + stop, + } +} + +export type UseMouseInElementReturn = ReturnType diff --git a/packages/electron-vueuse/src/composables/use-electron-mouse-in-window.ts b/packages/electron-vueuse/src/composables/use-electron-mouse-in-window.ts new file mode 100644 index 000000000..cfd7409e5 --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-mouse-in-window.ts @@ -0,0 +1,9 @@ +import type { MouseInElementOptions } from '@vueuse/core' + +import { useElectronMouseInElement } from './use-electron-mouse-in-element' + +export function useElectronMouseInWindow( + options: MouseInElementOptions = {}, +) { + return useElectronMouseInElement(undefined, options) +} diff --git a/packages/electron-vueuse/src/composables/use-electron-mouse.ts b/packages/electron-vueuse/src/composables/use-electron-mouse.ts new file mode 100644 index 000000000..5244bb609 --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-mouse.ts @@ -0,0 +1,36 @@ +import type { UseMouseOptions } from '@vueuse/core' + +import { defineInvoke } from '@moeru/eventa' +import { cursorScreenPoint, startLoopGetCursorScreenPoint } from '@proj-airi/electron-eventa' +import { useMouse } from '@vueuse/core' +import { ref } from 'vue' + +import { getElectronEventaContext } from './use-electron-eventa-context' + +let sharedEventTarget: EventTarget | undefined +let startedTracking = false + +export function useElectronMouseEventTarget() { + const context = getElectronEventaContext() + + if (!sharedEventTarget) { + sharedEventTarget = new EventTarget() + + context.on(cursorScreenPoint, (event) => { + const e = new MouseEvent('mousemove', { screenX: event.body?.x, screenY: event.body?.y }) + sharedEventTarget?.dispatchEvent(e) + }) + } + + if (!startedTracking) { + startedTracking = true + void defineInvoke(context, startLoopGetCursorScreenPoint)() + } + + return ref(sharedEventTarget) +} + +export function useElectronMouse(options?: UseMouseOptions) { + const eventTarget = useElectronMouseEventTarget() + return useMouse({ ...options, target: eventTarget, type: 'screen' }) +} diff --git a/packages/electron-vueuse/src/composables/use-electron-relative-mouse.ts b/packages/electron-vueuse/src/composables/use-electron-relative-mouse.ts new file mode 100644 index 000000000..7ff5c7242 --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-relative-mouse.ts @@ -0,0 +1,21 @@ +import type { UseMouseOptions } from '@vueuse/core' + +import { computed } from 'vue' + +import { useElectronMouse } from './use-electron-mouse' +import { useElectronWindowBounds } from './use-electron-window-bounds' + +export function useElectronRelativeMouse(options?: UseMouseOptions) { + const mouse = useElectronMouse(options) + const { x: windowX, y: windowY } = useElectronWindowBounds() + + // Transform screen coordinates to window-relative coordinates + const x = computed(() => mouse.x.value - windowX.value) + const y = computed(() => mouse.y.value - windowY.value) + + return { + ...mouse, + x, + y, + } +} diff --git a/packages/electron-vueuse/src/composables/use-electron-window-bounds.ts b/packages/electron-vueuse/src/composables/use-electron-window-bounds.ts new file mode 100644 index 000000000..734e34483 --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-window-bounds.ts @@ -0,0 +1,44 @@ +import { defineInvoke } from '@moeru/eventa' +import { bounds, startLoopGetBounds } from '@proj-airi/electron-eventa' +import { ref } from 'vue' + +import { getElectronEventaContext } from './use-electron-eventa-context' + +const windowBoundsX = ref(0) +const windowBoundsY = ref(0) +const windowBoundsWidth = ref(0) +const windowBoundsHeight = ref(0) + +let initialized = false + +function initializeWindowBoundsTracking() { + if (initialized) { + return + } + + initialized = true + const context = getElectronEventaContext() + + context.on(bounds, (event) => { + if (!event || !event.body) + return + + windowBoundsX.value = event.body.x + windowBoundsY.value = event.body.y + windowBoundsWidth.value = event.body.width + windowBoundsHeight.value = event.body.height + }) + + void defineInvoke(context, startLoopGetBounds)() +} + +export function useElectronWindowBounds() { + initializeWindowBoundsTracking() + + return { + x: windowBoundsX, + y: windowBoundsY, + width: windowBoundsWidth, + height: windowBoundsHeight, + } +} diff --git a/packages/electron-vueuse/src/composables/use-electron-window-resize.ts b/packages/electron-vueuse/src/composables/use-electron-window-resize.ts new file mode 100644 index 000000000..4713d6dc8 --- /dev/null +++ b/packages/electron-vueuse/src/composables/use-electron-window-resize.ts @@ -0,0 +1,44 @@ +import type { ResizeDirection } from '@proj-airi/electron-eventa' + +import { electron } from '@proj-airi/electron-eventa' + +import { useElectronEventaInvoke } from './use-electron-eventa-context' + +export function useElectronWindowResize() { + const isWindows = useElectronEventaInvoke(electron.app.isWindows) + const resizeWindow = useElectronEventaInvoke(electron.window.resize) + + const handleResizeStart = async (e: MouseEvent, direction: ResizeDirection) => { + if (!await isWindows()) + return + + e.preventDefault() + e.stopPropagation() + + let lastX = e.screenX + let lastY = e.screenY + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.screenX - lastX + const deltaY = moveEvent.screenY - lastY + + if (deltaX !== 0 || deltaY !== 0) { + void resizeWindow({ deltaX, deltaY, direction }) + lastX = moveEvent.screenX + lastY = moveEvent.screenY + } + } + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + return { + handleResizeStart, + } +} diff --git a/packages/electron-vueuse/src/index.ts b/packages/electron-vueuse/src/index.ts new file mode 100644 index 000000000..501ae4409 --- /dev/null +++ b/packages/electron-vueuse/src/index.ts @@ -0,0 +1,25 @@ +export { useElectronAllDisplays } from './composables/use-electron-all-displays' +export { useElectronAutoUpdater } from './composables/use-electron-auto-updater' +export { + getElectronEventaContext, + resetElectronEventaContextForTesting, + useElectronEventaContext, + useElectronEventaInvoke, +} from './composables/use-electron-eventa-context' +export { useElectronMouse, useElectronMouseEventTarget } from './composables/use-electron-mouse' +export { useElectronMouseAroundWindowBorder } from './composables/use-electron-mouse-around-window-border' +export type { UseMouseInElementReturn } from './composables/use-electron-mouse-in-element' +export { useElectronMouseInElement } from './composables/use-electron-mouse-in-element' +export { useElectronMouseInWindow } from './composables/use-electron-mouse-in-window' +export { useElectronRelativeMouse } from './composables/use-electron-relative-mouse' +export { useElectronWindowBounds } from './composables/use-electron-window-bounds' +export { useElectronWindowResize } from './composables/use-electron-window-resize' + +export { + createRendererLoop, + isRendererUnavailable, + shouldStopForRendererError, + stopLoopWhenRendererIsGone, + useLoop, +} from './main' +export type { LoopOptions } from './main' diff --git a/packages/electron-vueuse/src/main/index.ts b/packages/electron-vueuse/src/main/index.ts new file mode 100644 index 000000000..baed30e4c --- /dev/null +++ b/packages/electron-vueuse/src/main/index.ts @@ -0,0 +1,8 @@ +export { useLoop } from './loop' +export type { LoopOptions } from './loop' +export { + createRendererLoop, + isRendererUnavailable, + shouldStopForRendererError, + stopLoopWhenRendererIsGone, +} from './renderer-loop' diff --git a/packages/electron-vueuse/src/main/loop.ts b/packages/electron-vueuse/src/main/loop.ts new file mode 100644 index 000000000..9856ad175 --- /dev/null +++ b/packages/electron-vueuse/src/main/loop.ts @@ -0,0 +1,68 @@ +import { clearClockInterval, setClockInterval } from '@moeru/std' +import { Mutex } from 'es-toolkit/promise' + +export 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 tick = async () => { + if (!shouldRun || mutex.isLocked) { + return + } + + await mutex.acquire() + try { + await fn() + } + finally { + 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) { + startTimer() + } + + return { + start: () => toggleRunState(true), + resume: () => toggleRunState(true), + pause: () => toggleRunState(false), + stop: () => toggleRunState(false), + } +} diff --git a/packages/electron-vueuse/src/main/renderer-loop.ts b/packages/electron-vueuse/src/main/renderer-loop.ts new file mode 100644 index 000000000..a6c4ea417 --- /dev/null +++ b/packages/electron-vueuse/src/main/renderer-loop.ts @@ -0,0 +1,75 @@ +import type { BrowserWindow } from 'electron' + +import { attemptAsync } from 'es-toolkit' + +import { useLoop } from './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/packages/electron-vueuse/tsconfig.json b/packages/electron-vueuse/tsconfig.json new file mode 100644 index 000000000..6869a6239 --- /dev/null +++ b/packages/electron-vueuse/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "ESNext", + "DOM" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/electron-vueuse/tsdown.config.ts b/packages/electron-vueuse/tsdown.config.ts new file mode 100644 index 000000000..a676c259f --- /dev/null +++ b/packages/electron-vueuse/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: [ + 'src/index.ts', + 'src/main/index.ts', + ], + dts: true, + format: 'esm', + inlineOnly: false, +})