| 1 | // @effect-diagnostics nodeBuiltinImport:off - Release downloads are hashed before extraction. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | import { |
| 4 | ForkReleaseManifest, |
| 5 | ForkRuntimeManifest, |
| 6 | ForkReleaseListing, |
| 7 | latestForkRelease, |
| 8 | } from "@t3tools/shared/forkUpdateDirectory"; |
| 9 | import * as Context from "effect/Context"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Ref from "effect/Ref"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | import { HttpClient } from "effect/http"; |
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 17 | |
| 18 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 19 | import * as DesktopLocalUi from "./DesktopLocalUi.ts"; |
| 20 | import { activeForkRuntime } from "./forkRuntime.ts"; |
| 21 | |
| 22 | export class DesktopForkUpdateError extends Schema.TaggedError<DesktopForkUpdateError>()( |
| 23 | "DesktopForkUpdateError", |
| 24 | { message: Schema.String, cause: Schema.Defect() }, |
| 25 | ) {} |
| 26 | |
| 27 | const isForkUpdateError = Schema.is(DesktopForkUpdateError); |
| 28 | const decodeReleaseListing = Schema.decodeUnknownEffect(ForkReleaseListing); |
| 29 | const decodeRelease = Schema.decodeUnknownEffect(ForkReleaseManifest); |
| 30 | const decodeRuntime = Schema.decodeUnknownEffect(Schema.fromJsonString(ForkRuntimeManifest)); |
| 31 | |
| 32 | type Release = typeof ForkReleaseManifest.Type; |
| 33 | type Artifact = Release["ui"]; |
| 34 | type Prepared = { |
| 35 | version: string; |
| 36 | directory: string; |
| 37 | shell: string; |
| 38 | runtime: string; |
| 39 | kind: "ui" | "code"; |
| 40 | }; |
| 41 | |
| 42 | export 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 | |
| 58 | export 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 | |
| 265 | export const layer = Layer.effect(DesktopForkUpdates, make); |