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:
Makito
2026-05-10 20:38:47 +09:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 37807b84d6
commit d02a76f944
15 changed files with 990 additions and 33 deletions
+4 -1
View File
@@ -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'
+2
View File
@@ -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:
+2 -2
View File
@@ -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