226 lines · 8.9 KB · TypeScript
RawDownload
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 service materializes the archive into a real, version-keyed// directory the first time the WSL backend starts, and reuses it afterwards —// so only users who enable WSL ever pay for a loose copy of the server tree.//// 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.TaggedErrorClass<DesktopWslServerTreeExtractError>()(  "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<WslServerTreeResult>;  }>()("@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 = <Node, E, R>(  roots: ReadonlyArray<Node>,  visit: (node: Node) => Effect.Effect<ReadonlyArray<Node>, E, R>,): Effect.Effect<void, E, R> =>  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<void, PlatformError.PlatformError> =>  forEachBoundedTree<CopyTreeEntry, PlatformError.PlatformError, never>(    [{ 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 [];      }),  ); 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 ensure: Effect.Effect<WslServerTreeResult> = 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 });}); export const layer = Layer.effect(DesktopWslServerTree, make); export interface DesktopWslServerTreeTestStub {  readonly result?: WslServerTreeResult;} 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 }),      });    }),  );