862f821d76
Follow-up to the #4420 opener hardening: absolute-path rundll32 fixes the stripped-PATH spawn throw, but rundll32 exits 0 unconditionally, so the delayed-failure telemetry added there can never observe a Windows launch failure. Replace it with %SystemRoot%-resolved PowerShell Start-Process via -EncodedCommand: - failures ShellExecute itself reports (missing target, no handler executable, access denied) surface as exit code 1 and reach the existing non-zero-exit logging (verified live on Windows 11: missing file exits 1; unregistered schemes exit 0 on any opener because the OS hands them to the app-picker — documented limitation); - the UTF-16LE/base64 payload keeps OAuth query strings opaque to cmd/PowerShell metacharacter parsing; embedded single quotes are doubled into a PS literal; - %SystemRoot% anchoring with a bare-name PATH fallback preserves the stripped-PATH resilience from #4420. Also pins the WSL-mount test's path.resolve against Windows dev hosts so the mocked linux platform stays deterministic. Refs #4418
127 lines
4.7 KiB
TypeScript
127 lines
4.7 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as url from "node:url";
|
|
import { $which, logger } from "@oh-my-pi/pi-utils";
|
|
|
|
const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
|
|
|
|
function getExistingWslLocalPath(urlOrPath: string): string | undefined {
|
|
if (
|
|
process.platform !== "linux" ||
|
|
!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) ||
|
|
!$which("wslview")
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const localPath = urlOrPath.startsWith("file://")
|
|
? url.fileURLToPath(urlOrPath)
|
|
: URL_SCHEME_PATTERN.test(urlOrPath)
|
|
? undefined
|
|
: path.resolve(urlOrPath);
|
|
if (!localPath || !fs.existsSync(localPath)) return undefined;
|
|
|
|
const result = Bun.spawnSync(["wslpath", "-w", localPath], { stdout: "pipe", stderr: "ignore" });
|
|
if (result.exitCode !== 0) return undefined;
|
|
|
|
return result.stdout.toString().trim() || undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the Windows opener used to hand a URL/path to the user's registered
|
|
* protocol handler. PowerShell's `Start-Process` goes through ShellExecute
|
|
* like the previous `rundll32 url.dll,FileProtocolHandler`, with two
|
|
* advantages that make the delayed-failure telemetry in {@link openPath}
|
|
* actually observable on Windows:
|
|
*
|
|
* - `rundll32` exits 0 unconditionally, so no launch failure ever reaches the
|
|
* non-zero-exit logging below. `Start-Process` surfaces the failures
|
|
* ShellExecute itself reports — missing target file, no handler executable,
|
|
* access denied — as exit code 1 (verified live: a nonexistent file path
|
|
* exits 1; `$ErrorActionPreference='Stop'` additionally promotes any
|
|
* non-terminating error classes). Known limitation shared by every opener:
|
|
* an unregistered URL scheme exits 0 because Windows "handles" it by
|
|
* offering the app-picker.
|
|
* - `-EncodedCommand` carries the target as a UTF-16LE/base64 payload, so no
|
|
* cmd/PowerShell metacharacter parsing ever sees it (OAuth authorize URLs
|
|
* carry `&`); inside the decoded script the target is a single-quoted
|
|
* literal (no `$` expansion) with embedded quotes doubled.
|
|
*
|
|
* PowerShell is anchored to `%SystemRoot%\System32` for the same reason the
|
|
* previous revision anchored `rundll32`: machine PATHs that dropped
|
|
* `System32` are a real-world occurrence, and bare names throw
|
|
* `Executable not found in $PATH` from `Bun.spawn`. A bare-name fallback
|
|
* remains for exotic SystemRoot layouts.
|
|
*/
|
|
function windowsOpenerCommand(target: string): string[] {
|
|
const systemRoot = process.env.SystemRoot?.trim() || process.env.SYSTEMROOT?.trim() || "C:\\Windows";
|
|
// `path.win32` (not the platform-adaptive `path.join`) keeps Windows path
|
|
// separators when tests run under a POSIX host and matches Windows call
|
|
// conventions on the real target.
|
|
const absolute = path.win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
const powershell = fs.existsSync(absolute) ? absolute : "powershell.exe";
|
|
const script = `$ErrorActionPreference='Stop';Start-Process '${target.replaceAll("'", "''")}'`;
|
|
return [
|
|
powershell,
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-WindowStyle",
|
|
"Hidden",
|
|
"-EncodedCommand",
|
|
Buffer.from(script, "utf16le").toString("base64"),
|
|
];
|
|
}
|
|
/** Open a URL or file path in the default browser/application. Best-effort, never throws. */
|
|
export function openPath(urlOrPath: string): void {
|
|
let cmd: string[];
|
|
switch (process.platform) {
|
|
case "darwin":
|
|
cmd = ["open", urlOrPath];
|
|
break;
|
|
case "win32":
|
|
cmd = windowsOpenerCommand(urlOrPath);
|
|
break;
|
|
default: {
|
|
const wslPath = getExistingWslLocalPath(urlOrPath);
|
|
cmd = wslPath ? ["wslview", wslPath] : ["xdg-open", urlOrPath];
|
|
break;
|
|
}
|
|
}
|
|
let child: Bun.Subprocess | undefined;
|
|
try {
|
|
child = Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
} catch (error) {
|
|
// Spawn threw synchronously (missing binary, denied exec, sandbox
|
|
// restriction, …). Best-effort: log so the failure isn't invisible while
|
|
// still letting the caller advertise a copy-URL fallback.
|
|
logger.warn("Failed to open external URL/path", {
|
|
command: cmd[0],
|
|
target: urlOrPath,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return;
|
|
}
|
|
// Detect delayed failures (exec succeeded but the opener exited non-zero)
|
|
// without blocking the caller. Recording them makes silent misconfigurations
|
|
// (e.g. `xdg-open` present but no MIME handler for `https`) diagnosable from
|
|
// `~/.omp/logs/omp.*.log`.
|
|
child.exited.then(
|
|
exitCode => {
|
|
if (typeof exitCode === "number" && exitCode !== 0) {
|
|
logger.warn("External opener exited with non-zero status", {
|
|
command: cmd[0],
|
|
target: urlOrPath,
|
|
exitCode,
|
|
});
|
|
}
|
|
},
|
|
() => {
|
|
// Ignore — awaiting the subprocess is best-effort telemetry.
|
|
},
|
|
);
|
|
}
|