Skip to content

Commit 715f99b

Browse files
committed
feat(electron-vueuse): new package
1 parent 9ffebd2 commit 715f99b

18 files changed

+739
-0
lines changed

packages/electron-vueuse/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# @proj-airi/electron-vueuse
2+
3+
VueUse-like composables and helpers shared across AIRI Electron apps.
4+
5+
## What it provides
6+
7+
- Renderer composables for common Electron behaviors (`mouse`, `window bounds`, `auto updater`, etc.)
8+
- A reusable Eventa context/invoke pattern (`useElectronEventaContext`, `useElectronEventaInvoke`)
9+
- Eventa context/invoke ergonomics for renderer code
10+
- Main-process loop utilities (`useLoop`, `createRendererLoop`)
11+
12+
For IPC contract definitions, use `@proj-airi/electron-eventa`.
13+
14+
## Usage
15+
16+
```ts
17+
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
18+
import { electron } from '@proj-airi/electron-eventa'
19+
20+
const openSettings = useElectronEventaInvoke(electron.window.getBounds)
21+
```
22+
23+
```ts
24+
import { createRendererLoop } from '@proj-airi/electron-vueuse/main'
25+
```
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@proj-airi/electron-vueuse",
3+
"type": "module",
4+
"version": "0.8.5-beta.3",
5+
"private": true,
6+
"description": "VueUse-like composables and helpers for Electron apps",
7+
"author": {
8+
"name": "Moeru AI Project AIRI Team",
9+
"email": "airi@moeru.ai",
10+
"url": "https://github.com/moeru-ai"
11+
},
12+
"license": "MIT",
13+
"repository": {
14+
"type": "git",
15+
"url": "https://github.com/moeru-ai/airi.git",
16+
"directory": "packages/electron-vueuse"
17+
},
18+
"exports": {
19+
".": "./dist/index.mjs",
20+
"./main": "./dist/main/index.mjs",
21+
"./package.json": "./package.json"
22+
},
23+
"types": "./dist/index.d.mts",
24+
"files": [
25+
"README.md",
26+
"dist",
27+
"package.json"
28+
],
29+
"scripts": {
30+
"dev": "pnpm run build",
31+
"build": "tsdown",
32+
"typecheck": "tsc --noEmit"
33+
},
34+
"peerDependencies": {
35+
"electron": ">=39 <41",
36+
"vue": ">=3"
37+
},
38+
"dependencies": {
39+
"@moeru/eventa": "^1.0.0-beta.1",
40+
"@moeru/std": "catalog:",
41+
"@vueuse/core": "catalog:",
42+
"es-toolkit": "catalog:",
43+
"std-env": "catalog:"
44+
},
45+
"devDependencies": {
46+
"@proj-airi/electron-eventa": "workspace:^"
47+
}
48+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineInvoke } from '@moeru/eventa'
2+
import { electron } from '@proj-airi/electron-eventa'
3+
import { useAsyncState, useIntervalFn } from '@vueuse/core'
4+
5+
import { useElectronEventaContext } from './use-electron-eventa-context'
6+
7+
export function useElectronAllDisplays() {
8+
const context = useElectronEventaContext()
9+
const getAllDisplays = defineInvoke(context.value, electron.screen.getAllDisplays)
10+
const { state: allDisplays, execute } = useAsyncState(() => getAllDisplays(), [])
11+
12+
useIntervalFn(() => {
13+
void execute()
14+
}, 5000)
15+
16+
return allDisplays
17+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { AutoUpdaterState } from '@proj-airi/electron-eventa/electron-updater'
2+
3+
import {
4+
autoUpdater,
5+
electronAutoUpdaterStateChanged,
6+
} from '@proj-airi/electron-eventa/electron-updater'
7+
import { computed, onMounted, ref } from 'vue'
8+
9+
import { useElectronEventaContext, useElectronEventaInvoke } from './use-electron-eventa-context'
10+
11+
export function useElectronAutoUpdater() {
12+
const context = useElectronEventaContext()
13+
14+
const state = ref<AutoUpdaterState>({ status: 'idle' })
15+
16+
const getState = useElectronEventaInvoke(autoUpdater.getState, context.value)
17+
const checkForUpdates = useElectronEventaInvoke(autoUpdater.checkForUpdates, context.value)
18+
const downloadUpdate = useElectronEventaInvoke(autoUpdater.downloadUpdate, context.value)
19+
const quitAndInstall = useElectronEventaInvoke(autoUpdater.quitAndInstall, context.value)
20+
21+
const isBusy = computed(() => ['checking', 'downloading'].includes(state.value.status))
22+
const canDownload = computed(() => state.value.status === 'available')
23+
const canRestartToUpdate = computed(() => state.value.status === 'downloaded')
24+
25+
onMounted(async () => {
26+
try {
27+
const current = await getState()
28+
if (current)
29+
state.value = current
30+
}
31+
catch {}
32+
33+
try {
34+
context.value.on(electronAutoUpdaterStateChanged, (evt) => {
35+
if (evt.body)
36+
state.value = evt.body
37+
})
38+
}
39+
catch {}
40+
})
41+
42+
return {
43+
state,
44+
isBusy,
45+
canDownload,
46+
canRestartToUpdate,
47+
48+
checkForUpdates,
49+
downloadUpdate,
50+
quitAndInstall,
51+
}
52+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { InvokeEventa } from '@moeru/eventa'
2+
3+
import { defineInvoke } from '@moeru/eventa'
4+
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
5+
import { ref } from 'vue'
6+
7+
type EventaContext = ReturnType<typeof createContext>['context']
8+
type IpcRendererLike = Parameters<typeof createContext>[0]
9+
10+
let sharedContext: EventaContext | undefined
11+
12+
function resolveIpcRenderer(ipcRenderer?: IpcRendererLike): IpcRendererLike {
13+
if (ipcRenderer) {
14+
return ipcRenderer
15+
}
16+
17+
const globalIpcRenderer = (globalThis as { window?: { electron?: { ipcRenderer?: IpcRendererLike } } }).window?.electron?.ipcRenderer
18+
if (!globalIpcRenderer) {
19+
throw new Error('Electron ipcRenderer is not available. Pass it explicitly to useElectronEventaContext().')
20+
}
21+
22+
return globalIpcRenderer
23+
}
24+
25+
export function getElectronEventaContext(ipcRenderer?: IpcRendererLike): EventaContext {
26+
sharedContext ??= createContext(resolveIpcRenderer(ipcRenderer)).context
27+
return sharedContext
28+
}
29+
30+
export function useElectronEventaContext(ipcRenderer?: IpcRendererLike) {
31+
return ref(getElectronEventaContext(ipcRenderer))
32+
}
33+
34+
export function useElectronEventaInvoke<Res, Req = undefined, ResErr = Error, ReqErr = Error>(invoke: InvokeEventa<Res, Req, ResErr, ReqErr>, context?: EventaContext) {
35+
return defineInvoke(context ?? getElectronEventaContext(), invoke)
36+
}
37+
38+
export function resetElectronEventaContextForTesting() {
39+
sharedContext = undefined
40+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { computed } from 'vue'
2+
3+
import { useElectronRelativeMouse } from './use-electron-relative-mouse'
4+
import { useElectronWindowBounds } from './use-electron-window-bounds'
5+
6+
export interface UseElectronMouseAroundWindowBorderOptions {
7+
/** Pixel distance from the window edge to consider as "near". */
8+
threshold?: number
9+
/** Allow a small overshoot outside the window and still count as near. Defaults to threshold. */
10+
overshoot?: number
11+
}
12+
13+
/**
14+
* Detect when the cursor is near the window border using window-relative mouse coords.
15+
* Fast path: no extra listeners; reuses existing mouse and window bounds streams.
16+
*/
17+
export function useElectronMouseAroundWindowBorder(
18+
options: UseElectronMouseAroundWindowBorderOptions = {},
19+
) {
20+
const threshold = options.threshold ?? 8
21+
const overshoot = options.overshoot ?? threshold
22+
23+
const { x, y } = useElectronRelativeMouse()
24+
const { width, height } = useElectronWindowBounds()
25+
26+
// Helpers to determine proximity to each edge. We allow a small overshoot so
27+
// users hovering slightly outside still get feedback to find the edge.
28+
const nearLeft = computed(() => Math.abs(x.value) <= threshold && y.value > -overshoot && y.value < height.value + overshoot)
29+
const nearRight = computed(() => Math.abs(x.value - width.value) <= threshold && y.value > -overshoot && y.value < height.value + overshoot)
30+
const nearTop = computed(() => Math.abs(y.value) <= threshold && x.value > -overshoot && x.value < width.value + overshoot)
31+
const nearBottom = computed(() => Math.abs(y.value - height.value) <= threshold && x.value > -overshoot && x.value < width.value + overshoot)
32+
33+
const nearTopLeft = computed(() => nearTop.value && nearLeft.value)
34+
const nearTopRight = computed(() => nearTop.value && nearRight.value)
35+
const nearBottomLeft = computed(() => nearBottom.value && nearLeft.value)
36+
const nearBottomRight = computed(() => nearBottom.value && nearRight.value)
37+
38+
const isNearAnyBorder = computed(() =>
39+
nearLeft.value
40+
|| nearRight.value
41+
|| nearTop.value
42+
|| nearBottom.value,
43+
)
44+
45+
return {
46+
x,
47+
y,
48+
width,
49+
height,
50+
nearLeft,
51+
nearRight,
52+
nearTop,
53+
nearBottom,
54+
nearTopLeft,
55+
nearTopRight,
56+
nearBottomLeft,
57+
nearBottomRight,
58+
isNearAnyBorder,
59+
}
60+
}
61+
62+
export type UseElectronMouseAroundWindowBorderReturn = ReturnType<typeof useElectronMouseAroundWindowBorder>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { MaybeElementRef, MouseInElementOptions } from '@vueuse/core'
2+
3+
import { defaultWindow, tryOnMounted, unrefElement, useEventListener, useMutationObserver, useResizeObserver } from '@vueuse/core'
4+
import { shallowRef, watch } from 'vue'
5+
6+
import { useElectronRelativeMouse } from './use-electron-relative-mouse'
7+
8+
/**
9+
* Reactive mouse position related to an element.
10+
*
11+
* @see https://vueuse.org/useMouseInElement
12+
* @param target
13+
* @param options
14+
*/
15+
export function useElectronMouseInElement(
16+
target?: MaybeElementRef,
17+
options: MouseInElementOptions = {},
18+
) {
19+
const {
20+
windowResize = true,
21+
windowScroll = true,
22+
handleOutside = true,
23+
window = defaultWindow,
24+
} = options
25+
const type = options.type || 'page'
26+
27+
const { x, y, sourceType } = useElectronRelativeMouse(options)
28+
29+
const targetRef = shallowRef(target ?? window?.document.body)
30+
const elementX = shallowRef(0)
31+
const elementY = shallowRef(0)
32+
const elementPositionX = shallowRef(0)
33+
const elementPositionY = shallowRef(0)
34+
const elementHeight = shallowRef(0)
35+
const elementWidth = shallowRef(0)
36+
const isOutside = shallowRef(true)
37+
38+
function update() {
39+
if (!window)
40+
return
41+
42+
const el = unrefElement(targetRef)
43+
if (!el || !(el instanceof Element))
44+
return
45+
46+
const {
47+
left,
48+
top,
49+
width,
50+
height,
51+
} = el.getBoundingClientRect()
52+
53+
elementPositionX.value = left + (type === 'page' ? window.pageXOffset : 0)
54+
elementPositionY.value = top + (type === 'page' ? window.pageYOffset : 0)
55+
elementHeight.value = height
56+
elementWidth.value = width
57+
58+
const elX = x.value - elementPositionX.value
59+
const elY = y.value - elementPositionY.value
60+
isOutside.value = width === 0 || height === 0
61+
|| elX < 0 || elY < 0
62+
|| elX > width || elY > height
63+
64+
if (handleOutside || !isOutside.value) {
65+
elementX.value = elX
66+
elementY.value = elY
67+
}
68+
}
69+
70+
const stopFnList: Array<() => void> = []
71+
function stop() {
72+
stopFnList.forEach(fn => fn())
73+
stopFnList.length = 0
74+
}
75+
76+
tryOnMounted(() => {
77+
update()
78+
})
79+
80+
if (window) {
81+
const {
82+
stop: stopResizeObserver,
83+
} = useResizeObserver(targetRef, update)
84+
const {
85+
stop: stopMutationObserver,
86+
} = useMutationObserver(targetRef, update, {
87+
attributeFilter: ['style', 'class'],
88+
})
89+
90+
const stopWatch = watch(
91+
[targetRef, x, y],
92+
update,
93+
)
94+
95+
stopFnList.push(
96+
stopResizeObserver,
97+
stopMutationObserver,
98+
stopWatch,
99+
)
100+
101+
useEventListener(
102+
document,
103+
'mouseleave',
104+
() => isOutside.value = true,
105+
{ passive: true },
106+
)
107+
108+
if (windowScroll) {
109+
stopFnList.push(
110+
useEventListener('scroll', update, { capture: true, passive: true }),
111+
)
112+
}
113+
if (windowResize) {
114+
stopFnList.push(
115+
useEventListener('resize', update, { passive: true }),
116+
)
117+
}
118+
}
119+
120+
return {
121+
x,
122+
y,
123+
sourceType,
124+
elementX,
125+
elementY,
126+
elementPositionX,
127+
elementPositionY,
128+
elementHeight,
129+
elementWidth,
130+
isOutside,
131+
stop,
132+
}
133+
}
134+
135+
export type UseMouseInElementReturn = ReturnType<typeof useElectronMouseInElement>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { MouseInElementOptions } from '@vueuse/core'
2+
3+
import { useElectronMouseInElement } from './use-electron-mouse-in-element'
4+
5+
export function useElectronMouseInWindow(
6+
options: MouseInElementOptions = {},
7+
) {
8+
return useElectronMouseInElement(undefined, options)
9+
}

0 commit comments

Comments
 (0)