Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 86 additions & 4 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const windowsHost = HostProcessPlatform.defaultValue() === "win32";
interface MockSpawnResult {
readonly exitCode?: number;
readonly stdout?: string;
readonly stderr?: string;
/** Never deliver an exit code, like a child wedged on a broken desktop session. */
readonly stall?: boolean;
}
Expand All @@ -50,7 +51,10 @@ function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: ()
input.stdout === undefined
? Stream.empty
: Stream.make(new TextEncoder().encode(input.stdout)),
stderr: Stream.empty,
stderr:
input.stderr === undefined
? Stream.empty
: Stream.make(new TextEncoder().encode(input.stderr)),
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
Expand Down Expand Up @@ -194,6 +198,50 @@ it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS",
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect.each([
{
stderr: "The system cannot find the file specified.",
expected: "The system cannot find the file specified.",
},
{ stderr: "", expected: "Launch helper exited with code 1." },
])("reports a Windows reveal helper failure: $expected", ({ stderr, expected }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-reveal-failure-" });
yield* fileSystem.writeFileString(path.join(binDir, "explorer.CMD"), "@echo off\r\n");
const systemRoot = path.join(binDir, "system-root");
const powerShellPath = `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
yield* fileSystem.makeDirectory(path.dirname(powerShellPath), { recursive: true });
yield* fileSystem.writeFileString(powerShellPath, "");
const result = yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
return yield* launcher.launchEditor({
editor: "file-manager",
cwd: "C:/missing.txt",
reveal: true,
});
}).pipe(
Effect.result,
Effect.provide(
testLayer({
platform: "win32",
env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: systemRoot },
spawnResult: () => ({
exitCode: 1,
stderr,
}),
}),
),
);
assert.equal(result._tag, "Failure");
if (result._tag === "Failure") {
assert.equal(result.failure._tag, "ExternalLauncherEditorSpawnError");
assert.include(result.failure.message, expected);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("reveals a file in File Explorer through PowerShell on Windows", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -246,7 +294,7 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () =>
// PowerShell 5.1's Start-Process passes the argument string verbatim.
assert.equal(
decodedCommand,
"$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')",
"$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; try { $target = 'C:\\workspace with spaces\\media\\author''s clip.mp4'; if (!(Test-Path -LiteralPath $target)) { throw ('Path does not exist: ' + $target) }; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + $target + '\"') } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }",
);
assert.equal(spawned.options.shell, false);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
Expand All @@ -269,7 +317,8 @@ it.skipIf(process.platform !== "win32")(
const outputPath = NodePath.join(tempDir, "argv.txt");
NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`);

const target = "C:/workspace with spaces/media/author's clip.mp4";
const target = NodePath.join(tempDir, "author's clip.mp4").replaceAll("\\", "/");
NodeFS.writeFileSync(target, "");
const explorerTarget = target.replaceAll("/", "\\");
const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(
recorderPath,
Expand Down Expand Up @@ -309,6 +358,39 @@ it.skipIf(process.platform !== "win32")(
},
);

it.skipIf(!windowsHost)("PowerShell reveal fails for a missing target or launcher", () => {
const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
for (const target of [
NodePath.join(NodeOS.tmpdir(), "t3-missing-reveal-target", "missing.txt"),
NodeOS.tmpdir(),
]) {
const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(
NodePath.join(NodeOS.tmpdir(), "t3-missing-reveal-launcher.exe"),
target,
);
const result = NodeChildProcess.spawnSync(
powerShellPath,
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
Buffer.from(source, "utf16le").toString("base64"),
],
{ encoding: "utf8", windowsHide: true, timeout: 10_000 },
);
assert.equal(result.error, undefined);
assert.equal(result.status, 1);
assert.isNotEmpty(result.stderr.trim());
assert.notInclude(result.stderr, "CLIXML");
assert.notInclude(result.stderr, "<Objs");
if (target.endsWith("missing.txt")) {
assert.equal(result.stderr.trim(), `Path does not exist: ${target}`);
}
}
});

it.effect("does not advertise reveal on Windows when PowerShell is missing", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -392,7 +474,7 @@ it.effect.skipIf(windowsHost)(
const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
assert.equal(
decodedCommand,
"$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4' + '\"')",
"$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; try { $target = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\t3\\workspace\\media\\clip.mp4'; if (!(Test-Path -LiteralPath $target)) { throw ('Path does not exist: ' + $target) }; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + $target + '\"') } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }",
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
Expand Down
51 changes: 50 additions & 1 deletion apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface EditorLaunch {
readonly target: string;
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly waitForExit?: boolean;
}

interface ProcessLaunch {
Expand Down Expand Up @@ -589,7 +590,9 @@ export function buildFileExplorerRevealPowerShellSource(
explorerCommand: string,
target: string,
): string {
return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`;
// EncodedCommand serializes uncaught errors as CLIXML. Write just the
// exception message so the client can display it without PowerShell markup.
return `$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; try { $target = ${escapePowerShellStringLiteral(target)}; if (!(Test-Path -LiteralPath $target)) { throw ('Path does not exist: ' + $target) }; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + $target + '"') } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }`;
}

function fileExplorerRevealLaunch(
Expand All @@ -601,6 +604,7 @@ function fileExplorerRevealLaunch(
editor: "file-manager",
target,
command: powershellCommand,
waitForExit: true,
args: [
...POWERSHELL_ARGUMENTS_PREFIX,
encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)),
Expand Down Expand Up @@ -729,6 +733,51 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
}

const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env });
if (launch.waitForExit) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
// PowerShell is a short-lived launch helper. Its successful spawn does not
// mean that it managed to start Explorer; collect its result before replying.
const [stderr, exitCode] = yield* Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
shell: spawnCommand.shell,
windowsHide: true,
stdin: "ignore",
stdout: "ignore",
stderr: "pipe",
}),
);
return yield* Effect.all(
[handle.stderr.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode],
{ concurrency: "unbounded" },
);
}).pipe(
Effect.timeout("10 seconds"),
Effect.scoped,
Effect.mapError(
(cause) =>
new ExternalLauncherEditorSpawnError({
editor: launch.editor,
target: launch.target,
command: spawnCommand.command,
args: spawnCommand.args,
detail: cause.message,
cause,
}),
),
);
if (exitCode !== 0) {
return yield* new ExternalLauncherEditorSpawnError({
editor: launch.editor,
target: launch.target,
command: spawnCommand.command,
args: spawnCommand.args,
detail: stderr.trim() || `Launch helper exited with code ${exitCode}.`,
cause: { exitCode, stderr },
});
}
return;
}
yield* launchAndUnref(
{
command: spawnCommand.command,
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,13 @@ export class ExternalLauncherEditorSpawnError extends Schema.TaggedError<Externa
...ExternalLauncherSpawnFields,
editor: EditorId,
target: Schema.String,
detail: Schema.optional(Schema.String),
},
) {
override get message(): string {
if (this.detail) {
return `Failed to launch '${this.target}' in ${this.editor}: ${this.detail}`;
}
return `Failed to launch '${this.target}' in ${this.editor} with '${[this.command, ...this.args].join(" ")}'`;
}
}
Expand Down
Loading