History

Open VS Code on Windows via protocol URI

- Launch `explorer.exe` with a `vscode://file/...` target for Windows editor opens - Improve Windows spawn handling with `windowsHide` and protocol URI coverage

julius committed 7 months ago

Commit 8d257e21 parent 1218eff4 files · +86 −25Browse files

Unified · Split

4 files
  1. apps/server/spawned2.txt +2 −0
  2. apps/server/src/process/Layers/DesktopLauncher.test.ts +14 −23
  3. apps/server/src/process/Layers/DesktopLauncher.ts +67 −2
  4. apps/server/tmp-test.cmd +3 −0
apps/server/spawned2.txt+2−0
@@ -0,0 +1,2 @@
1started
2started
apps/server/src/process/Layers/DesktopLauncher.test.ts+14−23
@@ -29,6 +29,7 @@ interface SpawnCall { · 28 unmodified lines
29 readonly detached?: boolean | undefined;29 readonly detached?: boolean | undefined;
30 readonly shell?: boolean | string | undefined;30 readonly shell?: boolean | string | undefined;
31 readonly windowsVerbatimArguments?: boolean | undefined;31 readonly windowsVerbatimArguments?: boolean | undefined;
32 readonly windowsHide?: boolean | undefined;
32 readonly stdin?: unknown;33 readonly stdin?: unknown;
33 readonly stdout?: unknown;34 readonly stdout?: unknown;
34 readonly stderr?: unknown;35 readonly stderr?: unknown;
@@ -154,6 +155,7 @@ function spawnHarness( · 119 unmodified lines
154 detached: input.detached,155 detached: input.detached,
155 shell: input.shell,156 shell: input.shell,
156 windowsVerbatimArguments: input.windowsVerbatimArguments,157 windowsVerbatimArguments: input.windowsVerbatimArguments,
158 windowsHide: input.windowsHide,
157 stdin: input.stdin,159 stdin: input.stdin,
158 stdout: input.stdout,160 stdout: input.stdout,
159 stderr: input.stderr,161 stderr: input.stderr,
@@ -313,11 +315,11 @@ it.effect("openInEditor uses --goto for editors that support it", () => · 153 unmodified lines
313 }).pipe(Effect.provide(NodeFileSystem.layer)),315 }).pipe(Effect.provide(NodeFileSystem.layer)),
314);316);
315 317
316it.effect("openInEditor launches Windows batch shims through cmd.exe without shell mode", () =>318it.effect("openInEditor uses explorer with a VS Code protocol target on Windows", () =>
317 Effect.gen(function* () {319 Effect.gen(function* () {
318 const fs = yield* FileSystem.FileSystem;320 const fs = yield* FileSystem.FileSystem;
319 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" });321 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-vscode-win32-" });
320 yield* fs.writeFileString(`${dir}/code.cmd`, "@echo off\r\n");322 yield* fs.writeFileString(`${dir}/explorer.exe`, "MZ");
321 323
322 const calls: Array<SpawnCall> = [];324 const calls: Array<SpawnCall> = [];
323 const open = yield* runOpen(325 const open = yield* runOpen(
@@ -337,28 +339,17 @@ it.effect("openInEditor launches Windows batch shims through cmd.exe without she · 13 unmodified lines
337 editor: "vscode",339 editor: "vscode",
338 });340 });
339 341
340 assert.equal(calls.length, 1);342 assert.deepEqual(calls, [
341 assert.equal(calls[0]?.command.toLowerCase().includes("powershell"), true);343 {
342 assert.deepEqual(calls[0]?.args.slice(0, 5), [344 command: `${dir}/explorer.exe`,
343 "-NoProfile",345 args: ["vscode://file/C:/work/100%25%20real/file.ts:12:4"],
344 "-NonInteractive",346 detached: true,
345 "-ExecutionPolicy",347 shell: false,
346 "Bypass",348 stdin: "ignore",
347 "-EncodedCommand",349 stdout: "ignore",
350 stderr: "ignore",
351 },
348 ]);352 ]);
349 const encoded = calls[0]?.args[5];
350 assert.isString(encoded);
351 const decoded = decodePowerShellCommand(encoded!);
352 assert.equal(decoded.includes("Start-Process"), true);
353 assert.equal(decoded.toLowerCase().includes(`${dir}/code.cmd`.toLowerCase()), true);
354 assert.equal(decoded.includes("--goto"), true);
355 assert.equal(decoded.includes("100% real"), true);
356 assert.equal(calls[0]?.detached, true);
357 assert.equal(calls[0]?.windowsVerbatimArguments, undefined);
358 assert.equal(calls[0]?.shell, false);
359 assert.equal(calls[0]?.stdin, "ignore");
360 assert.equal(calls[0]?.stdout, "ignore");
361 assert.equal(calls[0]?.stderr, "ignore");
362 }).pipe(Effect.provide(NodeFileSystem.layer)),353 }).pipe(Effect.provide(NodeFileSystem.layer)),
363);354);
364 355
apps/server/src/process/Layers/DesktopLauncher.ts+67−2
@@ -9,6 +9,7 @@ · 8 unmodified lines
9 */9 */
10import OS from "node:os";10import OS from "node:os";
11import { spawn as spawnNodeChildProcess } from "node:child_process";11import { spawn as spawnNodeChildProcess } from "node:child_process";
12import { pathToFileURL } from "node:url";
12 13
13import { EDITORS, type EditorId } from "@t3tools/contracts";14import { EDITORS, type EditorId } from "@t3tools/contracts";
14import { Array, Effect, FileSystem, Layer, Option, Path, Scope } from "effect";15import { Array, Effect, FileSystem, Layer, Option, Path, Scope } from "effect";
@@ -38,6 +39,7 @@ export interface DetachedSpawnInput { · 23 unmodified lines
38 readonly detached?: boolean;39 readonly detached?: boolean;
39 readonly shell?: boolean;40 readonly shell?: boolean;
40 readonly windowsVerbatimArguments?: boolean;41 readonly windowsVerbatimArguments?: boolean;
42 readonly windowsHide?: boolean;
41 readonly stdin?: "ignore";43 readonly stdin?: "ignore";
42 readonly stdout?: "ignore";44 readonly stdout?: "ignore";
43 readonly stderr?: "ignore";45 readonly stderr?: "ignore";
@@ -119,6 +121,11 @@ const WSL_POWERSHELL_CANDIDATES = [ · 75 unmodified lines
119 "powershell.exe",121 "powershell.exe",
120 "pwsh.exe",122 "pwsh.exe",
121] as const;123] as const;
124const WINDOWS_EDITOR_URI_SCHEMES: Partial<Record<EditorId, string>> = {
125 vscode: "vscode",
126 "vscode-insiders": "vscode-insiders",
127 vscodium: "vscodium",
128};
122 129
123function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean {130function shouldUseGotoFlag(editor: (typeof EDITORS)[number], target: string): boolean {
124 return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target);131 return editor.supportsGoto && LINE_COLUMN_SUFFIX_PATTERN.test(target);
@@ -128,6 +135,24 @@ function stripWrappingQuotes(value: string): string { · 3 unmodified lines
128 return value.replace(/^"+|"+$/g, "");135 return value.replace(/^"+|"+$/g, "");
129}136}
130 137
138function splitLineColumnSuffix(target: string): {
139 readonly filePath: string;
140 readonly suffix: string;
141} {
142 const match = target.match(LINE_COLUMN_SUFFIX_PATTERN);
143 if (!match) {
144 return {
145 filePath: target,
146 suffix: "",
147 };
148 }
149
150 return {
151 filePath: target.slice(0, -match[0].length),
152 suffix: match[0],
153 };
154}
155
131function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {156function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
132 return env.PATH ?? env.Path ?? env.path ?? "";157 return env.PATH ?? env.Path ?? env.path ?? "";
133}158}
@@ -188,6 +213,10 @@ function quotePowerShellValue(value: string): string { · 54 unmodified lines
188 return `'${value.replaceAll("'", "''")}'`;213 return `'${value.replaceAll("'", "''")}'`;
189}214}
190 215
216function quotePowerShellArgument(value: string): string {
217 return `"\`"${value.replaceAll("`", "``").replaceAll('"', '`"')}\`""`;
218}
219
191function encodePowerShellCommand(command: string): string {220function encodePowerShellCommand(command: string): string {
192 return Buffer.from(command, "utf16le").toString("base64");221 return Buffer.from(command, "utf16le").toString("base64");
193}222}
@@ -218,6 +247,19 @@ function shouldPreferWindowsOpenerOnWsl(input: OpenExternalInput, runtime: Launc · 24 unmodified lines
218 return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target);247 return runtime.isWsl && !runtime.isInsideContainer && isUriLikeTarget(input.target);
219}248}
220 249
250function makeWindowsEditorProtocolTarget(editor: EditorId, target: string): string | undefined {
251 const scheme = WINDOWS_EDITOR_URI_SCHEMES[editor];
252 if (!scheme) return undefined;
253
254 const { filePath, suffix } = splitLineColumnSuffix(target);
255 const fileUrl = pathToFileURL(filePath).href;
256 const fileTarget = fileUrl.startsWith("file:///")
257 ? fileUrl.slice("file:///".length)
258 : fileUrl.replace(/^file:\/\//, "");
259
260 return `${scheme}://file/${fileTarget}${suffix}`;
261}
262
221function makeLaunchPlan(263function makeLaunchPlan(
222 command: string,264 command: string,
223 args: ReadonlyArray<string>,265 args: ReadonlyArray<string>,
@@ -309,8 +351,8 @@ function makePowerShellStartProcessArgs( · 85 unmodified lines
309 args: ReadonlyArray<string>,351 args: ReadonlyArray<string>,
310): ReadonlyArray<string> {352): ReadonlyArray<string> {
311 const argumentList =353 const argumentList =
312 args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellValue).join(", ")}` : "";354 args.length > 0 ? ` -ArgumentList ${args.map(quotePowerShellArgument).join(",")}` : "";
313 const command = `Start-Process -FilePath ${quotePowerShellValue(commandPath)}${argumentList} -WindowStyle Hidden`;355 const command = `Start ${quotePowerShellArgument(commandPath)}${argumentList} -WindowStyle Hidden`;
314 return [356 return [
315 "-NoProfile",357 "-NoProfile",
316 "-NonInteractive",358 "-NonInteractive",
@@ -336,6 +378,8 @@ function makeWindowsExplorerPlan(input: OpenExternalInput): LaunchPlan { · 19 unmodified lines
336 return makeLaunchPlan("explorer", [input.target], {378 return makeLaunchPlan("explorer", [input.target], {
337 wait: false,379 wait: false,
338 allowNonzeroExitCode: false,380 allowNonzeroExitCode: false,
381 detached: true,
382 stdio: "ignore",
339 shell: false,383 shell: false,
340 });384 });
341}385}
@@ -408,6 +452,9 @@ function resolveSpawnInput( · 66 unmodified lines
408 args: makePowerShellStartProcessArgs(resolvedCommand.path, plan.args),452 args: makePowerShellStartProcessArgs(resolvedCommand.path, plan.args),
409 ...(plan.detached !== undefined ? { detached: plan.detached } : {}),453 ...(plan.detached !== undefined ? { detached: plan.detached } : {}),
410 ...(plan.shell !== undefined ? { shell: plan.shell } : {}),454 ...(plan.shell !== undefined ? { shell: plan.shell } : {}),
455 ...(runtime.platform === "win32"
456 ? { windowsVerbatimArguments: true, windowsHide: true }
457 : {}),
411 ...(plan.stdio === "ignore"458 ...(plan.stdio === "ignore"
412 ? {459 ? {
413 stdin: "ignore" as const,460 stdin: "ignore" as const,
@@ -417,6 +464,7 @@ function resolveSpawnInput( · 3 unmodified lines
417 : {}),464 : {}),
418 };465 };
419 }466 }
467 const windowsHide = runtime.platform === "win32" && plan.detached ? true : undefined;
420 return {468 return {
421 command: resolvedCommand.usesCmdWrapper469 command: resolvedCommand.usesCmdWrapper
422 ? resolveWindowsCommandShell(runtime.env)470 ? resolveWindowsCommandShell(runtime.env)
@@ -426,6 +474,7 @@ function resolveSpawnInput( · 3 unmodified lines
426 : [...plan.args],474 : [...plan.args],
427 ...(plan.detached !== undefined ? { detached: plan.detached } : {}),475 ...(plan.detached !== undefined ? { detached: plan.detached } : {}),
428 ...(plan.shell !== undefined ? { shell: plan.shell } : {}),476 ...(plan.shell !== undefined ? { shell: plan.shell } : {}),
477 ...(windowsHide ? { windowsHide } : {}),
429 ...(resolvedCommand.usesCmdWrapper ? { windowsVerbatimArguments: true } : {}),478 ...(resolvedCommand.usesCmdWrapper ? { windowsVerbatimArguments: true } : {}),
430 ...(plan.stdio === "ignore"479 ...(plan.stdio === "ignore"
431 ? {480 ? {
@@ -525,6 +574,7 @@ function defaultSpawnDetached( · 93 unmodified lines
525 spawnNodeChildProcess(input.command, [...input.args], {574 spawnNodeChildProcess(input.command, [...input.args], {
526 ...(input.detached !== undefined ? { detached: input.detached } : {}),575 ...(input.detached !== undefined ? { detached: input.detached } : {}),
527 ...(input.shell !== undefined ? { shell: input.shell } : {}),576 ...(input.shell !== undefined ? { shell: input.shell } : {}),
577 ...(input.windowsHide !== undefined ? { windowsHide: input.windowsHide } : {}),
528 ...(input.windowsVerbatimArguments !== undefined578 ...(input.windowsVerbatimArguments !== undefined
529 ? { windowsVerbatimArguments: input.windowsVerbatimArguments }579 ? { windowsVerbatimArguments: input.windowsVerbatimArguments }
530 : {}),580 : {}),
@@ -891,6 +941,21 @@ export const make = Effect.fn("makeDesktopLauncher")(function* ( · 360 unmodified lines
891 return yield* new DesktopLauncherUnknownEditorError({ editor: input.editor });941 return yield* new DesktopLauncherUnknownEditorError({ editor: input.editor });
892 }942 }
893 943
944 const windowsEditorProtocolTarget =
945 runtime.platform === "win32"
946 ? makeWindowsEditorProtocolTarget(input.editor, input.cwd)
947 : undefined;
948 if (windowsEditorProtocolTarget) {
949 return yield* runFirstAvailablePlan(
950 [makeWindowsExplorerPlan({ target: windowsEditorProtocolTarget })],
951 {
952 operation: "openInEditor",
953 target: input.cwd,
954 editor: input.editor,
955 },
956 );
957 }
958
894 if (editor.command) {959 if (editor.command) {
895 return yield* runFirstAvailablePlan(960 return yield* runFirstAvailablePlan(
896 [961 [
apps/server/tmp-test.cmd+3−0
@@ -0,0 +1,3 @@
1@echo off
2echo started >> spawned2.txt
3