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 Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildRemoteNodeEnvScript } from "@t3tools/ssh/tunnel"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { parseWslDistroList, type WslDistro } from "./wslPathParsing.ts"; const PROCESS_TERMINATE_GRACE = Duration.seconds(1); const LIST_TIMEOUT = Duration.seconds(8); const PRE_WARM_TIMEOUT = Duration.seconds(10); const WSLPATH_TIMEOUT = Duration.seconds(10); const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); const BUILD_TIMEOUT = Duration.minutes(5); const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2); const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30); const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15); const USER_HOME_TIMEOUT = Duration.seconds(5); const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12; const BUILD_TRANSPORT_RETRY_LIMIT = 2; export interface EnsureWslNodePtyOptions { readonly allowBuild?: boolean; readonly nodeEngineRange?: string | null; } // The packaged WSL runtime archive plus the SHA-256 identity the build recorded // for it. The cache key derives from the same digest, and installation verifies // the bytes before promoting the extracted tree. export interface WslRuntimeArchive { readonly windowsPath: string; readonly runtimeId: string; readonly sha256: string; } export type PrepareWslRuntimeResult = | { readonly ok: true; readonly linuxAppRoot: string; } | { readonly ok: false; readonly reason: string; }; export type EnsureWslNodePtyResult = | { readonly ok: true; readonly nodePath: string; readonly resolvedPath: string; } | { readonly ok: false; readonly reason: string; readonly fatal: boolean; readonly retryLimit?: number; }; export class DesktopWslDistroListError extends Schema.TaggedErrorClass()( "DesktopWslDistroListError", { reason: Schema.String }, ) { override get message(): string { return this.reason; } } const isDesktopWslDistroListError = Schema.is(DesktopWslDistroListError); export class DesktopWslEnvironment extends Context.Service< DesktopWslEnvironment, { readonly isAvailable: Effect.Effect; // Best-effort enumeration for renderer UX. Backend health checks must use // probeDistros so a transient command failure is not mistaken for a // successful empty installation. readonly listDistros: Effect.Effect; readonly probeDistros: Effect.Effect; readonly preWarm: (distro: string | null) => Effect.Effect; readonly windowsToWslPath: ( distro: string | null, windowsPath: string, ) => Effect.Effect>; // Resolves the user's Linux home dir inside the chosen distro (e.g. // "/home/josh"). Used by the folder picker to expand `~` correctly. readonly getUserHome: (distro: string | null) => Effect.Effect>; // Resolves the WSL distro's IPv4 address on the WSL vEthernet adapter // (e.g. "172.x.x.x"). The orchestrator uses this for the WSL backend's // httpBaseUrl so the renderer can reach it without relying on wslhost's // localhost→WSL automatic forwarding, which is flaky in practice // (the backend can be listening for 30+ seconds before wslhost starts // forwarding 127.0.0.1:port to WSL-side localhost). readonly getDistroIp: (distro: string | null) => Effect.Effect>; readonly prepareRuntime: ( distro: string | null, archive: WslRuntimeArchive, ) => Effect.Effect; readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect; // Marks a staged runtime as unusable so the next launch reinstalls it. readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect; readonly ensureNodePty: ( distro: string | null, linuxAppRoot: string, options?: EnsureWslNodePtyOptions, ) => Effect.Effect; } >()("@t3tools/desktop/wsl/DesktopWslEnvironment") {} const buildDistroArgs = (distro: string | null): ReadonlyArray => distro ? ["-d", distro] : []; const concatChunks = (arrays: ReadonlyArray): Uint8Array => { let totalLength = 0; for (const arr of arrays) totalLength += arr.byteLength; const out = new Uint8Array(totalLength); let offset = 0; for (const arr of arrays) { out.set(arr, offset); offset += arr.byteLength; } return out; }; const decodeUtf8 = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes); interface ShellResult { readonly exitCode: number; readonly stdout: string; readonly stderr: string; readonly transportFailure: "timeout" | "spawn" | "process" | null; } const TIMEOUT_RESULT: ShellResult = { exitCode: 124, stdout: "", stderr: "\n[timeout]", transportFailure: "timeout", }; export const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], ): string | null => { switch (failure) { case "timeout": return "WSL backend preflight timed out while probing for Node.js. WSL may be slow to start; retry, or check that the distro is healthy."; case "spawn": return "WSL backend preflight could not start wsl.exe to probe for Node.js. Check that WSL is installed and the distro is accessible."; case "process": return "WSL backend preflight lost communication with wsl.exe while probing for Node.js. Retry, or check that the distro is healthy."; case null: return null; } }; // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node // the same way. Passing the engine range lets the resolver fall through to // version managers like nvm when a system node exists but is too old. export const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null, ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} ensure_remote_node_path || true `; // wsl.exe re-escapes args before forwarding them to the Linux side, which // mangles quotes inside `bash -lc "