mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 02:23:53 +02:00
refactor: fix dynamic tooltip resizing and split autocomplete into SPR modules
This commit is contained in:
parent
6899134a20
commit
9c1d9357c4
12 changed files with 326 additions and 193 deletions
|
|
@ -10,13 +10,13 @@ files:
|
||||||
- dist/**/*
|
- dist/**/*
|
||||||
- "!node_modules"
|
- "!node_modules"
|
||||||
- node_modules/uiohook-napi/**/*
|
- node_modules/uiohook-napi/**/*
|
||||||
- "!node_modules/uiohook-napi/build"
|
|
||||||
- "!node_modules/uiohook-napi/src"
|
- "!node_modules/uiohook-napi/src"
|
||||||
- "!node_modules/uiohook-napi/libuiohook"
|
- "!node_modules/uiohook-napi/libuiohook"
|
||||||
- "!node_modules/uiohook-napi/binding.gyp"
|
- "!node_modules/uiohook-napi/binding.gyp"
|
||||||
- node_modules/node-gyp-build/**/*
|
- node_modules/node-gyp-build/**/*
|
||||||
|
- node_modules/bindings/**/*
|
||||||
|
- node_modules/file-uri-to-path/**/*
|
||||||
- node_modules/node-mac-permissions/**/*
|
- node_modules/node-mac-permissions/**/*
|
||||||
- "!node_modules/node-mac-permissions/build"
|
|
||||||
- "!node_modules/node-mac-permissions/src"
|
- "!node_modules/node-mac-permissions/src"
|
||||||
- "!node_modules/node-mac-permissions/binding.gyp"
|
- "!node_modules/node-mac-permissions/binding.gyp"
|
||||||
- "!src"
|
- "!src"
|
||||||
|
|
@ -41,13 +41,19 @@ asarUnpack:
|
||||||
- "**/*.node"
|
- "**/*.node"
|
||||||
- "node_modules/uiohook-napi/**/*"
|
- "node_modules/uiohook-napi/**/*"
|
||||||
- "node_modules/node-gyp-build/**/*"
|
- "node_modules/node-gyp-build/**/*"
|
||||||
|
- "node_modules/bindings/**/*"
|
||||||
|
- "node_modules/file-uri-to-path/**/*"
|
||||||
- "node_modules/node-mac-permissions/**/*"
|
- "node_modules/node-mac-permissions/**/*"
|
||||||
mac:
|
mac:
|
||||||
icon: assets/icon.icns
|
icon: assets/icon.icns
|
||||||
category: public.app-category.productivity
|
category: public.app-category.productivity
|
||||||
artifactName: "${productName}-${version}-${arch}.${ext}"
|
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||||
hardenedRuntime: true
|
hardenedRuntime: false
|
||||||
gatekeeperAssess: 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."
|
||||||
target:
|
target:
|
||||||
- target: dmg
|
- target: dmg
|
||||||
arch: [x64, arm64]
|
arch: [x64, arm64]
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
"wait-on": "^9.0.4"
|
"wait-on": "^9.0.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
"electron-updater": "^6.8.3",
|
"electron-updater": "^6.8.3",
|
||||||
"get-port-please": "^3.2.0",
|
"get-port-please": "^3.2.0",
|
||||||
"node-mac-permissions": "^2.5.0",
|
"node-mac-permissions": "^2.5.0",
|
||||||
|
|
|
||||||
3
surfsense_desktop/pnpm-lock.yaml
generated
3
surfsense_desktop/pnpm-lock.yaml
generated
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
bindings:
|
||||||
|
specifier: ^1.5.0
|
||||||
|
version: 1.5.0
|
||||||
electron-updater:
|
electron-updater:
|
||||||
specifier: ^6.8.3
|
specifier: ^6.8.3
|
||||||
version: 6.8.3
|
version: 6.8.3
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ async function buildElectron() {
|
||||||
bundle: true,
|
bundle: true,
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
target: 'node18',
|
target: 'node18',
|
||||||
external: ['electron', 'uiohook-napi', 'node-mac-permissions'],
|
external: ['electron', 'uiohook-napi', 'node-mac-permissions', 'bindings', 'file-uri-to-path'],
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
minify: false,
|
minify: false,
|
||||||
define: {
|
define: {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ if (!setupDeepLinks()) {
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
|
|
||||||
function getInitialPath(): string {
|
function getInitialPath(): string {
|
||||||
if (process.platform === 'darwin' && !allPermissionsGranted()) {
|
const granted = allPermissionsGranted();
|
||||||
|
if (process.platform === 'darwin' && !granted) {
|
||||||
return '/desktop/permissions';
|
return '/desktop/permissions';
|
||||||
}
|
}
|
||||||
return '/dashboard';
|
return '/dashboard';
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
import { BrowserWindow, clipboard, ipcMain, screen, shell } from 'electron';
|
import { clipboard, ipcMain, screen } from 'electron';
|
||||||
import path from 'path';
|
import { IPC_CHANNELS } from '../../ipc/channels';
|
||||||
import { IPC_CHANNELS } from '../ipc/channels';
|
import { getFrontmostApp, hasAccessibilityPermission, simulatePaste } from '../platform';
|
||||||
import { allPermissionsGranted } from './permissions';
|
import { getMainWindow } from '../window';
|
||||||
import { getFieldContent, getFrontmostApp, hasAccessibilityPermission, simulatePaste } from './platform';
|
import {
|
||||||
import { getServerPort } from './server';
|
appendToBuffer, buildKeycodeMap, getBuffer, getBufferTrimmed,
|
||||||
import { getMainWindow } from './window';
|
getLastTrackedApp, removeLastChar, resetBuffer, resolveChar, setLastTrackedApp,
|
||||||
|
} from './keystroke-buffer';
|
||||||
|
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
|
||||||
|
|
||||||
const DEBOUNCE_MS = 600;
|
const DEBOUNCE_MS = 600;
|
||||||
const TOOLTIP_WIDTH = 420;
|
|
||||||
const TOOLTIP_HEIGHT = 140;
|
|
||||||
|
|
||||||
let uIOhook: any = null;
|
let uIOhook: any = null;
|
||||||
let UiohookKey: any = {};
|
let UiohookKey: any = {};
|
||||||
let IGNORED_KEYCODES: Set<number> = new Set();
|
let IGNORED_KEYCODES: Set<number> = new Set();
|
||||||
|
|
||||||
let suggestionWindow: BrowserWindow | null = null;
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let hookStarted = false;
|
let hookStarted = false;
|
||||||
let autocompleteEnabled = true;
|
let autocompleteEnabled = true;
|
||||||
|
|
@ -38,12 +37,8 @@ function loadUiohook(): boolean {
|
||||||
UiohookKey.F5, UiohookKey.F6, UiohookKey.F7, UiohookKey.F8,
|
UiohookKey.F5, UiohookKey.F6, UiohookKey.F7, UiohookKey.F8,
|
||||||
UiohookKey.F9, UiohookKey.F10, UiohookKey.F11, UiohookKey.F12,
|
UiohookKey.F9, UiohookKey.F10, UiohookKey.F11, UiohookKey.F12,
|
||||||
UiohookKey.PrintScreen,
|
UiohookKey.PrintScreen,
|
||||||
UiohookKey.Insert, UiohookKey.Delete,
|
|
||||||
UiohookKey.Home, UiohookKey.End,
|
|
||||||
UiohookKey.PageUp, UiohookKey.PageDown,
|
|
||||||
UiohookKey.ArrowUp, UiohookKey.ArrowDown,
|
|
||||||
UiohookKey.ArrowLeft, UiohookKey.ArrowRight,
|
|
||||||
]);
|
]);
|
||||||
|
buildKeycodeMap();
|
||||||
console.log('[autocomplete] uiohook-napi loaded');
|
console.log('[autocomplete] uiohook-napi loaded');
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -52,70 +47,6 @@ function loadUiohook(): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroySuggestion(): void {
|
|
||||||
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
|
|
||||||
suggestionWindow.close();
|
|
||||||
}
|
|
||||||
suggestionWindow = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } {
|
|
||||||
const display = screen.getDisplayNearestPoint({ x, y });
|
|
||||||
const { x: dx, y: dy, width: dw, height: dh } = display.workArea;
|
|
||||||
return {
|
|
||||||
x: Math.max(dx, Math.min(x, dx + dw - w)),
|
|
||||||
y: Math.max(dy, Math.min(y, dy + dh - h)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSuggestionWindow(x: number, y: number): BrowserWindow {
|
|
||||||
destroySuggestion();
|
|
||||||
|
|
||||||
const pos = clampToScreen(x, y + 20, TOOLTIP_WIDTH, TOOLTIP_HEIGHT);
|
|
||||||
|
|
||||||
suggestionWindow = new BrowserWindow({
|
|
||||||
width: TOOLTIP_WIDTH,
|
|
||||||
height: TOOLTIP_HEIGHT,
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y,
|
|
||||||
frame: false,
|
|
||||||
transparent: true,
|
|
||||||
focusable: false,
|
|
||||||
alwaysOnTop: true,
|
|
||||||
skipTaskbar: true,
|
|
||||||
resizable: false,
|
|
||||||
hasShadow: true,
|
|
||||||
type: 'panel',
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
contextIsolation: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
sandbox: true,
|
|
||||||
},
|
|
||||||
show: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
suggestionWindow.loadURL(`http://localhost:${getServerPort()}/desktop/suggestion?t=${Date.now()}`);
|
|
||||||
|
|
||||||
suggestionWindow.once('ready-to-show', () => {
|
|
||||||
suggestionWindow?.showInactive();
|
|
||||||
});
|
|
||||||
|
|
||||||
suggestionWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
||||||
if (url.startsWith('http://localhost')) {
|
|
||||||
return { action: 'allow' };
|
|
||||||
}
|
|
||||||
shell.openExternal(url);
|
|
||||||
return { action: 'deny' };
|
|
||||||
});
|
|
||||||
|
|
||||||
suggestionWindow.on('closed', () => {
|
|
||||||
suggestionWindow = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return suggestionWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDebounce(): void {
|
function clearDebounce(): void {
|
||||||
if (debounceTimer) {
|
if (debounceTimer) {
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
|
|
@ -128,10 +59,24 @@ function isSurfSenseWindow(): boolean {
|
||||||
return app === 'Electron' || app === 'SurfSense' || app === 'surfsense-desktop';
|
return app === 'Electron' || app === 'SurfSense' || app === 'surfsense-desktop';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(event: { keycode: number; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }): void {
|
function onKeyDown(event: {
|
||||||
|
keycode: number;
|
||||||
|
shiftKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
altKey?: boolean;
|
||||||
|
}): void {
|
||||||
if (!autocompleteEnabled) return;
|
if (!autocompleteEnabled) return;
|
||||||
|
|
||||||
if (event.keycode === UiohookKey.Tab && suggestionWindow && !suggestionWindow.isDestroyed()) {
|
const currentApp = getFrontmostApp();
|
||||||
|
if (currentApp !== getLastTrackedApp()) {
|
||||||
|
resetBuffer();
|
||||||
|
setLastTrackedApp(currentApp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = getSuggestionWindow();
|
||||||
|
|
||||||
|
if (event.keycode === UiohookKey.Tab && win && !win.isDestroyed()) {
|
||||||
if (pendingSuggestionText) {
|
if (pendingSuggestionText) {
|
||||||
acceptAndInject(pendingSuggestionText);
|
acceptAndInject(pendingSuggestionText);
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +84,7 @@ function onKeyDown(event: { keycode: number; ctrlKey?: boolean; metaKey?: boolea
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.keycode === UiohookKey.Escape) {
|
if (event.keycode === UiohookKey.Escape) {
|
||||||
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
|
if (win && !win.isDestroyed()) {
|
||||||
destroySuggestion();
|
destroySuggestion();
|
||||||
pendingSuggestionText = '';
|
pendingSuggestionText = '';
|
||||||
}
|
}
|
||||||
|
|
@ -147,11 +92,41 @@ function onKeyDown(event: { keycode: number; ctrlKey?: boolean; metaKey?: boolea
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IGNORED_KEYCODES.has(event.keycode)) return;
|
if (currentApp === 'Electron' || currentApp === 'SurfSense' || currentApp === 'surfsense-desktop') {
|
||||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
return;
|
||||||
if (isSurfSenseWindow()) return;
|
}
|
||||||
|
|
||||||
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
|
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();
|
destroySuggestion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,13 +136,16 @@ function onKeyDown(event: { keycode: number; ctrlKey?: boolean; metaKey?: boolea
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onMouseClick(): void {
|
||||||
|
resetBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
async function triggerAutocomplete(): Promise<void> {
|
async function triggerAutocomplete(): Promise<void> {
|
||||||
if (!hasAccessibilityPermission()) return;
|
if (!hasAccessibilityPermission()) return;
|
||||||
if (isSurfSenseWindow()) return;
|
if (isSurfSenseWindow()) return;
|
||||||
|
|
||||||
const fieldContent = getFieldContent();
|
const text = getBufferTrimmed();
|
||||||
if (!fieldContent || !fieldContent.text.trim()) return;
|
if (!text || text.length < 5) return;
|
||||||
if (fieldContent.text.trim().length < 5) return;
|
|
||||||
|
|
||||||
sourceApp = getFrontmostApp();
|
sourceApp = getFrontmostApp();
|
||||||
savedClipboard = clipboard.readText();
|
savedClipboard = clipboard.readText();
|
||||||
|
|
@ -186,13 +164,16 @@ async function triggerAutocomplete(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
win.webContents.once('did-finish-load', () => {
|
win.webContents.once('did-finish-load', () => {
|
||||||
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
|
const sw = getSuggestionWindow();
|
||||||
suggestionWindow.webContents.send(IPC_CHANNELS.AUTOCOMPLETE_CONTEXT, {
|
setTimeout(() => {
|
||||||
text: fieldContent.text,
|
if (sw && !sw.isDestroyed()) {
|
||||||
cursorPosition: fieldContent.cursorPosition,
|
sw.webContents.send(IPC_CHANNELS.AUTOCOMPLETE_CONTEXT, {
|
||||||
searchSpaceId,
|
text: getBuffer(),
|
||||||
});
|
cursorPosition: getBuffer().length,
|
||||||
}
|
searchSpaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,6 +190,7 @@ async function acceptAndInject(text: string): Promise<void> {
|
||||||
simulatePaste();
|
simulatePaste();
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
clipboard.writeText(savedClipboard);
|
clipboard.writeText(savedClipboard);
|
||||||
|
appendToBuffer(text);
|
||||||
} catch {
|
} catch {
|
||||||
clipboard.writeText(savedClipboard);
|
clipboard.writeText(savedClipboard);
|
||||||
}
|
}
|
||||||
|
|
@ -238,21 +220,16 @@ function registerIpcHandlers(): void {
|
||||||
export function registerAutocomplete(): void {
|
export function registerAutocomplete(): void {
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
|
|
||||||
if (!allPermissionsGranted()) {
|
|
||||||
console.log('[autocomplete] Permissions not granted — hook not started');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!loadUiohook()) {
|
if (!loadUiohook()) {
|
||||||
console.error('[autocomplete] Cannot start: uiohook-napi failed to load');
|
console.error('[autocomplete] Cannot start: uiohook-napi failed to load');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uIOhook.on('keydown', onKeyDown);
|
uIOhook.on('keydown', onKeyDown);
|
||||||
|
uIOhook.on('click', onMouseClick);
|
||||||
try {
|
try {
|
||||||
uIOhook.start();
|
uIOhook.start();
|
||||||
hookStarted = true;
|
hookStarted = true;
|
||||||
console.log('[autocomplete] uIOhook started');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[autocomplete] uIOhook.start() failed:', err);
|
console.error('[autocomplete] uIOhook.start() failed:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
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];
|
||||||
|
}
|
||||||
103
surfsense_desktop/src/modules/autocomplete/suggestion-window.ts
Normal file
103
surfsense_desktop/src/modules/autocomplete/suggestion-window.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { BrowserWindow, screen, shell } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import { getServerPort } from '../server';
|
||||||
|
|
||||||
|
const TOOLTIP_WIDTH = 420;
|
||||||
|
const TOOLTIP_HEIGHT = 38;
|
||||||
|
const MAX_HEIGHT = 400;
|
||||||
|
|
||||||
|
let suggestionWindow: BrowserWindow | null = null;
|
||||||
|
let resizeTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } {
|
||||||
|
const display = screen.getDisplayNearestPoint({ x, y });
|
||||||
|
const { x: dx, y: dy, width: dw, height: dh } = display.workArea;
|
||||||
|
return {
|
||||||
|
x: Math.max(dx, Math.min(x, dx + dw - w)),
|
||||||
|
y: Math.max(dy, Math.min(y, dy + dh - h)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopResizePolling(): void {
|
||||||
|
if (resizeTimer) { clearInterval(resizeTimer); resizeTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResizePolling(win: BrowserWindow): void {
|
||||||
|
stopResizePolling();
|
||||||
|
let lastH = 0;
|
||||||
|
resizeTimer = setInterval(async () => {
|
||||||
|
if (!win || win.isDestroyed()) { stopResizePolling(); return; }
|
||||||
|
try {
|
||||||
|
const h: number = await win.webContents.executeJavaScript(
|
||||||
|
`document.body.scrollHeight`
|
||||||
|
);
|
||||||
|
if (h > 0 && h !== lastH) {
|
||||||
|
lastH = h;
|
||||||
|
const clamped = Math.min(h, MAX_HEIGHT);
|
||||||
|
const bounds = win.getBounds();
|
||||||
|
win.setBounds({ x: bounds.x, y: bounds.y, width: TOOLTIP_WIDTH, height: clamped });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuggestionWindow(): BrowserWindow | null {
|
||||||
|
return suggestionWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroySuggestion(): void {
|
||||||
|
stopResizePolling();
|
||||||
|
if (suggestionWindow && !suggestionWindow.isDestroyed()) {
|
||||||
|
suggestionWindow.close();
|
||||||
|
}
|
||||||
|
suggestionWindow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSuggestionWindow(x: number, y: number): BrowserWindow {
|
||||||
|
destroySuggestion();
|
||||||
|
|
||||||
|
const pos = clampToScreen(x, y + 20, TOOLTIP_WIDTH, TOOLTIP_HEIGHT);
|
||||||
|
|
||||||
|
suggestionWindow = new BrowserWindow({
|
||||||
|
width: TOOLTIP_WIDTH,
|
||||||
|
height: TOOLTIP_HEIGHT,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
focusable: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
hasShadow: true,
|
||||||
|
type: 'panel',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '..', 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
},
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
suggestionWindow.loadURL(`http://localhost:${getServerPort()}/desktop/suggestion?t=${Date.now()}`);
|
||||||
|
|
||||||
|
suggestionWindow.once('ready-to-show', () => {
|
||||||
|
suggestionWindow?.showInactive();
|
||||||
|
if (suggestionWindow) startResizePolling(suggestionWindow);
|
||||||
|
});
|
||||||
|
|
||||||
|
suggestionWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (url.startsWith('http://localhost')) {
|
||||||
|
return { action: 'allow' };
|
||||||
|
}
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
suggestionWindow.on('closed', () => {
|
||||||
|
stopResizePolling();
|
||||||
|
suggestionWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return suggestionWindow;
|
||||||
|
}
|
||||||
|
|
@ -19,20 +19,6 @@ export function getFrontmostApp(): string {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectedText(): string {
|
|
||||||
try {
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
return execSync(
|
|
||||||
'osascript -e \'tell application "System Events" to get value of attribute "AXSelectedText" of focused UI element of first application process whose frontmost is true\''
|
|
||||||
).toString().trim();
|
|
||||||
}
|
|
||||||
// Windows: no reliable accessibility API for selected text across apps
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function simulateCopy(): void {
|
export function simulateCopy(): void {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'');
|
execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'');
|
||||||
|
|
@ -58,38 +44,3 @@ export function hasAccessibilityPermission(): boolean {
|
||||||
if (process.platform !== 'darwin') return true;
|
if (process.platform !== 'darwin') return true;
|
||||||
return systemPreferences.isTrustedAccessibilityClient(false);
|
return systemPreferences.isTrustedAccessibilityClient(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldContent {
|
|
||||||
text: string;
|
|
||||||
cursorPosition: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFieldContent(): FieldContent | null {
|
|
||||||
if (process.platform !== 'darwin') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = execSync(
|
|
||||||
'osascript -e \'tell application "System Events" to get value of attribute "AXValue" of focused UI element of first application process whose frontmost is true\'',
|
|
||||||
{ timeout: 500 }
|
|
||||||
).toString().trim();
|
|
||||||
|
|
||||||
let cursorPosition = text.length;
|
|
||||||
try {
|
|
||||||
const rangeStr = execSync(
|
|
||||||
'osascript -e \'tell application "System Events" to get value of attribute "AXSelectedTextRange" of focused UI element of first application process whose frontmost is true\'',
|
|
||||||
{ timeout: 500 }
|
|
||||||
).toString().trim();
|
|
||||||
|
|
||||||
const locationMatch = rangeStr.match(/location[:\s]*(\d+)/i);
|
|
||||||
if (locationMatch) {
|
|
||||||
cursorPosition = parseInt(locationMatch[1], 10);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall back to end of text
|
|
||||||
}
|
|
||||||
|
|
||||||
return { text, cursorPosition };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -169,11 +169,14 @@ export default function DesktopPermissionsPage() {
|
||||||
>
|
>
|
||||||
Open System Settings
|
Open System Settings
|
||||||
</Button>
|
</Button>
|
||||||
{status === "denied" && (
|
{status === "denied" && (
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||||
Toggle SurfSense on in System Settings to continue.
|
Toggle SurfSense on in System Settings to continue.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
If SurfSense doesn't appear in the list, click <strong>+</strong> and select it from Applications.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,9 @@ export default function SuggestionPage() {
|
||||||
<div className="suggestion-tooltip">
|
<div className="suggestion-tooltip">
|
||||||
<p className="suggestion-text">{suggestion}</p>
|
<p className="suggestion-text">{suggestion}</p>
|
||||||
<div className="suggestion-hint">
|
<div className="suggestion-hint">
|
||||||
<span className="suggestion-key">Tab</span> accept
|
<kbd>Tab</kbd> accept
|
||||||
<span className="suggestion-separator">·</span>
|
<span className="suggestion-separator" />
|
||||||
<span className="suggestion-key">Esc</span> dismiss
|
<kbd>Esc</kbd> dismiss
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
|
html, body {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
height: auto !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.suggestion-body {
|
.suggestion-body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
overflow: hidden;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
@ -10,69 +18,73 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-tooltip {
|
.suggestion-tooltip {
|
||||||
background: rgba(30, 30, 30, 0.95);
|
background: #1e1e1e;
|
||||||
backdrop-filter: blur(12px);
|
border: 1px solid #3c3c3c;
|
||||||
-webkit-backdrop-filter: blur(12px);
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
padding: 8px 12px;
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-text {
|
.suggestion-text {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: #d4d4d4;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.45;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 6px 0;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-hint {
|
.suggestion-hint {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: #666;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
|
border-top: 1px solid #2a2a2a;
|
||||||
|
padding-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-key {
|
.suggestion-hint kbd {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: #2a2a2a;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid #3c3c3c;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 1px 5px;
|
padding: 0 4px;
|
||||||
|
font-family: inherit;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: #999;
|
||||||
|
line-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-separator {
|
.suggestion-separator {
|
||||||
margin: 0 2px;
|
width: 1px;
|
||||||
|
height: 10px;
|
||||||
|
background: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-error {
|
.suggestion-error {
|
||||||
border-color: rgba(255, 80, 80, 0.3);
|
border-color: #5c2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-error-text {
|
.suggestion-error-text {
|
||||||
color: rgba(255, 120, 120, 0.9);
|
color: #f48771;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-loading {
|
.suggestion-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 5px;
|
||||||
padding: 4px 0;
|
padding: 2px 0;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-dot {
|
.suggestion-dot {
|
||||||
width: 5px;
|
width: 4px;
|
||||||
height: 5px;
|
height: 4px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(255, 255, 255, 0.4);
|
background: #666;
|
||||||
animation: suggestion-pulse 1.2s infinite ease-in-out;
|
animation: suggestion-pulse 1.2s infinite ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,6 +103,6 @@
|
||||||
}
|
}
|
||||||
40% {
|
40% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue