Skip to content

Commit f86d86c

Browse files
committed
feat(stage-tamagotchi,stage-shared): listen keyup for global shortcut with uiohook
1 parent 10f340a commit f86d86c

10 files changed

Lines changed: 823 additions & 276 deletions

File tree

apps/stage-tamagotchi/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,10 @@
192192
"h3": "2.0.1-rc.20",
193193
"less": "^4.6.4",
194194
"mkcert": "catalog:",
195+
"node-abi": "catalog:",
195196
"std-env": "^4.1.0",
196197
"superjson": "catalog:",
198+
"uiohook-napi": "catalog:",
197199
"unocss-preset-scrollbar": "^4.0.0",
198200
"unplugin-info": "^1.3.2",
199201
"unplugin-yaml": "^4.1.0",
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
import type { ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut'
2+
3+
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
/**
7+
* Builds a binding for the uiohook driver.
8+
*
9+
* Defaults to `receiveKeyUps: true` because that flag is the dispatch
10+
* signal in the orchestrator; the driver itself does not inspect it,
11+
* but tests stay closer to how callers will use the driver this way.
12+
*
13+
* @example
14+
* exampleBinding('ptt')
15+
* // => { id: 'ptt', accelerator: { modifiers: ['shift'], key: 'KeyK' },
16+
* // scope: 'global', receiveKeyUps: true }
17+
*/
18+
function exampleBinding(id: string, modifiers: ShortcutBinding['accelerator']['modifiers'] = ['shift'], key = 'KeyK'): ShortcutBinding {
19+
return {
20+
id,
21+
accelerator: { modifiers, key },
22+
scope: 'global',
23+
receiveKeyUps: true,
24+
}
25+
}
26+
27+
interface KeyboardEvent {
28+
keycode: number
29+
altKey: boolean
30+
ctrlKey: boolean
31+
metaKey: boolean
32+
shiftKey: boolean
33+
}
34+
35+
function event(partial: Partial<KeyboardEvent> & Pick<KeyboardEvent, 'keycode'>): KeyboardEvent {
36+
return {
37+
altKey: false,
38+
ctrlKey: false,
39+
metaKey: false,
40+
shiftKey: false,
41+
...partial,
42+
}
43+
}
44+
45+
/**
46+
* Wires mocks for `uiohook-napi` (singleton + listeners) and the
47+
* `electron` `systemPreferences` surface, then imports the driver
48+
* factory under test.
49+
*
50+
* @example
51+
* const m = await setupMocks()
52+
* const driver = m.createUiohookDriver({ ... })
53+
* m.fire('keydown', event({ keycode: 37 }))
54+
*/
55+
async function setupMocks() {
56+
const onMock = vi.fn()
57+
const removeListenerMock = vi.fn()
58+
const startMock = vi.fn()
59+
const stopMock = vi.fn()
60+
const isTrustedAccessibilityClientMock = vi.fn(() => true)
61+
62+
const listeners = new Map<string, Array<(e: KeyboardEvent) => void>>()
63+
64+
onMock.mockImplementation((event: string, listener: (e: KeyboardEvent) => void) => {
65+
const arr = listeners.get(event) ?? []
66+
arr.push(listener)
67+
listeners.set(event, arr)
68+
})
69+
70+
removeListenerMock.mockImplementation((event: string, listener: (e: KeyboardEvent) => void) => {
71+
const arr = listeners.get(event)
72+
if (!arr)
73+
return
74+
listeners.set(event, arr.filter(l => l !== listener))
75+
})
76+
77+
// Mirrors the literal subset of `UiohookKey` that exercises the
78+
// mapper. KeyK = 37, KeyA = 30 (matches real upstream constants so
79+
// tests assert real keycodes, not arbitrary numbers).
80+
const UiohookKey = {
81+
K: 37,
82+
A: 30,
83+
Q: 16,
84+
} as const
85+
86+
vi.doMock('uiohook-napi', () => ({
87+
uIOhook: {
88+
on: onMock,
89+
removeListener: removeListenerMock,
90+
start: startMock,
91+
stop: stopMock,
92+
},
93+
UiohookKey,
94+
}))
95+
96+
vi.doMock('electron', () => ({
97+
systemPreferences: {
98+
isTrustedAccessibilityClient: isTrustedAccessibilityClientMock,
99+
},
100+
}))
101+
102+
const { createUiohookDriver } = await import('./global-shortcut-uiohook')
103+
104+
function fire(name: 'keydown' | 'keyup', e: KeyboardEvent): void {
105+
for (const listener of listeners.get(name) ?? [])
106+
listener(e)
107+
}
108+
109+
function createDriver(overrides: { platform?: NodeJS.Platform, sessionType?: string } = {}) {
110+
const broadcastTriggered = vi.fn<(id: string, phase: 'down' | 'up') => void>()
111+
const logger = {
112+
warn: vi.fn(),
113+
withError: vi.fn(() => ({ warn: vi.fn() })),
114+
}
115+
const driver = createUiohookDriver({
116+
broadcastTriggered,
117+
logger: logger as unknown as Parameters<typeof createUiohookDriver>[0]['logger'],
118+
platform: overrides.platform ?? 'darwin',
119+
sessionType: overrides.sessionType,
120+
})
121+
return { driver, broadcastTriggered, logger }
122+
}
123+
124+
return {
125+
onMock,
126+
removeListenerMock,
127+
startMock,
128+
stopMock,
129+
isTrustedAccessibilityClientMock,
130+
fire,
131+
createDriver,
132+
}
133+
}
134+
135+
describe('createUiohookDriver', () => {
136+
beforeEach(() => {
137+
vi.resetModules()
138+
vi.clearAllMocks()
139+
vi.restoreAllMocks()
140+
})
141+
142+
it('starts the OS hook lazily and installs keydown/keyup listeners on first registration', async () => {
143+
const m = await setupMocks()
144+
const { driver } = m.createDriver()
145+
146+
expect(m.startMock).not.toHaveBeenCalled()
147+
expect(m.onMock).not.toHaveBeenCalled()
148+
149+
const result = driver.tryRegister(exampleBinding('ptt'))
150+
151+
expect(result).toEqual({ id: 'ptt', ok: true })
152+
expect(m.startMock).toHaveBeenCalledTimes(1)
153+
expect(m.onMock).toHaveBeenCalledWith('keydown', expect.any(Function))
154+
expect(m.onMock).toHaveBeenCalledWith('keyup', expect.any(Function))
155+
})
156+
157+
it('stops the OS hook only after the last binding is unregistered', async () => {
158+
const m = await setupMocks()
159+
const { driver } = m.createDriver()
160+
161+
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
162+
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
163+
expect(m.stopMock).not.toHaveBeenCalled()
164+
165+
driver.unregisterById('a')
166+
expect(m.stopMock).not.toHaveBeenCalled()
167+
168+
driver.unregisterById('b')
169+
expect(m.stopMock).toHaveBeenCalledTimes(1)
170+
})
171+
172+
it('broadcasts a "down" event when a matching keydown arrives', async () => {
173+
const m = await setupMocks()
174+
const { driver, broadcastTriggered } = m.createDriver()
175+
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
176+
177+
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
178+
179+
expect(broadcastTriggered).toHaveBeenCalledTimes(1)
180+
expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down')
181+
})
182+
183+
it('suppresses OS auto-repeat — repeated keydowns between matching down/up collapse to one broadcast', async () => {
184+
// ROOT CAUSE:
185+
//
186+
// libuiohook reports the OS-level keydown stream verbatim, which
187+
// includes auto-repeat events while the key remains physically
188+
// held. Without per-binding `pressed` tracking, a held PTT key
189+
// would emit hundreds of `down` broadcasts per second and the mic
190+
// would start/stop frantically.
191+
const m = await setupMocks()
192+
const { driver, broadcastTriggered } = m.createDriver()
193+
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
194+
195+
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
196+
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
197+
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
198+
199+
const downCalls = broadcastTriggered.mock.calls.filter(c => c[1] === 'down')
200+
expect(downCalls).toHaveLength(1)
201+
})
202+
203+
it('broadcasts "up" on matching keyup and re-arms the binding for the next press', async () => {
204+
const m = await setupMocks()
205+
const { driver, broadcastTriggered } = m.createDriver()
206+
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
207+
208+
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
209+
m.fire('keyup', event({ keycode: 37, shiftKey: false }))
210+
m.fire('keydown', event({ keycode: 37, shiftKey: true }))
211+
212+
expect(broadcastTriggered).toHaveBeenNthCalledWith(1, 'ptt', 'down')
213+
expect(broadcastTriggered).toHaveBeenNthCalledWith(2, 'ptt', 'up')
214+
expect(broadcastTriggered).toHaveBeenNthCalledWith(3, 'ptt', 'down')
215+
})
216+
217+
it('matches keyup by keycode even when modifiers were released before the main key', async () => {
218+
// NOTICE:
219+
// Users routinely release the modifier first (e.g. let Cmd go
220+
// before letting K go). The keyup event for K therefore carries
221+
// `metaKey: false`, which would fail strict modifier matching.
222+
// The driver keys the "up" broadcast off the prior `pressed`
223+
// state rather than the modifier predicate.
224+
const m = await setupMocks()
225+
const { driver, broadcastTriggered } = m.createDriver()
226+
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
227+
228+
m.fire('keydown', event({ keycode: 37, metaKey: true }))
229+
m.fire('keyup', event({ keycode: 37, metaKey: false }))
230+
231+
expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down')
232+
expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'up')
233+
})
234+
235+
it('ignores keyup when no matching keydown was tracked', async () => {
236+
const m = await setupMocks()
237+
const { driver, broadcastTriggered } = m.createDriver()
238+
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
239+
240+
m.fire('keyup', event({ keycode: 37, shiftKey: true }))
241+
242+
expect(broadcastTriggered).not.toHaveBeenCalled()
243+
})
244+
245+
it('does not match a keydown that carries an extra modifier', async () => {
246+
// Strict matching mirrors Electron's accelerator semantics: a
247+
// `Shift+K` binding must not fire on `Cmd+Shift+K`.
248+
const m = await setupMocks()
249+
const { driver, broadcastTriggered } = m.createDriver()
250+
driver.tryRegister(exampleBinding('ptt', ['shift'], 'KeyK'))
251+
252+
m.fire('keydown', event({ keycode: 37, shiftKey: true, metaKey: true }))
253+
254+
expect(broadcastTriggered).not.toHaveBeenCalled()
255+
})
256+
257+
it('maps cmd-or-ctrl to metaKey on darwin', async () => {
258+
const m = await setupMocks()
259+
const { driver, broadcastTriggered } = m.createDriver({ platform: 'darwin' })
260+
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
261+
262+
m.fire('keydown', event({ keycode: 37, metaKey: true }))
263+
m.fire('keydown', event({ keycode: 37, ctrlKey: true }))
264+
265+
expect(broadcastTriggered).toHaveBeenCalledTimes(1)
266+
expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down')
267+
})
268+
269+
it('maps cmd-or-ctrl to ctrlKey on non-darwin platforms', async () => {
270+
const m = await setupMocks()
271+
const { driver, broadcastTriggered } = m.createDriver({ platform: 'win32' })
272+
driver.tryRegister(exampleBinding('ptt', ['cmd-or-ctrl'], 'KeyK'))
273+
274+
m.fire('keydown', event({ keycode: 37, ctrlKey: true }))
275+
m.fire('keydown', event({ keycode: 37, metaKey: true }))
276+
277+
// First (ctrl) matches; second (meta) does not — the pressed
278+
// state stays cleared and produces no extra broadcast.
279+
expect(broadcastTriggered).toHaveBeenCalledTimes(1)
280+
expect(broadcastTriggered).toHaveBeenCalledWith('ptt', 'down')
281+
})
282+
283+
it('rejects duplicate ids with reason "duplicate-id" and does not double-start the hook', async () => {
284+
const m = await setupMocks()
285+
const { driver } = m.createDriver()
286+
287+
expect(driver.tryRegister(exampleBinding('ptt'))).toEqual({ id: 'ptt', ok: true })
288+
const second = driver.tryRegister(exampleBinding('ptt'))
289+
expect(second).toEqual({ id: 'ptt', ok: false, reason: ShortcutFailureReasons.DuplicateId })
290+
expect(m.startMock).toHaveBeenCalledTimes(1)
291+
})
292+
293+
it('returns Unsupported under a native Wayland session', async () => {
294+
const m = await setupMocks()
295+
const { driver } = m.createDriver({ platform: 'linux', sessionType: 'wayland' })
296+
297+
const result = driver.tryRegister(exampleBinding('ptt'))
298+
expect(result).toEqual({ id: 'ptt', ok: false, reason: ShortcutFailureReasons.Unsupported })
299+
expect(m.startMock).not.toHaveBeenCalled()
300+
})
301+
302+
it('permits registration on Linux under X11 / XWayland', async () => {
303+
const m = await setupMocks()
304+
const { driver } = m.createDriver({ platform: 'linux', sessionType: 'x11' })
305+
306+
expect(driver.tryRegister(exampleBinding('ptt'))).toEqual({ id: 'ptt', ok: true })
307+
expect(m.startMock).toHaveBeenCalledTimes(1)
308+
})
309+
310+
it('returns Denied when macOS Accessibility permission is not granted', async () => {
311+
const m = await setupMocks()
312+
m.isTrustedAccessibilityClientMock.mockReturnValue(false)
313+
const { driver } = m.createDriver({ platform: 'darwin' })
314+
315+
const result = driver.tryRegister(exampleBinding('ptt'))
316+
expect(result).toEqual({ id: 'ptt', ok: false, reason: ShortcutFailureReasons.Denied })
317+
expect(m.isTrustedAccessibilityClientMock).toHaveBeenCalledWith(true)
318+
expect(m.startMock).not.toHaveBeenCalled()
319+
})
320+
321+
it('skips the Accessibility check entirely on non-darwin', async () => {
322+
const m = await setupMocks()
323+
const { driver } = m.createDriver({ platform: 'win32' })
324+
driver.tryRegister(exampleBinding('ptt'))
325+
expect(m.isTrustedAccessibilityClientMock).not.toHaveBeenCalled()
326+
})
327+
328+
it('unregisterAll clears every binding and stops the hook in one shot', async () => {
329+
const m = await setupMocks()
330+
const { driver, broadcastTriggered } = m.createDriver()
331+
332+
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
333+
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
334+
driver.unregisterAll()
335+
336+
m.fire('keydown', event({ keycode: 30, shiftKey: true }))
337+
m.fire('keydown', event({ keycode: 16, shiftKey: true }))
338+
339+
expect(broadcastTriggered).not.toHaveBeenCalled()
340+
expect(m.stopMock).toHaveBeenCalledTimes(1)
341+
})
342+
343+
it('dispose removes the keydown/keyup listeners', async () => {
344+
const m = await setupMocks()
345+
const { driver } = m.createDriver()
346+
driver.tryRegister(exampleBinding('ptt'))
347+
348+
driver.dispose()
349+
350+
expect(m.removeListenerMock).toHaveBeenCalledWith('keydown', expect.any(Function))
351+
expect(m.removeListenerMock).toHaveBeenCalledWith('keyup', expect.any(Function))
352+
})
353+
354+
it('keeps per-binding pressed state independent across multiple bindings', async () => {
355+
const m = await setupMocks()
356+
const { driver, broadcastTriggered } = m.createDriver()
357+
driver.tryRegister(exampleBinding('a', ['shift'], 'KeyA'))
358+
driver.tryRegister(exampleBinding('b', ['shift'], 'KeyQ'))
359+
360+
m.fire('keydown', event({ keycode: 30, shiftKey: true }))
361+
m.fire('keydown', event({ keycode: 16, shiftKey: true }))
362+
m.fire('keyup', event({ keycode: 30 }))
363+
364+
expect(broadcastTriggered).toHaveBeenNthCalledWith(1, 'a', 'down')
365+
expect(broadcastTriggered).toHaveBeenNthCalledWith(2, 'b', 'down')
366+
expect(broadcastTriggered).toHaveBeenNthCalledWith(3, 'a', 'up')
367+
})
368+
})

0 commit comments

Comments
 (0)