1/**
2 * ExternalLauncher - external application launch service interface.
3 *
4 * Owns process launch helpers for browser URLs and workspace paths
5 * in configured editor integrations.
6 *
7 * @module ExternalLauncher
8 */
9import {
10 EDITORS,
11 ExternalLauncherError,
12 ExternalLauncherBrowserSpawnError,
13 ExternalLauncherCommandNotFoundError,
14 ExternalLauncherEditorSpawnError,
15 ExternalLauncherUnknownEditorError,
16 ExternalLauncherUnsupportedEditorError,
17 ExternalLauncherUnsupportedTargetError,
18 type EditorId,
19 type FileManagerRevealKind,
20 type LaunchEditorInput,
21} from "@t3tools/contracts";
22import { resolveEditorCommand } from "@t3tools/shared/editor";
23import * as HostProcess from "@t3tools/shared/HostProcess";
24import {
25 isCommandAvailable,
26 resolveSpawnCommand,
27 withPathDirectoryListings,
28} from "@t3tools/shared/shell";
29import * as Clock from "effect/Clock";
30import * as Config from "effect/Config";
31import * as Context from "effect/Context";
32import * as Deferred from "effect/Deferred";
33import * as Effect from "effect/Effect";
34import * as Base64 from "effect/encoding/Base64";
35import * as Exit from "effect/Exit";
36import * as FileSystem from "effect/FileSystem";
37import * as Layer from "effect/Layer";
38import * as Option from "effect/Option";
39import * as Path from "effect/Path";
40import * as Ref from "effect/Ref";
41import * as Scope from "effect/Scope";
42import * as Stream from "effect/Stream";
43import * as ChildProcess from "effect/process/ChildProcess";
44import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
45
46// ==============================
47// Definitions
48// ==============================
49
50export {
51 ExternalLauncherError,
52 ExternalLauncherBrowserSpawnError,
53 ExternalLauncherCommandNotFoundError,
54 ExternalLauncherEditorSpawnError,
55 ExternalLauncherUnknownEditorError,
56 ExternalLauncherUnsupportedEditorError,
57 ExternalLauncherUnsupportedTargetError,
58} from "@t3tools/contracts";
59export type { LaunchEditorInput };
60interface EditorLaunch {
61 readonly editor: EditorId;
62 readonly target: string;
63 readonly command: string;
64 readonly args: ReadonlyArray<string>;
65 /** Defaults to true. See WINDOWS_POWERSHELL_DETACHED. */
66 readonly detached?: boolean;
67}
68
69interface ProcessLaunch {
70 readonly command: string;
71 readonly args: ReadonlyArray<string>;
72 readonly options: ChildProcess.CommandOptions;
73}
74
75interface TargetPathAndPosition {
76 readonly path: string;
77 readonly line: string;
78 readonly column: Option.Option<string>;
79}
80
81/**
82 * Windows command shims (`code.cmd`) forward arguments through `%*`, which
83 * cmd.exe parses a second time after the first escaping layer is gone: a
84 * line break ends the command there, and a double quote closes the quoting
85 * that keeps `&` or `|` literal. Neither can occur in a Windows path.
86 */
87// oxlint-disable-next-line no-control-regex
88const WINDOWS_SHIM_UNSAFE_ARG_PATTERN = /[\u0000-\u001f\u007f"]/;
89const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
90const POWERSHELL_ARGUMENTS_PREFIX = [
91 "-NoProfile",
92 "-NonInteractive",
93 "-ExecutionPolicy",
94 "Bypass",
95 "-EncodedCommand",
96] as const;
97
98// Windows PowerShell started with DETACHED_PROCESS exits 0 without running its
99// command, so native Windows launches through it stay attached. The child is
100// still unref'd with ignored stdio, so nothing waits on it. WSL interop
101// PowerShell starts from a Linux process and keeps the detached launch.
102const WINDOWS_POWERSHELL_DETACHED = false;
103
104const DETACHED_IGNORE_STDIO_OPTIONS = {
105 detached: true,
106 stdin: "ignore",
107 stdout: "ignore",
108 stderr: "ignore",
109} as const satisfies ChildProcess.CommandOptions;
110
111const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>
112 Object.fromEntries(
113 Object.entries(input).flatMap(([key, value]) =>
114 Option.match(value, {
115 onNone: () => [],
116 onSome: (resolved) => [[key, resolved]],
117 }),
118 ),
119 );
120
121const BrowserLaunchEnvConfig = Config.all({
122 SYSTEMROOT: Config.String("SYSTEMROOT").pipe(Config.option),
123 windir: Config.String("windir").pipe(Config.option),
124 WSL_DISTRO_NAME: Config.String("WSL_DISTRO_NAME").pipe(Config.option),
125 WSL_INTEROP: Config.String("WSL_INTEROP").pipe(Config.option),
126 SSH_CONNECTION: Config.String("SSH_CONNECTION").pipe(Config.option),
127 SSH_TTY: Config.String("SSH_TTY").pipe(Config.option),
128 container: Config.String("container").pipe(Config.option),
129 DISPLAY: Config.String("DISPLAY").pipe(Config.option),
130 WAYLAND_DISPLAY: Config.String("WAYLAND_DISPLAY").pipe(Config.option),
131}).pipe(Config.map(compactEnv));
132
133const CommandLookupEnvConfig = Config.all({
134 PATH: Config.String("PATH").pipe(Config.option),
135 Path: Config.String("Path").pipe(Config.option),
136 path: Config.String("path").pipe(Config.option),
137 PATHEXT: Config.String("PATHEXT").pipe(Config.option),
138 HOME: Config.String("HOME").pipe(Config.option),
139 LOCALAPPDATA: Config.String("LOCALAPPDATA").pipe(Config.option),
140 ProgramFiles: Config.String("ProgramFiles").pipe(Config.option),
141 ProgramW6432: Config.String("ProgramW6432").pipe(Config.option),
142 XDG_DATA_HOME: Config.String("XDG_DATA_HOME").pipe(Config.option),
143 "ProgramFiles(x86)": Config.String("ProgramFiles(x86)").pipe(Config.option),
144}).pipe(Config.map(compactEnv));
145
146const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
147const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
148
149function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> {
150 const match = TARGET_WITH_POSITION_PATTERN.exec(target);
151 if (!match?.[1] || !match[2]) {
152 return Option.none();
153 }
154
155 return Option.some({
156 path: match[1],
157 line: match[2],
158 column: Option.fromUndefinedOr(match[3]),
159 });
160}
161
162function resolveCommandEditorArgs(
163 editor: (typeof EDITORS)[number],
164 target: string,
165): ReadonlyArray<string> {
166 const parsedTarget = parseTargetPathAndPosition(target);
167
168 switch (editor.launchStyle) {
169 case "direct-path":
170 return [target];
171 case "goto":
172 return Option.isSome(parsedTarget) ? ["--goto", target] : [target];
173 case "line-column":
174 return Option.match(parsedTarget, {
175 onNone: () => [target],
176 onSome: ({ path, line, column }) => [
177 "--line",
178 line,
179 ...Option.match(column, {
180 onNone: () => [],
181 onSome: (value) => ["--column", value],
182 }),
183 path,
184 ],
185 });
186 }
187}
188
189function encodeUtf16LeBase64(input: string): string {
190 const bytes = new Uint8Array(input.length * 2);
191 for (let index = 0; index < input.length; index += 1) {
192 const code = input.charCodeAt(index);
193 bytes[index * 2] = code & 0xff;
194 bytes[index * 2 + 1] = code >>> 8;
195 }
196 return Base64.encode(bytes);
197}
198
199function escapePowerShellStringLiteral(input: string): string {
200 return `'${input.replaceAll("'", "''")}'`;
201}
202
203function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string {
204 return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
205}
206
207function resolveWslPowerShellPath(): string {
208 return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
209}
210
211// File reveals from WSL resolve PowerShell through the interop PATH rather
212// than the fixed /mnt/c mount: the automount root is configurable, and a
213// PATH-resolved command keeps the advertised capability aligned with the
214// availability check `launchEditor` performs before spawning.
215const WSL_POWERSHELL_COMMAND = "powershell.exe";
216
217function shouldUseWindowsHostFromWsl(
218 platform: NodeJS.Platform,
219 env: NodeJS.ProcessEnv = {},
220): boolean {
221 return (
222 platform === "linux" &&
223 (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) &&
224 env.SSH_CONNECTION === undefined &&
225 env.SSH_TTY === undefined &&
226 env.container === undefined
227 );
228}
229
230function resolveWindowsBrowserLaunch(
231 target: string,
232 command: string,
233 detached: boolean,
234): ProcessLaunch {
235 const encodedCommand = encodeUtf16LeBase64(
236 `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`,
237 );
238 return {
239 command,
240 args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand],
241 options: {
242 detached,
243 shell: false,
244 stdin: "ignore",
245 stdout: "ignore",
246 stderr: "ignore",
247 },
248 };
249}
250
251function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean {
252 return [env.DISPLAY, env.WAYLAND_DISPLAY].some(
253 (value) => value !== undefined && value.trim().length > 0,
254 );
255}
256
257function fileManagerCommandForPlatform(
258 platform: NodeJS.Platform,
259 env: NodeJS.ProcessEnv,
260): string | undefined {
261 switch (platform) {
262 case "darwin":
263 return "open";
264 case "win32":
265 return "explorer";
266 default:
267 if (shouldUseWindowsHostFromWsl(platform, env)) {
268 return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined;
269 }
270 return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined;
271 }
272}
273
274// A graphical session variable plus an executable `xdg-open` does not prove
275// that opening a directory does anything: without an `inode/directory` MIME
276// handler, `xdg-open` exits nonzero after the launcher has already detached,
277// so the client would see a silent no-op. Require the handler before
278// advertising the file manager on Linux.
279//
280// The probe carries its own timeout well inside the scan timeout
281// `server.getConfig` applies to editor discovery: that outer timeout degrades
282// to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop
283// session) must cost only the file manager, not every discovered editor.
284const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds";
285
286const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")(
287 function* (
288 env: NodeJS.ProcessEnv,
289 ): Effect.fn.Return<
290 boolean,
291 never,
292 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
293 > {
294 if (!(yield* isCommandAvailable("xdg-mime", { env }))) {
295 return false;
296 }
297
298 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
299 return yield* spawner
300 .spawn(
301 ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], {
302 stdin: "ignore",
303 stderr: "ignore",
304 }),
305 )
306 .pipe(
307 Effect.flatMap((handle) =>
308 Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], {
309 concurrency: "unbounded",
310 }),
311 ),
312 Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0),
313 Effect.scoped,
314 Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT),
315 Effect.orElseSucceed(() => false),
316 );
317 },
318);
319
320const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")(
321 function* (
322 command: string,
323 env: NodeJS.ProcessEnv,
324 ): Effect.fn.Return<
325 boolean,
326 never,
327 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
328 > {
329 if (!(yield* isCommandAvailable(command, { env }))) {
330 return false;
331 }
332 return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env));
333 },
334);
335
336// The file-manager command a launch can actually run, not just the platform
337// preference. WSL hosts prefer the Windows Explorer bridge, but interop can
338// exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a
339// distro name while WSLg still provides a working Linux file manager, so they
340// keep the `xdg-open` fallback instead of losing the editor entirely.
341const resolveUsableFileManagerCommand = Effect.fn(
342 "externalLauncher.resolveUsableFileManagerCommand",
343)(function* (
344 platform: NodeJS.Platform,
345 env: NodeJS.ProcessEnv,
346): Effect.fn.Return<
347 string | undefined,
348 never,
349 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
350> {
351 const command = fileManagerCommandForPlatform(platform, env);
352 if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) {
353 return command;
354 }
355 if (
356 shouldUseWindowsHostFromWsl(platform, env) &&
357 hasGraphicalLinuxSession(env) &&
358 (yield* isUsableFileManagerCommand("xdg-open", env))
359 ) {
360 return "xdg-open";
361 }
362 return undefined;
363});
364
365// Reveal on Windows and WSL runs through PowerShell (see
366// resolveFileManagerRevealLaunch), not the `explorer` command that gates the
367// file-manager editor itself, so the capability must probe the executables the
368// reveal actually spawns. Callers gate on file-manager availability first;
369// the Linux "files" kind relies on that gate for the directory-handler probe,
370// while the WSL fallback re-probes because its availability may have come
371// from the Explorer bridge instead.
372const fileManagerRevealKindForPlatform = Effect.fn(
373 "externalLauncher.fileManagerRevealKindForPlatform",
374)(function* (
375 platform: NodeJS.Platform,
376 env: NodeJS.ProcessEnv,
377): Effect.fn.Return<
378 FileManagerRevealKind | undefined,
379 never,
380 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
381> {
382 if (platform === "darwin") return "finder";
383 if (platform === "win32") {
384 return (yield* isCommandAvailable(resolvePowerShellPath(env), { env }))
385 ? "file-explorer"
386 : undefined;
387 }
388 if (shouldUseWindowsHostFromWsl(platform, env)) {
389 if (
390 env.WSL_DISTRO_NAME?.trim() &&
391 (yield* isCommandAvailable("explorer.exe", { env })) &&
392 (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env }))
393 ) {
394 return "file-explorer";
395 }
396 return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))
397 ? "files"
398 : undefined;
399 }
400 return hasGraphicalLinuxSession(env) ? "files" : undefined;
401});
402
403function resolveWslFileManagerPath(target: string, distroName: string): string {
404 const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\");
405 return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`;
406}
407
408function buildBrowserLaunch(
409 target: string,
410 platform: NodeJS.Platform,
411 env: NodeJS.ProcessEnv = {},
412): ProcessLaunch {
413 if (platform === "darwin") {
414 return {
415 command: "open",
416 args: [target],
417 options: DETACHED_IGNORE_STDIO_OPTIONS,
418 };
419 }
420
421 if (platform === "win32") {
422 return resolveWindowsBrowserLaunch(
423 target,
424 resolvePowerShellPath(env),
425 WINDOWS_POWERSHELL_DETACHED,
426 );
427 }
428
429 if (shouldUseWindowsHostFromWsl(platform, env)) {
430 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath(), true);
431 }
432
433 return {
434 command: "xdg-open",
435 args: [target],
436 options: DETACHED_IGNORE_STDIO_OPTIONS,
437 };
438}
439
440const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* (
441 platform: NodeJS.Platform,
442 env: NodeJS.ProcessEnv,
443): Effect.fn.Return<
444 ReadonlyArray<EditorId>,
445 never,
446 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
447> {
448 const available: EditorId[] = [];
449
450 for (const editor of EDITORS) {
451 if (editor.commands === null) {
452 if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) {
453 available.push(editor.id);
454 }
455 continue;
456 }
457
458 const command = yield* resolveEditorCommand(editor, env);
459 if (Option.isSome(command)) {
460 available.push(editor.id);
461 }
462 }
463
464 return available;
465});
466
467const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* (
468 target: string,
469) {
470 const platform = yield* HostProcess.Platform;
471 const env = yield* readBrowserLaunchEnv;
472 return buildBrowserLaunch(target, platform, env);
473});
474
475const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () {
476 const platform = yield* HostProcess.Platform;
477 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
478 return yield* buildAvailableEditors(platform, env).pipe(withPathDirectoryListings);
479});
480
481const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")(
482 function* () {
483 const platform = yield* HostProcess.Platform;
484 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
485 return yield* fileManagerRevealKindForPlatform(platform, env);
486 },
487);
488
489// Editor discovery walks PATH for every known editor and runs for every
490// client connect (the server config embeds the available editors). Memoize
491// the discovered set for a bounded window so repeat connects skip even the
492// per-command cache lookups in @t3tools/shared/shell.
493//
494// The scan runs on its own fiber in the service scope, and every caller awaits
495// that one scan. Callers apply a timeout (`resolveAvailableEditorsForConfig`)
496// and disconnect mid-connect; neither may cancel a scan other connects are
497// waiting on, or throw away work a slow host (a busy server at startup, a
498// long PATH) needs more than one connect to finish. A failed scan clears the
499// entry so the next caller starts over rather than replaying the failure.
500// Expiry uses the monotonic clock (Clock.monotonicTimeNanos), matching the
501// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock
502// adjustment cannot keep an expired entry alive.
503const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n;
504
505interface EditorDiscoveryCacheEntry {
506 readonly scan: Deferred.Deferred<ReadonlyArray<EditorId>>;
507 /** Undefined while the scan is still running. */
508 readonly expiresAtNanos: bigint | undefined;
509}
510
511/**
512 * ExternalLauncher - Service tag for browser/editor launch operations.
513 */
514export class ExternalLauncher extends Context.Service<
515 ExternalLauncher,
516 {
517 readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>;
518 /**
519 * Reveal kind for the host, or undefined when the executable a reveal
520 * actually spawns is unavailable. Only meaningful when
521 * `resolveAvailableEditors` includes "file-manager": on Linux that
522 * availability check also carries the directory-handler probe this
523 * capability relies on.
524 */
525 readonly resolveFileManagerRevealKind: () => Effect.Effect<FileManagerRevealKind | undefined>;
526 /** Launch a URL target in the default browser. */
527 readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;
528 /**
529 * Launch a workspace path in a selected editor integration.
530 *
531 * Launches the editor as a detached process so server startup is not blocked.
532 */
533 readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect<void, ExternalLauncherError>;
534 }
535>()("t3/process/externalLauncher") {}
536
537// ==============================
538// Implementations
539// ==============================
540
541const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
542 input: LaunchEditorInput,
543): Effect.fn.Return<
544 EditorLaunch,
545 ExternalLauncherError,
546 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
547> {
548 const platform = yield* HostProcess.Platform;
549 const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) };
550 yield* Effect.annotateCurrentSpan({
551 "externalLauncher.editor": input.editor,
552 "externalLauncher.cwd": input.cwd,
553 "externalLauncher.platform": platform,
554 });
555 const editorDef = EDITORS.find((editor) => editor.id === input.editor);
556 if (!editorDef) {
557 return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor });
558 }
559
560 if (editorDef.commands) {
561 const { command, baseArgs } = Option.getOrElse(
562 yield* resolveEditorCommand(editorDef, env),
563 () => ({
564 command: editorDef.commands[0],
565 baseArgs: "baseArgs" in editorDef ? editorDef.baseArgs : [],
566 }),
567 );
568 return {
569 editor: editorDef.id,
570 target: input.cwd,
571 command,
572 args: [...baseArgs, ...resolveCommandEditorArgs(editorDef, input.cwd)],
573 };
574 }
575
576 if (editorDef.id !== "file-manager") {
577 return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
578 }
579
580 const command = yield* resolveUsableFileManagerCommand(platform, env);
581 if (command === undefined) {
582 return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });
583 }
584
585 if (input.reveal === true) {
586 return yield* resolveFileManagerRevealLaunch(input.cwd, platform, env, command);
587 }
588
589 return {
590 editor: editorDef.id,
591 target: input.cwd,
592 command,
593 args:
594 command === "explorer.exe" && env.WSL_DISTRO_NAME !== undefined
595 ? [resolveWslFileManagerPath(input.cwd, env.WSL_DISTRO_NAME)]
596 : [input.cwd],
597 };
598});
599
600/**
601 * PowerShell source that launches File Explorer with its raw selection
602 * switch. Explorer's contract is the single argument `/select,"<path>"` with
603 * only the path quoted; Node's default spawn quoting wraps the whole argument
604 * when the path has spaces and Explorer misparses it, silently opening a
605 * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1
606 * reaches the child's command line verbatim, preserving the raw switch.
607 */
608function buildFileExplorerRevealPowerShellSource(explorerCommand: string, target: string): string {
609 return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`;
610}
611
612function fileExplorerRevealLaunch(
613 target: string,
614 explorerTarget: string,
615 powershellCommand: string,
616): EditorLaunch {
617 return {
618 editor: "file-manager",
619 target,
620 command: powershellCommand,
621 args: [
622 ...POWERSHELL_ARGUMENTS_PREFIX,
623 encodeUtf16LeBase64(buildFileExplorerRevealPowerShellSource("explorer.exe", explorerTarget)),
624 ],
625 };
626}
627
628function normalizeWindowsFileManagerPath(target: string): string {
629 return target.replaceAll("/", "\\");
630}
631
632const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch")(function* (
633 target: string,
634 platform: NodeJS.Platform,
635 env: NodeJS.ProcessEnv,
636 // The command resolveUsableFileManagerCommand picked; a WSL host that fell
637 // back to the Linux file manager must reveal through it as well.
638 command: string,
639): Effect.fn.Return<
640 EditorLaunch,
641 never,
642 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
643> {
644 if (platform === "darwin") {
645 return { editor: "file-manager", target, command: "open", args: ["-R", target] };
646 }
647
648 if (platform === "win32") {
649 return {
650 ...fileExplorerRevealLaunch(
651 target,
652 normalizeWindowsFileManagerPath(target),
653 resolvePowerShellPath(env),
654 ),
655 detached: WINDOWS_POWERSHELL_DETACHED,
656 };
657 }
658
659 if (
660 command === "explorer.exe" &&
661 shouldUseWindowsHostFromWsl(platform, env) &&
662 env.WSL_DISTRO_NAME !== undefined
663 ) {
664 const explorerTarget = resolveWslFileManagerPath(target, env.WSL_DISTRO_NAME);
665 if (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) {
666 // Explorer's raw switch cannot express a double quote, and unlike
667 // Windows paths a WSL path may legally contain one: open the containing
668 // directory in File Explorer instead, matching the advertised
669 // "file-explorer" kind.
670 if (explorerTarget.includes('"')) {
671 const path = yield* Path.Path;
672 return {
673 editor: "file-manager",
674 target,
675 command: "explorer.exe",
676 args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)],
677 };
678 }
679 return fileExplorerRevealLaunch(target, explorerTarget, WSL_POWERSHELL_COMMAND);
680 }
681 // Without interop PowerShell the capability advertised the Linux "files"
682 // kind when it advertised anything at all, so the reveal must open the
683 // Linux file manager the label promised, not File Explorer.
684 if (hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env))) {
685 const path = yield* Path.Path;
686 return { editor: "file-manager", target, command: "xdg-open", args: [path.dirname(target)] };
687 }
688 // Nothing was advertised here; open the parent in File Explorer as the
689 // best remaining effort for a stale client.
690 const path = yield* Path.Path;
691 return {
692 editor: "file-manager",
693 target,
694 command: "explorer.exe",
695 args: [resolveWslFileManagerPath(path.dirname(target), env.WSL_DISTRO_NAME)],
696 };
697 }
698
699 // Linux file managers have no portable "select this file" flag, so open
700 // the containing directory instead.
701 const path = yield* Path.Path;
702 return { editor: "file-manager", target, command, args: [path.dirname(target)] };
703});
704
705const launchAndUnref = Effect.fn("externalLauncher.launchAndUnref")(function* (
706 launch: ProcessLaunch,
707 onError: (cause: unknown) => ExternalLauncherError,
708): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
709 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
710 const command = ChildProcess.make(launch.command, launch.args, launch.options);
711
712 yield* spawner.spawn(command).pipe(
713 Effect.flatMap((handle) => handle.unref),
714 Effect.asVoid,
715 Effect.scoped,
716 Effect.mapError(onError),
717 );
718});
719
720const launchBrowser = Effect.fn("externalLauncher.launchBrowser")(function* (
721 target: string,
722): Effect.fn.Return<void, ExternalLauncherError, ChildProcessSpawner.ChildProcessSpawner> {
723 const launch = yield* resolveBrowserLaunch(target);
724 return yield* launchAndUnref(
725 launch,
726 (cause) =>
727 new ExternalLauncherBrowserSpawnError({
728 target,
729 command: launch.command,
730 args: launch.args,
731 cause,
732 }),
733 );
734});
735
736const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(function* (
737 launch: EditorLaunch,
738): Effect.fn.Return<
739 void,
740 ExternalLauncherError,
741 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
742> {
743 const env = yield* readCommandLookupEnv;
744 if (!(yield* isCommandAvailable(launch.command, { env }))) {
745 return yield* new ExternalLauncherCommandNotFoundError({
746 editor: launch.editor,
747 command: launch.command,
748 });
749 }
750
751 const spawnCommand = yield* resolveSpawnCommand(launch.command, launch.args, { env });
752 if (spawnCommand.shell && launch.args.some((arg) => WINDOWS_SHIM_UNSAFE_ARG_PATTERN.test(arg))) {
753 return yield* new ExternalLauncherUnsupportedTargetError({ editor: launch.editor });
754 }
755 yield* launchAndUnref(
756 {
757 command: spawnCommand.command,
758 args: spawnCommand.args,
759 options: {
760 detached: launch.detached ?? true,
761 shell: spawnCommand.shell,
762 stdin: "ignore",
763 stdout: "ignore",
764 stderr: "ignore",
765 },
766 },
767 (cause) =>
768 new ExternalLauncherEditorSpawnError({
769 editor: launch.editor,
770 target: launch.target,
771 command: spawnCommand.command,
772 args: spawnCommand.args,
773 cause,
774 }),
775 );
776});
777
778/** @public Service construction is part of the canonical Effect module API. */
779export const make = Effect.gen(function* () {
780 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
781 const fileSystem = yield* FileSystem.FileSystem;
782 const path = yield* Path.Path;
783
784 const provideCommandResolutionServices = <A, E, R>(
785 effect: Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>,
786 ) =>
787 effect.pipe(
788 Effect.provideService(FileSystem.FileSystem, fileSystem),
789 Effect.provideService(Path.Path, path),
790 );
791
792 const scope = yield* Scope.Scope;
793 const editorDiscoveryCache = yield* Ref.make<Option.Option<EditorDiscoveryCacheEntry>>(
794 Option.none(),
795 );
796 const runEditorDiscovery = (scan: Deferred.Deferred<ReadonlyArray<EditorId>>) =>
797 provideCommandResolutionServices(resolveAvailableEditors()).pipe(
798 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
799 Effect.onExit((exit) =>
800 Effect.gen(function* () {
801 const expiresAtNanos =
802 (yield* Clock.monotonicTimeNanos) + EDITOR_DISCOVERY_CACHE_TTL_NANOS;
803 yield* Ref.update(editorDiscoveryCache, (current) =>
804 Option.isNone(current) || current.value.scan !== scan
805 ? current
806 : Exit.isSuccess(exit)
807 ? Option.some({ scan, expiresAtNanos })
808 : Option.none(),
809 );
810 yield* Deferred.done(scan, exit);
811 }),
812 ),
813 Effect.interruptible,
814 Effect.forkIn(scope),
815 );
816 // Claiming the cache entry and starting its scan must not be split by an
817 // interrupt, or the entry would wait on a scan that never runs.
818 const acquireEditorDiscovery = Effect.gen(function* () {
819 const nowNanos = yield* Clock.monotonicTimeNanos;
820 const [scan, isNewScan] = yield* Ref.modify(
821 editorDiscoveryCache,
822 (
823 current,
824 ): [
825 [EditorDiscoveryCacheEntry["scan"], boolean],
826 Option.Option<EditorDiscoveryCacheEntry>,
827 ] => {
828 if (
829 Option.isSome(current) &&
830 (current.value.expiresAtNanos === undefined || current.value.expiresAtNanos > nowNanos)
831 ) {
832 return [[current.value.scan, false], current];
833 }
834 const scan = Deferred.makeUnsafe<ReadonlyArray<EditorId>>();
835 return [[scan, true], Option.some({ scan, expiresAtNanos: undefined })];
836 },
837 );
838 if (isNewScan) {
839 yield* runEditorDiscovery(scan);
840 }
841 return scan;
842 }).pipe(Effect.uninterruptible);
843 const cachedAvailableEditors = Effect.flatMap(acquireEditorDiscovery, Deferred.await);
844
845 return ExternalLauncher.of({
846 resolveAvailableEditors: () => cachedAvailableEditors,
847 resolveFileManagerRevealKind: () =>
848 provideCommandResolutionServices(resolveFileManagerRevealKind()).pipe(
849 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
850 ),
851 launchBrowser: (target) =>
852 launchBrowser(target).pipe(
853 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
854 ),
855 launchEditor: (input) =>
856 provideCommandResolutionServices(
857 Effect.flatMap(resolveEditorLaunch(input), launchEditorProcess),
858 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
859 });
860});
861
862export const layer = Layer.effect(ExternalLauncher, make);