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:
+1041
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "fluxer_system_hunspell"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
fluxer_desktop_native = {path = "../rust"}
|
||||
hunspell-sys = {version = "0.3.1", features = ["bundled"]}
|
||||
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
|
||||
napi-derive = "3.5.6"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27"
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface SystemDictionary {
|
||||
tag: string;
|
||||
affPath: string;
|
||||
dicPath: string;
|
||||
}
|
||||
|
||||
export declare class Hunspell {
|
||||
constructor(affPath: string, dicPath: string);
|
||||
|
||||
spell(word: string): boolean;
|
||||
|
||||
suggest(word: string, max?: number): Array<string>;
|
||||
|
||||
add(word: string): void;
|
||||
|
||||
remove(word: string): void;
|
||||
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export declare function discoverSystemDictionaries(): Array<SystemDictionary>;
|
||||
|
||||
export declare function hashFile(path: string): Promise<string>;
|
||||
|
||||
export declare const loadError: Error | null;
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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/system-hunspell';
|
||||
const SKIP_NATIVE_PROBE_ENV = 'FLUXER_SYSTEM_HUNSPELL_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/system-hunspell is only supported on Linux, got ${process.platform}`);
|
||||
}
|
||||
switch (process.arch) {
|
||||
case 'x64':
|
||||
return 'system-hunspell.linux-x64-gnu.node';
|
||||
case 'arm64':
|
||||
return 'system-hunspell.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 = {
|
||||
Hunspell: binding ? binding.Hunspell : null,
|
||||
discoverSystemDictionaries: binding ? binding.discoverSystemDictionaries : null,
|
||||
hashFile: binding ? binding.hashFile : null,
|
||||
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/system-hunspell",
|
||||
"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",
|
||||
"system-hunspell.linux-x64-gnu.node",
|
||||
"system-hunspell.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,418 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use fluxer_desktop_native::system_hunspell::dictionaries::{EnvSnapshot, discover_dictionaries};
|
||||
use fluxer_desktop_native::system_hunspell::encoding::is_utf8_encoding;
|
||||
use fluxer_desktop_native::system_hunspell::hashing::hash_file_to_hex;
|
||||
use napi::Task;
|
||||
use napi::bindgen_prelude::{AsyncTask, Env, Error, Result, Status};
|
||||
use napi_derive::napi;
|
||||
|
||||
static HUNSPELL_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[napi(object)]
|
||||
pub struct SystemDictionary {
|
||||
pub tag: String,
|
||||
#[napi(js_name = "affPath")]
|
||||
pub aff_path: String,
|
||||
#[napi(js_name = "dicPath")]
|
||||
pub dic_path: String,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct Hunspell {
|
||||
dict: Option<Dictionary>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Hunspell {
|
||||
#[napi(constructor)]
|
||||
pub fn new(aff_path: String, dic_path: String) -> Result<Self> {
|
||||
Ok(Self {
|
||||
dict: Some(Dictionary::load(&aff_path, &dic_path)?),
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn spell(&self, word: String) -> Result<bool> {
|
||||
validate_text_arg(&word, "word")?;
|
||||
Ok(self
|
||||
.dict
|
||||
.as_ref()
|
||||
.is_some_and(|dict| dict.spell(&word).unwrap_or(true)))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn suggest(&self, word: String, max: Option<u32>) -> Result<Vec<String>> {
|
||||
validate_text_arg(&word, "word")?;
|
||||
let Some(dict) = &self.dict else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let limit = normalize_suggestion_limit(max);
|
||||
dict.suggest(&word, limit)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn add(&mut self, word: String) -> Result<()> {
|
||||
validate_text_arg(&word, "word")?;
|
||||
if let Some(dict) = &mut self.dict {
|
||||
let _ = dict.add(&word);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn remove(&mut self, word: String) -> Result<()> {
|
||||
validate_text_arg(&word, "word")?;
|
||||
if let Some(dict) = &mut self.dict {
|
||||
let _ = dict.remove(&word);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn close(&mut self) {
|
||||
self.dict = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "discoverSystemDictionaries")]
|
||||
pub fn discover_system_dictionaries() -> Vec<SystemDictionary> {
|
||||
let snapshot = EnvSnapshot {
|
||||
hunspell_dict_dir: std::env::var("HUNSPELL_DICT_DIR").ok(),
|
||||
xdg_data_home: std::env::var("XDG_DATA_HOME").ok(),
|
||||
home: std::env::var("HOME").ok(),
|
||||
xdg_data_dirs: std::env::var("XDG_DATA_DIRS").ok(),
|
||||
};
|
||||
discover_dictionaries(&snapshot)
|
||||
.into_iter()
|
||||
.map(|dict| SystemDictionary {
|
||||
tag: dict.tag,
|
||||
aff_path: dict.aff_path.display().to_string(),
|
||||
dic_path: dict.dic_path.display().to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct HashFileTask {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[napi(js_name = "hashFile")]
|
||||
pub fn hash_file(path: String) -> Result<AsyncTask<HashFileTask>> {
|
||||
validate_path_arg(&path)?;
|
||||
Ok(AsyncTask::new(HashFileTask { path }))
|
||||
}
|
||||
|
||||
impl Task for HashFileTask {
|
||||
type Output = String;
|
||||
type JsValue = String;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
hash_file_to_hex(&self.path).map_err(hash_error)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Dictionary {
|
||||
handle: *mut hunspell_sys::Hunhandle,
|
||||
}
|
||||
|
||||
impl Dictionary {
|
||||
fn load(aff_path: &str, dic_path: &str) -> Result<Self> {
|
||||
validate_path_arg(aff_path)?;
|
||||
validate_path_arg(dic_path)?;
|
||||
if File::open(Path::new(aff_path)).is_err() || File::open(Path::new(dic_path)).is_err() {
|
||||
return Err(hunspell_load_error("LoadFailed"));
|
||||
}
|
||||
|
||||
let aff = cstring_arg(aff_path, "affPath")?;
|
||||
let dic = cstring_arg(dic_path, "dicPath")?;
|
||||
let _guard = hunspell_lock();
|
||||
|
||||
let handle = unsafe { hunspell_sys::Hunspell_create(aff.as_ptr(), dic.as_ptr()) };
|
||||
if handle.is_null() {
|
||||
return Err(hunspell_load_error("LoadFailed"));
|
||||
}
|
||||
|
||||
let encoding_ptr = unsafe { hunspell_sys::Hunspell_get_dic_encoding(handle) };
|
||||
if encoding_ptr.is_null() {
|
||||
unsafe { hunspell_sys::Hunspell_destroy(handle) };
|
||||
return Err(hunspell_load_error("LoadFailed"));
|
||||
}
|
||||
|
||||
let encoding = unsafe { CStr::from_ptr(encoding_ptr) }
|
||||
.to_str()
|
||||
.map_err(|_| hunspell_load_error("DictionaryNotUtf8"))?;
|
||||
if !is_utf8_encoding(encoding) {
|
||||
unsafe { hunspell_sys::Hunspell_destroy(handle) };
|
||||
return Err(hunspell_load_error("DictionaryNotUtf8"));
|
||||
}
|
||||
|
||||
Ok(Self { handle })
|
||||
}
|
||||
|
||||
fn spell(&self, word: &str) -> Result<bool> {
|
||||
let word = cstring_arg(word, "word")?;
|
||||
let _guard = hunspell_lock();
|
||||
|
||||
Ok(unsafe { hunspell_sys::Hunspell_spell(self.handle, word.as_ptr()) } != 0)
|
||||
}
|
||||
|
||||
fn suggest(&self, word: &str, max: usize) -> Result<Vec<String>> {
|
||||
let word = cstring_arg(word, "word")?;
|
||||
let mut raw: *mut *mut std::os::raw::c_char = ptr::null_mut();
|
||||
let _hunspell_guard = hunspell_lock();
|
||||
|
||||
let count = unsafe { hunspell_sys::Hunspell_suggest(self.handle, &mut raw, word.as_ptr()) };
|
||||
let _guard = SuggestionList {
|
||||
handle: self.handle,
|
||||
raw,
|
||||
count,
|
||||
};
|
||||
if count <= 0 || raw.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let limit = (count as usize).min(max);
|
||||
let mut out = Vec::with_capacity(limit);
|
||||
for index in 0..limit {
|
||||
let item = unsafe { *raw.add(index) };
|
||||
if item.is_null() {
|
||||
out.push(String::new());
|
||||
continue;
|
||||
}
|
||||
|
||||
let suggestion = unsafe { CStr::from_ptr(item) };
|
||||
if let Ok(text) = suggestion.to_str() {
|
||||
out.push(text.to_owned());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn add(&mut self, word: &str) -> Result<()> {
|
||||
let word = cstring_arg(word, "word")?;
|
||||
let _guard = hunspell_lock();
|
||||
|
||||
let rc = unsafe { hunspell_sys::Hunspell_add(self.handle, word.as_ptr()) };
|
||||
if rc == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(hunspell_load_error("LoadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, word: &str) -> Result<()> {
|
||||
let word = cstring_arg(word, "word")?;
|
||||
let _guard = hunspell_lock();
|
||||
|
||||
unsafe { hunspell_sys::Hunspell_remove(self.handle, word.as_ptr()) };
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Dictionary {
|
||||
fn drop(&mut self) {
|
||||
if !self.handle.is_null() {
|
||||
let _guard = hunspell_lock();
|
||||
|
||||
unsafe { hunspell_sys::Hunspell_destroy(self.handle) };
|
||||
self.handle = ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SuggestionList {
|
||||
handle: *mut hunspell_sys::Hunhandle,
|
||||
raw: *mut *mut std::os::raw::c_char,
|
||||
count: i32,
|
||||
}
|
||||
|
||||
impl Drop for SuggestionList {
|
||||
fn drop(&mut self) {
|
||||
if self.count > 0 && !self.raw.is_null() {
|
||||
unsafe { hunspell_sys::Hunspell_free_list(self.handle, &mut self.raw, self.count) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_suggestion_limit(max: Option<u32>) -> usize {
|
||||
match max {
|
||||
Some(value @ 1..=64) => value as usize,
|
||||
_ => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn hunspell_lock() -> MutexGuard<'static, ()> {
|
||||
HUNSPELL_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn validate_path_arg(path: &str) -> Result<()> {
|
||||
if path.is_empty() {
|
||||
return Err(Error::new(Status::InvalidArg, "path must be non-empty"));
|
||||
}
|
||||
if path.as_bytes().contains(&0) {
|
||||
return Err(Error::new(
|
||||
Status::InvalidArg,
|
||||
"path must not contain NUL bytes",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_text_arg(value: &str, label: &str) -> Result<()> {
|
||||
if value.as_bytes().contains(&0) {
|
||||
return Err(Error::new(
|
||||
Status::InvalidArg,
|
||||
format!("{label} must not contain NUL bytes"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cstring_arg(value: &str, label: &str) -> Result<CString> {
|
||||
CString::new(value).map_err(|_| {
|
||||
Error::new(
|
||||
Status::InvalidArg,
|
||||
format!("{label} must not contain NUL bytes"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn hunspell_load_error(reason: &str) -> Error {
|
||||
Error::new(
|
||||
Status::GenericFailure,
|
||||
format!("Hunspell load failed: {reason}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn hash_error(error: std::io::Error) -> Error {
|
||||
let message = match error.kind() {
|
||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied => {
|
||||
"could not open file for hashing"
|
||||
}
|
||||
_ => "read failed while hashing file",
|
||||
};
|
||||
Error::new(Status::GenericFailure, message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn suggestion_limit_defaults_and_bounds_match_legacy_contract() {
|
||||
assert_eq!(8, normalize_suggestion_limit(None));
|
||||
assert_eq!(8, normalize_suggestion_limit(Some(0)));
|
||||
assert_eq!(1, normalize_suggestion_limit(Some(1)));
|
||||
assert_eq!(64, normalize_suggestion_limit(Some(64)));
|
||||
assert_eq!(8, normalize_suggestion_limit(Some(65)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_validation_rejects_empty_and_nul_paths() {
|
||||
assert_eq!(
|
||||
"path must be non-empty",
|
||||
validate_path_arg("")
|
||||
.expect_err("empty path should fail")
|
||||
.reason
|
||||
);
|
||||
assert_eq!(
|
||||
"path must not contain NUL bytes",
|
||||
validate_path_arg("/tmp/a\0b")
|
||||
.expect_err("NUL path should fail")
|
||||
.reason
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_validation_rejects_nul_words() {
|
||||
assert_eq!(
|
||||
"word must not contain NUL bytes",
|
||||
validate_text_arg("a\0b", "word")
|
||||
.expect_err("NUL word should fail")
|
||||
.reason
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_error_maps_open_failures_to_existing_js_message() {
|
||||
let err = hash_file_to_hex("/nonexistent/path/that/should/not/exist.bin")
|
||||
.map_err(hash_error)
|
||||
.expect_err("missing file should fail");
|
||||
assert_eq!(Status::GenericFailure, err.status);
|
||||
assert_eq!("could not open file for hashing", err.reason);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_file_task_streams_file_to_expected_hex() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("payload");
|
||||
let mut file = File::create(&path).unwrap();
|
||||
file.write_all(b"abc").unwrap();
|
||||
|
||||
let mut task = HashFileTask {
|
||||
path: path.display().to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
task.compute().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_fixture_spells_and_accepts_runtime_words() {
|
||||
let (dir, aff_path, dic_path) = write_fixture_dictionary("UTF-8");
|
||||
let mut dict =
|
||||
Dictionary::load(&aff_path, &dic_path).expect("fixture dictionary should load");
|
||||
|
||||
assert!(dict.spell("cat").unwrap());
|
||||
assert!(dict.spell("cats").unwrap());
|
||||
assert!(!dict.spell("Fluxer").unwrap());
|
||||
dict.add("Fluxer").unwrap();
|
||||
assert!(dict.spell("Fluxer").unwrap());
|
||||
dict.remove("Fluxer").unwrap();
|
||||
assert!(!dict.spell("Fluxer").unwrap());
|
||||
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_rejects_non_utf8_dictionaries() {
|
||||
let (_dir, aff_path, dic_path) = write_fixture_dictionary("ISO-8859-1");
|
||||
let err = Dictionary::load(&aff_path, &dic_path)
|
||||
.expect_err("non-UTF-8 dictionary should be rejected");
|
||||
assert_eq!(Status::GenericFailure, err.status);
|
||||
assert_eq!("Hunspell load failed: DictionaryNotUtf8", err.reason);
|
||||
}
|
||||
|
||||
fn write_fixture_dictionary(encoding: &str) -> (tempfile::TempDir, String, String) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let aff_path = dir.path().join("fixture.aff");
|
||||
let dic_path = dir.path().join("fixture.dic");
|
||||
std::fs::write(
|
||||
&aff_path,
|
||||
format!("SET {encoding}\n\nSFX S Y 1\nSFX S 0 s [^sxzhy]\n"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&dic_path, "2\ncat/S\nprogram/S\n").unwrap();
|
||||
(
|
||||
dir,
|
||||
aff_path.display().to_string(),
|
||||
dic_path.display().to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user