dsh-plugin-notify-sounds 1.8.4 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +211 -202
  2. package/README.zh.md +210 -201
  3. package/lib/client.js +3285 -2780
  4. package/lib/index.js +1349 -1552
  5. package/package.json +2 -2
package/lib/index.js CHANGED
@@ -1,1552 +1,1349 @@
1
- /**
2
- * Host half of `dsh-plugin-notify-sounds`.
3
- *
4
- * All behavior lives in the browser entry (`./client`): the sounds themselves
5
- * are Web Audio synthesis, and the triggers are the Client's own
6
- * `uiSession.sessionStatus` facts (pending interactions and running→idle
7
- * transitions). This Node half owns two things the browser cannot:
8
- *
9
- * - the volatile Config schema below is what the Host Settings document
10
- * serves under the `notify-sounds` namespace, so the Settings section the
11
- * Client registers reads and writes through `configForms` exactly like
12
- * DSH's own preference rows (locale, developer tools, web search);
13
- * - `apply` registers the `/notify-sounds/focus` route the browser posts to
14
- * when a system notification is clicked — the renderer cannot raise the
15
- * OS window, but this process can: on Windows it calls `user32.dll`
16
- * through the Desktop's own FFI binding and finds the window by title.
17
- *
18
- * `apply` also asks the settings namespace to hold off any auto-generated
19
- * inventory page for this entry, because the Client contributes a dedicated
20
- * Settings section of its own. (Note: `autoGenerate` is not yet consumed by
21
- * any shipped client — the flag is correct and future-proof, but today the
22
- * effect is a no-op. Runtime README: "no shipped client does so yet".)
23
- *
24
- * @module dsh-plugin-notify-sounds
25
- */
26
-
27
- import z from "@deepseek-ai/schemastery";
28
- import { spawn } from "node:child_process";
29
- import { createHash, timingSafeEqual } from "node:crypto";
30
- import { createRequire } from "node:module";
31
- import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs";
32
- import { dirname, join, resolve } from "node:path";
33
- import { fileURLToPath } from "node:url";
34
- import { gunzipSync } from "node:zlib";
35
-
36
- /** Diagnostic name of this Host plugin. */
37
- export const name = "notify-sounds";
38
-
39
- /** Sound ids shared with the Client half's preset table (spelled, not imported: the browser bundle is self-contained). Exported so the tests can cross-check the two spellings. */
40
- export const SOUND_IDS = ["bell", "chime", "ding", "drop", "pulse", "arc", "coin", "success", "knock", "custom", "none"];
41
-
42
- /** Quiet-hours time-of-day pattern, "HH:MM". Exported for the same cross-check. */
43
- export const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
44
-
45
- /** Deep link the packaged Desktop registers (`setAsDefaultProtocolClient`); the last-resort focus path. */
46
- const FOCUS_DEEP_LINK = "dsh://open";
47
-
48
- /**
49
- * The desktop app's bundle id, for raising it by identity on macOS.
50
- *
51
- * Taken from the packaged shell's own manifest (`dshDesktopAppId`), not
52
- * guessed: `open -b <id>` is the only raise that needs no URL scheme to have
53
- * been registered.
54
- */
55
- const MACOS_BUNDLE_ID = "com.deepseek.dsh";
56
-
57
- /** Route the browser half posts to when a system notification is clicked. Exported so the tests can cross-check the Client half's spelling. */
58
- export const FOCUS_PATH = "/notify-sounds/focus";
59
-
60
- /** App name the Desktop shell appends to its primary window's title. */
61
- const DESKTOP_APP_TITLE = "DeepSeek Harness";
62
-
63
- /** `ShowWindow` command: restore a minimized or maximized window to its prior size. */
64
- const SW_RESTORE = 9;
65
- /** `ShowWindow` command: show a window without asking for the foreground. */
66
- const SW_SHOW = 5;
67
-
68
- /** Virtual key and flag for the ALT tap that satisfies the foreground lock. */
69
- const VK_MENU = 0x12;
70
-
71
- /** The shell owns the window: this Host is its child process. */
72
- const SHELL_PID = process.ppid;
73
- const KEYEVENTF_KEYUP = 0x2;
74
-
75
- //#region native window raise
76
-
77
- /**
78
- * Locate the Desktop's own prebuilt `koffi` native module.
79
- *
80
- * The Desktop Host process already ships `koffi` as its own dependency (it is
81
- * what backs the PTY bindings), so borrowing it costs this plugin nothing. The
82
- * native binary is the one thing the app cannot pack into `app.asar`, so it
83
- * sits in `app.asar.unpacked` as a real filesystem path this process can load.
84
- *
85
- * The install root cannot be derived from this module's own location: the
86
- * plugin lives in the DSH profile (`~/.dsh/profiles/<name>/node_modules/…`),
87
- * which has nothing to do with where the app is installed. Two sources are
88
- * tried, because neither is guaranteed here:
89
- *
90
- * - `process.resourcesPath`, which Electron injects — but this process runs
91
- * as Electron's own binary in Node mode, where it is absent (measured:
92
- * empty).
93
- * - this process's own executable: the Host IS the Desktop binary, so
94
- * `<exe dir>/resources` is the same directory. A `resources` candidate is
95
- * only trusted when it actually holds the app.
96
- *
97
- * @returns the module path, or undefined when this build does not ship it.
98
- */
99
- function resolveNativeModule() {
100
- if (process.platform !== "win32") return undefined;
101
- const resources = [];
102
- if (typeof process.resourcesPath === "string" && process.resourcesPath !== "") {
103
- resources.push(process.resourcesPath);
104
- }
105
- try {
106
- const beside = join(dirname(process.execPath), "resources");
107
- if (!resources.includes(beside) && existsSync(join(beside, "app.asar"))) resources.push(beside);
108
- } catch {
109
- // An unusable executable path simply leaves the other sources.
110
- }
111
- // Only the unpacked native binary is reachable: koffi's JavaScript wrapper
112
- // lives inside `app.asar`, while its `.node` is extracted beside it, so the
113
- // package directory itself never resolves from here. The unpacked location
114
- // has been one shape in every build examined; the second entry covers the
115
- // flat layout in case a build hoists it.
116
- const relatives = [
117
- ["app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "dsh-desktop-host", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
118
- ["app.asar.unpacked", "dsh", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
119
- // A non-asar (development) install has no `app.asar.unpacked` wrapper.
120
- ["dsh", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
121
- ];
122
- for (const base of resources) {
123
- for (const relative of relatives) {
124
- const candidate = join(base, ...relative);
125
- if (existsSync(candidate)) return candidate;
126
- }
127
- }
128
- return undefined;
129
- }
130
-
131
- /**
132
- * Load the Win32 entry points through the Desktop's own FFI binding.
133
- *
134
- * Deliberately narrow. Only `user32` window calls are bound: finding the window
135
- * by title means this never inspects another process, which is the behaviour
136
- * proactive defence exists to catch. An earlier version resolved the owning
137
- * executable with `OpenProcess`, and a security suite flagged the Desktop's
138
- * own executable on every click.
139
- *
140
- * Loaded once and cached; a failed load is remembered so it is not retried.
141
- *
142
- * @returns the bound calls, or undefined when FFI is unavailable here.
143
- */
144
- function loadNativeRaise() {
145
- if (process.platform !== "win32") return undefined;
146
- if (nativeRaise !== undefined) return nativeRaise ?? undefined;
147
- let bound;
148
- try {
149
- const modulePath = resolveNativeModule();
150
- if (modulePath === undefined) throw new Error("koffi not found");
151
- // The `.node` is loaded directly: koffi's JavaScript wrapper sits inside
152
- // `app.asar` and cannot be required from here, but the binding exports
153
- // the whole API on its own. The base path only has to be a real
154
- // directory for module resolution, which the binary's own is.
155
- const koffi = createRequire(join(dirname(modulePath), "index.cjs"))(modulePath);
156
- const user32 = koffi.load("user32.dll");
157
- const kernel32 = koffi.load("kernel32.dll");
158
- const EnumWindowsProc = koffi.proto("bool __stdcall WNDENUMPROC(void *hWnd, intptr_t lParam)");
159
- bound = {
160
- koffi,
161
- EnumWindowsProc,
162
- EnumWindows: user32.func("bool __stdcall EnumWindows(void *cb, intptr_t param)"),
163
- IsWindowVisible: user32.func("bool __stdcall IsWindowVisible(void *h)"),
164
- GetWindowTextLengthW: user32.func("int __stdcall GetWindowTextLengthW(void *h)"),
165
- GetWindowTextW: user32.func("int __stdcall GetWindowTextW(void *h, _Out_ uint16 *buf, int max)"),
166
- ShowWindow: user32.func("bool __stdcall ShowWindow(void *h, int cmd)"),
167
- BringWindowToTop: user32.func("bool __stdcall BringWindowToTop(void *h)"),
168
- SetForegroundWindow: user32.func("bool __stdcall SetForegroundWindow(void *h)"),
169
- AllowSetForegroundWindow: user32.func("bool __stdcall AllowSetForegroundWindow(int pid)"),
170
- GetForegroundWindow: user32.func("void * __stdcall GetForegroundWindow()"),
171
- GetWindowThreadProcessId: user32.func("uint32 __stdcall GetWindowThreadProcessId(void *h, void *pid)"),
172
- GetWindowPid: user32.func("uint32 __stdcall GetWindowThreadProcessId(void *h, _Out_ uint32 *pid)"),
173
- AttachThreadInput: user32.func("bool __stdcall AttachThreadInput(uint32 attach, uint32 attachTo, bool attachFlag)"),
174
- keybd_event: user32.func("void __stdcall keybd_event(uint8 vk, uint8 scan, uint32 flags, uintptr_t extra)"),
175
- GetCurrentThreadId: kernel32.func("uint32 __stdcall GetCurrentThreadId()"),
176
- };
177
- } catch (error) {
178
- // Remember the failure so it is not retried, but say so once: a user's
179
- // only symptom otherwise is "clicking did nothing".
180
- warn(`native raise unavailable: ${error?.message ?? error}`);
181
- bound = null;
182
- }
183
- nativeRaise = bound;
184
- return bound ?? undefined;
185
- }
186
-
187
- /** Cached FFI binding; `null` records a failed load so it is not retried. */
188
- let nativeRaise;
189
-
190
- /**
191
- * Read a window's title, or "" when it has none.
192
- *
193
- * @param native - the bound Win32 calls.
194
- * @param handle - the window to read.
195
- * @returns the title text.
196
- */
197
- export function windowTitle(native, handle) {
198
- try {
199
- const length = native.GetWindowTextLengthW(handle);
200
- if (length <= 0) return "";
201
- const buffer = new Uint16Array(length + 2);
202
- const copied = native.GetWindowTextW(handle, buffer, buffer.length);
203
- // The two calls are not atomic: between them the page can rewrite its
204
- // title (switching sessions is exactly when it does), and this call
205
- // then copies fewer characters than the first one counted. Slicing by
206
- // the OLD count would hand back a string with NULs on the end — which
207
- // no real title ever contains, and which silently stops `endsWith`
208
- // matching, i.e. one click that does nothing at all. Slice by what was
209
- // actually copied, and treat a nonsense return as "read the old count"
210
- // rather than trusting it.
211
- const written = Number.isFinite(copied) && copied >= 0 ? Math.min(copied, length) : length;
212
- return Buffer.from(buffer.buffer, 0, written * 2).toString("utf16le");
213
- } catch {
214
- return "";
215
- }
216
- }
217
-
218
- /**
219
- * Whether a window's title is the Desktop's.
220
- *
221
- * The title is whatever the page last set — the shell only overrides it for
222
- * its own error screens — so it is `<session or screen title> — <app name>`
223
- * while a session is open, and may be just the app name on a blank screen.
224
- * Both shapes have to match, which is why the bare title is accepted too: a
225
- * prefix-only rule silently stops finding the window on a screen without a
226
- * session.
227
- *
228
- * @param title - the window's title.
229
- * @returns whether it names this app.
230
- *
231
- * Exported for the Host-half tests: the one pure piece of the Win32 search.
232
- */
233
- export function isDesktopTitle(title) {
234
- return title === DESKTOP_APP_TITLE
235
- || title.endsWith(`— ${DESKTOP_APP_TITLE}`)
236
- || title.endsWith(`- ${DESKTOP_APP_TITLE}`);
237
- }
238
-
239
- /**
240
- * Find the Desktop's primary window.
241
- *
242
- * Matched by the title the page composes. This deliberately inspects nothing
243
- * about a window beyond its title: resolving an owning process
244
- * (`OpenProcess` + `GetModuleBaseNameW`) finds it too, but reading another
245
- * process is what a security suite's behaviour engine flags — that version
246
- * brought a detection dialog on every click, attributed to the Desktop's own
247
- * executable.
248
- *
249
- * @param native - the bound Win32 calls.
250
- * @returns the window handle, or undefined.
251
- */
252
- export function findDesktopWindow(native) {
253
- let byTitleVisible;
254
- let byTitleHidden;
255
- let byPidVisible;
256
- let byPidHidden;
257
- const pidBuffer = new Uint32Array(1);
258
- const visit = native.koffi.register((handle) => {
259
- try {
260
- const visible = native.IsWindowVisible(handle);
261
- const title = windowTitle(native, handle);
262
- if (isDesktopTitle(title)) {
263
- if (visible) {
264
- byTitleVisible = handle;
265
- return false; // stop at the first visible title match
266
- }
267
- if (byTitleHidden === undefined) byTitleHidden = handle;
268
- return true;
269
- }
270
- // A hidden or repainting window can transiently answer with no
271
- // title; the shell process owns the window, so match that too.
272
- if (SHELL_PID === undefined || SHELL_PID === 0) return true;
273
- if (typeof native.GetWindowPid !== "function") return true;
274
- pidBuffer[0] = 0;
275
- native.GetWindowPid(handle, pidBuffer);
276
- if (pidBuffer[0] !== SHELL_PID) return true;
277
- if (visible) {
278
- if (byPidVisible === undefined) byPidVisible = handle;
279
- } else if (byPidHidden === undefined) {
280
- byPidHidden = handle;
281
- }
282
- } catch {
283
- // A window that cannot be inspected simply does not match.
284
- }
285
- return true;
286
- }, native.koffi.pointer(native.EnumWindowsProc));
287
- try {
288
- native.EnumWindows(visit, 0);
289
- } catch {
290
- // Enumeration failing means no window is found.
291
- } finally {
292
- try {
293
- native.koffi.unregister(visit);
294
- } catch {}
295
- }
296
- return byTitleVisible ?? byTitleHidden ?? byPidVisible ?? byPidHidden;
297
- }
298
-
299
- /**
300
- * Restore and focus the Desktop window from inside this process.
301
- *
302
- * @returns whether the window actually ended up in the foreground.
303
- */
304
- function raiseDesktopWindowNative() {
305
- const native = loadNativeRaise();
306
- if (native === undefined) return false;
307
- const handle = findDesktopWindow(native);
308
- if (handle === undefined) return false;
309
- // Restore first: `ShowWindow(SW_RESTORE)` is not a foreground request, so it
310
- // is granted even to a background process. Its return value is the window's
311
- // PREVIOUS visibility, not a success flag, so it is deliberately ignored.
312
- // SW_SHOW is not a foreground request, so a background process is granted
313
- // it; it is what makes a tray-hidden window visible again.
314
- if (!native.IsWindowVisible(handle)) native.ShowWindow(handle, SW_SHOW);
315
- native.ShowWindow(handle, SW_RESTORE);
316
- native.BringWindowToTop(handle);
317
- // The foreground request is the part Windows polices: a background process
318
- // may only take the foreground under some conditions, so on its own this is
319
- // not reliable. When it is refused, the window is lifted in Z-order but
320
- // stays unfocused, which the user reads as "nothing happened".
321
- //
322
- // Two documented ways to satisfy the lock are applied first. The ALT tap
323
- // makes Windows treat this as user activity (ALT is not delivered to any
324
- // window), and the thread attach makes the request appear to come from a
325
- // thread already connected to the active window. Together they cover the
326
- // case where the foreground grant the click carried has already lapsed.
327
- //
328
- // `AllowSetForegroundWindow` is not among them: the documentation grants
329
- // the right to ANOTHER process, so calling it before our own request buys
330
- // nothing — measured, it returned false every time while the request that
331
- // followed still succeeded. It stays bound only for the retry below.
332
- //
333
- // The attach/detach pair is guarded so a throw from the calls between them
334
- // cannot leave this thread attached to the other window's input queue.
335
- native.keybd_event(VK_MENU, 0, 0, 0); // ALT down
336
- native.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0); // ALT up
337
- const activate = () => {
338
- const foreground = native.GetForegroundWindow();
339
- const foregroundThread = foreground === null ? 0 : native.GetWindowThreadProcessId(foreground, null);
340
- const ownThread = native.GetCurrentThreadId();
341
- let attached = false;
342
- try {
343
- attached = foregroundThread !== 0 && foregroundThread !== ownThread
344
- && native.AttachThreadInput(foregroundThread, ownThread, true);
345
- native.SetForegroundWindow(handle);
346
- native.BringWindowToTop(handle);
347
- } finally {
348
- if (attached) native.AttachThreadInput(foregroundThread, ownThread, false);
349
- }
350
- };
351
- activate();
352
- if (native.GetForegroundWindow() === handle) return true;
353
- // Refused. Ask the system for the right and try once more before giving up;
354
- // the caller falls back to the deep link if this still does not take.
355
- native.AllowSetForegroundWindow(-1);
356
- activate();
357
- return native.GetForegroundWindow() === handle;
358
- }
359
-
360
- /**
361
- * Raise the Desktop window through the app's own deep link.
362
- *
363
- * Last resort, for a build that does not ship the FFI binding. The deep link
364
- * goes through Windows, and the second instance's foreground request can be
365
- * refused when the launch comes from a background process — which is why it is
366
- * the fallback and not the primary path.
367
- */
368
- function raiseDesktopWindowDeepLink() {
369
- // `start ""` — the empty quoted title keeps `start` from treating the URL as
370
- // a window title.
371
- //
372
- // `spawn`, NOT `spawnSync`: this is the last resort, and blocking the Host's
373
- // event loop for up to a timeout would stall every other HTTP request —
374
- // including streamed output — while the user waits on a click. `detached`
375
- // is deliberately NOT set either: measured earlier, a detached child here
376
- // exits 0 without the launch taking effect. The child exits on its own once
377
- // `cmd /c start` has handed the URL to Windows, and holding the reference
378
- // keeps it alive until then.
379
- launcher("cmd.exe", ["/d", "/s", "/c", "start", "", FOCUS_DEEP_LINK]);
380
- }
381
-
382
- /**
383
- * Start a short-lived launcher without blocking, reporting failures once.
384
- *
385
- * The exit code matters as much as the spawn: `open` exits non-zero when no
386
- * application handles the request (macOS: "Unable to find application for
387
- * URL"), and a launch that never happened must not look exactly like one
388
- * that did.
389
- *
390
- * @param command - the executable to run.
391
- * @param args - its arguments.
392
- * @param done - called with whether the launcher exited cleanly; optional,
393
- * so a caller can chain a fallback.
394
- */
395
- function launcher(command, args, done) {
396
- try {
397
- const child = spawn(command, args, { windowsHide: true, stdio: "ignore" });
398
- child.on("error", (error) => {
399
- warn(`could not launch ${command}: ${error?.message ?? error}`);
400
- done?.(false);
401
- });
402
- child.on("exit", (code) => {
403
- const ok = code === 0;
404
- if (!ok) warn(`${command} exited ${code}: ${args.join(" ")}`);
405
- done?.(ok);
406
- });
407
- child.unref();
408
- } catch (error) {
409
- warn(`could not launch ${command}: ${error?.message ?? error}`);
410
- done?.(false);
411
- }
412
- }
413
-
414
- /**
415
- * One line in the Host log per focus click.
416
- *
417
- * Unlike `warn`, deliberately NOT deduplicated: when a click reports "nothing
418
- * happened", the first question is whether the click ever reached this process
419
- * at all, and one line per attempt is what answers it — `focus click` says the
420
- * request arrived, `window raised` / `fell back` says what the raise did with
421
- * it.
422
- *
423
- * @param message - what happened.
424
- */
425
- function log(message) {
426
- try {
427
- // eslint-disable-next-line no-console -- the Host has no logger on this context
428
- console.log(`notify-sounds: ${message}`);
429
- } catch {
430
- // Reporting must never become the failure.
431
- }
432
- }
433
-
434
- /**
435
- * Report a degraded path once, without failing the click.
436
- *
437
- * The raise has several ways to fall back silently, and a user's only symptom
438
- * is "clicking did nothing" — which cannot be diagnosed from the UI. One line
439
- * per distinct problem is enough to explain it from the Host log.
440
- *
441
- * @param message - what went wrong.
442
- */
443
- function warn(message) {
444
- try {
445
- if (warned.has(message)) return;
446
- warned.add(message);
447
- // eslint-disable-next-line no-console -- the Host has no logger on this context
448
- console.warn(`notify-sounds: ${message}`);
449
- } catch {
450
- // Reporting must never become the failure.
451
- }
452
- }
453
-
454
- /** Messages already reported, so a repeated click does not repeat the warning. */
455
- const warned = new Set();
456
-
457
- //#endregion
458
-
459
- /**
460
- * Raise the Desktop's primary window.
461
- *
462
- * The renderer cannot do this itself: `window.focus()` only focuses the page
463
- * inside the window, and the sandboxed preload bridge exposes no window
464
- * control. On Windows this process raises the window itself over FFI, falling
465
- * back to the app's deep link. macOS and Linux use the platform opener.
466
- *
467
- * Every failure mode leaves the click a harmless no-op.
468
- */
469
- function tryRaiseNative() {
470
- try {
471
- if (process.platform !== "win32") return false;
472
- return raiseDesktopWindowNative();
473
- } catch (error) {
474
- warn(`native raise failed: ${error?.message ?? error}`);
475
- return false;
476
- }
477
- }
478
-
479
- /** Raise the window through the app's own deep link; last resort. */
480
- function focusDesktopWindowDeepLink() {
481
- try {
482
- if (process.platform === "win32") {
483
- raiseDesktopWindowDeepLink();
484
- } else if (process.platform === "darwin") {
485
- log("raising via open -b");
486
- launcher("open", ["-b", MACOS_BUNDLE_ID], (ok) => {
487
- if (!ok) launcher("open", [FOCUS_DEEP_LINK]);
488
- });
489
- } else {
490
- log("raising via xdg-open + deep link");
491
- launcher("xdg-open", [FOCUS_DEEP_LINK]);
492
- }
493
- } catch (error) {
494
- warn(`focus fallback failed: ${error?.message ?? error}`);
495
- }
496
- }
497
-
498
- //#region update check
499
-
500
- /** Route the browser half reads the update check through. */
501
- export const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
502
-
503
- /** Route the browser half reads a session's goal phase through. */
504
- export const GOAL_PHASE_PATH = "/notify-sounds/goal";
505
-
506
- /** The npm package this plugin publishes as — the only name the check ever asks for. */
507
- const UPDATE_PACKAGE = "dsh-plugin-notify-sounds";
508
-
509
- /**
510
- * Registries the update check consults, in order.
511
- *
512
- * HTTPS and hardcoded on purpose: this is the one place the plugin asks the
513
- * outside world for something it may later act on, so nothing about it is
514
- * configurable. The second entry is the mainland-China mirror, matching the
515
- * install source DSH itself offers.
516
- */
517
- const UPDATE_REGISTRIES = ["https://registry.npmjs.org", "https://registry.npmmirror.com"];
518
-
519
- /**
520
- * Hosts a tarball may live on: the registries above, plus the one legitimate
521
- * hop between them.
522
- *
523
- * The mainland mirror answers its metadata on its own host but serves the
524
- * tarball from its CDN, so a download that refuses every redirect could never
525
- * finish when the mirror answered (measured). The CDN is therefore trusted
526
- * for tarballs only, and redirects are still followed one hop at a time with
527
- * every hop checked against this list.
528
- */
529
- const UPDATE_TARBALL_HOSTS = [...UPDATE_REGISTRIES, "https://cdn.npmmirror.com"];
530
-
531
- /** How many redirects a download may follow before giving up. */
532
- const UPDATE_MAX_REDIRECTS = 4;
533
-
534
- /** How long one check result is trusted before the next check may run. */
535
- const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
536
-
537
- /** Minimum spacing between real requests, so a held-down button cannot hammer a registry. */
538
- const UPDATE_CHECK_SPACING_MS = 15 * 1000;
539
-
540
- /** Bounds on the single request a check makes. */
541
- const UPDATE_FETCH_TIMEOUT_MS = 10 * 1000;
542
- const UPDATE_MAX_RESPONSE_BYTES = 256 * 1024;
543
-
544
- /** Result of the last check, when the last real request went out, and the last successful install. */
545
- const updateCheck = { checkedAt: 0, requestedAt: 0, result: undefined, applied: undefined };
546
-
547
- /** The check currently in flight, if any — callers join it instead of starting a second one. */
548
- let checkInFlight;
549
-
550
- /** The update currently in flight, if any — one download and one swap at a time. */
551
- let applyInFlight;
552
-
553
- /**
554
- * Parse `major.minor.patch` with an optional prerelease tag.
555
- *
556
- * @returns the parts, or undefined when the text is not a version.
557
- */
558
- export function parseVersion(text) {
559
- if (typeof text !== "string") return undefined;
560
- const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(text);
561
- if (match === null) return undefined;
562
- return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] ?? ""];
563
- }
564
-
565
- /**
566
- * Compare two versions the way an npm `latest` tag is meant to be compared.
567
- *
568
- * @returns negative when `a` sorts before `b`, 0 when they are the same
569
- * release, positive when `a` sorts after; `NaN` when either is not a version.
570
- */
571
- export function compareVersions(a, b) {
572
- const left = parseVersion(a);
573
- const right = parseVersion(b);
574
- if (left === undefined || right === undefined) return NaN;
575
- for (let i = 0; i < 3; i++) {
576
- if (left[i] !== right[i]) return left[i] - right[i];
577
- }
578
- return comparePrerelease(left[3], right[3]);
579
- }
580
-
581
- /**
582
- * Compare two prerelease fields the way semver defines it, not as strings.
583
- *
584
- * Plain string order gets the common case wrong: `rc.10` sorts BEFORE `rc.9`
585
- * ("1" < "9"), so a registry whose `latest` points at a later release candidate
586
- * would look older than the one already installed and the update would never be
587
- * offered. The rules are: identifiers are compared field by field; a numeric
588
- * field compares numerically and ranks below an alphanumeric one; an
589
- * alphanumeric field compares in ASCII order; and when every shared field is
590
- * equal, the one with MORE fields wins (`1.0.0-rc.1` < `1.0.0-rc.1.1`).
591
- *
592
- * @param left - the prerelease field of the first version, "" for a release.
593
- * @param right - the prerelease field of the second version.
594
- * @returns negative, 0, or positive.
595
- */
596
- function comparePrerelease(left, right) {
597
- if (left === right) return 0;
598
- // A release outranks any of its own prereleases (1.5.0 > 1.5.0-rc.1).
599
- if (left === "") return 1;
600
- if (right === "") return -1;
601
- const a = left.split(".");
602
- const b = right.split(".");
603
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
604
- const x = a[i];
605
- const y = b[i];
606
- if (x === undefined) return -1;
607
- if (y === undefined) return 1;
608
- const numericX = /^\d+$/.test(x);
609
- const numericY = /^\d+$/.test(y);
610
- if (numericX && numericY) {
611
- const dx = Number(x);
612
- const dy = Number(y);
613
- if (dx !== dy) return dx < dy ? -1 : 1;
614
- continue;
615
- }
616
- // A numeric identifier always ranks below an alphanumeric one.
617
- if (numericX !== numericY) return numericX ? -1 : 1;
618
- if (x !== y) return x < y ? -1 : 1;
619
- }
620
- return 0;
621
- }
622
-
623
- /**
624
- * The version this installation ships, read from the package's own manifest.
625
- *
626
- * @returns the version, or undefined when the manifest is unreadable.
627
- */
628
- function readOwnVersion(root) {
629
- try {
630
- const manifest = root === undefined
631
- ? new URL("../package.json", import.meta.url)
632
- : join(root, "package.json");
633
- const parsed = JSON.parse(readFileSync(manifest, "utf8"));
634
- return typeof parsed.version === "string" ? parsed.version : undefined;
635
- } catch {
636
- return undefined;
637
- }
638
- }
639
-
640
- /**
641
- * Whether a tarball URL is somewhere this plugin is willing to download from.
642
- *
643
- * The URL arrives inside a registry response, so it is untrusted input: https
644
- * only, and the host must be one of `UPDATE_TARBALL_HOSTS`. A registry pointing
645
- * anywhere else is refused rather than followed. The download stage re-checks
646
- * every redirect against this same list, so a hostile answer — or a hostile
647
- * redirect — is rejected at the door instead of at the moment it is about to
648
- * be used.
649
- */
650
- export function isTrustedTarball(url) {
651
- try {
652
- const parsed = new URL(url);
653
- return parsed.protocol === "https:" && UPDATE_TARBALL_HOSTS.some((host) => parsed.origin === host);
654
- } catch {
655
- return false;
656
- }
657
- }
658
-
659
- /**
660
- * Read a response body under a hard size cap.
661
- *
662
- * The check asks for one small JSON document; anything longer is either not
663
- * that document or is trying to be more than one, and neither is worth
664
- * buffering. Chunks are counted as they arrive and the read is abandoned at
665
- * the cap.
666
- */
667
- async function readCappedBytes(response, cap) {
668
- const declared = Number(response.headers.get("content-length") ?? 0);
669
- if (Number.isFinite(declared) && declared > cap) throw new Error("response too large");
670
- const reader = response.body?.getReader?.();
671
- if (reader === undefined) throw new Error("response has no body");
672
- const chunks = [];
673
- let size = 0;
674
- for (;;) {
675
- const { done, value } = await reader.read();
676
- if (done) break;
677
- size += value.byteLength;
678
- if (size > cap) {
679
- try {
680
- await reader.cancel();
681
- } catch {}
682
- throw new Error("response too large");
683
- }
684
- chunks.push(value);
685
- }
686
- const merged = new Uint8Array(size);
687
- let offset = 0;
688
- for (const chunk of chunks) {
689
- merged.set(chunk, offset);
690
- offset += chunk.byteLength;
691
- }
692
- return merged;
693
- }
694
-
695
- /**
696
- * Ask the registries for the newest published version of this plugin.
697
- *
698
- * Every failure mode — offline, refused, oversized, malformed, a tarball URL
699
- * pointing somewhere else — becomes a result the UI can show, never a throw.
700
- * This stage only reads: nothing is written to disk and nothing is executed.
701
- *
702
- * @param options - `force` asks again inside the freshness window (the
703
- * button); otherwise a recent result is returned as-is.
704
- * @returns `{ ok, current, latest, updateAvailable, checkedAt }`, or
705
- * `{ ok: false, current, error }` when no registry answered.
706
- */
707
- /**
708
- * How the running package relates to what the profile's package manager
709
- * recorded for it.
710
- *
711
- * This plugin no longer installs its own releases (as of 1.8.0): a release
712
- * reaches a profile through the package manager, which also owns the
713
- * lockfile. A tree that arrived by any other route looks identical on disk
714
- * while the lockfile still pins an older version — and the next
715
- * `pnpm install` in that profile silently restores the old one. Reading the
716
- * version the lockfile would restore lets the Settings row say so instead of
717
- * leaving the user to discover it after the next plugin operation.
718
- *
719
- * Every part is optional: no profile directory, no lockfile or an unreadable
720
- * one simply reports nothing, and the row keeps its normal wording.
721
- *
722
- * @param current - the version actually on disk.
723
- * @returns the fields the check payload mirrors to the row.
724
- */
725
- function installationProvenance(current) {
726
- try {
727
- const profileDir = process.env.DSH_PROFILE_DIR;
728
- if (profileDir === undefined) return {};
729
- const lock = readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8");
730
- const recorded = new RegExp(`${UPDATE_PACKAGE}@([0-9][^:\\s"']*)`).exec(lock)?.[1];
731
- return {
732
- recorded,
733
- unregistered: current !== undefined && recorded !== undefined && recorded !== current,
734
- profile: process.env.DSH_PROFILE,
735
- };
736
- } catch {
737
- return {};
738
- }
739
- }
740
- export { installationProvenance };
741
-
742
- export async function checkForUpdate(options = {}) {
743
- const now = Date.now();
744
- if (options.force !== true && updateCheck.result !== undefined && now - updateCheck.checkedAt < UPDATE_CHECK_TTL_MS) {
745
- return updateCheck.result;
746
- }
747
- // Spacing applies to real requests of every kind: a forced check may
748
- // refresh, it may not become a load generator.
749
- if (now - updateCheck.requestedAt < UPDATE_CHECK_SPACING_MS && updateCheck.result !== undefined) {
750
- return updateCheck.result;
751
- }
752
- // Two callers arriving together — the settings row and the notice above
753
- // it, or an automatic check landing on a user's click — must not become
754
- // two requests. The spacing rule above cannot cover that: it only sees a
755
- // finished result, and while the first request is still in the air there
756
- // is none. So a second caller joins the first instead.
757
- if (checkInFlight !== undefined) return checkInFlight;
758
- updateCheck.requestedAt = now;
759
- checkInFlight = (async () => {
760
- const current = readOwnVersion(options.root);
761
- const answer = await fetchRegistryAnswer();
762
- if (answer === undefined) {
763
- const result = { ok: false, current, error: "no registry answered", applied: updateCheck.applied, ...installationProvenance(current) };
764
- updateCheck.checkedAt = now;
765
- updateCheck.result = result;
766
- return result;
767
- }
768
- const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
769
- const result = {
770
- ok: true,
771
- current,
772
- latest: answer.version,
773
- updateAvailable: Number.isFinite(ordering) && ordering < 0,
774
- checkedAt: now,
775
- applied: updateCheck.applied,
776
- ...installationProvenance(current),
777
- };
778
- updateCheck.checkedAt = now;
779
- updateCheck.result = result;
780
- return result;
781
- })();
782
- try {
783
- return await checkInFlight;
784
- } finally {
785
- checkInFlight = undefined;
786
- }
787
- }
788
-
789
- /**
790
- * Ask the registries for this plugin's newest release and believe only a
791
- * well-formed answer.
792
- *
793
- * The response is untrusted input: the version must parse, the integrity must
794
- * be a sha512 digest, and the tarball must live on one of the two registries.
795
- * Anything else is skipped, so a hostile or broken answer is never the one a
796
- * later download acts on. Both the check and the download go through here —
797
- * the download never takes a URL from anywhere else.
798
- *
799
- * @returns `{ registry, version, tarball, integrity }`, or undefined when no
800
- * registry produced an answer worth believing.
801
- */
802
- async function fetchRegistryAnswer() {
803
- for (const registry of UPDATE_REGISTRIES) {
804
- try {
805
- log(`update check: asking ${registry}`);
806
- const response = await fetch(`${registry}/${UPDATE_PACKAGE}/latest`, {
807
- signal: AbortSignal.timeout(UPDATE_FETCH_TIMEOUT_MS),
808
- redirect: "error",
809
- headers: { accept: "application/json" },
810
- });
811
- if (!response.ok) continue;
812
- const payload = JSON.parse(new TextDecoder().decode(await readCappedBytes(response, UPDATE_MAX_RESPONSE_BYTES)));
813
- const version = payload?.version;
814
- if (typeof version !== "string" || parseVersion(version) === undefined) continue;
815
- const integrity = payload?.dist?.integrity;
816
- const tarball = payload?.dist?.tarball;
817
- if (typeof integrity !== "string" || !integrity.startsWith("sha512-")) continue;
818
- if (typeof tarball !== "string" || !isTrustedTarball(tarball)) continue;
819
- return { registry, version, tarball, integrity };
820
- } catch {
821
- // One registry failing is the ordinary case for the next one to
822
- // answer; running out of registries is the failure worth reporting.
823
- }
824
- }
825
- return undefined;
826
- }
827
-
828
- //#endregion
829
-
830
- //#region update apply — UNREACHABLE since 1.8.0: no route calls it any more.
831
- // The Settings row sends the user to the plugin manager / package manager
832
- // instead, so this plugin never writes its own files. The code below stays
833
- // only until its tests retire with a later release; nothing in apply() can
834
- // reach it.
835
-
836
- /** Route the browser half asks for an update to be applied through. */
837
- export const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
838
-
839
- /** Largest update tarball accepted: this plugin is small, and anything larger is not one. */
840
- const UPDATE_MAX_TARBALL_BYTES = 8 * 1024 * 1024;
841
-
842
- /**
843
- * Largest expansion accepted after gunzip.
844
- *
845
- * The download cap alone does not bound memory: a few-hundred-KB gzip stream
846
- * can expand to gigabytes, and that allocation happens inside this process.
847
- * `gunzipSync` is given this as `maxOutputLength` so the limit is enforced
848
- * while inflating rather than after.
849
- */
850
- const UPDATE_MAX_UNPACKED_BYTES = UPDATE_MAX_TARBALL_BYTES * 8;
851
-
852
- /** Most files one release of this package may carry. It is a handful of text files. */
853
- const UPDATE_MAX_FILES = 256;
854
-
855
- /**
856
- * Path segments an update may never contain, however an archive spells them.
857
- *
858
- * `.update-backup` is this updater's own working directory: an archive that
859
- * carried a file with that name could leave the previous version's bytes
860
- * exactly where a rollback looks for them. `node_modules` and `.git` are never
861
- * published in a tarball, so an archive containing them describes a different
862
- * tree than the one this installation came from.
863
- */
864
- const UPDATE_RESERVED_SEGMENTS = new Set(["node_modules", ".git", ".update-backup"]);
865
-
866
- /**
867
- * Records which files this updater installed, so the next update can prune.
868
- *
869
- * It lives in the installed package directory and is written by the updater at
870
- * runtime — it is NEVER part of the published tarball, which is why adding it
871
- * cannot break an older installation's update (those check the archive's file
872
- * names, not what the directory happens to contain).
873
- */
874
- const UPDATE_MANIFEST = ".update-manifest.json";
875
-
876
- /** Package-root names a package manager reads as configuration, and the updater's own bookkeeping; never delivered by an update. */
877
- const UPDATE_RESERVED_ROOT_FILES = new Set([".npmrc", ".pnpmfile.cjs", UPDATE_MANIFEST]);
878
-
879
- /** Where this package is installed: the only tree an update may write to. */
880
- function packageRoot() {
881
- return fileURLToPath(new URL("..", import.meta.url));
882
- }
883
-
884
- /** Read a NUL-terminated string field from a tar header. */
885
- function readTarString(header, offset, length) {
886
- const field = header.subarray(offset, offset + length);
887
- let end = field.indexOf(0);
888
- if (end === -1) end = field.length;
889
- return new TextDecoder().decode(field.subarray(0, end)).trim();
890
- }
891
-
892
- /** Read an octal size field from a tar header; NaN when it is not one. */
893
- function readTarOctal(header, offset, length) {
894
- const text = readTarString(header, offset, length);
895
- if (text === "") return 0;
896
- const value = parseInt(text, 8);
897
- return Number.isFinite(value) ? value : NaN;
898
- }
899
-
900
- /**
901
- * Map an archive path to the package-relative file it may write.
902
- *
903
- * Everything about a tar path is hostile until shown otherwise: only the
904
- * `package/` prefix the registry publishes under, never a path that climbs,
905
- * escapes, or arrives by another separator, never a reserved name, and never
906
- * one that an NTFS alternate data stream could hide behind.
907
- *
908
- * What is deliberately NOT checked is the file's NAME. An exact list of seven
909
- * names was the previous design and it was a trap: the updater that runs during
910
- * an update is the one already installed, so its list decides what the NEXT
911
- * release may contain. A release that added a file became uninstallable by
912
- * every older installation — permanently, and with no way to fix it after the
913
- * fact, because the old code is already on those machines. The rules below are
914
- * the ones that never need to change (path safety, not a file inventory), so
915
- * they can remain the whole gate while the file set moves forward.
916
- */
917
- export function archivePathToRelative(path) {
918
- const normalized = path.replaceAll("\\", "/");
919
- const segments = normalized.split("/");
920
- if (normalized.startsWith("/") || segments.includes("..")) throw new Error(`refusing tar path: ${path}`);
921
- const prefix = "package/";
922
- if (!normalized.startsWith(prefix)) throw new Error(`refusing tar path: ${path}`);
923
- const relative = normalized.slice(prefix.length);
924
- if (relative === "" || relative.endsWith("/")) throw new Error(`refusing tar path: ${path}`);
925
- for (const segment of relative.split("/")) {
926
- // A `.` segment would let one file be named twice in one archive, and it
927
- // slips past the case check below, which compares names rather than
928
- // resolved paths.
929
- if (segment === "" || segment === "." || segment.includes(":")) throw new Error(`refusing tar path: ${path}`);
930
- if (UPDATE_RESERVED_SEGMENTS.has(segment) || segment.endsWith(".update-tmp")) throw new Error(`refusing tar path: ${path}`);
931
- }
932
- if (UPDATE_RESERVED_ROOT_FILES.has(relative)) throw new Error(`refusing tar path: ${path}`);
933
- return relative;
934
- }
935
-
936
- /**
937
- * Read a tar archive into the files an update may write.
938
- *
939
- * The archive arrived from the network and is treated accordingly:
940
- *
941
- * - regular files only — a symlink in an update could point the next write
942
- * anywhere, so any other entry type is refused outright;
943
- * - pax/gnu extension headers are skipped, not interpreted;
944
- * - every name must survive `archivePathToRelative`, so nothing outside
945
- * this package is ever named and nothing reserved is ever written;
946
- * - two entries may not differ only in case: on Windows and macOS the second
947
- * would silently overwrite the first;
948
- * - a header claiming more than the archive holds is an error, not a read;
949
- * - the total size and the file count are capped, so an archive cannot turn
950
- * a bounded download into unbounded disk usage.
951
- *
952
- * @returns a Map of package-relative path → contents.
953
- */
954
- export function unpackTar(bytes) {
955
- const files = new Map();
956
- const seen = new Map();
957
- let total = 0;
958
- let offset = 0;
959
- while (offset + 512 <= bytes.length) {
960
- const header = bytes.subarray(offset, offset + 512);
961
- const name = readTarString(header, 0, 100);
962
- if (name === "") break;
963
- const size = readTarOctal(header, 124, 12);
964
- if (!Number.isFinite(size) || size < 0) throw new Error("tar entry has no readable size");
965
- const contentStart = offset + 512;
966
- if (contentStart + size > bytes.length) throw new Error("tar entry claims more than the archive holds");
967
- const typeflag = readTarString(header, 156, 1);
968
- const prefix = readTarString(header, 345, 155);
969
- const content = bytes.slice(contentStart, contentStart + size);
970
- offset = contentStart + Math.ceil(size / 512) * 512;
971
- if (typeflag === "x" || typeflag === "g" || typeflag === "L") continue;
972
- if (typeflag !== "0" && typeflag !== "") throw new Error(`refusing non-file tar entry (${typeflag})`);
973
- const relative = archivePathToRelative(prefix === "" ? name : `${prefix}/${name}`);
974
- const folded = relative.toLowerCase();
975
- const collidesWith = seen.get(folded);
976
- if (collidesWith !== undefined && collidesWith !== relative) {
977
- throw new Error(`refusing two entries that differ only in case: ${collidesWith} / ${relative}`);
978
- }
979
- seen.set(folded, relative);
980
- total += content.length;
981
- if (total > UPDATE_MAX_UNPACKED_BYTES) throw new Error("the archive expands beyond its limit");
982
- if (files.size >= UPDATE_MAX_FILES) throw new Error("the archive carries more files than this package can have");
983
- files.set(relative, content);
984
- }
985
- if (files.size === 0) throw new Error("the archive carried no usable files");
986
- return files;
987
- }
988
-
989
- /**
990
- * Check that the archive really is this package, at the version the registry named.
991
- *
992
- * With the file inventory gone, this is what still ties the bytes to an
993
- * identity. The registry answer was validated before anything was downloaded,
994
- * and the digest binds the bytes to that answer's `dist` — but the archive's
995
- * own manifest is the only thing that says WHICH package and version those
996
- * bytes are, and the next check trusts the manifest this update leaves on
997
- * disk. A manifest that is missing, unreadable, or about something else would
998
- * otherwise be installed silently and make "an update landed" a lie the check
999
- * cannot see through.
1000
- *
1001
- * @param files - the unpacked archive, package-relative path → contents.
1002
- * @param version - the version the registry announced.
1003
- * @returns the parsed manifest.
1004
- */
1005
- export function verifyManifest(files, version) {
1006
- const manifest = files.get("package.json");
1007
- if (manifest === undefined) throw new Error("the archive carries no package.json");
1008
- let parsed;
1009
- try {
1010
- parsed = JSON.parse(new TextDecoder().decode(manifest));
1011
- } catch {
1012
- throw new Error("the archive's package.json is not readable JSON");
1013
- }
1014
- if (parsed?.name !== UPDATE_PACKAGE) throw new Error(`the archive is not ${UPDATE_PACKAGE}`);
1015
- if (parsed?.version !== version) {
1016
- throw new Error(`the archive says version ${String(parsed?.version)} where the registry said ${version}`);
1017
- }
1018
- // A file whose name differs from a path this manifest declares only by case
1019
- // would replace that file on Windows and macOS and be a dead file on Linux:
1020
- // one archive, two different installations. The set is derived from the
1021
- // archive's OWN manifest (`package.json` itself, `main`, and every `exports`
1022
- // target) — never from a list held here — so a release stays free to move
1023
- // or rename its own files, which is the whole point of this design.
1024
- const declared = new Map([["package.json", "package.json"]]);
1025
- const declare = (value) => {
1026
- if (typeof value !== "string" || value === "") return;
1027
- const relative = value.startsWith("./") ? value.slice(2) : value;
1028
- declared.set(relative.toLowerCase(), relative);
1029
- };
1030
- const declareAll = (value) => {
1031
- if (typeof value === "string") declare(value);
1032
- else if (Array.isArray(value)) value.forEach(declareAll);
1033
- else if (value !== null && typeof value === "object") Object.values(value).forEach(declareAll);
1034
- };
1035
- declare(parsed.main);
1036
- declareAll(parsed.exports);
1037
- for (const relative of files.keys()) {
1038
- const target = declared.get(relative.toLowerCase());
1039
- if (target !== undefined && target !== relative) {
1040
- throw new Error(`refusing a file that differs only in case from ${target}: ${relative}`);
1041
- }
1042
- }
1043
- return parsed;
1044
- }
1045
-
1046
- /**
1047
- * Check downloaded bytes against the digest the registry published.
1048
- *
1049
- * A mismatch is the one failure that must stop everything: whatever arrived is
1050
- * not the package the registry described, and not one byte of it is written.
1051
- */
1052
- export function verifyIntegrity(bytes, integrity) {
1053
- const expected = Buffer.from(String(integrity).slice("sha512-".length), "base64");
1054
- const actual = createHash("sha512").update(bytes).digest();
1055
- if (expected.length === 0 || expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
1056
- throw new Error("the tarball failed its sha512 check");
1057
- }
1058
- }
1059
-
1060
- /**
1061
- * Download the tarball a validated registry answer named.
1062
- *
1063
- * Redirects are followed by hand rather than by the fetch layer: every hop's
1064
- * URL must itself be on `UPDATE_TARBALL_HOSTS`, so a redirect can never walk
1065
- * off the list — the mirror's CDN hop is the one legitimate case, and anything
1066
- * else is refused by name. The response is capped and time-limited like every
1067
- * other request; the sha512 check afterwards is what actually vouches for the
1068
- * bytes, whatever host they came from.
1069
- *
1070
- * @returns the tarball bytes.
1071
- */
1072
- async function downloadTarball(url) {
1073
- let current = url;
1074
- for (let hop = 0; hop <= UPDATE_MAX_REDIRECTS; hop++) {
1075
- if (!isTrustedTarball(current)) throw new Error(`download refused: ${current}`);
1076
- log(`update: downloading ${current}`);
1077
- let response;
1078
- try {
1079
- response = await fetch(current, {
1080
- signal: AbortSignal.timeout(60 * 1000),
1081
- redirect: "manual",
1082
- headers: { accept: "application/octet-stream" },
1083
- });
1084
- } catch (error) {
1085
- throw new Error(`download failed: ${error?.message ?? error}`);
1086
- }
1087
- if (response.status >= 300 && response.status < 400) {
1088
- const location = response.headers.get("location");
1089
- if (typeof location !== "string" || location === "") {
1090
- throw new Error(`download failed: HTTP ${response.status} without a redirect target`);
1091
- }
1092
- current = new URL(location, current).toString();
1093
- continue;
1094
- }
1095
- if (!response.ok) throw new Error(`download failed: HTTP ${response.status}`);
1096
- return readCappedBytes(response, UPDATE_MAX_TARBALL_BYTES);
1097
- }
1098
- throw new Error(`download refused: more than ${UPDATE_MAX_REDIRECTS} redirects`);
1099
- }
1100
-
1101
- /**
1102
- * The file list a previous update recorded, as a Set.
1103
- *
1104
- * A missing, unreadable, or malformed manifest reads as "nothing recorded",
1105
- * which prunes nothing: the failure mode of a damaged manifest has to be
1106
- * "leaves a file behind", never "deletes something".
1107
- *
1108
- * @param root - the package directory.
1109
- * @returns the recorded package-relative paths.
1110
- */
1111
- function readUpdateManifest(root) {
1112
- try {
1113
- const parsed = JSON.parse(readFileSync(join(root, UPDATE_MANIFEST), "utf8"));
1114
- if (!Array.isArray(parsed?.files)) return new Set();
1115
- return new Set(parsed.files.filter((name) => typeof name === "string"));
1116
- } catch {
1117
- return new Set();
1118
- }
1119
- }
1120
-
1121
- /**
1122
- * Which recorded files this release no longer carries.
1123
- *
1124
- * Only paths the PREVIOUS update wrote are candidates, so a file the user put
1125
- * in the package directory themselves is never named here and never removed.
1126
- * This is what makes an update add and remove files while the file-list rule
1127
- * stays out of the way — the alternative, shipping deletions inside the
1128
- * archive, is not something a tar can express.
1129
- *
1130
- * @param previous - paths recorded by the last update.
1131
- * @param files - the incoming archive's package-relative paths.
1132
- * @returns the paths to delete, sorted.
1133
- */
1134
- export function filesToPrune(previous, files) {
1135
- const gone = [];
1136
- for (const relative of previous) {
1137
- if (relative === UPDATE_MANIFEST) continue;
1138
- if (!files.has(relative)) gone.push(relative);
1139
- }
1140
- return gone.sort();
1141
- }
1142
-
1143
- /**
1144
- * Swap the package's files for the update's, keeping the previous ones.
1145
- *
1146
- * Each file is written to a temporary name and renamed into place, so a
1147
- * failure cannot leave a half-written file under a real name. Everything the
1148
- * update is about to replace is copied aside first, and any failure puts it
1149
- * all back: a failed update is not a half-updated plugin.
1150
- *
1151
- * The files are whatever the archive carried, not a list held here. An earlier
1152
- * design iterated a fixed set of seven names, which meant a release could only
1153
- * ever touch those seven; that list is gone on purpose (see
1154
- * `archivePathToRelative`), and this function is the second half of the same
1155
- * decision — unpacking a new file and then never writing it would look exactly
1156
- * like a successful update that quietly did nothing.
1157
- */
1158
- export function installUpdate(root, files) {
1159
- const rootPrefix = resolve(root);
1160
- const backup = join(root, ".update-backup");
1161
- const tempName = (relative) => `${resolve(root, relative)}.update-tmp`;
1162
- rmSync(backup, { recursive: true, force: true });
1163
- // The manifest goes last, and that order is load-bearing. If this process
1164
- // dies mid-swap — a power cut or a kill, not a thrown error, which rolls
1165
- // back — the installation is left with the OLD version number over part of
1166
- // the new code, so the next check still reports an update and the user can
1167
- // retry. The opposite order would leave a NEW version number over the old
1168
- // code: the check would call that "up to date" and there would be nothing
1169
- // left to prompt anyone with.
1170
- const relatives = [...files.keys()].sort((a, b) => (a === "package.json" ? 1 : 0) - (b === "package.json" ? 1 : 0));
1171
- /** Directories this update creates, outermost first, so a rollback can take them back. */
1172
- const created = [];
1173
- const written = [];
1174
- /** Files this update deletes, kept aside first so a rollback can bring them back. */
1175
- const pruned = [];
1176
- /** The previous update's record, read before anything is written over it. */
1177
- const previous = readUpdateManifest(root);
1178
- const manifestPath = join(root, UPDATE_MANIFEST);
1179
- /** Copy the current bytes aside before they are replaced or removed. */
1180
- const backUp = (relative, target) => {
1181
- const aside = join(backup, relative);
1182
- mkdirSync(dirname(aside), { recursive: true });
1183
- writeFileSync(aside, readFileSync(target));
1184
- };
1185
- try {
1186
- for (const relative of relatives) {
1187
- const source = files.get(relative);
1188
- if (source === undefined) continue;
1189
- const target = resolve(root, relative);
1190
- if (target !== rootPrefix && !target.startsWith(rootPrefix)) throw new Error(`refusing to write outside the package: ${relative}`);
1191
- let parent = rootPrefix;
1192
- for (const segment of relative.split("/").slice(0, -1)) {
1193
- parent = join(parent, segment);
1194
- if (!existsSync(parent)) created.push(parent);
1195
- }
1196
- mkdirSync(dirname(target), { recursive: true });
1197
- if (existsSync(target)) backUp(relative, target);
1198
- const temp = tempName(relative);
1199
- writeFileSync(temp, source);
1200
- renameSync(temp, target);
1201
- written.push(relative);
1202
- }
1203
- // A release can also DROP a file, and an archive cannot say so: it is a
1204
- // list of what exists, not of what should be deleted. The previous
1205
- // update's record is what makes the difference computable, and it is
1206
- // deliberately limited to files an update wrote — anything else in this
1207
- // directory (a user's own notes, a tool's leftovers) is never named in
1208
- // the record and therefore never deleted.
1209
- for (const relative of filesToPrune(previous, files)) {
1210
- const target = resolve(root, relative);
1211
- if (target !== rootPrefix && !target.startsWith(rootPrefix)) continue;
1212
- // Files only. A path that is now a directory is left alone rather
1213
- // than failing the whole update, and never removed recursively.
1214
- try {
1215
- if (!statSync(target).isFile()) continue;
1216
- } catch {
1217
- continue;
1218
- }
1219
- backUp(relative, target);
1220
- rmSync(target, { force: true });
1221
- pruned.push(relative);
1222
- }
1223
- // The record is written LAST, through the same temporary-name swap as
1224
- // everything else, and it describes the state this update leaves behind:
1225
- // the incoming files plus itself, minus nothing.
1226
- if (existsSync(manifestPath)) backUp(UPDATE_MANIFEST, manifestPath);
1227
- const record = new TextEncoder().encode(JSON.stringify({ version: 1, files: [...files.keys(), UPDATE_MANIFEST].sort() }));
1228
- writeFileSync(`${manifestPath}.update-tmp`, record);
1229
- renameSync(`${manifestPath}.update-tmp`, manifestPath);
1230
- written.push(UPDATE_MANIFEST);
1231
- } catch (error) {
1232
- // Put everything back — including files the update ADDED, which have
1233
- // no backup to restore from and must simply go away again, the files it
1234
- // DELETED, which come back from their copies, and the directories it
1235
- // created. A failed update has to leave nothing behind: no half-replaced
1236
- // file, no leftover temp, no file that was not there before, and no
1237
- // empty directory either.
1238
- for (const relative of written) {
1239
- const aside = join(backup, relative);
1240
- const target = resolve(root, relative);
1241
- if (existsSync(aside)) writeFileSync(target, readFileSync(aside));
1242
- else rmSync(target, { force: true });
1243
- }
1244
- for (const relative of pruned) {
1245
- const aside = join(backup, relative);
1246
- if (existsSync(aside)) writeFileSync(resolve(root, relative), readFileSync(aside));
1247
- }
1248
- for (const relative of relatives) rmSync(tempName(relative), { force: true });
1249
- // Guarded: a cleanup step must never replace the failure it is cleaning
1250
- // up after (this path can hold a directory, and `rmSync` refuses one
1251
- // without `recursive`).
1252
- try {
1253
- rmSync(`${manifestPath}.update-tmp`, { force: true });
1254
- } catch {}
1255
- // Deepest first, so a chain collapses one level at a time. `rmdirSync`
1256
- // refuses a directory that still holds something, which is the point:
1257
- // a failure here must never delete real content. (Not `rmSync` — that
1258
- // one needs `recursive` even for an empty directory on Windows and
1259
- // throws EISDIR, which would have left every created directory behind.)
1260
- for (const directory of created.reverse()) {
1261
- try {
1262
- rmdirSync(directory);
1263
- } catch {}
1264
- }
1265
- // The restore above is done, so the copies have no further use. If a
1266
- // restore threw, this is never reached and the backup stays on disk —
1267
- // which is the one case where keeping it is the safer outcome.
1268
- try {
1269
- rmSync(backup, { recursive: true, force: true });
1270
- } catch {}
1271
- throw error;
1272
- }
1273
- // The swap succeeded, so the backup has done its job. Leaving it would
1274
- // park the previous version's files — a hundred-odd KB including the
1275
- // client half — inside the profile until some later update clears it.
1276
- rmSync(backup, { recursive: true, force: true });
1277
- }
1278
-
1279
- /**
1280
- * Replace this installation with the newest published version.
1281
- *
1282
- * The stages are deliberate, and each one is a gate for the next: ask a
1283
- * registry (the answer is validated as strictly as for a check); download the
1284
- * tarball from the URL that answer named and nowhere else; verify its sha512
1285
- * against the digest the registry published — a mismatch stops here, before a
1286
- * byte is written; unpack it in memory, accepting only regular files that stay
1287
- * inside the package, and confirm its manifest is this package at the version
1288
- * the registry announced; then back up what is there and swap the files,
1289
- * restoring on failure.
1290
- *
1291
- * Nothing downloaded is ever executed here. The new code runs when DSH
1292
- * restarts, which is the same trust model as the user updating by hand.
1293
- *
1294
- * @param options - `root` overrides the package directory (for tests).
1295
- * @returns `{ ok: true, from, to, requiresRestart }`, `{ ok: true,
1296
- * upToDate: true, current }`, or `{ ok: false, stage, error }`.
1297
- */
1298
- export async function applyUpdate(options = {}) {
1299
- // One download and one swap at a time. Two callers — a second tab, or a
1300
- // user clicking again because the first attempt looks stuck — would share
1301
- // the fixed temp names and the single backup directory, and the slower
1302
- // one's cleanup would pull the floor out from under the other.
1303
- if (applyInFlight !== undefined) return applyInFlight;
1304
- applyInFlight = runUpdate(options);
1305
- try {
1306
- return await applyInFlight;
1307
- } finally {
1308
- applyInFlight = undefined;
1309
- }
1310
- }
1311
-
1312
- /** `applyUpdate` without the single-flight guard. */
1313
- async function runUpdate(options = {}) {
1314
- const root = options.root ?? packageRoot();
1315
- const current = readOwnVersion(root);
1316
- let stage = "check";
1317
- /** The version the registry announced, once there is one, for the failure report. */
1318
- let announced;
1319
- try {
1320
- const answer = await fetchRegistryAnswer();
1321
- if (answer === undefined) return { ok: false, stage, error: "no registry answered" };
1322
- const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
1323
- if (Number.isFinite(ordering) && ordering >= 0) return { ok: true, upToDate: true, current };
1324
- announced = answer.version;
1325
- stage = "download";
1326
- const bytes = await downloadTarball(answer.tarball);
1327
- stage = "verify";
1328
- verifyIntegrity(bytes, answer.integrity);
1329
- stage = "unpack";
1330
- // A gzip stream can expand far beyond the bytes it arrived in, so the
1331
- // decompressor is told where to stop instead of being trusted to be
1332
- // reasonable; the tarball cap above bounds the input, not the output.
1333
- const files = unpackTar(gunzipSync(bytes, { maxOutputLength: UPDATE_MAX_UNPACKED_BYTES }));
1334
- stage = "manifest";
1335
- verifyManifest(files, answer.version);
1336
- stage = "replace";
1337
- installUpdate(root, files);
1338
- // The answer that said "update available" is now a lie about this
1339
- // installation: drop the cached check so the next one re-reads the
1340
- // manifest this install just wrote. The outcome is remembered too —
1341
- // the very files that were just swapped include the client half, so
1342
- // the page may reload and lose its own state, and the news that an
1343
- // update landed must survive that. It is kept until DSH restarts,
1344
- // which is exactly as long as "restart to load it" is true.
1345
- updateCheck.checkedAt = 0;
1346
- updateCheck.result = undefined;
1347
- updateCheck.applied = { from: current, to: answer.version, at: Date.now() };
1348
- log(`update applied: ${current ?? "?"} -> ${answer.version}`);
1349
- return { ok: true, from: current, to: answer.version, requiresRestart: true };
1350
- } catch (error) {
1351
- warn(`update failed at ${stage}: ${error?.message ?? error}`);
1352
- return { ok: false, stage, to: announced, error: error?.message ?? String(error) };
1353
- }
1354
- }
1355
-
1356
- //#endregion
1357
-
1358
- /**
1359
- * Live preferences projected to the browser. Every field is volatile: the
1360
- * Settings document serves them, form edits reach them without restarting
1361
- * this entry, and the Client mirrors them through `configForms`.
1362
- */
1363
- export const Config = z.object({
1364
- /** Master switch; `false` mutes every notification sound. */
1365
- enabled: z.boolean().default(true).volatile(),
1366
- /** Playback loudness in percent. */
1367
- volume: z.number().step(1).min(0).max(100).default(60).volatile(),
1368
- /** Preset played when a Session gains a pending approval or plan review. */
1369
- decisionSound: z.union(SOUND_IDS).default("bell").volatile(),
1370
- /** Preset played when a Session gains a pending question. */
1371
- questionSound: z.union(SOUND_IDS).default("chime").volatile(),
1372
- /** Preset played when a Session's agent finishes (running → idle). */
1373
- completionSound: z.union(SOUND_IDS).default("ding").volatile(),
1374
- /** Also alert about sessions the agent spawned (sub-agents); off = only the user's own conversations. */
1375
- subagentAlerts: z.boolean().default(false).volatile(),
1376
- /** Check npm for a new version on its own (at most one real check a day); off = only when the user asks. */
1377
- updateAutoCheck: z.boolean().default(true).volatile(),
1378
- /** Play only while the page is not visible (background tab or window). */
1379
- onlyWhenHidden: z.boolean().default(false).volatile(),
1380
- /** Uploaded audio for the `custom` preset, as base64; empty when none. */
1381
- customSound: z.string().default("").volatile(),
1382
- /** Original file name of the uploaded custom sound, for display. */
1383
- customSoundName: z.string().default("").volatile(),
1384
- /** Show an OS notification (when the page is hidden) in addition to the sound. */
1385
- systemNotify: z.boolean().default(false).volatile(),
1386
- /** Prefix the window title with a word (待处理 / 已完成) while the page is hidden. */
1387
- titleFlash: z.boolean().default(false).volatile(),
1388
- /** Re-ping unanswered decisions at a fixed interval, until one is handled or seen. */
1389
- renotify: z.boolean().default(false).volatile(),
1390
- /** Re-ping interval in minutes. */
1391
- renotifyMinutes: z.number().step(1).min(1).max(60).default(5).volatile(),
1392
- /** Suppress every sound and notification during a daily time range. */
1393
- quietHours: z.boolean().default(false).volatile(),
1394
- /** Quiet range start, "HH:MM" 24-hour. */
1395
- quietStart: z.string().pattern(TIME_OF_DAY).default("22:00").volatile(),
1396
- /** Quiet range end, "HH:MM" 24-hour. */
1397
- quietEnd: z.string().pattern(TIME_OF_DAY).default("08:00").volatile(),
1398
- /** In goal mode, stay silent on intermediate rounds and chime once when the goal ends. */
1399
- goalFinalOnly: z.boolean().default(false).volatile(),
1400
- });
1401
-
1402
- /**
1403
- * Host plugin body. Registers the page policy and the focus route; the
1404
- * sounds and the Settings section exist in the browser entry.
1405
- *
1406
- * @param ctx - the Host plugin context.
1407
- */
1408
- export function apply(ctx) {
1409
- // The Client half registers a dedicated `settings.section` page; this asks
1410
- // the plugin inventory to hold off an auto-generated form for the same
1411
- // namespace so the preference is editable from exactly one place. Note:
1412
- // `autoGenerate` is not yet consumed by any shipped client (runtime README
1413
- // confirms), so this is a forward-compatible declaration rather than an
1414
- // effect that is already in force.
1415
- ctx.inject(["settings"], (child) => {
1416
- child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
1417
- });
1418
-
1419
- // The focus route: the browser half posts here when a system notification
1420
- // is clicked. It carries the composition's connection fence — the same
1421
- // Host/Origin checks plus login-token cookie the open-in-app routes rely
1422
- // on — so only the authenticated GUI can trip it, and it answers before
1423
- // raising so the click never waits.
1424
- ctx.inject(["webServer", "connection"], (child) => {
1425
- child.effect(() => child.webServer.register({
1426
- kind: "exact",
1427
- path: FOCUS_PATH,
1428
- handler: async (req, res) => {
1429
- const rejection = child.connection.requestRejection(req);
1430
- if (rejection !== undefined) {
1431
- res.statusCode = rejection;
1432
- res.end();
1433
- return;
1434
- }
1435
- if (req.method !== "POST") {
1436
- res.statusCode = 405;
1437
- res.setHeader("allow", "POST");
1438
- res.end();
1439
- return;
1440
- }
1441
- // Which client asked? The Desktop's renderer fetches from its
1442
- // own `dsh-app://app/` origin, and the shell STRIPS Origin and
1443
- // Sec-Fetch-Site before forwarding to this server (the shell's
1444
- // `forwardWebRequest` in `lib/main.js`), so a Desktop click
1445
- // arrives with neither header while a real browser always sends
1446
- // at least one. Checking BOTH keeps this working if the shell
1447
- // ever stops stripping only one of them: the day it strips
1448
- // neither, a Desktop click would silently start looking like a
1449
- // browser one and clicks would stop raising the window — with no
1450
- // other symptom to explain it.
1451
- //
1452
- // The two need opposite things, so the choice is derived here
1453
- // rather than configured: a browser click needs no help, since
1454
- // activating the notification raises the browser window itself
1455
- // and raising the Desktop window instead would be the wrong
1456
- // window entirely.
1457
- const headers = req.headers ?? {};
1458
- const fromBrowser = headers.origin !== undefined || headers["sec-fetch-site"] !== undefined;
1459
- res.statusCode = 204;
1460
- res.end();
1461
- log(`focus click (fromBrowser=${fromBrowser})`);
1462
- if (!fromBrowser) {
1463
- // Foreground requests can be refused while another app owns
1464
- // the input queue; retry before paying for a deep-link launch.
1465
- let raised = false;
1466
- for (let attempt = 0; attempt < 3 && !raised; attempt += 1) {
1467
- raised = tryRaiseNative();
1468
- if (!raised) await new Promise((resolve) => setTimeout(resolve, 180));
1469
- }
1470
- if (!raised) focusDesktopWindowDeepLink();
1471
- }
1472
- },
1473
- }), "notify-sounds: focus route");
1474
-
1475
- // The update check route: a fixed, read-only question about this
1476
- // plugin's own npm package. Same fence as the focus route. It takes no
1477
- // URL, package name or version from the caller — there is nothing here
1478
- // to aim anywhere else.
1479
- child.effect(() => child.webServer.register({
1480
- kind: "exact",
1481
- path: UPDATE_CHECK_PATH,
1482
- handler: async (req, res) => {
1483
- const rejection = child.connection.requestRejection(req);
1484
- if (rejection !== undefined) {
1485
- res.statusCode = rejection;
1486
- res.end();
1487
- return;
1488
- }
1489
- if (req.method !== "GET") {
1490
- res.statusCode = 405;
1491
- res.setHeader("allow", "GET");
1492
- res.end();
1493
- return;
1494
- }
1495
- try {
1496
- const force = new URL(req.url ?? "/", "http://localhost").searchParams.get("force") === "1";
1497
- const result = await checkForUpdate({ force });
1498
- res.statusCode = 200;
1499
- res.setHeader("content-type", "application/json");
1500
- res.end(JSON.stringify(result));
1501
- } catch (error) {
1502
- warn(`update check failed: ${error?.message ?? error}`);
1503
- res.statusCode = 502;
1504
- res.end();
1505
- }
1506
- },
1507
- }), "notify-sounds: update check route");
1508
-
1509
- });
1510
- // The goal phase route (1.8.1): the browser cannot rely on its own
1511
- // session projections for a session it never opened, so goal mode asks
1512
- // the Host, whose projection store is authoritative. Read-only, one
1513
- // session id, fenced like every other route here.
1514
- ctx.inject(["webServer", "connection", "sessions", "sessionProjections"], (child) => {
1515
- /** The goal phase for one session, or undefined when there is none. */
1516
- const phaseOf = (sessionId) => {
1517
- try {
1518
- const session = child.sessions.get(sessionId);
1519
- if (session === undefined) return undefined;
1520
- const projection = child.sessionProjections.stateOf(session, "goal");
1521
- if (projection === undefined || projection.failure != null) return undefined;
1522
- const phase = projection.current?.goal?.phase ?? projection.current?.phase;
1523
- return typeof phase === "string" ? phase : undefined;
1524
- } catch {
1525
- return undefined;
1526
- }
1527
- };
1528
- child.effect(() => child.webServer.register({
1529
- kind: "exact",
1530
- path: GOAL_PHASE_PATH,
1531
- handler: (req, res) => {
1532
- const rejection = child.connection.requestRejection(req);
1533
- if (rejection !== undefined) {
1534
- res.statusCode = rejection;
1535
- res.end();
1536
- return;
1537
- }
1538
- if (req.method !== "GET") {
1539
- res.statusCode = 405;
1540
- res.setHeader("allow", "GET");
1541
- res.end();
1542
- return;
1543
- }
1544
- const sessionId = new URL(req.url ?? "/", "http://localhost").searchParams.get("session") ?? "";
1545
- const phase = sessionId === "" ? undefined : phaseOf(sessionId);
1546
- res.statusCode = 200;
1547
- res.setHeader("content-type", "application/json");
1548
- res.end(JSON.stringify({ phase: phase ?? null }));
1549
- },
1550
- }), "notify-sounds: goal phase route");
1551
- });
1552
- }
1
+ /**
2
+ * Host half of `dsh-plugin-notify-sounds`.
3
+ *
4
+ * All behavior lives in the browser entry (`./client`): the sounds themselves
5
+ * are Web Audio synthesis, and the triggers are the Client's own
6
+ * `uiSession.sessionStatus` facts (pending interactions and running→idle
7
+ * transitions). This Node half owns the five things the browser cannot:
8
+ *
9
+ * - the volatile Config schema below is what the Host Settings document
10
+ * serves under the `notify-sounds` namespace, so the Settings section the
11
+ * Client registers reads and writes through `configForms` exactly like
12
+ * DSH's own preference rows (locale, developer tools, web search);
13
+ * - `apply` registers the `/notify-sounds/focus` route the browser posts to
14
+ * when a system notification is clicked — the renderer cannot raise the
15
+ * OS window, but this process can: on Windows it calls `user32.dll`
16
+ * through the Desktop's own FFI binding and finds the window by title;
17
+ * - `/notify-sounds/update/check` asks npm for this package's newest
18
+ * release. It only reads: since 1.8.0 installation belongs to the package
19
+ * manager, so the row reports and points, and never writes a file;
20
+ * - `/notify-sounds/goal` answers one session's goal phase from the Host's
21
+ * authoritative projection. The browser only carries sessions it has
22
+ * opened, and a goal-mode completion edge can belong to one it has not;
23
+ * - `/notify-sounds/sound` is the uploaded-sound store: the page reads a
24
+ * stored clip by content hash, posts a new one, or deletes a clip nothing
25
+ * references. It is the only place this plugin writes a file, and it only
26
+ * ever touches its own directory under the profile.
27
+ *
28
+ * `apply` also asks the settings namespace to hold off any auto-generated
29
+ * inventory page for this entry, because the Client contributes a dedicated
30
+ * Settings section of its own. (Note: `autoGenerate` is not yet consumed by
31
+ * any shipped client — the flag is correct and future-proof, but today the
32
+ * effect is a no-op. Runtime README: "no shipped client does so yet".)
33
+ *
34
+ * @module dsh-plugin-notify-sounds
35
+ */
36
+
37
+ import z from "@deepseek-ai/schemastery";
38
+ import { spawn } from "node:child_process";
39
+ import { createHash } from "node:crypto";
40
+ import { createRequire } from "node:module";
41
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
42
+ import { basename, dirname, join } from "node:path";
43
+ import { fileURLToPath } from "node:url";
44
+
45
+ /** Diagnostic name of this Host plugin. */
46
+ export const name = "notify-sounds";
47
+
48
+ /** Sound ids shared with the Client half's preset table (spelled, not imported: the browser bundle is self-contained). Exported so the tests can cross-check the two spellings. */
49
+ export const SOUND_IDS = ["bell", "chime", "ding", "drop", "pulse", "arc", "coin", "success", "knock", "custom1", "custom2", "custom3", "custom", "none"];
50
+
51
+ /** Quiet-hours time-of-day pattern, "HH:MM". Exported for the same cross-check. */
52
+ export const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
53
+
54
+ /** Deep link the packaged Desktop registers (`setAsDefaultProtocolClient`); the last-resort focus path. */
55
+ const FOCUS_DEEP_LINK = "dsh://open";
56
+
57
+ /**
58
+ * The desktop app's bundle id, for raising it by identity on macOS.
59
+ *
60
+ * Taken from the packaged shell's own manifest (`dshDesktopAppId`), not
61
+ * guessed: `open -b <id>` is the only raise that needs no URL scheme to have
62
+ * been registered.
63
+ */
64
+ const MACOS_BUNDLE_ID = "com.deepseek.dsh";
65
+
66
+ /** Route the browser half posts to when a system notification is clicked. Exported so the tests can cross-check the Client half's spelling. */
67
+ export const FOCUS_PATH = "/notify-sounds/focus";
68
+
69
+ /** App name the Desktop shell appends to its primary window's title. */
70
+ const DESKTOP_APP_TITLE = "DeepSeek Harness";
71
+
72
+ /** `ShowWindow` command: restore a minimized or maximized window to its prior size. */
73
+ const SW_RESTORE = 9;
74
+ /** `ShowWindow` command: show a window without asking for the foreground. */
75
+ const SW_SHOW = 5;
76
+
77
+ /** Virtual key and flag for the ALT tap that satisfies the foreground lock. */
78
+ const VK_MENU = 0x12;
79
+
80
+ /** The shell owns the window: this Host is its child process. */
81
+ const SHELL_PID = process.ppid;
82
+ const KEYEVENTF_KEYUP = 0x2;
83
+
84
+ //#region native window raise
85
+
86
+ /**
87
+ * Locate the Desktop's own prebuilt `koffi` native module.
88
+ *
89
+ * The Desktop Host process already ships `koffi` as its own dependency (it is
90
+ * what backs the PTY bindings), so borrowing it costs this plugin nothing. The
91
+ * native binary is the one thing the app cannot pack into `app.asar`, so it
92
+ * sits in `app.asar.unpacked` as a real filesystem path this process can load.
93
+ *
94
+ * The install root cannot be derived from this module's own location: the
95
+ * plugin lives in the DSH profile (`~/.dsh/profiles/<name>/node_modules/…`),
96
+ * which has nothing to do with where the app is installed. Two sources are
97
+ * tried, because neither is guaranteed here:
98
+ *
99
+ * - `process.resourcesPath`, which Electron injects — but this process runs
100
+ * as Electron's own binary in Node mode, where it is absent (measured:
101
+ * empty).
102
+ * - this process's own executable: the Host IS the Desktop binary, so
103
+ * `<exe dir>/resources` is the same directory. A `resources` candidate is
104
+ * only trusted when it actually holds the app.
105
+ *
106
+ * @returns the module path, or undefined when this build does not ship it.
107
+ */
108
+ function resolveNativeModule() {
109
+ if (process.platform !== "win32") return undefined;
110
+ const resources = [];
111
+ if (typeof process.resourcesPath === "string" && process.resourcesPath !== "") {
112
+ resources.push(process.resourcesPath);
113
+ }
114
+ try {
115
+ const beside = join(dirname(process.execPath), "resources");
116
+ if (!resources.includes(beside) && existsSync(join(beside, "app.asar"))) resources.push(beside);
117
+ } catch {
118
+ // An unusable executable path simply leaves the other sources.
119
+ }
120
+ // Only the unpacked native binary is reachable: koffi's JavaScript wrapper
121
+ // lives inside `app.asar`, while its `.node` is extracted beside it, so the
122
+ // package directory itself never resolves from here. The unpacked location
123
+ // has been one shape in every build examined; the second entry covers the
124
+ // flat layout in case a build hoists it.
125
+ const relatives = [
126
+ ["app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "dsh-desktop-host", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
127
+ ["app.asar.unpacked", "dsh", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
128
+ // A non-asar (development) install has no `app.asar.unpacked` wrapper.
129
+ ["dsh", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
130
+ ];
131
+ for (const base of resources) {
132
+ for (const relative of relatives) {
133
+ const candidate = join(base, ...relative);
134
+ if (existsSync(candidate)) return candidate;
135
+ }
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ /**
141
+ * Load the Win32 entry points through the Desktop's own FFI binding.
142
+ *
143
+ * Deliberately narrow. Only `user32` window calls are bound: finding the window
144
+ * by title means this never inspects another process, which is the behaviour
145
+ * proactive defence exists to catch. An earlier version resolved the owning
146
+ * executable with `OpenProcess`, and a security suite flagged the Desktop's
147
+ * own executable on every click.
148
+ *
149
+ * Loaded once and cached; a failed load is remembered so it is not retried.
150
+ *
151
+ * @returns the bound calls, or undefined when FFI is unavailable here.
152
+ */
153
+ function loadNativeRaise() {
154
+ if (process.platform !== "win32") return undefined;
155
+ if (nativeRaise !== undefined) return nativeRaise ?? undefined;
156
+ let bound;
157
+ try {
158
+ const modulePath = resolveNativeModule();
159
+ if (modulePath === undefined) throw new Error("koffi not found");
160
+ // The `.node` is loaded directly: koffi's JavaScript wrapper sits inside
161
+ // `app.asar` and cannot be required from here, but the binding exports
162
+ // the whole API on its own. The base path only has to be a real
163
+ // directory for module resolution, which the binary's own is.
164
+ const koffi = createRequire(join(dirname(modulePath), "index.cjs"))(modulePath);
165
+ const user32 = koffi.load("user32.dll");
166
+ const kernel32 = koffi.load("kernel32.dll");
167
+ const EnumWindowsProc = koffi.proto("bool __stdcall WNDENUMPROC(void *hWnd, intptr_t lParam)");
168
+ bound = {
169
+ koffi,
170
+ EnumWindowsProc,
171
+ EnumWindows: user32.func("bool __stdcall EnumWindows(void *cb, intptr_t param)"),
172
+ IsWindowVisible: user32.func("bool __stdcall IsWindowVisible(void *h)"),
173
+ GetWindowTextLengthW: user32.func("int __stdcall GetWindowTextLengthW(void *h)"),
174
+ GetWindowTextW: user32.func("int __stdcall GetWindowTextW(void *h, _Out_ uint16 *buf, int max)"),
175
+ ShowWindow: user32.func("bool __stdcall ShowWindow(void *h, int cmd)"),
176
+ BringWindowToTop: user32.func("bool __stdcall BringWindowToTop(void *h)"),
177
+ SetForegroundWindow: user32.func("bool __stdcall SetForegroundWindow(void *h)"),
178
+ GetForegroundWindow: user32.func("void * __stdcall GetForegroundWindow()"),
179
+ // Bound once, with the out-parameter shape callers actually use:
180
+ // both sites pass a Uint32Array. An earlier build also bound the
181
+ // same export a second time under a `void *` signature and passed
182
+ // `null` there — two names for one API, one of them relying on the
183
+ // binding layer to tolerate a null out-parameter.
184
+ GetWindowThreadProcessId: user32.func("uint32 __stdcall GetWindowThreadProcessId(void *h, _Out_ uint32 *pid)"),
185
+ AttachThreadInput: user32.func("bool __stdcall AttachThreadInput(uint32 attach, uint32 attachTo, bool attachFlag)"),
186
+ keybd_event: user32.func("void __stdcall keybd_event(uint8 vk, uint8 scan, uint32 flags, uintptr_t extra)"),
187
+ GetCurrentThreadId: kernel32.func("uint32 __stdcall GetCurrentThreadId()"),
188
+ };
189
+ } catch (error) {
190
+ // Remember the failure so it is not retried, but say so once: a user's
191
+ // only symptom otherwise is "clicking did nothing".
192
+ warn(`native raise unavailable: ${error?.message ?? error}`);
193
+ bound = null;
194
+ }
195
+ nativeRaise = bound;
196
+ return bound ?? undefined;
197
+ }
198
+
199
+ /** Cached FFI binding; `null` records a failed load so it is not retried. */
200
+ let nativeRaise;
201
+
202
+ /**
203
+ * Read a window's title, or "" when it has none.
204
+ *
205
+ * @param native - the bound Win32 calls.
206
+ * @param handle - the window to read.
207
+ * @returns the title text.
208
+ */
209
+ export function windowTitle(native, handle) {
210
+ try {
211
+ const length = native.GetWindowTextLengthW(handle);
212
+ if (length <= 0) return "";
213
+ const buffer = new Uint16Array(length + 2);
214
+ const copied = native.GetWindowTextW(handle, buffer, buffer.length);
215
+ // The two calls are not atomic: between them the page can rewrite its
216
+ // title (switching sessions is exactly when it does), and this call
217
+ // then copies fewer characters than the first one counted. Slicing by
218
+ // the OLD count would hand back a string with NULs on the end — which
219
+ // no real title ever contains, and which silently stops `endsWith`
220
+ // matching, i.e. one click that does nothing at all. Slice by what was
221
+ // actually copied, and treat a nonsense return as "read the old count"
222
+ // rather than trusting it.
223
+ const written = Number.isFinite(copied) && copied >= 0 ? Math.min(copied, length) : length;
224
+ return Buffer.from(buffer.buffer, 0, written * 2).toString("utf16le");
225
+ } catch {
226
+ return "";
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Whether a window's title is the Desktop's.
232
+ *
233
+ * The title is whatever the page last set — the shell only overrides it for
234
+ * its own error screens — so it is `<session or screen title> — <app name>`
235
+ * while a session is open, and may be just the app name on a blank screen.
236
+ * Both shapes have to match, which is why the bare title is accepted too: a
237
+ * prefix-only rule silently stops finding the window on a screen without a
238
+ * session.
239
+ *
240
+ * @param title - the window's title.
241
+ * @returns whether it names this app.
242
+ *
243
+ * Exported for the Host-half tests: the one pure piece of the Win32 search.
244
+ */
245
+ export function isDesktopTitle(title) {
246
+ return title === DESKTOP_APP_TITLE
247
+ || title.endsWith(`— ${DESKTOP_APP_TITLE}`)
248
+ || title.endsWith(`- ${DESKTOP_APP_TITLE}`);
249
+ }
250
+
251
+ /**
252
+ * Find the Desktop's primary window.
253
+ *
254
+ * Matched by the title the page composes. This deliberately inspects nothing
255
+ * about a window beyond its title: resolving an owning process
256
+ * (`OpenProcess` + `GetModuleBaseNameW`) finds it too, but reading another
257
+ * process is what a security suite's behaviour engine flags — that version
258
+ * brought a detection dialog on every click, attributed to the Desktop's own
259
+ * executable.
260
+ *
261
+ * @param native - the bound Win32 calls.
262
+ * @returns the window handle, or undefined.
263
+ */
264
+ export function findDesktopWindow(native) {
265
+ let byTitleVisible;
266
+ let byTitleHidden;
267
+ let byPidVisible;
268
+ let byPidHidden;
269
+ const pidBuffer = new Uint32Array(1);
270
+ const visit = native.koffi.register((handle) => {
271
+ try {
272
+ const visible = native.IsWindowVisible(handle);
273
+ const title = windowTitle(native, handle);
274
+ if (isDesktopTitle(title)) {
275
+ if (visible) {
276
+ byTitleVisible = handle;
277
+ return false; // stop at the first visible title match
278
+ }
279
+ if (byTitleHidden === undefined) byTitleHidden = handle;
280
+ return true;
281
+ }
282
+ // A hidden or repainting window can transiently answer with no
283
+ // title; the shell process owns the window, so match that too.
284
+ if (SHELL_PID === undefined || SHELL_PID === 0) return true;
285
+ pidBuffer[0] = 0;
286
+ native.GetWindowThreadProcessId(handle, pidBuffer);
287
+ if (pidBuffer[0] !== SHELL_PID) return true;
288
+ if (visible) {
289
+ if (byPidVisible === undefined) byPidVisible = handle;
290
+ } else if (byPidHidden === undefined) {
291
+ byPidHidden = handle;
292
+ }
293
+ } catch {
294
+ // A window that cannot be inspected simply does not match.
295
+ }
296
+ return true;
297
+ }, native.koffi.pointer(native.EnumWindowsProc));
298
+ try {
299
+ native.EnumWindows(visit, 0);
300
+ } catch {
301
+ // Enumeration failing means no window is found.
302
+ } finally {
303
+ try {
304
+ native.koffi.unregister(visit);
305
+ } catch {}
306
+ }
307
+ // Which match wins when several windows qualify. A VISIBLE window owned
308
+ // by the shell outranks a hidden one matched only by its title: the
309
+ // hidden-title case exists for a window parked in the tray (1.8.3), and a
310
+ // stale hidden instance left over from an earlier run must not be the one
311
+ // brought forward while the live window is right there on screen.
312
+ return byTitleVisible ?? byPidVisible ?? byTitleHidden ?? byPidHidden;
313
+ }
314
+
315
+ /**
316
+ * Restore and focus the Desktop window from inside this process.
317
+ *
318
+ * @returns whether the window actually ended up in the foreground.
319
+ */
320
+ function raiseDesktopWindowNative() {
321
+ const native = loadNativeRaise();
322
+ if (native === undefined) return false;
323
+ const handle = findDesktopWindow(native);
324
+ if (handle === undefined) return false;
325
+ // Restore first: `ShowWindow(SW_RESTORE)` is not a foreground request, so it
326
+ // is granted even to a background process. Its return value is the window's
327
+ // PREVIOUS visibility, not a success flag, so it is deliberately ignored.
328
+ // SW_SHOW is not a foreground request, so a background process is granted
329
+ // it; it is what makes a tray-hidden window visible again.
330
+ if (!native.IsWindowVisible(handle)) native.ShowWindow(handle, SW_SHOW);
331
+ native.ShowWindow(handle, SW_RESTORE);
332
+ native.BringWindowToTop(handle);
333
+ // The foreground request is the part Windows polices: a background process
334
+ // may only take the foreground under some conditions, so on its own this is
335
+ // not reliable. When it is refused, the window is lifted in Z-order but
336
+ // stays unfocused, which the user reads as "nothing happened".
337
+ //
338
+ // Two documented ways to satisfy the lock are applied first. The ALT tap
339
+ // makes Windows treat this as user activity (ALT is not delivered to any
340
+ // window), and the thread attach makes the request appear to come from a
341
+ // thread already connected to the active window. Together they cover the
342
+ // case where the foreground grant the click carried has already lapsed.
343
+ //
344
+ // `AllowSetForegroundWindow` is deliberately absent: the documentation
345
+ // grants the right to ANOTHER process, so calling it before our own request
346
+ // buys nothing (measured: it returned false every time, while the request
347
+ // that followed still succeeded). The retry that does help is one level up,
348
+ // in the focus route, where the whole raise is repeated after a real pause.
349
+ //
350
+ // The attach/detach pair is guarded so a throw from the calls between them
351
+ // cannot leave this thread attached to the other window's input queue.
352
+ native.keybd_event(VK_MENU, 0, 0, 0); // ALT down
353
+ native.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0); // ALT up
354
+ const threadId = new Uint32Array(1);
355
+ const activate = () => {
356
+ const foreground = native.GetForegroundWindow();
357
+ threadId[0] = 0;
358
+ const foregroundThread = foreground === null ? 0 : native.GetWindowThreadProcessId(foreground, threadId);
359
+ const ownThread = native.GetCurrentThreadId();
360
+ let attached = false;
361
+ try {
362
+ attached = foregroundThread !== 0 && foregroundThread !== ownThread
363
+ && native.AttachThreadInput(foregroundThread, ownThread, true);
364
+ native.SetForegroundWindow(handle);
365
+ native.BringWindowToTop(handle);
366
+ } finally {
367
+ if (attached) native.AttachThreadInput(foregroundThread, ownThread, false);
368
+ }
369
+ };
370
+ activate();
371
+ // One attempt per call. The retry that matters lives one level up, in the
372
+ // focus route: it calls this whole raise up to three times with a real
373
+ // pause between attempts, which is what gives the ALT tap time to land.
374
+ // Repeating `activate()` back to back here bought nothing measurable.
375
+ return native.GetForegroundWindow() === handle;
376
+ }
377
+
378
+ /**
379
+ * Raise the Desktop window through the app's own deep link.
380
+ *
381
+ * Last resort, for a build that does not ship the FFI binding. The deep link
382
+ * goes through Windows, and the second instance's foreground request can be
383
+ * refused when the launch comes from a background process — which is why it is
384
+ * the fallback and not the primary path.
385
+ */
386
+ function raiseDesktopWindowDeepLink() {
387
+ // `start ""` — the empty quoted title keeps `start` from treating the URL as
388
+ // a window title.
389
+ //
390
+ // `spawn`, NOT `spawnSync`: this is the last resort, and blocking the Host's
391
+ // event loop for up to a timeout would stall every other HTTP request —
392
+ // including streamed output — while the user waits on a click. `detached`
393
+ // is deliberately NOT set either: measured earlier, a detached child here
394
+ // exits 0 without the launch taking effect. The child exits on its own once
395
+ // `cmd /c start` has handed the URL to Windows, and holding the reference
396
+ // keeps it alive until then.
397
+ launcher("cmd.exe", ["/d", "/s", "/c", "start", "", FOCUS_DEEP_LINK]);
398
+ }
399
+
400
+ /**
401
+ * Start a short-lived launcher without blocking, reporting failures once.
402
+ *
403
+ * The exit code matters as much as the spawn: `open` exits non-zero when no
404
+ * application handles the request (macOS: "Unable to find application for
405
+ * URL"), and a launch that never happened must not look exactly like one
406
+ * that did.
407
+ *
408
+ * @param command - the executable to run.
409
+ * @param args - its arguments.
410
+ * @param done - called with whether the launcher exited cleanly; optional,
411
+ * so a caller can chain a fallback.
412
+ */
413
+ function launcher(command, args, done) {
414
+ try {
415
+ const child = spawn(command, args, { windowsHide: true, stdio: "ignore" });
416
+ child.on("error", (error) => {
417
+ warn(`could not launch ${command}: ${error?.message ?? error}`);
418
+ done?.(false);
419
+ });
420
+ child.on("exit", (code) => {
421
+ const ok = code === 0;
422
+ if (!ok) warn(`${command} exited ${code}: ${args.join(" ")}`);
423
+ done?.(ok);
424
+ });
425
+ child.unref();
426
+ } catch (error) {
427
+ warn(`could not launch ${command}: ${error?.message ?? error}`);
428
+ done?.(false);
429
+ }
430
+ }
431
+
432
+ /**
433
+ * One line in the Host log.
434
+ *
435
+ * Unlike `warn`, deliberately NOT deduplicated: routine events are the point.
436
+ * For a click that reports "nothing happened", one line per attempt answers
437
+ * whether the request ever reached this process — `focus click` says it
438
+ * arrived. The update check uses the same channel for its one-per-day
439
+ * `asking <registry>` line, which is what a "why is the version stale?"
440
+ * report needs.
441
+ *
442
+ * @param message - what happened.
443
+ */
444
+ function log(message) {
445
+ try {
446
+ // eslint-disable-next-line no-console -- the Host has no logger on this context
447
+ console.log(`notify-sounds: ${message}`);
448
+ } catch {
449
+ // Reporting must never become the failure.
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Report a degraded path once, without failing the click.
455
+ *
456
+ * The raise has several ways to fall back silently, and a user's only symptom
457
+ * is "clicking did nothing" — which cannot be diagnosed from the UI. One line
458
+ * per distinct problem is enough to explain it from the Host log.
459
+ *
460
+ * @param message - what went wrong.
461
+ */
462
+ function warn(message) {
463
+ try {
464
+ if (warned.has(message)) return;
465
+ warned.add(message);
466
+ // eslint-disable-next-line no-console -- the Host has no logger on this context
467
+ console.warn(`notify-sounds: ${message}`);
468
+ } catch {
469
+ // Reporting must never become the failure.
470
+ }
471
+ }
472
+
473
+ /** Messages already reported, so a repeated click does not repeat the warning. */
474
+ const warned = new Set();
475
+
476
+ //#endregion
477
+
478
+ /**
479
+ * Raise the Desktop's primary window.
480
+ *
481
+ * The renderer cannot do this itself: `window.focus()` only focuses the page
482
+ * inside the window, and the sandboxed preload bridge exposes no window
483
+ * control. On Windows this process raises the window itself over FFI, falling
484
+ * back to the app's deep link. macOS and Linux use the platform opener.
485
+ *
486
+ * Every failure mode leaves the click a harmless no-op.
487
+ */
488
+ function tryRaiseNative() {
489
+ try {
490
+ if (process.platform !== "win32") return false;
491
+ return raiseDesktopWindowNative();
492
+ } catch (error) {
493
+ warn(`native raise failed: ${error?.message ?? error}`);
494
+ return false;
495
+ }
496
+ }
497
+
498
+ /** Raise the window through the app's own deep link; last resort. */
499
+ function focusDesktopWindowDeepLink() {
500
+ try {
501
+ if (process.platform === "win32") {
502
+ raiseDesktopWindowDeepLink();
503
+ } else if (process.platform === "darwin") {
504
+ log("raising via open -b");
505
+ launcher("open", ["-b", MACOS_BUNDLE_ID], (ok) => {
506
+ if (!ok) launcher("open", [FOCUS_DEEP_LINK]);
507
+ });
508
+ } else {
509
+ log("raising via xdg-open + deep link");
510
+ launcher("xdg-open", [FOCUS_DEEP_LINK]);
511
+ }
512
+ } catch (error) {
513
+ warn(`focus fallback failed: ${error?.message ?? error}`);
514
+ }
515
+ }
516
+
517
+ //#region update check
518
+
519
+ /** Route the browser half reads the update check through. */
520
+ export const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
521
+
522
+ /** Route the browser half reads a session's goal phase through. */
523
+ export const GOAL_PHASE_PATH = "/notify-sounds/goal";
524
+
525
+ /** The npm package this plugin publishes as — the only name the check ever asks for. */
526
+ const UPDATE_PACKAGE = "dsh-plugin-notify-sounds";
527
+
528
+ /**
529
+ * Registries the update check consults, in order.
530
+ *
531
+ * HTTPS and hardcoded on purpose: this is the one place the plugin asks the
532
+ * outside world for something it may later act on, so nothing about it is
533
+ * configurable. The second entry is the mainland-China mirror, matching the
534
+ * install source DSH itself offers.
535
+ */
536
+ const UPDATE_REGISTRIES = ["https://registry.npmjs.org", "https://registry.npmmirror.com"];
537
+
538
+ /**
539
+ * How long one successful check result is trusted before the next check runs.
540
+ *
541
+ * A FAILURE is not trusted this long — see `UPDATE_FAILURE_TTL_MS`.
542
+ */
543
+ const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
544
+
545
+ /**
546
+ * How long a failed check is cached.
547
+ *
548
+ * Deliberately short. A failure is usually a transient network or proxy
549
+ * problem, and caching it for the full day meant one blip left the Settings
550
+ * row reading "check failed" until tomorrow — the row looked broken long
551
+ * after the cause was gone. A minute is long enough to collapse the pair of
552
+ * callers a page load produces (the row's own mount plus a user click), and
553
+ * short enough that the next look is a fresh answer.
554
+ */
555
+ const UPDATE_FAILURE_TTL_MS = 60 * 1000;
556
+
557
+ /** Minimum spacing between real requests, so a held-down button cannot hammer a registry. */
558
+ const UPDATE_CHECK_SPACING_MS = 15 * 1000;
559
+
560
+ /** Bounds on the single request a check makes. */
561
+ const UPDATE_FETCH_TIMEOUT_MS = 10 * 1000;
562
+ const UPDATE_MAX_RESPONSE_BYTES = 256 * 1024;
563
+
564
+ /** Result of the last check, and when the last real request went out. */
565
+ const updateCheck = { checkedAt: 0, requestedAt: 0, result: undefined };
566
+
567
+ /** The check currently in flight, if any — callers join it instead of starting a second one. */
568
+ let checkInFlight;
569
+
570
+ /**
571
+ * Parse `major.minor.patch` with an optional prerelease tag.
572
+ *
573
+ * Anchored at both ends on purpose: this validates an answer that arrived
574
+ * over the network, and `1.8.5-junk-here` or `1.8.5.9` must be rejected
575
+ * rather than read as `1.8.5`. Build metadata (`+sha`) is accepted and
576
+ * ignored, as semver says it must be for ordering.
577
+ *
578
+ * @returns the parts, or undefined when the text is not a version.
579
+ */
580
+ export function parseVersion(text) {
581
+ if (typeof text !== "string") return undefined;
582
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
583
+ if (match === null) return undefined;
584
+ return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] ?? ""];
585
+ }
586
+
587
+ /**
588
+ * Compare two versions the way an npm `latest` tag is meant to be compared.
589
+ *
590
+ * @returns negative when `a` sorts before `b`, 0 when they are the same
591
+ * release, positive when `a` sorts after; `NaN` when either is not a version.
592
+ */
593
+ export function compareVersions(a, b) {
594
+ const left = parseVersion(a);
595
+ const right = parseVersion(b);
596
+ if (left === undefined || right === undefined) return NaN;
597
+ for (let i = 0; i < 3; i++) {
598
+ if (left[i] !== right[i]) return left[i] - right[i];
599
+ }
600
+ return comparePrerelease(left[3], right[3]);
601
+ }
602
+
603
+ /**
604
+ * Compare two prerelease fields the way semver defines it, not as strings.
605
+ *
606
+ * Plain string order gets the common case wrong: `rc.10` sorts BEFORE `rc.9`
607
+ * ("1" < "9"), so a registry whose `latest` points at a later release candidate
608
+ * would look older than the one already installed and the update would never be
609
+ * offered. The rules are: identifiers are compared field by field; a numeric
610
+ * field compares numerically and ranks below an alphanumeric one; an
611
+ * alphanumeric field compares in ASCII order; and when every shared field is
612
+ * equal, the one with MORE fields wins (`1.0.0-rc.1` < `1.0.0-rc.1.1`).
613
+ *
614
+ * @param left - the prerelease field of the first version, "" for a release.
615
+ * @param right - the prerelease field of the second version.
616
+ * @returns negative, 0, or positive.
617
+ */
618
+ function comparePrerelease(left, right) {
619
+ if (left === right) return 0;
620
+ // A release outranks any of its own prereleases (1.5.0 > 1.5.0-rc.1).
621
+ if (left === "") return 1;
622
+ if (right === "") return -1;
623
+ const a = left.split(".");
624
+ const b = right.split(".");
625
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
626
+ const x = a[i];
627
+ const y = b[i];
628
+ if (x === undefined) return -1;
629
+ if (y === undefined) return 1;
630
+ const numericX = /^\d+$/.test(x);
631
+ const numericY = /^\d+$/.test(y);
632
+ if (numericX && numericY) {
633
+ const dx = Number(x);
634
+ const dy = Number(y);
635
+ if (dx !== dy) return dx < dy ? -1 : 1;
636
+ continue;
637
+ }
638
+ // A numeric identifier always ranks below an alphanumeric one.
639
+ if (numericX !== numericY) return numericX ? -1 : 1;
640
+ if (x !== y) return x < y ? -1 : 1;
641
+ }
642
+ return 0;
643
+ }
644
+
645
+ /**
646
+ * The version this installation ships, read from the package's own manifest.
647
+ *
648
+ * @returns the version, or undefined when the manifest is unreadable.
649
+ */
650
+ function readOwnVersion(root) {
651
+ try {
652
+ const manifest = root === undefined
653
+ ? new URL("../package.json", import.meta.url)
654
+ : join(root, "package.json");
655
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
656
+ return typeof parsed.version === "string" ? parsed.version : undefined;
657
+ } catch {
658
+ return undefined;
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Read a response body under a hard size cap.
664
+ *
665
+ * The check asks for one small JSON document; anything longer is either not
666
+ * that document or is trying to be more than one, and neither is worth
667
+ * buffering. Chunks are counted as they arrive and the read is abandoned at
668
+ * the cap.
669
+ */
670
+ async function readCappedBytes(response, cap) {
671
+ const declared = Number(response.headers.get("content-length") ?? 0);
672
+ if (Number.isFinite(declared) && declared > cap) throw new Error("response too large");
673
+ const reader = response.body?.getReader?.();
674
+ if (reader === undefined) throw new Error("response has no body");
675
+ const chunks = [];
676
+ let size = 0;
677
+ for (;;) {
678
+ const { done, value } = await reader.read();
679
+ if (done) break;
680
+ size += value.byteLength;
681
+ if (size > cap) {
682
+ try {
683
+ await reader.cancel();
684
+ } catch {}
685
+ throw new Error("response too large");
686
+ }
687
+ chunks.push(value);
688
+ }
689
+ const merged = new Uint8Array(size);
690
+ let offset = 0;
691
+ for (const chunk of chunks) {
692
+ merged.set(chunk, offset);
693
+ offset += chunk.byteLength;
694
+ }
695
+ return merged;
696
+ }
697
+
698
+ /**
699
+ * Route the page uses to read and write uploaded sounds. Exported so the
700
+ * tests can cross-check the Client half's spelling.
701
+ */
702
+ export const SOUND_PATH = "/notify-sounds/sound";
703
+
704
+ /** Largest uploaded sound accepted, in bytes. */
705
+ export const SOUND_MAX_BYTES = 3 * 1024 * 1024;
706
+
707
+ /**
708
+ * The only file names the sound directory may contain: a lowercase sha256 and
709
+ * a short extension. Anything else is refused outright rather than cleaned, so
710
+ * a hand-edited configuration cannot walk out of the directory.
711
+ */
712
+ const SOUND_FILE_PATTERN = /^[a-f0-9]{64}\.(?:mp3|wav|ogg|m4a|flac)$/;
713
+
714
+ /** Directory under the profile that holds uploaded sounds. */
715
+ const SOUND_DIRECTORY = "notify-sounds";
716
+
717
+ /** Container formats this store accepts, identified by magic bytes. */
718
+ const SOUND_FORMATS = [
719
+ { extension: "flac", mime: "audio/flac", matches: (b) => b[0] === 0x66 && b[1] === 0x4c && b[2] === 0x61 && b[3] === 0x43 },
720
+ { extension: "ogg", mime: "audio/ogg", matches: (b) => b[0] === 0x4f && b[1] === 0x67 && b[2] === 0x67 && b[3] === 0x53 },
721
+ {
722
+ extension: "wav",
723
+ mime: "audio/wav",
724
+ matches: (b) => b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x41 && b[10] === 0x56 && b[11] === 0x45,
725
+ },
726
+ { extension: "m4a", mime: "audio/mp4", matches: (b) => b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70 },
727
+ {
728
+ extension: "mp3",
729
+ mime: "audio/mpeg",
730
+ matches: (b) => (b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0),
731
+ },
732
+ ];
733
+
734
+ /**
735
+ * The uploaded-sound directory for this installation, or undefined when there
736
+ * is no profile (a development checkout): then nothing is stored and the page
737
+ * keeps using the legacy inline payload.
738
+ *
739
+ * @returns the absolute directory, or undefined.
740
+ */
741
+ function soundsDirectory() {
742
+ const profile = profileDirectory();
743
+ return profile === undefined ? undefined : join(profile, SOUND_DIRECTORY, "sounds");
744
+ }
745
+
746
+ /**
747
+ * Resolve one stored sound by name, refusing anything that is not a plain
748
+ * digest-plus-extension inside the directory.
749
+ *
750
+ * @param fileName - the name the page asked for.
751
+ * @returns the absolute path, or undefined when the name is not one of ours.
752
+ */
753
+ export function soundFilePath(fileName) {
754
+ if (typeof fileName !== "string" || !SOUND_FILE_PATTERN.test(fileName)) return undefined;
755
+ const directory = soundsDirectory();
756
+ return directory === undefined ? undefined : join(directory, fileName);
757
+ }
758
+
759
+ /**
760
+ * Write one uploaded sound and return the name it was stored under.
761
+ *
762
+ * The name is the sha256 of the bytes, so the same audio uploaded twice — or
763
+ * migrated twice — lands on the same file and nothing has to be reconciled.
764
+ * A partially written file is never visible: the bytes go to `<name>.tmp` and
765
+ * are renamed into place, and an existing target is left untouched because its
766
+ * content is by definition already identical.
767
+ *
768
+ * @param bytes - the uploaded audio.
769
+ * @returns the stored name, or undefined when the bytes are not audio this
770
+ * store accepts (or there is no profile to store them in).
771
+ */
772
+ export function writeSoundFile(bytes) {
773
+ try {
774
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
775
+ if (buffer.length === 0 || buffer.length > SOUND_MAX_BYTES) return undefined;
776
+ const format = SOUND_FORMATS.find((candidate) => candidate.matches(buffer));
777
+ if (format === undefined) return undefined;
778
+ const directory = soundsDirectory();
779
+ if (directory === undefined) return undefined;
780
+ const fileName = `${createHash("sha256").update(buffer).digest("hex")}.${format.extension}`;
781
+ const target = join(directory, fileName);
782
+ if (existsSync(target)) return fileName;
783
+ mkdirSync(directory, { recursive: true });
784
+ const temporary = `${target}.tmp`;
785
+ writeFileSync(temporary, buffer);
786
+ renameSync(temporary, target);
787
+ return fileName;
788
+ } catch (error) {
789
+ // A read-only profile or a full disk must not take the route down: the
790
+ // caller reports "not stored" and the page keeps whatever it had.
791
+ warn(`sound write failed: ${error?.message ?? error}`);
792
+ return undefined;
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Delete one stored sound, ignoring a name that is not ours and a file that is
798
+ * already gone.
799
+ *
800
+ * @param fileName - the name to remove.
801
+ */
802
+ export function deleteSoundFile(fileName) {
803
+ const path = soundFilePath(fileName);
804
+ if (path === undefined) return;
805
+ try {
806
+ rmSync(path, { force: true });
807
+ } catch (error) {
808
+ warn(`sound delete failed: ${error?.message ?? error}`);
809
+ }
810
+ }
811
+
812
+
813
+ /**
814
+ * The MIME type for a stored name.
815
+ *
816
+ * @param fileName - a name already accepted by `soundFilePath`.
817
+ * @returns the content type to serve it with.
818
+ */
819
+ export function soundMimeType(fileName) {
820
+ const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
821
+ return SOUND_FORMATS.find((format) => format.extension === extension)?.mime ?? "application/octet-stream";
822
+ }
823
+
824
+ /**
825
+ * Read a request body no larger than `limit`.
826
+ *
827
+ * The stream is abandoned the moment the cap is crossed rather than read to
828
+ * the end: an oversized upload is a mistake or an attack, and neither deserves
829
+ * the bandwidth.
830
+ *
831
+ * @param req - the incoming request.
832
+ * @param limit - the largest body accepted.
833
+ * @returns the body, or undefined when it is too large.
834
+ */
835
+ async function readRequestBytes(req, limit) {
836
+ const chunks = [];
837
+ let total = 0;
838
+ for await (const chunk of req) {
839
+ total += chunk.length;
840
+ if (total > limit) return undefined;
841
+ chunks.push(chunk);
842
+ }
843
+ return Buffer.concat(chunks);
844
+ }
845
+
846
+ /**
847
+ * The profile directory that owns this installation, when there is one.
848
+ *
849
+ * Two sources, in this order, and the order matters:
850
+ *
851
+ * - `DSH_PROFILE_DIR`, when it is set. In 0.2.1-rc.2 the only code that
852
+ * produces that variable is `@deepseek-ai/dsh-shell-env`, and it injects
853
+ * it into the MODEL's shell calls — nothing in the application puts it in
854
+ * this process. So for the Host this is a bonus, not the mechanism.
855
+ * - the filesystem. A profile-installed plugin is
856
+ * `<profile>/node_modules/<name>` (a real directory in DSH's own
857
+ * installer, and `.pnpm/<name>@<version>/node_modules/<name>` under a
858
+ * plain pnpm install), so the nearest ancestor holding `pnpm-lock.yaml`
859
+ * IS the profile. Walking up finds both layouts without knowing which
860
+ * one this is, and finds nothing for a dev checkout or a bundle shipped
861
+ * inside the app — which is exactly right, since neither has a lockfile
862
+ * that could roll this install back.
863
+ *
864
+ * @param moduleUrl - this module's URL; the test passes a fixture's.
865
+ * @returns the profile directory, or undefined.
866
+ */
867
+ export function profileDirectory(moduleUrl = import.meta.url) {
868
+ const fromEnv = process.env.DSH_PROFILE_DIR;
869
+ if (typeof fromEnv === "string" && fromEnv !== "") return fromEnv;
870
+ try {
871
+ let dir = fileURLToPath(new URL("..", moduleUrl));
872
+ for (let level = 0; level < 6; level += 1) {
873
+ const parent = dirname(dir);
874
+ if (parent === dir) break;
875
+ if (existsSync(join(parent, "pnpm-lock.yaml"))) return parent;
876
+ dir = parent;
877
+ }
878
+ } catch {
879
+ // A URL that cannot be resolved simply reports no profile.
880
+ }
881
+ return undefined;
882
+ }
883
+
884
+ /**
885
+ * How the running package relates to what the profile's package manager
886
+ * recorded for it.
887
+ *
888
+ * This plugin no longer installs its own releases (as of 1.8.0): a release
889
+ * reaches a profile through the package manager, which also owns the
890
+ * lockfile. A tree that arrived by any other route looks identical on disk
891
+ * while the lockfile still pins an older version — and the next
892
+ * `pnpm install` in that profile silently restores the old one. Reading the
893
+ * version the lockfile would restore lets the Settings row say so instead of
894
+ * leaving the user to discover it after the next plugin operation.
895
+ *
896
+ * This is the prompt that turns a self-update into a permanent one: a
897
+ * 1.7.0 install that updated itself to 1.8.6 has the new files but the old
898
+ * record, and the row is where it says so.
899
+ *
900
+ * Every part is optional: no profile directory, no lockfile or an unreadable
901
+ * one simply reports nothing, and the row keeps its normal wording.
902
+ *
903
+ * @param current - the version actually on disk.
904
+ * @returns the fields the check payload mirrors to the row.
905
+ */
906
+ export function installationProvenance(current) {
907
+ try {
908
+ const profileDir = profileDirectory();
909
+ if (profileDir === undefined) return {};
910
+ const lock = readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8");
911
+ // Only the version segment, deliberately. Dependency keys in a pnpm
912
+ // lockfile can carry a peer suffix — `name@1.8.4(@scope/peer@3.18.4)`
913
+ // — and a looser `[^:\s]*` swallowed that whole parenthetical. The
914
+ // comparison below then saw a "recorded" version that never existed
915
+ // and told the user to reinstall a perfectly good installation.
916
+ const recorded = new RegExp(`^\\s*${UPDATE_PACKAGE}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`, "m").exec(lock)?.[1];
917
+ return {
918
+ recorded,
919
+ unregistered: current !== undefined && recorded !== undefined && recorded !== current,
920
+ // A profile is `$DSH_HOME/profiles/<name>`, so the directory's own
921
+ // name is the profile name when the variable is absent — which,
922
+ // on the Host, it is. The row only uses this to spell out
923
+ // `dsh plugin --profile <name>`; without it the command simply
924
+ // omits the flag.
925
+ profile: typeof process.env.DSH_PROFILE === "string" && process.env.DSH_PROFILE !== ""
926
+ ? process.env.DSH_PROFILE
927
+ : basename(profileDir),
928
+ };
929
+ } catch {
930
+ return {};
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Ask the registries for the newest published version of this plugin.
936
+ *
937
+ * Every failure mode — offline, refused, oversized, malformed — becomes a
938
+ * result the UI can show, never a throw. This stage only reads: nothing is
939
+ * written to disk and nothing is executed. Since 1.8.0 installation belongs
940
+ * to the package manager, so the answer is a version number and nothing else:
941
+ * no tarball URL is needed, and one is not asked for.
942
+ *
943
+ * A successful answer is cached for a day; a failure for one minute (see
944
+ * `UPDATE_FAILURE_TTL_MS`) — a blip must not blank the row until tomorrow.
945
+ *
946
+ * @param options - `force` asks again inside the freshness window (the
947
+ * button); otherwise a recent result is returned as-is.
948
+ * @returns `{ ok, current, latest, updateAvailable, checkedAt }`, or
949
+ * `{ ok: false, current, error }` when no registry answered.
950
+ */
951
+ export async function checkForUpdate(options = {}) {
952
+ const now = Date.now();
953
+ const ttl = updateCheck.result?.ok === false ? UPDATE_FAILURE_TTL_MS : UPDATE_CHECK_TTL_MS;
954
+ if (options.force !== true && updateCheck.result !== undefined && now - updateCheck.checkedAt < ttl) {
955
+ return updateCheck.result;
956
+ }
957
+ // Spacing applies to real requests of every kind: a forced check may
958
+ // refresh, it may not become a load generator.
959
+ if (now - updateCheck.requestedAt < UPDATE_CHECK_SPACING_MS && updateCheck.result !== undefined) {
960
+ return updateCheck.result;
961
+ }
962
+ // Two callers arriving together — a forced check landing on an automatic
963
+ // one — must not become two requests. The spacing rule above cannot cover
964
+ // that: it only sees a finished result, and while the first request is
965
+ // still in the air there is none. So a second caller joins the first.
966
+ if (checkInFlight !== undefined) return checkInFlight;
967
+ updateCheck.requestedAt = now;
968
+ checkInFlight = (async () => {
969
+ const current = readOwnVersion(options.root);
970
+ const answer = await fetchRegistryAnswer();
971
+ if (answer === undefined) {
972
+ const result = { ok: false, current, error: "no registry answered", ...installationProvenance(current) };
973
+ updateCheck.checkedAt = now;
974
+ updateCheck.result = result;
975
+ return result;
976
+ }
977
+ const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
978
+ const result = {
979
+ ok: true,
980
+ current,
981
+ latest: answer.version,
982
+ updateAvailable: Number.isFinite(ordering) && ordering < 0,
983
+ checkedAt: now,
984
+ ...installationProvenance(current),
985
+ };
986
+ updateCheck.checkedAt = now;
987
+ updateCheck.result = result;
988
+ return result;
989
+ })();
990
+ try {
991
+ return await checkInFlight;
992
+ } finally {
993
+ checkInFlight = undefined;
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Ask the registries for this plugin's newest release and believe only a
999
+ * well-formed answer.
1000
+ *
1001
+ * The response is untrusted input, so the version has to parse as a version
1002
+ * (`parseVersion` is anchored). Anything else is skipped, so a hostile or
1003
+ * broken answer is never the one the row reports.
1004
+ *
1005
+ * @returns `{ registry, version }`, or undefined when no registry produced an
1006
+ * answer worth believing.
1007
+ */
1008
+ async function fetchRegistryAnswer() {
1009
+ for (const registry of UPDATE_REGISTRIES) {
1010
+ try {
1011
+ log(`update check: asking ${registry}`);
1012
+ const response = await fetch(`${registry}/${UPDATE_PACKAGE}/latest`, {
1013
+ signal: AbortSignal.timeout(UPDATE_FETCH_TIMEOUT_MS),
1014
+ redirect: "error",
1015
+ headers: { accept: "application/json" },
1016
+ });
1017
+ if (!response.ok) continue;
1018
+ const payload = JSON.parse(new TextDecoder().decode(await readCappedBytes(response, UPDATE_MAX_RESPONSE_BYTES)));
1019
+ const version = payload?.version;
1020
+ if (typeof version !== "string" || parseVersion(version) === undefined) continue;
1021
+ return { registry, version };
1022
+ } catch {
1023
+ // One registry failing is the ordinary case for the next one to
1024
+ // answer; running out of registries is the failure worth reporting.
1025
+ }
1026
+ }
1027
+ return undefined;
1028
+ }
1029
+
1030
+ //#endregion
1031
+
1032
+ /**
1033
+ * Live preferences projected to the browser. Every field is volatile: the
1034
+ * Settings document serves them, form edits reach them without restarting
1035
+ * this entry, and the Client mirrors them through `configForms`.
1036
+ */
1037
+ export const Config = z.object({
1038
+ /** Master switch; `false` mutes every notification sound. */
1039
+ enabled: z.boolean().default(true).volatile(),
1040
+ /** Playback loudness in percent. */
1041
+ volume: z.number().step(1).min(0).max(100).default(60).volatile(),
1042
+ /** Preset played when a Session gains a pending approval or plan review. */
1043
+ decisionSound: z.union(SOUND_IDS).default("bell").volatile(),
1044
+ /** Preset played when a Session gains a pending question. */
1045
+ questionSound: z.union(SOUND_IDS).default("chime").volatile(),
1046
+ /** Preset played when a Session's agent finishes (running → idle). */
1047
+ completionSound: z.union(SOUND_IDS).default("ding").volatile(),
1048
+ /** Also alert about sessions the agent spawned (sub-agents); off = only the user's own conversations. */
1049
+ subagentAlerts: z.boolean().default(false).volatile(),
1050
+ /** Check npm for a new version on its own (at most one real check a day); off = only when the user asks. */
1051
+ updateAutoCheck: z.boolean().default(true).volatile(),
1052
+ /** Play only while the page is not visible (background tab or window). */
1053
+ onlyWhenHidden: z.boolean().default(false).volatile(),
1054
+ /**
1055
+ * The user's own sounds: up to three, each a stored file plus the name it
1056
+ * is shown under. A trigger picks one by slot id (`custom1`…`custom3`).
1057
+ *
1058
+ * Slots are FIXED, not compacted: deleting the second sound leaves slot 2
1059
+ * empty rather than shifting slot 3 into it, so a trigger pointing at
1060
+ * `custom3` keeps meaning the same recording.
1061
+ */
1062
+ custom1File: z.string().default("").volatile(),
1063
+ custom1Name: z.string().default("").volatile(),
1064
+ custom2File: z.string().default("").volatile(),
1065
+ custom2Name: z.string().default("").volatile(),
1066
+ custom3File: z.string().default("").volatile(),
1067
+ custom3Name: z.string().default("").volatile(),
1068
+ /**
1069
+ * Legacy inline audio (up to 1.8.6), base64; empty when none.
1070
+ *
1071
+ * Kept readable so an install that updated itself from an older release
1072
+ * keeps its sound: the browser migrates it into slot 1 on first run and
1073
+ * only then clears it. Nothing writes this field any more.
1074
+ */
1075
+ customSound: z.string().default("").volatile(),
1076
+ /** Legacy display name that belonged to `customSound`. */
1077
+ customSoundName: z.string().default("").volatile(),
1078
+ /** Legacy per-trigger reference from 1.9.0, migrated into the list then cleared. */
1079
+ customDecisionFile: z.string().default("").volatile(),
1080
+ /** Legacy display name that belonged to `customDecisionFile`. */
1081
+ customDecisionName: z.string().default("").volatile(),
1082
+ /** Legacy per-trigger reference from 1.9.0. */
1083
+ customQuestionFile: z.string().default("").volatile(),
1084
+ /** Legacy display name that belonged to `customQuestionFile`. */
1085
+ customQuestionName: z.string().default("").volatile(),
1086
+ /** Legacy per-trigger reference from 1.9.0. */
1087
+ customCompletionFile: z.string().default("").volatile(),
1088
+ /** Legacy display name that belonged to `customCompletionFile`. */
1089
+ customCompletionName: z.string().default("").volatile(),
1090
+ /** Show an OS notification (when the page is hidden) in addition to the sound. */
1091
+ systemNotify: z.boolean().default(false).volatile(),
1092
+ /** Prefix the window title with a word (待处理 / 已完成) while the page is hidden. */
1093
+ titleFlash: z.boolean().default(false).volatile(),
1094
+ /** Re-ping unanswered decisions at a fixed interval, until one is handled or seen. */
1095
+ renotify: z.boolean().default(false).volatile(),
1096
+ /** Re-ping interval in minutes. */
1097
+ renotifyMinutes: z.number().step(1).min(1).max(60).default(5).volatile(),
1098
+ /** Suppress every sound and notification during a daily time range. */
1099
+ quietHours: z.boolean().default(false).volatile(),
1100
+ /** Quiet range start, "HH:MM" 24-hour. */
1101
+ quietStart: z.string().pattern(TIME_OF_DAY).default("22:00").volatile(),
1102
+ /** Quiet range end, "HH:MM" 24-hour. */
1103
+ quietEnd: z.string().pattern(TIME_OF_DAY).default("08:00").volatile(),
1104
+ /** In goal mode, stay silent on intermediate rounds and chime once when the goal ends. */
1105
+ goalFinalOnly: z.boolean().default(false).volatile(),
1106
+ });
1107
+
1108
+ /**
1109
+ * Host plugin body. Registers the page policy and the focus route; the
1110
+ * sounds and the Settings section exist in the browser entry.
1111
+ *
1112
+ * @param ctx - the Host plugin context.
1113
+ */
1114
+ export function apply(ctx) {
1115
+ // The Client half registers a dedicated `settings.section` page; this asks
1116
+ // the plugin inventory to hold off an auto-generated form for the same
1117
+ // namespace so the preference is editable from exactly one place. Note:
1118
+ // `autoGenerate` is not yet consumed by any shipped client (runtime README
1119
+ // confirms), so this is a forward-compatible declaration rather than an
1120
+ // effect that is already in force.
1121
+ ctx.inject(["settings"], (child) => {
1122
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
1123
+ });
1124
+
1125
+ // The focus route: the browser half posts here when a system notification
1126
+ // is clicked. It carries the composition's connection fence — the same
1127
+ // Host/Origin checks plus login-token cookie the open-in-app routes rely
1128
+ // on — so only the authenticated GUI can trip it, and it answers before
1129
+ // raising so the click never waits.
1130
+ ctx.inject(["webServer", "connection"], (child) => {
1131
+ child.effect(() => child.webServer.register({
1132
+ kind: "exact",
1133
+ path: FOCUS_PATH,
1134
+ handler: async (req, res) => {
1135
+ const rejection = child.connection.requestRejection(req);
1136
+ if (rejection !== undefined) {
1137
+ res.statusCode = rejection;
1138
+ res.end();
1139
+ return;
1140
+ }
1141
+ if (req.method !== "POST") {
1142
+ res.statusCode = 405;
1143
+ res.setHeader("allow", "POST");
1144
+ res.end();
1145
+ return;
1146
+ }
1147
+ // Which client asked? The Desktop's renderer fetches from its
1148
+ // own `dsh-app://app/` origin, and the shell STRIPS Origin and
1149
+ // Sec-Fetch-Site before forwarding to this server (the shell's
1150
+ // `forwardWebRequest` in `lib/main.js`), so a Desktop click
1151
+ // arrives with neither header while a real browser always sends
1152
+ // at least one. Checking BOTH keeps this working if the shell
1153
+ // ever stops stripping only one of them: the day it strips
1154
+ // neither, a Desktop click would silently start looking like a
1155
+ // browser one and clicks would stop raising the window — with no
1156
+ // other symptom to explain it.
1157
+ //
1158
+ // The two need opposite things, so the choice is derived here
1159
+ // rather than configured: a browser click needs no help, since
1160
+ // activating the notification raises the browser window itself
1161
+ // and raising the Desktop window instead would be the wrong
1162
+ // window entirely.
1163
+ const headers = req.headers ?? {};
1164
+ const fromBrowser = headers.origin !== undefined || headers["sec-fetch-site"] !== undefined;
1165
+ res.statusCode = 204;
1166
+ res.end();
1167
+ log(`focus click (fromBrowser=${fromBrowser})`);
1168
+ if (!fromBrowser) {
1169
+ // Foreground requests can be refused while another app owns
1170
+ // the input queue; retry before paying for a deep-link launch.
1171
+ let raised = false;
1172
+ for (let attempt = 0; attempt < 3 && !raised; attempt += 1) {
1173
+ raised = tryRaiseNative();
1174
+ if (!raised) await new Promise((resolve) => setTimeout(resolve, 180));
1175
+ }
1176
+ if (!raised) focusDesktopWindowDeepLink();
1177
+ }
1178
+ },
1179
+ }), "notify-sounds: focus route");
1180
+
1181
+ // The update check route: a fixed, read-only question about this
1182
+ // plugin's own npm package. Same fence as the focus route. It takes no
1183
+ // URL, package name or version from the caller — there is nothing here
1184
+ // to aim anywhere else.
1185
+ child.effect(() => child.webServer.register({
1186
+ kind: "exact",
1187
+ path: UPDATE_CHECK_PATH,
1188
+ handler: async (req, res) => {
1189
+ const rejection = child.connection.requestRejection(req);
1190
+ if (rejection !== undefined) {
1191
+ res.statusCode = rejection;
1192
+ res.end();
1193
+ return;
1194
+ }
1195
+ if (req.method !== "GET") {
1196
+ res.statusCode = 405;
1197
+ res.setHeader("allow", "GET");
1198
+ res.end();
1199
+ return;
1200
+ }
1201
+ try {
1202
+ const force = new URL(req.url ?? "/", "http://localhost").searchParams.get("force") === "1";
1203
+ const result = await checkForUpdate({ force });
1204
+ res.statusCode = 200;
1205
+ res.setHeader("content-type", "application/json");
1206
+ res.end(JSON.stringify(result));
1207
+ } catch (error) {
1208
+ warn(`update check failed: ${error?.message ?? error}`);
1209
+ res.statusCode = 502;
1210
+ res.end();
1211
+ }
1212
+ },
1213
+ }), "notify-sounds: update check route");
1214
+
1215
+ });
1216
+ // The goal phase route (1.8.1): the browser cannot rely on its own
1217
+ // session projections for a session it never opened, so goal mode asks
1218
+ // the Host, whose projection store is authoritative. Read-only, one
1219
+ // session id, fenced like every other route here.
1220
+ ctx.inject(["webServer", "connection", "sessions", "sessionProjections"], (child) => {
1221
+ /** The goal phase for one session, or undefined when there is none. */
1222
+ const phaseOf = (sessionId) => {
1223
+ try {
1224
+ const session = child.sessions.get(sessionId);
1225
+ if (session === undefined) return undefined;
1226
+ const projection = child.sessionProjections.stateOf(session, "goal");
1227
+ if (projection === undefined || projection.failure != null) return undefined;
1228
+ const phase = projection.current?.goal?.phase ?? projection.current?.phase;
1229
+ return typeof phase === "string" ? phase : undefined;
1230
+ } catch (error) {
1231
+ // The projection API is internal, and it has already changed
1232
+ // shape once (1.8.2). A throw here would otherwise be invisible:
1233
+ // the browser reads "no phase" as "no goal" and goal mode goes
1234
+ // back to chiming on every round, with nothing to explain it.
1235
+ // One deduplicated line in the Host log is the difference
1236
+ // between "a DSH update broke this" and "the plugin is buggy".
1237
+ warn(`goal projection unreadable: ${error?.message ?? error}`);
1238
+ return undefined;
1239
+ }
1240
+ };
1241
+ child.effect(() => child.webServer.register({
1242
+ kind: "exact",
1243
+ path: GOAL_PHASE_PATH,
1244
+ handler: (req, res) => {
1245
+ const rejection = child.connection.requestRejection(req);
1246
+ if (rejection !== undefined) {
1247
+ res.statusCode = rejection;
1248
+ res.end();
1249
+ return;
1250
+ }
1251
+ if (req.method !== "GET") {
1252
+ res.statusCode = 405;
1253
+ res.setHeader("allow", "GET");
1254
+ res.end();
1255
+ return;
1256
+ }
1257
+ const sessionId = new URL(req.url ?? "/", "http://localhost").searchParams.get("session") ?? "";
1258
+ const phase = sessionId === "" ? undefined : phaseOf(sessionId);
1259
+ res.statusCode = 200;
1260
+ res.setHeader("content-type", "application/json");
1261
+ res.end(JSON.stringify({ phase: phase ?? null }));
1262
+ },
1263
+ }), "notify-sounds: goal phase route");
1264
+ });
1265
+
1266
+ // Uploaded sounds (1.9.0): the bytes live as files beside the profile, not
1267
+ // inside the configuration, and the page asks for them by content hash.
1268
+ //
1269
+ // This is deliberately STATELESS: the Host does not read its own config
1270
+ // here. Volatile config values are live cells rather than plain strings,
1271
+ // so a Host-side read would have to unwrap them — and the browser already
1272
+ // holds the authoritative snapshot. The page therefore names the file it
1273
+ // wants (`?file=<sha>.<ext>`), and the Host only ever touches the one
1274
+ // directory it owns, after checking the name against a hash pattern.
1275
+ ctx.inject(["webServer", "connection"], (child) => {
1276
+ child.effect(() => child.webServer.register({
1277
+ kind: "exact",
1278
+ path: SOUND_PATH,
1279
+ handler: async (req, res) => {
1280
+ const rejection = child.connection.requestRejection(req);
1281
+ if (rejection !== undefined) {
1282
+ res.statusCode = rejection;
1283
+ res.end();
1284
+ return;
1285
+ }
1286
+ const url = new URL(req.url ?? "/", "http://localhost");
1287
+ try {
1288
+ if (req.method === "GET" || req.method === "HEAD") {
1289
+ const name = url.searchParams.get("file") ?? "";
1290
+ const file = soundFilePath(name);
1291
+ // Read, do not stat: one call closes the window between
1292
+ // "the file exists" and "here are its bytes".
1293
+ let bytes;
1294
+ try {
1295
+ bytes = file === undefined ? undefined : readFileSync(file);
1296
+ } catch {
1297
+ bytes = undefined;
1298
+ }
1299
+ if (bytes === undefined) {
1300
+ res.statusCode = 404;
1301
+ res.end();
1302
+ return;
1303
+ }
1304
+ res.statusCode = 200;
1305
+ // Content-addressed: the name IS the digest, so the bytes
1306
+ // behind it can never change.
1307
+ res.setHeader("cache-control", "public, max-age=31536000, immutable");
1308
+ res.setHeader("content-type", soundMimeType(name));
1309
+ res.setHeader("content-length", String(bytes.length));
1310
+ res.end(req.method === "HEAD" ? undefined : bytes);
1311
+ return;
1312
+ }
1313
+ if (req.method === "POST") {
1314
+ const bytes = await readRequestBytes(req, SOUND_MAX_BYTES);
1315
+ if (bytes === undefined) {
1316
+ res.statusCode = 413;
1317
+ res.end();
1318
+ return;
1319
+ }
1320
+ const name = writeSoundFile(bytes);
1321
+ if (name === undefined) {
1322
+ res.statusCode = 415;
1323
+ res.end();
1324
+ return;
1325
+ }
1326
+ res.statusCode = 200;
1327
+ res.setHeader("content-type", "application/json");
1328
+ res.end(JSON.stringify({ file: name }));
1329
+ return;
1330
+ }
1331
+ if (req.method === "DELETE") {
1332
+ const name = url.searchParams.get("file") ?? "";
1333
+ deleteSoundFile(name);
1334
+ res.statusCode = 204;
1335
+ res.end();
1336
+ return;
1337
+ }
1338
+ res.statusCode = 405;
1339
+ res.setHeader("allow", "GET, POST, DELETE");
1340
+ res.end();
1341
+ } catch (error) {
1342
+ warn(`sound store failed: ${error?.message ?? error}`);
1343
+ res.statusCode = 500;
1344
+ res.end();
1345
+ }
1346
+ },
1347
+ }), "notify-sounds: sound store route");
1348
+ });
1349
+ }