feat(stage-tamagotchi,stage-shared): wire up global shortcut service and devtools (#1811)
1. Introduce the global shortcut service 1. Add more concrete failure reasons for shortcut registration attempts 1. Add a devtool page to test (un) registering and triggering shortcuts --- <img width="1174" height="921" alt="Screenshot 2026-05-10 at 19 33 45" src="https://github.com/user-attachments/assets/10712013-fd49-4285-bdc9-4e6955d9c3a7" /> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
37807b84d6
commit
d02a76f944
@@ -33,6 +33,7 @@ import { setupMcpStdioManager } from './services/airi/mcp-servers'
|
||||
import { setupPluginHost } from './services/airi/plugins'
|
||||
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
||||
import { setupAutoUpdater } from './services/electron/auto-updater'
|
||||
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
|
||||
import { setupTray } from './tray'
|
||||
import { setupAboutWindowReusable } from './windows/about'
|
||||
import { setupBeatSync } from './windows/beat-sync'
|
||||
@@ -156,6 +157,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService())
|
||||
|
||||
const globalShortcut = injeca.provide('services:global-shortcut', () => setupGlobalShortcutService())
|
||||
|
||||
// BeatSync will create a background window to capture and process audio.
|
||||
const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync())
|
||||
|
||||
@@ -182,7 +185,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
const settingsWindow = injeca.provide('windows:settings', {
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager },
|
||||
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager, globalShortcut },
|
||||
build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import type { ShortcutBinding } from '@proj-airi/stage-shared/global-shortcut'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import type { EventaContext } from './global-shortcut'
|
||||
|
||||
import { ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function exampleBinding(id: string, key = 'KeyK'): ShortcutBinding {
|
||||
return {
|
||||
id,
|
||||
accelerator: { modifiers: ['cmd-or-ctrl', 'shift'], key },
|
||||
scope: 'global',
|
||||
}
|
||||
}
|
||||
|
||||
interface MockContext {
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
invokeHandlers: Map<string, (payload: unknown) => unknown>
|
||||
}
|
||||
|
||||
interface MockWindow {
|
||||
on: ReturnType<typeof vi.fn>
|
||||
/** Manually trigger the registered `closed` handler. */
|
||||
close: () => void
|
||||
}
|
||||
|
||||
function createMockContext(): MockContext {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
invokeHandlers: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// MockWindow only models what the driver touches: subscribing to a
|
||||
// `'closed'` event. The mock exposes a manual `close()` so tests can
|
||||
// assert the auto-cleanup path.
|
||||
function createMockWindow(): MockWindow {
|
||||
let closedHandler: (() => void) | undefined
|
||||
return {
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'closed')
|
||||
closedHandler = handler
|
||||
}),
|
||||
close() {
|
||||
closedHandler?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// MockContext / MockWindow are intentionally minimal — only what the
|
||||
// driver touches. Casting through `unknown` lets us pass them to
|
||||
// `service.registerWindow` whose typed signature wants the full
|
||||
// `EventaContext` and `BrowserWindow` types.
|
||||
function asEventaContext(ctx: MockContext): EventaContext {
|
||||
return ctx as unknown as EventaContext
|
||||
}
|
||||
|
||||
function asBrowserWindow(window: MockWindow): BrowserWindow {
|
||||
return window as unknown as BrowserWindow
|
||||
}
|
||||
|
||||
function registerMockWindow(service: { registerWindow: (params: { context: EventaContext, window: BrowserWindow }) => void }, ctx: MockContext): MockWindow {
|
||||
const window = createMockWindow()
|
||||
service.registerWindow({
|
||||
context: asEventaContext(ctx),
|
||||
window: asBrowserWindow(window),
|
||||
})
|
||||
return window
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks the heavy collaborators (`electron`, eventa, bootkit, logger)
|
||||
* so the driver can be exercised through its public interface in a
|
||||
* single test file.
|
||||
*/
|
||||
async function setupMocks() {
|
||||
const registerMock = vi.fn<(accelerator: string, callback: () => void) => boolean>(() => true)
|
||||
const unregisterMock = vi.fn<(accelerator: string) => void>()
|
||||
const unregisterAllMock = vi.fn<() => void>()
|
||||
const triggerCallbacks = new Map<string, () => void>()
|
||||
|
||||
registerMock.mockImplementation((accelerator, callback) => {
|
||||
triggerCallbacks.set(accelerator, callback)
|
||||
return true
|
||||
})
|
||||
unregisterMock.mockImplementation((accelerator) => {
|
||||
triggerCallbacks.delete(accelerator)
|
||||
})
|
||||
unregisterAllMock.mockImplementation(() => {
|
||||
triggerCallbacks.clear()
|
||||
})
|
||||
|
||||
const onAppBeforeQuitMock = vi.fn<(fn: () => void | Promise<void>) => void>()
|
||||
|
||||
vi.doMock('electron', () => ({
|
||||
globalShortcut: {
|
||||
register: registerMock,
|
||||
unregister: unregisterMock,
|
||||
unregisterAll: unregisterAllMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@moeru/eventa', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@moeru/eventa')>()
|
||||
return {
|
||||
...actual,
|
||||
defineInvokeHandler: (context: MockContext, eventa: { sendEvent: { id: string } }, handler: (payload: unknown) => unknown) => {
|
||||
// `defineInvokeEventa('foo')` returns `{ sendEvent: { id: 'foo-send' }, ... }`;
|
||||
// strip the `-send` suffix so test lookups match the contract name.
|
||||
const id = eventa.sendEvent.id.replace(/-send$/, '')
|
||||
context.invokeHandlers.set(id, handler)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('../../libs/bootkit/lifecycle', () => ({
|
||||
onAppBeforeQuit: onAppBeforeQuitMock,
|
||||
}))
|
||||
|
||||
vi.doMock('@guiiai/logg', () => ({
|
||||
useLogg: () => ({
|
||||
useGlobalConfig: () => ({
|
||||
warn: vi.fn(),
|
||||
withError: vi.fn(() => ({ warn: vi.fn() })),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { setupGlobalShortcutService } = await import('./global-shortcut')
|
||||
|
||||
return {
|
||||
setupGlobalShortcutService,
|
||||
registerMock,
|
||||
unregisterMock,
|
||||
unregisterAllMock,
|
||||
triggerCallbacks,
|
||||
onAppBeforeQuitMock,
|
||||
}
|
||||
}
|
||||
|
||||
describe('setupGlobalShortcutService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('registers a binding via the invoke handler', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const handler = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')
|
||||
expect(handler).toBeDefined()
|
||||
|
||||
const result = handler!(exampleBinding('toggle')) as { id: string, ok: boolean }
|
||||
expect(result).toEqual({ id: 'toggle', ok: true })
|
||||
expect(m.registerMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+K', expect.any(Function))
|
||||
})
|
||||
|
||||
it('refuses receiveKeyUps with reason "unsupported" and does not call globalShortcut', async () => {
|
||||
// Electron's `globalShortcut` does not deliver key-release
|
||||
// events. The driver refuses `receiveKeyUps: true` honestly so
|
||||
// callers can switch to (or fail back from) the uiohook driver
|
||||
// path that will handle it.
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const handler = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
const result = handler({ ...exampleBinding('ptt'), receiveKeyUps: true }) as { id: string, ok: boolean, reason?: string }
|
||||
expect(result).toEqual({ id: 'ptt', ok: false, reason: ShortcutFailureReasons.Unsupported })
|
||||
expect(m.registerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports conflict when globalShortcut.register returns false', async () => {
|
||||
const m = await setupMocks()
|
||||
m.registerMock.mockImplementationOnce(() => false)
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const handler = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
const result = handler(exampleBinding('toggle')) as { id: string, ok: boolean, reason?: string }
|
||||
expect(result).toEqual({ id: 'toggle', ok: false, reason: ShortcutFailureReasons.Conflict })
|
||||
})
|
||||
|
||||
it('rejects duplicate id with reason "duplicate-id" without touching globalShortcut', async () => {
|
||||
// Strict registration: the second register call under the same id
|
||||
// must fail explicitly so silent overrides between unrelated
|
||||
// registration sites cannot happen. Callers rebind by calling
|
||||
// `unregister` first.
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const handler = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
const first = handler(exampleBinding('toggle', 'KeyK')) as { ok: boolean }
|
||||
const second = handler(exampleBinding('toggle', 'KeyZ')) as { id: string, ok: boolean, reason?: string }
|
||||
|
||||
expect(first.ok).toBe(true)
|
||||
expect(second).toEqual({ id: 'toggle', ok: false, reason: ShortcutFailureReasons.DuplicateId })
|
||||
expect(m.registerMock).toHaveBeenCalledTimes(1)
|
||||
expect(m.unregisterMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows re-register after explicit unregister', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
const unreg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:unregister')!
|
||||
|
||||
reg(exampleBinding('toggle', 'KeyK'))
|
||||
unreg({ id: 'toggle' })
|
||||
const result = reg(exampleBinding('toggle', 'KeyZ')) as { ok: boolean }
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(m.registerMock).toHaveBeenLastCalledWith('CmdOrCtrl+Shift+Z', expect.any(Function))
|
||||
})
|
||||
|
||||
it('broadcasts a "down" trigger to every registered context', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctxA = createMockContext()
|
||||
const ctxB = createMockContext()
|
||||
registerMockWindow(service, ctxA)
|
||||
registerMockWindow(service, ctxB)
|
||||
|
||||
const handler = ctxA.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
handler(exampleBinding('toggle'))
|
||||
|
||||
const callback = m.triggerCallbacks.get('CmdOrCtrl+Shift+K')
|
||||
expect(callback).toBeDefined()
|
||||
callback!()
|
||||
|
||||
expect(ctxA.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'eventa:event:electron:shortcut:triggered' }),
|
||||
{ id: 'toggle', phase: 'down' },
|
||||
)
|
||||
expect(ctxB.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'eventa:event:electron:shortcut:triggered' }),
|
||||
{ id: 'toggle', phase: 'down' },
|
||||
)
|
||||
})
|
||||
|
||||
it('unregister removes the active binding', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
reg(exampleBinding('toggle'))
|
||||
const unreg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:unregister')!
|
||||
unreg({ id: 'toggle' })
|
||||
|
||||
expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+K')
|
||||
})
|
||||
|
||||
it('list returns currently active bindings', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
reg(exampleBinding('a', 'KeyA'))
|
||||
reg(exampleBinding('b', 'KeyB'))
|
||||
|
||||
const list = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:list')!
|
||||
const result = list(undefined) as ShortcutBinding[]
|
||||
expect(result.map(b => b.id).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('unregisterAll only unregisters bindings owned by this service', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
reg(exampleBinding('a', 'KeyA'))
|
||||
reg(exampleBinding('b', 'KeyB'))
|
||||
const unregAll = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:unregister-all')!
|
||||
unregAll(undefined)
|
||||
|
||||
expect(m.unregisterAllMock).not.toHaveBeenCalled()
|
||||
expect(m.unregisterMock).toHaveBeenCalledTimes(2)
|
||||
expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+A')
|
||||
expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+B')
|
||||
|
||||
const list = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:list')!
|
||||
expect(list(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('removes a context from broadcast set when its window closes', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
|
||||
const ctxA = createMockContext()
|
||||
const ctxB = createMockContext()
|
||||
const winA = registerMockWindow(service, ctxA)
|
||||
registerMockWindow(service, ctxB)
|
||||
|
||||
const handler = ctxA.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
handler(exampleBinding('toggle'))
|
||||
|
||||
// ctxA's window closes; subsequent triggers should only reach ctxB
|
||||
winA.close()
|
||||
const callback = m.triggerCallbacks.get('CmdOrCtrl+Shift+K')!
|
||||
callback()
|
||||
|
||||
expect(ctxA.emit).not.toHaveBeenCalled()
|
||||
expect(ctxB.emit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'eventa:event:electron:shortcut:triggered' }),
|
||||
{ id: 'toggle', phase: 'down' },
|
||||
)
|
||||
})
|
||||
|
||||
it('hooks dispose into onAppBeforeQuit and clears state on call', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
expect(m.onAppBeforeQuitMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
reg(exampleBinding('a'))
|
||||
|
||||
service.dispose()
|
||||
expect(m.unregisterMock).toHaveBeenCalledWith('CmdOrCtrl+Shift+K')
|
||||
|
||||
// After dispose, a fresh trigger callback should not reach contexts
|
||||
const callback = m.triggerCallbacks.get('CmdOrCtrl+Shift+K')
|
||||
callback?.()
|
||||
expect(ctx.emit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed register payloads at the IPC boundary', async () => {
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const reg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:register')!
|
||||
expect(() => reg({})).toThrow(TypeError)
|
||||
expect(() => reg({ id: 'no-accel' })).toThrow(TypeError)
|
||||
expect(() => reg({ accelerator: { modifiers: [], key: 'KeyK' } })).toThrow(TypeError)
|
||||
expect(m.registerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores unregister payloads with missing id and skips unknown ids', async () => {
|
||||
// The Eventa contract types `payload` as `{ id: string }`, so a
|
||||
// `null`/`undefined` payload is a programmer error and surfaces as
|
||||
// a thrown TypeError. A well-shaped payload with an empty or
|
||||
// unknown id is a no-op.
|
||||
const m = await setupMocks()
|
||||
const service = m.setupGlobalShortcutService()
|
||||
const ctx = createMockContext()
|
||||
registerMockWindow(service, ctx)
|
||||
|
||||
const unreg = ctx.invokeHandlers.get('eventa:invoke:electron:shortcut:unregister')!
|
||||
expect(() => unreg({ id: '' })).not.toThrow()
|
||||
expect(() => unreg({ id: 'never-registered' })).not.toThrow()
|
||||
expect(m.unregisterMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import type { ShortcutBinding, ShortcutRegistrationResult } from '@proj-airi/stage-shared/global-shortcut'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { formatElectronAccelerator, ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
|
||||
import { globalShortcut } from 'electron'
|
||||
|
||||
import {
|
||||
electronShortcutList,
|
||||
electronShortcutRegister,
|
||||
electronShortcutTriggered,
|
||||
electronShortcutUnregister,
|
||||
electronShortcutUnregisterAll,
|
||||
} from '../../../shared/eventa'
|
||||
import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle'
|
||||
|
||||
export type EventaContext = ReturnType<typeof createContext>['context']
|
||||
|
||||
export interface RegisterWindowParams {
|
||||
context: EventaContext
|
||||
window: BrowserWindow
|
||||
}
|
||||
|
||||
export interface GlobalShortcutService {
|
||||
/**
|
||||
* Register a per-window eventa context. Invoke handlers are installed
|
||||
* on the context; trigger events are broadcast to every registered
|
||||
* context, so each window's renderer receives them. Auto-removes on
|
||||
* `window.on('closed')`.
|
||||
*/
|
||||
registerWindow: (params: RegisterWindowParams) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
interface ActiveBinding {
|
||||
binding: ShortcutBinding
|
||||
electronAccelerator: string
|
||||
}
|
||||
|
||||
export function setupGlobalShortcutService(): GlobalShortcutService {
|
||||
const log = useLogg('global-shortcut').useGlobalConfig()
|
||||
|
||||
const contexts = new Set<EventaContext>()
|
||||
const active = new Map<string, ActiveBinding>()
|
||||
|
||||
function broadcastTriggered(id: string, phase: 'down' | 'up') {
|
||||
for (const context of contexts) {
|
||||
try {
|
||||
context.emit(electronShortcutTriggered, { id, phase })
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).warn(`Failed to emit shortcut trigger for "${id}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryRegister(binding: ShortcutBinding): ShortcutRegistrationResult {
|
||||
if (binding.receiveKeyUps) {
|
||||
// Electron's `globalShortcut` only fires on press. A separate
|
||||
// driver path (uiohook-napi) handles `receiveKeyUps: true`;
|
||||
// this driver refuses honestly until that path is wired.
|
||||
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Unsupported }
|
||||
}
|
||||
|
||||
if (active.has(binding.id)) {
|
||||
// Callers must `unregister` first to rebind. Avoids silent overrides
|
||||
// between unrelated registration sites.
|
||||
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.DuplicateId }
|
||||
}
|
||||
|
||||
const electronAccelerator = formatElectronAccelerator(binding.accelerator)
|
||||
const ok = globalShortcut.register(electronAccelerator, () => broadcastTriggered(binding.id, 'down'))
|
||||
|
||||
if (!ok) {
|
||||
// `globalShortcut.register` returns false for several distinct
|
||||
// causes (held by another app, or denied by the OS for media
|
||||
// keys / Accessibility-gated combos on macOS). Electron does not
|
||||
// expose which case applied, so this driver reports `Conflict`
|
||||
// for both. A future driver path (XDG portal, native macOS) can
|
||||
// emit `Denied` directly.
|
||||
return { id: binding.id, ok: false, reason: ShortcutFailureReasons.Conflict }
|
||||
}
|
||||
|
||||
active.set(binding.id, { binding, electronAccelerator })
|
||||
return { id: binding.id, ok: true }
|
||||
}
|
||||
|
||||
function unregisterById(id: string): void {
|
||||
const entry = active.get(id)
|
||||
if (!entry)
|
||||
return
|
||||
try {
|
||||
globalShortcut.unregister(entry.electronAccelerator)
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).warn(`Failed to unregister accelerator for "${id}"`)
|
||||
}
|
||||
active.delete(id)
|
||||
}
|
||||
|
||||
function unregisterAll(): void {
|
||||
for (const [id, entry] of active) {
|
||||
try {
|
||||
globalShortcut.unregister(entry.electronAccelerator)
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).warn(`Failed to unregister accelerator for "${id}"`)
|
||||
}
|
||||
}
|
||||
active.clear()
|
||||
}
|
||||
|
||||
const registerWindow: GlobalShortcutService['registerWindow'] = ({ context, window }) => {
|
||||
contexts.add(context)
|
||||
window.on('closed', () => {
|
||||
contexts.delete(context)
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronShortcutRegister, (binding) => {
|
||||
if (!binding.id) {
|
||||
throw new TypeError('electronShortcutRegister called with invalid binding payload')
|
||||
}
|
||||
return tryRegister(binding)
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronShortcutUnregister, (payload) => {
|
||||
if (!payload.id)
|
||||
return
|
||||
unregisterById(payload.id)
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronShortcutUnregisterAll, () => {
|
||||
unregisterAll()
|
||||
})
|
||||
|
||||
defineInvokeHandler(context, electronShortcutList, () => {
|
||||
return Array.from(active.values(), entry => entry.binding)
|
||||
})
|
||||
}
|
||||
|
||||
const dispose: GlobalShortcutService['dispose'] = () => {
|
||||
unregisterAll()
|
||||
contexts.clear()
|
||||
}
|
||||
|
||||
onAppBeforeQuit(() => dispose())
|
||||
|
||||
return { registerWindow, dispose }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './app'
|
||||
export * from './auto-updater'
|
||||
export * from './global-shortcut'
|
||||
export * from './powerMonitor'
|
||||
export * from './screen'
|
||||
export * from './window'
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ServerChannel } from '../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../services/airi/godot-stage'
|
||||
import type { McpStdioManager } from '../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../services/electron/auto-updater'
|
||||
import type { GlobalShortcutService } from '../../services/electron/global-shortcut'
|
||||
import type { DevtoolsWindowManager } from '../devtools'
|
||||
import type { WidgetsWindowManager } from '../widgets'
|
||||
|
||||
@@ -35,6 +36,7 @@ export function setupSettingsWindowReusableFunc(params: {
|
||||
mcpStdioManager: McpStdioManager
|
||||
i18n: I18n
|
||||
windowAuthManager: WindowAuthManager
|
||||
globalShortcut: GlobalShortcutService
|
||||
}): SettingsWindowManager {
|
||||
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
|
||||
const defaultRoute = '/settings'
|
||||
@@ -74,6 +76,7 @@ export function setupSettingsWindowReusableFunc(params: {
|
||||
mcpStdioManager: params.mcpStdioManager,
|
||||
i18n: params.i18n,
|
||||
windowAuthManager: params.windowAuthManager,
|
||||
globalShortcut: params.globalShortcut,
|
||||
})
|
||||
|
||||
await load(window, withHashRoute(rendererBase, currentRoute))
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ServerChannel } from '../../../services/airi/channel-server'
|
||||
import type { GodotStageManager } from '../../../services/airi/godot-stage'
|
||||
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
|
||||
import type { AutoUpdater } from '../../../services/electron/auto-updater'
|
||||
import type { GlobalShortcutService } from '../../../services/electron/global-shortcut'
|
||||
import type { DevtoolsWindowManager } from '../../devtools'
|
||||
import type { WidgetsWindowManager } from '../../widgets'
|
||||
|
||||
@@ -31,6 +32,7 @@ export async function setupSettingsWindowInvokes(params: {
|
||||
mcpStdioManager: McpStdioManager
|
||||
i18n: I18n
|
||||
windowAuthManager: WindowAuthManager
|
||||
globalShortcut: GlobalShortcutService
|
||||
}) {
|
||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
||||
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
|
||||
@@ -47,6 +49,9 @@ export async function setupSettingsWindowInvokes(params: {
|
||||
createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow })
|
||||
createAuthService({ context, window: params.settingsWindow, windowAuthManager: params.windowAuthManager })
|
||||
|
||||
// Register the global shortcut service for the settings window.
|
||||
params.globalShortcut.registerWindow({ context, window: params.settingsWindow })
|
||||
|
||||
defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
|
||||
defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => {
|
||||
await params.devtoolsWindow.openWindow(payload)
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ShortcutAccelerator,
|
||||
ShortcutBinding,
|
||||
ShortcutRegistrationResult,
|
||||
} from '@proj-airi/stage-shared/global-shortcut'
|
||||
|
||||
import type { ElectronShortcutTriggerPhase } from '../../../shared/eventa'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { getElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { formatAccelerator, parseAccelerator } from '@proj-airi/stage-shared/global-shortcut'
|
||||
import { Button, FieldCheckbox, FieldInput } from '@proj-airi/ui'
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
|
||||
import {
|
||||
electronShortcutList,
|
||||
electronShortcutRegister,
|
||||
electronShortcutTriggered,
|
||||
electronShortcutUnregister,
|
||||
electronShortcutUnregisterAll,
|
||||
} from '../../../shared/eventa'
|
||||
|
||||
interface FormState {
|
||||
id: string
|
||||
acceleratorText: string
|
||||
receiveKeyUps: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
interface TriggerLogEntry {
|
||||
time: number
|
||||
id: string
|
||||
phase: ElectronShortcutTriggerPhase
|
||||
}
|
||||
|
||||
const TRIGGER_LOG_LIMIT = 50
|
||||
|
||||
const form = reactive<FormState>({
|
||||
id: '',
|
||||
acceleratorText: 'Mod+Shift+K',
|
||||
receiveKeyUps: false,
|
||||
description: '',
|
||||
})
|
||||
|
||||
const lastResult = ref<ShortcutRegistrationResult | null>(null)
|
||||
const lastError = ref('')
|
||||
const active = ref<ShortcutBinding[]>([])
|
||||
const triggers = ref<TriggerLogEntry[]>([])
|
||||
const busy = ref(false)
|
||||
|
||||
const registerShortcut = useElectronEventaInvoke(electronShortcutRegister)
|
||||
const unregisterShortcut = useElectronEventaInvoke(electronShortcutUnregister)
|
||||
const unregisterAllShortcuts = useElectronEventaInvoke(electronShortcutUnregisterAll)
|
||||
const listShortcuts = useElectronEventaInvoke(electronShortcutList)
|
||||
|
||||
async function refreshList() {
|
||||
try {
|
||||
active.value = await listShortcuts()
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = errorMessageFrom(error) ?? 'Failed to list bindings'
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseAccelerator(): ShortcutAccelerator | null {
|
||||
try {
|
||||
return parseAccelerator(form.acceleratorText)
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = errorMessageFrom(error) ?? 'Invalid accelerator'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
lastError.value = ''
|
||||
if (!form.id.trim()) {
|
||||
lastError.value = 'Id is required.'
|
||||
return
|
||||
}
|
||||
const accelerator = tryParseAccelerator()
|
||||
if (!accelerator)
|
||||
return
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
lastResult.value = await registerShortcut({
|
||||
id: form.id.trim(),
|
||||
accelerator,
|
||||
scope: 'global',
|
||||
receiveKeyUps: form.receiveKeyUps,
|
||||
description: form.description.trim() || undefined,
|
||||
})
|
||||
await refreshList()
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = errorMessageFrom(error) ?? 'Register failed'
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnregister(id: string) {
|
||||
busy.value = true
|
||||
try {
|
||||
await unregisterShortcut({ id })
|
||||
await refreshList()
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = errorMessageFrom(error) ?? `Unregister failed for "${id}"`
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnregisterAll() {
|
||||
busy.value = true
|
||||
try {
|
||||
await unregisterAllShortcuts()
|
||||
await refreshList()
|
||||
}
|
||||
catch (error) {
|
||||
lastError.value = errorMessageFrom(error) ?? 'Unregister all failed'
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
triggers.value = []
|
||||
}
|
||||
|
||||
function pushTrigger(entry: TriggerLogEntry) {
|
||||
triggers.value.unshift(entry)
|
||||
if (triggers.value.length > TRIGGER_LOG_LIMIT)
|
||||
triggers.value.length = TRIGGER_LOG_LIMIT
|
||||
}
|
||||
|
||||
function formatTime(ms: number) {
|
||||
return new Date(ms).toLocaleTimeString()
|
||||
}
|
||||
|
||||
let disposeTriggerListener: (() => void) | undefined
|
||||
|
||||
onMounted(async () => {
|
||||
const context = getElectronEventaContext()
|
||||
disposeTriggerListener = context.on(electronShortcutTriggered, (event) => {
|
||||
const payload = event?.body
|
||||
if (!payload)
|
||||
return
|
||||
pushTrigger({ time: Date.now(), id: payload.id, phase: payload.phase })
|
||||
})
|
||||
await refreshList()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeTriggerListener?.()
|
||||
disposeTriggerListener = undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6 space-y-6">
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-300">
|
||||
Register a global shortcut, watch trigger events fire, exercise the
|
||||
driver's refusal paths (duplicate id, conflict, unsupported).
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<FieldInput
|
||||
v-model="form.id"
|
||||
label="Id"
|
||||
description="Stable handle, e.g. toggle-main-window"
|
||||
placeholder="my-shortcut"
|
||||
/>
|
||||
<FieldInput
|
||||
v-model="form.acceleratorText"
|
||||
label="Accelerator"
|
||||
description="e.g. Mod+Shift+K, Cmd+Shift+1, Ctrl+Alt+F12"
|
||||
placeholder="Mod+Shift+K"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<FieldInput
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
description="Surfaced in settings UI."
|
||||
:required="false"
|
||||
/>
|
||||
<FieldCheckbox
|
||||
v-model="form.receiveKeyUps"
|
||||
label="Receive key-ups"
|
||||
description="Asks the driver to also emit on release. Electron driver refuses with 'unsupported'."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button variant="primary" :disabled="busy" @click="handleRegister">
|
||||
Register
|
||||
</Button>
|
||||
<Button variant="secondary" :disabled="busy" @click="refreshList">
|
||||
Refresh List
|
||||
</Button>
|
||||
<Button
|
||||
class="ml-auto"
|
||||
variant="danger"
|
||||
:disabled="busy"
|
||||
@click="handleUnregisterAll"
|
||||
>
|
||||
Unregister All
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="lastError" class="text-danger-200/90 text-sm">
|
||||
{{ lastError }}
|
||||
</div>
|
||||
<div
|
||||
v-if="lastResult"
|
||||
:class="[
|
||||
'rounded p-3 space-y-1',
|
||||
'text-xs font-mono',
|
||||
'bg-neutral-100 dark:bg-neutral-800',
|
||||
]"
|
||||
>
|
||||
<div>id: {{ lastResult.id }}</div>
|
||||
<div>ok: {{ lastResult.ok }}</div>
|
||||
<div v-if="!lastResult.ok">
|
||||
reason: {{ lastResult.reason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-sm text-neutral-700 font-semibold dark:text-neutral-200">
|
||||
Active bindings ({{ active.length }})
|
||||
</h3>
|
||||
<div
|
||||
v-if="active.length === 0"
|
||||
:class="[
|
||||
'rounded-2xl border-2 border-dashed border-neutral-200/70 dark:border-neutral-800/40',
|
||||
'px-4 py-6',
|
||||
'text-sm text-neutral-500',
|
||||
]"
|
||||
>
|
||||
No bindings registered yet.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'overflow-hidden rounded-lg',
|
||||
'border border-neutral-200 dark:border-neutral-800',
|
||||
]"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-neutral-100 dark:bg-neutral-900">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">
|
||||
Id
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left">
|
||||
Accelerator
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left">
|
||||
Receive Key-Ups
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left">
|
||||
Description
|
||||
</th>
|
||||
<th class="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="b in active"
|
||||
:key="b.id"
|
||||
class="border-t border-neutral-200 dark:border-neutral-800"
|
||||
>
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{{ b.id }}
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{{ formatAccelerator(b.accelerator) }}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
{{ b.receiveKeyUps ? 'yes' : 'no' }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
||||
{{ b.description || '—' }}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:disabled="busy"
|
||||
@click="handleUnregister(b.id)"
|
||||
>
|
||||
Unregister
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm text-neutral-700 font-semibold dark:text-neutral-200">
|
||||
Trigger log ({{ triggers.length }})
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:disabled="triggers.length === 0"
|
||||
@click="clearLog"
|
||||
>
|
||||
Clear log
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="triggers.length === 0"
|
||||
:class="[
|
||||
'rounded-2xl border-2 border-dashed border-neutral-200/70 dark:border-neutral-800/40',
|
||||
'px-4 py-6',
|
||||
'text-sm text-neutral-500',
|
||||
]"
|
||||
>
|
||||
No triggers yet. Register a shortcut and press the combo.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'overflow-hidden rounded-lg',
|
||||
'border border-neutral-200 dark:border-neutral-800',
|
||||
]"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-neutral-100 dark:bg-neutral-900">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">
|
||||
Time
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left">
|
||||
Id
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left">
|
||||
Phase
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(t, i) in triggers"
|
||||
:key="`${t.time}-${i}`"
|
||||
class="border-t border-neutral-200 dark:border-neutral-800"
|
||||
>
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{{ formatTime(t.time) }}
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{{ t.id }}
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono">
|
||||
{{ t.phase }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
title: Global Shortcut
|
||||
subtitleKey: tamagotchi.settings.devtools.title
|
||||
</route>
|
||||
@@ -91,6 +91,12 @@ const menu = computed(() => [
|
||||
icon: 'i-solar:eye-closed-bold-duotone',
|
||||
to: '/devtools/vision',
|
||||
},
|
||||
{
|
||||
title: 'Global Shortcut',
|
||||
description: 'Register/unregister global shortcuts and watch trigger events fire',
|
||||
icon: 'i-solar:keyboard-bold-duotone',
|
||||
to: '/devtools/global-shortcut',
|
||||
},
|
||||
])
|
||||
|
||||
const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
|
||||
|
||||
@@ -345,9 +345,9 @@ export const electronGodotStageStatusChanged = defineEventa<ElectronGodotStageSt
|
||||
/**
|
||||
* Phase of a shortcut trigger event.
|
||||
*
|
||||
* - `down` — key-combination pressed
|
||||
* - `up` — key-combination released; only emitted by drivers that set
|
||||
* `ok: true` for bindings with `receiveKeyUps: true`
|
||||
* - `down` — key combination pressed
|
||||
* - `up` — key combination released; only emitted by drivers that
|
||||
* accepted a binding with `receiveKeyUps: true`
|
||||
*/
|
||||
export type ElectronShortcutTriggerPhase = 'down' | 'up'
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ words:
|
||||
- msvc
|
||||
- multisampling
|
||||
- Myriam
|
||||
- napi
|
||||
- ndarray
|
||||
- Neko
|
||||
- nekomeowww
|
||||
@@ -316,6 +317,7 @@ words:
|
||||
- tsdown
|
||||
- ttft
|
||||
- turborepo
|
||||
- uiohook
|
||||
- unbird
|
||||
- unbundle
|
||||
- unconfig
|
||||
|
||||
@@ -66,7 +66,7 @@ signIn:
|
||||
footer:
|
||||
prefix: Al continuar, aceptas nuestros
|
||||
terms: Términos
|
||||
and: "y"
|
||||
and: 'y'
|
||||
privacy: Política de Privacidad
|
||||
verifyEmail:
|
||||
title:
|
||||
|
||||
@@ -685,7 +685,7 @@ pages:
|
||||
empty: Здесь пока ничего нет. Добавьте одно ниже!
|
||||
add:
|
||||
title: Новый
|
||||
description: "Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше."
|
||||
description: 'Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше.'
|
||||
pending-badge: Не сохранено
|
||||
status:
|
||||
unknown: Не загружен
|
||||
@@ -902,7 +902,7 @@ pages:
|
||||
description: >-
|
||||
Провайдеры транскрипции (speech-to-text): Whisper.cpp, OpenAI, Azure Speech
|
||||
artistry:
|
||||
title: Artistry
|
||||
title: Artistry
|
||||
description: Поставщики моделей генерации и создания изображений, например ComfyUI, Replicate.
|
||||
items:
|
||||
comfyui:
|
||||
|
||||
@@ -685,7 +685,7 @@ pages:
|
||||
empty: 这里什么都还没有哦,在下面添加一个!
|
||||
add:
|
||||
title: 新建
|
||||
description: "填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」"
|
||||
description: '填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」'
|
||||
pending-badge: 未保存
|
||||
status:
|
||||
unknown: 未加载
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { ShortcutAccelerator, ShortcutKey, ShortcutModifier } from './types
|
||||
* are an ergonomic input/output format only.
|
||||
*
|
||||
* Two output flavours are provided:
|
||||
* - `formatAccelerator` — canonical IR (`"Mod+Shift+KeyK"`),
|
||||
* round-trips losslessly through
|
||||
* `parseAccelerator`.
|
||||
* - `formatAccelerator` — canonical string form
|
||||
* (`"Mod+Shift+KeyK"`); round-trips
|
||||
* losslessly through `parseAccelerator`.
|
||||
* - `formatElectronAccelerator` — Electron's accelerator string
|
||||
* (`"CmdOrCtrl+Shift+K"`), suitable for
|
||||
* passing directly to
|
||||
@@ -182,10 +182,11 @@ const MODIFIER_CANONICAL_ORDER: readonly ShortcutModifier[] = [
|
||||
]
|
||||
|
||||
/**
|
||||
* Title-case modifier tokens used by `formatAccelerator` (canonical IR
|
||||
* output). Mirrors Tauri/Electron casing so output is recognizable.
|
||||
* Title-case modifier tokens used by `formatAccelerator` (canonical
|
||||
* string output). Mirrors Tauri/Electron casing so output is
|
||||
* recognizable.
|
||||
*/
|
||||
const MODIFIER_TO_IR_TOKEN: Readonly<Record<ShortcutModifier, string>> = {
|
||||
const MODIFIER_TO_CANONICAL_TOKEN: Readonly<Record<ShortcutModifier, string>> = {
|
||||
'cmd-or-ctrl': 'Mod',
|
||||
'cmd': 'Cmd',
|
||||
'ctrl': 'Ctrl',
|
||||
@@ -368,7 +369,7 @@ function canonicalModifiers(acc: ShortcutAccelerator): ShortcutModifier[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a structured accelerator back to canonical IR string
|
||||
* Serializes a structured accelerator back to its canonical string
|
||||
* form.
|
||||
*
|
||||
* Use when:
|
||||
@@ -385,7 +386,7 @@ function canonicalModifiers(acc: ShortcutAccelerator): ShortcutModifier[] {
|
||||
* // => 'Mod+Shift+KeyK'
|
||||
*/
|
||||
export function formatAccelerator(acc: ShortcutAccelerator): string {
|
||||
const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_IR_TOKEN[m])
|
||||
const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_CANONICAL_TOKEN[m])
|
||||
tokens.push(acc.key)
|
||||
return tokens.join('+')
|
||||
}
|
||||
|
||||
@@ -62,8 +62,10 @@ export interface ShortcutBinding {
|
||||
/**
|
||||
* Whether the driver should also emit key-release events.
|
||||
*
|
||||
* Drivers that cannot deliver release events refuse the
|
||||
* registration with `{ ok: false, reason: 'unsupported' }`.
|
||||
* Drivers that cannot deliver release events refuse the registration
|
||||
* with `{ ok: false, reason: ShortcutFailureReasons.Unsupported }`. The Electron
|
||||
* `globalShortcut` driver currently refuses; a uiohook-based driver
|
||||
* path is planned to honour this flag.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
@@ -72,6 +74,42 @@ export interface ShortcutBinding {
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed set of failure reasons returned by drivers.
|
||||
*
|
||||
* Drivers translate platform-specific failures into one of these
|
||||
* values at the boundary; raw underlying errors stay in driver logs,
|
||||
* not on the wire. Add a new value here before any driver may emit it.
|
||||
*/
|
||||
export const ShortcutFailureReasons = {
|
||||
/**
|
||||
* The accelerator is held by another app or by another binding here
|
||||
* under a different id.
|
||||
*/
|
||||
Conflict: 'conflict',
|
||||
/**
|
||||
* An active binding already uses this id; callers must `unregister`
|
||||
* first to rebind.
|
||||
*/
|
||||
DuplicateId: 'duplicate-id',
|
||||
/**
|
||||
* The OS or portal refused the registration (e.g. user declined a
|
||||
* Wayland portal dialog, macOS denied Accessibility for a media-key
|
||||
* combo). Drivers that can distinguish denial from conflict report
|
||||
* this; the Electron `globalShortcut` driver cannot distinguish and
|
||||
* reports `Conflict` for both.
|
||||
*/
|
||||
Denied: 'denied',
|
||||
/**
|
||||
* The driver cannot satisfy the request (e.g. a binding asks for
|
||||
* `receiveKeyUps: true` on a driver path that only delivers
|
||||
* presses).
|
||||
*/
|
||||
Unsupported: 'unsupported',
|
||||
} as const
|
||||
|
||||
export type ShortcutFailureReason = typeof ShortcutFailureReasons[keyof typeof ShortcutFailureReasons]
|
||||
|
||||
/**
|
||||
* Outcome of a registration request.
|
||||
*
|
||||
@@ -79,21 +117,10 @@ export interface ShortcutBinding {
|
||||
* `actualAccelerator` is populated when the host had to substitute the
|
||||
* requested accelerator (e.g. user choice via a Wayland portal dialog).
|
||||
*/
|
||||
export interface ShortcutRegistrationResult {
|
||||
id: string
|
||||
ok: boolean
|
||||
/**
|
||||
* The accelerator the host actually bound. Absent when the request
|
||||
* was honoured verbatim.
|
||||
*/
|
||||
actualAccelerator?: ShortcutAccelerator
|
||||
/**
|
||||
* Failure reason. Known values: `'conflict'`, `'denied'`,
|
||||
* `'unsupported'`. Drivers may emit other strings; treat unknown
|
||||
* values as opaque.
|
||||
*/
|
||||
reason?: 'conflict' | 'denied' | 'unsupported' | string
|
||||
}
|
||||
export type ShortcutRegistrationResult
|
||||
= { id: string }
|
||||
& ({ ok: true, actualAccelerator?: ShortcutAccelerator }
|
||||
| { ok: false, reason: ShortcutFailureReason })
|
||||
|
||||
/**
|
||||
* In-memory shortcut config. Bump `version` on any breaking schema
|
||||
|
||||
Reference in New Issue
Block a user