remove uiohook-napi and keystroke monitoring

This commit is contained in:
CREDO23 2026-04-03 16:10:52 +02:00
parent a99d999a36
commit 8ba571566d
12 changed files with 57 additions and 350 deletions

View file

@ -9,10 +9,6 @@ directories:
files:
- dist/**/*
- "!node_modules"
- node_modules/uiohook-napi/**/*
- "!node_modules/uiohook-napi/src"
- "!node_modules/uiohook-napi/libuiohook"
- "!node_modules/uiohook-napi/binding.gyp"
- node_modules/node-gyp-build/**/*
- node_modules/bindings/**/*
- node_modules/file-uri-to-path/**/*
@ -39,7 +35,6 @@ extraResources:
filter: ["**/*"]
asarUnpack:
- "**/*.node"
- "node_modules/uiohook-napi/**/*"
- "node_modules/node-gyp-build/**/*"
- "node_modules/bindings/**/*"
- "node_modules/file-uri-to-path/**/*"
@ -51,9 +46,8 @@ mac:
hardenedRuntime: false
gatekeeperAssess: false
extendInfo:
NSInputMonitoringUsageDescription: "SurfSense uses input monitoring to provide system-wide autocomplete suggestions as you type."
NSAccessibilityUsageDescription: "SurfSense uses accessibility features to read text fields and insert suggestions."
NSAppleEventsUsageDescription: "SurfSense uses Apple Events to read text from the active application and insert autocomplete suggestions."
NSAccessibilityUsageDescription: "SurfSense uses accessibility features to insert suggestions into the active application."
NSAppleEventsUsageDescription: "SurfSense uses Apple Events to interact with the active application."
target:
- target: dmg
arch: [x64, arm64]

View file

@ -32,7 +32,6 @@
"bindings": "^1.5.0",
"electron-updater": "^6.8.3",
"get-port-please": "^3.2.0",
"node-mac-permissions": "^2.5.0",
"uiohook-napi": "^1.5.5"
"node-mac-permissions": "^2.5.0"
}
}

View file

@ -20,9 +20,6 @@ importers:
node-mac-permissions:
specifier: ^2.5.0
version: 2.5.0
uiohook-napi:
specifier: ^1.5.5
version: 1.5.5
devDependencies:
'@electron/rebuild':
specifier: ^4.0.3
@ -1128,10 +1125,6 @@ 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@11.5.0:
resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==}
engines: {node: ^18.17.0 || >=20.5.0}
@ -1454,10 +1447,6 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uiohook-napi@1.5.5:
resolution: {integrity: sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==}
engines: {node: '>= 16'}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@ -2785,8 +2774,6 @@ snapshots:
dependencies:
semver: 7.7.4
node-gyp-build@4.8.4: {}
node-gyp@11.5.0:
dependencies:
env-paths: 2.2.1
@ -3113,10 +3100,6 @@ snapshots:
typescript@5.9.3: {}
uiohook-napi@1.5.5:
dependencies:
node-gyp-build: 4.8.4
undici-types@7.16.0: {}
undici-types@7.18.2: {}

View file

@ -104,7 +104,7 @@ async function buildElectron() {
bundle: true,
platform: 'node',
target: 'node18',
external: ['electron', 'uiohook-napi', 'node-mac-permissions', 'bindings', 'file-uri-to-path'],
external: ['electron', 'node-mac-permissions', 'bindings', 'file-uri-to-path'],
sourcemap: true,
minify: false,
define: {

View file

@ -9,7 +9,6 @@ export const IPC_CHANNELS = {
// Permissions
GET_PERMISSIONS_STATUS: 'get-permissions-status',
REQUEST_ACCESSIBILITY: 'request-accessibility',
REQUEST_INPUT_MONITORING: 'request-input-monitoring',
RESTART_APP: 'restart-app',
// Autocomplete
AUTOCOMPLETE_CONTEXT: 'autocomplete-context',

View file

@ -3,7 +3,6 @@ import { IPC_CHANNELS } from './channels';
import {
getPermissionsStatus,
requestAccessibility,
requestInputMonitoring,
restartApp,
} from '../modules/permissions';
@ -31,10 +30,6 @@ export function registerIpcHandlers(): void {
requestAccessibility();
});
ipcMain.handle(IPC_CHANNELS.REQUEST_INPUT_MONITORING, async () => {
return await requestInputMonitoring();
});
ipcMain.handle(IPC_CHANNELS.RESTART_APP, () => {
restartApp();
});

View file

@ -1,152 +1,23 @@
import { clipboard, ipcMain, screen } from 'electron';
import { clipboard, globalShortcut, ipcMain, screen } from 'electron';
import { IPC_CHANNELS } from '../../ipc/channels';
import { getFrontmostApp, hasAccessibilityPermission, simulatePaste } from '../platform';
import { getMainWindow } from '../window';
import {
appendToBuffer, buildKeycodeMap, getBuffer, getBufferTrimmed,
getLastTrackedApp, removeLastChar, resetBuffer, resolveChar, setLastTrackedApp,
} from './keystroke-buffer';
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
const DEBOUNCE_MS = 600;
let uIOhook: any = null;
let UiohookKey: any = {};
let IGNORED_KEYCODES: Set<number> = new Set();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let hookStarted = false;
let autocompleteEnabled = true;
let savedClipboard = '';
let sourceApp = '';
let pendingSuggestionText = '';
function loadUiohook(): boolean {
if (uIOhook) return true;
try {
const mod = require('uiohook-napi');
uIOhook = mod.uIOhook;
UiohookKey = mod.UiohookKey;
IGNORED_KEYCODES = new Set([
UiohookKey.Shift, UiohookKey.ShiftRight,
UiohookKey.Ctrl, UiohookKey.CtrlRight,
UiohookKey.Alt, UiohookKey.AltRight,
UiohookKey.Meta, UiohookKey.MetaRight,
UiohookKey.CapsLock, UiohookKey.NumLock, UiohookKey.ScrollLock,
UiohookKey.F1, UiohookKey.F2, UiohookKey.F3, UiohookKey.F4,
UiohookKey.F5, UiohookKey.F6, UiohookKey.F7, UiohookKey.F8,
UiohookKey.F9, UiohookKey.F10, UiohookKey.F11, UiohookKey.F12,
UiohookKey.PrintScreen,
]);
buildKeycodeMap();
console.log('[autocomplete] uiohook-napi loaded');
return true;
} catch (err) {
console.error('[autocomplete] Failed to load uiohook-napi:', err);
return false;
}
}
function clearDebounce(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
}
function isSurfSenseWindow(): boolean {
const app = getFrontmostApp();
return app === 'Electron' || app === 'SurfSense' || app === 'surfsense-desktop';
}
function onKeyDown(event: {
keycode: number;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
}): void {
if (!autocompleteEnabled) return;
const currentApp = getFrontmostApp();
if (currentApp !== getLastTrackedApp()) {
resetBuffer();
setLastTrackedApp(currentApp);
}
const win = getSuggestionWindow();
if (event.keycode === UiohookKey.Tab && win && !win.isDestroyed()) {
if (pendingSuggestionText) {
acceptAndInject(pendingSuggestionText);
}
return;
}
if (event.keycode === UiohookKey.Escape) {
if (win && !win.isDestroyed()) {
destroySuggestion();
pendingSuggestionText = '';
}
clearDebounce();
return;
}
if (currentApp === 'Electron' || currentApp === 'SurfSense' || currentApp === 'surfsense-desktop') {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey) {
resetBuffer();
clearDebounce();
return;
}
if (event.keycode === UiohookKey.Backspace) {
removeLastChar();
} else if (event.keycode === UiohookKey.Delete) {
// forward delete doesn't affect our trailing buffer
} else if (event.keycode === UiohookKey.Enter) {
appendToBuffer('\n');
} else if (event.keycode === UiohookKey.Space) {
appendToBuffer(' ');
} else if (
event.keycode === UiohookKey.ArrowLeft || event.keycode === UiohookKey.ArrowRight ||
event.keycode === UiohookKey.ArrowUp || event.keycode === UiohookKey.ArrowDown ||
event.keycode === UiohookKey.Home || event.keycode === UiohookKey.End ||
event.keycode === UiohookKey.PageUp || event.keycode === UiohookKey.PageDown
) {
resetBuffer();
clearDebounce();
return;
} else if (IGNORED_KEYCODES.has(event.keycode)) {
return;
} else {
const ch = resolveChar(event.keycode, !!event.shiftKey);
if (ch) appendToBuffer(ch);
}
if (win && !win.isDestroyed()) {
destroySuggestion();
}
clearDebounce();
debounceTimer = setTimeout(() => {
triggerAutocomplete();
}, DEBOUNCE_MS);
}
function onMouseClick(): void {
resetBuffer();
}
async function triggerAutocomplete(): Promise<void> {
if (!hasAccessibilityPermission()) return;
if (isSurfSenseWindow()) return;
const text = getBufferTrimmed();
if (!text || text.length < 5) return;
sourceApp = getFrontmostApp();
savedClipboard = clipboard.readText();
@ -168,8 +39,8 @@ async function triggerAutocomplete(): Promise<void> {
setTimeout(() => {
if (sw && !sw.isDestroyed()) {
sw.webContents.send(IPC_CHANNELS.AUTOCOMPLETE_CONTEXT, {
text: getBuffer(),
cursorPosition: getBuffer().length,
text: '',
cursorPosition: 0,
searchSpaceId,
});
}
@ -190,7 +61,6 @@ async function acceptAndInject(text: string): Promise<void> {
simulatePaste();
await new Promise((r) => setTimeout(r, 100));
clipboard.writeText(savedClipboard);
appendToBuffer(text);
} catch {
clipboard.writeText(savedClipboard);
}
@ -210,7 +80,6 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.SET_AUTOCOMPLETE_ENABLED, (_event, enabled: boolean) => {
autocompleteEnabled = enabled;
if (!enabled) {
clearDebounce();
destroySuggestion();
}
});
@ -220,25 +89,10 @@ function registerIpcHandlers(): void {
export function registerAutocomplete(): void {
registerIpcHandlers();
if (!loadUiohook()) {
console.error('[autocomplete] Cannot start: uiohook-napi failed to load');
return;
}
uIOhook.on('keydown', onKeyDown);
uIOhook.on('click', onMouseClick);
try {
uIOhook.start();
hookStarted = true;
} catch (err) {
console.error('[autocomplete] uIOhook.start() failed:', err);
}
// TODO: Phase 2 — replace with vision-based trigger (desktopCapturer + globalShortcut)
console.log('[autocomplete] IPC handlers registered');
}
export function unregisterAutocomplete(): void {
clearDebounce();
destroySuggestion();
if (uIOhook && hookStarted) {
try { uIOhook.stop(); } catch { /* already stopped */ }
}
}

View file

@ -1,76 +0,0 @@
const MAX_BUFFER_LENGTH = 4000;
const KEYCODE_TO_CHAR: Record<number, [string, string]> = {};
let keystrokeBuffer = '';
let lastTrackedApp = '';
export function buildKeycodeMap(): void {
const letters: [string, number][] = [
['q', 16], ['w', 17], ['e', 18], ['r', 19], ['t', 20],
['y', 21], ['u', 22], ['i', 23], ['o', 24], ['p', 25],
['a', 30], ['s', 31], ['d', 32], ['f', 33], ['g', 34],
['h', 35], ['j', 36], ['k', 37], ['l', 38],
['z', 44], ['x', 45], ['c', 46], ['v', 47],
['b', 48], ['n', 49], ['m', 50],
];
for (const [ch, code] of letters) {
KEYCODE_TO_CHAR[code] = [ch, ch.toUpperCase()];
}
const digits: [string, string, number][] = [
['1', '!', 2], ['2', '@', 3], ['3', '#', 4], ['4', '$', 5],
['5', '%', 6], ['6', '^', 7], ['7', '&', 8], ['8', '*', 9],
['9', '(', 10], ['0', ')', 11],
];
for (const [norm, shifted, code] of digits) {
KEYCODE_TO_CHAR[code] = [norm, shifted];
}
const punctuation: [string, string, number][] = [
[';', ':', 39], ['=', '+', 13], [',', '<', 51], ['-', '_', 12],
['.', '>', 52], ['/', '?', 53], ['`', '~', 41], ['[', '{', 26],
['\\', '|', 43], [']', '}', 27], ["'", '"', 40],
];
for (const [norm, shifted, code] of punctuation) {
KEYCODE_TO_CHAR[code] = [norm, shifted];
}
}
export function resetBuffer(): void {
keystrokeBuffer = '';
}
export function appendToBuffer(char: string): void {
keystrokeBuffer += char;
if (keystrokeBuffer.length > MAX_BUFFER_LENGTH) {
keystrokeBuffer = keystrokeBuffer.slice(-MAX_BUFFER_LENGTH);
}
}
export function removeLastChar(): void {
if (keystrokeBuffer.length > 0) {
keystrokeBuffer = keystrokeBuffer.slice(0, -1);
}
}
export function getBuffer(): string {
return keystrokeBuffer;
}
export function getBufferTrimmed(): string {
return keystrokeBuffer.trim();
}
export function getLastTrackedApp(): string {
return lastTrackedApp;
}
export function setLastTrackedApp(app: string): void {
lastTrackedApp = app;
}
export function resolveChar(keycode: number, shift: boolean): string | null {
const mapping = KEYCODE_TO_CHAR[keycode];
if (!mapping) return null;
return shift ? mapping[1] : mapping[0];
}

View file

@ -4,7 +4,6 @@ type PermissionStatus = 'authorized' | 'denied' | 'not determined' | 'restricted
export interface PermissionsStatus {
accessibility: PermissionStatus;
inputMonitoring: PermissionStatus;
}
function isMac(): boolean {
@ -17,19 +16,18 @@ function getNodeMacPermissions() {
export function getPermissionsStatus(): PermissionsStatus {
if (!isMac()) {
return { accessibility: 'authorized', inputMonitoring: 'authorized' };
return { accessibility: 'authorized' };
}
const perms = getNodeMacPermissions();
return {
accessibility: perms.getAuthStatus('accessibility'),
inputMonitoring: perms.getAuthStatus('input-monitoring'),
};
}
export function allPermissionsGranted(): boolean {
const status = getPermissionsStatus();
return status.accessibility === 'authorized' && status.inputMonitoring === 'authorized';
return status.accessibility === 'authorized';
}
export function requestAccessibility(): void {
@ -38,12 +36,6 @@ export function requestAccessibility(): void {
perms.askForAccessibilityAccess();
}
export async function requestInputMonitoring(): Promise<string> {
if (!isMac()) return 'authorized';
const perms = getNodeMacPermissions();
return perms.askForInputMonitoringAccess('listen');
}
export function restartApp(): void {
app.relaunch();
app.exit(0);

View file

@ -24,7 +24,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Permissions
getPermissionsStatus: () => ipcRenderer.invoke(IPC_CHANNELS.GET_PERMISSIONS_STATUS),
requestAccessibility: () => ipcRenderer.invoke(IPC_CHANNELS.REQUEST_ACCESSIBILITY),
requestInputMonitoring: () => ipcRenderer.invoke(IPC_CHANNELS.REQUEST_INPUT_MONITORING),
restartApp: () => ipcRenderer.invoke(IPC_CHANNELS.RESTART_APP),
// Autocomplete
onAutocompleteContext: (callback: (data: { text: string; cursorPosition: number; searchSpaceId?: string }) => void) => {

View file

@ -10,26 +10,8 @@ type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted
interface PermissionsStatus {
accessibility: PermissionStatus;
inputMonitoring: PermissionStatus;
}
const STEPS = [
{
id: "input-monitoring",
title: "Input Monitoring",
description: "Helps you write faster by enriching your text with suggestions from your knowledge base.",
action: "requestInputMonitoring",
field: "inputMonitoring" as const,
},
{
id: "accessibility",
title: "Accessibility",
description: "Lets you accept suggestions seamlessly, right where you're typing.",
action: "requestAccessibility",
field: "accessibility" as const,
},
];
function StatusBadge({ status }: { status: PermissionStatus }) {
if (status === "authorized") {
return (
@ -66,13 +48,11 @@ export default function DesktopPermissionsPage() {
let interval: ReturnType<typeof setInterval> | null = null;
const isResolved = (s: string) => s === "authorized" || s === "restricted";
const poll = async () => {
const status = await window.electronAPI!.getPermissionsStatus();
setPermissions(status);
if (isResolved(status.accessibility) && isResolved(status.inputMonitoring)) {
if (status.accessibility === "authorized" || status.accessibility === "restricted") {
if (interval) clearInterval(interval);
}
};
@ -98,14 +78,10 @@ export default function DesktopPermissionsPage() {
);
}
const allGranted = permissions.accessibility === "authorized" && permissions.inputMonitoring === "authorized";
const allGranted = permissions.accessibility === "authorized";
const handleRequest = async (action: string) => {
if (action === "requestInputMonitoring") {
await window.electronAPI!.requestInputMonitoring();
} else if (action === "requestAccessibility") {
await window.electronAPI!.requestAccessibility();
}
const handleRequest = async () => {
await window.electronAPI!.requestAccessibility();
};
const handleContinue = () => {
@ -127,61 +103,55 @@ export default function DesktopPermissionsPage() {
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">System Permissions</h1>
<p className="text-sm text-muted-foreground">
SurfSense needs two macOS permissions to provide system-wide autocomplete.
SurfSense needs Accessibility permission to insert suggestions into the active application.
</p>
</div>
</div>
{/* Steps */}
{/* Permission card */}
<div className="rounded-xl border bg-background dark:bg-neutral-900 flex-1 min-h-0 overflow-y-auto px-6 py-6 space-y-6">
{STEPS.map((step, index) => {
const status = permissions[step.field];
const isGranted = status === "authorized";
return (
<div
key={step.id}
className={`rounded-lg border p-4 transition-colors ${
isGranted
? "border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20"
: "border-border"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{isGranted ? "✓" : index + 1}
</span>
<div className="space-y-1">
<h3 className="text-sm font-medium">{step.title}</h3>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</div>
<StatusBadge status={status} />
</div>
{!isGranted && (
<div className="mt-3 pl-10 space-y-2">
<Button
size="sm"
variant="outline"
onClick={() => handleRequest(step.action)}
className="text-xs"
>
Open System Settings
</Button>
{status === "denied" && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Toggle SurfSense on in System Settings to continue.
</p>
)}
<div
className={`rounded-lg border p-4 transition-colors ${
allGranted
? "border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20"
: "border-border"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary">
{allGranted ? "\u2713" : "1"}
</span>
<div className="space-y-1">
<h3 className="text-sm font-medium">Accessibility</h3>
<p className="text-xs text-muted-foreground">
If SurfSense doesn&apos;t appear in the list, click <strong>+</strong> and select it from Applications.
Lets SurfSense insert suggestions seamlessly, right where you&apos;re typing.
</p>
</div>
)}
</div>
</div>
);
})}
<StatusBadge status={permissions.accessibility} />
</div>
{!allGranted && (
<div className="mt-3 pl-10 space-y-2">
<Button
size="sm"
variant="outline"
onClick={handleRequest}
className="text-xs"
>
Open System Settings
</Button>
{permissions.accessibility === "denied" && (
<p className="text-xs text-amber-700 dark:text-amber-400">
Toggle SurfSense on in System Settings to continue.
</p>
)}
<p className="text-xs text-muted-foreground">
If SurfSense doesn&apos;t appear in the list, click <strong>+</strong> and select it from Applications.
</p>
</div>
)}
</div>
</div>
{/* Footer */}
@ -198,7 +168,7 @@ export default function DesktopPermissionsPage() {
) : (
<>
<Button disabled className="text-sm h-9 min-w-[180px]">
Grant permissions to continue
Grant permission to continue
</Button>
<button
onClick={handleSkip}

View file

@ -17,10 +17,8 @@ interface ElectronAPI {
// Permissions
getPermissionsStatus: () => Promise<{
accessibility: 'authorized' | 'denied' | 'not determined' | 'restricted' | 'limited';
inputMonitoring: 'authorized' | 'denied' | 'not determined' | 'restricted' | 'limited';
}>;
requestAccessibility: () => Promise<void>;
requestInputMonitoring: () => Promise<void>;
restartApp: () => Promise<void>;
// Autocomplete
onAutocompleteContext: (callback: (data: { text: string; cursorPosition: number; searchSpaceId?: string }) => void) => () => void;