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 { setupPluginHost } from './services/airi/plugins'
|
||||||
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
||||||
import { setupAutoUpdater } from './services/electron/auto-updater'
|
import { setupAutoUpdater } from './services/electron/auto-updater'
|
||||||
|
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
|
||||||
import { setupTray } from './tray'
|
import { setupTray } from './tray'
|
||||||
import { setupAboutWindowReusable } from './windows/about'
|
import { setupAboutWindowReusable } from './windows/about'
|
||||||
import { setupBeatSync } from './windows/beat-sync'
|
import { setupBeatSync } from './windows/beat-sync'
|
||||||
@@ -156,6 +157,8 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
const windowAuthManager = injeca.provide('services:window-auth-manager', () => createWindowAuthManagerService())
|
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.
|
// BeatSync will create a background window to capture and process audio.
|
||||||
const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync())
|
const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync())
|
||||||
|
|
||||||
@@ -182,7 +185,7 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const settingsWindow = injeca.provide('windows:settings', {
|
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),
|
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 './app'
|
||||||
export * from './auto-updater'
|
export * from './auto-updater'
|
||||||
|
export * from './global-shortcut'
|
||||||
export * from './powerMonitor'
|
export * from './powerMonitor'
|
||||||
export * from './screen'
|
export * from './screen'
|
||||||
export * from './window'
|
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 { GodotStageManager } from '../../services/airi/godot-stage'
|
||||||
import type { McpStdioManager } from '../../services/airi/mcp-servers'
|
import type { McpStdioManager } from '../../services/airi/mcp-servers'
|
||||||
import type { AutoUpdater } from '../../services/electron/auto-updater'
|
import type { AutoUpdater } from '../../services/electron/auto-updater'
|
||||||
|
import type { GlobalShortcutService } from '../../services/electron/global-shortcut'
|
||||||
import type { DevtoolsWindowManager } from '../devtools'
|
import type { DevtoolsWindowManager } from '../devtools'
|
||||||
import type { WidgetsWindowManager } from '../widgets'
|
import type { WidgetsWindowManager } from '../widgets'
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export function setupSettingsWindowReusableFunc(params: {
|
|||||||
mcpStdioManager: McpStdioManager
|
mcpStdioManager: McpStdioManager
|
||||||
i18n: I18n
|
i18n: I18n
|
||||||
windowAuthManager: WindowAuthManager
|
windowAuthManager: WindowAuthManager
|
||||||
|
globalShortcut: GlobalShortcutService
|
||||||
}): SettingsWindowManager {
|
}): SettingsWindowManager {
|
||||||
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
|
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
|
||||||
const defaultRoute = '/settings'
|
const defaultRoute = '/settings'
|
||||||
@@ -74,6 +76,7 @@ export function setupSettingsWindowReusableFunc(params: {
|
|||||||
mcpStdioManager: params.mcpStdioManager,
|
mcpStdioManager: params.mcpStdioManager,
|
||||||
i18n: params.i18n,
|
i18n: params.i18n,
|
||||||
windowAuthManager: params.windowAuthManager,
|
windowAuthManager: params.windowAuthManager,
|
||||||
|
globalShortcut: params.globalShortcut,
|
||||||
})
|
})
|
||||||
|
|
||||||
await load(window, withHashRoute(rendererBase, currentRoute))
|
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 { GodotStageManager } from '../../../services/airi/godot-stage'
|
||||||
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
|
import type { McpStdioManager } from '../../../services/airi/mcp-servers'
|
||||||
import type { AutoUpdater } from '../../../services/electron/auto-updater'
|
import type { AutoUpdater } from '../../../services/electron/auto-updater'
|
||||||
|
import type { GlobalShortcutService } from '../../../services/electron/global-shortcut'
|
||||||
import type { DevtoolsWindowManager } from '../../devtools'
|
import type { DevtoolsWindowManager } from '../../devtools'
|
||||||
import type { WidgetsWindowManager } from '../../widgets'
|
import type { WidgetsWindowManager } from '../../widgets'
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export async function setupSettingsWindowInvokes(params: {
|
|||||||
mcpStdioManager: McpStdioManager
|
mcpStdioManager: McpStdioManager
|
||||||
i18n: I18n
|
i18n: I18n
|
||||||
windowAuthManager: WindowAuthManager
|
windowAuthManager: WindowAuthManager
|
||||||
|
globalShortcut: GlobalShortcutService
|
||||||
}) {
|
}) {
|
||||||
// TODO: once we refactored eventa to support window-namespaced contexts,
|
// 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
|
// 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 })
|
createGodotStageService({ context, manager: params.godotStageManager, window: params.settingsWindow })
|
||||||
createAuthService({ context, window: params.settingsWindow, windowAuthManager: params.windowAuthManager })
|
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, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' }))
|
||||||
defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => {
|
defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => {
|
||||||
await params.devtoolsWindow.openWindow(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',
|
icon: 'i-solar:eye-closed-bold-duotone',
|
||||||
to: '/devtools/vision',
|
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)
|
const openDevTools = useElectronEventaInvoke(electronOpenMainDevtools)
|
||||||
|
|||||||
@@ -345,9 +345,9 @@ export const electronGodotStageStatusChanged = defineEventa<ElectronGodotStageSt
|
|||||||
/**
|
/**
|
||||||
* Phase of a shortcut trigger event.
|
* Phase of a shortcut trigger event.
|
||||||
*
|
*
|
||||||
* - `down` — key-combination pressed
|
* - `down` — key combination pressed
|
||||||
* - `up` — key-combination released; only emitted by drivers that set
|
* - `up` — key combination released; only emitted by drivers that
|
||||||
* `ok: true` for bindings with `receiveKeyUps: true`
|
* accepted a binding with `receiveKeyUps: true`
|
||||||
*/
|
*/
|
||||||
export type ElectronShortcutTriggerPhase = 'down' | 'up'
|
export type ElectronShortcutTriggerPhase = 'down' | 'up'
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ words:
|
|||||||
- msvc
|
- msvc
|
||||||
- multisampling
|
- multisampling
|
||||||
- Myriam
|
- Myriam
|
||||||
|
- napi
|
||||||
- ndarray
|
- ndarray
|
||||||
- Neko
|
- Neko
|
||||||
- nekomeowww
|
- nekomeowww
|
||||||
@@ -316,6 +317,7 @@ words:
|
|||||||
- tsdown
|
- tsdown
|
||||||
- ttft
|
- ttft
|
||||||
- turborepo
|
- turborepo
|
||||||
|
- uiohook
|
||||||
- unbird
|
- unbird
|
||||||
- unbundle
|
- unbundle
|
||||||
- unconfig
|
- unconfig
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ signIn:
|
|||||||
footer:
|
footer:
|
||||||
prefix: Al continuar, aceptas nuestros
|
prefix: Al continuar, aceptas nuestros
|
||||||
terms: Términos
|
terms: Términos
|
||||||
and: "y"
|
and: 'y'
|
||||||
privacy: Política de Privacidad
|
privacy: Política de Privacidad
|
||||||
verifyEmail:
|
verifyEmail:
|
||||||
title:
|
title:
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ pages:
|
|||||||
empty: Здесь пока ничего нет. Добавьте одно ниже!
|
empty: Здесь пока ничего нет. Добавьте одно ниже!
|
||||||
add:
|
add:
|
||||||
title: Новый
|
title: Новый
|
||||||
description: "Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше."
|
description: 'Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше.'
|
||||||
pending-badge: Не сохранено
|
pending-badge: Не сохранено
|
||||||
status:
|
status:
|
||||||
unknown: Не загружен
|
unknown: Не загружен
|
||||||
@@ -902,7 +902,7 @@ pages:
|
|||||||
description: >-
|
description: >-
|
||||||
Провайдеры транскрипции (speech-to-text): Whisper.cpp, OpenAI, Azure Speech
|
Провайдеры транскрипции (speech-to-text): Whisper.cpp, OpenAI, Azure Speech
|
||||||
artistry:
|
artistry:
|
||||||
title: Artistry
|
title: Artistry
|
||||||
description: Поставщики моделей генерации и создания изображений, например ComfyUI, Replicate.
|
description: Поставщики моделей генерации и создания изображений, например ComfyUI, Replicate.
|
||||||
items:
|
items:
|
||||||
comfyui:
|
comfyui:
|
||||||
|
|||||||
@@ -685,7 +685,7 @@ pages:
|
|||||||
empty: 这里什么都还没有哦,在下面添加一个!
|
empty: 这里什么都还没有哦,在下面添加一个!
|
||||||
add:
|
add:
|
||||||
title: 新建
|
title: 新建
|
||||||
description: "填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」"
|
description: '填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」'
|
||||||
pending-badge: 未保存
|
pending-badge: 未保存
|
||||||
status:
|
status:
|
||||||
unknown: 未加载
|
unknown: 未加载
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import type { ShortcutAccelerator, ShortcutKey, ShortcutModifier } from './types
|
|||||||
* are an ergonomic input/output format only.
|
* are an ergonomic input/output format only.
|
||||||
*
|
*
|
||||||
* Two output flavours are provided:
|
* Two output flavours are provided:
|
||||||
* - `formatAccelerator` — canonical IR (`"Mod+Shift+KeyK"`),
|
* - `formatAccelerator` — canonical string form
|
||||||
* round-trips losslessly through
|
* (`"Mod+Shift+KeyK"`); round-trips
|
||||||
* `parseAccelerator`.
|
* losslessly through `parseAccelerator`.
|
||||||
* - `formatElectronAccelerator` — Electron's accelerator string
|
* - `formatElectronAccelerator` — Electron's accelerator string
|
||||||
* (`"CmdOrCtrl+Shift+K"`), suitable for
|
* (`"CmdOrCtrl+Shift+K"`), suitable for
|
||||||
* passing directly to
|
* passing directly to
|
||||||
@@ -182,10 +182,11 @@ const MODIFIER_CANONICAL_ORDER: readonly ShortcutModifier[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Title-case modifier tokens used by `formatAccelerator` (canonical IR
|
* Title-case modifier tokens used by `formatAccelerator` (canonical
|
||||||
* output). Mirrors Tauri/Electron casing so output is recognizable.
|
* 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-or-ctrl': 'Mod',
|
||||||
'cmd': 'Cmd',
|
'cmd': 'Cmd',
|
||||||
'ctrl': 'Ctrl',
|
'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.
|
* form.
|
||||||
*
|
*
|
||||||
* Use when:
|
* Use when:
|
||||||
@@ -385,7 +386,7 @@ function canonicalModifiers(acc: ShortcutAccelerator): ShortcutModifier[] {
|
|||||||
* // => 'Mod+Shift+KeyK'
|
* // => 'Mod+Shift+KeyK'
|
||||||
*/
|
*/
|
||||||
export function formatAccelerator(acc: ShortcutAccelerator): string {
|
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)
|
tokens.push(acc.key)
|
||||||
return tokens.join('+')
|
return tokens.join('+')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,10 @@ export interface ShortcutBinding {
|
|||||||
/**
|
/**
|
||||||
* Whether the driver should also emit key-release events.
|
* Whether the driver should also emit key-release events.
|
||||||
*
|
*
|
||||||
* Drivers that cannot deliver release events refuse the
|
* Drivers that cannot deliver release events refuse the registration
|
||||||
* registration with `{ ok: false, reason: 'unsupported' }`.
|
* 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
|
* @default false
|
||||||
*/
|
*/
|
||||||
@@ -72,6 +74,42 @@ export interface ShortcutBinding {
|
|||||||
description?: string
|
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.
|
* Outcome of a registration request.
|
||||||
*
|
*
|
||||||
@@ -79,21 +117,10 @@ export interface ShortcutBinding {
|
|||||||
* `actualAccelerator` is populated when the host had to substitute the
|
* `actualAccelerator` is populated when the host had to substitute the
|
||||||
* requested accelerator (e.g. user choice via a Wayland portal dialog).
|
* requested accelerator (e.g. user choice via a Wayland portal dialog).
|
||||||
*/
|
*/
|
||||||
export interface ShortcutRegistrationResult {
|
export type ShortcutRegistrationResult
|
||||||
id: string
|
= { id: string }
|
||||||
ok: boolean
|
& ({ ok: true, actualAccelerator?: ShortcutAccelerator }
|
||||||
/**
|
| { ok: false, reason: ShortcutFailureReason })
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory shortcut config. Bump `version` on any breaking schema
|
* In-memory shortcut config. Bump `version` on any breaking schema
|
||||||
|
|||||||
Reference in New Issue
Block a user