forked from pingdotgg/t3code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathenvironmentTheme.ts
More file actions
297 lines (273 loc) · 11.7 KB
/
Copy pathenvironmentTheme.ts
File metadata and controls
297 lines (273 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
// @effect-diagnostics nodeBuiltinImport:off - the guarded file read needs open
// flags (O_NOFOLLOW, O_NONBLOCK) the FileSystem service does not expose.
/**
* EnvironmentTheme - palettes this machine publishes for clients to follow.
*
* A desktop that retints its apps when the user switches system theme writes
* `<stateDir>/themes/<id>.json`; this service watches that directory and
* streams the published set to connected clients so a theme change lands
* without a restart. The filename is the theme id: it stays stable while the
* machine rewrites the colors underneath, so `defaultTheme` and a client\'s
* selection keep pointing at the same theme across recolors. Theming is
* cosmetic, so every failure here degrades to "not published" rather than
* propagating.
*
* @module EnvironmentTheme
*/
import * as NodeFS from "node:fs";
import {
EnvironmentTheme,
EnvironmentThemeFile,
EnvironmentThemeId,
environmentThemeFileHasColors,
} from "@t3tools/contracts";
import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Equal from "effect/Equal";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as ServerConfig from "./config.ts";
const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit(
Schema.fromJsonString(EnvironmentThemeFile),
);
const isEnvironmentThemeId = Schema.is(EnvironmentThemeId);
const THEME_FILE_SUFFIX = ".json";
/**
* Bounds on what a machine can publish. The directory is local, so this is not
* a trust boundary -- but an accidental dump of large files there would
* otherwise be read in full, streamed to every client, and repainted, so the
* cost of a mistake is capped rather than unbounded.
*/
const MAX_THEME_FILES = 32;
/** Exported so the publish path cannot accept a file the watcher will skip. */
export const MAX_THEME_FILE_BYTES = 32 * 1024;
/**
* The set travels whole in a websocket event to every subscriber, so the sum
* matters more than any single file. An exported theme runs a few KB, leaving
* this far above any real directory while keeping a mistake off the wire.
*/
const MAX_THEME_TOTAL_BYTES = 192 * 1024;
/** The published set with the sequence number it was observed at. */
interface PublishedThemes {
readonly seq: number;
readonly themes: ReadonlyArray<EnvironmentTheme>;
}
export class EnvironmentThemeService extends Context.Service<
EnvironmentThemeService,
{
/**
* The set published right now, read from disk rather than from the
* watcher\'s last observation: a client connecting must see what the
* machine actually publishes even if it missed a filesystem event.
*/
readonly current: Effect.Effect<ReadonlyArray<EnvironmentTheme>>;
/**
* The current set followed by every change, with repeats dropped. The
* subscription is acquired before the current set is read, so a publish
* landing while a client connects is delivered rather than lost.
*/
readonly streamChanges: Stream.Stream<ReadonlyArray<EnvironmentTheme>>;
}
>()("t3/environmentTheme/EnvironmentThemeService") {}
/**
* Reads a theme file through one opened handle, so every check binds to the
* file actually read rather than to a path that may have been swapped since:
* O_NOFOLLOW rejects a symlink outright (a symlinked themes directory stays
* usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from
* blocking the open, and the fstat type and size gate examines the open
* descriptor. Returns null for anything that is not a small regular file.
*/
export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => {
let fd: number;
try {
fd = NodeFS.openSync(
filePath,
NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW | NodeFS.constants.O_NONBLOCK,
);
} catch {
return null;
}
try {
const info = NodeFS.fstatSync(fd);
if (!info.isFile() || info.size > maxBytes) return null;
const contents = Buffer.alloc(info.size);
let offset = 0;
while (offset < contents.length) {
const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset);
if (read <= 0) break;
offset += read;
}
return contents.subarray(0, offset).toString("utf8");
} catch {
return null;
} finally {
NodeFS.closeSync(fd);
}
};
/**
* Every theme the directory actually publishes. A file that is missing,
* unreadable, malformed, colorless, or misnamed is simply skipped; the rest of
* the set is unaffected. The one place that decides what "published" means, so
* a caller validating an id cannot disagree with the watcher serving it.
*/
export const readPublishedThemes = Effect.fn(function* (themesDir: string) {
const fs = yield* FileSystem.FileSystem;
const entries = yield* fs
.readDirectory(themesDir)
.pipe(Effect.orElseSucceed((): Array<string> => []));
const themes: Array<EnvironmentTheme> = [];
let examined = 0;
let totalBytes = 0;
for (const entry of entries.toSorted()) {
if (!entry.endsWith(THEME_FILE_SUFFIX)) continue;
const id = entry.slice(0, -THEME_FILE_SUFFIX.length);
// A reserved id is either shadowed by a built-in on the client or captures
// clients that never chose it, so it is not publishable.
if (!isEnvironmentThemeId(id) || UNPUBLISHABLE_THEME_IDS.has(id)) continue;
// Counts files examined, not themes accepted: capping the output would
// let a directory of malformed files be opened, read, and decoded in full
// on every refresh and every client connect.
examined += 1;
if (examined > MAX_THEME_FILES) {
yield* Effect.logWarning("ignoring environment theme files past the limit", {
path: themesDir,
limit: MAX_THEME_FILES,
});
break;
}
const filePath = `${themesDir}/${entry}`;
const raw = readThemeFileGuarded(filePath, MAX_THEME_FILE_BYTES);
if (raw === null) {
yield* Effect.logWarning("ignoring unusable environment theme file", {
path: filePath,
limit: MAX_THEME_FILE_BYTES,
});
continue;
}
if (raw.trim().length === 0) continue;
const decoded = decodeEnvironmentThemeFileJsonExit(raw);
if (decoded._tag === "Failure") {
yield* Effect.logWarning("ignoring invalid environment theme", {
path: filePath,
detail: Cause.pretty(decoded.cause),
});
continue;
}
const file = decoded.value;
if (!environmentThemeFileHasColors(file)) {
yield* Effect.logWarning("ignoring environment theme without colors", { path: filePath });
continue;
}
// Counted only once accepted: the cap bounds what travels to clients, so
// a skipped file must not eat the budget of valid themes sorted after it.
// Bytes, not string length -- the cap describes wire weight.
totalBytes += Buffer.byteLength(raw);
if (totalBytes > MAX_THEME_TOTAL_BYTES) {
yield* Effect.logWarning("ignoring environment themes past the total size limit", {
path: themesDir,
limit: MAX_THEME_TOTAL_BYTES,
});
break;
}
themes.push({ id, ...file });
}
return themes;
});
/**
* Reads the directory and folds it into the sequenced state, publishing only
* a genuine change. Every reader goes through here, so the snapshot a client
* connects on and the events it then receives come from one ordered source
* rather than from disk and the queue independently.
*/
const make = Effect.gen(function* () {
const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
/**
* Sliding with capacity 1: every update carries the complete set, so a
* subscriber that stops consuming holds at most the newest set rather than
* an unbounded backlog. Every observed set carries a sequence number, so a
* subscriber can drop queued events that predate the snapshot it started
* from. Without it a publish landing between subscribing and reading
* replays after the newer value and walks clients backwards onto stale
* colors.
*/
const changes = yield* PubSub.sliding<PublishedThemes>(1);
const published = yield* Ref.make<PublishedThemes>({ seq: 0, themes: [] });
/**
* Guards the whole read/compare/publish, not just the state update. The
* directory read is async, so two concurrent refreshes can finish out of
* order and a slower read of an older set would publish under a higher
* sequence -- which the subscriber filter, ordering publications rather than
* observations, could not then drop.
*/
const refreshSemaphore = yield* Semaphore.make(1);
const watcherScope = yield* Scope.make("sequential");
yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));
const refresh = refreshSemaphore.withPermits(1)(
Effect.gen(function* () {
const themes = yield* readPublishedThemes(environmentThemesDir).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
);
// Structural equality over the whole decoded value: a hand-rolled field
// list here silently drops republishes for any field it forgets.
const [changed, next] = yield* Ref.modify(
published,
(previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => {
if (Equal.equals(previous.themes, themes)) return [[false, previous], previous];
const updated: PublishedThemes = { seq: previous.seq + 1, themes };
return [[true, updated], updated];
},
);
if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid);
return next;
}),
);
// The directory is created up front so the watcher has something to attach
// to before the first publisher writes into it.
yield* fs
.makeDirectory(environmentThemesDir, { recursive: true })
.pipe(Effect.ignoreCause({ log: true }));
// Debounced for the same reason settings watching is: a theme script emits
// several events per save and `fs.watch` can fire before the content is
// flushed. Every event triggers a full re-read, so no event needs filtering.
const watchEvents = fs.watch(environmentThemesDir).pipe(Stream.debounce(Duration.millis(100)));
// Seeds the dedupe so a watch event that reports no actual change (a touch,
// a rewrite with identical contents) does not retint every client.
yield* refresh;
yield* Stream.runForEach(watchEvents, () => refresh.pipe(Effect.ignoreCause({ log: true }))).pipe(
Effect.ignoreCause({ log: true }),
Effect.forkIn(watcherScope),
Effect.asVoid,
);
return {
current: Effect.map(refresh, (state) => state.themes),
get streamChanges() {
return Stream.unwrap(
Effect.gen(function* () {
// Subscribe first so nothing published during the read is missed,
// then drop anything the snapshot already accounts for.
const subscription = yield* PubSub.subscribe(changes);
const snapshot = yield* refresh;
return Stream.concat(
Stream.make(snapshot.themes),
Stream.fromSubscription(subscription).pipe(
Stream.filter((update) => update.seq > snapshot.seq),
Stream.map((update) => update.themes),
),
);
}),
);
},
} satisfies EnvironmentThemeService["Service"];
});
export const layer = Layer.effect(EnvironmentThemeService, make);