selection-hook
Version:
Text selection monitoring of native Node.js module with N-API across applications
588 lines (511 loc) • 15.8 kB
JavaScript
/**
* Node Selection Hook
*
* This module provides a Node.js interface for monitoring text selections
* across applications on Windows and macOS using UI Automation and Accessibility APIs.
*
* Copyright (c) 2025 0xfullex (https://github.com/0xfullex/selection-hook)
* Licensed under the MIT License
*/
const EventEmitter = require("events");
const gypBuild = require("node-gyp-build");
const path = require("path");
const isWindows = process.platform === "win32";
const isMac = process.platform === "darwin";
let nativeModule = null;
// Make debugFlag a private module variable to avoid global state issues
let _debugFlag = false;
try {
if (!isWindows && !isMac) {
throw new Error("[selection-hook] Only supports Windows and macOS platforms");
}
nativeModule = gypBuild(path.resolve(__dirname));
} catch (err) {
console.error("[selection-hook] Failed to load native module:", err.message);
}
class SelectionHook extends EventEmitter {
#instance = null;
#running = false;
static SelectionMethod = {
NONE: 0,
UIA: 1,
/** @deprecated This method has been removed */
FOCUSCTL: 2,
ACCESSIBLE: 3,
AXAPI: 11,
CLIPBOARD: 99,
};
static PositionLevel = {
NONE: 0,
MOUSE_SINGLE: 1,
MOUSE_DUAL: 2,
SEL_FULL: 3,
SEL_DETAILED: 4,
};
static FilterMode = {
DEFAULT: 0,
INCLUDE_LIST: 1,
EXCLUDE_LIST: 2,
};
static FineTunedListType = {
EXCLUDE_CLIPBOARD_CURSOR_DETECT: 0,
INCLUDE_CLIPBOARD_DELAY_READ: 1,
};
constructor() {
if (!nativeModule) {
throw new Error(
"[selection-hook] Native module failed to load - only works on Windows and macOS"
);
}
super();
}
/**
* Start monitoring text selections
* @param {boolean} debug Enable debug logging
* @returns {boolean} Success status
*/
start(config = null) {
const defaultConfig = this.#getDefaultConfig();
_debugFlag = config?.debug ?? defaultConfig.debug;
if (this.#running) {
this.#logDebug("Text selection hook already running");
return true;
}
if (!this.#instance) {
try {
this.#instance = new nativeModule.TextSelectionHook();
} catch (err) {
this.#handleError("Failed to create hook instance", err);
return false;
}
}
if (config) {
this.#initByConfig(defaultConfig, config);
}
try {
const callback = (data) => {
try {
if (!data || !data.type || !this.#running) return;
switch (data.type) {
case "text-selection":
const formattedData = this.#formatSelectionData(data);
if (formattedData) {
this.emit("text-selection", formattedData);
}
break;
case "mouse-event":
this.emit(data.action, data);
break;
case "keyboard-event":
this.emit(data.action, data);
break;
case "status":
this.emit("status", data.status);
break;
case "error":
this.emit("error", new Error(data.error));
break;
}
} catch (err) {
this.#handleError("Failed to process event data", err);
}
};
this.#instance.start(callback);
this.#running = true;
this.emit("status", "started");
return true;
} catch (err) {
this.#handleError("Failed to start hook", err, "fatal");
return false;
}
}
/**
* Stop monitoring text selections, can start again using start()
* @returns {boolean} Success status
*/
stop() {
if (!this.#instance || !this.#running) {
this.#logDebug("Text selection hook not running");
return true;
}
try {
this.#instance.stop();
this.#running = false;
this.emit("status", "stopped");
return true;
} catch (err) {
this.#handleError("Failed to stop hook", err, "fatal");
this.#running = false;
return false;
}
}
/**
* Get current text selection
* @returns {object|null} Selection data or null
*/
getCurrentSelection() {
if (!this.#instance || !this.#running) {
this.#logDebug("Text selection hook not running");
return null;
}
try {
const data = this.#instance.getCurrentSelection();
return this.#formatSelectionData(data);
} catch (err) {
this.#handleError("Failed to get current selection", err);
return null;
}
}
/**
* Enable mousemove events (high CPU usage)
* @returns {boolean} Success status
*/
enableMouseMoveEvent() {
if (!this.#checkRunning()) return false;
try {
this.#instance.enableMouseMoveEvent();
return true;
} catch (err) {
this.#handleError("Failed to enable mouse move events", err);
return false;
}
}
/**
* Disable mousemove events
* @returns {boolean} Success status
*/
disableMouseMoveEvent() {
if (!this.#checkRunning()) return false;
try {
this.#instance.disableMouseMoveEvent();
return true;
} catch (err) {
this.#handleError("Failed to disable mouse move events", err);
return false;
}
}
/**
* Enable clipboard fallback for text selection
* Uses Ctrl+C as a last resort to get selected text
* @returns {boolean} Success status
*/
enableClipboard() {
if (!this.#checkRunning()) return false;
try {
this.#instance.enableClipboard();
return true;
} catch (err) {
this.#handleError("Failed to enable clipboard fallback", err);
return false;
}
}
/**
* Disable clipboard fallback for text selection
* Will not use Ctrl+C to get selected text
* @returns {boolean} Success status
*/
disableClipboard() {
if (!this.#checkRunning()) return false;
try {
this.#instance.disableClipboard();
return true;
} catch (err) {
this.#handleError("Failed to disable clipboard fallback", err);
return false;
}
}
/**
* Set clipboard mode and program list for text selection
*
* Configures how the clipboard fallback mechanism works for different programs.
* Mode can be:
* - DEFAULT: Use clipboard for all programs
* - INCLUDE_LIST: Only use clipboard for programs in the list
* - EXCLUDE_LIST: Use clipboard for all programs except those in the list
*
* @param {number} mode - Clipboard mode (SelectionHook.ClipboardMode)
* @param {string[]} programList - Array of program names to include/exclude.
* @returns {boolean} Success status
*/
setClipboardMode(mode, programList = []) {
if (!this.#checkRunning()) return false;
const validModes = Object.values(SelectionHook.FilterMode);
if (!validModes.includes(mode)) {
this.#handleError("Invalid clipboard mode", new Error("Invalid argument"));
return false;
}
if (!Array.isArray(programList)) {
this.#handleError("Program list must be an array", new Error("Invalid argument"));
return false;
}
try {
this.#instance.setClipboardMode(mode, programList);
return true;
} catch (err) {
this.#handleError("Failed to set clipboard mode and list", err);
return false;
}
}
/**
* Set global filter mode for text selection
*
* Configures how the global filter mechanism works for different programs.
* Mode can be:
* - DEFAULT: disable global filter
* - INCLUDE_LIST: Only use global filter for programs in the list
* - EXCLUDE_LIST: Use global filter for all programs except those in the list
*
* @param {number} mode - Filter mode (SelectionHook.FilterMode)
* @param {string[]} programList - Array of program names to include/exclude
* @returns {boolean} Success status
*/
setGlobalFilterMode(mode, programList = []) {
if (!this.#checkRunning()) return false;
const validModes = Object.values(SelectionHook.FilterMode);
if (!validModes.includes(mode)) {
this.#handleError("Invalid filter mode", new Error("Invalid argument"));
return false;
}
if (!Array.isArray(programList)) {
this.#handleError("Program list must be an array", new Error("Invalid argument"));
return false;
}
try {
this.#instance.setGlobalFilterMode(mode, programList);
return true;
} catch (err) {
this.#handleError("Failed to set global filter mode and list", err);
return false;
}
}
/**
* Set fine-tuned list for specific behaviors
*
* Configures fine-tuned lists for specific application behaviors.
* List types:
* - EXCLUDE_CLIPBOARD_CURSOR_DETECT: Exclude cursor detection for clipboard operations
* - INCLUDE_CLIPBOARD_DELAY_READ: Include delay when reading clipboard content
*
* @param {number} listType - Fine-tuned list type (SelectionHook.FineTunedListType)
* @param {string[]} programList - Array of program names for the fine-tuned list
* @returns {boolean} Success status
*/
setFineTunedList(listType, programList = []) {
if (!this.#checkRunning()) return false;
const validTypes = Object.values(SelectionHook.FineTunedListType);
if (!validTypes.includes(listType)) {
this.#handleError("Invalid fine-tuned list type", new Error("Invalid argument"));
return false;
}
if (!Array.isArray(programList)) {
this.#handleError("Program list must be an array", new Error("Invalid argument"));
return false;
}
try {
this.#instance.setFineTunedList(listType, programList);
return true;
} catch (err) {
this.#handleError("Failed to set fine-tuned list", err);
return false;
}
}
/**
* Set selection passive mode
* @param {boolean} passive - Passive mode
* @returns {boolean} Success status
*/
setSelectionPassiveMode(passive) {
if (!this.#checkRunning()) return false;
try {
this.#instance.setSelectionPassiveMode(passive);
return true;
} catch (err) {
this.#handleError("Failed to set selection passive mode", err);
return false;
}
}
/**
* Write text to clipboard
* @param {string} text - Text to write to clipboard
* @returns {boolean} Success status
*/
writeToClipboard(text) {
if (!this.#checkRunning()) return false;
if (typeof text !== "string") {
this.#handleError("Text must be a string", new Error("Invalid argument"));
return false;
}
try {
return this.#instance.writeToClipboard(text);
} catch (err) {
this.#handleError("Failed to write text to clipboard", err);
return false;
}
}
/**
* Read text from clipboard
* @returns {string|null} Text from clipboard or null if empty or error
*/
readFromClipboard() {
if (!this.#checkRunning()) return null;
try {
return this.#instance.readFromClipboard();
} catch (err) {
this.#handleError("Failed to read text from clipboard", err);
return null;
}
}
/**
* Check if the process is trusted for accessibility (macOS only)
* @returns {boolean} True if the process is trusted for accessibility, false otherwise
*/
macIsProcessTrusted() {
if (!isMac) {
this.#logDebug("Not supported on this platform");
return false;
}
//don't need to be running
if (!this.#instance) {
this.#logDebug("Text selection hook instance not created");
return false;
}
try {
return this.#instance.macIsProcessTrusted();
} catch (err) {
this.#handleError("Failed to check macOS process trust status", err);
return false;
}
}
/**
* Try to request accessibility permissions (macOS only)
* This MAY show a dialog to the user if permissions are not granted
* @returns {boolean} The current permission status, not the request result
*/
macRequestProcessTrust() {
if (!isMac) {
this.#logDebug("Not supported on this platform");
return false;
}
//don't need to be running
if (!this.#instance) {
this.#logDebug("Text selection hook instance not created");
return false;
}
try {
return this.#instance.macRequestProcessTrust();
} catch (err) {
this.#handleError("Failed to request macOS process trust", err);
return false;
}
}
/**
* Check if hook is running
* @returns {boolean} Running status
*/
isRunning() {
return this.#running;
}
/**
* Release resources
*/
cleanup() {
this.stop();
this.removeAllListeners();
this.#instance = null;
}
#getDefaultConfig() {
return {
debug: false,
enableMouseMoveEvent: false,
enableClipboard: true,
selectionPassiveMode: false,
clipboardMode: SelectionHook.FilterMode.DEFAULT,
globalFilterMode: SelectionHook.FilterMode.DEFAULT,
clipboardFilterList: [],
globalFilterList: [],
};
}
#initByConfig(defaultConfig, userConfig) {
const config = {};
// Only keep values that exist in userConfig and differ from defaultConfig
for (const key in userConfig) {
if (userConfig[key] !== defaultConfig[key]) {
config[key] = userConfig[key];
}
}
// Apply the filtered config
if (config.enableMouseMoveEvent !== undefined) {
if (config.enableMouseMoveEvent) {
this.#instance.enableMouseMoveEvent();
} else {
this.#instance.disableMouseMoveEvent();
}
}
if (config.enableClipboard !== undefined) {
if (config.enableClipboard) {
this.#instance.enableClipboard();
} else {
this.#instance.disableClipboard();
}
}
if (config.selectionPassiveMode !== undefined) {
this.#instance.setSelectionPassiveMode(config.selectionPassiveMode);
}
if (config.clipboardMode !== undefined || config.clipboardFilterList !== undefined) {
this.#instance.setClipboardMode(
config.clipboardMode ?? defaultConfig.clipboardMode,
config.clipboardFilterList ?? defaultConfig.clipboardFilterList
);
}
if (config.globalFilterMode !== undefined || config.globalFilterList !== undefined) {
this.#instance.setGlobalFilterMode(
config.globalFilterMode ?? defaultConfig.globalFilterMode,
config.globalFilterList ?? defaultConfig.globalFilterList
);
}
}
#formatSelectionData(data) {
if (!data) return null;
const selectionInfo = {
text: data.text,
programName: data.programName,
startTop: { x: data.startTopX, y: data.startTopY },
startBottom: { x: data.startBottomX, y: data.startBottomY },
endTop: { x: data.endTopX, y: data.endTopY },
endBottom: { x: data.endBottomX, y: data.endBottomY },
mousePosStart: { x: data.mouseStartX, y: data.mouseStartY },
mousePosEnd: { x: data.mouseEndX, y: data.mouseEndY },
method: data.method || 0,
posLevel: data.posLevel || 0,
};
if (isMac) {
selectionInfo.isFullscreen = data.isFullscreen;
}
return selectionInfo;
}
// Private helper methods
#checkRunning() {
if (!this.#instance || !this.#running) {
this.#logDebug("Text selection hook not running");
return false;
}
return true;
}
// level: "error" or "fatal"
// fatal will always show the error message
#handleError(message, err, level = "error") {
if (!_debugFlag && level === "error") return;
const errorMsg = `${message}: ${err.message}`;
console.error("[selection-hook] ", errorMsg);
if (err.stack) {
console.error(err.stack);
}
this.emit("error", new Error(errorMsg));
}
#logDebug(message) {
if (_debugFlag) {
console.warn("[selection-hook] ", message);
}
}
}
module.exports = SelectionHook;