feat(desktop): add KeybindManager with uiohook-napi for global shortcuts
Installs uiohook-napi for OS-level non-consuming input hooks, creates KeybindManager class that receives keybind configs from the renderer, matches the OS-wide key/mouse event stream, and sends matched actions back via IPC. Exposes syncKeybinds, onKeybindAction, onAccessibilityStatus, onKeybindHookError, and checkAccessibility APIs through the preload bridge.
This commit is contained in:
@@ -14,12 +14,15 @@
|
||||
"dev": "mkdir -p build && cp ../../icon.png build/icon.png && bash scripts/gen-icns.sh && cp build/icon.icns $(find ../../node_modules -path '*/electron/dist/Electron.app/Contents/Resources/electron.icns' 2>/dev/null | head -1) 2>/dev/null; tsc && electron .",
|
||||
"prebuild": "mkdir -p build && cp ../../icon.png build/icon.png",
|
||||
"build": "tsc && electron-builder",
|
||||
"clean": "rm -rf dist dist-electron"
|
||||
"clean": "rm -rf dist dist-electron",
|
||||
"postinstall": "electron-rebuild -f -w uiohook-napi"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.0"
|
||||
"electron-updater": "^6.3.0",
|
||||
"uiohook-napi": "^1.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"electron": "^40.0.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { uIOhook, UiohookKeyboardEvent, UiohookMouseEvent } from 'uiohook-napi';
|
||||
import { BrowserWindow, systemPreferences } from 'electron';
|
||||
|
||||
interface KeybindConfig {
|
||||
actionId: string;
|
||||
keys: number[];
|
||||
mouseButton?: number;
|
||||
}
|
||||
|
||||
export class KeybindManager {
|
||||
private keybinds: KeybindConfig[] = [];
|
||||
private pressedKeys = new Set<number>();
|
||||
private activeActions = new Set<string>();
|
||||
private window: BrowserWindow | null = null;
|
||||
private started = false;
|
||||
|
||||
constructor() {
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.onKeyUp = this.onKeyUp.bind(this);
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
}
|
||||
|
||||
setWindow(win: BrowserWindow): void {
|
||||
this.window = win;
|
||||
}
|
||||
|
||||
updateKeybinds(keybinds: KeybindConfig[]): void {
|
||||
this.keybinds = keybinds;
|
||||
for (const actionId of this.activeActions) {
|
||||
if (!keybinds.some((kb) => kb.actionId === actionId)) {
|
||||
this.sendAction(actionId, false);
|
||||
this.activeActions.delete(actionId);
|
||||
}
|
||||
}
|
||||
if (keybinds.length > 0 && !this.started) {
|
||||
this.start();
|
||||
}
|
||||
if (keybinds.length === 0 && this.started) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
if (this.started) return;
|
||||
if (process.platform === 'darwin') {
|
||||
const trusted = systemPreferences.isTrustedAccessibilityClient(true);
|
||||
this.sendAccessibilityStatus(trusted);
|
||||
if (!trusted) return;
|
||||
}
|
||||
uIOhook.on('keydown', this.onKeyDown);
|
||||
uIOhook.on('keyup', this.onKeyUp);
|
||||
uIOhook.on('mousedown', this.onMouseDown);
|
||||
uIOhook.on('mouseup', this.onMouseUp);
|
||||
try {
|
||||
uIOhook.start();
|
||||
this.started = true;
|
||||
} catch (err) {
|
||||
console.error('[KeybindManager] Failed to start uiohook:', err);
|
||||
this.window?.webContents.send('keybind-hook-error', { message: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.started) return;
|
||||
for (const actionId of this.activeActions) {
|
||||
this.sendAction(actionId, false);
|
||||
}
|
||||
this.activeActions.clear();
|
||||
this.pressedKeys.clear();
|
||||
uIOhook.removeAllListeners();
|
||||
try { uIOhook.stop(); } catch { /* ignore */ }
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
checkAccessibility(): boolean {
|
||||
if (process.platform !== 'darwin') return true;
|
||||
return systemPreferences.isTrustedAccessibilityClient(false);
|
||||
}
|
||||
|
||||
private onKeyDown(e: UiohookKeyboardEvent): void {
|
||||
this.pressedKeys.add(e.keycode);
|
||||
this.evaluateKeybinds();
|
||||
}
|
||||
|
||||
private onKeyUp(e: UiohookKeyboardEvent): void {
|
||||
this.pressedKeys.delete(e.keycode);
|
||||
this.checkReleases();
|
||||
}
|
||||
|
||||
private onMouseDown(e: UiohookMouseEvent): void {
|
||||
const button = e.button as number;
|
||||
if (button <= 2) return;
|
||||
this.evaluateKeybindsWithMouse(button);
|
||||
}
|
||||
|
||||
private onMouseUp(e: UiohookMouseEvent): void {
|
||||
const button = e.button as number;
|
||||
if (button <= 2) return;
|
||||
this.checkMouseReleases(button);
|
||||
}
|
||||
|
||||
private evaluateKeybinds(): void {
|
||||
for (const kb of this.keybinds) {
|
||||
if (this.activeActions.has(kb.actionId)) continue;
|
||||
if (kb.mouseButton) continue;
|
||||
if (kb.keys.length === 0) continue;
|
||||
if (kb.keys.every((k) => this.pressedKeys.has(k))) {
|
||||
this.activeActions.add(kb.actionId);
|
||||
this.sendAction(kb.actionId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateKeybindsWithMouse(mouseButton: number): void {
|
||||
for (const kb of this.keybinds) {
|
||||
if (this.activeActions.has(kb.actionId)) continue;
|
||||
if (kb.mouseButton !== mouseButton) continue;
|
||||
if (kb.keys.every((k) => this.pressedKeys.has(k))) {
|
||||
this.activeActions.add(kb.actionId);
|
||||
this.sendAction(kb.actionId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private checkReleases(): void {
|
||||
for (const actionId of this.activeActions) {
|
||||
const kb = this.keybinds.find((k) => k.actionId === actionId);
|
||||
if (!kb) continue;
|
||||
if (kb.mouseButton) continue;
|
||||
if (!kb.keys.every((k) => this.pressedKeys.has(k))) {
|
||||
this.activeActions.delete(actionId);
|
||||
this.sendAction(actionId, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private checkMouseReleases(mouseButton: number): void {
|
||||
for (const actionId of this.activeActions) {
|
||||
const kb = this.keybinds.find((k) => k.actionId === actionId);
|
||||
if (!kb || kb.mouseButton !== mouseButton) continue;
|
||||
this.activeActions.delete(actionId);
|
||||
this.sendAction(actionId, false);
|
||||
}
|
||||
}
|
||||
|
||||
private sendAction(actionId: string, pressed: boolean): void {
|
||||
if (!this.window || this.window.isDestroyed()) return;
|
||||
this.window.webContents.send('keybind-action', { actionId, pressed });
|
||||
}
|
||||
|
||||
private sendAccessibilityStatus(trusted: boolean): void {
|
||||
if (!this.window || this.window.isDestroyed()) return;
|
||||
this.window.webContents.send('accessibility-status', { trusted });
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { startActivityDetection, stopActivityDetection, getCurrentActivity } from './activityDetector';
|
||||
import { KeybindManager } from './keybindManager';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const keybindManager = new KeybindManager();
|
||||
let tray: Tray | null = null;
|
||||
let isQuitting = false;
|
||||
let pendingDeepLink: string | null = null;
|
||||
@@ -276,6 +278,8 @@ function createWindow(): void {
|
||||
},
|
||||
});
|
||||
|
||||
keybindManager.setWindow(mainWindow);
|
||||
|
||||
if (savedState.isMaximized) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
@@ -545,6 +549,15 @@ function registerIpcHandlers(): void {
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Keybinds
|
||||
ipcMain.on('keybinds-sync', (_event, keybinds) => {
|
||||
keybindManager.updateKeybinds(keybinds);
|
||||
});
|
||||
|
||||
ipcMain.handle('check-accessibility', () => {
|
||||
return keybindManager.checkAccessibility();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auto-Update ────────────────────────────────────────────────────────────
|
||||
@@ -810,5 +823,6 @@ if (!gotTheLock) {
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
stopActivityDetection();
|
||||
keybindManager.stop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,4 +76,25 @@ contextBridge.exposeInMainWorld('backspace', {
|
||||
return () => { ipcRenderer.removeListener('activity-detected', handler); };
|
||||
},
|
||||
getCurrentActivity: () => ipcRenderer.invoke('get-current-activity'),
|
||||
|
||||
// Keybind support
|
||||
syncKeybinds: (keybinds: Array<{ actionId: string; keys: number[]; mouseButton?: number }>) => {
|
||||
ipcRenderer.send('keybinds-sync', keybinds);
|
||||
},
|
||||
onKeybindAction: (callback: (action: { actionId: string; pressed: boolean }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, action: { actionId: string; pressed: boolean }) => callback(action);
|
||||
ipcRenderer.on('keybind-action', handler);
|
||||
return () => { ipcRenderer.removeListener('keybind-action', handler); };
|
||||
},
|
||||
onAccessibilityStatus: (callback: (status: { trusted: boolean }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, status: { trusted: boolean }) => callback(status);
|
||||
ipcRenderer.on('accessibility-status', handler);
|
||||
return () => { ipcRenderer.removeListener('accessibility-status', handler); };
|
||||
},
|
||||
onKeybindHookError: (callback: (error: { message: string }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, error: { message: string }) => callback(error);
|
||||
ipcRenderer.on('keybind-hook-error', handler);
|
||||
return () => { ipcRenderer.removeListener('keybind-hook-error', handler); };
|
||||
},
|
||||
checkAccessibility: () => ipcRenderer.invoke('check-accessibility'),
|
||||
});
|
||||
|
||||
Generated
+17
@@ -13,6 +13,9 @@ importers:
|
||||
electron-updater:
|
||||
specifier: ^6.3.0
|
||||
version: 6.8.3
|
||||
uiohook-napi:
|
||||
specifier: ^1.5.5
|
||||
version: 1.5.5
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: ^40.0.0
|
||||
@@ -4041,6 +4044,10 @@ packages:
|
||||
node-api-version@0.2.1:
|
||||
resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
node-gyp@9.4.1:
|
||||
resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==}
|
||||
engines: {node: ^12.13 || ^14.13 || >=16}
|
||||
@@ -4989,6 +4996,10 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uiohook-napi@1.5.5:
|
||||
resolution: {integrity: sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9586,6 +9597,8 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
node-gyp@9.4.1:
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
@@ -10717,6 +10730,10 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uiohook-napi@1.5.5:
|
||||
dependencies:
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
Reference in New Issue
Block a user