Skip to content
Source
Sign inSign up

externalLauncher.ts

864 lines28.9 KB

1import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
2import * as NodePath from "@effect/platform-node/NodePath";
3import {
4 EDITORS,
5 ExternalLauncherError,
6 ExternalLauncherBrowserSpawnError,
7 ExternalLauncherCommandNotFoundError,
8 ExternalLauncherEditorSpawnError,
9 ExternalLauncherUnknownEditorError,
10 ExternalLauncherUnsupportedEditorError,
11 ExternalLauncherUnsupportedTargetError,
12 type EditorId,
13 type FileManagerRevealKind,
14 type LaunchEditorInput,
15} from "@t3tools/contracts";
16import { resolveEditorCommand } from "@t3tools/shared/editor";
17import * as HostProcess from "@t3tools/shared/HostProcess";
18import {
19 isCommandAvailable,
20 resolveSpawnCommand,
21 withPathDirectoryListings,
22} from "@t3tools/shared/shell";
23import * as Config from "effect/Config";
24import * as Context from "effect/Context";
25import * as Effect from "effect/Effect";
26import * as Base64 from "effect/encoding/Base64";
27import * as FileSystem from "effect/FileSystem";
28import * as Layer from "effect/Layer";
29import * as Option from "effect/Option";
30import * as Path from "effect/Path";
31import * as Schema from "effect/Schema";
32import * as Stream from "effect/Stream";
33import * as ChildProcess from "effect/process/ChildProcess";
34import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
35​
36import { makeStaleWhileRevalidate } from "../environment/staleWhileRevalidate.ts";
37export {
38 ExternalLauncherError,
39 ExternalLauncherBrowserSpawnError,
40 ExternalLauncherCommandNotFoundError,
41 ExternalLauncherEditorSpawnError,
42 ExternalLauncherUnknownEditorError,
43 ExternalLauncherUnsupportedEditorError,
44 ExternalLauncherUnsupportedTargetError,
45} from "@t3tools/contracts";
46export type { LaunchEditorInput };
47interface EditorLaunch {
48 readonly editor: EditorId;
49 readonly target: string;
50 readonly command: string;
51 readonly args: ReadonlyArray<string>;
52 readonly detached?: boolean;
54​
55interface ProcessLaunch {
56 readonly command: string;
57 readonly args: ReadonlyArray<string>;
58 readonly options: ChildProcess.CommandOptions;
60​
61interface TargetPathAndPosition {
62 readonly path: string;
63 readonly line: string;
64 readonly column: Option.Option<string>;
66​
67// cmd shims reparse %* after escaping is gone; newlines and quotes can expose shell operators.
68const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/;
69const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
70const POWERSHELL_ARGUMENTS_PREFIX = [
71 "-NoProfile",
72 "-NonInteractive",
73 "-ExecutionPolicy",
74 "Bypass",
75 "-EncodedCommand",
76] as const;
77​
78// Detached Windows PowerShell exits 0 without executing; only WSL interop keeps detached launches.
79const WINDOWS_POWERSHELL_DETACHED = false;
80​
81const DETACHED_IGNORE_STDIO_OPTIONS = {
82 detached: true,
83 stdin: "ignore",
84 stdout: "ignore",
85 stderr: "ignore",
86} as const satisfies ChildProcess.CommandOptions;
87​
88const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>
89 Object.fromEntries(
90 Object.entries(input).flatMap(([key, value]) =>
91 Option.match(value, {
92 onNone: () => [],
93 onSome: (resolved) => [[key, resolved]],
94 }),
95 ),
96 );
97​
98const BrowserLaunchEnvConfig = Config.all({
99 SYSTEMROOT: Config.String("SYSTEMROOT").pipe(Config.option),
100 windir: Config.String("windir").pipe(Config.option),
101 WSL_DISTRO_NAME: Config.String("WSL_DISTRO_NAME").pipe(Config.option),
102 WSL_INTEROP: Config.String("WSL_INTEROP").pipe(Config.option),
103 SSH_CONNECTION: Config.String("SSH_CONNECTION").pipe(Config.option),
104 SSH_TTY: Config.String("SSH_TTY").pipe(Config.option),
105 container: Config.String("container").pipe(Config.option),
106 DISPLAY: Config.String("DISPLAY").pipe(Config.option),
107 WAYLAND_DISPLAY: Config.String("WAYLAND_DISPLAY").pipe(Config.option),
108}).pipe(Config.map(compactEnv));
109​
110const CommandLookupEnvConfig = Config.all({
111 PATH: Config.String("PATH").pipe(Config.option),
112 Path: Config.String("Path").pipe(Config.option),
113 path: Config.String("path").pipe(Config.option),
114 PATHEXT: Config.String("PATHEXT").pipe(Config.option),
115 HOME: Config.String("HOME").pipe(Config.option),
116 LOCALAPPDATA: Config.String("LOCALAPPDATA").pipe(Config.option),
117 ProgramFiles: Config.String("ProgramFiles").pipe(Config.option),
118 ProgramW6432: Config.String("ProgramW6432").pipe(Config.option),
119 XDG_DATA_HOME: Config.String("XDG_DATA_HOME").pipe(Config.option),
120 "ProgramFiles(x86)": Config.String("ProgramFiles(x86)").pipe(Config.option),
121}).pipe(Config.map(compactEnv));
122​
123const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
124const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
125​
126function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> {
127 const match = TARGET_WITH_POSITION_PATTERN.exec(target);
128 if (!match?.[1] || !match[2]) {
129 return Option.none();
130 }
131​
132 return Option.some({
133 path: match[1],
134 line: match[2],
135 column: Option.fromUndefinedOr(match[3]),
136 });
138​
139function resolveCommandEditorArgs(
140 editor: (typeof EDITORS)[number],
141 target: string,
142 isDirectory: boolean,
143 projectRoot: string | undefined,
144): ReadonlyArray<string> {
145 const parsedTarget = parseTargetPathAndPosition(target);
146​
147 switch (editor.launchStyle) {
148 case "direct-path":
149 return [target];
150 case "goto":
151 if (Option.isSome(parsedTarget)) return ["--goto", target];
152 // Without a window flag, VS Code's window settings may replace the user's last active window.
153 return isDirectory ? ["--new-window", target] : [target];
154 case "line-column":
155 return Option.match(parsedTarget, {
156 onNone: () => [target],
157 onSome: ({ path, line, column }) => [
158 // Only positioned targets get the root: a folder target would open as a second project.
159 ...(projectRoot === undefined ? [] : [projectRoot]),
160 "--line",
161 line,
162 // JetBrains IDEs count `--column` from 0; targets count columns from 1.
163 ...Option.match(column, {
164 onNone: () => [],
165 onSome: (value) => ["--column", String(Math.max(0, Number(value) - 1))],
166 }),
167 path,
168 ],
169 });
170 }
172​
173function encodeUtf16LeBase64(input: string): string {
174 const bytes = new Uint8Array(input.length * 2);
175 for (let index = 0; index < input.length; index += 1) {
176 const code = input.charCodeAt(index);
177 bytes[index * 2] = code & 0xff;
178 bytes[index * 2 + 1] = code >>> 8;
179 }
180 return Base64.encode(bytes);
182​
183function escapePowerShellStringLiteral(input: string): string {
184 return `'${input.replaceAll("'", "''")}'`;
186​
187function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string {
188 return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
190​
191function resolveWslPowerShellPath(): string {
192 return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
194​
195// WSL automount roots vary; resolve PowerShell via PATH to match launchEditor's availability check.
196const WSL_POWERSHELL_COMMAND = "powershell.exe";
197​
198function shouldUseWindowsHostFromWsl(
199 platform: NodeJS.Platform,
200 env: NodeJS.ProcessEnv = {},
201): boolean {
202 return (
203 platform === "linux" &&
204 (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) &&
205 env.SSH_CONNECTION === undefined &&
206 env.SSH_TTY === undefined &&
207 env.container === undefined
208 );
210​
211function resolveWindowsBrowserLaunch(
212 target: string,
213 command: string,
214 detached: boolean,
215): ProcessLaunch {
216 const encodedCommand = encodeUtf16LeBase64(
217 `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`,
218 );
219 return {
220 command,
221 args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand],
222 options: {
223 detached,
224 shell: false,
225 stdin: "ignore",
226 stdout: "ignore",
227 stderr: "ignore",
228 },
229 };
231​
232function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean {
233 return [env.DISPLAY, env.WAYLAND_DISPLAY].some(
234 (value) => value !== undefined && value.trim().length > 0,
235 );
237​
238function fileManagerCommandForPlatform(
239 platform: NodeJS.Platform,
240 env: NodeJS.ProcessEnv,
241): string | undefined {
242 switch (platform) {
243 case "darwin":
244 return "open";
245 case "win32":
246 return "explorer";
247 default:
248 if (shouldUseWindowsHostFromWsl(platform, env)) {
249 return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined;
250 }
251 return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined;
252 }
254​
255// xdg-open needs an inode/directory handler or detached launches silently fail.
256// Bound xdg-mime below discovery's timeout so a hung probe drops only the file manager.
257const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds";
258​
259const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")(
260 function* (
261 env: NodeJS.ProcessEnv,
262 ): Effect.fn.Return<
263 boolean,
264 never,
265 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
266 > {
267 if (!(yield* isCommandAvailable("xdg-mime", { env }))) {
268 return false;
269 }
270​
271 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
272 return yield* spawner
273 .spawn(
274 ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], {
275 stdin: "ignore",
276 stderr: "ignore",
277 }),
278 )
279 .pipe(
280 Effect.flatMap((handle) =>
281 Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], {
282 concurrency: "unbounded",
283 }),
284 ),
285 Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0),
286 Effect.scoped,
287 Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT),
288 Effect.orElseSucceed(() => false),
289 );
290 },
291);
292​
293const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")(
294 function* (
295 command: string,
296 env: NodeJS.ProcessEnv,
297 ): Effect.fn.Return<
298 boolean,
299 never,
300 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
301 > {
302 if (!(yield* isCommandAvailable(command, { env }))) {
303 return false;
304 }
305 return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env));
306 },
307);
308​
309// WSL may lack Explorer on PATH or a distro name while WSLg still offers a Linux file manager.
310const resolveUsableFileManagerCommand = Effect.fn(
311 "externalLauncher.resolveUsableFileManagerCommand",
312)(function* (
313 platform: NodeJS.Platform,
314 env: NodeJS.ProcessEnv,
315): Effect.fn.Return<
316 string | undefined,
317 never,
318 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
319> {
320 const command = fileManagerCommandForPlatform(platform, env);
321 if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) {
322 return command;
323 }
324 if (
325 shouldUseWindowsHostFromWsl(platform, env) &&
326 hasGraphicalLinuxSession(env) &&
327 (yield* isUsableFileManagerCommand("xdg-open", env))
328 ) {
329 return "xdg-open";
330 }
331 return undefined;
332});
333​
334// Reveal uses PowerShell on Windows/WSL, so probe it separately from Explorer availability.
335// Linux relies on the file-manager gate's handler probe; WSL re-probes because the gate may use Explorer.
336const fileManagerRevealKindForPlatform = Effect.fn(
337 "externalLauncher.fileManagerRevealKindForPlatform",
338)(function* (
339 platform: NodeJS.Platform,
340 env: NodeJS.ProcessEnv,
341): Effect.fn.Return<
342 FileManagerRevealKind | undefined,
343 never,
344 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
345> {
346 if (platform === "darwin") return "finder";
347 if (platform === "win32") {
348 return (yield* isCommandAvailable(resolvePowerShellPath(env), { env }))
349 ? "file-explorer"
350 : undefined;
351 }
352 if (shouldUseWindowsHostFromWsl(platform, env)) {
353 if (
354 env.WSL_DISTRO_NAME?.trim() &&
355 (yield* isCommandAvailable("explorer.exe", { env })) &&
356 (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env }))
357 ) {
358 return "file-explorer";
359 }
360 return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))
361 ? "files"
362 : undefined;
363 }
364 return hasGraphicalLinuxSession(env) ? "files" : undefined;
365});
366​
367function resolveWslFileManagerPath(target: string, distroName: string): string {
368 const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\");
369 return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`;
371​
372function buildBrowserLaunch(
373 target: string,
374 platform: NodeJS.Platform,
375 env: NodeJS.ProcessEnv = {},
376): ProcessLaunch {
377 if (platform === "darwin") {
378 return {
379 command: "open",
380 args: [target],
381 options: DETACHED_IGNORE_STDIO_OPTIONS,
382 };
383 }
384​
385 if (platform === "win32") {
386 return resolveWindowsBrowserLaunch(
387 target,
388 resolvePowerShellPath(env),
389 WINDOWS_POWERSHELL_DETACHED,
390 );
391 }
392​
393 if (shouldUseWindowsHostFromWsl(platform, env)) {
394 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath(), true);
395 }
396​
397 return {
398 command: "xdg-open",
399 args: [target],
400 options: DETACHED_IGNORE_STDIO_OPTIONS,
401 };
403​
404const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* (
405 platform: NodeJS.Platform,
406 env: NodeJS.ProcessEnv,
407): Effect.fn.Return<
408 ReadonlyArray<EditorId>,
409 never,
410 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
411> {
412 const available: EditorId[] = [];
413​
414 for (const editor of EDITORS) {
415 if (editor.commands === null) {
416 if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) {
417 available.push(editor.id);
418 }
419 continue;
420 }
421​
422 const command = yield* resolveEditorCommand(editor, env);
423 if (Option.isSome(command)) {
424 available.push(editor.id);
425 }
426 }
427​
428 return available;
429});
430​
431const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* (
432 target: string,
433) {
434 const platform = yield* HostProcess.Platform;
435 const env = yield* readBrowserLaunchEnv;
436 return buildBrowserLaunch(target, platform, env);
437});
438​
439const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {
440 const platform = yield* HostProcess.Platform;
441 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
442 return yield* buildAvailableEditors(platform, env).pipe(withPathDirectoryListings);
443});
444​
445const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")(
446 function* () {
447 const platform = yield* HostProcess.Platform;
448 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
449 return yield* fileManagerRevealKindForPlatform(platform, env);
450 },
451);
452​
453const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds";
454​
455export class ExternalLauncher extends Context.Service<
456 ExternalLauncher,
457 {
458 readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>;
459 /** Requires file-manager in resolveAvailableEditors; Linux relies on its directory-handler probe. */
460 readonly resolveFileManagerRevealKind: () => Effect.Effect<FileManagerRevealKind | undefined>;
461 readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;
462 readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect<void, ExternalLauncherError>;
463 }
464>()("t3/process/externalLauncher") {}
465​
466const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
467 input: LaunchEditorInput,
468): Effect.fn.Return<
469 EditorLaunch,
470 ExternalLauncherError,
471 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
472> {
473 const platform = yield* HostProcess.Platform;
474 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
475 yield* Effect.annotateCurrentSpan({
476 "externalLauncher.editor": input.editor,
477 "externalLauncher.cwd": input.cwd,
478 "externalLauncher.platform": platform,
479 });
480 const editorDef = EDITORS.find((editor) => editor.id === input.editor);
481 if (!editorDef) {
482 return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor });
483 }
484​
485 if (editorDef.commands) {
486 const resolved = yield* resolveEditorCommand(editorDef, env);
487 if (Option.isNone(resolved)) {
488 return yield* new ExternalLauncherCommandNotFoundError({
489 editor: editorDef.id,
490 command: editorDef.commands[0],
491 });
492 }
493 const { command, baseArgs } = resolved.value;
494 const isDirectory =
495 editorDef.launchStyle === "goto" &&
496 (yield* (yield* FileSystem.FileSystem).stat(input.cwd).pipe(
497 Effect.map((info) => info.type === "Directory"),
498 Effect.orElseSucceed(() => false),
499 ));
500 return {
501 editor: editorDef.id,
502 target: input.cwd,
503 command,
504 args: [
505 ...baseArgs,
506 ...resolveCommandEditorArgs(editorDef, input.cwd, isDirectory, input.projectRoot),
507 ],
508 };
509 }
510​
511 if (editorDef.id !== "file-manager") {
512 return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
513 }
514​
515 const command = yield* resolveUsableFileManagerCommand(platform, env);
516 if (command === undefined) {
517 return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
518 }
519​
520 const target = expandHomePath(input.cwd, yield* HostProcess.HomeDirectory);
521 if (input.reveal === true) {
522 return yield* resolveFileManagerRevealLaunch(target, platform, env, command);
523 }
524​
525 return {
526 editor: editorDef.id,
527 target,
528 command,
529 args:
530 command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined
531 ? [resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME)]
532 : platform === "win32"
533 ? // Explorer interprets / as a switch and silently opens a fallback folder.
534 [normalizeWindowsFileManagerPath(target)]
535 : [target],
536 };
537});
538​
539// A replacement manager registers a default Directory shell verb; stock Windows
540// uses "none". Explorer needs its /select argument passed verbatim through
541// PowerShell because Node's argument quoting changes how Explorer parses it.
542function buildFileManagerRevealPowerShellSource(target: string, parent: string): string {
543 const literal = escapePowerShellStringLiteral(target);
544 return `$ProgressPreference = 'SilentlyContinue'; $folderVerb = (Get-ItemProperty -LiteralPath 'Registry::HKEY_CLASSES_ROOT\\Directory\\shell' -ErrorAction SilentlyContinue).'(default)'; if ($folderVerb -and $folderVerb -ne 'none') { Invoke-Item -LiteralPath ${escapePowerShellStringLiteral(parent)} } else { Start-Process 'explorer.exe' -ArgumentList ('/select,"' + ${literal} + '"') }`;
546​
547function powerShellFileManagerLaunch(
548 target: string,
549 source: string,
550 powershellCommand: string,
551): EditorLaunch {
552 return {
553 editor: "file-manager",
554 target,
555 command: powershellCommand,
556 args: [...POWERSHELL_ARGUMENTS_PREFIX, encodeUtf16LeBase64(source)],
557 };
559​
560function normalizeWindowsFileManagerPath(target: string): string {
561 return target.replaceAll("/", "\\");
563​
564const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch")(function* (
565 target: string,
566 platform: NodeJS.Platform,
567 env: NodeJS.ProcessEnv,
568 command: string,
569): Effect.fn.Return<
570 EditorLaunch,
571 never,
572 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
573> {
574 if (platform === "darwin") {
575 return { editor: "file-manager", target, command: "open", args: ["-R", target] };
576 }
577​
578 if (platform === "win32") {
579 const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32));
580 const windowsTarget = normalizeWindowsFileManagerPath(target);
581 return {
582 ...powerShellFileManagerLaunch(
583 target,
584 buildFileManagerRevealPowerShellSource(windowsTarget, path.dirname(windowsTarget)),
585 resolvePowerShellPath(env),
586 ),
587 detached: WINDOWS_POWERSHELL_DETACHED,
588 };
589 }
590​
591 if (
592 command === "explorer.exe" &&
593 shouldUseWindowsHostFromWsl(platform, env) &&
594 env.WSL_DISTRO_NAME !== undefined
595 ) {
596 const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME);
597 if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) {
598 // WSL names may contain quotes that Explorer's /select cannot encode.
599 // Open the parent through the shell so replacement managers still apply.
600 const path = yield* Path.Path;
601 // .NET path parsing rejects characters that are legal in WSL filenames.
602 const parent = resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME);
603 const source = explorerTarget.includes('"')
604 ? `Invoke-Item -LiteralPath ${escapePowerShellStringLiteral(parent)}`
605 : buildFileManagerRevealPowerShellSource(explorerTarget, parent);
606 return powerShellFileManagerLaunch(target, source, WSL_POWERSHELL_COMMAND);
607 }
608 if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) {
609 const path = yield* Path.Path;
610 return { editor: "file-manager", target, command: "xdg-open", args: [path.dirname(target)] };
611 }
612 const path = yield* Path.Path;
613 return {
614 editor: "file-manager",
615 target,
616 command: "explorer.exe",
617 args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)],
618 };
619 }
620​
621 const path = yield* Path.Path;
622 return { editor: "file-manager", target, command, args: [path.dirname(target)] };
623});
624​
625const tryRevealLinuxItem = Effect.fn("externalLauncher.tryRevealLinuxItem")(
626 function* (target: string) {
627 const env = yield* readCommandLookupEnv;
628 if (!(yield* isCommandAvailable("gdbus", { env }))) return false;
629​
630 const path = yield* Path.Path;
631 const uri = yield* path.toFileUrl(target);
632 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
633 const exitCode = yield* spawner.exitCode(
634 ChildProcess.make(
635 "gdbus",
636 [
637 "call",
638 "--session",
639 "--dest",
640 "org.freedesktop.FileManager1",
641 "--object-path",
642 "/org/freedesktop/FileManager1",
643 "--method",
644 "org.freedesktop.FileManager1.ShowItems",
645 // File URLs percent-encode the double quotes and backslashes GVariant would interpret.
646 `["${uri.href}"]`,
647 "",
648 ],
649 {
650 stdin: "ignore",
651 stdout: "ignore",
652 stderr: "ignore",
653 // Timeout waits for scoped cleanup, even if the helper ignores SIGTERM.
654 forceKillAfter: "1 second",
655 },
656 ),
657 );
658 return exitCode === 0;
659 },
660 Effect.timeout("2 seconds"),
661 Effect.catchTags({
662 BadArgument: () => Effect.succeed(false),
663 PlatformError: () => Effect.succeed(false),
664 TimeoutError: () => Effect.succeed(false),
665 }),
666);
667​
668const launchEditor = Effect.fn("externalLauncher.launchEditor")(function* (
669 input: LaunchEditorInput,
670) {
671 const launch = yield* resolveEditorLaunch(input);
672 if (
673 input.editor === "file-manager" &&
674 input.reveal === true &&
675 launch.command === "xdg-open" &&
676 (yield* HostProcess.Platform) === "linux" &&
677 (yield* tryRevealLinuxItem(launch.target))
678 ) {
679 return;
680 }
681 yield* launchEditorProcess(launch);
682});
683​
684const GRAPHICAL_SESSION_ENV_KEYS: ReadonlySet<string> = new Set([
685 "DISPLAY",
686 "WAYLAND_DISPLAY",
687 "XAUTHORITY",
688]);
689​
690// The user manager's environment over D-Bus. `systemctl show-environment` would
691// shell-quote values with spaces as $'...', while this JSON carries them raw.
692const decodeManagerEnvironment = Schema.decodeUnknownEffect(
693 Schema.fromJsonString(Schema.Struct({ data: Schema.Array(Schema.String) })),
694);
695​
696// Boot services miss the desktop environment imported later into the systemd user manager.
697const readGraphicalSessionEnv = Effect.fn("externalLauncher.readGraphicalSessionEnv")(
698 function* () {
699 const hostEnv = yield* HostProcess.Environment;
700 if ((yield* HostProcess.Platform) !== "linux" || hasGraphicalLinuxSession(hostEnv)) {
701 return {};
702 }
703 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
704 const output = yield* spawner.string(
705 ChildProcess.make(
706 "busctl",
707 [
708 "--user",
709 "--json=short",
710 "get-property",
711 "org.freedesktop.systemd1",
712 "/org/freedesktop/systemd1",
713 "org.freedesktop.systemd1.Manager",
714 "Environment",
715 ],
716 { stdin: "ignore", stderr: "ignore" },
717 ),
718 );
719 const { data } = yield* decodeManagerEnvironment(output);
720 return Object.fromEntries(
721 data.flatMap((line) => {
722 const separator = line.indexOf("=");
723 const key = line.slice(0, separator);
724 return separator > 0 && GRAPHICAL_SESSION_ENV_KEYS.has(key)
725 ? [[key, line.slice(separator + 1)]]
726 : [];
727 }),
728 );
729 },
730 Effect.timeout("2 seconds"),
731 Effect.orElseSucceed((): NodeJS.ProcessEnv => ({})),
732);
733​
734const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
735 launch: ProcessLaunch,
736 onError: (cause: unknown) => ExternalLauncherError,
737): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
738 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
739 // Inherited ELECTRON_RUN_AS_NODE would make Electron apps opened by this launcher run as Node and exit.
740 const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = yield* HostProcess.Environment;
741 const command = ChildProcess.make(launch.command, launch.args, {
742 ...launch.options,
743 env: { ...env, ...launch.options.env },
744 extendEnv: false,
745 });
746​
747 yield* spawner.spawn(command).pipe(
748 Effect.flatMap((handle) => handle.unref),
749 Effect.asVoid,
750 Effect.scoped,
751 Effect.mapError(onError),
752 );
753});
754​
755const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (
756 target: string,
757): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
758 const launch = yield* resolveBrowserLaunch(target);
759 return yield* launchAndUnref(
760 launch,
761 (cause) =>
762 new ExternalLauncherBrowserSpawnError({
763 target,
764 command: launch.command,
765 args: launch.args,
766 cause,
767 }),
768 );
769});
770​
771const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
772 launch: EditorLaunch,
773): Effect.fn.Return<
774 void,
775 ExternalLauncherError,
776 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
777> {
778 const env = yield* readCommandLookupEnv;
779 if (!(yield* isCommandAvailable(launch.command, { env }))) {
780 return yield* new ExternalLauncherCommandNotFoundError({
781 editor: launch.editor,
782 command: launch.command,
783 });
784 }
785​
786 const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env });
787 if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) {
788 return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor });
789 }
790 yield* launchAndUnref(
791 {
792 command: spawnCommand.command,
793 args: spawnCommand.args,
794 options: {
795 detached: launch.detached ?? true,
796 shell: spawnCommand.shell,
797 env: yield* readGraphicalSessionEnv(),
798 stdin: "ignore",
799 stdout: "ignore",
800 stderr: "ignore",
801 },
802 },
803 (cause) =>
804 new ExternalLauncherEditorSpawnError({
805 editor: launch.editor,
806 target: launch.target,
807 command: spawnCommand.command,
808 args: spawnCommand.args,
809 cause,
810 }),
811 );
812});
813​
814/** @public */
815export const make = Effect.gen(function* () {
816 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
817 const fileSystem = yield* FileSystem.FileSystem;
818 const path = yield* Path.Path;
819​
820 const provideCommandResolutionServices = <A, E, R>(
821 effect: Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>,
822 ) =>
823 effect.pipe(
824 Effect.provideService(FileSystem.FileSystem, fileSystem),
825 Effect.provideService(Path.Path, path),
826 );
827​
828 const cachedAvailableEditors = yield* makeStaleWhileRevalidate(
829 provideCommandResolutionServices(resolveAvailableEditors()).pipe(
830 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
831 ),
832 EDITOR_DISCOVERY_CACHE_TTL,
833 );
834 const cachedFileManagerRevealKind = yield* makeStaleWhileRevalidate(
835 provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe(
836 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
837 ),
838 EDITOR_DISCOVERY_CACHE_TTL,
839 );
840​
841 // Forwarders retain the underlying launcher and discovery spans.
842 return ExternalLauncher.of({
843 resolveAvailableEditors: Effect.fnUntraced(function* () {
844 return yield* cachedAvailableEditors;
845 }),
846 resolveFileManagerRevealKind: Effect.fnUntraced(function* () {
847 return yield* cachedFileManagerRevealKind;
848 }),
849 launchBrowser: Effect.fnUntraced(
850 function* (target) {
851 return yield* launchBrowser(target);
852 },
853 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
854 ),
855 launchEditor: Effect.fnUntraced(
856 function* (input) {
857 return yield* provideCommandResolutionServices(launchEditor(input));
858 },
859 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
860 ),
861 });
862});
863​
864export const layer = Layer.effect(ExternalLauncher, make);