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
+382
View File
@@ -0,0 +1,382 @@
# 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 = "fluxer_win_shell"
version = "0.0.0"
dependencies = [
"napi",
"napi-build",
"napi-derive",
"windows",
]
[[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 = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "memchr"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "napi"
version = "3.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash",
]
[[package]]
name = "napi-build"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
[[package]]
name = "napi-derive"
version = "3.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
dependencies = [
"convert_case",
"ctor",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "napi-derive-backend"
version = "5.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"semver",
"syn",
]
[[package]]
name = "napi-sys"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
dependencies = [
"libloading",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
@@ -0,0 +1,31 @@
[package]
name = "fluxer_win_shell"
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 = "windows")'.dependencies]
windows = {version = "0.62.2", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Registry",
"Win32_System_Variant",
"Win32_UI_Shell",
"Win32_UI_Shell_PropertiesSystem",
]}
[build-dependencies]
napi-build = "2.3.2"
+5
View File
@@ -0,0 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
fn main() {
napi_build::setup();
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface CreateShortcutOptions {
lnkPath: string;
target: string;
args?: string;
appUserModelId?: string;
toastActivatorClsid?: string;
iconPath?: string;
iconIndex?: number;
workingDir?: string;
description?: string;
}
export interface SetRunValueOptions {
name: string;
command: string;
}
export declare function createShortcut(opts: CreateShortcutOptions): Promise<void>;
export declare function setCurrentUserRunValue(opts: SetRunValueOptions): Promise<void>;
export declare function deleteCurrentUserRunValue(name: string): Promise<void>;
export declare function getCurrentUserRunValue(name: string): Promise<string | null>;
export declare function getUserNotificationState(): string;
export declare const loadError: Error | null;
+65
View File
@@ -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/win-shell';
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 !== 'win32') {
throw new Error(`@fluxer/win-shell is only supported on Windows, got ${process.platform}`);
}
switch (process.arch) {
case 'x64':
return 'win-shell.win32-x64-msvc.node';
case 'arm64':
return 'win-shell.win32-arm64-msvc.node';
default:
throw new Error(`Unsupported Windows architecture: ${process.arch}`);
}
}
let binding = null;
let loadError = null;
if (process.platform === 'win32') {
try {
const nativeRoot = resolveNativeRoot();
const nativePath = join(nativeRoot, nativeFileName());
const loaded = loadNativeBinding({
moduleName: MODULE_NAME,
nativePath,
nativeRoot,
packageDir: __dirname,
probe: false,
});
binding = loaded.binding;
loadError = loaded.loadError;
if (loadError) throw loadError;
} catch (error) {
loadError = createNativeLoadError({
moduleName: MODULE_NAME,
nativeRoot: resolveNativeRoot(),
packageDir: __dirname,
reason: 'native loader threw before binding load completed',
cause: error,
});
throw loadError;
}
}
module.exports = {
createShortcut: binding ? binding.createShortcut : null,
setCurrentUserRunValue: binding ? binding.setCurrentUserRunValue : null,
deleteCurrentUserRunValue: binding ? binding.deleteCurrentUserRunValue : null,
getCurrentUserRunValue: binding ? binding.getCurrentUserRunValue : null,
getUserNotificationState: binding ? binding.getUserNotificationState : 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,29 @@
{
"name": "@fluxer/win-shell",
"version": "0.0.0",
"description": "",
"private": true,
"license": "AGPL-3.0-or-later",
"os": [
"win32"
],
"cpu": [
"x64",
"arm64"
],
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"loader-diagnostics.cjs",
"win-shell.win32-x64-msvc.node",
"win-shell.win32-arm64-msvc.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
"test": "cargo test --manifest-path Cargo.toml",
"check:windows:x64": "cargo check --manifest-path Cargo.toml --target x86_64-pc-windows-msvc --tests",
"check:windows:arm64": "cargo check --manifest-path Cargo.toml --target aarch64-pc-windows-msvc --tests"
}
}
+705
View File
@@ -0,0 +1,705 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use napi::Task;
use napi::bindgen_prelude::{AsyncTask, Env, Error, Result, Status};
use napi_derive::napi;
#[napi(object)]
#[derive(Debug)]
pub struct CreateShortcutOptions {
#[napi(js_name = "lnkPath")]
pub lnk_path: Option<String>,
pub target: Option<String>,
pub args: Option<String>,
#[napi(js_name = "appUserModelId")]
pub app_user_model_id: Option<String>,
#[napi(js_name = "toastActivatorClsid")]
pub toast_activator_clsid: Option<String>,
#[napi(js_name = "iconPath")]
pub icon_path: Option<String>,
#[napi(js_name = "iconIndex")]
pub icon_index: Option<i32>,
#[napi(js_name = "workingDir")]
pub working_dir: Option<String>,
pub description: Option<String>,
}
#[napi(object)]
#[derive(Debug)]
pub struct SetRunValueOptions {
pub name: Option<String>,
pub command: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ShortcutOptions {
lnk_path: String,
target: String,
args: Option<String>,
app_user_model_id: Option<String>,
toast_activator_clsid: Option<String>,
icon_path: Option<String>,
icon_index: i32,
working_dir: Option<String>,
description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RunValueOptions {
name: String,
command: String,
}
pub struct CreateShortcutTask {
options: ShortcutOptions,
}
pub struct SetRunValueTask {
options: RunValueOptions,
}
pub struct DeleteRunValueTask {
name: String,
}
pub struct GetRunValueTask {
name: String,
}
#[napi(js_name = "createShortcut")]
pub fn create_shortcut(opts: CreateShortcutOptions) -> Result<AsyncTask<CreateShortcutTask>> {
Ok(AsyncTask::new(CreateShortcutTask {
options: validate_options(opts)?,
}))
}
#[napi(js_name = "setCurrentUserRunValue")]
pub fn set_current_user_run_value(opts: SetRunValueOptions) -> Result<AsyncTask<SetRunValueTask>> {
Ok(AsyncTask::new(SetRunValueTask {
options: validate_run_value_options(opts)?,
}))
}
#[napi(js_name = "deleteCurrentUserRunValue")]
pub fn delete_current_user_run_value(name: String) -> Result<AsyncTask<DeleteRunValueTask>> {
Ok(AsyncTask::new(DeleteRunValueTask {
name: require_non_empty(Some(name), "name")?,
}))
}
#[napi(js_name = "getCurrentUserRunValue")]
pub fn get_current_user_run_value(name: String) -> Result<AsyncTask<GetRunValueTask>> {
Ok(AsyncTask::new(GetRunValueTask {
name: require_non_empty(Some(name), "name")?,
}))
}
#[cfg(target_os = "windows")]
#[napi(js_name = "getUserNotificationState")]
pub fn get_user_notification_state() -> Result<String> {
platform::get_user_notification_state()
}
impl Task for CreateShortcutTask {
type Output = ();
type JsValue = ();
fn compute(&mut self) -> Result<Self::Output> {
platform::create_shortcut(&self.options)
}
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
Ok(())
}
}
impl Task for SetRunValueTask {
type Output = ();
type JsValue = ();
fn compute(&mut self) -> Result<Self::Output> {
platform::set_current_user_run_value(&self.options.name, &self.options.command)
}
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
Ok(())
}
}
impl Task for DeleteRunValueTask {
type Output = ();
type JsValue = ();
fn compute(&mut self) -> Result<Self::Output> {
platform::delete_current_user_run_value(&self.name)
}
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
Ok(())
}
}
impl Task for GetRunValueTask {
type Output = Option<String>;
type JsValue = Option<String>;
fn compute(&mut self) -> Result<Self::Output> {
platform::get_current_user_run_value(&self.name)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
fn validate_options(opts: CreateShortcutOptions) -> Result<ShortcutOptions> {
Ok(ShortcutOptions {
lnk_path: require_non_empty(opts.lnk_path, "lnkPath")?,
target: require_non_empty(opts.target, "target")?,
args: opts.args,
app_user_model_id: opts.app_user_model_id,
toast_activator_clsid: opts.toast_activator_clsid,
icon_path: opts.icon_path,
icon_index: opts.icon_index.unwrap_or(0),
working_dir: opts.working_dir,
description: opts.description,
})
}
fn validate_run_value_options(opts: SetRunValueOptions) -> Result<RunValueOptions> {
Ok(RunValueOptions {
name: require_non_empty(opts.name, "name")?,
command: require_non_empty(opts.command, "command")?,
})
}
fn require_non_empty(value: Option<String>, property: &'static str) -> Result<String> {
match value {
Some(value) if !value.is_empty() => Ok(value),
_ => Err(Error::new(
Status::InvalidArg,
format!("{property} is required and must be non-empty"),
)),
}
}
#[cfg(target_os = "windows")]
mod platform {
use super::ShortcutOptions;
use napi::bindgen_prelude::{Error, Result, Status};
use std::ffi::c_void;
use std::mem::ManuallyDrop;
use windows::Win32::Foundation::{
ERROR_FILE_NOT_FOUND, ERROR_SUCCESS, PROPERTYKEY, WIN32_ERROR,
};
use windows::Win32::System::Com::StructuredStorage::{
PROPVARIANT, PROPVARIANT_0, PROPVARIANT_0_0, PROPVARIANT_0_0_0,
};
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, CLSIDFromString, COINIT_APARTMENTTHREADED, CoCreateInstance,
CoInitializeEx, CoUninitialize, IPersistFile,
};
use windows::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_SET_VALUE, REG_OPTION_NON_VOLATILE, REG_SZ, RRF_RT_REG_SZ,
RegCloseKey, RegCreateKeyExW, RegDeleteValueW, RegGetValueW, RegOpenKeyExW, RegSetValueExW,
};
use windows::Win32::System::Variant::{VT_CLSID, VT_LPWSTR};
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
use windows::Win32::UI::Shell::{
IShellLinkW, QUNS_ACCEPTS_NOTIFICATIONS, QUNS_APP, QUNS_BUSY, QUNS_NOT_PRESENT,
QUNS_PRESENTATION_MODE, QUNS_QUIET_TIME, QUNS_RUNNING_D3D_FULL_SCREEN,
SHQueryUserNotificationState, ShellLink,
};
use windows::core::{GUID, Interface, PCWSTR, PWSTR};
const CURRENT_USER_RUN_SUBKEY: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Run";
const PKEY_APP_USER_MODEL_ID: PROPERTYKEY = PROPERTYKEY {
fmtid: GUID::from_u128(0x9f4c2855_9f79_4b39_a8d0_e1d42de1d5f3),
pid: 5,
};
const PKEY_APP_USER_MODEL_TOAST_ACTIVATOR_CLSID: PROPERTYKEY = PROPERTYKEY {
fmtid: GUID::from_u128(0x9f4c2855_9f79_4b39_a8d0_e1d42de1d5f3),
pid: 26,
};
pub(super) fn get_user_notification_state() -> Result<String> {
let state = unsafe { SHQueryUserNotificationState() }
.map_err(|err| hresult_error("SHQueryUserNotificationState failed", err))?;
let label = match state.0 {
value if value == QUNS_NOT_PRESENT.0 => "not-present",
value if value == QUNS_BUSY.0 => "busy",
value if value == QUNS_RUNNING_D3D_FULL_SCREEN.0 => "running-d3d-full-screen",
value if value == QUNS_PRESENTATION_MODE.0 => "presentation-mode",
value if value == QUNS_ACCEPTS_NOTIFICATIONS.0 => "accepts-notifications",
value if value == QUNS_QUIET_TIME.0 => "quiet-time",
value if value == QUNS_APP.0 => "app",
_ => "unknown",
};
Ok(label.to_string())
}
pub(super) fn create_shortcut(opts: &ShortcutOptions) -> Result<()> {
let _com = ComApartment::initialize()?;
unsafe {
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)
.map_err(|err| hresult_error("CoCreateInstance(CLSID_ShellLink) failed", err))?;
let target = wide_null(&opts.target);
link.SetPath(PCWSTR(target.as_ptr()))
.map_err(|err| hresult_error("IShellLinkW::SetPath failed", err))?;
if let Some(args) = opts.args.as_deref() {
let args = wide_null(args);
link.SetArguments(PCWSTR(args.as_ptr()))
.map_err(|err| hresult_error("IShellLinkW::SetArguments failed", err))?;
}
if let Some(icon_path) = opts.icon_path.as_deref() {
let icon_path = wide_null(icon_path);
link.SetIconLocation(PCWSTR(icon_path.as_ptr()), opts.icon_index)
.map_err(|err| hresult_error("IShellLinkW::SetIconLocation failed", err))?;
}
if let Some(working_dir) = opts.working_dir.as_deref() {
let working_dir = wide_null(working_dir);
link.SetWorkingDirectory(PCWSTR(working_dir.as_ptr()))
.map_err(|err| hresult_error("IShellLinkW::SetWorkingDirectory failed", err))?;
}
if let Some(description) = opts.description.as_deref() {
let description = wide_null(description);
link.SetDescription(PCWSTR(description.as_ptr()))
.map_err(|err| hresult_error("IShellLinkW::SetDescription failed", err))?;
}
if opts.app_user_model_id.is_some() || opts.toast_activator_clsid.is_some() {
let property_store: IPropertyStore = link
.cast()
.map_err(|err| hresult_error("QueryInterface(IPropertyStore) failed", err))?;
if let Some(app_user_model_id) = opts.app_user_model_id.as_deref() {
set_string_property(
&property_store,
&PKEY_APP_USER_MODEL_ID,
app_user_model_id,
"IPropertyStore::SetValue(PKEY_AppUserModel_ID) failed",
)?;
}
if let Some(toast_activator_clsid) = opts.toast_activator_clsid.as_deref() {
set_guid_property(
&property_store,
&PKEY_APP_USER_MODEL_TOAST_ACTIVATOR_CLSID,
toast_activator_clsid,
"IPropertyStore::SetValue(PKEY_AppUserModel_ToastActivatorCLSID) failed",
)?;
}
property_store
.Commit()
.map_err(|err| hresult_error("IPropertyStore::Commit failed", err))?;
}
let persist: IPersistFile = link
.cast()
.map_err(|err| hresult_error("QueryInterface(IPersistFile) failed", err))?;
let lnk_path = wide_null(&opts.lnk_path);
persist
.Save(PCWSTR(lnk_path.as_ptr()), true)
.map_err(|err| hresult_error("IPersistFile::Save failed", err))?;
}
Ok(())
}
pub(super) fn set_current_user_run_value(name: &str, command: &str) -> Result<()> {
let key = RunKey::create()?;
let name = wide_null(name);
let command = wide_null(command);
let data =
unsafe { std::slice::from_raw_parts(command.as_ptr().cast::<u8>(), command.len() * 2) };
let status =
unsafe { RegSetValueExW(key.raw(), PCWSTR(name.as_ptr()), None, REG_SZ, Some(data)) };
check_win32(status, "RegSetValueExW(HKCU Run) failed")
}
pub(super) fn delete_current_user_run_value(name: &str) -> Result<()> {
let Some(key) = RunKey::open_for_set()? else {
return Ok(());
};
let name = wide_null(name);
let status = unsafe { RegDeleteValueW(key.raw(), PCWSTR(name.as_ptr())) };
if status == ERROR_FILE_NOT_FOUND {
return Ok(());
}
check_win32(status, "RegDeleteValueW(HKCU Run) failed")
}
pub(super) fn get_current_user_run_value(name: &str) -> Result<Option<String>> {
let subkey = wide_null(CURRENT_USER_RUN_SUBKEY);
let name = wide_null(name);
let mut value_type = REG_SZ;
let mut byte_len = 0u32;
let status = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
PCWSTR(subkey.as_ptr()),
PCWSTR(name.as_ptr()),
RRF_RT_REG_SZ,
Some(&mut value_type),
None,
Some(&mut byte_len),
)
};
if status == ERROR_FILE_NOT_FOUND {
return Ok(None);
}
check_win32(status, "RegGetValueW(HKCU Run size) failed")?;
if byte_len == 0 {
return Ok(Some(String::new()));
}
let mut buffer = vec![0u16; byte_len.div_ceil(2) as usize];
let status = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
PCWSTR(subkey.as_ptr()),
PCWSTR(name.as_ptr()),
RRF_RT_REG_SZ,
Some(&mut value_type),
Some(buffer.as_mut_ptr().cast::<c_void>()),
Some(&mut byte_len),
)
};
check_win32(status, "RegGetValueW(HKCU Run data) failed")?;
let mut code_units = (byte_len / 2) as usize;
while code_units > 0 && buffer[code_units - 1] == 0 {
code_units -= 1;
}
String::from_utf16(&buffer[..code_units])
.map(Some)
.map_err(|_| Error::new(Status::GenericFailure, "HKCU Run value is not valid UTF-16"))
}
struct RunKey(HKEY);
impl RunKey {
fn create() -> Result<Self> {
let subkey = wide_null(CURRENT_USER_RUN_SUBKEY);
let mut key = HKEY::default();
let status = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
PCWSTR(subkey.as_ptr()),
None,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_SET_VALUE,
None,
&mut key,
None,
)
};
check_win32(status, "RegCreateKeyExW(HKCU Run) failed")?;
Ok(Self(key))
}
fn open_for_set() -> Result<Option<Self>> {
let subkey = wide_null(CURRENT_USER_RUN_SUBKEY);
let mut key = HKEY::default();
let status = unsafe {
RegOpenKeyExW(
HKEY_CURRENT_USER,
PCWSTR(subkey.as_ptr()),
None,
KEY_SET_VALUE,
&mut key,
)
};
if status == ERROR_FILE_NOT_FOUND {
return Ok(None);
}
check_win32(status, "RegOpenKeyExW(HKCU Run) failed")?;
Ok(Some(Self(key)))
}
fn raw(&self) -> HKEY {
self.0
}
}
impl Drop for RunKey {
fn drop(&mut self) {
unsafe {
let _ = RegCloseKey(self.0);
}
}
}
struct ComApartment;
impl ComApartment {
fn initialize() -> Result<Self> {
unsafe {
CoInitializeEx(None, COINIT_APARTMENTTHREADED)
.ok()
.map_err(|err| hresult_error("CoInitializeEx failed", err))?;
}
Ok(Self)
}
}
impl Drop for ComApartment {
fn drop(&mut self) {
unsafe {
CoUninitialize();
}
}
}
fn wide_null(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
fn propvariant_from_wide_string(wide: &mut [u16]) -> PROPVARIANT {
PROPVARIANT {
Anonymous: PROPVARIANT_0 {
Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 {
vt: VT_LPWSTR,
wReserved1: 0,
wReserved2: 0,
wReserved3: 0,
Anonymous: PROPVARIANT_0_0_0 {
pwszVal: PWSTR(wide.as_mut_ptr()),
},
}),
},
}
}
fn propvariant_from_guid(guid: &mut GUID) -> PROPVARIANT {
PROPVARIANT {
Anonymous: PROPVARIANT_0 {
Anonymous: ManuallyDrop::new(PROPVARIANT_0_0 {
vt: VT_CLSID,
wReserved1: 0,
wReserved2: 0,
wReserved3: 0,
Anonymous: PROPVARIANT_0_0_0 {
puuid: guid as *mut GUID,
},
}),
},
}
}
fn set_string_property(
property_store: &IPropertyStore,
key: &PROPERTYKEY,
value: &str,
label: &'static str,
) -> Result<()> {
let mut wide = wide_null(value);
unsafe {
let prop = propvariant_from_wide_string(&mut wide);
property_store
.SetValue(key as *const PROPERTYKEY, &prop as *const PROPVARIANT)
.map_err(|err| hresult_error(label, err))
}
}
fn set_guid_property(
property_store: &IPropertyStore,
key: &PROPERTYKEY,
value: &str,
label: &'static str,
) -> Result<()> {
let wide = wide_null(value);
unsafe {
let mut guid = CLSIDFromString(PCWSTR(wide.as_ptr()))
.map_err(|err| hresult_error("CLSIDFromString failed", err))?;
let prop = propvariant_from_guid(&mut guid);
property_store
.SetValue(key as *const PROPERTYKEY, &prop as *const PROPVARIANT)
.map_err(|err| hresult_error(label, err))
}
}
fn hresult_error(label: &'static str, err: windows::core::Error) -> Error {
Error::new(
Status::GenericFailure,
format!("{label} (hr 0x{:x})", err.code().0 as u32),
)
}
fn check_win32(status: WIN32_ERROR, label: &'static str) -> Result<()> {
if status == ERROR_SUCCESS {
Ok(())
} else {
Err(win32_error(label, status))
}
}
fn win32_error(label: &'static str, status: WIN32_ERROR) -> Error {
Error::new(
Status::GenericFailure,
format!("{label} (win32 {})", status.0),
)
}
}
#[cfg(not(target_os = "windows"))]
mod platform {
use super::ShortcutOptions;
use napi::bindgen_prelude::{Error, Result, Status};
pub(super) fn create_shortcut(_opts: &ShortcutOptions) -> Result<()> {
Err(Error::new(
Status::GenericFailure,
"win-shell not supported on this platform",
))
}
pub(super) fn set_current_user_run_value(_name: &str, _command: &str) -> Result<()> {
Err(Error::new(
Status::GenericFailure,
"win-shell not supported on this platform",
))
}
pub(super) fn delete_current_user_run_value(_name: &str) -> Result<()> {
Err(Error::new(
Status::GenericFailure,
"win-shell not supported on this platform",
))
}
pub(super) fn get_current_user_run_value(_name: &str) -> Result<Option<String>> {
Err(Error::new(
Status::GenericFailure,
"win-shell not supported on this platform",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_raw_options() -> CreateShortcutOptions {
CreateShortcutOptions {
lnk_path: Some("C:\\Users\\Example\\Desktop\\Fluxer.lnk".to_string()),
target: Some("C:\\Program Files\\Fluxer\\Fluxer.exe".to_string()),
args: None,
app_user_model_id: None,
toast_activator_clsid: None,
icon_path: None,
icon_index: None,
working_dir: None,
description: None,
}
}
fn valid_run_value_options() -> SetRunValueOptions {
SetRunValueOptions {
name: Some("velopack.fluxer_desktop_canary".to_string()),
command: Some(
"\"C:\\Users\\Example\\AppData\\Local\\Fluxer\\Fluxer.exe\" --autostart"
.to_string(),
),
}
}
#[test]
fn validation_rejects_missing_lnk_path() {
let mut opts = valid_raw_options();
opts.lnk_path = None;
let err = validate_options(opts).expect_err("missing lnkPath should be rejected");
assert_eq!(err.status, Status::InvalidArg);
assert_eq!(err.reason, "lnkPath is required and must be non-empty");
}
#[test]
fn validation_rejects_empty_target() {
let mut opts = valid_raw_options();
opts.target = Some(String::new());
let err = validate_options(opts).expect_err("empty target should be rejected");
assert_eq!(err.status, Status::InvalidArg);
assert_eq!(err.reason, "target is required and must be non-empty");
}
#[test]
fn validation_defaults_icon_index_to_zero() {
let opts = validate_options(valid_raw_options()).expect("options should validate");
assert_eq!(opts.icon_index, 0);
}
#[test]
fn validation_preserves_optional_fields() {
let mut raw = valid_raw_options();
raw.args = Some("--startup".to_string());
raw.icon_path = Some("C:\\Program Files\\Fluxer\\Fluxer.exe".to_string());
raw.icon_index = Some(2);
raw.working_dir = Some("C:\\Program Files\\Fluxer".to_string());
raw.description = Some("Fluxer desktop client".to_string());
raw.app_user_model_id = Some("velopack.fluxer_desktop".to_string());
raw.toast_activator_clsid = Some("{48EEF21B-F3AE-431E-8CF2-386FFB2143F2}".to_string());
let opts = validate_options(raw).expect("options should validate");
assert_eq!(opts.args.as_deref(), Some("--startup"));
assert_eq!(
opts.app_user_model_id.as_deref(),
Some("velopack.fluxer_desktop")
);
assert_eq!(
opts.toast_activator_clsid.as_deref(),
Some("{48EEF21B-F3AE-431E-8CF2-386FFB2143F2}")
);
assert_eq!(
opts.icon_path.as_deref(),
Some("C:\\Program Files\\Fluxer\\Fluxer.exe")
);
assert_eq!(opts.icon_index, 2);
assert_eq!(
opts.working_dir.as_deref(),
Some("C:\\Program Files\\Fluxer")
);
assert_eq!(opts.description.as_deref(), Some("Fluxer desktop client"));
}
#[test]
fn run_value_validation_rejects_missing_name() {
let mut opts = valid_run_value_options();
opts.name = None;
let err = validate_run_value_options(opts).expect_err("missing name should be rejected");
assert_eq!(err.status, Status::InvalidArg);
assert_eq!(err.reason, "name is required and must be non-empty");
}
#[test]
fn run_value_validation_rejects_empty_command() {
let mut opts = valid_run_value_options();
opts.command = Some(String::new());
let err = validate_run_value_options(opts).expect_err("empty command should be rejected");
assert_eq!(err.status, Status::InvalidArg);
assert_eq!(err.reason, "command is required and must be non-empty");
}
#[test]
fn run_value_validation_preserves_fields() {
let opts =
validate_run_value_options(valid_run_value_options()).expect("options should validate");
assert_eq!(opts.name, "velopack.fluxer_desktop_canary");
assert_eq!(
opts.command,
"\"C:\\Users\\Example\\AppData\\Local\\Fluxer\\Fluxer.exe\" --autostart"
);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn non_windows_worker_preserves_stub_error_contract() {
let opts = validate_options(valid_raw_options()).expect("options should validate");
let err = platform::create_shortcut(&opts).expect_err("non-Windows should fail");
assert_eq!(err.status, Status::GenericFailure);
assert_eq!(err.reason, "win-shell not supported on this platform");
}
}