import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; type EnvironmentPatch = Record; interface ShellEnvironmentConfig { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly userShell: Option.Option; } interface WindowsProbeOptions { readonly loadProfile: boolean; } const DesktopShellEnvironmentProbe = Schema.Literals([ "login-shell", "launchctl-path", "powershell-profile", "powershell-no-profile", ]); type DesktopShellEnvironmentProbe = typeof DesktopShellEnvironmentProbe.Type; const desktopShellEnvironmentCommandFields = { probe: DesktopShellEnvironmentProbe, executable: Schema.String, argumentCount: Schema.Number, }; export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass()( "DesktopShellEnvironmentCommandError", { ...desktopShellEnvironmentCommandFields, cause: Schema.Defect(), }, ) { override get message(): string { return `Desktop shell environment ${this.probe} probe (${this.executable}) failed.`; } } export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErrorClass()( "DesktopShellEnvironmentCommandTimeoutError", { ...desktopShellEnvironmentCommandFields, timeoutMs: Schema.Number, }, ) { override get message(): string { return `Desktop shell environment ${this.probe} probe (${this.executable}) timed out after ${this.timeoutMs}ms.`; } } export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { readonly installIntoProcess: Effect.Effect; } >()("@t3tools/desktop/shell/DesktopShellEnvironment") {} const LOGIN_SHELL_ENV_NAMES = [ "PATH", "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "LANG", "LC_ALL", "LC_CTYPE", "SSH_AUTH_SOCK", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", "XDG_CURRENT_DESKTOP", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", "WAYLAND_DISPLAY", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; const FALLBACK_LC_CTYPE = "en_US.UTF-8"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); const PROCESS_TERMINATE_GRACE = Duration.seconds(1); const trimNonEmpty = (value: string | null | undefined): Option.Option => Option.fromNullishOr(value).pipe( Option.map((entry) => entry.trim()), Option.filter((entry) => entry.length > 0), ); const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => trimNonEmpty(env.PATH ?? env.Path ?? env.path); const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); const linuxRuntimeDirCandidates = ( env: NodeJS.ProcessEnv, uid: number | undefined, ): ReadonlyArray => { const candidates: string[] = []; const fromEnv = trimNonEmpty(env.XDG_RUNTIME_DIR); if (Option.isSome(fromEnv)) { candidates.push(normalizeRuntimeDir(fromEnv.value)); } if (uid !== undefined) { candidates.push(`/run/user/${uid}`); } return candidates.filter((candidate) => candidate.length > 0); }; function resolveDefaultLinuxDbusSessionBusPath(input: { readonly env: NodeJS.ProcessEnv; readonly uid: number | undefined; readonly exists?: (path: string) => boolean; }): string | null { for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) { const busPath = `${runtimeDir}/bus`; if (input.exists === undefined || input.exists(busPath)) { return busPath; } } return null; } export function resolveDefaultLinuxDbusSessionBusAddress(input: { readonly env: NodeJS.ProcessEnv; readonly exists: (path: string) => boolean; readonly uid: number | undefined; }): string | null { const busPath = resolveDefaultLinuxDbusSessionBusPath(input); return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null; } const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { const normalized = entry.trim().replace(/^"+|"+$/g, ""); return platform === "win32" ? normalized.toLowerCase() : normalized; }; const mergePaths = ( platform: NodeJS.Platform, values: ReadonlyArray>, ): Option.Option => { const delimiter = pathDelimiter(platform); const entries: string[] = []; const seen = new Set(); for (const value of values) { if (Option.isNone(value)) continue; for (const entry of value.value.split(delimiter)) { const trimmed = entry.trim(); if (trimmed.length === 0) continue; const key = pathComparisonKey(trimmed, platform); if (key.length === 0 || seen.has(key)) continue; seen.add(key); entries.push(trimmed); } } return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); }; const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray => { const fallback = config.platform === "darwin" ? "/bin/zsh" : config.platform === "linux" ? "/bin/bash" : ""; const seen = new Set(); const candidates: string[] = []; for (const candidate of [ trimNonEmpty(config.env.SHELL), config.userShell, trimNonEmpty(fallback), ]) { if (Option.isNone(candidate) || seen.has(candidate.value)) continue; seen.add(candidate.value); candidates.push(candidate.value); } return candidates; }; const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ ...trimNonEmpty(env.APPDATA).pipe( Option.match({ onNone: () => [], onSome: (value) => [`${value}\\npm`], }), ), ...trimNonEmpty(env.LOCALAPPDATA).pipe( Option.match({ onNone: () => [], onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`], }), ), ...trimNonEmpty(env.USERPROFILE).pipe( Option.match({ onNone: () => [], onSome: (value) => [`${value}\\.local\\bin`, `${value}\\.bun\\bin`, `${value}\\scoop\\shims`], }), ), ]; const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; const executableName = (command: string): string => command.split(/[\\/]/u).at(-1) ?? command; const logShellEnvironmentCommandError = ( error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError, ) => Effect.logWarning(error).pipe( Effect.annotateLogs({ component: "desktop-shell-environment", error, }), ); const capturePosixEnvironmentCommand = (names: ReadonlyArray) => names .map((name) => { return [ `printf '%s\\n' '${startMarker(name)}'`, `printenv ${name} || true`, `printf '%s\\n' '${endMarker(name)}'`, ].join("; "); }) .join("; "); const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => [ "$ErrorActionPreference = 'Stop'", ...names.flatMap((name) => { return [ `Write-Output '${startMarker(name)}'`, `$value = [Environment]::GetEnvironmentVariable('${name}')`, "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", `Write-Output '${endMarker(name)}'`, ]; }), ].join("; "); const extractEnvironment = (output: string, names: ReadonlyArray): EnvironmentPatch => { const environment: EnvironmentPatch = {}; for (const name of names) { const start = output.indexOf(startMarker(name)); if (start === -1) continue; const valueStart = start + startMarker(name).length; const end = output.indexOf(endMarker(name), valueStart); if (end === -1) continue; const value = output .slice(valueStart, end) .replace(/^\r?\n/, "") .replace(/\r?\n$/, ""); if (value.length > 0) { environment[name] = value; } } return environment; }; const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { readonly probe: DesktopShellEnvironmentProbe; readonly command: string; readonly args: ReadonlyArray; readonly timeout: Duration.Duration; readonly shell?: boolean; }): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const output = yield* spawner .string( ChildProcess.make(input.command, input.args, { shell: input.shell ?? false, stdin: "ignore", stdout: "pipe", stderr: "pipe", killSignal: "SIGTERM", forceKillAfter: PROCESS_TERMINATE_GRACE, }), ) .pipe( Effect.mapError( (cause) => new DesktopShellEnvironmentCommandError({ probe: input.probe, executable: executableName(input.command), argumentCount: input.args.length, cause, }), ), Effect.catchTags({ DesktopShellEnvironmentCommandError: (error) => logShellEnvironmentCommandError(error).pipe(Effect.as("")), }), Effect.timeoutOption(input.timeout), ); if (Option.isSome(output)) { return output.value; } const error = new DesktopShellEnvironmentCommandTimeoutError({ probe: input.probe, executable: executableName(input.command), argumentCount: input.args.length, timeoutMs: Duration.toMillis(input.timeout), }); yield* logShellEnvironmentCommandError(error); return ""; }); const readLoginShellEnvironment = ( shell: string, names: ReadonlyArray, ): Effect.Effect => names.length === 0 ? Effect.succeed({}) : runCommandOutput({ probe: "login-shell", command: shell, args: ["-ilc", capturePosixEnvironmentCommand(names)], timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); const readLaunchctlPath = runCommandOutput({ probe: "launchctl-path", command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, }).pipe(Effect.map(trimNonEmpty)); const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEnvironment")( function* ( names: ReadonlyArray, options: WindowsProbeOptions, ): Effect.fn.Return { if (names.length === 0) return {}; const args = [ "-NoLogo", ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), "-NonInteractive", "-Command", captureWindowsEnvironmentCommand(names), ]; for (const command of WINDOWS_SHELL_CANDIDATES) { const output = yield* runCommandOutput({ probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, timeout: LOGIN_SHELL_TIMEOUT, }); const environment = extractEnvironment(output, names); if (Object.keys(environment).length > 0) { return environment; } } return {}; }, ); const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWindowsEnvironment")( function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return { // Concurrent, not sequential: these two probes are independent (only their // results are combined below) and each spawns its own PowerShell. Run in // series they sit at offset 0 of desktop.startup, before anything else, and // launch traces measured them at 2718ms then 2066ms — the entire 4.8s // startup span, of which desktop.bootstrap is ~30ms. const [noProfile, profile] = yield* Effect.all( [ readWindowsEnvironment(["PATH"], { loadProfile: false }), readWindowsEnvironment(WINDOWS_PROFILE_ENV_NAMES, { loadProfile: true }), ], { concurrency: 2 }, ); const mergedPath = mergePaths("win32", [ trimNonEmpty(profile.PATH), trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), trimNonEmpty(noProfile.PATH), readEnvPath(config.env), ]); if (Option.isSome(mergedPath)) { config.env.PATH = mergedPath.value; } if (!config.env.FNM_DIR && profile.FNM_DIR) { config.env.FNM_DIR = profile.FNM_DIR; } if (!config.env.FNM_MULTISHELL_PATH && profile.FNM_MULTISHELL_PATH) { config.env.FNM_MULTISHELL_PATH = profile.FNM_MULTISHELL_PATH; } }, ); const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosixEnvironment")( function* ( config: ShellEnvironmentConfig, ): Effect.fn.Return< void, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem > { const fileSystem = yield* FileSystem.FileSystem; const shellEnvironment: EnvironmentPatch = {}; for (const shell of listLoginShellCandidates(config)) { Object.assign( shellEnvironment, yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), ); if (shellEnvironment.PATH) break; } const launchctlPath = config.platform === "darwin" && !shellEnvironment.PATH ? yield* readLaunchctlPath : Option.none(); const mergedPath = mergePaths(config.platform, [ trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), readEnvPath(config.env), ]); if (Option.isSome(mergedPath)) { config.env.PATH = mergedPath.value; } if (!config.env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { config.env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; } const shellPreferredEnvNames = [ "DBUS_SESSION_BUS_ADDRESS", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", ] as const; for (const name of shellPreferredEnvNames) { if (shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; } } for (const name of [ "DISPLAY", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "WAYLAND_DISPLAY", ] as const) { if (!config.env[name] && shellEnvironment[name]) { config.env[name] = shellEnvironment[name]; } } // Locale variables form one precedence group: LC_ALL can override an inherited // LANG or LC_CTYPE, so only hydrate the group when the process has none of them. if ( config.platform === "darwin" && LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name]))) ) { for (const name of LOCALE_ENV_NAMES) { const value = trimNonEmpty(shellEnvironment[name]); if (Option.isSome(value)) { config.env[name] = value.value; } } // GUI launches inherit no locale from launchd, so spawned agents land in the C // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale // available on those releases. Leaving LANG unset keeps C-stable collation and // formatting, so output parsing is unaffected. if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { config.env.LC_CTYPE = FALLBACK_LC_CTYPE; } } if ( config.platform === "linux" && Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS)) ) { for (const runtimeDir of linuxRuntimeDirCandidates(config.env, process.getuid?.())) { const dbusSessionBusPath = `${runtimeDir}/bus`; const busExists = yield* fileSystem .exists(dbusSessionBusPath) .pipe(Effect.orElseSucceed(() => false)); if (busExists) { config.env.DBUS_SESSION_BUS_ADDRESS = `unix:path=${dbusSessionBusPath}`; break; } } } }, ); const installShellEnvironment = ( config: ShellEnvironmentConfig, ): Effect.Effect => { if (config.platform === "win32") { return installWindowsEnvironment(config); } if (config.platform === "darwin" || config.platform === "linux") { return installPosixEnvironment(config); } return Effect.void; }; export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] = installShellEnvironment({ env: process.env, platform: environment.platform, userShell: Option.none(), }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.withSpan("desktop.shellEnvironment.installIntoProcess"), ); return DesktopShellEnvironment.of({ installIntoProcess }); }); export const layer = Layer.effect(DesktopShellEnvironment, make);