1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as PlatformError from "effect/PlatformError";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8
9import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
10
11// Packaged Windows builds ship the server tree inside resources/server.asar
12// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in
13// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL
14// backend launches plain `wsl.exe -- node`, which cannot read an asar archive.
15// This fallback service materializes the archive into a real, version-keyed
16// directory only when the distro-local runtime cannot be prepared.
17//
18// Reading through Electron's patched fs also transparently returns the
19// contents of files that electron-builder/asar left in the server.asar.unpacked
20// sibling (native binaries), so a single walk of the archive yields the
21// complete tree.
22
23export type WslServerTreeResult =
24 | { readonly ok: true; readonly root: string }
25 | { readonly ok: false; readonly reason: string; readonly fatal: boolean };
26
27const MARKER_FILE_NAME = "t3code-wsl-server-tree.json";
28const COPY_CONCURRENCY = 8;
29
30const Marker = Schema.Struct({ version: Schema.String });
31const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker));
32const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker));
33
34export class DesktopWslServerTreeExtractError extends Schema.TaggedError<DesktopWslServerTreeExtractError>()(
35 "DesktopWslServerTreeExtractError",
36 {
37 targetDir: Schema.String,
38 cause: Schema.Defect(),
39 },
40) {
41 override get message(): string {
42 return `Failed to extract the WSL server tree to ${this.targetDir}.`;
43 }
44}
45
46export class DesktopWslServerTree extends Context.Service<
47 DesktopWslServerTree,
48 {
49 // Resolves the directory the WSL backend should treat as the app root
50 // (the directory containing apps/server/dist and node_modules). In dev
51 // the checkout already is that directory; packaged Windows builds extract
52 // server.asar on first use.
53 readonly ensure: Effect.Effect<WslServerTreeResult>;
54 // Removes the Windows-side extraction cache after a distro-local runtime
55 // has proven healthy. Serialized with ensure so cleanup cannot race an
56 // extraction that the mounted fallback is preparing.
57 readonly cleanupLegacy: Effect.Effect<void>;
58 }
59>()("@t3tools/desktop/wsl/DesktopWslServerTree") {}
60
61// Child scheduling stays here instead of inside `visit`, so nested directories
62// cannot create independent concurrency pools. The LIFO work list also keeps
63// traversal memory proportional to the remaining frontier rather than the
64// number of active fibers.
65export const forEachBoundedTree = <Node, E, R>(
66 roots: ReadonlyArray<Node>,
67 visit: (node: Node) => Effect.Effect<ReadonlyArray<Node>, E, R>,
68): Effect.Effect<void, E, R> =>
69 Effect.gen(function* () {
70 const pending = [...roots];
71 while (pending.length > 0) {
72 const batch = pending.splice(-COPY_CONCURRENCY);
73 const children = yield* Effect.forEach(batch, visit, {
74 concurrency: COPY_CONCURRENCY,
75 });
76 for (const entries of children) {
77 pending.push(...entries);
78 }
79 }
80 });
81
82interface CopyTreeEntry {
83 readonly sourcePath: string;
84 readonly targetPath: string;
85}
86
87// Copy using only operations supported by Electron's asar-patched fs. Symlinks
88// are not expected because the sidecar is installed with a hoisted, physical
89// layout; anything that is neither a file nor a directory is skipped.
90const copyTree = (
91 fs: FileSystem.FileSystem,
92 join: (first: string, ...rest: string[]) => string,
93 from: string,
94 to: string,
95): Effect.Effect<void, PlatformError.PlatformError> =>
96 forEachBoundedTree<CopyTreeEntry, PlatformError.PlatformError, never>(
97 [{ sourcePath: from, targetPath: to }],
98 ({ sourcePath, targetPath }) =>
99 Effect.gen(function* () {
100 const info = yield* fs.stat(sourcePath);
101 if (info.type === "Directory") {
102 yield* fs.makeDirectory(targetPath, { recursive: true });
103 const entries = yield* fs.readDirectory(sourcePath);
104 return entries.map((entry) => ({
105 sourcePath: join(sourcePath, entry),
106 targetPath: join(targetPath, entry),
107 }));
108 }
109 if (info.type === "File") {
110 // Read and write stay in the same bounded task, so at most eight file
111 // buffers can be retained while their writes complete.
112 const bytes = yield* fs.readFile(sourcePath);
113 yield* fs.writeFile(targetPath, bytes);
114 }
115 return [];
116 }),
117 );
118
119/** @public Service construction is part of the canonical Effect module API. */
120export const make = Effect.gen(function* () {
121 const environment = yield* DesktopEnvironment.DesktopEnvironment;
122 const fs = yield* FileSystem.FileSystem;
123 const join = environment.path.join;
124
125 const serverRoot = environment.serverRoot;
126 const needsExtraction = environment.isPackaged && environment.platform === "win32";
127 const treeRoot = join(environment.stateDir, "wsl-server-tree");
128 const version = environment.appVersion;
129 const versionDir = join(treeRoot, version);
130
131 // Remove sibling trees left behind by previous app versions (and aborted
132 // extractions). Best-effort: a locked file must not block the backend.
133 const sweepStale = Effect.gen(function* () {
134 const entries = yield* fs.readDirectory(treeRoot).pipe(Effect.orElseSucceed(() => []));
135 yield* Effect.forEach(
136 entries.filter((entry) => entry !== version),
137 (entry) => fs.remove(join(treeRoot, entry), { recursive: true }).pipe(Effect.ignore),
138 { discard: true },
139 );
140 });
141
142 const markerMatches = Effect.gen(function* () {
143 const raw = yield* fs.readFileString(join(versionDir, MARKER_FILE_NAME));
144 const marker = yield* decodeMarker(raw);
145 return marker.version === version;
146 }).pipe(Effect.orElseSucceed(() => false));
147
148 const extract = Effect.gen(function* () {
149 yield* Effect.log(`[wsl-server-tree] Extracting ${serverRoot} to ${versionDir}...`);
150 yield* fs.makeDirectory(treeRoot, { recursive: true });
151 // Keep the temporary tree beside the target so rename is atomic. Cleanup
152 // is owned explicitly because a scoped temp-directory finalizer treats the
153 // successful rename (and therefore missing original path) as an error.
154 const partialDir = yield* fs.makeTempDirectory({
155 directory: treeRoot,
156 prefix: `.${version}.extract-`,
157 });
158 yield* Effect.gen(function* () {
159 yield* copyTree(fs, join, serverRoot, partialDir);
160 const markerJson = yield* encodeMarker({ version });
161 yield* fs.writeFileString(join(partialDir, MARKER_FILE_NAME), `${markerJson}\n`);
162 // The marker is written before the rename, so a directory named after
163 // the version is complete by construction.
164 yield* fs.remove(versionDir, { recursive: true }).pipe(Effect.ignore);
165 yield* fs.rename(partialDir, versionDir);
166 }).pipe(
167 Effect.ensuring(fs.remove(partialDir, { recursive: true, force: true }).pipe(Effect.ignore)),
168 );
169 yield* Effect.log(`[wsl-server-tree] Extraction complete at ${versionDir}.`);
170 }).pipe(
171 Effect.mapError(
172 (cause) => new DesktopWslServerTreeExtractError({ targetDir: versionDir, cause }),
173 ),
174 );
175
176 // Serialize concurrent ensure calls (backend restarts can overlap): the
177 // first caller extracts, later callers see the marker and reuse the tree.
178 const gate = yield* Semaphore.make(1);
179
180 const cleanupLegacy = gate
181 .withPermits(1)(
182 needsExtraction
183 ? Effect.gen(function* () {
184 // Invalidate completeness before recursive deletion. Windows can
185 // remove part of a tree and then fail on a locked file; without
186 // this ordering, a surviving marker makes ensure reuse that
187 // half-deleted fallback instead of extracting it again.
188 yield* fs.remove(join(versionDir, MARKER_FILE_NAME), { force: true });
189 yield* fs.remove(treeRoot, { recursive: true, force: true });
190 }).pipe(
191 Effect.catch((cause) =>
192 Effect.logWarning("[wsl-server-tree] Could not remove the legacy extraction cache.", {
193 treeRoot,
194 cause,
195 }),
196 ),
197 )
198 : Effect.void,
199 )
200 .pipe(Effect.withSpan("desktop.wslServerTree.cleanupLegacy"));
201
202 const ensure: Effect.Effect<WslServerTreeResult> = gate
203 .withPermits(1)(
204 Effect.gen(function* () {
205 if (!needsExtraction) {
206 return { ok: true, root: serverRoot } as const;
207 }
208 if (yield* markerMatches) {
209 yield* sweepStale;
210 return { ok: true, root: versionDir } as const;
211 }
212 const result = yield* extract.pipe(
213 Effect.map(() => ({ ok: true, root: versionDir }) as const),
214 // Retryable: transient antivirus locks and slow disks are the common
215 // causes, and the backend manager already bounds preflight retries.
216 Effect.catch((error) =>
217 Effect.succeed({
218 ok: false,
219 reason: `WSL server files could not be extracted to ${versionDir}: ${
220 error.cause instanceof Error ? error.cause.message : String(error.cause)
221 }`,
222 fatal: false,
223 } as const),
224 ),
225 );
226 if (result.ok) {
227 yield* sweepStale;
228 }
229 return result;
230 }),
231 )
232 .pipe(Effect.withSpan("desktop.wslServerTree.ensure"));
233
234 return DesktopWslServerTree.of({ ensure, cleanupLegacy });
235});
236
237export const layer = Layer.effect(DesktopWslServerTree, make);
238
239export interface DesktopWslServerTreeTestStub {
240 readonly result?: WslServerTreeResult;
241 readonly cleanupLegacy?: Effect.Effect<void>;
242}
243
244export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
245 Layer.effect(
246 DesktopWslServerTree,
247 Effect.gen(function* () {
248 const environment = yield* DesktopEnvironment.DesktopEnvironment;
249 return DesktopWslServerTree.of({
250 ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }),
251 cleanupLegacy: stub.cleanupLegacy ?? Effect.void,
252 });
253 }),
254 );