640 lines · 20.9 KB · TypeScript
RawDownload
/** * 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 LaunchEditorInput,  type RevealInFileManagerInput,} 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 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, RevealInFileManagerInput };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 WSL_DISTRO_NAME_PATTERN = /^\w(?:[\w .-]*\w)?$/;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),}).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),  DISPLAY: Config.string("DISPLAY").pipe(Config.option),  WAYLAND_DISPLAY: Config.string("WAYLAND_DISPLAY").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),  SESSIONNAME: Config.string("SESSIONNAME").pipe(Config.option),  container: Config.string("container").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";} function shouldUseWindowsBrowserFromWsl(  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 resolveWslDistroName(env: NodeJS.ProcessEnv): string | undefined {  const distroName = env.WSL_DISTRO_NAME?.trim();  return distroName && WSL_DISTRO_NAME_PATTERN.test(distroName) ? distroName : undefined;} function shouldUseWindowsFileManagerFromWsl(  platform: NodeJS.Platform,  env: NodeJS.ProcessEnv,): boolean {  return (    shouldUseWindowsBrowserFromWsl(platform, env) &&    !env.DISPLAY?.trim() &&    !env.WAYLAND_DISPLAY?.trim() &&    resolveWslDistroName(env) !== undefined  );} function hasGraphicalFileManagerSession(  platform: NodeJS.Platform,  env: NodeJS.ProcessEnv,): boolean {  if (env.SSH_CONNECTION?.trim() || env.SSH_TTY?.trim()) return false;  if (shouldUseWindowsFileManagerFromWsl(platform, env)) return true;  if (platform === "linux") {    return Boolean(env.DISPLAY?.trim() || env.WAYLAND_DISPLAY?.trim());  }  if (platform === "win32") {    return env.SESSIONNAME?.trim().toLowerCase() !== "services";  }  return true;} function normalizeWindowsFileManagerPath(target: string): string {  return target.replaceAll("/", "\\");} function resolveWslFileManagerPath(target: string, env: NodeJS.ProcessEnv): string {  const distroName = resolveWslDistroName(env);  if (!distroName) return target;  return `\\\\wsl.localhost\\${distroName}${normalizeWindowsFileManagerPath(target)}`;} function fileManagerFolderPath(platform: NodeJS.Platform, target: string, path: Path.Path): string {  if (platform !== "win32") return path.dirname(target);   const normalized = normalizeWindowsFileManagerPath(target);  const separatorIndex = normalized.lastIndexOf("\\");  if (separatorIndex < 0) return ".";  if (separatorIndex === 2 && normalized[1] === ":") return normalized.slice(0, 3);  return normalized.slice(0, separatorIndex) || "\\";} function fileManagerRevealArgs(  platform: NodeJS.Platform,  target: string,  targetExists: boolean,  path: Path.Path,  env: NodeJS.ProcessEnv,): ReadonlyArray<string> {  if (shouldUseWindowsFileManagerFromWsl(platform, env)) {    const revealTarget = targetExists ? target : fileManagerFolderPath(platform, target, path);    const windowsTarget = resolveWslFileManagerPath(revealTarget, env);    return targetExists ? ["/select,", windowsTarget] : [windowsTarget];  }  if (!targetExists) return [fileManagerFolderPath(platform, target, path)];  if (platform === "darwin") return ["-R", target];  if (platform === "win32") return ["/select,", normalizeWindowsFileManagerPath(target)];  return [fileManagerFolderPath(platform, target, path)];} function fileManagerCommandForPlatform(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {  if (shouldUseWindowsFileManagerFromWsl(platform, env)) return "explorer.exe";  switch (platform) {    case "darwin":      return "open";    case "win32":      return "explorer";    default:      return "xdg-open";  }} 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 (shouldUseWindowsBrowserFromWsl(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> {  const available: EditorId[] = [];   for (const editor of EDITORS) {    if (editor.commands === null) {      if (!hasGraphicalFileManagerSession(platform, env)) continue;      const command = fileManagerCommandForPlatform(platform, env);      if (yield* isCommandAvailable(command, { env })) {        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* readCommandLookupEnv;  return yield* buildAvailableEditors(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>>;    /** Launch a URL target in the default browser. */    readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;    /**     * Launch a workspace path in a selected editor integration.     *     * Launches the editor as a detached process so server startup is not blocked.     */    readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect<void, ExternalLauncherError>;    /** Reveal a workspace file in the host file manager. */    readonly revealInFileManager: (      input: RevealInFileManagerInput,    ) => Effect.Effect<void, ExternalLauncherError>;  }>()("t3/process/externalLauncher") {} // ==============================// Implementations// ============================== const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (  input: LaunchEditorInput,): Effect.fn.Return<EditorLaunch, ExternalLauncherError, FileSystem.FileSystem | Path.Path> {  const platform = yield* HostProcessPlatform;  const env = yield* readCommandLookupEnv;  yield* Effect.annotateCurrentSpan({    "externalLauncher.editor": input.editor,    "externalLauncher.cwd": input.cwd,    "externalLauncher.platform": platform,  });  const editorDef = EDITORS.find((editor) => editor.id === input.editor);  if (!editorDef) {    return yield* new ExternalLauncherUnknownEditorError({ editor: input.editor });  }   if (editorDef.commands) {    const command = Option.getOrElse(      yield* resolveAvailableCommand(editorDef.commands, env),      () => editorDef.commands[0],    );    return {      editor: editorDef.id,      target: input.cwd,      command,      args: resolveEditorArgs(editorDef, input.cwd),    };  }   if (editorDef.id !== "file-manager") {    return yield* new ExternalLauncherUnsupportedEditorError({ editor: input.editor });  }   const path = yield* Path.Path;  const target = shouldUseWindowsFileManagerFromWsl(platform, env)    ? path.resolve(input.cwd)    : input.cwd;  return {    editor: editorDef.id,    target,    command: fileManagerCommandForPlatform(platform, env),    args: [      shouldUseWindowsFileManagerFromWsl(platform, env)        ? resolveWslFileManagerPath(target, env)        : target,    ],  };}); const resolveFileManagerRevealLaunch = Effect.fn("externalLauncher.resolveFileManagerRevealLaunch")(  function* (    input: RevealInFileManagerInput,  ): Effect.fn.Return<EditorLaunch, ExternalLauncherError, FileSystem.FileSystem | Path.Path> {    const platform = yield* HostProcessPlatform;    const env = yield* readCommandLookupEnv;    if (!hasGraphicalFileManagerSession(platform, env)) {      return yield* new ExternalLauncherUnsupportedEditorError({ editor: "file-manager" });    }    const fileSystem = yield* FileSystem.FileSystem;    const path = yield* Path.Path;    const target = shouldUseWindowsFileManagerFromWsl(platform, env)      ? path.resolve(input.path)      : input.path;    const targetExists = yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => false));    const args = fileManagerRevealArgs(platform, target, targetExists, path, env);     yield* Effect.annotateCurrentSpan({      "externalLauncher.target": target,      "externalLauncher.platform": platform,

Lines 1–500 of 640.