feat(electron-vueuse): new package
This commit is contained in:
@@ -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'
|
||||
```
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<AutoUpdaterState>({ 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,
|
||||
}
|
||||
}
|
||||
@@ -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<typeof createContext>['context']
|
||||
type IpcRendererLike = Parameters<typeof createContext>[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<Res, Req = undefined, ResErr = Error, ReqErr = Error>(invoke: InvokeEventa<Res, Req, ResErr, ReqErr>, context?: EventaContext) {
|
||||
return defineInvoke(context ?? getElectronEventaContext(), invoke)
|
||||
}
|
||||
|
||||
export function resetElectronEventaContextForTesting() {
|
||||
sharedContext = undefined
|
||||
}
|
||||
@@ -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<typeof useElectronMouseAroundWindowBorder>
|
||||
@@ -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<typeof useElectronMouseInElement>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,8 @@
|
||||
export { useLoop } from './loop'
|
||||
export type { LoopOptions } from './loop'
|
||||
export {
|
||||
createRendererLoop,
|
||||
isRendererUnavailable,
|
||||
shouldStopForRendererError,
|
||||
stopLoopWhenRendererIsGone,
|
||||
} from './renderer-loop'
|
||||
@@ -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> | 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),
|
||||
}
|
||||
}
|
||||
@@ -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> | 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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user