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 Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8import * as NodeBuffer from "node:buffer";
9
10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11
12const UiBuildManifest = Schema.fromJsonString(
13 Schema.Record(
14 Schema.String,
15 Schema.Struct({
16 file: Schema.String,
17 css: Schema.optionalKey(Schema.Array(Schema.String)),
18 assets: Schema.optionalKey(Schema.Array(Schema.String)),
19 }),
20 ),
21);
22
23export class DesktopLocalUiError extends Schema.TaggedError<DesktopLocalUiError>()(
24 "DesktopLocalUiError",
25 { message: Schema.String, cause: Schema.Defect() },
26) {}
27
28const isLocalUiError = Schema.is(DesktopLocalUiError);
29const decodeUiBuildManifest = Schema.decodeEffect(UiBuildManifest);
30const UiPointer = Schema.fromJsonString(
31 Schema.Struct({ name: Schema.String, version: Schema.String }),
32);
33const decodeUiPointer = Schema.decodeEffect(UiPointer);
34const encodeUiPointer = Schema.encodeEffect(UiPointer);
35const incompleteBuildMessage = "The UI build is incomplete. Finish the build and try again.";
36
37export class DesktopLocalUi extends Context.Service<
38 DesktopLocalUi,
39 {
40 readonly assetDirectories: Effect.Effect<readonly [string, ...string[]]>;
41 readonly currentVersion: Effect.Effect<string>;
42 readonly apply: (source: string, version: string) => Effect.Effect<void, DesktopLocalUiError>;
43 readonly reset: Effect.Effect<void, DesktopLocalUiError>;
44 }
45>()("@t3tools/desktop/updates/DesktopLocalUi") {}
46
47/** @public Service construction is part of the canonical Effect module API. */
48export const make = Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const environment = yield* DesktopEnvironment.DesktopEnvironment;
51 const path = environment.path;
52 const directory = path.join(environment.stateDir, "local-ui", environment.appVersion);
53 const pointer = path.join(directory, "current");
54 const bundled = environment.clientAssetsDir;
55 const currentVersion = yield* Ref.make(environment.appVersion);
56 const initial = yield* Effect.gen(function* () {
57 if (yield* fs.exists(pointer)) {
58 const { name, version } = yield* decodeUiPointer(yield* fs.readFileString(pointer));
59 if (name.startsWith("build-") && path.basename(name) === name) {
60 const snapshot = path.join(directory, name);
61 if (yield* fs.exists(path.join(snapshot, "index.html"))) {
62 yield* Ref.set(currentVersion, version);
63 return snapshot;
64 }
65 }
66 }
67 return bundled;
68 }).pipe(Effect.catch((error) => Effect.logError(error).pipe(Effect.as(bundled))));
69
70 const active = yield* Ref.make<readonly [string, ...string[]]>(
71 initial === bundled ? [bundled] : [initial, bundled],
72 );
73 const mutex = yield* Semaphore.make(1);
74
75 const apply = (source: string, version: string) =>
76 Effect.gen(function* () {
77 if (environment.isDevelopment) {
78 return yield* new DesktopLocalUiError({
79 message: "The development UI updates automatically.",
80 cause: "Development renderer",
81 });
82 }
83 if (!(yield* fs.exists(path.join(source, "index.html")))) {
84 return yield* new DesktopLocalUiError({
85 message: "The downloaded update has no completed UI build.",
86 cause: source,
87 });
88 }
89
90 yield* fs.makeDirectory(directory, { recursive: true });
91 const snapshot = yield* fs.makeTempDirectory({ directory, prefix: "build-" });
92
93 yield* Effect.gen(function* () {
94 yield* Effect.gen(function* () {
95 const html = yield* fs.readFileString(path.join(source, "index.html"));
96 const manifestJson = yield* fs.readFileString(
97 path.join(source, ".vite", "manifest.json"),
98 );
99 const manifest = yield* decodeUiBuildManifest(manifestJson);
100 const references = [...html.matchAll(/<(?:script|link)\b[^>]*(?:src|href)="([^"]+)"/g)];
101 if (!references.some(([tag]) => tag.startsWith("<script"))) {
102 return yield* new DesktopLocalUiError({
103 message: incompleteBuildMessage,
104 cause: "Missing application script",
105 });
106 }
107
108 const files = Object.values(manifest).flatMap((entry) => [
109 entry.file,
110 ...(entry.css ?? []),
111 ...(entry.assets ?? []),
112 ]);
113 const stagedFiles: Array<{ file: string; contents: NodeBuffer.Buffer }> = [
114 { file: "index.html", contents: NodeBuffer.Buffer.from(html) },
115 {
116 file: ".vite/manifest.json",
117 contents: NodeBuffer.Buffer.from(manifestJson),
118 },
119 ];
120 for (const reference of new Set([...files, ...references.map((match) => match[1]!)])) {
121 const url = yield* Effect.try({
122 try: () => new URL(reference, "http://local-ui/"),
123 catch: (cause) => new DesktopLocalUiError({ message: incompleteBuildMessage, cause }),
124 });
125 if (url.origin !== "http://local-ui") continue;
126 const file = yield* Effect.try({
127 try: () => path.resolve(source, `.${decodeURIComponent(url.pathname)}`),
128 catch: (cause) => new DesktopLocalUiError({ message: incompleteBuildMessage, cause }),
129 });
130 if (!file.startsWith(source + path.sep)) {
131 return yield* new DesktopLocalUiError({
132 message: incompleteBuildMessage,
133 cause: reference,
134 });
135 }
136 const contents = NodeBuffer.Buffer.from(yield* fs.readFile(file));
137 if (contents.length === 0) {
138 return yield* new DesktopLocalUiError({
139 message: incompleteBuildMessage,
140 cause: reference,
141 });
142 }
143 stagedFiles.push({ file: path.relative(source, file), contents });
144 }
145
146 yield* fs.copy(source, snapshot);
147 for (const { file, contents } of stagedFiles) {
148 for (const root of [source, snapshot]) {
149 const copied = yield* fs.readFile(path.join(root, file));
150 if (!contents.equals(copied)) {
151 return yield* new DesktopLocalUiError({
152 message: incompleteBuildMessage,
153 cause: file,
154 });
155 }
156 }
157 }
158 }).pipe(
159 Effect.mapError((cause) =>
160 isLocalUiError(cause)
161 ? cause
162 : new DesktopLocalUiError({ message: incompleteBuildMessage, cause }),
163 ),
164 );
165
166 yield* Effect.uninterruptible(
167 Effect.gen(function* () {
168 yield* fs.writeFileString(
169 `${pointer}.tmp`,
170 yield* encodeUiPointer({ name: path.basename(snapshot), version }),
171 );
172 yield* fs.rename(`${pointer}.tmp`, pointer);
173 const previous = yield* Ref.get(active);
174 yield* Ref.set(active, [snapshot, previous[0]]);
175 yield* Ref.set(currentVersion, version);
176 }),
177 );
178 }).pipe(
179 Effect.onError(() =>
180 Effect.gen(function* () {
181 if (!(yield* Ref.get(active)).includes(snapshot)) {
182 yield* fs.remove(snapshot, { recursive: true, force: true });
183 }
184 }).pipe(Effect.ignore),
185 ),
186 );
187
188 const keep = yield* Ref.get(active);
189 yield* fs.readDirectory(directory).pipe(
190 Effect.flatMap((entries) =>
191 Effect.forEach(entries, (name) => {
192 const candidate = path.join(directory, name);
193 return name.startsWith("build-") && !keep.includes(candidate)
194 ? fs.remove(candidate, { recursive: true, force: true })
195 : Effect.void;
196 }),
197 ),
198 Effect.ignore,
199 );
200 }).pipe(
201 Effect.mapError((cause) =>
202 isLocalUiError(cause)
203 ? cause
204 : new DesktopLocalUiError({
205 message: "Could not apply the UI build. Try again.",
206 cause,
207 }),
208 ),
209 Effect.tapError((error) => Effect.logError(error)),
210 mutex.withPermit,
211 );
212
213 const reset = Effect.uninterruptible(
214 Effect.gen(function* () {
215 yield* fs.remove(pointer, { force: true });
216 yield* Ref.set(currentVersion, environment.appVersion);
217 const previous = yield* Ref.get(active);
218 yield* Ref.set(active, previous[0] === bundled ? [bundled] : [bundled, previous[0]]);
219 }),
220 ).pipe(
221 Effect.mapError(
222 (cause) => new DesktopLocalUiError({ message: "Could not restore the bundled UI.", cause }),
223 ),
224 Effect.tapError((error) => Effect.logError(error)),
225 mutex.withPermit,
226 );
227
228 return DesktopLocalUi.of({
229 assetDirectories: Ref.get(active),
230 currentVersion: Ref.get(currentVersion),
231 apply,
232 reset,
233 });
234});
235
236export const layer = Layer.effect(DesktopLocalUi, make);