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
+349
View File
@@ -0,0 +1,349 @@
# 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 = "ctor"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fluxer_linux_input_hook"
version = "0.0.0"
dependencies = [
"napi",
"napi-build",
"napi-derive",
"x11rb",
]
[[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 = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link",
]
[[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 = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[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 = "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 = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[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"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
@@ -0,0 +1,22 @@
[package]
name = "fluxer_linux_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 = "linux")'.dependencies]
x11rb = {version = "0.13.2", default-features = false, features = ["record"]}
[build-dependencies]
napi-build = "2.3.2"
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+54
View File
@@ -0,0 +1,54 @@
// 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 const loadError: Error | null;
@@ -0,0 +1,64 @@
// 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/linux-input-hook';
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_LINUX_INPUT_HOOK_SKIP_NATIVE_PROBE';
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 !== 'linux') {
throw new Error(`@fluxer/linux-input-hook is only supported on Linux, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'linux-input-hook.linux-x64-gnu.node';
case 'arm64':
return 'linux-input-hook.linux-arm64-gnu.node';
default:
throw new Error(`Unsupported Linux architecture: ${process.arch}`);
}
}
let binding = null;
let loadError = null;
if (process.platform === 'linux') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
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,
skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV,
});
throw loadError;
}
}
module.exports = {
InputHook: binding ? binding.InputHook : null,
isAvailable: binding ? binding.isAvailable : () => 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/linux-input-hook",
"version": "0.0.0",
"description": "",
"private": true,
"license": "AGPL-3.0-or-later",
"os": [
"linux"
],
"cpu": [
"x64",
"arm64"
],
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"loader-diagnostics.cjs",
"linux-input-hook.linux-x64-gnu.node",
"linux-input-hook.linux-arm64-gnu.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,118 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayServer {
X11,
Wayland,
WaylandWithXwayland,
Unknown,
}
impl DisplayServer {
pub fn supports_global_xrecord(self) -> bool {
matches!(self, Self::X11)
}
}
pub fn detect_display_server() -> DisplayServer {
detect_from(
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
std::env::var("DISPLAY").ok().as_deref(),
std::env::var("WAYLAND_DISPLAY").ok().as_deref(),
)
}
fn detect_from(
xdg_session_type: Option<&str>,
display: Option<&str>,
wayland_display: Option<&str>,
) -> DisplayServer {
let has_x11 = display.is_some_and(|v| !v.is_empty());
let has_wayland = wayland_display.is_some_and(|v| !v.is_empty());
let xdg_is_wayland = xdg_session_type == Some("wayland");
match (has_x11, has_wayland) {
(true, true) => DisplayServer::WaylandWithXwayland,
(true, false) if xdg_is_wayland => DisplayServer::WaylandWithXwayland,
(true, false) => DisplayServer::X11,
(false, true) => DisplayServer::Wayland,
(false, false) => match xdg_session_type {
Some("x11") => DisplayServer::X11,
Some("wayland") => DisplayServer::Wayland,
_ => DisplayServer::Unknown,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_pure_x11_from_display_only() {
assert_eq!(
detect_from(Some("x11"), Some(":0"), None),
DisplayServer::X11
);
assert_eq!(detect_from(None, Some(":0"), None), DisplayServer::X11);
}
#[test]
fn detect_pure_wayland_from_wayland_display_only() {
assert_eq!(
detect_from(Some("wayland"), None, Some("wayland-0")),
DisplayServer::Wayland
);
assert_eq!(
detect_from(None, None, Some("wayland-0")),
DisplayServer::Wayland
);
}
#[test]
fn detect_xwayland_when_both_sockets_present() {
let ds = detect_from(Some("wayland"), Some(":0"), Some("wayland-0"));
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
assert!(!ds.supports_global_xrecord());
}
#[test]
fn detect_xwayland_from_wayland_session_with_display_only() {
let ds = detect_from(Some("wayland"), Some(":0"), None);
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
assert!(!ds.supports_global_xrecord());
}
#[test]
fn detect_unknown_when_nothing_set() {
assert_eq!(detect_from(None, None, None), DisplayServer::Unknown);
assert_eq!(detect_from(Some("tty"), None, None), DisplayServer::Unknown);
}
#[test]
fn detect_empty_display_var_is_ignored() {
assert_eq!(
detect_from(Some("wayland"), Some(""), Some("wayland-0")),
DisplayServer::Wayland
);
}
#[test]
fn xdg_session_type_fallback_when_sockets_missing() {
assert_eq!(detect_from(Some("x11"), None, None), DisplayServer::X11);
assert_eq!(
detect_from(Some("wayland"), None, None),
DisplayServer::Wayland
);
}
#[test]
fn global_xrecord_support_is_only_for_pure_x11() {
assert!(DisplayServer::X11.supports_global_xrecord());
assert!(!DisplayServer::WaylandWithXwayland.supports_global_xrecord());
assert!(!DisplayServer::Wayland.supports_global_xrecord());
assert!(!DisplayServer::Unknown.supports_global_xrecord());
}
}
@@ -0,0 +1,653 @@
#![allow(non_snake_case)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use napi::{
Env, Status,
bindgen_prelude::{Function, Object, Result, ToNapiValue},
sys,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
};
use napi_derive::napi;
use x11rb::connection::{Connection, RequestConnection};
use x11rb::errors::ReplyError;
use x11rb::protocol::record::{
self, ConnectionExt as RecordConnectionExt, ExtRange, Range, Range8, Range16,
};
use x11rb::protocol::xproto::{
ConnectionExt as XprotoConnectionExt, GetKeyboardMappingReply, Keycode,
};
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
use x11rb::x11_utils::TryParse;
use crate::env::{DisplayServer, detect_display_server};
use crate::keymap;
use crate::modifiers::{self, Modifiers};
use crate::mouse::{self, MouseClassification};
use crate::x11;
const RECORD_FROM_SERVER: u8 = 0;
const RECORD_START_OF_DATA: u8 = 4;
const KEY_PRESS: u8 = 2;
const KEY_RELEASE: u8 = 3;
const BUTTON_PRESS: u8 = 4;
const BUTTON_RELEASE: u8 = 5;
const MOTION_NOTIFY: u8 = 6;
#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct XKeyButtonProto {
type_: u8,
detail: u8,
seq_l: u8,
seq_h: u8,
time: u32,
root: u32,
event: u32,
child: u32,
root_x: i16,
root_y: i16,
event_x: i16,
event_y: i16,
state: u16,
same_screen: u8,
pad0: u8,
}
#[derive(Debug, Clone)]
pub enum EventKind {
KeyDown,
KeyUp,
MouseDown,
MouseUp,
MouseMove,
Wheel,
}
#[derive(Debug, Clone)]
pub struct DecodedEvent {
pub kind: EventKind,
pub keycode: u32,
pub key_name: String,
pub button: u8,
pub delta_x: i32,
pub delta_y: i32,
pub x: i32,
pub y: i32,
pub has_xy: bool,
pub mods: Modifiers,
}
impl DecodedEvent {
fn new(kind: EventKind, mods: Modifiers) -> Self {
Self {
kind,
keycode: 0,
key_name: String::new(),
button: 0,
delta_x: 0,
delta_y: 0,
x: 0,
y: 0,
has_xy: false,
mods,
}
}
}
impl ToNapiValue for DecodedEvent {
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
let env = Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
let kind = match event.kind {
EventKind::KeyDown => "keydown",
EventKind::KeyUp => "keyup",
EventKind::MouseDown => "mousedown",
EventKind::MouseUp => "mouseup",
EventKind::MouseMove => "mousemove",
EventKind::Wheel => "wheel",
};
object.set("type", kind)?;
object.set("ctrlKey", event.mods.ctrl)?;
object.set("altKey", event.mods.alt)?;
object.set("shiftKey", event.mods.shift)?;
object.set("metaKey", event.mods.meta)?;
match event.kind {
EventKind::KeyDown | EventKind::KeyUp => {
object.set("keycode", event.keycode)?;
object.set("keyName", event.key_name.as_str())?;
}
EventKind::MouseDown | EventKind::MouseUp => {
object.set("button", u32::from(event.button))?;
if event.has_xy {
object.set("x", event.x)?;
object.set("y", event.y)?;
}
}
EventKind::MouseMove => {
object.set("x", event.x)?;
object.set("y", event.y)?;
}
EventKind::Wheel => {
object.set("deltaX", event.delta_x)?;
object.set("deltaY", event.delta_y)?;
if event.has_xy {
object.set("x", event.x)?;
object.set("y", event.y)?;
}
}
}
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, object) }
}
}
const EVENT_QUEUE_LIMIT: usize = 1024;
type EventTsfn = Arc<
ThreadsafeFunction<
DecodedEvent,
UnknownReturnValue,
DecodedEvent,
Status,
false,
true,
EVENT_QUEUE_LIMIT,
>,
>;
#[derive(Clone)]
struct KeysymCache {
min_keycode: Keycode,
syms: Vec<u32>,
}
impl KeysymCache {
fn build(reply: &GetKeyboardMappingReply, min_keycode: Keycode) -> Self {
let per = reply.keysyms_per_keycode as usize;
let count = if per == 0 {
0
} else {
reply.keysyms.len() / per
};
let mut syms = Vec::with_capacity(count);
if per > 0 {
for i in 0..count {
syms.push(reply.keysyms[i * per]);
}
}
Self { min_keycode, syms }
}
fn lookup(&self, keycode: u8) -> u32 {
if keycode < self.min_keycode {
return 0;
}
let idx = (keycode - self.min_keycode) as usize;
self.syms.get(idx).copied().unwrap_or(0)
}
}
struct Active {
ctrl_conn: Arc<RustConnection>,
record_ctx: record::Context,
worker: Option<JoinHandle<()>>,
stop: Arc<AtomicBool>,
}
struct Inner {
callback: EventTsfn,
active: Mutex<Option<Active>>,
}
#[napi]
pub struct InputHook {
inner: Arc<Inner>,
}
#[napi]
impl InputHook {
#[napi(constructor)]
pub fn new(callback: Function<DecodedEvent, UnknownReturnValue>) -> Result<Self> {
let tsfn = callback
.build_threadsafe_function::<DecodedEvent>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| generic_error(format!("failed to create TSFN: {}", err.reason)))?;
Ok(Self {
inner: Arc::new(Inner {
callback: Arc::new(tsfn),
active: Mutex::new(None),
}),
})
}
#[napi]
pub fn start(&self) -> Result<()> {
let mut guard = self
.inner
.active
.lock()
.map_err(|_| generic_error("InputHook lock poisoned"))?;
if guard.is_some() {
return Ok(());
}
let active = start_record(self.inner.callback.clone())?;
*guard = Some(active);
Ok(())
}
#[napi]
pub fn stop(&self) -> Result<()> {
let active = {
let mut guard = self
.inner
.active
.lock()
.map_err(|_| generic_error("InputHook lock poisoned"))?;
guard.take()
};
if let Some(active) = active {
tear_down(active);
}
Ok(())
}
}
impl Drop for InputHook {
fn drop(&mut self) {
if let Ok(mut guard) = self.inner.active.lock()
&& let Some(active) = guard.take()
{
tear_down(active);
}
}
}
fn start_record(callback: EventTsfn) -> Result<Active> {
if !detect_display_server().supports_global_xrecord() {
return Err(generic_error(
"InputHook.start failed: WaylandUnsupported — global input \
capture is blocked by the Wayland security model. Use \
@fluxer/linux-evdev for kernel-level capture when the user has \
input device access."
.to_string(),
));
}
let (ctrl_conn, _) = x11rb::connect(None)
.map_err(|err| generic_error(format!("InputHook.start failed: NoXDisplay: {err}")))?;
let (data_conn, _) = x11rb::connect(None)
.map_err(|err| generic_error(format!("InputHook.start failed: NoXDisplay: {err}")))?;
let ctrl_conn = Arc::new(ctrl_conn);
let has_record = ctrl_conn
.extension_information(record::X11_EXTENSION_NAME)
.map_err(|err| generic_error(format!("InputHook.start failed: {err}")))?
.is_some();
if !has_record {
return Err(generic_error(
"InputHook.start failed: RecordExtensionUnavailable",
));
}
ctrl_conn
.record_query_version(
record::X11_XML_VERSION.0 as _,
record::X11_XML_VERSION.1 as _,
)
.map_err(|err| generic_error(format!("InputHook.start failed: RecordQueryVersion: {err}")))?
.reply()
.map_err(|err| {
generic_error(format!("InputHook.start failed: RecordQueryVersion: {err}"))
})?;
let setup = ctrl_conn.setup();
let min_keycode = setup.min_keycode;
let max_keycode = setup.max_keycode;
let count = max_keycode.saturating_sub(min_keycode).saturating_add(1);
let mapping = ctrl_conn
.get_keyboard_mapping(min_keycode, count)
.map_err(|err| generic_error(format!("InputHook.start failed: GetKeyboardMapping: {err}")))?
.reply()
.map_err(|err| {
generic_error(format!("InputHook.start failed: GetKeyboardMapping: {err}"))
})?;
let keysyms = KeysymCache::build(&mapping, min_keycode);
let record_ctx = ctrl_conn
.generate_id()
.map_err(|err| generic_error(format!("InputHook.start failed: GenerateId: {err}")))?;
let empty = Range8 { first: 0, last: 0 };
let empty_ext = ExtRange {
major: empty,
minor: Range16 { first: 0, last: 0 },
};
let range = Range {
core_requests: empty,
core_replies: empty,
ext_requests: empty_ext,
ext_replies: empty_ext,
delivered_events: empty,
device_events: Range8 {
first: KEY_PRESS,
last: MOTION_NOTIFY,
},
errors: empty,
client_started: false,
client_died: false,
};
ctrl_conn
.record_create_context(record_ctx, 0, &[record::CS::ALL_CLIENTS.into()], &[range])
.map_err(|err| {
generic_error(format!(
"InputHook.start failed: RecordCreateContext: {err}"
))
})?
.check()
.map_err(|err| {
generic_error(format!(
"InputHook.start failed: RecordCreateContext: {err}"
))
})?;
let stop = Arc::new(AtomicBool::new(false));
let worker_callback = callback.clone();
let worker_stop = stop.clone();
let worker = thread::Builder::new()
.name("fluxer-linux-input-hook".to_string())
.spawn(move || {
worker_main(data_conn, record_ctx, keysyms, worker_callback, worker_stop);
})
.map_err(|err| generic_error(format!("InputHook.start failed: thread spawn: {err}")))?;
Ok(Active {
ctrl_conn,
record_ctx,
worker: Some(worker),
stop,
})
}
fn worker_main(
data_conn: RustConnection,
record_ctx: record::Context,
keysyms: KeysymCache,
callback: EventTsfn,
stop: Arc<AtomicBool>,
) {
let cookie = match data_conn.record_enable_context(record_ctx) {
Ok(c) => c,
Err(_) => {
stop.store(true, Ordering::Release);
return;
}
};
for reply in cookie {
if stop.load(Ordering::Acquire) {
break;
}
let reply = match reply {
Ok(r) => r,
Err(ReplyError::ConnectionError(_)) => break,
Err(_) => continue,
};
if reply.client_swapped {
continue;
}
match reply.category {
RECORD_START_OF_DATA => continue,
RECORD_FROM_SERVER => {}
_ => continue,
}
let mut data: &[u8] = &reply.data;
while !data.is_empty() {
let consumed = decode_one(data, &keysyms, &callback, &stop);
if consumed == 0 || consumed > data.len() {
break;
}
data = &data[consumed..];
}
}
}
fn decode_one(
data: &[u8],
keysyms: &KeysymCache,
callback: &EventTsfn,
stop: &Arc<AtomicBool>,
) -> usize {
if data.is_empty() {
return 0;
}
let type_ = data[0];
match type_ {
KEY_PRESS | KEY_RELEASE | BUTTON_PRESS | BUTTON_RELEASE | MOTION_NOTIFY => {
if data.len() < std::mem::size_of::<XKeyButtonProto>() {
return 0;
}
let evt: XKeyButtonProto =
unsafe { std::ptr::read_unaligned(data.as_ptr() as *const XKeyButtonProto) };
handle_event(&evt, keysyms, callback, stop);
32
}
0 => {
if data.len() < 8 {
return 0;
}
let (length, _) = match u32::try_parse(&data[4..]) {
Ok(v) => v,
Err(_) => return 0,
};
32 + (length as usize) * 4
}
_ => 32,
}
}
fn handle_event(
evt: &XKeyButtonProto,
keysyms: &KeysymCache,
callback: &EventTsfn,
stop: &Arc<AtomicBool>,
) {
let mods = modifiers::from_state(u32::from(evt.state));
match evt.type_ {
KEY_PRESS | KEY_RELEASE => {
let _lookup = x11::xkb_lookup_for_base();
let keysym = keysyms.lookup(evt.detail);
let mut event = DecodedEvent::new(
if evt.type_ == KEY_PRESS {
EventKind::KeyDown
} else {
EventKind::KeyUp
},
mods,
);
event.keycode = keysym;
event.key_name = match keymap::keysym_to_name(keysym) {
Some(name) => name.to_string(),
None => keymap::fallback_name(keysym),
};
dispatch(callback, stop, event);
}
BUTTON_PRESS | BUTTON_RELEASE => {
let cls = mouse::classify(u32::from(evt.detail));
match cls {
MouseClassification::Button(b) => {
let mut event = DecodedEvent::new(
if evt.type_ == BUTTON_PRESS {
EventKind::MouseDown
} else {
EventKind::MouseUp
},
mods,
);
event.button = b;
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
MouseClassification::Wheel(dir) => {
if evt.type_ == BUTTON_PRESS {
let mut event = DecodedEvent::new(EventKind::Wheel, mods);
event.delta_x = dir.delta_x();
event.delta_y = dir.delta_y();
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
}
MouseClassification::Ignored => {}
}
}
MOTION_NOTIFY => {
let mut event = DecodedEvent::new(EventKind::MouseMove, mods);
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
_ => {}
}
}
fn dispatch(callback: &EventTsfn, stop: &Arc<AtomicBool>, event: DecodedEvent) {
let status = callback.call(event, ThreadsafeFunctionCallMode::NonBlocking);
if status == Status::Closing {
stop.store(true, Ordering::Release);
}
}
fn tear_down(mut active: Active) {
active.stop.store(true, Ordering::Release);
if active.record_ctx != 0 {
let _ = active.ctrl_conn.record_disable_context(active.record_ctx);
let _ = active.ctrl_conn.sync();
}
if let Some(worker) = active.worker.take() {
let _ = worker.join();
}
if active.record_ctx != 0 {
let _ = active.ctrl_conn.record_free_context(active.record_ctx);
let _ = active.ctrl_conn.sync();
}
}
#[napi(js_name = "isAvailable")]
pub fn is_available() -> bool {
if !detect_display_server().supports_global_xrecord() {
return false;
}
x11rb::connect(None).is_ok()
}
#[allow(dead_code)]
pub(crate) fn detected_display_server() -> DisplayServer {
detect_display_server()
}
fn generic_error(reason: impl Into<String>) -> napi::Error {
napi::Error::new(Status::GenericFailure, reason.into())
}
#[allow(dead_code)]
const _ASSERT_PROTO_LAYOUT: fn() = || {
use std::mem::offset_of;
let _ = offset_of!(XKeyButtonProto, seq_l);
let _ = offset_of!(XKeyButtonProto, seq_h);
let _ = offset_of!(XKeyButtonProto, event);
let _ = offset_of!(XKeyButtonProto, child);
let _ = offset_of!(XKeyButtonProto, event_x);
let _ = offset_of!(XKeyButtonProto, event_y);
let _ = offset_of!(XKeyButtonProto, same_screen);
let _ = offset_of!(XKeyButtonProto, pad0);
let _ = offset_of!(XKeyButtonProto, root);
let _ = offset_of!(XKeyButtonProto, time);
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decoded_keydown_carries_keysym_and_name() {
let mut event = DecodedEvent::new(EventKind::KeyDown, modifiers::from_state(0));
event.keycode = 0x0061;
event.key_name = keymap::keysym_to_name(0x0061).unwrap().to_string();
assert!(matches!(event.kind, EventKind::KeyDown));
assert_eq!(event.keycode, 0x0061);
assert_eq!(event.key_name, "A");
assert!(!event.has_xy);
}
#[test]
fn decoded_wheel_uses_120_step_deltas() {
let dir = mouse::WheelDirection::Down;
let mut event = DecodedEvent::new(EventKind::Wheel, modifiers::from_state(0));
event.delta_x = dir.delta_x();
event.delta_y = dir.delta_y();
event.has_xy = true;
assert_eq!(event.delta_x, 0);
assert_eq!(event.delta_y, 120);
}
#[test]
fn x_key_button_proto_layout_offsets_match_x11_wire_format() {
use std::mem::offset_of;
assert_eq!(offset_of!(XKeyButtonProto, type_), 0);
assert_eq!(offset_of!(XKeyButtonProto, detail), 1);
assert_eq!(offset_of!(XKeyButtonProto, time), 4);
assert_eq!(offset_of!(XKeyButtonProto, root_x), 20);
assert_eq!(offset_of!(XKeyButtonProto, root_y), 22);
assert_eq!(offset_of!(XKeyButtonProto, state), 28);
}
#[test]
fn event_kind_to_string_matches_js_contract() {
let cases: &[(EventKind, &str)] = &[
(EventKind::KeyDown, "keydown"),
(EventKind::KeyUp, "keyup"),
(EventKind::MouseDown, "mousedown"),
(EventKind::MouseUp, "mouseup"),
(EventKind::MouseMove, "mousemove"),
(EventKind::Wheel, "wheel"),
];
for (kind, expected) in cases {
let label = match kind {
EventKind::KeyDown => "keydown",
EventKind::KeyUp => "keyup",
EventKind::MouseDown => "mousedown",
EventKind::MouseUp => "mouseup",
EventKind::MouseMove => "mousemove",
EventKind::Wheel => "wheel",
};
assert_eq!(label, *expected);
}
}
#[test]
fn keysym_cache_returns_zero_below_min_keycode() {
let cache = KeysymCache {
min_keycode: 8,
syms: vec![0x61, 0x62, 0x63],
};
assert_eq!(cache.lookup(7), 0);
assert_eq!(cache.lookup(8), 0x61);
assert_eq!(cache.lookup(10), 0x63);
assert_eq!(cache.lookup(255), 0);
}
}
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const KEYSYM_TABLE: &[(u32, &str)] = &[
(0xff1b, "Escape"),
(0xffbe, "F1"),
(0xffbf, "F2"),
(0xffc0, "F3"),
(0xffc1, "F4"),
(0xffc2, "F5"),
(0xffc3, "F6"),
(0xffc4, "F7"),
(0xffc5, "F8"),
(0xffc6, "F9"),
(0xffc7, "F10"),
(0xffc8, "F11"),
(0xffc9, "F12"),
(0xffca, "F13"),
(0xffcb, "F14"),
(0xffcc, "F15"),
(0xffcd, "F16"),
(0xffce, "F17"),
(0xffcf, "F18"),
(0xffd0, "F19"),
(0xffd1, "F20"),
(0xffd2, "F21"),
(0xffd3, "F22"),
(0xffd4, "F23"),
(0xffd5, "F24"),
(0xff61, "PrintScreen"),
(0xff14, "ScrollLock"),
(0xff13, "Pause"),
(0xff7f, "NumLock"),
(0xff67, "ContextMenu"),
(0x0060, "Backquote"),
(0x007e, "Backquote"),
(0x0031, "1"),
(0x0032, "2"),
(0x0033, "3"),
(0x0034, "4"),
(0x0035, "5"),
(0x0036, "6"),
(0x0037, "7"),
(0x0038, "8"),
(0x0039, "9"),
(0x0030, "0"),
(0x002d, "Minus"),
(0x003d, "Equal"),
(0xff08, "Backspace"),
(0xff09, "Tab"),
(0x0071, "Q"),
(0x0077, "W"),
(0x0065, "E"),
(0x0072, "R"),
(0x0074, "T"),
(0x0079, "Y"),
(0x0075, "U"),
(0x0069, "I"),
(0x006f, "O"),
(0x0070, "P"),
(0x005b, "BracketLeft"),
(0x005d, "BracketRight"),
(0x005c, "Backslash"),
(0xffe5, "CapsLock"),
(0x0061, "A"),
(0x0073, "S"),
(0x0064, "D"),
(0x0066, "F"),
(0x0067, "G"),
(0x0068, "H"),
(0x006a, "J"),
(0x006b, "K"),
(0x006c, "L"),
(0x003b, "Semicolon"),
(0x0027, "Quote"),
(0xff0d, "Enter"),
(0xffe1, "ShiftLeft"),
(0x007a, "Z"),
(0x0078, "X"),
(0x0063, "C"),
(0x0076, "V"),
(0x0062, "B"),
(0x006e, "N"),
(0x006d, "M"),
(0x002c, "Comma"),
(0x002e, "Period"),
(0x002f, "Slash"),
(0xffe2, "ShiftRight"),
(0xffe3, "ControlLeft"),
(0xffeb, "MetaLeft"),
(0xffe9, "AltLeft"),
(0x0020, "Space"),
(0xffea, "AltRight"),
(0xffec, "MetaRight"),
(0xffe4, "ControlRight"),
(0xff80, "Space"),
(0xff89, "Tab"),
(0xff8d, "NumpadEnter"),
(0xffbd, "NumpadEqual"),
(0xffaa, "NumpadMultiply"),
(0xffab, "NumpadAdd"),
(0xffac, "NumpadComma"),
(0xffad, "NumpadSubtract"),
(0xffae, "NumpadDecimal"),
(0xffaf, "NumpadDivide"),
(0xffb0, "Numpad0"),
(0xffb1, "Numpad1"),
(0xffb2, "Numpad2"),
(0xffb3, "Numpad3"),
(0xffb4, "Numpad4"),
(0xffb5, "Numpad5"),
(0xffb6, "Numpad6"),
(0xffb7, "Numpad7"),
(0xffb8, "Numpad8"),
(0xffb9, "Numpad9"),
(0xff51, "ArrowLeft"),
(0xff52, "ArrowUp"),
(0xff53, "ArrowRight"),
(0xff54, "ArrowDown"),
(0xff63, "Insert"),
(0xffff, "Delete"),
(0xff50, "Home"),
(0xff57, "End"),
(0xff55, "PageUp"),
(0xff56, "PageDown"),
(0x1008ff12, "AudioVolumeMute"),
(0x1008ff11, "AudioVolumeDown"),
(0x1008ff13, "AudioVolumeUp"),
(0x1008ff17, "MediaTrackNext"),
(0x1008ff16, "MediaTrackPrevious"),
(0x1008ff15, "MediaStop"),
(0x1008ff14, "MediaPlayPause"),
(0x1008ff26, "BrowserBack"),
(0x1008ff27, "BrowserForward"),
(0x1008ff29, "BrowserRefresh"),
(0x1008ff28, "BrowserStop"),
(0x1008ff1b, "BrowserSearch"),
(0x1008ff30, "BrowserFavorites"),
(0x1008ff18, "BrowserHome"),
(0x1008ff19, "LaunchMail"),
(0x1008ff32, "LaunchMediaPlayer"),
(0x1008ff41, "LaunchApp1"),
(0x1008ff42, "LaunchApp2"),
(0x1008ff2a, "Power"),
(0x1008ff2f, "Sleep"),
(0x1008ff2b, "WakeUp"),
(0xff23, "Convert"),
(0xff22, "NonConvert"),
(0xff2d, "KanaMode"),
(0xff31, "Lang1"),
(0xff34, "Lang2"),
];
pub fn keysym_to_name(keysym: u32) -> Option<&'static str> {
KEYSYM_TABLE
.iter()
.find(|(sym, _)| *sym == keysym)
.map(|(_, name)| *name)
}
pub fn fallback_name(keysym: u32) -> String {
format!("Key{keysym}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn common_letters_map_to_single_letter_names() {
assert_eq!(keysym_to_name(0x0061), Some("A"));
assert_eq!(keysym_to_name(0x006d), Some("M"));
assert_eq!(keysym_to_name(0x007a), Some("Z"));
}
#[test]
fn modifier_keysyms_produce_side_distinguished_names() {
assert_eq!(keysym_to_name(0xffe1), Some("ShiftLeft"));
assert_eq!(keysym_to_name(0xffe2), Some("ShiftRight"));
assert_eq!(keysym_to_name(0xffe3), Some("ControlLeft"));
assert_eq!(keysym_to_name(0xffe9), Some("AltLeft"));
assert_eq!(keysym_to_name(0xffeb), Some("MetaLeft"));
}
#[test]
fn function_keys_f1_through_f12() {
assert_eq!(keysym_to_name(0xffbe), Some("F1"));
assert_eq!(keysym_to_name(0xffc9), Some("F12"));
assert_eq!(keysym_to_name(0xff13), Some("Pause"));
assert_eq!(keysym_to_name(0xffca), Some("F13"));
assert_eq!(keysym_to_name(0xffb0), Some("Numpad0"));
assert_eq!(keysym_to_name(0x1008ff12), Some("AudioVolumeMute"));
assert_eq!(keysym_to_name(0x1008ff41), Some("LaunchApp1"));
}
#[test]
fn arrows_and_editing_keys_round_trip() {
assert_eq!(keysym_to_name(0xff51), Some("ArrowLeft"));
assert_eq!(keysym_to_name(0xff56), Some("PageDown"));
assert_eq!(keysym_to_name(0xffff), Some("Delete"));
}
#[test]
fn unknown_keysym_falls_back_to_key_n() {
assert!(keysym_to_name(0x12345).is_none());
assert_eq!(fallback_name(0x12345), "Key74565");
}
#[test]
fn no_distinct_names_share_a_keysym() {
for (i, (sym_i, name_i)) in KEYSYM_TABLE.iter().enumerate() {
for (sym_j, name_j) in &KEYSYM_TABLE[i + 1..] {
if sym_i == sym_j {
assert_eq!(name_i, name_j, "duplicate keysym {sym_i:#x}");
}
}
}
}
}
@@ -0,0 +1,28 @@
#![deny(clippy::all)]
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod env;
pub mod keymap;
pub mod modifiers;
pub mod mouse;
pub mod x11;
#[cfg(target_os = "linux")]
mod hook;
#[cfg(target_os = "linux")]
pub use hook::{InputHook, is_available};
#[cfg(not(target_os = "linux"))]
mod stub {
use napi_derive::napi;
#[napi]
pub fn is_available() -> bool {
false
}
}
#[cfg(not(target_os = "linux"))]
pub use stub::is_available;
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub const SHIFT_MASK: u32 = 1 << 0;
pub const CONTROL_MASK: u32 = 1 << 2;
pub const MOD1_MASK: u32 = 1 << 3;
pub const MOD4_MASK: u32 = 1 << 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Modifiers {
pub ctrl: bool,
pub alt: bool,
pub shift: bool,
pub meta: bool,
}
pub fn from_state(state: u32) -> Modifiers {
Modifiers {
ctrl: (state & CONTROL_MASK) != 0,
alt: (state & MOD1_MASK) != 0,
shift: (state & SHIFT_MASK) != 0,
meta: (state & MOD4_MASK) != 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_bits_all_false() {
let m = from_state(0);
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
}
#[test]
fn shift_alone() {
let m = from_state(SHIFT_MASK);
assert!(m.shift);
assert!(!m.ctrl && !m.alt && !m.meta);
}
#[test]
fn all_four_modifiers_together() {
let m = from_state(SHIFT_MASK | CONTROL_MASK | MOD1_MASK | MOD4_MASK);
assert!(m.ctrl && m.alt && m.shift && m.meta);
}
#[test]
fn lock_and_numlock_ignored() {
let m = from_state((1 << 1) | (1 << 4));
assert!(!m.ctrl && !m.alt && !m.shift && !m.meta);
}
#[test]
fn mod1_is_alt_mod4_is_meta() {
let a = from_state(MOD1_MASK);
assert!(a.alt && !a.meta);
let b = from_state(MOD4_MASK);
assert!(b.meta && !b.alt);
}
}
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WheelDirection {
Up,
Down,
Left,
Right,
}
impl WheelDirection {
pub fn delta_x(self) -> i32 {
match self {
Self::Left => -120,
Self::Right => 120,
_ => 0,
}
}
pub fn delta_y(self) -> i32 {
match self {
Self::Up => -120,
Self::Down => 120,
_ => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseClassification {
Button(u8),
Wheel(WheelDirection),
Ignored,
}
pub fn classify(x11_button: u32) -> MouseClassification {
match x11_button {
1 => MouseClassification::Button(0),
2 => MouseClassification::Button(1),
3 => MouseClassification::Button(2),
4 => MouseClassification::Wheel(WheelDirection::Up),
5 => MouseClassification::Wheel(WheelDirection::Down),
6 => MouseClassification::Wheel(WheelDirection::Left),
7 => MouseClassification::Wheel(WheelDirection::Right),
8 => MouseClassification::Button(3),
9 => MouseClassification::Button(4),
_ => MouseClassification::Ignored,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primary_buttons_map_to_browser_indices() {
assert_eq!(classify(1), MouseClassification::Button(0));
assert_eq!(classify(2), MouseClassification::Button(1));
assert_eq!(classify(3), MouseClassification::Button(2));
}
#[test]
fn vertical_wheel_delta_y_120_step() {
assert_eq!(classify(4), MouseClassification::Wheel(WheelDirection::Up));
assert_eq!(WheelDirection::Up.delta_y(), -120);
assert_eq!(WheelDirection::Down.delta_y(), 120);
assert_eq!(WheelDirection::Up.delta_x(), 0);
}
#[test]
fn horizontal_wheel_delta_x_120_step() {
assert_eq!(WheelDirection::Left.delta_x(), -120);
assert_eq!(WheelDirection::Right.delta_x(), 120);
assert_eq!(WheelDirection::Left.delta_y(), 0);
}
#[test]
fn back_forward_buttons_map_to_3_and_4() {
assert_eq!(classify(8), MouseClassification::Button(3));
assert_eq!(classify(9), MouseClassification::Button(4));
}
#[test]
fn unknown_buttons_are_ignored() {
assert_eq!(classify(0), MouseClassification::Ignored);
assert_eq!(classify(15), MouseClassification::Ignored);
assert_eq!(classify(255), MouseClassification::Ignored);
}
}
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy)]
pub struct XkbLookup {
pub group: u32,
pub level: u32,
}
pub fn xkb_lookup_for_base() -> XkbLookup {
XkbLookup { group: 0, level: 0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xkb_lookup_for_base_pins_group_level_to_zero() {
let lookup = xkb_lookup_for_base();
assert_eq!(lookup.group, 0);
assert_eq!(lookup.level, 0);
}
}