diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5ad26b9c..b5e0f150 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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" diff --git a/packages/desktop/src/keybindManager.ts b/packages/desktop/src/keybindManager.ts new file mode 100644 index 00000000..5f19811d --- /dev/null +++ b/packages/desktop/src/keybindManager.ts @@ -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(); + private activeActions = new Set(); + 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 }); + } +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 430f5640..ccca13ff 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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(); }); } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index e186af24..dbdd5fa4 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -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'), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c021cef..26f6a0f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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