diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 0f73ab4fb9c53..ba759a1ca7509 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -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"; @@ -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]> = []; + 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"), diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index aea3e2d1472f9..3b1ed43142ac0 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -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, @@ -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 { @@ -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, ) { 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); }); @@ -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({ @@ -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), ), diff --git a/apps/server/src/process/windowsVerbatimSpawn.ts b/apps/server/src/process/windowsVerbatimSpawn.ts new file mode 100644 index 0000000000000..15075c22455b9 --- /dev/null +++ b/apps/server/src/process/windowsVerbatimSpawn.ts @@ -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, +) => Effect.Effect; + +const spawnDetachedVerbatim: WindowsVerbatimSpawner = (command, args) => + Effect.callback((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( + "t3/process/WindowsVerbatimSpawn", + { defaultValue: () => spawnDetachedVerbatim }, +);