import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; // Packaged Windows builds ship the server tree inside resources/server.asar // (see scripts/build-desktop-artifact.ts). The Windows primary reads it in // place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL // backend launches plain `wsl.exe -- node`, which cannot read an asar archive. // This fallback service materializes the archive into a real, version-keyed // directory only when the distro-local runtime cannot be prepared. // // Reading through Electron's patched fs also transparently returns the // contents of files that electron-builder/asar left in the server.asar.unpacked // sibling (native binaries), so a single walk of the archive yields the // complete tree. export type WslServerTreeResult = | { readonly ok: true; readonly root: string } | { readonly ok: false; readonly reason: string; readonly fatal: boolean }; const MARKER_FILE_NAME = "t3code-wsl-server-tree.json"; const COPY_CONCURRENCY = 8; const Marker = Schema.Struct({ version: Schema.String }); const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker)); const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker)); export class DesktopWslServerTreeExtractError extends Schema.TaggedError()( "DesktopWslServerTreeExtractError", { targetDir: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Failed to extract the WSL server tree to ${this.targetDir}.`; } } export class DesktopWslServerTree extends Context.Service< DesktopWslServerTree, { // Resolves the directory the WSL backend should treat as the app root // (the directory containing apps/server/dist and node_modules). In dev // the checkout already is that directory; packaged Windows builds extract // server.asar on first use. readonly ensure: Effect.Effect; // Removes the Windows-side extraction cache after a distro-local runtime // has proven healthy. Serialized with ensure so cleanup cannot race an // extraction that the mounted fallback is preparing. readonly cleanupLegacy: Effect.Effect; } >()("@t3tools/desktop/wsl/DesktopWslServerTree") {} // Child scheduling stays here instead of inside `visit`, so nested directories // cannot create independent concurrency pools. The LIFO work list also keeps // traversal memory proportional to the remaining frontier rather than the // number of active fibers. export const forEachBoundedTree = ( roots: ReadonlyArray, visit: (node: Node) => Effect.Effect, E, R>, ): Effect.Effect => Effect.gen(function* () { const pending = [...roots]; while (pending.length > 0) { const batch = pending.splice(-COPY_CONCURRENCY); const children = yield* Effect.forEach(batch, visit, { concurrency: COPY_CONCURRENCY, }); for (const entries of children) { pending.push(...entries); } } }); interface CopyTreeEntry { readonly sourcePath: string; readonly targetPath: string; } // Copy using only operations supported by Electron's asar-patched fs. Symlinks // are not expected because the sidecar is installed with a hoisted, physical // layout; anything that is neither a file nor a directory is skipped. const copyTree = ( fs: FileSystem.FileSystem, join: (first: string, ...rest: string[]) => string, from: string, to: string, ): Effect.Effect => forEachBoundedTree( [{ sourcePath: from, targetPath: to }], ({ sourcePath, targetPath }) => Effect.gen(function* () { const info = yield* fs.stat(sourcePath); if (info.type === "Directory") { yield* fs.makeDirectory(targetPath, { recursive: true }); const entries = yield* fs.readDirectory(sourcePath); return entries.map((entry) => ({ sourcePath: join(sourcePath, entry), targetPath: join(targetPath, entry), })); } if (info.type === "File") { // Read and write stay in the same bounded task, so at most eight file // buffers can be retained while their writes complete. const bytes = yield* fs.readFile(sourcePath); yield* fs.writeFile(targetPath, bytes); } return []; }), ); /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fs = yield* FileSystem.FileSystem; const join = environment.path.join; const serverRoot = environment.serverRoot; const needsExtraction = environment.isPackaged && environment.platform === "win32"; const treeRoot = join(environment.stateDir, "wsl-server-tree"); const version = environment.appVersion; const versionDir = join(treeRoot, version); // Remove sibling trees left behind by previous app versions (and aborted // extractions). Best-effort: a locked file must not block the backend. const sweepStale = Effect.gen(function* () { const entries = yield* fs.readDirectory(treeRoot).pipe(Effect.orElseSucceed(() => [])); yield* Effect.forEach( entries.filter((entry) => entry !== version), (entry) => fs.remove(join(treeRoot, entry), { recursive: true }).pipe(Effect.ignore), { discard: true }, ); }); const markerMatches = Effect.gen(function* () { const raw = yield* fs.readFileString(join(versionDir, MARKER_FILE_NAME)); const marker = yield* decodeMarker(raw); return marker.version === version; }).pipe(Effect.orElseSucceed(() => false)); const extract = Effect.gen(function* () { yield* Effect.log(`[wsl-server-tree] Extracting ${serverRoot} to ${versionDir}...`); yield* fs.makeDirectory(treeRoot, { recursive: true }); // Keep the temporary tree beside the target so rename is atomic. Cleanup // is owned explicitly because a scoped temp-directory finalizer treats the // successful rename (and therefore missing original path) as an error. const partialDir = yield* fs.makeTempDirectory({ directory: treeRoot, prefix: `.${version}.extract-`, }); yield* Effect.gen(function* () { yield* copyTree(fs, join, serverRoot, partialDir); const markerJson = yield* encodeMarker({ version }); yield* fs.writeFileString(join(partialDir, MARKER_FILE_NAME), `${markerJson}\n`); // The marker is written before the rename, so a directory named after // the version is complete by construction. yield* fs.remove(versionDir, { recursive: true }).pipe(Effect.ignore); yield* fs.rename(partialDir, versionDir); }).pipe( Effect.ensuring(fs.remove(partialDir, { recursive: true, force: true }).pipe(Effect.ignore)), ); yield* Effect.log(`[wsl-server-tree] Extraction complete at ${versionDir}.`); }).pipe( Effect.mapError( (cause) => new DesktopWslServerTreeExtractError({ targetDir: versionDir, cause }), ), ); // Serialize concurrent ensure calls (backend restarts can overlap): the // first caller extracts, later callers see the marker and reuse the tree. const gate = yield* Semaphore.make(1); const cleanupLegacy = gate .withPermits(1)( needsExtraction ? Effect.gen(function* () { // Invalidate completeness before recursive deletion. Windows can // remove part of a tree and then fail on a locked file; without // this ordering, a surviving marker makes ensure reuse that // half-deleted fallback instead of extracting it again. yield* fs.remove(join(versionDir, MARKER_FILE_NAME), { force: true }); yield* fs.remove(treeRoot, { recursive: true, force: true }); }).pipe( Effect.catch((cause) => Effect.logWarning("[wsl-server-tree] Could not remove the legacy extraction cache.", { treeRoot, cause, }), ), ) : Effect.void, ) .pipe(Effect.withSpan("desktop.wslServerTree.cleanupLegacy")); const ensure: Effect.Effect = gate .withPermits(1)( Effect.gen(function* () { if (!needsExtraction) { return { ok: true, root: serverRoot } as const; } if (yield* markerMatches) { yield* sweepStale; return { ok: true, root: versionDir } as const; } const result = yield* extract.pipe( Effect.map(() => ({ ok: true, root: versionDir }) as const), // Retryable: transient antivirus locks and slow disks are the common // causes, and the backend manager already bounds preflight retries. Effect.catch((error) => Effect.succeed({ ok: false, reason: `WSL server files could not be extracted to ${versionDir}: ${ error.cause instanceof Error ? error.cause.message : String(error.cause) }`, fatal: false, } as const), ), ); if (result.ok) { yield* sweepStale; } return result; }), ) .pipe(Effect.withSpan("desktop.wslServerTree.ensure")); return DesktopWslServerTree.of({ ensure, cleanupLegacy }); }); export const layer = Layer.effect(DesktopWslServerTree, make); export interface DesktopWslServerTreeTestStub { readonly result?: WslServerTreeResult; readonly cleanupLegacy?: Effect.Effect; } export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => Layer.effect( DesktopWslServerTree, Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; return DesktopWslServerTree.of({ ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }), cleanupLegacy: stub.cleanupLegacy ?? Effect.void, }); }), );