/** * ExternalLauncher - external application launch service interface. * * Owns process launch helpers for browser URLs and workspace paths * in configured editor integrations. * * @module ExternalLauncher */import { EDITORS, ExternalLauncherError, ExternalLauncherBrowserSpawnError, ExternalLauncherCommandNotFoundError, ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, type EditorId, type FileManagerRevealKind, type LaunchEditorInput,} from "@t3tools/contracts";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";import * as Clock from "effect/Clock";import * as Config from "effect/Config";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Ref from "effect/Ref";import * as Stream from "effect/Stream";import * as ChildProcess from "effect/unstable/process/ChildProcess";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; // ==============================// Definitions// ============================== export { ExternalLauncherError, ExternalLauncherBrowserSpawnError, ExternalLauncherCommandNotFoundError, ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, isExternalLauncherError,} from "@t3tools/contracts";export type { LaunchEditorInput };interface EditorLaunch { readonly editor: EditorId; readonly target: string; readonly command: string; readonly args: ReadonlyArray<string>;} interface ProcessLaunch { readonly command: string; readonly args: ReadonlyArray<string>; readonly options: ChildProcess.CommandOptions;} interface TargetPathAndPosition { readonly path: string; readonly line: string; readonly column: Option.Option<string>;} const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand",] as const; const DETACHED_IGNORE_STDIO_OPTIONS = { detached: true, stdin: "ignore", stdout: "ignore", stderr: "ignore",} as const satisfies ChildProcess.CommandOptions; const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv => Object.fromEntries( Object.entries(input).flatMap(([key, value]) => Option.match(value, { onNone: () => [], onSome: (resolved) => [[key, resolved]], }), ), ); const BrowserLaunchEnvConfig = Config.all({ SYSTEMROOT: Config.string("SYSTEMROOT").pipe(Config.option), windir: Config.string("windir").pipe(Config.option), WSL_DISTRO_NAME: Config.string("WSL_DISTRO_NAME").pipe(Config.option), WSL_INTEROP: Config.string("WSL_INTEROP").pipe(Config.option), SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), container: Config.string("container").pipe(Config.option), DISPLAY: Config.string("DISPLAY").pipe(Config.option), WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option),}).pipe(Config.map(compactEnv)); const CommandLookupEnvConfig = Config.all({ PATH: Config.string("PATH").pipe(Config.option), Path: Config.string("Path").pipe(Config.option), path: Config.string("path").pipe(Config.option), PATHEXT: Config.string("PATHEXT").pipe(Config.option),}).pipe(Config.map(compactEnv)); const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({})));const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> { const match = TARGET_WITH_POSITION_PATTERN.exec(target); if (!match?.[1] || !match[2]) { return Option.none(); } return Option.some({ path: match[1], line: match[2], column: Option.fromUndefinedOr(match[3]), });} function resolveCommandEditorArgs( editor: (typeof EDITORS)[number], target: string,): ReadonlyArray<string> { const parsedTarget = parseTargetPathAndPosition(target); switch (editor.launchStyle) { case "direct-path": return [target]; case "goto": return Option.isSome(parsedTarget) ? ["--goto", target] : [target]; case "line-column": return Option.match(parsedTarget, { onNone: () => [target], onSome: ({ path, line, column }) => [ "--line", line, ...Option.match(column, { onNone: () => [], onSome: (value) => ["--column", value], }), path, ], }); }} function resolveEditorArgs( editor: (typeof EDITORS)[number], target: string,): ReadonlyArray<string> { const baseArgs = "baseArgs" in editor ? editor.baseArgs : []; return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];} const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray<string>, env: NodeJS.ProcessEnv,): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> { for (const command of commands) { if (yield* isCommandAvailable(command, { env })) { return Option.some(command); } } return Option.none();}); function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); for (let index = 0; index < input.length; index += 1) { const code = input.charCodeAt(index); bytes[index * 2] = code & 0xff; bytes[index * 2 + 1] = code >>> 8; } return Encoding.encodeBase64(bytes);} function escapePowerShellStringLiteral(input: string): string { return `'${input.replaceAll("'", "''")}'`;} function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string { return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;} function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";} // File reveals from WSL resolve PowerShell through the interop PATH rather// than the fixed /mnt/c mount: the automount root is configurable, and a// PATH-resolved command keeps the advertised capability aligned with the// availability check `launchEditor` performs before spawning.const WSL_POWERSHELL_COMMAND = "powershell.exe"; function shouldUseWindowsHostFromWsl( platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {},): boolean { return ( platform === "linux" && (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) && env.SSH_CONNECTION === undefined && env.SSH_TTY === undefined && env.container === undefined );} function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLaunch { const encodedCommand = encodeUtf16LeBase64( `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`, ); return { command, args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand], options: { detached: true, shell: false, stdin: "ignore", stdout: "ignore", stderr: "ignore", }, };} function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean { return [env.DISPLAY, env.WAYLAND_DISPLAY].some( (value) => value !== undefined && value.trim().length > 0, );} function fileManagerCommandForPlatform( platform: NodeJS.Platform, env: NodeJS.ProcessEnv,): string | undefined { switch (platform) { case "darwin": return "open"; case "win32": return "explorer"; default: if (shouldUseWindowsHostFromWsl(platform, env)) { return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined; } return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined; }} // A graphical session variable plus an executable `xdg-open` does not prove// that opening a directory does anything: without an `inode/directory` MIME// handler, `xdg-open` exits nonzero after the launcher has already detached,// so the client would see a silent no-op. Require the handler before// advertising the file manager on Linux.//// The probe carries its own timeout well inside the scan timeout// `server.getConfig` applies to editor discovery: that outer timeout degrades// to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop// session) must cost only the file manager, not every discovered editor.const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( function* ( env: NodeJS.ProcessEnv, ): Effect.fn.Return< boolean, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { if (!(yield* isCommandAvailable("xdg-mime", { env }))) { return false; } const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; return yield* spawner .spawn( ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], { stdin: "ignore", stderr: "ignore", }), ) .pipe( Effect.flatMap((handle) => Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { concurrency: "unbounded", }), ), Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0), Effect.scoped, Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT), Effect.orElseSucceed(() => false), ); },); const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")( function* ( command: string, env: NodeJS.ProcessEnv, ): Effect.fn.Return< boolean, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { if (!(yield* isCommandAvailable(command, { env }))) { return false; } return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env)); },); // The file-manager command a launch can actually run, not just the platform// preference. WSL hosts prefer the Windows Explorer bridge, but interop can// exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a// distro name while WSLg still provides a working Linux file manager, so they// keep the `xdg-open` fallback instead of losing the editor entirely.const resolveUsableFileManagerCommand = Effect.fn( "externalLauncher.resolveUsableFileManagerCommand",)(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv,): Effect.fn.Return< string | undefined, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner> { const command = fileManagerCommandForPlatform(platform, env); if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) { return command; } if ( shouldUseWindowsHostFromWsl(platform, env) && hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) ) { return "xdg-open"; } return undefined;}); // Reveal on Windows and WSL runs through PowerShell (see// resolveFileManagerRevealLaunch), not the `explorer` command that gates the// file-manager editor itself, so the capability must probe the executables the// reveal actually spawns. Callers gate on file-manager availability first;// the Linux "files" kind relies on that gate for the directory-handler probe,// while the WSL fallback re-probes because its availability may have come// from the Explorer bridge instead.const fileManagerRevealKindForPlatform = Effect.fn( "externalLauncher.fileManagerRevealKindForPlatform",)(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv,): Effect.fn.Return< FileManagerRevealKind | undefined, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner> { if (platform === "darwin") return "finder"; if (platform === "win32") { return (yield* isCommandAvailable(resolvePowerShellPath(env), { env })) ? "file-explorer" : undefined; } if (shouldUseWindowsHostFromWsl(platform, env)) { if ( env.WSL_DISTRO_NAME?.trim() && (yield* isCommandAvailable("explorer.exe", { env })) && (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) ) { return "file-explorer"; } return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) ? "files" : undefined; } return hasGraphicalLinuxSession(env) ? "files" : undefined;}); function resolveWslFileManagerPath(target: string, distroName: string): string { const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`;} function buildBrowserLaunch( target: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {},): ProcessLaunch { if (platform === "darwin") { return { command: "open", args: [target], options: DETACHED_IGNORE_STDIO_OPTIONS, }; } if (platform === "win32") { return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); } if (shouldUseWindowsHostFromWsl(platform, env)) { return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); } return { command: "xdg-open", args: [target], options: DETACHED_IGNORE_STDIO_OPTIONS, };} const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv,): Effect.fn.Return< ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner> { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { available.push(editor.id); } continue; } const command = yield* resolveAvailableCommand(editor.commands, env); if (Option.isSome(command)) { available.push(editor.id); } } return available;}); const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( target: string,) { const platform = yield* HostProcessPlatform; const env = yield* readBrowserLaunchEnv; return buildBrowserLaunch(target, platform, env);}); const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* buildAvailableEditors(platform, env);}); const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( function* () { const platform = yield* HostProcessPlatform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* fileManagerRevealKindForPlatform(platform, env); },); // Editor discovery walks PATH for every known editor and runs for every// client connect (the server config embeds the available editors). Memoize// the discovered set for a bounded window so repeat connects skip even the// per-command cache lookups in @t3tools/shared/shell.//// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the// first caller's Exit whatever it is, including an interrupt. Callers run this// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`),// so one client disconnecting mid-scan would cache the interrupt and replay it// to every later connect for the whole TTL, breaking `server.getConfig`// permanently. Storing only on success means an interrupted scan leaves the// cache untouched and the next connect simply rescans.// Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock// adjustment cannot keep an expired entry alive.const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; interface EditorDiscoveryCacheEntry { readonly editors: ReadonlyArray<EditorId>; readonly expiresAtNanos: bigint;} /** * ExternalLauncher - Service tag for browser/editor launch operations. */export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>; /** * Reveal kind for the host, or undefined when the executable a revealLines 1–500 of 800. Load more
/** * ExternalLauncher - external application launch service interface. * * Owns process launch helpers for browser URLs and workspace paths * in configured editor integrations. * * @module ExternalLauncher */ import { EDITORS, ExternalLauncherError, ExternalLauncherBrowserSpawnError, ExternalLauncherCommandNotFoundError, ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, type EditorId, type FileManagerRevealKind, type LaunchEditorInput, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; // ============================== // Definitions // ============================== export { ExternalLauncherError, ExternalLauncherBrowserSpawnError, ExternalLauncherCommandNotFoundError, ExternalLauncherEditorSpawnError, ExternalLauncherUnknownEditorError, ExternalLauncherUnsupportedEditorError, isExternalLauncherError, } from "@t3tools/contracts"; export type { LaunchEditorInput }; interface EditorLaunch { readonly editor: EditorId; readonly target: string; readonly command: string; readonly args: ReadonlyArray<string>; } interface ProcessLaunch { readonly command: string; readonly args: ReadonlyArray<string>; readonly options: ChildProcess.CommandOptions; } interface TargetPathAndPosition { readonly path: string; readonly line: string; readonly column: Option.Option<string>; } const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; const POWERSHELL_ARGUMENTS_PREFIX = [ "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", ] as const; const DETACHED_IGNORE_STDIO_OPTIONS = { detached: true, stdin: "ignore", stdout: "ignore", stderr: "ignore", } as const satisfies ChildProcess.CommandOptions; const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv => Object.fromEntries( Object.entries(input).flatMap(([key, value]) => Option.match(value, { onNone: () => [], onSome: (resolved) => [[key, resolved]], }), ), ); const BrowserLaunchEnvConfig = Config.all({ SYSTEMROOT: Config.string("SYSTEMROOT").pipe(Config.option), windir: Config.string("windir").pipe(Config.option), WSL_DISTRO_NAME: Config.string("WSL_DISTRO_NAME").pipe(Config.option), WSL_INTEROP: Config.string("WSL_INTEROP").pipe(Config.option), SSH_CONNECTION: Config.string("SSH_CONNECTION").pipe(Config.option), SSH_TTY: Config.string("SSH_TTY").pipe(Config.option), container: Config.string("container").pipe(Config.option), DISPLAY: Config.string("DISPLAY").pipe(Config.option), WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").pipe(Config.option), }).pipe(Config.map(compactEnv)); const CommandLookupEnvConfig = Config.all({ PATH: Config.string("PATH").pipe(Config.option), Path: Config.string("Path").pipe(Config.option), path: Config.string("path").pipe(Config.option), PATHEXT: Config.string("PATHEXT").pipe(Config.option), }).pipe(Config.map(compactEnv)); const readBrowserLaunchEnv = BrowserLaunchEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({}))); function parseTargetPathAndPosition(target: string): Option.Option<TargetPathAndPosition> { const match = TARGET_WITH_POSITION_PATTERN.exec(target); if (!match?.[1] || !match[2]) { return Option.none(); } return Option.some({ path: match[1], line: match[2], column: Option.fromUndefinedOr(match[3]), }); } function resolveCommandEditorArgs( editor: (typeof EDITORS)[number], target: string, ): ReadonlyArray<string> { const parsedTarget = parseTargetPathAndPosition(target); switch (editor.launchStyle) { case "direct-path": return [target]; case "goto": return Option.isSome(parsedTarget) ? ["--goto", target] : [target]; case "line-column": return Option.match(parsedTarget, { onNone: () => [target], onSome: ({ path, line, column }) => [ "--line", line, ...Option.match(column, { onNone: () => [], onSome: (value) => ["--column", value], }), path, ], }); } } function resolveEditorArgs( editor: (typeof EDITORS)[number], target: string, ): ReadonlyArray<string> { const baseArgs = "baseArgs" in editor ? editor.baseArgs : []; return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; } const resolveAvailableCommand = Effect.fn("externalLauncher.resolveAvailableCommand")(function* ( commands: ReadonlyArray<string>, env: NodeJS.ProcessEnv, ): Effect.fn.Return<Option.Option<string>, never, FileSystem.FileSystem | Path.Path> { for (const command of commands) { if (yield* isCommandAvailable(command, { env })) { return Option.some(command); } } return Option.none(); }); function encodeUtf16LeBase64(input: string): string { const bytes = new Uint8Array(input.length * 2); for (let index = 0; index < input.length; index += 1) { const code = input.charCodeAt(index); bytes[index * 2] = code & 0xff; bytes[index * 2 + 1] = code >>> 8; } return Encoding.encodeBase64(bytes); } function escapePowerShellStringLiteral(input: string): string { return `'${input.replaceAll("'", "''")}'`; } function resolvePowerShellPath(env: NodeJS.ProcessEnv = {}): string { return `${env.SYSTEMROOT || env.windir || String.raw`C:\Windows`}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; } function resolveWslPowerShellPath(): string { return "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; } // File reveals from WSL resolve PowerShell through the interop PATH rather // than the fixed /mnt/c mount: the automount root is configurable, and a // PATH-resolved command keeps the advertised capability aligned with the // availability check `launchEditor` performs before spawning. const WSL_POWERSHELL_COMMAND = "powershell.exe"; function shouldUseWindowsHostFromWsl( platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}, ): boolean { return ( platform === "linux" && (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) && env.SSH_CONNECTION === undefined && env.SSH_TTY === undefined && env.container === undefined ); } function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLaunch { const encodedCommand = encodeUtf16LeBase64( `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`, ); return { command, args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand], options: { detached: true, shell: false, stdin: "ignore", stdout: "ignore", stderr: "ignore", }, }; } function hasGraphicalLinuxSession(env: NodeJS.ProcessEnv): boolean { return [env.DISPLAY, env.WAYLAND_DISPLAY].some( (value) => value !== undefined && value.trim().length > 0, ); } function fileManagerCommandForPlatform( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ): string | undefined { switch (platform) { case "darwin": return "open"; case "win32": return "explorer"; default: if (shouldUseWindowsHostFromWsl(platform, env)) { return env.WSL_DISTRO_NAME?.trim() ? "explorer.exe" : undefined; } return hasGraphicalLinuxSession(env) ? "xdg-open" : undefined; } } // A graphical session variable plus an executable `xdg-open` does not prove // that opening a directory does anything: without an `inode/directory` MIME // handler, `xdg-open` exits nonzero after the launcher has already detached, // so the client would see a silent no-op. Require the handler before // advertising the file manager on Linux. // // The probe carries its own timeout well inside the scan timeout // `server.getConfig` applies to editor discovery: that outer timeout degrades // to an empty editor list, so a hung `xdg-mime` (broken D-Bus or desktop // session) must cost only the file manager, not every discovered editor. const LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT = "2 seconds"; const hasUsableLinuxDirectoryHandler = Effect.fn("externalLauncher.hasUsableLinuxDirectoryHandler")( function* ( env: NodeJS.ProcessEnv, ): Effect.fn.Return< boolean, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { if (!(yield* isCommandAvailable("xdg-mime", { env }))) { return false; } const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; return yield* spawner .spawn( ChildProcess.make("xdg-mime", ["query", "default", "inode/directory"], { stdin: "ignore", stderr: "ignore", }), ) .pipe( Effect.flatMap((handle) => Effect.all([handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], { concurrency: "unbounded", }), ), Effect.map(([stdout, exitCode]) => exitCode === 0 && stdout.trim().length > 0), Effect.scoped, Effect.timeout(LINUX_DIRECTORY_HANDLER_PROBE_TIMEOUT), Effect.orElseSucceed(() => false), ); }, ); const isUsableFileManagerCommand = Effect.fn("externalLauncher.isUsableFileManagerCommand")( function* ( command: string, env: NodeJS.ProcessEnv, ): Effect.fn.Return< boolean, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { if (!(yield* isCommandAvailable(command, { env }))) { return false; } return command !== "xdg-open" || (yield* hasUsableLinuxDirectoryHandler(env)); }, ); // The file-manager command a launch can actually run, not just the platform // preference. WSL hosts prefer the Windows Explorer bridge, but interop can // exist without `explorer.exe` on PATH (appendWindowsPath=false) or without a // distro name while WSLg still provides a working Linux file manager, so they // keep the `xdg-open` fallback instead of losing the editor entirely. const resolveUsableFileManagerCommand = Effect.fn( "externalLauncher.resolveUsableFileManagerCommand", )(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ): Effect.fn.Return< string | undefined, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { const command = fileManagerCommandForPlatform(platform, env); if (command !== undefined && (yield* isUsableFileManagerCommand(command, env))) { return command; } if ( shouldUseWindowsHostFromWsl(platform, env) && hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) ) { return "xdg-open"; } return undefined; }); // Reveal on Windows and WSL runs through PowerShell (see // resolveFileManagerRevealLaunch), not the `explorer` command that gates the // file-manager editor itself, so the capability must probe the executables the // reveal actually spawns. Callers gate on file-manager availability first; // the Linux "files" kind relies on that gate for the directory-handler probe, // while the WSL fallback re-probes because its availability may have come // from the Explorer bridge instead. const fileManagerRevealKindForPlatform = Effect.fn( "externalLauncher.fileManagerRevealKindForPlatform", )(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ): Effect.fn.Return< FileManagerRevealKind | undefined, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { if (platform === "darwin") return "finder"; if (platform === "win32") { return (yield* isCommandAvailable(resolvePowerShellPath(env), { env })) ? "file-explorer" : undefined; } if (shouldUseWindowsHostFromWsl(platform, env)) { if ( env.WSL_DISTRO_NAME?.trim() && (yield* isCommandAvailable("explorer.exe", { env })) && (yield* isCommandAvailable(WSL_POWERSHELL_COMMAND, { env })) ) { return "file-explorer"; } return hasGraphicalLinuxSession(env) && (yield* isUsableFileManagerCommand("xdg-open", env)) ? "files" : undefined; } return hasGraphicalLinuxSession(env) ? "files" : undefined; }); function resolveWslFileManagerPath(target: string, distroName: string): string { const relativePath = target.replace(/^\/+/, "").replaceAll("/", "\\"); return `\\\\wsl.localhost\\${distroName}${relativePath.length > 0 ? `\\${relativePath}` : ""}`; } function buildBrowserLaunch( target: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv = {}, ): ProcessLaunch { if (platform === "darwin") { return { command: "open", args: [target], options: DETACHED_IGNORE_STDIO_OPTIONS, }; } if (platform === "win32") { return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env)); } if (shouldUseWindowsHostFromWsl(platform, env)) { return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath()); } return { command: "xdg-open", args: [target], options: DETACHED_IGNORE_STDIO_OPTIONS, }; } const buildAvailableEditors = Effect.fn("externalLauncher.buildAvailableEditors")(function* ( platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ): Effect.fn.Return< ReadonlyArray<EditorId>, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > { const available: EditorId[] = []; for (const editor of EDITORS) { if (editor.commands === null) { if ((yield* resolveUsableFileManagerCommand(platform, env)) !== undefined) { available.push(editor.id); } continue; } const command = yield* resolveAvailableCommand(editor.commands, env); if (Option.isSome(command)) { available.push(editor.id); } } return available; }); const resolveBrowserLaunch = Effect.fn("externalLauncher.resolveBrowserLaunch")(function* ( target: string, ) { const platform = yield* HostProcessPlatform; const env = yield* readBrowserLaunchEnv; return buildBrowserLaunch(target, platform, env); }); const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEditors")(function* () { const platform = yield* HostProcessPlatform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* buildAvailableEditors(platform, env); }); const resolveFileManagerRevealKind = Effect.fn("externalLauncher.resolveFileManagerRevealKind")( function* () { const platform = yield* HostProcessPlatform; const env = { ...(yield* readBrowserLaunchEnv), ...(yield* readCommandLookupEnv) }; return yield* fileManagerRevealKindForPlatform(platform, env); }, ); // Editor discovery walks PATH for every known editor and runs for every // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the // per-command cache lookups in @t3tools/shared/shell. // // This deliberately does not use `Effect.cachedWithTTL`: that memoizes the // first caller's Exit whatever it is, including an interrupt. Callers run this // on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), // so one client disconnecting mid-scan would cache the interrupt and replay it // to every later connect for the whole TTL, breaking `server.getConfig` // permanently. Storing only on success means an interrupted scan leaves the // cache untouched and the next connect simply rescans. // Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the // command-resolution cache in @t3tools/shared/shell, so a backward wall-clock // adjustment cannot keep an expired entry alive. const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; interface EditorDiscoveryCacheEntry { readonly editors: ReadonlyArray<EditorId>; readonly expiresAtNanos: bigint; } /** * ExternalLauncher - Service tag for browser/editor launch operations. */ export class ExternalLauncher extends Context.Service< ExternalLauncher, { readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>; /** * Reveal kind for the host, or undefined when the executable a reveal