Add native self-hosted instance connection to fluxer_desktop

Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
This commit is contained in:
2026-07-01 18:22:43 -04:00
commit 682afacd30
1763 changed files with 613720 additions and 0 deletions
+371
View File
@@ -0,0 +1,371 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags",
"core-foundation",
"core-graphics-types",
"foreign-types",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags",
"core-foundation",
"libc",
]
[[package]]
name = "ctor"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
[[package]]
name = "fluxer_macos_input_hook"
version = "0.0.0"
dependencies = [
"core-foundation",
"core-graphics",
"napi",
"napi-build",
"napi-derive",
"objc2-core-graphics",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
]
[[package]]
name = "foreign-types-macros"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "napi"
version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "5.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
dependencies = [
"libloading",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
@@ -0,0 +1,24 @@
[package]
name = "fluxer_macos_input_hook"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
publish = false
[workspace]
resolver = "2"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
napi-derive = "3.5.6"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10.1"
core-graphics = "0.25.0"
objc2-core-graphics = {version = "0.3.2", default-features = false, features = ["std", "CGEvent"]}
[build-dependencies]
napi-build = "2.3.2"
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export type InputEvent =
| {
type: 'keydown' | 'keyup';
keycode: number;
keyName: string;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
| {
type: 'mousedown' | 'mouseup';
button: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
x?: number;
y?: number;
}
| {
type: 'mousemove';
x: number;
y: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
| {
type: 'wheel';
x?: number;
y?: number;
deltaX: number;
deltaY: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
};
export declare class InputHook {
constructor(callback: (event: InputEvent) => void);
start(): void;
stop(): void;
}
export declare function isAvailable(): boolean;
export declare function hasAccessibilityPermission(): boolean;
export declare const loadError: Error | null;
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync} = require('node:fs');
const {join, sep} = require('node:path');
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
const MODULE_NAME = '@fluxer/macos-input-hook';
function resolveNativeRoot() {
const asarSegment = `${sep}app.asar${sep}`;
if (!__dirname.includes(asarSegment)) return __dirname;
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
return existsSync(unpackedDir) ? unpackedDir : __dirname;
}
function nativeFileName() {
if (process.platform !== 'darwin') {
throw new Error(`@fluxer/macos-input-hook is only supported on macOS, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'macos-input-hook.darwin-x64.node';
case 'arm64':
return 'macos-input-hook.darwin-arm64.node';
default:
throw new Error(`Unsupported macOS architecture: ${process.arch}`);
}
}
let binding = null;
let loadError = null;
if (process.platform === 'darwin') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
probe: false,
});
binding = loaded.binding;
loadError = loaded.loadError;
if (loadError) throw loadError;
} catch (error) {
loadError = createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot: resolveNativeRoot(),
packageDir: __dirname,
reason: 'native loader threw before binding load completed',
cause: error,
});
throw loadError;
}
}
module.exports = {
InputHook: binding ? binding.InputHook : null,
isAvailable: binding ? binding.isAvailable : () => false,
hasAccessibilityPermission: binding ? binding.hasAccessibilityPermission : () => false,
loadError,
};
@@ -0,0 +1,524 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
const os = require('node:os');
const {basename} = require('node:path');
const {spawnSync} = require('node:child_process');
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
const MAX_TEXT_LENGTH = 6000;
const MAX_DIRECTORY_ENTRIES = 80;
function trimText(value, limit = MAX_TEXT_LENGTH) {
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
const trimmed = text.trim();
if (!trimmed) return null;
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
}
function errorDiagnostic(error) {
if (!error) return null;
if (error instanceof Error) {
return {
name: error.name || 'Error',
message: error.message,
code: error.code || null,
stack: trimText(error.stack || error.message),
};
}
return {
name: typeof error,
message: trimText(String(error)),
code: null,
stack: null,
};
}
function formatErrorDiagnostic(diagnostic) {
if (!diagnostic) return null;
const lines = [];
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
if (diagnostic.stack) lines.push(diagnostic.stack);
else if (diagnostic.message) lines.push(diagnostic.message);
return trimText(lines.join('\n'));
}
function fileDiagnostic(filePath) {
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
try {
const stat = statSync(filePath);
return {
path: filePath,
exists: true,
size: stat.size,
mode: `0${(stat.mode & 0o777).toString(8)}`,
mtime: stat.mtime.toISOString(),
isFile: stat.isFile(),
isDirectory: stat.isDirectory(),
};
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return {path: filePath, exists: false, error: reason};
}
}
function formatFileDiagnostic(diagnostic) {
if (!diagnostic) return 'not resolved';
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
return [
`exists=true`,
`size=${diagnostic.size}`,
`mode=${diagnostic.mode}`,
`mtime=${diagnostic.mtime}`,
`isFile=${diagnostic.isFile}`,
].join(', ');
}
function directoryDiagnostic(dirPath) {
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
try {
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
name: entry.name,
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
}));
entries.sort((a, b) => a.name.localeCompare(b.name));
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
return {
path: dirPath,
ok: true,
entries: visible,
total: entries.length,
omitted: Math.max(0, entries.length - visible.length),
};
} catch (error) {
return {
path: dirPath,
ok: false,
error: error instanceof Error ? error.message : String(error),
entries: [],
total: 0,
omitted: 0,
};
}
}
function formatDirectoryDiagnostic(diagnostic) {
if (!diagnostic) return '<unavailable>';
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
return [...entries, ...suffix].join('\n') || '<empty>';
}
function selectedEnvironmentNames(skipNativeProbeEnv) {
const names = [
'ELECTRON_RUN_AS_NODE',
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
'LD_LIBRARY_PATH',
'DYLD_LIBRARY_PATH',
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_CURRENT_DESKTOP',
'XDG_SESSION_TYPE',
'DBUS_SESSION_BUS_ADDRESS',
'PULSE_SERVER',
'PIPEWIRE_REMOTE',
'PATH',
];
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
return names;
}
function environmentDiagnostics(skipNativeProbeEnv) {
return Object.fromEntries(
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
);
}
function formatEnvironment(diagnostic) {
return Object.entries(diagnostic)
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
.join('\n');
}
function runtimeDiagnostics() {
const versions = process.versions || {};
let reportHeader = null;
if (process.report && typeof process.report.getReport === 'function') {
try {
reportHeader = process.report.getReport().header || null;
} catch {
reportHeader = null;
}
}
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
return {
node: versions.node || null,
electron: versions.electron || null,
modules: versions.modules || null,
napi: versions.napi || null,
v8: versions.v8 || null,
uv: versions.uv || null,
openssl: versions.openssl || null,
glibcRuntime,
glibcCompiler,
platform: process.platform,
arch: process.arch,
osType: os.type(),
osRelease: os.release(),
osVersion: typeof os.version === 'function' ? os.version() : null,
execPath: process.execPath,
resourcesPath: process.resourcesPath || null,
cwd: process.cwd(),
};
}
function formatRuntimeDiagnostics(diagnostic) {
return [
`node=${diagnostic.node || '<unknown>'}`,
`electron=${diagnostic.electron || '<none>'}`,
`modules=${diagnostic.modules || '<unknown>'}`,
`napi=${diagnostic.napi || '<unknown>'}`,
`v8=${diagnostic.v8 || '<unknown>'}`,
`uv=${diagnostic.uv || '<unknown>'}`,
`openssl=${diagnostic.openssl || '<unknown>'}`,
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
`process=${diagnostic.platform}/${diagnostic.arch}`,
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
`execPath=${diagnostic.execPath}`,
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
`cwd=${diagnostic.cwd}`,
].join('\n');
}
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
/^vcruntime\d+(?:_\d+)?\.dll$/i,
/^msvcp\d+(?:_\d+)?\.dll$/i,
/^msvcr\d+(?:_\d+)?\.dll$/i,
/^concrt\d+\.dll$/i,
/^vcamp\d+\.dll$/i,
/^vcomp\d+\.dll$/i,
];
function readPeImports(filePath) {
let buffer;
try {
buffer = readFileSync(filePath);
} catch {
return null;
}
if (buffer.length < 0x40) return null;
const peOffset = buffer.readUInt32LE(0x3c);
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
const coffOffset = peOffset + 4;
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
const optionalHeaderOffset = coffOffset + 20;
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
const magic = buffer.readUInt16LE(optionalHeaderOffset);
if (magic !== 0x10b && magic !== 0x20b) return null;
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
const importEntryOffset = dataDirectoriesOffset + 8;
if (importEntryOffset + 8 > buffer.length) return null;
const importRva = buffer.readUInt32LE(importEntryOffset);
if (importRva === 0) return [];
const sections = [];
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
for (let i = 0; i < numberOfSections; i++) {
const base = sectionTableOffset + i * 40;
if (base + 40 > buffer.length) return null;
sections.push({
virtualSize: buffer.readUInt32LE(base + 8),
virtualAddress: buffer.readUInt32LE(base + 12),
rawSize: buffer.readUInt32LE(base + 16),
rawPointer: buffer.readUInt32LE(base + 20),
});
}
const rvaToOffset = (rva) => {
for (const s of sections) {
const span = Math.max(s.virtualSize, s.rawSize);
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
return rva - s.virtualAddress + s.rawPointer;
}
}
return -1;
};
const readCString = (offset) => {
let end = offset;
while (end < buffer.length && buffer[end] !== 0) end++;
return buffer.toString('ascii', offset, end);
};
const importTableOffset = rvaToOffset(importRva);
if (importTableOffset < 0) return [];
const imports = new Set();
for (let i = 0; i < 1024; i++) {
const base = importTableOffset + i * 20;
if (base + 20 > buffer.length) break;
const lookupRva = buffer.readUInt32LE(base);
const nameRva = buffer.readUInt32LE(base + 12);
const iatRva = buffer.readUInt32LE(base + 16);
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
const nameOffset = rvaToOffset(nameRva);
if (nameOffset < 0) continue;
const name = readCString(nameOffset);
if (name) imports.add(name);
}
return Array.from(imports);
}
function windowsImportProbe(nativePath) {
const imports = readPeImports(nativePath);
if (imports === null) return null;
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
const redistributable = sortedImports.filter((dll) =>
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
);
return {
command: ['pe-imports', nativePath],
status: 0,
signal: null,
error: null,
stdout: sortedImports.join('\n') || null,
stderr: null,
missing: [],
redistributable,
};
}
function dependencyProbe(nativePath) {
if (!nativePath || !existsSync(nativePath)) return null;
if (process.platform === 'win32') return windowsImportProbe(nativePath);
const command =
process.platform === 'linux'
? ['ldd', nativePath]
: process.platform === 'darwin'
? ['otool', '-L', nativePath]
: null;
if (!command) return null;
const [bin, ...args] = command;
const result = spawnSync(bin, args, {
encoding: 'utf8',
timeout: 4000,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdout = trimText(result.stdout);
const stderr = trimText(result.stderr);
const missing =
process.platform === 'linux' && stdout
? stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('not found'))
: [];
return {
command,
status: result.status,
signal: result.signal || null,
error: result.error ? result.error.message : null,
stdout,
stderr,
missing,
redistributable: [],
};
}
function formatDependencyProbe(diagnostic) {
if (!diagnostic) return null;
const status = diagnostic.error
? `error=${diagnostic.error}`
: diagnostic.signal
? `signal=${diagnostic.signal}`
: `status=${diagnostic.status}`;
return [
`$ ${diagnostic.command.join(' ')}`,
status,
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
diagnostic.redistributable?.length
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
: null,
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
]
.filter(Boolean)
.join('\n');
}
function formatExtraDiagnostic(diagnostic) {
if (!diagnostic) return null;
if (typeof diagnostic === 'string') return diagnostic;
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
return `${diagnostic.name}:\n${diagnostic.text}`;
}
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
}
function collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
return {
schemaVersion: 1,
moduleName,
reason,
target: {
platform: process.platform,
arch: process.arch,
},
packageDir: packageDir || null,
nativeRoot: nativeRoot || null,
nativePath: nativePath || null,
nativeFile: nativePath ? basename(nativePath) : null,
nativeFileStat: fileDiagnostic(nativePath),
runtime: runtimeDiagnostics(),
environment: environmentDiagnostics(skipNativeProbeEnv),
nativeRootEntries: directoryDiagnostic(nativeRoot),
dependencyProbe: dependencyProbe(nativePath),
extraDiagnostics: extraDiagnostics.filter(Boolean),
cause: errorDiagnostic(cause),
};
}
function formatNativeDiagnostics(diagnostics) {
const sections = [
`module=${diagnostics.moduleName}`,
`reason=${diagnostics.reason}`,
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
`environment:\n${formatEnvironment(diagnostics.environment)}`,
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
];
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
const causeText = formatErrorDiagnostic(diagnostics.cause);
if (causeText) sections.push(`cause:\n${causeText}`);
return sections.join('\n');
}
function isNativeLoadError(error) {
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
}
function createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics = [],
}) {
if (isNativeLoadError(cause)) return cause;
const diagnostics = collectNativeDiagnostics({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
cause,
skipNativeProbeEnv,
extraDiagnostics,
});
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
error.name = 'NativeModuleLoadError';
error[NATIVE_LOAD_ERROR_MARKER] = true;
error.nativeDiagnostics = diagnostics;
error.toJSON = () => ({
name: error.name,
message: error.message,
nativeDiagnostics: diagnostics,
});
if (cause) error.cause = cause;
return error;
}
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
return null;
}
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: timeoutMs,
});
if (result.status === 0) return null;
const reason = result.error
? result.error.message
: result.signal
? `safety probe terminated by signal ${result.signal}`
: `safety probe exited with code ${result.status}`;
return createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason,
skipNativeProbeEnv,
extraDiagnostics: [
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
],
});
}
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
if (!existsSync(nativePath)) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'native binary not found',
skipNativeProbeEnv,
}),
};
}
const nativeProbeError = probe
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
: null;
if (nativeProbeError) {
return {binding: null, loadError: nativeProbeError};
}
try {
return {binding: require(nativePath), loadError: null};
} catch (error) {
return {
binding: null,
loadError: createNativeLoadError({
moduleName,
nativePath,
nativeRoot,
packageDir,
reason: 'require(nativePath) threw',
cause: error,
skipNativeProbeEnv,
}),
};
}
}
module.exports = {
collectNativeDiagnostics,
createNativeLoadError,
formatNativeDiagnostics,
isNativeLoadError,
loadNativeBinding,
probeNativeBinary,
};
@@ -0,0 +1,27 @@
{
"name": "@fluxer/macos-input-hook",
"version": "0.0.0",
"description": "",
"private": true,
"license": "AGPL-3.0-or-later",
"os": [
"darwin"
],
"cpu": [
"x64",
"arm64"
],
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"loader-diagnostics.cjs",
"macos-input-hook.darwin-x64.node",
"macos-input-hook.darwin-arm64.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
"test": "cargo test --manifest-path Cargo.toml"
}
}
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::ffi::c_void;
use std::ptr;
use core_foundation::base::{CFAllocatorRef, CFRelease, TCFType, kCFAllocatorDefault};
use core_foundation::dictionary::{CFDictionary, CFDictionaryRef};
use core_foundation::number::CFNumber;
use core_foundation::runloop::{CFRunLoop, CFRunLoopRef, kCFRunLoopCommonModes};
use core_foundation::string::{CFString, CFStringRef};
const KEYBOARD_USAGE_PAGE: u32 = 0x07;
const CAPS_LOCK_USAGE: u32 = 0x39;
const GENERIC_DESKTOP_USAGE_PAGE: i32 = 0x01;
const KEYBOARD_DEVICE_USAGE: i32 = 0x06;
const HID_OPTIONS_NONE: u32 = 0;
const KERN_SUCCESS: i32 = 0;
type IOHIDManagerRef = *mut c_void;
type IOHIDValueRef = *mut c_void;
type IOHIDElementRef = *mut c_void;
type IOHIDValueCallback = extern "C" fn(*mut c_void, i32, *mut c_void, IOHIDValueRef);
#[link(name = "IOKit", kind = "framework")]
unsafe extern "C" {
fn IOHIDManagerCreate(allocator: CFAllocatorRef, options: u32) -> IOHIDManagerRef;
fn IOHIDManagerSetDeviceMatching(manager: IOHIDManagerRef, matching: CFDictionaryRef);
fn IOHIDManagerSetInputValueMatching(manager: IOHIDManagerRef, matching: CFDictionaryRef);
fn IOHIDManagerRegisterInputValueCallback(
manager: IOHIDManagerRef,
callback: Option<IOHIDValueCallback>,
context: *mut c_void,
);
fn IOHIDManagerScheduleWithRunLoop(
manager: IOHIDManagerRef,
run_loop: CFRunLoopRef,
mode: CFStringRef,
);
fn IOHIDManagerUnscheduleFromRunLoop(
manager: IOHIDManagerRef,
run_loop: CFRunLoopRef,
mode: CFStringRef,
);
fn IOHIDManagerOpen(manager: IOHIDManagerRef, options: u32) -> i32;
fn IOHIDManagerClose(manager: IOHIDManagerRef, options: u32) -> i32;
fn IOHIDValueGetElement(value: IOHIDValueRef) -> IOHIDElementRef;
fn IOHIDValueGetIntegerValue(value: IOHIDValueRef) -> isize;
fn IOHIDElementGetUsagePage(element: IOHIDElementRef) -> u32;
fn IOHIDElementGetUsage(element: IOHIDElementRef) -> u32;
}
struct CapsLockContext {
emit: Box<dyn Fn(bool)>,
}
extern "C" fn caps_lock_input_value_callback(
context: *mut c_void,
result: i32,
_sender: *mut c_void,
value: IOHIDValueRef,
) {
if result != KERN_SUCCESS || context.is_null() || value.is_null() {
return;
}
let element = unsafe { IOHIDValueGetElement(value) };
if element.is_null() {
return;
}
let usage_page = unsafe { IOHIDElementGetUsagePage(element) };
let usage = unsafe { IOHIDElementGetUsage(element) };
if usage_page != KEYBOARD_USAGE_PAGE || usage != CAPS_LOCK_USAGE {
return;
}
let pressed = unsafe { IOHIDValueGetIntegerValue(value) } != 0;
let caps_context = unsafe { &*context.cast::<CapsLockContext>() };
(caps_context.emit)(pressed);
}
fn usage_matching_dictionary(entries: &[(&'static str, i32)]) -> CFDictionary<CFString, CFNumber> {
let pairs: Vec<(CFString, CFNumber)> = entries
.iter()
.map(|(key, value)| (CFString::from_static_string(key), CFNumber::from(*value)))
.collect();
CFDictionary::from_CFType_pairs(&pairs)
}
pub struct CapsLockHidListener {
manager: IOHIDManagerRef,
context: *mut CapsLockContext,
run_loop: CFRunLoop,
}
impl CapsLockHidListener {
pub fn start(emit: Box<dyn Fn(bool)>) -> Option<Self> {
let manager = unsafe { IOHIDManagerCreate(kCFAllocatorDefault, HID_OPTIONS_NONE) };
if manager.is_null() {
return None;
}
let device_matching = usage_matching_dictionary(&[
("DeviceUsagePage", GENERIC_DESKTOP_USAGE_PAGE),
("DeviceUsage", KEYBOARD_DEVICE_USAGE),
]);
#[allow(clippy::cast_possible_wrap)]
let value_matching = usage_matching_dictionary(&[
("UsagePage", KEYBOARD_USAGE_PAGE as i32),
("Usage", CAPS_LOCK_USAGE as i32),
]);
let context = Box::into_raw(Box::new(CapsLockContext { emit }));
let run_loop = CFRunLoop::get_current();
unsafe {
IOHIDManagerSetDeviceMatching(manager, device_matching.as_concrete_TypeRef());
IOHIDManagerSetInputValueMatching(manager, value_matching.as_concrete_TypeRef());
IOHIDManagerRegisterInputValueCallback(
manager,
Some(caps_lock_input_value_callback),
context.cast(),
);
IOHIDManagerScheduleWithRunLoop(
manager,
run_loop.as_concrete_TypeRef(),
kCFRunLoopCommonModes,
);
let status = IOHIDManagerOpen(manager, HID_OPTIONS_NONE);
if status != KERN_SUCCESS {
IOHIDManagerUnscheduleFromRunLoop(
manager,
run_loop.as_concrete_TypeRef(),
kCFRunLoopCommonModes,
);
IOHIDManagerRegisterInputValueCallback(manager, None, ptr::null_mut());
CFRelease(manager.cast());
drop(Box::from_raw(context));
return None;
}
}
Some(Self {
manager,
context,
run_loop,
})
}
}
impl Drop for CapsLockHidListener {
fn drop(&mut self) {
unsafe {
IOHIDManagerUnscheduleFromRunLoop(
self.manager,
self.run_loop.as_concrete_TypeRef(),
kCFRunLoopCommonModes,
);
IOHIDManagerRegisterInputValueCallback(self.manager, None, ptr::null_mut());
let _ = IOHIDManagerClose(self.manager, HID_OPTIONS_NONE);
CFRelease(self.manager.cast());
drop(Box::from_raw(self.context));
}
}
}
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const KEYCODE_TABLE: &[(u16, &str)] = &[
(0x35, "Escape"),
(0x7a, "F1"),
(0x78, "F2"),
(0x63, "F3"),
(0x76, "F4"),
(0x60, "F5"),
(0x61, "F6"),
(0x62, "F7"),
(0x64, "F8"),
(0x65, "F9"),
(0x6d, "F10"),
(0x67, "F11"),
(0x6f, "F12"),
(0x69, "F13"),
(0x6b, "F14"),
(0x71, "F15"),
(0x6a, "F16"),
(0x40, "F17"),
(0x4f, "F18"),
(0x50, "F19"),
(0x5a, "F20"),
(0x32, "Backquote"),
(0x12, "1"),
(0x13, "2"),
(0x14, "3"),
(0x15, "4"),
(0x17, "5"),
(0x16, "6"),
(0x1a, "7"),
(0x1c, "8"),
(0x19, "9"),
(0x1d, "0"),
(0x1b, "Minus"),
(0x18, "Equal"),
(0x33, "Backspace"),
(0x30, "Tab"),
(0x0c, "Q"),
(0x0d, "W"),
(0x0e, "E"),
(0x0f, "R"),
(0x11, "T"),
(0x10, "Y"),
(0x20, "U"),
(0x22, "I"),
(0x1f, "O"),
(0x23, "P"),
(0x21, "BracketLeft"),
(0x1e, "BracketRight"),
(0x2a, "Backslash"),
(0x0a, "IntlBackslash"),
(0x39, "CapsLock"),
(0x00, "A"),
(0x01, "S"),
(0x02, "D"),
(0x03, "F"),
(0x05, "G"),
(0x04, "H"),
(0x26, "J"),
(0x28, "K"),
(0x25, "L"),
(0x29, "Semicolon"),
(0x27, "Quote"),
(0x24, "Enter"),
(0x38, "ShiftLeft"),
(0x06, "Z"),
(0x07, "X"),
(0x08, "C"),
(0x09, "V"),
(0x0b, "B"),
(0x2d, "N"),
(0x2e, "M"),
(0x2b, "Comma"),
(0x2f, "Period"),
(0x2c, "Slash"),
(0x3c, "ShiftRight"),
(0x3b, "ControlLeft"),
(0x37, "MetaLeft"),
(0x3a, "AltLeft"),
(0x31, "Space"),
(0x3d, "AltRight"),
(0x36, "MetaRight"),
(0x3e, "ControlRight"),
(0x41, "NumpadDecimal"),
(0x43, "NumpadMultiply"),
(0x45, "NumpadAdd"),
(0x47, "NumLock"),
(0x4b, "NumpadDivide"),
(0x4c, "NumpadEnter"),
(0x4e, "NumpadSubtract"),
(0x51, "NumpadEqual"),
(0x52, "Numpad0"),
(0x53, "Numpad1"),
(0x54, "Numpad2"),
(0x55, "Numpad3"),
(0x56, "Numpad4"),
(0x57, "Numpad5"),
(0x58, "Numpad6"),
(0x59, "Numpad7"),
(0x5b, "Numpad8"),
(0x5c, "Numpad9"),
(0x5d, "IntlYen"),
(0x5e, "IntlRo"),
(0x5f, "NumpadComma"),
(0x66, "Lang2"),
(0x68, "Lang1"),
(0x7b, "ArrowLeft"),
(0x7e, "ArrowUp"),
(0x7c, "ArrowRight"),
(0x7d, "ArrowDown"),
(0x72, "Insert"),
(0x75, "Delete"),
(0x73, "Home"),
(0x77, "End"),
(0x74, "PageUp"),
(0x79, "PageDown"),
];
pub fn keycode_to_name(keycode: u16) -> Option<&'static str> {
KEYCODE_TABLE
.iter()
.find(|(code, _)| *code == keycode)
.map(|(_, name)| *name)
}
pub fn keycode_name_or_fallback(keycode: u16) -> String {
match keycode_to_name(keycode) {
Some(name) => name.to_owned(),
None => format!("Key{keycode}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letters_use_apple_hid_positions() {
assert_eq!(Some("A"), keycode_to_name(0x00));
assert_eq!(Some("Z"), keycode_to_name(0x06));
assert_eq!(Some("M"), keycode_to_name(0x2e));
}
#[test]
fn caps_lock_maps_to_dom_style_name() {
assert_eq!(Some("CapsLock"), keycode_to_name(0x39));
assert_eq!("CapsLock", keycode_name_or_fallback(0x39));
}
#[test]
fn modifiers_map_to_side_distinguished_names() {
assert_eq!(Some("ShiftLeft"), keycode_to_name(0x38));
assert_eq!(Some("ShiftRight"), keycode_to_name(0x3c));
assert_eq!(Some("MetaLeft"), keycode_to_name(0x37));
assert_eq!(Some("AltLeft"), keycode_to_name(0x3a));
}
#[test]
fn function_keys_cover_f1_through_f20() {
assert_eq!(Some("F1"), keycode_to_name(0x7a));
assert_eq!(Some("F12"), keycode_to_name(0x6f));
assert_eq!(Some("F13"), keycode_to_name(0x69));
assert_eq!(Some("F20"), keycode_to_name(0x5a));
}
#[test]
fn numpad_and_international_keys_map() {
assert_eq!(Some("Numpad0"), keycode_to_name(0x52));
assert_eq!(Some("NumpadEnter"), keycode_to_name(0x4c));
assert_eq!(Some("NumpadEqual"), keycode_to_name(0x51));
assert_eq!(Some("NumLock"), keycode_to_name(0x47));
assert_eq!(Some("IntlYen"), keycode_to_name(0x5d));
assert_eq!(Some("Lang1"), keycode_to_name(0x68));
}
#[test]
fn arrows_and_editing_keys_map() {
assert_eq!(Some("ArrowLeft"), keycode_to_name(0x7b));
assert_eq!(Some("ArrowDown"), keycode_to_name(0x7d));
assert_eq!(Some("Delete"), keycode_to_name(0x75));
}
#[test]
fn unknown_keycode_falls_back_to_key_n() {
assert_eq!(None, keycode_to_name(0xfff));
assert_eq!("Key291", keycode_name_or_fallback(0x123));
}
}
@@ -0,0 +1,46 @@
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
// SPDX-License-Identifier: AGPL-3.0-or-later
mod keymap;
mod modifiers;
mod mouse;
use napi_derive::napi;
#[cfg(target_os = "macos")]
mod caps_lock_hid;
#[cfg(target_os = "macos")]
mod platform;
#[cfg(target_os = "macos")]
pub use platform::InputHook;
#[napi(js_name = "isAvailable")]
pub fn is_available() -> bool {
cfg!(target_os = "macos")
}
#[napi(js_name = "hasAccessibilityPermission")]
pub fn has_accessibility_permission() -> bool {
#[cfg(target_os = "macos")]
{
platform::has_accessibility_permission()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
#[cfg(all(test, not(target_os = "macos")))]
mod tests {
use super::*;
#[test]
fn stub_reports_not_available_off_macos() {
assert!(!is_available());
assert!(!has_accessibility_permission());
}
}
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const SHIFT_MASK: u64 = 1 << 17;
pub const CONTROL_MASK: u64 = 1 << 18;
pub const ALTERNATE_MASK: u64 = 1 << 19;
pub const COMMAND_MASK: u64 = 1 << 20;
const LEFT_SHIFT_KEYCODE: u16 = 0x38;
const RIGHT_SHIFT_KEYCODE: u16 = 0x3c;
const LEFT_CONTROL_KEYCODE: u16 = 0x3b;
const RIGHT_CONTROL_KEYCODE: u16 = 0x3e;
const LEFT_OPTION_KEYCODE: u16 = 0x3a;
const RIGHT_OPTION_KEYCODE: u16 = 0x3d;
const LEFT_COMMAND_KEYCODE: u16 = 0x37;
const RIGHT_COMMAND_KEYCODE: u16 = 0x36;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct Modifiers {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub meta: bool,
}
pub fn from_flags(flags: u64) -> Modifiers {
Modifiers {
ctrl: (flags & CONTROL_MASK) != 0,
alt: (flags & ALTERNATE_MASK) != 0,
shift: (flags & SHIFT_MASK) != 0,
meta: (flags & COMMAND_MASK) != 0,
}
}
pub fn modifier_key_down_from_flags(keycode: u16, flags: u64) -> Option<bool> {
match keycode {
LEFT_SHIFT_KEYCODE | RIGHT_SHIFT_KEYCODE => Some((flags & SHIFT_MASK) != 0),
LEFT_CONTROL_KEYCODE | RIGHT_CONTROL_KEYCODE => Some((flags & CONTROL_MASK) != 0),
LEFT_OPTION_KEYCODE | RIGHT_OPTION_KEYCODE => Some((flags & ALTERNATE_MASK) != 0),
LEFT_COMMAND_KEYCODE | RIGHT_COMMAND_KEYCODE => Some((flags & COMMAND_MASK) != 0),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_flags_all_false() {
let m = from_flags(0);
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
}
#[test]
fn command_alone_sets_only_meta() {
let m = from_flags(COMMAND_MASK);
assert!(m.meta);
assert!(!m.ctrl && !m.alt && !m.shift);
}
#[test]
fn option_alone_sets_only_alt() {
let m = from_flags(ALTERNATE_MASK);
assert!(m.alt);
assert!(!m.ctrl && !m.meta && !m.shift);
}
#[test]
fn cmd_shift_combo() {
let m = from_flags(COMMAND_MASK | SHIFT_MASK);
assert!(m.meta && m.shift);
assert!(!m.ctrl && !m.alt);
}
#[test]
fn all_four_modifiers_together() {
let m = from_flags(SHIFT_MASK | CONTROL_MASK | ALTERNATE_MASK | COMMAND_MASK);
assert!(m.ctrl && m.alt && m.shift && m.meta);
}
#[test]
fn unrelated_high_bits_ignored() {
let m = from_flags(0xff << 32);
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
}
#[test]
fn modifier_key_down_uses_matching_aggregate_flag() {
assert_eq!(
modifier_key_down_from_flags(LEFT_SHIFT_KEYCODE, SHIFT_MASK),
Some(true)
);
assert_eq!(
modifier_key_down_from_flags(RIGHT_SHIFT_KEYCODE, 0),
Some(false)
);
assert_eq!(
modifier_key_down_from_flags(LEFT_COMMAND_KEYCODE, COMMAND_MASK),
Some(true)
);
assert_eq!(modifier_key_down_from_flags(0x39, 0), None);
}
}
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[repr(u32)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CgEventType {
LeftMouseDown = 1,
LeftMouseUp = 2,
RightMouseDown = 3,
RightMouseUp = 4,
MouseMoved = 5,
LeftMouseDragged = 6,
RightMouseDragged = 7,
KeyDown = 10,
KeyUp = 11,
FlagsChanged = 12,
ScrollWheel = 22,
OtherMouseDown = 25,
OtherMouseUp = 26,
OtherMouseDragged = 27,
}
impl CgEventType {
pub fn from_u32(value: u32) -> Option<Self> {
Some(match value {
1 => Self::LeftMouseDown,
2 => Self::LeftMouseUp,
3 => Self::RightMouseDown,
4 => Self::RightMouseUp,
5 => Self::MouseMoved,
6 => Self::LeftMouseDragged,
7 => Self::RightMouseDragged,
10 => Self::KeyDown,
11 => Self::KeyUp,
12 => Self::FlagsChanged,
22 => Self::ScrollWheel,
25 => Self::OtherMouseDown,
26 => Self::OtherMouseUp,
27 => Self::OtherMouseDragged,
_ => return None,
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Classification {
Button(u8),
Ignored,
}
pub fn classify(event_type: CgEventType, other_button: u32) -> Classification {
match event_type {
CgEventType::LeftMouseDown | CgEventType::LeftMouseUp => Classification::Button(0),
CgEventType::RightMouseDown | CgEventType::RightMouseUp => Classification::Button(2),
CgEventType::OtherMouseDown | CgEventType::OtherMouseUp => match other_button {
2 => Classification::Button(1),
3 => Classification::Button(3),
4 => Classification::Button(4),
_ => Classification::Ignored,
},
_ => Classification::Ignored,
}
}
pub fn is_down(event_type: CgEventType) -> bool {
matches!(
event_type,
CgEventType::LeftMouseDown | CgEventType::RightMouseDown | CgEventType::OtherMouseDown
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn left_and_right_buttons_map_to_0_and_2() {
assert_eq!(
Classification::Button(0),
classify(CgEventType::LeftMouseDown, 0)
);
assert_eq!(
Classification::Button(2),
classify(CgEventType::RightMouseUp, 0)
);
}
#[test]
fn middle_button_other_2_maps_to_1() {
assert_eq!(
Classification::Button(1),
classify(CgEventType::OtherMouseDown, 2)
);
}
#[test]
fn back_forward_other_3_4_map_to_3_and_4() {
assert_eq!(
Classification::Button(3),
classify(CgEventType::OtherMouseDown, 3)
);
assert_eq!(
Classification::Button(4),
classify(CgEventType::OtherMouseUp, 4)
);
}
#[test]
fn unknown_other_button_is_ignored() {
assert_eq!(
Classification::Ignored,
classify(CgEventType::OtherMouseDown, 99)
);
}
#[test]
fn is_down_distinguishes_press_from_release() {
assert!(is_down(CgEventType::LeftMouseDown));
assert!(!is_down(CgEventType::LeftMouseUp));
assert!(is_down(CgEventType::OtherMouseDown));
assert!(!is_down(CgEventType::MouseMoved));
}
#[test]
fn from_u32_maps_known_event_types() {
assert_eq!(Some(CgEventType::LeftMouseDown), CgEventType::from_u32(1));
assert_eq!(Some(CgEventType::ScrollWheel), CgEventType::from_u32(22));
assert_eq!(None, CgEventType::from_u32(999));
}
}
@@ -0,0 +1,504 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::ffi::c_void;
use std::ptr;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicPtr, AtomicU64, Ordering},
mpsc::{Receiver, Sender, channel},
};
use std::thread::{self, JoinHandle};
use core_foundation::base::TCFType;
use core_foundation::mach_port::CFMachPortRef;
use core_foundation::runloop::{CFRunLoop, kCFRunLoopCommonModes};
use core_graphics::event::{
CGEvent, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement,
CGEventTapProxy, CGEventType, CallbackResult, EventField,
};
use napi::bindgen_prelude::{Env, Error, Function, Object, Result, Status, ToNapiValue};
use napi::sys;
use napi::threadsafe_function::{
ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue,
};
use napi_derive::napi;
use crate::caps_lock_hid::CapsLockHidListener;
use crate::keymap;
use crate::modifiers::{self, Modifiers};
use crate::mouse::{self, CgEventType, Classification};
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGEventTapEnable(tap: CFMachPortRef, enable: bool);
}
#[derive(Debug)]
enum InputEventKind {
KeyDown,
KeyUp,
MouseDown,
MouseUp,
MouseMove,
Wheel,
}
#[derive(Debug)]
pub struct InputEventPayload {
kind: InputEventKind,
mods: Modifiers,
keycode: u32,
key_name: String,
button: u8,
delta_x: i32,
delta_y: i32,
x: i32,
y: i32,
has_xy: bool,
}
impl ToNapiValue for InputEventPayload {
unsafe fn to_napi_value(raw_env: sys::napi_env, value: Self) -> Result<sys::napi_value> {
let env = Env::from_raw(raw_env);
let mut obj = Object::new(&env)?;
let kind_str = match value.kind {
InputEventKind::KeyDown => "keydown",
InputEventKind::KeyUp => "keyup",
InputEventKind::MouseDown => "mousedown",
InputEventKind::MouseUp => "mouseup",
InputEventKind::MouseMove => "mousemove",
InputEventKind::Wheel => "wheel",
};
obj.set("type", kind_str)?;
obj.set("ctrlKey", value.mods.ctrl)?;
obj.set("altKey", value.mods.alt)?;
obj.set("shiftKey", value.mods.shift)?;
obj.set("metaKey", value.mods.meta)?;
match value.kind {
InputEventKind::KeyDown | InputEventKind::KeyUp => {
obj.set("keycode", value.keycode)?;
obj.set("keyName", value.key_name)?;
}
InputEventKind::MouseDown | InputEventKind::MouseUp => {
obj.set("button", u32::from(value.button))?;
if value.has_xy {
obj.set("x", value.x)?;
obj.set("y", value.y)?;
}
}
InputEventKind::MouseMove => {
obj.set("x", value.x)?;
obj.set("y", value.y)?;
}
InputEventKind::Wheel => {
obj.set("deltaX", value.delta_x)?;
obj.set("deltaY", value.delta_y)?;
if value.has_xy {
obj.set("x", value.x)?;
obj.set("y", value.y)?;
}
}
}
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
}
}
type EventTsfn = Arc<
ThreadsafeFunction<
InputEventPayload,
UnknownReturnValue,
InputEventPayload,
Status,
false,
true,
EVENT_QUEUE_LIMIT,
>,
>;
const EVENT_QUEUE_LIMIT: usize = 1024;
struct WorkerState {
run_loop: CFRunLoop,
join: JoinHandle<()>,
}
#[napi]
pub struct InputHook {
tsfn: EventTsfn,
state: Mutex<Option<WorkerState>>,
}
#[napi]
impl InputHook {
#[napi(constructor)]
pub fn new(callback: Function<InputEventPayload, UnknownReturnValue>) -> Result<Self> {
let tsfn = Arc::new(
callback
.build_threadsafe_function::<InputEventPayload>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| {
Error::new(
Status::GenericFailure,
format!("failed to create input-hook callback: {}", err.reason),
)
})?,
);
Ok(Self {
tsfn,
state: Mutex::new(None),
})
}
#[napi]
pub fn start(&self) -> Result<()> {
let mut guard = self
.state
.lock()
.map_err(|_| Error::new(Status::GenericFailure, "input hook state mutex poisoned"))?;
if guard.is_some() {
return Ok(());
}
let (loop_tx, loop_rx): (Sender<std::result::Result<CFRunLoop, String>>, Receiver<_>) =
channel();
let tsfn = self.tsfn.clone();
let join = thread::Builder::new()
.name("macos-input-hook".to_owned())
.spawn(move || worker_main(tsfn, loop_tx))
.map_err(|err| {
Error::new(
Status::GenericFailure,
format!("InputHook.start failed: thread spawn failed: {err}"),
)
})?;
let run_loop = match loop_rx.recv() {
Ok(Ok(run_loop)) => run_loop,
Ok(Err(err)) => {
let _ = join.join();
return Err(Error::new(
Status::GenericFailure,
format!("InputHook.start failed: {err}"),
));
}
Err(_) => {
let _ = join.join();
return Err(Error::new(
Status::GenericFailure,
"InputHook.start failed: worker exited before signalling",
));
}
};
*guard = Some(WorkerState { run_loop, join });
Ok(())
}
#[napi]
pub fn stop(&self) -> Result<()> {
let state = {
let mut guard = self.state.lock().map_err(|_| {
Error::new(Status::GenericFailure, "input hook state mutex poisoned")
})?;
guard.take()
};
if let Some(WorkerState { run_loop, join }) = state {
run_loop.stop();
let _ = join.join();
}
Ok(())
}
}
impl Drop for InputHook {
fn drop(&mut self) {
if let Ok(mut guard) = self.state.lock()
&& let Some(WorkerState { run_loop, join }) = guard.take()
{
run_loop.stop();
let _ = join.join();
}
}
}
const EVENTS_OF_INTEREST: &[CGEventType] = &[
CGEventType::KeyDown,
CGEventType::KeyUp,
CGEventType::FlagsChanged,
CGEventType::LeftMouseDown,
CGEventType::LeftMouseUp,
CGEventType::RightMouseDown,
CGEventType::RightMouseUp,
CGEventType::OtherMouseDown,
CGEventType::OtherMouseUp,
CGEventType::MouseMoved,
CGEventType::LeftMouseDragged,
CGEventType::RightMouseDragged,
CGEventType::OtherMouseDragged,
CGEventType::ScrollWheel,
];
const CAPS_LOCK_KEYCODE: u16 = 0x39;
fn worker_main(tsfn: EventTsfn, loop_tx: Sender<std::result::Result<CFRunLoop, String>>) {
let tap_ref = Arc::new(AtomicPtr::new(ptr::null_mut()));
let last_flags = Arc::new(AtomicU64::new(0));
let caps_lock_via_hid = Arc::new(AtomicBool::new(false));
let dispatcher = EventDispatcher {
tsfn: tsfn.clone(),
tap_ref: tap_ref.clone(),
last_flags: last_flags.clone(),
caps_lock_via_hid: caps_lock_via_hid.clone(),
};
let tap_result = CGEventTap::new(
CGEventTapLocation::Session,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::ListenOnly,
EVENTS_OF_INTEREST.to_vec(),
move |proxy, event_type, event| dispatcher.dispatch(proxy, event_type, event),
);
let tap = match tap_result {
Ok(tap) => tap,
Err(()) => {
let _ = loop_tx.send(Err(
"CGEventTapCreate returned NULL (accessibility permission denied?)".to_owned(),
));
return;
}
};
let loop_source = match tap.mach_port().create_runloop_source(0) {
Ok(source) => source,
Err(()) => {
let _ = loop_tx.send(Err("CFMachPortCreateRunLoopSource returned NULL".to_owned()));
return;
}
};
let run_loop = CFRunLoop::get_current();
unsafe { run_loop.add_source(&loop_source, kCFRunLoopCommonModes) };
tap_ref.store(
tap.mach_port().as_concrete_TypeRef().cast::<c_void>(),
Ordering::Release,
);
tap.enable();
let caps_tsfn = tsfn.clone();
let caps_flags = last_flags.clone();
let caps_listener = CapsLockHidListener::start(Box::new(move |pressed| {
let payload = caps_lock_payload(pressed, caps_flags.load(Ordering::Relaxed));
let _ = caps_tsfn.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
}));
caps_lock_via_hid.store(caps_listener.is_some(), Ordering::Release);
if loop_tx.send(Ok(run_loop)).is_err() {
tap_ref.store(ptr::null_mut(), Ordering::Release);
return;
}
CFRunLoop::run_current();
tap_ref.store(ptr::null_mut(), Ordering::Release);
drop(caps_listener);
drop(tap);
drop(loop_source);
}
fn caps_lock_payload(pressed: bool, flags: u64) -> InputEventPayload {
InputEventPayload {
kind: if pressed {
InputEventKind::KeyDown
} else {
InputEventKind::KeyUp
},
mods: modifiers::from_flags(flags),
keycode: u32::from(CAPS_LOCK_KEYCODE),
key_name: keymap::keycode_name_or_fallback(CAPS_LOCK_KEYCODE),
button: 0,
delta_x: 0,
delta_y: 0,
x: 0,
y: 0,
has_xy: false,
}
}
struct EventDispatcher {
tsfn: EventTsfn,
tap_ref: Arc<AtomicPtr<c_void>>,
last_flags: Arc<AtomicU64>,
caps_lock_via_hid: Arc<AtomicBool>,
}
impl EventDispatcher {
fn dispatch(
&self,
_proxy: CGEventTapProxy,
event_type: CGEventType,
event: &CGEvent,
) -> CallbackResult {
match event_type {
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput => {
self.reenable_tap();
return CallbackResult::Keep;
}
_ => {}
}
let flags = event.get_flags();
self.last_flags.store(flags.bits(), Ordering::Relaxed);
let mods = modifiers::from_flags(flags.bits());
let Some(cls) = CgEventType::from_u32(event_type as u32) else {
return CallbackResult::Keep;
};
match cls {
CgEventType::KeyDown | CgEventType::KeyUp => {
let raw = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let keycode = (raw as u64) as u16;
let kind = if matches!(cls, CgEventType::KeyDown) {
InputEventKind::KeyDown
} else {
InputEventKind::KeyUp
};
self.send_key(kind, keycode, mods);
}
CgEventType::FlagsChanged => {
let raw = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let keycode = (raw as u64) as u16;
if keycode == CAPS_LOCK_KEYCODE {
if self.caps_lock_via_hid.load(Ordering::Acquire) {
return CallbackResult::Keep;
}
self.send_key(InputEventKind::KeyDown, keycode, mods);
self.send_key(InputEventKind::KeyUp, keycode, mods);
return CallbackResult::Keep;
}
let Some(is_down) = modifiers::modifier_key_down_from_flags(keycode, flags.bits())
else {
return CallbackResult::Keep;
};
let kind = if is_down {
InputEventKind::KeyDown
} else {
InputEventKind::KeyUp
};
self.send_key(kind, keycode, mods);
}
CgEventType::LeftMouseDown
| CgEventType::LeftMouseUp
| CgEventType::RightMouseDown
| CgEventType::RightMouseUp
| CgEventType::OtherMouseDown
| CgEventType::OtherMouseUp => {
let raw_button =
event.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let other_btn = (raw_button as u64) as u32;
if let Classification::Button(b) = mouse::classify(cls, other_btn) {
let point = event.location();
#[allow(clippy::cast_possible_truncation)]
let payload = InputEventPayload {
kind: if mouse::is_down(cls) {
InputEventKind::MouseDown
} else {
InputEventKind::MouseUp
},
mods,
keycode: 0,
key_name: String::new(),
button: b,
delta_x: 0,
delta_y: 0,
x: point.x as i32,
y: point.y as i32,
has_xy: true,
};
self.send(payload);
}
}
CgEventType::MouseMoved
| CgEventType::LeftMouseDragged
| CgEventType::RightMouseDragged
| CgEventType::OtherMouseDragged => {
let point = event.location();
#[allow(clippy::cast_possible_truncation)]
let payload = InputEventPayload {
kind: InputEventKind::MouseMove,
mods,
keycode: 0,
key_name: String::new(),
button: 0,
delta_x: 0,
delta_y: 0,
x: point.x as i32,
y: point.y as i32,
has_xy: true,
};
self.send(payload);
}
CgEventType::ScrollWheel => {
let dy = event.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1)
as i32;
let dx = event.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2)
as i32;
let point = event.location();
#[allow(clippy::cast_possible_truncation)]
let payload = InputEventPayload {
kind: InputEventKind::Wheel,
mods,
keycode: 0,
key_name: String::new(),
button: 0,
delta_x: dx,
delta_y: dy,
x: point.x as i32,
y: point.y as i32,
has_xy: true,
};
self.send(payload);
}
}
CallbackResult::Keep
}
fn send_key(&self, kind: InputEventKind, keycode: u16, mods: Modifiers) {
let payload = InputEventPayload {
kind,
mods,
keycode: u32::from(keycode),
key_name: keymap::keycode_name_or_fallback(keycode),
button: 0,
delta_x: 0,
delta_y: 0,
x: 0,
y: 0,
has_xy: false,
};
self.send(payload);
}
fn send(&self, payload: InputEventPayload) {
let _ = self
.tsfn
.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
}
fn reenable_tap(&self) {
let tap: CFMachPortRef = self.tap_ref.load(Ordering::Acquire).cast();
if !tap.is_null() {
unsafe { CGEventTapEnable(tap, true) };
}
}
}
pub fn has_accessibility_permission() -> bool {
objc2_core_graphics::CGPreflightListenEventAccess()
}