mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1031aa7e85 | ||
|
|
ebb4c4c6fa | ||
|
|
1c4a220d9d | ||
|
|
a3aae21e33 | ||
|
|
d687a53594 | ||
|
|
fb555ae65e |
No files matched your search
@@ -8,7 +8,7 @@
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { execFileSync, spawnSync } = require("node:child_process");
|
||||
|
||||
const EXEC_OPTS = { encoding: "utf8", timeout: 5000, windowsHide: true };
|
||||
|
||||
@@ -88,9 +88,189 @@ function regEnumSubkeys(keyPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path prefix of the per-user Windows App Execution Aliases
|
||||
* directory (`%LOCALAPPDATA%\Microsoft\WindowsApps`), lower-cased and
|
||||
* terminated with a path separator — or `null` when it cannot be
|
||||
* determined (non-Windows platforms, missing LOCALAPPDATA).
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} [env] Environment to read LOCALAPPDATA from.
|
||||
* @param {string} [platform] Platform override (tests).
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function windowsAppsAliasPrefix(env = process.env, platform = process.platform) {
|
||||
if (platform !== "win32") return null;
|
||||
const localAppData = env.LOCALAPPDATA || "";
|
||||
if (!localAppData) return null;
|
||||
// Win32 paths always use backslashes; build the prefix explicitly so the
|
||||
// check does not depend on the path module of the host OS (tests).
|
||||
return (
|
||||
localAppData.replace(/[\\/]+$/, "") +
|
||||
"\\Microsoft\\WindowsApps\\"
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a candidate path lives in the per-user Windows App
|
||||
* Execution Aliases directory (e.g. the MSIX/Store `pwsh.exe` alias).
|
||||
*
|
||||
* @param {string} candidate Candidate executable path.
|
||||
* @param {NodeJS.ProcessEnv} [env] Environment to read LOCALAPPDATA from.
|
||||
* @param {string} [platform] Platform override (tests).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isWindowsAppExecutionAlias(candidate, env = process.env, platform) {
|
||||
if (!candidate || typeof candidate !== "string") return false;
|
||||
const prefix = windowsAppsAliasPrefix(env, platform);
|
||||
if (!prefix) return false;
|
||||
return (
|
||||
candidate.replace(/\//g, "\\").toLowerCase().startsWith(prefix)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arguments that make a PowerShell-family executable exit immediately
|
||||
* without loading the user's profile. The probe only needs to prove that
|
||||
* CreateProcess can resolve the AppExecLink — running the user's normal
|
||||
* shell initialization would be a surprising side effect (profiles may
|
||||
* write, hit the network, or block on input, which would stall the main
|
||||
* process until the probe timeout and then kill the profile midway).
|
||||
*
|
||||
* An empty list means "do not probe": there is no argument set that
|
||||
* guarantees the candidate exits immediately without side effects, so
|
||||
* callers must never spawn it. See `probeAliasLaunchable`.
|
||||
*
|
||||
* @param {string} candidate Executable path.
|
||||
* @returns {string[]} Extra arguments, or an empty list when the candidate
|
||||
* must not be probed.
|
||||
*/
|
||||
function probeArgsFor(candidate) {
|
||||
const base = path.win32.basename(String(candidate || "")).toLowerCase();
|
||||
if (base === "pwsh.exe" || base === "powershell.exe" || base === "pwsh" || base === "powershell") {
|
||||
return ["-NoProfile", "-NonInteractive", "-Command", "exit"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a WindowsApps alias candidate is actually launchable.
|
||||
*
|
||||
* Node cannot read the reparse tag of an AppExecLink, so `statSync` failing
|
||||
* while `lstatSync` succeeds matches both a live alias and a stale or broken
|
||||
* reparse point (e.g. after an incomplete package removal). The only reliable
|
||||
* pure-JS check is to let CreateProcess resolve it: a live alias spawns, a
|
||||
* stale one fails to spawn.
|
||||
*
|
||||
* Only the PowerShell family is probed. `probeArgsFor()` returns
|
||||
* `-NoProfile -NonInteractive -Command exit` for those, so the probe is
|
||||
* guaranteed to exit immediately without running the user's profile.
|
||||
* Any other WindowsApps alias (e.g. `python.exe`, `wt.exe`) is NOT probed:
|
||||
* spawning an arbitrary alias with no arguments can open a GUI or the
|
||||
* Microsoft Store, or block the Electron main process until the probe
|
||||
* timeout. Unknown aliases are therefore treated as not launchable.
|
||||
*
|
||||
* @param {string} candidate WindowsApps alias path (AppExecLink reparse point).
|
||||
* @param {{spawnSync?: typeof import("node:child_process").spawnSync}} [deps] Injectable for tests.
|
||||
* @returns {boolean} True when the process could be started.
|
||||
*/
|
||||
function probeAliasLaunchable(candidate, deps = {}) {
|
||||
const spawn = deps.spawnSync || spawnSync;
|
||||
// Restrict the probe to candidates we know launch headlessly and exit
|
||||
// immediately; anything else would be spawned with no arguments.
|
||||
const args = probeArgsFor(candidate);
|
||||
if (args.length === 0) return false;
|
||||
try {
|
||||
const result = spawn(candidate, args, {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
input: "",
|
||||
});
|
||||
if (result.error) {
|
||||
// ETIMEDOUT means the process ran until we killed it — it launched.
|
||||
return result.error.code === "ETIMEDOUT";
|
||||
}
|
||||
return true;
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the first launchable executable path from an ordered candidate list
|
||||
* (e.g. the lines `where.exe <name>` printed, in PATH order).
|
||||
*
|
||||
* Regular files are verified with `fs.existsSync()`. Candidates inside the
|
||||
* WindowsApps alias directory need special handling: MSIX/Store packages
|
||||
* expose their executables as AppExecLink reparse points, which Node's
|
||||
* `fs.statSync()`/`fs.existsSync()` cannot resolve (they fail with EACCES,
|
||||
* see nodejs/node#36790) even though CreateProcess launches them fine via
|
||||
* the full alias path. For those, fall back to `fs.lstatSync()`, which
|
||||
* still sees the reparse point itself, and then confirm launchability with
|
||||
* a spawn probe — `lstatSync` alone cannot tell a live alias from a stale
|
||||
* reparse point. Only PowerShell-family aliases are probed (see
|
||||
* `probeAliasLaunchable`); other aliases are never spawned and are rejected.
|
||||
* A zero-byte plain file in the same directory (e.g. a
|
||||
* disabled alias) stats cleanly and is rejected.
|
||||
*
|
||||
* Non-alias candidates win over alias candidates so a real install (MSI,
|
||||
* zip, Chocolatey…) is still preferred when both are on PATH. The launch
|
||||
* probe for alias candidates is deferred until after the full scan: the
|
||||
* probe spawns a process (up to a five-second timeout), so it must not run
|
||||
* while a later regular candidate may make the result moot.
|
||||
*
|
||||
* @param {string[]} candidates Ordered candidate paths.
|
||||
* @param {{fs?: typeof import("node:fs"), env?: NodeJS.ProcessEnv, platform?: string, probe?: (candidate: string) => boolean}} [deps] Injectable for tests.
|
||||
* @returns {string|null} First launchable path, or `null`.
|
||||
*/
|
||||
function selectExecutableCandidate(candidates, deps = {}) {
|
||||
const fsMod = deps.fs || fs;
|
||||
const env = deps.env || process.env;
|
||||
const platform = deps.platform || process.platform;
|
||||
const probe = deps.probe || probeAliasLaunchable;
|
||||
let aliasFallback = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
|
||||
if (isWindowsAppExecutionAlias(candidate, env, platform)) {
|
||||
if (aliasFallback) continue;
|
||||
// existsSync() is false for AppExecLink reparse points, so a plain
|
||||
// existence check would filter the alias out. Verify via statSync:
|
||||
// success means a plain (non-launchable) stub; failure means a
|
||||
// reparse point, confirmed with lstatSync.
|
||||
try {
|
||||
fsMod.statSync(candidate);
|
||||
continue; // Plain zero-byte stub — not an AppExecLink.
|
||||
} catch (_statErr) {
|
||||
// Expected for AppExecLink reparse points.
|
||||
}
|
||||
try {
|
||||
fsMod.lstatSync(candidate);
|
||||
} catch (_lstatErr) {
|
||||
continue;
|
||||
}
|
||||
// Record the reparse-point alias as the fallback candidate, but do
|
||||
// NOT probe yet — the probe spawns a process and must be deferred
|
||||
// until we know no regular candidate exists (see after the loop).
|
||||
aliasFallback = candidate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fsMod.existsSync(candidate)) continue;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// No regular candidate won, so the recorded alias fallback is the last
|
||||
// option. lstatSync cannot distinguish a live AppExecLink from a stale or
|
||||
// broken one (both fail statSync with EACCES), so confirm launchability
|
||||
// with the spawn probe before accepting it.
|
||||
if (aliasFallback && !probe(aliasFallback)) return null;
|
||||
return aliasFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an executable on the system PATH using `where.exe`.
|
||||
* Returns the first valid, non-alias path, or `null` if not found.
|
||||
* Returns the first valid, launchable path, or `null` if not found.
|
||||
*
|
||||
* @param {string} name Executable name, e.g. "pwsh"
|
||||
* @returns {string|null}
|
||||
@@ -98,30 +278,12 @@ function regEnumSubkeys(keyPath) {
|
||||
function findExecutableOnPath(name) {
|
||||
try {
|
||||
const result = execFileSync("where.exe", [name], EXEC_OPTS);
|
||||
const candidates = result
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
// Skip Windows App Execution Aliases (WindowsApps zero-byte stubs).
|
||||
try {
|
||||
const localAppData = (process.env.LOCALAPPDATA || "").toLowerCase();
|
||||
if (
|
||||
localAppData &&
|
||||
candidate.toLowerCase().startsWith(
|
||||
path.join(localAppData, "Microsoft", "WindowsApps").toLowerCase() +
|
||||
path.sep,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore — just use the candidate.
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
return selectExecutableCandidate(
|
||||
result
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
} catch (_err) {
|
||||
// Not found on PATH.
|
||||
}
|
||||
@@ -709,5 +871,10 @@ module.exports = {
|
||||
regQueryValue,
|
||||
regEnumSubkeys,
|
||||
findExecutableOnPath,
|
||||
windowsAppsAliasPrefix,
|
||||
isWindowsAppExecutionAlias,
|
||||
selectExecutableCandidate,
|
||||
probeAliasLaunchable,
|
||||
probeArgsFor,
|
||||
mapWslDistroIcon,
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
isWindowsAppExecutionAlias,
|
||||
probeAliasLaunchable,
|
||||
probeArgsFor,
|
||||
selectExecutableCandidate,
|
||||
windowsAppsAliasPrefix,
|
||||
} = require("./shellDiscovery.cjs");
|
||||
|
||||
const WIN_ENV = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" };
|
||||
const ALIAS_DIR = "C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps";
|
||||
const ALIAS_PWSH = ALIAS_DIR + "\\pwsh.exe";
|
||||
|
||||
/**
|
||||
* Build a fake fs module simulating Windows file semantics:
|
||||
* - "file": regular file — existsSync/statSync/lstatSync all succeed.
|
||||
* - "appExecLink": AppExecLink reparse point — existsSync/statSync fail
|
||||
* (EACCES, nodejs/node#36790), lstatSync sees the reparse point.
|
||||
* - "stub": plain zero-byte file (e.g. disabled alias) — all stat calls
|
||||
* succeed but it is not launchable.
|
||||
* Missing paths behave like ENOENT everywhere.
|
||||
*/
|
||||
const makeFakeFs = (entries) => {
|
||||
const kindOf = (p) => entries.get(String(p).toLowerCase());
|
||||
const stat = (p) => {
|
||||
const kind = kindOf(p);
|
||||
if (!kind) {
|
||||
const err = new Error("ENOENT");
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
if (kind === "appExecLink") {
|
||||
const err = new Error("EACCES");
|
||||
err.code = "EACCES";
|
||||
throw err;
|
||||
}
|
||||
return { isFile: () => true, size: 0 };
|
||||
};
|
||||
return {
|
||||
existsSync: (p) => {
|
||||
const kind = kindOf(p);
|
||||
return !!kind && kind !== "appExecLink";
|
||||
},
|
||||
statSync: stat,
|
||||
lstatSync: (p) => {
|
||||
if (!kindOf(p)) {
|
||||
const err = new Error("ENOENT");
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return { isFile: () => true, size: 0 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const WIN_DEPS = (entries) => ({
|
||||
fs: makeFakeFs(entries),
|
||||
env: WIN_ENV,
|
||||
platform: "win32",
|
||||
// Default probe: treat every reparse point as a live, launchable alias.
|
||||
probe: () => true,
|
||||
});
|
||||
|
||||
test("windowsAppsAliasPrefix returns null off Windows", () => {
|
||||
assert.equal(windowsAppsAliasPrefix(WIN_ENV, "linux"), null);
|
||||
assert.equal(windowsAppsAliasPrefix(WIN_ENV, "darwin"), null);
|
||||
});
|
||||
|
||||
test("windowsAppsAliasPrefix joins LOCALAPPDATA + WindowsApps on win32", () => {
|
||||
const prefix = windowsAppsAliasPrefix(WIN_ENV, "win32");
|
||||
assert.ok(prefix.startsWith("c:\\users\\u\\appdata\\local"));
|
||||
assert.ok(prefix.endsWith("microsoft\\windowsapps\\"));
|
||||
});
|
||||
|
||||
test("isWindowsAppExecutionAlias matches the per-user alias directory", () => {
|
||||
assert.equal(isWindowsAppExecutionAlias(ALIAS_PWSH, WIN_ENV, "win32"), true);
|
||||
assert.equal(
|
||||
isWindowsAppExecutionAlias(ALIAS_DIR + "\\WindowsPowerShell\\pwsh.exe", WIN_ENV, "win32"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isWindowsAppExecutionAlias("C:\\Program Files\\PowerShell\\7\\pwsh.exe", WIN_ENV, "win32"),
|
||||
false,
|
||||
);
|
||||
assert.equal(isWindowsAppExecutionAlias(ALIAS_PWSH, WIN_ENV, "linux"), false);
|
||||
assert.equal(isWindowsAppExecutionAlias(null, WIN_ENV, "win32"), false);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate returns the first existing regular file", () => {
|
||||
const deps = WIN_DEPS(new Map([
|
||||
["c:\\tools\\pwsh.exe", "file"],
|
||||
]));
|
||||
assert.equal(
|
||||
selectExecutableCandidate(["C:\\tools\\pwsh.exe", ALIAS_PWSH], deps),
|
||||
"C:\\tools\\pwsh.exe",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate skips missing candidates", () => {
|
||||
const deps = WIN_DEPS(new Map());
|
||||
assert.equal(selectExecutableCandidate(["C:\\nope\\pwsh.exe"], deps), null);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate accepts an MSIX app execution alias (issue #3280)", () => {
|
||||
// winget MSIX PowerShell 7: only the WindowsApps alias exists, and
|
||||
// existsSync()/statSync() fail on its AppExecLink reparse point.
|
||||
const deps = WIN_DEPS(new Map([[ALIAS_PWSH.toLowerCase(), "appExecLink"]]));
|
||||
assert.equal(selectExecutableCandidate([ALIAS_PWSH], deps), ALIAS_PWSH);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate rejects a plain zero-byte alias stub", () => {
|
||||
// Disabled alias: statSync succeeds (plain file) → not launchable.
|
||||
const deps = WIN_DEPS(new Map([[ALIAS_PWSH.toLowerCase(), "stub"]]));
|
||||
assert.equal(selectExecutableCandidate([ALIAS_PWSH], deps), null);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate rejects a stale app execution alias", () => {
|
||||
// Stale reparse point (e.g. after incomplete package removal): statSync
|
||||
// fails with EACCES just like a live AppExecLink, but the spawn probe
|
||||
// shows the process cannot be launched.
|
||||
const deps = {
|
||||
...WIN_DEPS(new Map([[ALIAS_PWSH.toLowerCase(), "appExecLink"]])),
|
||||
probe: () => false,
|
||||
};
|
||||
assert.equal(selectExecutableCandidate([ALIAS_PWSH], deps), null);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate skips a stale alias for later candidates", () => {
|
||||
// A stale alias must not be recorded as the fallback, so a later real
|
||||
// install is still selected.
|
||||
const deps = {
|
||||
...WIN_DEPS(new Map([
|
||||
["c:\\program files\\powershell\\7\\pwsh.exe", "file"],
|
||||
[ALIAS_PWSH.toLowerCase(), "appExecLink"],
|
||||
])),
|
||||
probe: (candidate) => candidate !== ALIAS_PWSH,
|
||||
};
|
||||
assert.equal(
|
||||
selectExecutableCandidate([ALIAS_PWSH, "C:\\Program Files\\PowerShell\\7\\pwsh.exe"], deps),
|
||||
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate prefers a real install over the alias", () => {
|
||||
const deps = WIN_DEPS(new Map([
|
||||
["c:\\program files\\powershell\\7\\pwsh.exe", "file"],
|
||||
[ALIAS_PWSH.toLowerCase(), "appExecLink"],
|
||||
]));
|
||||
assert.equal(
|
||||
selectExecutableCandidate([
|
||||
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
ALIAS_PWSH,
|
||||
], deps),
|
||||
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
);
|
||||
// Alias is still kept as fallback when it comes first in PATH order.
|
||||
assert.equal(
|
||||
selectExecutableCandidate([
|
||||
ALIAS_PWSH,
|
||||
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
], deps),
|
||||
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate falls back to the alias when no real install exists", () => {
|
||||
const deps = WIN_DEPS(new Map([
|
||||
[ALIAS_PWSH.toLowerCase(), "appExecLink"],
|
||||
]));
|
||||
assert.equal(
|
||||
selectExecutableCandidate([
|
||||
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
|
||||
ALIAS_PWSH,
|
||||
], deps),
|
||||
ALIAS_PWSH,
|
||||
);
|
||||
});
|
||||
|
||||
test("probeArgsFor suppresses the profile for PowerShell-family aliases", () => {
|
||||
// The spawn probe must never execute the user's PowerShell profile.
|
||||
assert.deepEqual(probeArgsFor(ALIAS_PWSH), [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"exit",
|
||||
]);
|
||||
assert.deepEqual(probeArgsFor("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"), [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"exit",
|
||||
]);
|
||||
// Non-PowerShell candidates must not be probed at all (empty args = "do
|
||||
// not probe"): launching an arbitrary WindowsApps alias with no arguments
|
||||
// can open a GUI or the Microsoft Store.
|
||||
assert.deepEqual(probeArgsFor("C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\wt.exe"), []);
|
||||
assert.deepEqual(probeArgsFor("C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe"), []);
|
||||
});
|
||||
|
||||
test("probeAliasLaunchable never spawns non-PowerShell WindowsApps aliases", () => {
|
||||
// Probing python.exe/wt.exe with no arguments can open a GUI or the
|
||||
// Microsoft Store, so unknown aliases must be rejected without spawning.
|
||||
const spawnCalls = [];
|
||||
const spawnSync = (...args) => {
|
||||
spawnCalls.push(args);
|
||||
return { error: undefined };
|
||||
};
|
||||
assert.equal(
|
||||
probeAliasLaunchable("C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe", { spawnSync }),
|
||||
false,
|
||||
);
|
||||
assert.equal(spawnCalls.length, 0);
|
||||
});
|
||||
|
||||
test("probeAliasLaunchable probes PowerShell-family aliases with -NoProfile", () => {
|
||||
const spawnCalls = [];
|
||||
const spawnSync = (...args) => {
|
||||
spawnCalls.push(args);
|
||||
return { error: undefined };
|
||||
};
|
||||
assert.equal(probeAliasLaunchable(ALIAS_PWSH, { spawnSync }), true);
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.deepEqual(spawnCalls[0][1], [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"exit",
|
||||
]);
|
||||
});
|
||||
|
||||
test("probeAliasLaunchable treats a probe timeout as launchable", () => {
|
||||
// ETIMEDOUT means the process ran until we killed it — it launched.
|
||||
const spawnSync = () => {
|
||||
const err = new Error("spawn timeout");
|
||||
err.code = "ETIMEDOUT";
|
||||
return { error: err };
|
||||
};
|
||||
assert.equal(probeAliasLaunchable(ALIAS_PWSH, { spawnSync }), true);
|
||||
});
|
||||
|
||||
test("probeAliasLaunchable rejects aliases that fail to spawn", () => {
|
||||
const spawnSync = () => {
|
||||
const err = new Error("spawn failed");
|
||||
err.code = "ENOENT";
|
||||
return { error: err };
|
||||
};
|
||||
assert.equal(probeAliasLaunchable(ALIAS_PWSH, { spawnSync }), false);
|
||||
});
|
||||
|
||||
test("selectExecutableCandidate ignores empty candidates", () => {
|
||||
const deps = WIN_DEPS(new Map([["c:\\tools\\pwsh.exe", "file"]]));
|
||||
assert.equal(
|
||||
selectExecutableCandidate(["", "C:\\tools\\pwsh.exe"], deps),
|
||||
"C:\\tools\\pwsh.exe",
|
||||
);
|
||||
});
|
||||
@@ -58,7 +58,10 @@ const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { stripAnsi, trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
const {
|
||||
discoverShells,
|
||||
selectExecutableCandidate,
|
||||
} = require("./shellDiscovery.cjs");
|
||||
const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
@@ -741,22 +744,9 @@ function resolvePosixExecutable(name, opts = {}) {
|
||||
/**
|
||||
* Find executable path on Windows
|
||||
*/
|
||||
function isWindowsAppExecutionAlias(filePath) {
|
||||
if (!filePath || process.platform !== "win32") return false;
|
||||
|
||||
const normalizedPath = path.normalize(filePath).toLowerCase();
|
||||
const windowsAppsDir = path.join(
|
||||
process.env.LOCALAPPDATA || "",
|
||||
"Microsoft",
|
||||
"WindowsApps",
|
||||
).toLowerCase();
|
||||
|
||||
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
|
||||
}
|
||||
|
||||
function findExecutable(name, opts = {}) {
|
||||
if (process.platform !== "win32") return name;
|
||||
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
try {
|
||||
const pathOverride = Object.prototype.hasOwnProperty.call(opts, "pathOverride")
|
||||
@@ -776,15 +766,17 @@ function findExecutable(name, opts = {}) {
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
if (name === "pwsh" && isWindowsAppExecutionAlias(candidate)) continue;
|
||||
return candidate;
|
||||
}
|
||||
// selectExecutableCandidate accepts Windows App Execution Aliases
|
||||
// (MSIX/Store installs, e.g. winget's MSIX PowerShell 7) as a fallback
|
||||
// after regular files — ConPTY launches them fine via the full alias
|
||||
// path even though fs.existsSync() cannot resolve AppExecLink reparse
|
||||
// points (issue #3280).
|
||||
const found = selectExecutableCandidate(candidates, { fs, env: process.env });
|
||||
if (found) return found;
|
||||
} catch (err) {
|
||||
console.warn(`Could not find ${name} via where.exe:`, err.message);
|
||||
}
|
||||
|
||||
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name;
|
||||
|
||||
const commonPaths = [];
|
||||
@@ -793,6 +785,9 @@ function findExecutable(name, opts = {}) {
|
||||
commonPaths.push(
|
||||
path.join(process.env.ProgramFiles || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
|
||||
path.join(process.env.ProgramW6432 || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
|
||||
// MSIX/Store (winget) installs expose pwsh only via the per-user
|
||||
// app execution alias — there is no "Program Files\PowerShell\7".
|
||||
path.join(process.env.LOCALAPPDATA || "", "Microsoft", "WindowsApps", "pwsh.exe"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -815,28 +810,44 @@ function findExecutable(name, opts = {}) {
|
||||
);
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
// selectExecutableCandidate also accepts app execution aliases, whose
|
||||
// AppExecLink reparse points fs.existsSync() cannot see.
|
||||
if (selectExecutableCandidate([p], { fs, env: process.env })) return p;
|
||||
}
|
||||
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
// Resolved default Windows shell for this app session (undefined = not yet
|
||||
// resolved). Resolution can run a spawn probe for MSIX app-execution aliases
|
||||
// (see findExecutable → selectExecutableCandidate), which blocks the Electron
|
||||
// main process for up to the probe timeout, so the result is memoized and
|
||||
// never recomputed per terminal.
|
||||
let cachedDefaultLocalShell;
|
||||
|
||||
function getDefaultLocalShell() {
|
||||
if (process.platform !== "win32") {
|
||||
return process.env.SHELL || "/bin/bash";
|
||||
}
|
||||
|
||||
if (cachedDefaultLocalShell !== undefined) {
|
||||
return cachedDefaultLocalShell;
|
||||
}
|
||||
|
||||
const pwsh = findExecutable("pwsh");
|
||||
if (pwsh && pwsh.toLowerCase() !== "pwsh") {
|
||||
cachedDefaultLocalShell = pwsh;
|
||||
return pwsh;
|
||||
}
|
||||
|
||||
const powershell = findExecutable("powershell");
|
||||
if (powershell && powershell.toLowerCase() !== "powershell") {
|
||||
cachedDefaultLocalShell = powershell;
|
||||
return powershell;
|
||||
}
|
||||
|
||||
return "powershell.exe";
|
||||
cachedDefaultLocalShell = "powershell.exe";
|
||||
return cachedDefaultLocalShell;
|
||||
}
|
||||
|
||||
function getLocalShellArgs(shellPath) {
|
||||
@@ -885,7 +896,6 @@ const applyLocaleDefaults = (env) => {
|
||||
*/
|
||||
function startLocalSession(event, payload) {
|
||||
const sessionId = payload?.sessionId || randomUUID();
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
|
||||
let resolvedShell = payload?.shell;
|
||||
let resolvedArgs = payload?.shellArgs;
|
||||
@@ -898,7 +908,10 @@ function startLocalSession(event, payload) {
|
||||
resolvedArgs = resolvedArgs ?? match.args;
|
||||
}
|
||||
}
|
||||
const shell = normalizeExecutablePath(resolvedShell) || defaultShell;
|
||||
// Resolve the platform default shell lazily — only when the payload did not
|
||||
// name a usable shell — so terminal creation never pays for default-shell
|
||||
// resolution (which may probe MSIX aliases on Windows) when it isn't needed.
|
||||
const shell = normalizeExecutablePath(resolvedShell) || getDefaultLocalShell();
|
||||
const shellArgs = resolvedArgs ?? getLocalShellArgs(shell);
|
||||
const shellKind = detectShellKind(shell);
|
||||
const { buildTerminalProcessEnv } = require("./httpNetworkProxyBridge.cjs");
|
||||
|
||||
Reference in new issue
Block a user