From d02a76f944cbd658de576cf262f398ff95da96d6 Mon Sep 17 00:00:00 2001 From: Makito Date: Sun, 10 May 2026 20:38:47 +0900 Subject: [PATCH] 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 --- Screenshot 2026-05-10 at 19 33 45 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- apps/stage-tamagotchi/src/main/index.ts | 5 +- .../services/electron/global-shortcut.test.ts | 376 +++++++++++++++++ .../main/services/electron/global-shortcut.ts | 151 +++++++ .../src/main/services/electron/index.ts | 1 + .../src/main/windows/settings/index.ts | 3 + .../windows/settings/rpc/index.electron.ts | 5 + .../pages/devtools/global-shortcut.vue | 382 ++++++++++++++++++ .../pages/settings/system/developer.vue | 6 + .../src/shared/eventa/index.ts | 6 +- cspell.config.yaml | 2 + packages/i18n/src/locales/es/server/auth.yaml | 2 +- packages/i18n/src/locales/ru/settings.yaml | 4 +- .../i18n/src/locales/zh-Hans/settings.yaml | 2 +- .../src/global-shortcut/accelerators.ts | 17 +- .../stage-shared/src/global-shortcut/types.ts | 61 ++- 15 files changed, 990 insertions(+), 33 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts create mode 100644 apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts create mode 100644 apps/stage-tamagotchi/src/renderer/pages/devtools/global-shortcut.vue diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 76cabba21..0d4c2679e 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -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), }) diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts new file mode 100644 index 000000000..e340f2cf5 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.test.ts @@ -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 + invokeHandlers: Map unknown> +} + +interface MockWindow { + on: ReturnType + /** 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 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>() + + vi.doMock('electron', () => ({ + globalShortcut: { + register: registerMock, + unregister: unregisterMock, + unregisterAll: unregisterAllMock, + }, + })) + + vi.doMock('@moeru/eventa', async (importOriginal) => { + const actual = await importOriginal() + 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() + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts new file mode 100644 index 000000000..910ea9b7a --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/global-shortcut.ts @@ -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['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() + const active = new Map() + + 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 } +} diff --git a/apps/stage-tamagotchi/src/main/services/electron/index.ts b/apps/stage-tamagotchi/src/main/services/electron/index.ts index 6b58098a7..7b86cc531 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/index.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/index.ts @@ -1,5 +1,6 @@ export * from './app' export * from './auto-updater' +export * from './global-shortcut' export * from './powerMonitor' export * from './screen' export * from './window' diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 045aedea7..683059328 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -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)) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index 11a7bfc8c..40e5be354 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -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) diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/global-shortcut.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/global-shortcut.vue new file mode 100644 index 000000000..f26ea2c81 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/devtools/global-shortcut.vue @@ -0,0 +1,382 @@ + + + + + +meta: + layout: settings + title: Global Shortcut + subtitleKey: tamagotchi.settings.devtools.title + diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue index f913e6028..7d46f6d01 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue @@ -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) diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index 172be09b0..3c07b3ee4 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -345,9 +345,9 @@ export const electronGodotStageStatusChanged = defineEventa- Провайдеры транскрипции (speech-to-text): Whisper.cpp, OpenAI, Azure Speech artistry: - title: Artistry + title: Artistry description: Поставщики моделей генерации и создания изображений, например ComfyUI, Replicate. items: comfyui: diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index aa5166fd9..423a2a011 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -685,7 +685,7 @@ pages: empty: 这里什么都还没有哦,在下面添加一个! add: title: 新建 - description: "填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」" + description: '填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」' pending-badge: 未保存 status: unknown: 未加载 diff --git a/packages/stage-shared/src/global-shortcut/accelerators.ts b/packages/stage-shared/src/global-shortcut/accelerators.ts index 0f729227c..480e3d2e8 100644 --- a/packages/stage-shared/src/global-shortcut/accelerators.ts +++ b/packages/stage-shared/src/global-shortcut/accelerators.ts @@ -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> = { +const MODIFIER_TO_CANONICAL_TOKEN: Readonly> = { '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('+') } diff --git a/packages/stage-shared/src/global-shortcut/types.ts b/packages/stage-shared/src/global-shortcut/types.ts index 5ae3e1dd2..5a063f0d8 100644 --- a/packages/stage-shared/src/global-shortcut/types.ts +++ b/packages/stage-shared/src/global-shortcut/types.ts @@ -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