Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
68 changes: 68 additions & 0 deletions apps/server/src/process/externalLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { resolveEditorCommand } from "@t3tools/shared/editor";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import * as ExternalLauncher from "./externalLauncher.ts";
import { WindowsVerbatimSpawn } from "./windowsVerbatimSpawn.ts";

// Windows drive letters break mocked POSIX PATH splitting; these tests also require /bin/sh stubs.
const windowsHost = HostProcess.Platform.defaultValue() === "win32";
Expand Down Expand Up @@ -392,6 +393,73 @@ it.effect.skipIf(windowsHost)(
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

const STOCK_FOLDER_VERB =
"\r\nHKEY_CLASSES_ROOT\\Directory\\shell\r\n (Default) REG_SZ none\r\n\r\n";

it.effect.each([
["selects the file with Explorer on stock Windows", { stdout: STOCK_FOLDER_VERB }, true],
[
"keeps the PowerShell reveal for a replacement file manager",
{ stdout: " (Default) REG_SZ open\r\n" },
false,
],
["keeps the PowerShell reveal when the registry lookup fails", { exitCode: 1 }, false],
] as const)("%s", ([, registry, native]) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" });
yield* fileSystem.writeFileString(path.join(binDir, "explorer.EXE"), "");
const spawned: ChildProcess.StandardCommand[] = [];
const verbatim: Array<readonly [string, ReadonlyArray<string>]> = [];
const exit = yield* Effect.gen(function* () {
const launcher = yield* ExternalLauncher.ExternalLauncher;
return yield* launcher
.launchEditor({
editor: "file-manager",
cwd: "C:/Users/t3/my project/a,b.ts",
reveal: true,
})
.pipe(Effect.exit);
}).pipe(
Effect.provideService(WindowsVerbatimSpawn, (command, args) =>
Effect.sync(() => verbatim.push([command, args])),
),
Effect.provide(
layerTest({
platform: "win32",
env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD", SYSTEMROOT: "C:\\Windows" },
onSpawn: (command) => spawned.push(command),
spawnResult: (command) => (command.command.endsWith("reg.exe") ? registry : undefined),
}),
),
);

assert.deepEqual(
spawned.map((command) => command.command),
["C:\\Windows\\System32\\reg.exe"],
);
if (native) {
assert.isTrue(Exit.isSuccess(exit));
assert.deepEqual(verbatim, [
["C:\\Windows\\explorer.exe", ['/select,"C:\\Users\\t3\\my project\\a,b.ts"']],
]);
} else {
assert.deepEqual(verbatim, []);
// The PowerShell launch is reached; this host has no powershell.exe to run it.
assert.isTrue(Exit.isFailure(exit));
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause);
assert.instanceOf(error, ExternalLauncher.ExternalLauncherCommandNotFoundError);
assert.equal(
(error as ExternalLauncher.ExternalLauncherCommandNotFoundError).command,
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
);
}
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

for (const wsl of [false, true]) {
it.effect.skipIf(windowsHost)(
"selects a file over D-Bus on " + (wsl ? "WSLg without interop PowerShell" : "Linux"),
Expand Down
82 changes: 72 additions & 10 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import { makeStaleWhileRevalidate } from "../environment/staleWhileRevalidate.ts";
import { WindowsVerbatimSpawn } from "./windowsVerbatimSpawn.ts";
export {
ExternalLauncherError,
ExternalLauncherBrowserSpawnError,
Expand Down Expand Up @@ -184,8 +185,12 @@ function escapePowerShellStringLiteral(input: string): string {
return `'${input.replaceAll("'", "''")}'`;
}

function resolveWindowsRoot(env: NodeJS.ProcessEnv): string {
return env.SYSTEMROOT || env.windir || String.raw`C:\Windows`;
}

function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string {
return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
return `${resolveWindowsRoot(env)}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
}

function resolveWslPowerShellPath(): string {
Expand Down Expand Up @@ -665,18 +670,67 @@ const tryRevealLinuxItem = Effect.fn("externalLauncher.tryRevealLinuxItem")(
}),
);

// Stock Windows answers "none"; any other verb may be a replacement file manager, which only
// the PowerShell reveal honors. Reading stdout is about 600 ms faster than spawner.string here.
const hasStockWindowsFolderVerb = Effect.fn("externalLauncher.hasStockWindowsFolderVerb")(
function* () {
const env = yield* readBrowserLaunchEnv;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const handle = yield* spawner.spawn(
ChildProcess.make(
`${resolveWindowsRoot(env)}\\System32\\reg.exe`,
["query", "HKCR\\Directory\\shell", "/ve"],
{ stdin: "ignore", stderr: "ignore" },
),
);
const [stdout, exitCode] = yield* Effect.all(
[handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode],
{ concurrency: "unbounded" },
);
return exitCode === 0 && /REG_SZ\s+none\s*$/im.test(stdout);
},
Effect.scoped,
Effect.timeout("2 seconds"),
Effect.orElseSucceed(() => false),
);

// Explorer parses /select itself, so it needs the argument verbatim rather than Node's quoting.
const revealWindowsItem = Effect.fn("externalLauncher.revealWindowsItem")(function* (
target: string,
) {
const command = `${resolveWindowsRoot(yield* readBrowserLaunchEnv)}\\explorer.exe`;
const args = [`/select,"${normalizeWindowsFileManagerPath(target)}"`];
yield* (yield* WindowsVerbatimSpawn)(command, args).pipe(
Effect.mapError(
(cause) =>
new ExternalLauncherEditorSpawnError({
editor: "file-manager",
target,
command,
args,
cause,
}),
),
);
});

const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* (
input: LaunchEditorInput,
hasStockFolderVerb: Effect.Effect<boolean>,
) {
const launch = yield* resolveEditorLaunch(input);
if (
input.editor === "file-manager" &&
input.reveal === true &&
launch.command === "xdg-open" &&
(yield* HostProcess.Platform) === "linux" &&
(yield* tryRevealLinuxItem(launch.target))
) {
return;
if (input.editor === "file-manager" && input.reveal === true) {
const platform = yield* HostProcess.Platform;
if (
platform === "linux" &&
launch.command === "xdg-open" &&
(yield* tryRevealLinuxItem(launch.target))
) {
return;
}
if (platform === "win32" && (yield* hasStockFolderVerb)) {
return yield* revealWindowsItem(launch.target);
}
}
yield* launchEditorProcess(launch);
});
Expand Down Expand Up @@ -837,6 +891,12 @@ export const make = Effect.gen(function* () {
),
EDITOR_DISCOVERY_CACHE_TTL,
);
const cachedStockWindowsFolderVerb = yield* makeStaleWhileRevalidate(
hasStockWindowsFolderVerb().pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
EDITOR_DISCOVERY_CACHE_TTL,
);

// Forwarders retain the underlying launcher and discovery spans.
return ExternalLauncher.of({
Expand All @@ -854,7 +914,9 @@ export const make = Effect.gen(function* () {
),
launchEditor: Effect.fnUntraced(
function* (input) {
return yield* provideCommandResolutionServices(launchEditor(input));
return yield* provideCommandResolutionServices(
launchEditor(input, cachedStockWindowsFolderVerb),
);
},
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/process/windowsVerbatimSpawn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// @effect-diagnostics nodeBuiltinImport:off - Effect's spawner has no windowsVerbatimArguments option.
import * as NodeChildProcess from "node:child_process";

import * as Context from "effect/Context";
import * as Effect from "effect/Effect";

export type WindowsVerbatimSpawner = (
command: string,
args: ReadonlyArray<string>,
) => Effect.Effect<void, Error>;

const spawnDetachedVerbatim: WindowsVerbatimSpawner = (command, args) =>
Effect.callback<void, Error>((resume) => {
const child = NodeChildProcess.spawn(command, args, {
detached: true,
stdio: "ignore",
windowsVerbatimArguments: true,
});
child.once("error", (error) => resume(Effect.fail(error)));
child.once("spawn", () => {
child.unref();
resume(Effect.void);
});
});

export const WindowsVerbatimSpawn = Context.Reference<WindowsVerbatimSpawner>(
"t3/process/WindowsVerbatimSpawn",
{ defaultValue: () => spawnDetachedVerbatim },
);
Loading