Open History to see the last commit
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// WSL's plain Node cannot read server.asar. Electron's patched fs also reads its unpacked native siblings.
12
13export type WslServerTreeResult =
14 | { readonly ok: true; readonly root: string }
15 | { readonly ok: false; readonly reason: string; readonly fatal: boolean };
16
17const MARKER_FILE_NAME = "t3code-wsl-server-tree.json";
18const COPY_CONCURRENCY = 8;
19
20const Marker = Schema.Struct({ version: Schema.String });
21const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker));
22const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker));
23
24export class DesktopWslServerTreeExtractError extends Schema.TaggedError<DesktopWslServerTreeExtractError>()(
25 "DesktopWslServerTreeExtractError",
26 {
27 targetDir: Schema.String,
28 cause: Schema.Defect(),
29 },
30) {
31 override get message(): string {
32 return `Failed to extract the WSL server tree to ${this.targetDir}.`;
33 }
34}
35
36export class DesktopWslServerTree extends Context.Service<
37 DesktopWslServerTree,
38 {
39 readonly ensure: Effect.Effect<WslServerTreeResult>;
40 readonly cleanupLegacy: Effect.Effect<void>;
41 }
42>()("@t3tools/desktop/wsl/DesktopWslServerTree") {}
43
44export const forEachBoundedTree = Effect.fn("DesktopWslServerTree.forEachBoundedTree")(function* <
45 Node,
46 E,
47 R,
48>(
49 roots: ReadonlyArray<Node>,
50 visit: (node: Node) => Effect.Effect<ReadonlyArray<Node>, E, R>,
51): Effect.fn.Return<void, E, R> {
52 const pending = [...roots];
53 while (pending.length > 0) {
54 const batch = pending.splice(-COPY_CONCURRENCY);
55 const children = yield* Effect.forEach(batch, visit, {
56 concurrency: COPY_CONCURRENCY,
57 });
58 for (const entries of children) {
59 pending.push(...entries);
60 }
61 }
62});
63
64interface CopyTreeEntry {
65 readonly sourcePath: string;
66 readonly targetPath: string;
67}
68
69const copyTree = (
70 fs: FileSystem.FileSystem,
71 join: (first: string, ...rest: string[]) => string,
72 from: string,
73 to: string,
74): Effect.Effect<void, PlatformError.PlatformError> =>
75 forEachBoundedTree<CopyTreeEntry, PlatformError.PlatformError, never>(
76 [{ sourcePath: from, targetPath: to }],
77 ({ sourcePath, targetPath }) =>
78 Effect.gen(function* () {
79 const info = yield* fs.stat(sourcePath);
80 if (info.type === "Directory") {
81 yield* fs.makeDirectory(targetPath, { recursive: true });
82 const entries = yield* fs.readDirectory(sourcePath);
83 return entries.map((entry) => ({
84 sourcePath: join(sourcePath, entry),
85 targetPath: join(targetPath, entry),
86 }));
87 }
88 if (info.type === "File") {
89 const bytes = yield* fs.readFile(sourcePath);
90 yield* fs.writeFile(targetPath, bytes);
91 }
92 return [];
93 }),
94 );
95
96/** @public */
97export const make = Effect.gen(function* () {
98 const environment = yield* DesktopEnvironment.DesktopEnvironment;
99 const fs = yield* FileSystem.FileSystem;
100 const join = environment.path.join;
101
102 const serverRoot = environment.serverRoot;
103 const needsExtraction = environment.isPackaged && environment.platform === "win32";
104 const treeRoot = join(environment.stateDir, "wsl-server-tree");
105 const version = environment.appVersion;
106 const versionDir = join(treeRoot, version);
107
108 const sweepStale = Effect.gen(function* () {
109 const entries = yield* fs.readDirectory(treeRoot).pipe(Effect.orElseSucceed(() => []));
110 yield* Effect.forEach(
111 entries.filter((entry) => entry !== version),
112 (entry) => fs.remove(join(treeRoot, entry), { recursive: true }).pipe(Effect.ignore),
113 { discard: true },
114 );
115 });
116
117 const markerMatches = Effect.gen(function* () {
118 const raw = yield* fs.readFileString(join(versionDir, MARKER_FILE_NAME));
119 const marker = yield* decodeMarker(raw);
120 return marker.version === version;
121 }).pipe(Effect.orElseSucceed(() => false));
122
123 const extract = Effect.gen(function* () {
124 yield* Effect.log(`[wsl-server-tree] Extracting ${serverRoot} to ${versionDir}...`);
125 yield* fs.makeDirectory(treeRoot, { recursive: true });
126 // A sibling temp directory permits atomic rename; explicit cleanup avoids a scoped finalizer failing after the move.
127 const partialDir = yield* fs.makeTempDirectory({
128 directory: treeRoot,
129 prefix: `.${version}.extract-`,
130 });
131 yield* Effect.gen(function* () {
132 yield* copyTree(fs, join, serverRoot, partialDir);
133 const markerJson = yield* encodeMarker({ version });
134 yield* fs.writeFileString(join(partialDir, MARKER_FILE_NAME), `${markerJson}\n`);
135 yield* fs.remove(versionDir, { recursive: true }).pipe(Effect.ignore);
136 yield* fs.rename(partialDir, versionDir);
137 }).pipe(
138 Effect.ensuring(fs.remove(partialDir, { recursive: true, force: true }).pipe(Effect.ignore)),
139 );
140 yield* Effect.log(`[wsl-server-tree] Extraction complete at ${versionDir}.`);
141 }).pipe(
142 Effect.mapError(
143 (cause) => new DesktopWslServerTreeExtractError({ targetDir: versionDir, cause }),
144 ),
145 );
146
147 const gate = yield* Semaphore.make(1);
148
149 const cleanupLegacy = gate
150 .withPermits(1)(
151 needsExtraction
152 ? Effect.gen(function* () {
153 // Delete completeness first: a locked Windows file can leave a half-deleted tree that must not be reused.
154 yield* fs.remove(join(versionDir, MARKER_FILE_NAME), { force: true });
155 yield* fs.remove(treeRoot, { recursive: true, force: true });
156 }).pipe(
157 Effect.catch((cause) =>
158 Effect.logWarning("[wsl-server-tree] Could not remove the legacy extraction cache.", {
159 treeRoot,
160 cause,
161 }),
162 ),
163 )
164 : Effect.void,
165 )
166 .pipe(Effect.withSpan("desktop.wslServerTree.cleanupLegacy"));
167
168 const ensure: Effect.Effect<WslServerTreeResult> = gate
169 .withPermits(1)(
170 Effect.gen(function* () {
171 if (!needsExtraction) {
172 return { ok: true, root: serverRoot } as const;
173 }
174 if (yield* markerMatches) {
175 yield* sweepStale;
176 return { ok: true, root: versionDir } as const;
177 }
178 const result = yield* extract.pipe(
179 Effect.map(() => ({ ok: true, root: versionDir }) as const),
180 Effect.catch((error) =>
181 Effect.succeed({
182 ok: false,
183 reason: `WSL server files could not be extracted to ${versionDir}: ${
184 error.cause instanceof Error ? error.cause.message : String(error.cause)
185 }`,
186 fatal: false,
187 } as const),
188 ),
189 );
190 if (result.ok) {
191 yield* sweepStale;
192 }
193 return result;
194 }),
195 )
196 .pipe(Effect.withSpan("desktop.wslServerTree.ensure"));
197
198 return DesktopWslServerTree.of({ ensure, cleanupLegacy });
199});
200
201export const layer = Layer.effect(DesktopWslServerTree, make);
202
203export interface DesktopWslServerTreeTestStub {
204 readonly result?: WslServerTreeResult;
205 readonly cleanupLegacy?: Effect.Effect<void>;
206}
207
208export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
209 Layer.effect(
210 DesktopWslServerTree,
211 Effect.gen(function* () {
212 const environment = yield* DesktopEnvironment.DesktopEnvironment;
213 return DesktopWslServerTree.of({
214 ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }),
215 cleanupLegacy: stub.cleanupLegacy ?? Effect.void,
216 });
217 }),
218 );