Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
933 lines
31 KiB
JavaScript
933 lines
31 KiB
JavaScript
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { spawnSync } = require("node:child_process");
|
|
|
|
const EXPLORER_CONTEXT_MENU_PREFERENCES_FILE = "explorer-context-menu-preferences.json";
|
|
// Separate from the user preference: records that a no-verb default-off probe
|
|
// completed. Must not be treated as an intentional "disable" choice, because
|
|
// ZIP/portable builds can share AppData with a later NSIS install.
|
|
const EXPLORER_CONTEXT_MENU_PROBE_FILE = "explorer-context-menu-probe.json";
|
|
// Bump when the shell verb command/label/icon contract changes so warm starts
|
|
// re-apply registry entries once after upgrade, then stay query-free again.
|
|
// v2: prefer PORTABLE_EXECUTABLE_FILE over process.execPath for registry cmds.
|
|
// v3: include development app entry path after electron.exe.
|
|
const EXPLORER_CONTEXT_MENU_SCHEMA_VERSION = 3;
|
|
const SHELL_VERB = "Netcatty";
|
|
const DIRECTORY_SHELL_KEY = `Software\\Classes\\Directory\\shell\\${SHELL_VERB}`;
|
|
const DIRECTORY_BACKGROUND_SHELL_KEY = `Software\\Classes\\Directory\\Background\\shell\\${SHELL_VERB}`;
|
|
const MENU_LABEL = "Open in NetMesh";
|
|
const OPEN_TERMINAL_PATH_ARG = "--open-terminal-path";
|
|
// Hides a shell verb from Explorer while keeping the key present. Used as a
|
|
// per-user override when per-machine (HKLM) keys cannot be deleted without elevation.
|
|
const SUPPRESSION_VALUE = "ProgrammaticAccessOnly";
|
|
|
|
function isWindowsPlatform(platform = process.platform) {
|
|
return platform === "win32";
|
|
}
|
|
|
|
/**
|
|
* Resolve the stable executable path for Explorer shell verbs.
|
|
* electron-builder portable apps unpack under %TEMP%; process.execPath then
|
|
* points at a transient binary that is deleted when the app exits. The
|
|
* durable launcher path is exposed as PORTABLE_EXECUTABLE_FILE.
|
|
*/
|
|
function resolveExplorerContextMenuExecutablePath({
|
|
execPath = process.execPath,
|
|
env = process.env,
|
|
} = {}) {
|
|
return resolveExplorerContextMenuLaunchSpec({ execPath, env }).executablePath;
|
|
}
|
|
|
|
/**
|
|
* Resolve executable + optional app entry args for shell registration.
|
|
* In development (`electron.exe .`), include the absolute app path so Explorer
|
|
* launches Netcatty rather than a bare Electron binary.
|
|
*/
|
|
function resolveExplorerContextMenuLaunchSpec({
|
|
execPath = process.execPath,
|
|
env = process.env,
|
|
argv = process.argv,
|
|
defaultApp = process.defaultApp,
|
|
pathModule = path,
|
|
} = {}) {
|
|
const portable = String(env?.PORTABLE_EXECUTABLE_FILE || "").trim();
|
|
if (portable) {
|
|
return { executablePath: portable, appArgs: [] };
|
|
}
|
|
const exe = String(execPath || "").trim();
|
|
if (defaultApp && typeof argv?.[1] === "string" && argv[1].trim()) {
|
|
const appEntry = pathModule.resolve(argv[1].trim());
|
|
return { executablePath: exe, appArgs: [appEntry] };
|
|
}
|
|
return { executablePath: exe, appArgs: [] };
|
|
}
|
|
|
|
function getExplorerContextMenuPreferencePath({
|
|
app,
|
|
pathModule = path,
|
|
} = {}) {
|
|
if (!app || typeof app.getPath !== "function") return null;
|
|
try {
|
|
return pathModule.join(app.getPath("userData"), EXPLORER_CONTEXT_MENU_PREFERENCES_FILE);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getExplorerContextMenuProbePath({
|
|
app,
|
|
pathModule = path,
|
|
} = {}) {
|
|
if (!app || typeof app.getPath !== "function") return null;
|
|
try {
|
|
return pathModule.join(app.getPath("userData"), EXPLORER_CONTEXT_MENU_PROBE_FILE);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readExplorerContextMenuProbeRecord({
|
|
app,
|
|
fsModule = fs,
|
|
pathModule = path,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
const filePath = getExplorerContextMenuProbePath({ app, pathModule });
|
|
if (!filePath) return null;
|
|
try {
|
|
if (!fsModule.existsSync(filePath)) return null;
|
|
const parsed = JSON.parse(fsModule.readFileSync(filePath, "utf8"));
|
|
const schemaVersion = Number.isInteger(parsed?.schemaVersion)
|
|
? parsed.schemaVersion
|
|
: 0;
|
|
return { schemaVersion };
|
|
} catch (err) {
|
|
logWarn?.("[Main] Failed to read Explorer context menu probe marker:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isExplorerContextMenuProbeCurrent(options = {}) {
|
|
const record = readExplorerContextMenuProbeRecord(options);
|
|
return Boolean(record && record.schemaVersion === EXPLORER_CONTEXT_MENU_SCHEMA_VERSION);
|
|
}
|
|
|
|
function writeExplorerContextMenuProbeMarker({
|
|
app,
|
|
fsModule = fs,
|
|
pathModule = path,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
const filePath = getExplorerContextMenuProbePath({ app, pathModule });
|
|
if (!filePath) return false;
|
|
try {
|
|
fsModule.mkdirSync(pathModule.dirname(filePath), { recursive: true });
|
|
fsModule.writeFileSync(
|
|
filePath,
|
|
JSON.stringify({ schemaVersion: EXPLORER_CONTEXT_MENU_SCHEMA_VERSION }, null, 2),
|
|
);
|
|
return true;
|
|
} catch (err) {
|
|
logWarn?.("[Main] Failed to write Explorer context menu probe marker:", err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function readExplorerContextMenuPreferenceRecord({
|
|
app,
|
|
fsModule = fs,
|
|
pathModule = path,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
const filePath = getExplorerContextMenuPreferencePath({ app, pathModule });
|
|
if (!filePath) return null;
|
|
try {
|
|
if (!fsModule.existsSync(filePath)) return null;
|
|
const parsed = JSON.parse(fsModule.readFileSync(filePath, "utf8"));
|
|
if (typeof parsed?.enabled !== "boolean") return null;
|
|
const schemaVersion = Number.isInteger(parsed.schemaVersion)
|
|
? parsed.schemaVersion
|
|
: 0;
|
|
const executablePath = typeof parsed.executablePath === "string"
|
|
? parsed.executablePath.trim()
|
|
: "";
|
|
const appArgs = Array.isArray(parsed.appArgs)
|
|
? parsed.appArgs.map((arg) => String(arg || "").trim()).filter(Boolean)
|
|
: [];
|
|
return {
|
|
enabled: parsed.enabled,
|
|
schemaVersion,
|
|
executablePath,
|
|
appArgs,
|
|
};
|
|
} catch (err) {
|
|
logWarn?.("[Main] Failed to read Explorer context menu preference:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readExplorerContextMenuEnabledPreference({
|
|
app,
|
|
fsModule = fs,
|
|
pathModule = path,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
const record = readExplorerContextMenuPreferenceRecord({
|
|
app,
|
|
fsModule,
|
|
pathModule,
|
|
logWarn,
|
|
});
|
|
return record ? record.enabled : null;
|
|
}
|
|
|
|
function normalizeAppArgs(appArgs = []) {
|
|
return Array.isArray(appArgs)
|
|
? appArgs.map((arg) => String(arg || "").trim()).filter(Boolean)
|
|
: [];
|
|
}
|
|
|
|
function appArgsEqual(left = [], right = []) {
|
|
const a = normalizeAppArgs(left);
|
|
const b = normalizeAppArgs(right);
|
|
if (a.length !== b.length) return false;
|
|
return a.every((value, index) => value === b[index]);
|
|
}
|
|
|
|
function writeExplorerContextMenuEnabledPreference({
|
|
app,
|
|
enabled,
|
|
executablePath = "",
|
|
appArgs = [],
|
|
fsModule = fs,
|
|
pathModule = path,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
const filePath = getExplorerContextMenuPreferencePath({ app, pathModule });
|
|
if (!filePath) return false;
|
|
try {
|
|
fsModule.mkdirSync(pathModule.dirname(filePath), { recursive: true });
|
|
const payload = {
|
|
enabled: enabled !== false,
|
|
schemaVersion: EXPLORER_CONTEXT_MENU_SCHEMA_VERSION,
|
|
};
|
|
const exe = String(executablePath || "").trim();
|
|
if (exe) payload.executablePath = exe;
|
|
const normalizedArgs = normalizeAppArgs(appArgs);
|
|
if (normalizedArgs.length > 0) payload.appArgs = normalizedArgs;
|
|
fsModule.writeFileSync(filePath, JSON.stringify(payload, null, 2));
|
|
return true;
|
|
} catch (err) {
|
|
logWarn?.("[Main] Failed to write Explorer context menu preference:", err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function quoteWindowsCmdArg(value) {
|
|
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
function buildExplorerContextMenuCommand(executablePath, pathPlaceholder, {
|
|
appArgs = [],
|
|
} = {}) {
|
|
const exe = String(executablePath || "").trim();
|
|
const placeholder = String(pathPlaceholder || "").trim();
|
|
if (!exe || !placeholder) return null;
|
|
const prefixArgs = Array.isArray(appArgs)
|
|
? appArgs.map((arg) => String(arg || "").trim()).filter(Boolean).map(quoteWindowsCmdArg)
|
|
: [];
|
|
const launchPrefix = [quoteWindowsCmdArg(exe), ...prefixArgs].join(" ");
|
|
// Put app args after `--` so Chromium does not consume them, and keep the
|
|
// path in the same token (`=`) so spaces survive CommandLineToArgvW.
|
|
// Trailing `.` avoids the classic `"C:\"` quote-escape bug for drive roots.
|
|
// Development: `"electron.exe" "C:\\...\\app" -- --open-terminal-path="%1."`
|
|
return `${launchPrefix} -- ${OPEN_TERMINAL_PATH_ARG}="${placeholder}."`;
|
|
}
|
|
|
|
function runReg(args, {
|
|
spawnSyncImpl = spawnSync,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
try {
|
|
const result = spawnSyncImpl("reg.exe", args, {
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
});
|
|
return {
|
|
status: typeof result.status === "number" ? result.status : 1,
|
|
stdout: String(result.stdout || ""),
|
|
stderr: String(result.stderr || ""),
|
|
error: result.error || null,
|
|
};
|
|
} catch (err) {
|
|
logWarn?.("[Main] Failed to run reg.exe:", err);
|
|
return {
|
|
status: 1,
|
|
stdout: "",
|
|
stderr: err?.message || String(err),
|
|
error: err,
|
|
};
|
|
}
|
|
}
|
|
|
|
function regKeyExists(hive, keyPath, options = {}) {
|
|
const result = runReg(["query", `${hive}\\${keyPath}`], options);
|
|
return result.status === 0;
|
|
}
|
|
|
|
function regValueExists(hive, keyPath, valueName, options = {}) {
|
|
const result = runReg(["query", `${hive}\\${keyPath}`, "/v", valueName], options);
|
|
return result.status === 0;
|
|
}
|
|
|
|
function parseRegSzValue(stdout) {
|
|
const lines = String(stdout || "").split(/\r?\n/);
|
|
for (const line of lines) {
|
|
const match = line.match(/\bREG_SZ\s+(.*)$/i);
|
|
if (match) return match[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function readRegStr(hive, keyPath, valueName, options = {}) {
|
|
const args = valueName
|
|
? ["query", `${hive}\\${keyPath}`, "/v", valueName]
|
|
: ["query", `${hive}\\${keyPath}`, "/ve"];
|
|
const result = runReg(args, options);
|
|
if (result.status !== 0) return null;
|
|
return parseRegSzValue(result.stdout);
|
|
}
|
|
|
|
function deleteRegKey(hive, keyPath, options = {}) {
|
|
if (!regKeyExists(hive, keyPath, options)) return true;
|
|
const result = runReg(["delete", `${hive}\\${keyPath}`, "/f"], options);
|
|
return result.status === 0;
|
|
}
|
|
|
|
function deleteRegValue(hive, keyPath, valueName, options = {}) {
|
|
if (!regValueExists(hive, keyPath, valueName, options)) return true;
|
|
const result = runReg(["delete", `${hive}\\${keyPath}`, "/v", valueName, "/f"], options);
|
|
return result.status === 0;
|
|
}
|
|
|
|
function writeRegStr(hive, keyPath, valueName, value, options = {}) {
|
|
const args = ["add", `${hive}\\${keyPath}`, "/f"];
|
|
if (valueName) {
|
|
args.push("/v", valueName);
|
|
} else {
|
|
args.push("/ve");
|
|
}
|
|
args.push("/t", "REG_SZ", "/d", value);
|
|
const result = runReg(args, options);
|
|
return result.status === 0;
|
|
}
|
|
|
|
function shellVerbIsCurrent(hive, keyPath, {
|
|
executablePath,
|
|
pathPlaceholder,
|
|
iconPath,
|
|
appArgs = [],
|
|
}, options = {}) {
|
|
if (!regKeyExists(hive, keyPath, options)) return false;
|
|
// Suppression keys are not a real install even if the path exists.
|
|
if (hive === "HKCU" && isSuppressionKey(hive, keyPath, options)) return false;
|
|
|
|
const expectedCommand = buildExplorerContextMenuCommand(executablePath, pathPlaceholder, { appArgs });
|
|
if (!expectedCommand) return false;
|
|
const expectedIcon = `${iconPath || executablePath},0`;
|
|
|
|
const currentCommand = readRegStr(hive, `${keyPath}\\command`, "", options);
|
|
if (currentCommand !== expectedCommand) return false;
|
|
|
|
const currentLabel = readRegStr(hive, keyPath, "MUIVerb", options);
|
|
if (currentLabel !== MENU_LABEL) return false;
|
|
|
|
const currentIcon = readRegStr(hive, keyPath, "Icon", options);
|
|
if (currentIcon !== expectedIcon) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
function writeShellVerb(hive, keyPath, {
|
|
executablePath,
|
|
pathPlaceholder,
|
|
iconPath,
|
|
appArgs = [],
|
|
}, options = {}) {
|
|
const command = buildExplorerContextMenuCommand(executablePath, pathPlaceholder, { appArgs });
|
|
if (!command) return false;
|
|
// Skip reg.exe writes when the verb is already current (common warm-start path).
|
|
if (shellVerbIsCurrent(hive, keyPath, {
|
|
executablePath,
|
|
pathPlaceholder,
|
|
iconPath,
|
|
appArgs,
|
|
}, options)) {
|
|
return true;
|
|
}
|
|
const icon = `${iconPath || executablePath},0`;
|
|
return (
|
|
writeRegStr(hive, keyPath, "MUIVerb", MENU_LABEL, options)
|
|
&& writeRegStr(hive, keyPath, "Icon", icon, options)
|
|
&& writeRegStr(hive, `${keyPath}\\command`, "", command, options)
|
|
);
|
|
}
|
|
|
|
function isSuppressionKey(hive, keyPath, options = {}) {
|
|
return regKeyExists(hive, keyPath, options)
|
|
&& regValueExists(hive, keyPath, SUPPRESSION_VALUE, options);
|
|
}
|
|
|
|
function isUserSuppressed(options = {}) {
|
|
// Both verbs must be suppressed; a partial write would otherwise hide only
|
|
// one Explorer entry while still reporting the integration as disabled.
|
|
return (
|
|
isSuppressionKey("HKCU", DIRECTORY_SHELL_KEY, options)
|
|
&& isSuppressionKey("HKCU", DIRECTORY_BACKGROUND_SHELL_KEY, options)
|
|
);
|
|
}
|
|
|
|
function writeUserSuppression(options = {}) {
|
|
// HKCU Classes values override HKLM for the same key path. Marking the verb
|
|
// ProgrammaticAccessOnly hides it in Explorer for this user without elevation.
|
|
// Both writes must succeed; roll back a partial pair so disable never leaves
|
|
// only one Explorer entry hidden while the other remains visible.
|
|
//
|
|
// Important: when rolling back, only remove the suppression value (or a key
|
|
// we newly created). Never deleteRegKey a pre-existing portable/user install
|
|
// verb — that would destroy the only working Explorer command if one of the
|
|
// two suppression writes fails.
|
|
const folderExisted = regKeyExists("HKCU", DIRECTORY_SHELL_KEY, options);
|
|
const backgroundExisted = regKeyExists("HKCU", DIRECTORY_BACKGROUND_SHELL_KEY, options);
|
|
|
|
const folderOk = writeRegStr("HKCU", DIRECTORY_SHELL_KEY, SUPPRESSION_VALUE, "", options);
|
|
const backgroundOk = writeRegStr(
|
|
"HKCU",
|
|
DIRECTORY_BACKGROUND_SHELL_KEY,
|
|
SUPPRESSION_VALUE,
|
|
"",
|
|
options,
|
|
);
|
|
if (folderOk && backgroundOk) return true;
|
|
|
|
if (folderOk) {
|
|
if (folderExisted) {
|
|
deleteRegValue("HKCU", DIRECTORY_SHELL_KEY, SUPPRESSION_VALUE, options);
|
|
} else {
|
|
deleteRegKey("HKCU", DIRECTORY_SHELL_KEY, options);
|
|
}
|
|
}
|
|
if (backgroundOk) {
|
|
if (backgroundExisted) {
|
|
deleteRegValue("HKCU", DIRECTORY_BACKGROUND_SHELL_KEY, SUPPRESSION_VALUE, options);
|
|
} else {
|
|
deleteRegKey("HKCU", DIRECTORY_BACKGROUND_SHELL_KEY, options);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function clearUserSuppression(options = {}) {
|
|
// Remove per-user ProgrammaticAccessOnly hides without destroying a real
|
|
// portable/user install that was only hidden (command value still present).
|
|
let ok = true;
|
|
for (const keyPath of [DIRECTORY_SHELL_KEY, DIRECTORY_BACKGROUND_SHELL_KEY]) {
|
|
if (!isSuppressionKey("HKCU", keyPath, options)) continue;
|
|
const command = readRegStr("HKCU", `${keyPath}\\command`, "", options);
|
|
if (typeof command === "string" && command.trim()) {
|
|
// Hidden install verb: drop only the suppression value.
|
|
if (!deleteRegValue("HKCU", keyPath, SUPPRESSION_VALUE, options)) ok = false;
|
|
} else if (!deleteRegKey("HKCU", keyPath, options)) {
|
|
// Pure suppression stub with no runnable command.
|
|
ok = false;
|
|
}
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
function clearUserShellKeys(options = {}) {
|
|
// Drop both HKCU Netcatty verbs (real install or suppression). Used when a
|
|
// machine-wide (HKLM) registration exists so stale portable/ZIP HKCU commands
|
|
// cannot take precedence over the all-users path.
|
|
let ok = true;
|
|
for (const keyPath of [DIRECTORY_SHELL_KEY, DIRECTORY_BACKGROUND_SHELL_KEY]) {
|
|
if (!deleteRegKey("HKCU", keyPath, options)) {
|
|
if (regKeyExists("HKCU", keyPath, options)) ok = false;
|
|
}
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
function hasResidualUserShellKeys(options = {}) {
|
|
for (const keyPath of [DIRECTORY_SHELL_KEY, DIRECTORY_BACKGROUND_SHELL_KEY]) {
|
|
if (!regKeyExists("HKCU", keyPath, options)) continue;
|
|
// Any remaining HKCU verb (install or suppression) can hide or override HKLM.
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hasMachineRegistration(options = {}) {
|
|
return (
|
|
regKeyExists("HKLM", DIRECTORY_SHELL_KEY, options)
|
|
|| regKeyExists("HKLM", DIRECTORY_BACKGROUND_SHELL_KEY, options)
|
|
);
|
|
}
|
|
|
|
function hasActiveShellKey(hive, keyPath, options = {}) {
|
|
if (!regKeyExists(hive, keyPath, options)) return false;
|
|
// Suppression keys are not an active menu entry.
|
|
if (hive === "HKCU" && isSuppressionKey(hive, keyPath, options)) return false;
|
|
// A bare verb key without a command is a partial write, not a runnable menu.
|
|
const command = readRegStr(hive, `${keyPath}\\command`, "", options);
|
|
return typeof command === "string" && command.trim().length > 0;
|
|
}
|
|
|
|
function isHiveFullyRegistered(hive, options = {}) {
|
|
// Both folder and folder-background verbs are required for a complete install.
|
|
return (
|
|
hasActiveShellKey(hive, DIRECTORY_SHELL_KEY, options)
|
|
&& hasActiveShellKey(hive, DIRECTORY_BACKGROUND_SHELL_KEY, options)
|
|
);
|
|
}
|
|
|
|
function isExplorerContextMenuRegistered({
|
|
platform = process.platform,
|
|
spawnSyncImpl = spawnSync,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
if (!isWindowsPlatform(platform)) return false;
|
|
const options = { spawnSyncImpl, logWarn };
|
|
// Per-user suppression hides machine registration for this user.
|
|
if (isUserSuppressed(options)) return false;
|
|
return isHiveFullyRegistered("HKCU", options) || isHiveFullyRegistered("HKLM", options);
|
|
}
|
|
|
|
function hasAnyActiveShellVerb({
|
|
platform = process.platform,
|
|
spawnSyncImpl = spawnSync,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
if (!isWindowsPlatform(platform)) return false;
|
|
const options = { spawnSyncImpl, logWarn };
|
|
if (isUserSuppressed(options)) return false;
|
|
for (const hive of ["HKCU", "HKLM"]) {
|
|
if (
|
|
hasActiveShellKey(hive, DIRECTORY_SHELL_KEY, options)
|
|
|| hasActiveShellKey(hive, DIRECTORY_BACKGROUND_SHELL_KEY, options)
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function removeExplorerContextMenu({
|
|
platform = process.platform,
|
|
spawnSyncImpl = spawnSync,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
if (!isWindowsPlatform(platform)) {
|
|
return { success: true, enabled: false, supported: false };
|
|
}
|
|
|
|
const options = { spawnSyncImpl, logWarn };
|
|
|
|
// The Settings toggle is a per-user preference (stored under this user's
|
|
// userData). Never delete HKLM verbs here — even when elevated — so other
|
|
// accounts keep the installer-created menu. Machine-wide cleanup belongs to
|
|
// the NSIS uninstaller.
|
|
let success = true;
|
|
|
|
if (hasMachineRegistration(options)) {
|
|
// Per-user hide of machine verbs via ProgrammaticAccessOnly. Do not delete
|
|
// working portable/user HKCU install verbs first: if suppression fails
|
|
// (partial write rolled back), Explorer must keep the last working path.
|
|
if (!writeUserSuppression(options)) {
|
|
success = false;
|
|
}
|
|
} else {
|
|
// User-scope only: remove the install keys entirely.
|
|
for (const keyPath of [DIRECTORY_SHELL_KEY, DIRECTORY_BACKGROUND_SHELL_KEY]) {
|
|
if (!deleteRegKey("HKCU", keyPath, options)) {
|
|
if (regKeyExists("HKCU", keyPath, options)) success = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Any surviving runnable verb counts as still enabled (including a partial
|
|
// single-verb leftover that isExplorerContextMenuRegistered would miss).
|
|
const stillActive = hasAnyActiveShellVerb({ platform, spawnSyncImpl, logWarn });
|
|
return {
|
|
success: success && !stillActive,
|
|
enabled: stillActive,
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
function installExplorerContextMenu({
|
|
executablePath = process.execPath,
|
|
appArgs = [],
|
|
platform = process.platform,
|
|
spawnSyncImpl = spawnSync,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
if (!isWindowsPlatform(platform)) {
|
|
return { success: true, enabled: false, supported: false };
|
|
}
|
|
|
|
const exe = String(executablePath || "").trim();
|
|
if (!exe) {
|
|
return { success: false, enabled: false, supported: true };
|
|
}
|
|
|
|
const options = { spawnSyncImpl, logWarn };
|
|
const normalizedAppArgs = Array.isArray(appArgs)
|
|
? appArgs.map((arg) => String(arg || "").trim()).filter(Boolean)
|
|
: [];
|
|
|
|
// Enable path. For machine installs, refresh HKLM *before* clearing any
|
|
// working HKCU verbs/suppressions: an unelevated process may fail to rewrite
|
|
// HKLM, and deleting portable HKCU first would leave the user with no working
|
|
// Explorer integration.
|
|
const wasSuppressed = isUserSuppressed(options);
|
|
const machineRegistered = hasMachineRegistration(options);
|
|
const folderSpec = {
|
|
executablePath: exe,
|
|
pathPlaceholder: "%1",
|
|
iconPath: exe,
|
|
appArgs: normalizedAppArgs,
|
|
};
|
|
const backgroundSpec = {
|
|
executablePath: exe,
|
|
pathPlaceholder: "%V",
|
|
iconPath: exe,
|
|
appArgs: normalizedAppArgs,
|
|
};
|
|
|
|
if (machineRegistered) {
|
|
// Prefer refreshing per-machine verbs. Do NOT mirror into HKCU while the
|
|
// machine registration is fully current: NSIS uninstall only cleans SHCTX
|
|
// (HKLM in all-users mode).
|
|
let writesOk = true;
|
|
for (const [keyPath, spec] of [
|
|
[DIRECTORY_SHELL_KEY, folderSpec],
|
|
[DIRECTORY_BACKGROUND_SHELL_KEY, backgroundSpec],
|
|
]) {
|
|
if (!writeShellVerb("HKLM", keyPath, spec, options)) writesOk = false;
|
|
}
|
|
|
|
const machineCurrent = shellVerbIsCurrent("HKLM", DIRECTORY_SHELL_KEY, folderSpec, options)
|
|
&& shellVerbIsCurrent("HKLM", DIRECTORY_BACKGROUND_SHELL_KEY, backgroundSpec, options);
|
|
|
|
if (machineCurrent) {
|
|
// HKLM is current — now safe to drop stale portable HKCU verbs and any
|
|
// ProgrammaticAccessOnly hide so Explorer uses the machine path.
|
|
const userKeysCleared = clearUserShellKeys(options) && !hasResidualUserShellKeys(options);
|
|
if (!userKeysCleared) {
|
|
// HKLM is good but residual HKCU still shadows it. Re-hide if the user
|
|
// previously disabled, so we do not leave a half-migrated state.
|
|
if (wasSuppressed) writeUserSuppression(options);
|
|
return {
|
|
success: false,
|
|
enabled: hasAnyActiveShellVerb({ platform, spawnSyncImpl, logWarn }),
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: writesOk || machineCurrent,
|
|
enabled: true,
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
// Residual/stale/partial HKLM keys cannot be repaired without elevation.
|
|
// Fall through to a per-user HKCU registration so ZIP/portable and unelevated
|
|
// Settings toggles still work; HKCU takes precedence over the broken HKLM
|
|
// entries for this user.
|
|
}
|
|
|
|
// User-scope install (no usable machine registration): clear suppressions, then write HKCU.
|
|
const userKeysCleared = clearUserSuppression(options);
|
|
let writesOk = true;
|
|
for (const [keyPath, spec] of [
|
|
[DIRECTORY_SHELL_KEY, folderSpec],
|
|
[DIRECTORY_BACKGROUND_SHELL_KEY, backgroundSpec],
|
|
]) {
|
|
if (!writeShellVerb("HKCU", keyPath, spec, options)) writesOk = false;
|
|
}
|
|
|
|
const userCurrent = shellVerbIsCurrent("HKCU", DIRECTORY_SHELL_KEY, folderSpec, options)
|
|
&& shellVerbIsCurrent("HKCU", DIRECTORY_BACKGROUND_SHELL_KEY, backgroundSpec, options);
|
|
let success = userKeysCleared && (writesOk || userCurrent);
|
|
|
|
if (!success && wasSuppressed) {
|
|
writeUserSuppression(options);
|
|
}
|
|
|
|
const enabled = success
|
|
|| hasAnyActiveShellVerb({ platform, spawnSyncImpl, logWarn });
|
|
|
|
return {
|
|
success,
|
|
enabled,
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
function applyExplorerContextMenuPreference({
|
|
enabled,
|
|
executablePath = process.execPath,
|
|
appArgs = [],
|
|
platform = process.platform,
|
|
spawnSyncImpl = spawnSync,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
if (!isWindowsPlatform(platform)) {
|
|
return { success: true, enabled: false, supported: false };
|
|
}
|
|
if (enabled === false) {
|
|
return removeExplorerContextMenu({ platform, spawnSyncImpl, logWarn });
|
|
}
|
|
return installExplorerContextMenu({
|
|
executablePath,
|
|
appArgs,
|
|
platform,
|
|
spawnSyncImpl,
|
|
logWarn,
|
|
});
|
|
}
|
|
|
|
function updateExplorerContextMenuEnabledPreference({
|
|
currentEnabled = true,
|
|
enabled = true,
|
|
applyPreference = () => ({ success: false, enabled: currentEnabled }),
|
|
writePreference = () => false,
|
|
} = {}) {
|
|
const nextEnabled = enabled !== false;
|
|
if (nextEnabled === currentEnabled) {
|
|
return { enabled: currentEnabled, success: true, supported: true };
|
|
}
|
|
|
|
const applied = applyPreference(nextEnabled) || {};
|
|
if (applied.success !== true) {
|
|
// Apply may have partially mutated the registry (one verb suppressed, or
|
|
// suppression cleared before a failed HKLM refresh). Restore the previous
|
|
// preference so the toggle and Explorer state stay aligned.
|
|
const rolledBack = applyPreference(currentEnabled) || {};
|
|
return {
|
|
enabled: typeof rolledBack.enabled === "boolean" ? rolledBack.enabled : currentEnabled,
|
|
success: false,
|
|
supported: applied.supported !== false,
|
|
};
|
|
}
|
|
|
|
const writeSucceeded = writePreference(nextEnabled) === true;
|
|
if (!writeSucceeded) {
|
|
const rolledBack = applyPreference(currentEnabled) || {};
|
|
return {
|
|
enabled: typeof rolledBack.enabled === "boolean" ? rolledBack.enabled : nextEnabled,
|
|
success: false,
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
enabled: nextEnabled,
|
|
success: true,
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
function resolveExplorerContextMenuEnabled({
|
|
app,
|
|
platform = process.platform,
|
|
spawnSyncImpl = spawnSync,
|
|
fsModule = fs,
|
|
pathModule = path,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
if (!isWindowsPlatform(platform)) {
|
|
return { enabled: false, supported: false };
|
|
}
|
|
|
|
const preferred = readExplorerContextMenuEnabledPreference({
|
|
app,
|
|
fsModule,
|
|
pathModule,
|
|
logWarn,
|
|
});
|
|
if (typeof preferred === "boolean") {
|
|
return { enabled: preferred, supported: true };
|
|
}
|
|
|
|
// Probe-only default-off (no user choice). Still scan for residual verbs so a
|
|
// later per-user (HKCU) or all-users (HKLM) install is not ignored.
|
|
if (isExplorerContextMenuProbeCurrent({ app, fsModule, pathModule, logWarn })) {
|
|
return {
|
|
enabled: hasAnyActiveShellVerb({ platform, spawnSyncImpl, logWarn }),
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
enabled: isExplorerContextMenuRegistered({ platform, spawnSyncImpl, logWarn })
|
|
|| hasAnyActiveShellVerb({ platform, spawnSyncImpl, logWarn }),
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
function applyInitialExplorerContextMenuPreference({
|
|
app,
|
|
executablePath = process.execPath,
|
|
appArgs = [],
|
|
platform = process.platform,
|
|
spawnSyncImpl = spawnSync,
|
|
fsModule = fs,
|
|
pathModule = path,
|
|
logWarn = console.warn,
|
|
} = {}) {
|
|
if (!isWindowsPlatform(platform)) {
|
|
return { enabled: false, success: true, supported: false };
|
|
}
|
|
|
|
const record = readExplorerContextMenuPreferenceRecord({
|
|
app,
|
|
fsModule,
|
|
pathModule,
|
|
logWarn,
|
|
});
|
|
|
|
const currentExe = String(executablePath || "").trim();
|
|
const normalizedAppArgs = normalizeAppArgs(appArgs);
|
|
|
|
// No saved preference: keep installer/portable state, but repair when any
|
|
// residual verb remains (complete or partial). A single leftover verb would
|
|
// otherwise leave the toggle off while Explorer still shows one entry.
|
|
if (record === null) {
|
|
// Probe markers only skip *writes*; always re-check residual verbs. A later
|
|
// per-user NSIS install writes HKCU (not HKLM), so machine-only checks would
|
|
// leave the menu unrepaired under a shared AppData profile.
|
|
const residual = isExplorerContextMenuRegistered({ platform, spawnSyncImpl, logWarn })
|
|
|| hasAnyActiveShellVerb({ platform, spawnSyncImpl, logWarn });
|
|
if (residual) {
|
|
const refreshed = installExplorerContextMenu({
|
|
executablePath: currentExe,
|
|
appArgs: normalizedAppArgs,
|
|
platform,
|
|
spawnSyncImpl,
|
|
logWarn,
|
|
});
|
|
if (refreshed.success === true && refreshed.enabled === true) {
|
|
writeExplorerContextMenuEnabledPreference({
|
|
app,
|
|
enabled: true,
|
|
executablePath: currentExe,
|
|
appArgs: normalizedAppArgs,
|
|
fsModule,
|
|
pathModule,
|
|
logWarn,
|
|
});
|
|
}
|
|
return {
|
|
// Prefer live residual state when repair fails so a single surviving
|
|
// verb still lights the toggle and can be disabled from Settings.
|
|
enabled: refreshed.enabled === true
|
|
|| hasAnyActiveShellVerb({ platform, spawnSyncImpl, logWarn }),
|
|
success: refreshed.success === true,
|
|
supported: true,
|
|
};
|
|
}
|
|
// ZIP / portable default: no verbs and no preference. Record a probe marker
|
|
// only — never write enabled:false as a durable user choice (shared AppData
|
|
// with a later NSIS install would otherwise suppress installer verbs).
|
|
// The marker is informational / for resolve() short-circuit of "no preference
|
|
// yet"; residual verbs are always re-checked above on every launch.
|
|
writeExplorerContextMenuProbeMarker({
|
|
app,
|
|
fsModule,
|
|
pathModule,
|
|
logWarn,
|
|
});
|
|
return { enabled: false, success: true, supported: true };
|
|
}
|
|
|
|
const preferred = record.enabled;
|
|
const schemaCurrent = record.schemaVersion === EXPLORER_CONTEXT_MENU_SCHEMA_VERSION;
|
|
// Portable builds / dev checkouts can move; re-apply when the launcher path
|
|
// or development app entry that was last written into the shell verbs no
|
|
// longer matches.
|
|
const executableCurrent = !preferred
|
|
|| !currentExe
|
|
|| record.executablePath === currentExe;
|
|
const appArgsCurrent = !preferred || appArgsEqual(record.appArgs, normalizedAppArgs);
|
|
|
|
// Healthy warm start for enabled installs: schema + launch identity match
|
|
// *and* both Explorer verbs still look registered. An interrupted installer
|
|
// rewrite or partial cleanup can leave a current preference while one verb
|
|
// is missing; re-apply in that case instead of short-circuiting forever.
|
|
// Disabled preference is never short-circuited: NSIS updates re-create shell
|
|
// verbs unconditionally, so we must re-assert per-user suppression each launch.
|
|
if (preferred === true && schemaCurrent && executableCurrent && appArgsCurrent) {
|
|
if (isExplorerContextMenuRegistered({ platform, spawnSyncImpl, logWarn })) {
|
|
return { enabled: true, success: true, supported: true };
|
|
}
|
|
}
|
|
|
|
// Schema bump, portable path change, dev app path change, or disabled preference: re-apply.
|
|
const applied = applyExplorerContextMenuPreference({
|
|
enabled: preferred,
|
|
executablePath: currentExe,
|
|
appArgs: normalizedAppArgs,
|
|
platform,
|
|
spawnSyncImpl,
|
|
logWarn,
|
|
});
|
|
if (applied.success === true) {
|
|
writeExplorerContextMenuEnabledPreference({
|
|
app,
|
|
// Keep the user's intended preference as source of truth.
|
|
enabled: preferred,
|
|
executablePath: currentExe,
|
|
appArgs: normalizedAppArgs,
|
|
fsModule,
|
|
pathModule,
|
|
logWarn,
|
|
});
|
|
}
|
|
return {
|
|
// UI follows the saved preference when apply succeeds; on failure fall back
|
|
// to the live registry so the toggle stays honest about residual menus.
|
|
enabled: applied.success === true
|
|
? preferred === true
|
|
: applied.enabled === true,
|
|
success: applied.success === true,
|
|
supported: true,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
DIRECTORY_BACKGROUND_SHELL_KEY,
|
|
DIRECTORY_SHELL_KEY,
|
|
EXPLORER_CONTEXT_MENU_PREFERENCES_FILE,
|
|
EXPLORER_CONTEXT_MENU_PROBE_FILE,
|
|
EXPLORER_CONTEXT_MENU_SCHEMA_VERSION,
|
|
MENU_LABEL,
|
|
SUPPRESSION_VALUE,
|
|
applyExplorerContextMenuPreference,
|
|
applyInitialExplorerContextMenuPreference,
|
|
buildExplorerContextMenuCommand,
|
|
getExplorerContextMenuPreferencePath,
|
|
installExplorerContextMenu,
|
|
isExplorerContextMenuRegistered,
|
|
isWindowsPlatform,
|
|
readExplorerContextMenuEnabledPreference,
|
|
removeExplorerContextMenu,
|
|
resolveExplorerContextMenuEnabled,
|
|
resolveExplorerContextMenuExecutablePath,
|
|
resolveExplorerContextMenuLaunchSpec,
|
|
updateExplorerContextMenuEnabledPreference,
|
|
writeExplorerContextMenuEnabledPreference,
|
|
};
|