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:
Jannis Braun
2026-03-22 20:47:41 +01:00
parent 11d802f86c
commit 3ce9ee0db0
5 changed files with 213 additions and 2 deletions
+5 -2
View File
@@ -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"
+156
View File
@@ -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
View File
@@ -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();
});
}
+21
View File
@@ -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'),
});
+17
View File
@@ -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