| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as PlatformError from "effect/PlatformError"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as Semaphore from "effect/Semaphore"; |
| 8 | |
| 9 | import * 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 | |
| 23 | export type WslServerTreeResult = |
| 24 | | { readonly ok: true; readonly root: string } |
| 25 | | { readonly ok: false; readonly reason: string; readonly fatal: boolean }; |
| 26 | |
| 27 | const MARKER_FILE_NAME = "t3code-wsl-server-tree.json"; |
| 28 | const COPY_CONCURRENCY = 8; |
| 29 | |
| 30 | const Marker = Schema.Struct({ version: Schema.String }); |
| 31 | const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker)); |
| 32 | const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker)); |
| 33 | |
| 34 | export 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 | |
| 46 | export 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. |
| 65 | export 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 | |
| 82 | interface 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. |
| 90 | const 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. */ |
| 120 | export 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 | |
| 237 | export const layer = Layer.effect(DesktopWslServerTree, make); |
| 238 | |
| 239 | export interface DesktopWslServerTreeTestStub { |
| 240 | readonly result?: WslServerTreeResult; |
| 241 | readonly cleanupLegacy?: Effect.Effect<void>; |
| 242 | } |
| 243 | |
| 244 | export 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 | ); |