1// @effect-diagnostics nodeBuiltinImport:off - Release downloads are hashed before extraction.
2import * as NodeCrypto from "node:crypto";
3import {
4 ForkReleaseManifest,
5 ForkRuntimeManifest,
6 ForkReleaseListing,
7 latestForkRelease,
8} from "@t3tools/shared/forkUpdateDirectory";
9import * as Context from "effect/Context";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";
15import { HttpClient } from "effect/http";
16import { ChildProcess, ChildProcessSpawner } from "effect/process";
17
18import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
19import * as DesktopLocalUi from "./DesktopLocalUi.ts";
20import { activeForkRuntime } from "./forkRuntime.ts";
21
22export class DesktopForkUpdateError extends Schema.TaggedError<DesktopForkUpdateError>()(
23 "DesktopForkUpdateError",
24 { message: Schema.String, cause: Schema.Defect() },
25) {}
26
27const isForkUpdateError = Schema.is(DesktopForkUpdateError);
28const decodeReleaseListing = Schema.decodeUnknownEffect(ForkReleaseListing);
29const decodeRelease = Schema.decodeUnknownEffect(ForkReleaseManifest);
30const decodeRuntime = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest));
31
32type Release = typeof ForkReleaseManifest.Type;
33type Artifact = Release["ui"];
34type Prepared = {
35 version: string;
36 directory: string;
37 shell: string;
38 runtime: string;
39 kind: "ui" | "code";
40};
41
42export class DesktopForkUpdates extends Context.Service<
43 DesktopForkUpdates,
44 {
45 readonly check: (base: URL) => Effect.Effect<Release, DesktopForkUpdateError>;
46 readonly kind: (
47 release: Release,
48 ) => Effect.Effect<"ui" | "code" | "shell", DesktopForkUpdateError>;
49 readonly prepare: (
50 base: URL,
51 release: Release,
52 ) => Effect.Effect<"ui" | "code" | "shell", DesktopForkUpdateError>;
53 readonly installUi: (version: string) => Effect.Effect<boolean, DesktopForkUpdateError>;
54 readonly activateCode: (version: string) => Effect.Effect<boolean, DesktopForkUpdateError>;
55 }
56>()("@t3tools/desktop/updates/DesktopForkUpdates") {}
57
58export const make = Effect.gen(function* () {
59 const environment = yield* DesktopEnvironment.DesktopEnvironment;
60 const fs = yield* FileSystem.FileSystem;
61 const path = environment.path;
62 const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
63 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
64 const ui = yield* DesktopLocalUi.DesktopLocalUi;
65 const prepared = yield* Ref.make<Prepared | null>(null);
66 const encodePointer = Schema.encodeEffect(
67 Schema.fromJsonString(
68 Schema.Struct({
69 version: Schema.String,
70 directory: Schema.String,
71 shell: Schema.String,
72 runtime: Schema.String,
73 }),
74 ),
75 );
76 const releasesDir = path.join(environment.stateDir, "fork-releases");
77 const fail = (cause: unknown) =>
78 isForkUpdateError(cause)
79 ? cause
80 : new DesktopForkUpdateError({
81 message: "Could not prepare the update. Try again.",
82 cause,
83 });
84
85 const check = (base: URL) =>
86 Effect.scoped(
87 Effect.gen(function* () {
88 const listingUrl = new URL(base);
89 listingUrl.searchParams.set("ls", "");
90 const client = HttpClient.withScope(http);
91 const response = yield* client.get(listingUrl.href);
92 const listing = yield* decodeReleaseListing(yield* response.json);
93 const version = latestForkRelease(listing);
94 if (version === undefined)
95 return yield* new DesktopForkUpdateError({
96 message: "No updates have been published. Try again later.",
97 cause: "Empty release share",
98 });
99 const manifest = yield* client.get(new URL(`${version}.json`, base).href);
100 const release = yield* decodeRelease(yield* manifest.json);
101 if (release.version !== version)
102 return yield* fail("The published manifest has a different version.");
103 return release;
104 }),
105 ).pipe(Effect.mapError(fail));
106
107 const extract = (base: URL, artifact: Artifact, directory: string) =>
108 Effect.scoped(
109 Effect.gen(function* () {
110 const archive = path.join(releasesDir, artifact.file);
111 const valid = (bytes: Uint8Array) =>
112 bytes.byteLength === artifact.size &&
113 NodeCrypto.createHash("sha256").update(bytes).digest("hex") === artifact.sha256;
114 if (!(yield* fs.exists(archive)) || !valid(yield* fs.readFile(archive))) {
115 const response = yield* HttpClient.withScope(http).get(new URL(artifact.file, base).href);
116 const bytes = new Uint8Array(yield* response.arrayBuffer);
117 if (!valid(bytes))
118 return yield* fail("The downloaded update did not match its checksum.");
119 yield* fs.writeFile(`${archive}.tmp`, bytes);
120 yield* fs.rename(`${archive}.tmp`, archive);
121 }
122 yield* fs.makeDirectory(directory, { recursive: true });
123 const child = yield* spawner.spawn(
124 ChildProcess.make("tar", ["-xzf", archive, "-C", directory]),
125 );
126 if (Number(yield* child.exitCode) !== 0)
127 return yield* fail("Could not extract the update.");
128 }),
129 );
130
131 const kind = (release: Release) =>
132 Effect.gen(function* () {
133 const target = release.desktop[`${environment.platform}-${environment.processArch}`];
134 if (target === undefined) return yield* fail("This release is unavailable for this machine.");
135 const baseline = yield* fs
136 .readFileString(path.join(environment.resourcesPath, "app.asar", "knife-runtime.json"))
137 .pipe(Effect.flatMap(decodeRuntime));
138 if (target.shell !== baseline.shell) return "shell" as const;
139 const runtime = activeForkRuntime()?.runtime ?? baseline.sha256;
140 return target.runtime === runtime ? ("ui" as const) : ("code" as const);
141 }).pipe(Effect.mapError(fail));
142
143 const prepare = (base: URL, release: Release) =>
144 Effect.gen(function* () {
145 const previous = yield* Ref.getAndSet(prepared, null);
146 if (previous !== null) yield* fs.remove(previous.directory, { recursive: true, force: true });
147 const installKind = yield* kind(release);
148 if (installKind === "shell") return installKind;
149 const target = release.desktop[`${environment.platform}-${environment.processArch}`]!;
150 const serverRoot = environment.platform === "win32" ? "server" : ".";
151 yield* fs.makeDirectory(releasesDir, { recursive: true });
152 const directory = yield* fs.makeTempDirectory({ directory: releasesDir, prefix: "release-" });
153 return yield* Effect.gen(function* () {
154 if (installKind === "code") yield* extract(base, target.code, directory);
155 const client =
156 installKind === "ui"
157 ? directory
158 : path.join(directory, serverRoot, "apps/server/dist/client");
159 yield* extract(base, target.assets, client);
160 yield* extract(base, target.ui, client);
161 if (!(yield* fs.exists(path.join(client, "index.html"))))
162 return yield* fail("The update has no completed UI build.");
163 if (installKind === "code") {
164 for (const file of [
165 "apps/desktop/dist-electron/main.cjs",
166 "apps/desktop/dist-electron/preload.cjs",
167 "apps/desktop/dist-electron/compileCache.cjs",
168 path.join(serverRoot, "apps/server/dist/bin.mjs"),
169 ]) {
170 if (!(yield* fs.exists(path.join(directory, file))))
171 return yield* fail("The update is incomplete.");
172 }
173 const probe = yield* spawner.spawn(
174 ChildProcess.make(
175 process.execPath,
176 [path.join(directory, serverRoot, "apps/server/dist/bin.mjs"), "--version"],
177 {
178 env: { ELECTRON_RUN_AS_NODE: "1", T3CODE_VERSION_OVERRIDE: target.version },
179 extendEnv: true,
180 stdout: "ignore",
181 stderr: "ignore",
182 },
183 ),
184 );
185 if (Number(yield* probe.exitCode) !== 0)
186 return yield* fail("The downloaded server could not start.");
187 }
188 yield* fs.readDirectory(releasesDir).pipe(
189 Effect.flatMap((entries) =>
190 Effect.forEach(entries, (name) =>
191 name.endsWith(".tar.gz") &&
192 name !== target.code.file &&
193 name !== target.ui.file &&
194 name !== target.assets.file
195 ? fs.remove(path.join(releasesDir, name), { force: true })
196 : Effect.void,
197 ),
198 ),
199 Effect.ignore,
200 );
201 yield* Ref.set(prepared, {
202 version: target.version,
203 directory,
204 runtime: target.runtime,
205 shell: target.shell,
206 kind: installKind,
207 });
208 return installKind;
209 }).pipe(
210 Effect.onError(() =>
211 fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
212 ),
213 );
214 }).pipe(Effect.scoped, Effect.mapError(fail));
215
216 const installUi = (version: string) =>
217 Effect.gen(function* () {
218 const candidate = yield* Ref.get(prepared);
219 if (candidate?.version !== version || candidate.kind !== "ui") return false;
220 yield* ui.apply(candidate.directory, version);
221 yield* Ref.set(prepared, null);
222 yield* fs.remove(candidate.directory, { recursive: true, force: true }).pipe(Effect.ignore);
223 return true;
224 }).pipe(Effect.mapError(fail));
225
226 const activateCode = (version: string) =>
227 Effect.gen(function* () {
228 const candidate = yield* Ref.get(prepared);
229 if (candidate?.version !== version || candidate.kind !== "code") return false;
230 const pointer = path.join(releasesDir, "current.json");
231 yield* Effect.uninterruptible(
232 Effect.gen(function* () {
233 yield* fs.writeFileString(
234 `${pointer}.tmp`,
235 yield* encodePointer({
236 version,
237 directory: path.basename(candidate.directory),
238 shell: candidate.shell,
239 runtime: candidate.runtime,
240 }),
241 );
242 yield* fs.rename(`${pointer}.tmp`, pointer);
243 yield* Ref.set(prepared, null);
244 }),
245 );
246 yield* fs.readDirectory(releasesDir).pipe(
247 Effect.flatMap((entries) =>
248 Effect.forEach(entries, (name) => {
249 const directory = path.join(releasesDir, name);
250 return name.startsWith("release-") &&
251 directory !== candidate.directory &&
252 directory !== environment.appRoot
253 ? fs.remove(directory, { recursive: true, force: true })
254 : Effect.void;
255 }),
256 ),
257 Effect.ignore,
258 );
259 return true;
260 }).pipe(Effect.mapError(fail));
261
262 return DesktopForkUpdates.of({ check, kind, prepare, installUi, activateCode });
263});
264
265export const layer = Layer.effect(DesktopForkUpdates, make);