/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {createRequire} from 'node:module'; import {ipcMain} from 'electron'; import type {UiohookKeyboardEvent, UiohookMouseEvent} from 'uiohook-napi'; import {getMainWindow} from './window.js'; let uIOhook: typeof import('uiohook-napi').uIOhook | null = null; let UiohookKey: typeof import('uiohook-napi').UiohookKey | null = null; interface KeybindRegistration { id: string; keycode: number; mouseButton?: number; modifiers: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean; }; } const registeredKeybinds = new Map(); const activeKeys = new Set(); let hookStarted = false; const requireModule = createRequire(import.meta.url); function keycodeToKeyName(keycode: number): string { if (!UiohookKey) return `Key${keycode}`; const keyMap: Record = { [UiohookKey.Escape]: 'Escape', [UiohookKey.F1]: 'F1', [UiohookKey.F2]: 'F2', [UiohookKey.F3]: 'F3', [UiohookKey.F4]: 'F4', [UiohookKey.F5]: 'F5', [UiohookKey.F6]: 'F6', [UiohookKey.F7]: 'F7', [UiohookKey.F8]: 'F8', [UiohookKey.F9]: 'F9', [UiohookKey.F10]: 'F10', [UiohookKey.F11]: 'F11', [UiohookKey.F12]: 'F12', [UiohookKey.Backquote]: 'Backquote', [UiohookKey['1']]: '1', [UiohookKey['2']]: '2', [UiohookKey['3']]: '3', [UiohookKey['4']]: '4', [UiohookKey['5']]: '5', [UiohookKey['6']]: '6', [UiohookKey['7']]: '7', [UiohookKey['8']]: '8', [UiohookKey['9']]: '9', [UiohookKey['0']]: '0', [UiohookKey.Minus]: 'Minus', [UiohookKey.Equal]: 'Equal', [UiohookKey.Backspace]: 'Backspace', [UiohookKey.Tab]: 'Tab', [UiohookKey.Q]: 'Q', [UiohookKey.W]: 'W', [UiohookKey.E]: 'E', [UiohookKey.R]: 'R', [UiohookKey.T]: 'T', [UiohookKey.Y]: 'Y', [UiohookKey.U]: 'U', [UiohookKey.I]: 'I', [UiohookKey.O]: 'O', [UiohookKey.P]: 'P', [UiohookKey.BracketLeft]: 'BracketLeft', [UiohookKey.BracketRight]: 'BracketRight', [UiohookKey.Backslash]: 'Backslash', [UiohookKey.CapsLock]: 'CapsLock', [UiohookKey.A]: 'A', [UiohookKey.S]: 'S', [UiohookKey.D]: 'D', [UiohookKey.F]: 'F', [UiohookKey.G]: 'G', [UiohookKey.H]: 'H', [UiohookKey.J]: 'J', [UiohookKey.K]: 'K', [UiohookKey.L]: 'L', [UiohookKey.Semicolon]: 'Semicolon', [UiohookKey.Quote]: 'Quote', [UiohookKey.Enter]: 'Enter', [UiohookKey.Shift]: 'ShiftLeft', [UiohookKey.Z]: 'Z', [UiohookKey.X]: 'X', [UiohookKey.C]: 'C', [UiohookKey.V]: 'V', [UiohookKey.B]: 'B', [UiohookKey.N]: 'N', [UiohookKey.M]: 'M', [UiohookKey.Comma]: 'Comma', [UiohookKey.Period]: 'Period', [UiohookKey.Slash]: 'Slash', [UiohookKey.ShiftRight]: 'ShiftRight', [UiohookKey.Ctrl]: 'ControlLeft', [UiohookKey.Meta]: 'MetaLeft', [UiohookKey.Alt]: 'AltLeft', [UiohookKey.Space]: 'Space', [UiohookKey.AltRight]: 'AltRight', [UiohookKey.MetaRight]: 'MetaRight', [UiohookKey.CtrlRight]: 'ControlRight', [UiohookKey.ArrowLeft]: 'ArrowLeft', [UiohookKey.ArrowUp]: 'ArrowUp', [UiohookKey.ArrowRight]: 'ArrowRight', [UiohookKey.ArrowDown]: 'ArrowDown', [UiohookKey.Insert]: 'Insert', [UiohookKey.Delete]: 'Delete', [UiohookKey.Home]: 'Home', [UiohookKey.End]: 'End', [UiohookKey.PageUp]: 'PageUp', [UiohookKey.PageDown]: 'PageDown', }; return keyMap[keycode] ?? `Key${keycode}`; } function handleKeyEvent(event: UiohookKeyboardEvent, type: 'keydown' | 'keyup') { const mainWindow = getMainWindow(); if (!mainWindow) return; const keycode = event.keycode; const keyName = keycodeToKeyName(keycode); if (type === 'keydown') { activeKeys.add(keycode); } else { activeKeys.delete(keycode); } mainWindow.webContents.send('global-key-event', { type, keycode, keyName, altKey: event.altKey, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, metaKey: event.metaKey, }); for (const [id, keybind] of registeredKeybinds) { if (keybind.keycode === keycode) { const modifiersMatch = keybind.modifiers.ctrl === event.ctrlKey && keybind.modifiers.alt === event.altKey && keybind.modifiers.shift === event.shiftKey && keybind.modifiers.meta === event.metaKey; if (modifiersMatch || !Object.values(keybind.modifiers).some(Boolean)) { mainWindow.webContents.send('global-keybind-triggered', { id, type, }); } } } } function handleMouseEvent(event: UiohookMouseEvent, type: 'mousedown' | 'mouseup') { const mainWindow = getMainWindow(); if (!mainWindow) return; const button = event.button; mainWindow.webContents.send('global-mouse-event', { type, button, }); for (const [id, keybind] of registeredKeybinds) { if (keybind.mouseButton === button) { mainWindow.webContents.send('global-keybind-triggered', { id, type: type === 'mousedown' ? 'keydown' : 'keyup', }); } } } async function startHook(): Promise { if (hookStarted) return true; try { const uiohookModule = await import('uiohook-napi'); uIOhook = uiohookModule.uIOhook; UiohookKey = uiohookModule.UiohookKey; uIOhook.on('keydown', (event) => handleKeyEvent(event, 'keydown')); uIOhook.on('keyup', (event) => handleKeyEvent(event, 'keyup')); uIOhook.on('mousedown', (event) => handleMouseEvent(event, 'mousedown')); uIOhook.on('mouseup', (event) => handleMouseEvent(event, 'mouseup')); uIOhook.start(); hookStarted = true; return true; } catch (error) { console.error('[GlobalKeyHook] Failed to start:', error); return false; } } const INPUT_MONITORING_PERMISSION = 'input-monitoring'; const INPUT_MONITORING_STATUS_ALLOWLIST = new Set(['authorized', 'not-determined']); function getInputMonitoringStatus(): string | null { const permissionsModule = (() => { try { return requireModule('node-mac-permissions'); } catch (error) { console.error('[GlobalKeyHook] Failed to load node-mac-permissions:', error); return null; } })(); if (!permissionsModule || typeof permissionsModule.getAuthStatus !== 'function') { return null; } try { return permissionsModule.getAuthStatus(INPUT_MONITORING_PERMISSION); } catch (error) { console.error('[GlobalKeyHook] Failed to query Input Monitoring auth status:', error); return null; } } async function checkInputMonitoringAccess(): Promise { if (process.platform !== 'darwin') { return true; } if (hookStarted) { return true; } const status = getInputMonitoringStatus(); if (status === null) { return true; } if (INPUT_MONITORING_STATUS_ALLOWLIST.has(status)) { return true; } console.warn('[GlobalKeyHook] Input Monitoring access denied, status:', status); return false; } function stopHook(): void { if (!hookStarted || !uIOhook) return; try { uIOhook.stop(); hookStarted = false; } catch (error) { console.error('[GlobalKeyHook] Failed to stop:', error); } } export function registerGlobalKeyHookHandlers(): void { ipcMain.handle('global-key-hook-start', async (): Promise => { return startHook(); }); ipcMain.handle('global-key-hook-stop', (): void => { stopHook(); }); ipcMain.handle('global-key-hook-is-running', (): boolean => { return hookStarted; }); ipcMain.handle('check-input-monitoring-access', async (): Promise => { return checkInputMonitoringAccess(); }); ipcMain.handle( 'global-key-hook-register', ( _event, options: { id: string; keycode?: number; mouseButton?: number; ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean; }, ): void => { registeredKeybinds.set(options.id, { id: options.id, keycode: options.keycode ?? 0, mouseButton: options.mouseButton, modifiers: { ctrl: options.ctrl ?? false, alt: options.alt ?? false, shift: options.shift ?? false, meta: options.meta ?? false, }, }); }, ); ipcMain.handle('global-key-hook-unregister', (_event, id: string): void => { registeredKeybinds.delete(id); }); ipcMain.handle('global-key-hook-unregister-all', (): void => { registeredKeybinds.clear(); }); } export function cleanupGlobalKeyHook(): void { stopHook(); registeredKeybinds.clear(); activeKeys.clear(); }