feat(electron-vueuse): new package

This commit is contained in:
Neko Ayaka
2026-02-28 00:49:40 +08:00
parent 9ffebd236b
commit 715f99bfdd
18 changed files with 739 additions and 0 deletions
+25
View File
@@ -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'
```
+48
View File
@@ -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,
}
}
+25
View File
@@ -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'
+68
View File
@@ -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,
}
}
+19
View File
@@ -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"
]
}
+11
View File
@@ -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,
})