dsh-plugin-notify-sounds 1.8.4 → 1.8.6

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.
package/lib/index.js CHANGED
@@ -1,1552 +1,1080 @@
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 four 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
+ *
24
+ * `apply` also asks the settings namespace to hold off any auto-generated
25
+ * inventory page for this entry, because the Client contributes a dedicated
26
+ * Settings section of its own. (Note: `autoGenerate` is not yet consumed by
27
+ * any shipped client — the flag is correct and future-proof, but today the
28
+ * effect is a no-op. Runtime README: "no shipped client does so yet".)
29
+ *
30
+ * @module dsh-plugin-notify-sounds
31
+ */
32
+
33
+ import z from "@deepseek-ai/schemastery";
34
+ import { spawn } from "node:child_process";
35
+ import { createRequire } from "node:module";
36
+ import { existsSync, readFileSync } from "node:fs";
37
+ import { basename, dirname, join } from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+
40
+ /** Diagnostic name of this Host plugin. */
41
+ export const name = "notify-sounds";
42
+
43
+ /** 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. */
44
+ export const SOUND_IDS = ["bell", "chime", "ding", "drop", "pulse", "arc", "coin", "success", "knock", "custom", "none"];
45
+
46
+ /** Quiet-hours time-of-day pattern, "HH:MM". Exported for the same cross-check. */
47
+ export const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
48
+
49
+ /** Deep link the packaged Desktop registers (`setAsDefaultProtocolClient`); the last-resort focus path. */
50
+ const FOCUS_DEEP_LINK = "dsh://open";
51
+
52
+ /**
53
+ * The desktop app's bundle id, for raising it by identity on macOS.
54
+ *
55
+ * Taken from the packaged shell's own manifest (`dshDesktopAppId`), not
56
+ * guessed: `open -b <id>` is the only raise that needs no URL scheme to have
57
+ * been registered.
58
+ */
59
+ const MACOS_BUNDLE_ID = "com.deepseek.dsh";
60
+
61
+ /** Route the browser half posts to when a system notification is clicked. Exported so the tests can cross-check the Client half's spelling. */
62
+ export const FOCUS_PATH = "/notify-sounds/focus";
63
+
64
+ /** App name the Desktop shell appends to its primary window's title. */
65
+ const DESKTOP_APP_TITLE = "DeepSeek Harness";
66
+
67
+ /** `ShowWindow` command: restore a minimized or maximized window to its prior size. */
68
+ const SW_RESTORE = 9;
69
+ /** `ShowWindow` command: show a window without asking for the foreground. */
70
+ const SW_SHOW = 5;
71
+
72
+ /** Virtual key and flag for the ALT tap that satisfies the foreground lock. */
73
+ const VK_MENU = 0x12;
74
+
75
+ /** The shell owns the window: this Host is its child process. */
76
+ const SHELL_PID = process.ppid;
77
+ const KEYEVENTF_KEYUP = 0x2;
78
+
79
+ //#region native window raise
80
+
81
+ /**
82
+ * Locate the Desktop's own prebuilt `koffi` native module.
83
+ *
84
+ * The Desktop Host process already ships `koffi` as its own dependency (it is
85
+ * what backs the PTY bindings), so borrowing it costs this plugin nothing. The
86
+ * native binary is the one thing the app cannot pack into `app.asar`, so it
87
+ * sits in `app.asar.unpacked` as a real filesystem path this process can load.
88
+ *
89
+ * The install root cannot be derived from this module's own location: the
90
+ * plugin lives in the DSH profile (`~/.dsh/profiles/<name>/node_modules/…`),
91
+ * which has nothing to do with where the app is installed. Two sources are
92
+ * tried, because neither is guaranteed here:
93
+ *
94
+ * - `process.resourcesPath`, which Electron injects — but this process runs
95
+ * as Electron's own binary in Node mode, where it is absent (measured:
96
+ * empty).
97
+ * - this process's own executable: the Host IS the Desktop binary, so
98
+ * `<exe dir>/resources` is the same directory. A `resources` candidate is
99
+ * only trusted when it actually holds the app.
100
+ *
101
+ * @returns the module path, or undefined when this build does not ship it.
102
+ */
103
+ function resolveNativeModule() {
104
+ if (process.platform !== "win32") return undefined;
105
+ const resources = [];
106
+ if (typeof process.resourcesPath === "string" && process.resourcesPath !== "") {
107
+ resources.push(process.resourcesPath);
108
+ }
109
+ try {
110
+ const beside = join(dirname(process.execPath), "resources");
111
+ if (!resources.includes(beside) && existsSync(join(beside, "app.asar"))) resources.push(beside);
112
+ } catch {
113
+ // An unusable executable path simply leaves the other sources.
114
+ }
115
+ // Only the unpacked native binary is reachable: koffi's JavaScript wrapper
116
+ // lives inside `app.asar`, while its `.node` is extracted beside it, so the
117
+ // package directory itself never resolves from here. The unpacked location
118
+ // has been one shape in every build examined; the second entry covers the
119
+ // flat layout in case a build hoists it.
120
+ const relatives = [
121
+ ["app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "dsh-desktop-host", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
122
+ ["app.asar.unpacked", "dsh", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
123
+ // A non-asar (development) install has no `app.asar.unpacked` wrapper.
124
+ ["dsh", "node_modules", "@koromix", "koffi-win32-x64", "win32_x64", "koffi.node"],
125
+ ];
126
+ for (const base of resources) {
127
+ for (const relative of relatives) {
128
+ const candidate = join(base, ...relative);
129
+ if (existsSync(candidate)) return candidate;
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ /**
136
+ * Load the Win32 entry points through the Desktop's own FFI binding.
137
+ *
138
+ * Deliberately narrow. Only `user32` window calls are bound: finding the window
139
+ * by title means this never inspects another process, which is the behaviour
140
+ * proactive defence exists to catch. An earlier version resolved the owning
141
+ * executable with `OpenProcess`, and a security suite flagged the Desktop's
142
+ * own executable on every click.
143
+ *
144
+ * Loaded once and cached; a failed load is remembered so it is not retried.
145
+ *
146
+ * @returns the bound calls, or undefined when FFI is unavailable here.
147
+ */
148
+ function loadNativeRaise() {
149
+ if (process.platform !== "win32") return undefined;
150
+ if (nativeRaise !== undefined) return nativeRaise ?? undefined;
151
+ let bound;
152
+ try {
153
+ const modulePath = resolveNativeModule();
154
+ if (modulePath === undefined) throw new Error("koffi not found");
155
+ // The `.node` is loaded directly: koffi's JavaScript wrapper sits inside
156
+ // `app.asar` and cannot be required from here, but the binding exports
157
+ // the whole API on its own. The base path only has to be a real
158
+ // directory for module resolution, which the binary's own is.
159
+ const koffi = createRequire(join(dirname(modulePath), "index.cjs"))(modulePath);
160
+ const user32 = koffi.load("user32.dll");
161
+ const kernel32 = koffi.load("kernel32.dll");
162
+ const EnumWindowsProc = koffi.proto("bool __stdcall WNDENUMPROC(void *hWnd, intptr_t lParam)");
163
+ bound = {
164
+ koffi,
165
+ EnumWindowsProc,
166
+ EnumWindows: user32.func("bool __stdcall EnumWindows(void *cb, intptr_t param)"),
167
+ IsWindowVisible: user32.func("bool __stdcall IsWindowVisible(void *h)"),
168
+ GetWindowTextLengthW: user32.func("int __stdcall GetWindowTextLengthW(void *h)"),
169
+ GetWindowTextW: user32.func("int __stdcall GetWindowTextW(void *h, _Out_ uint16 *buf, int max)"),
170
+ ShowWindow: user32.func("bool __stdcall ShowWindow(void *h, int cmd)"),
171
+ BringWindowToTop: user32.func("bool __stdcall BringWindowToTop(void *h)"),
172
+ SetForegroundWindow: user32.func("bool __stdcall SetForegroundWindow(void *h)"),
173
+ GetForegroundWindow: user32.func("void * __stdcall GetForegroundWindow()"),
174
+ // Bound once, with the out-parameter shape callers actually use:
175
+ // both sites pass a Uint32Array. An earlier build also bound the
176
+ // same export a second time under a `void *` signature and passed
177
+ // `null` there — two names for one API, one of them relying on the
178
+ // binding layer to tolerate a null out-parameter.
179
+ GetWindowThreadProcessId: user32.func("uint32 __stdcall GetWindowThreadProcessId(void *h, _Out_ uint32 *pid)"),
180
+ AttachThreadInput: user32.func("bool __stdcall AttachThreadInput(uint32 attach, uint32 attachTo, bool attachFlag)"),
181
+ keybd_event: user32.func("void __stdcall keybd_event(uint8 vk, uint8 scan, uint32 flags, uintptr_t extra)"),
182
+ GetCurrentThreadId: kernel32.func("uint32 __stdcall GetCurrentThreadId()"),
183
+ };
184
+ } catch (error) {
185
+ // Remember the failure so it is not retried, but say so once: a user's
186
+ // only symptom otherwise is "clicking did nothing".
187
+ warn(`native raise unavailable: ${error?.message ?? error}`);
188
+ bound = null;
189
+ }
190
+ nativeRaise = bound;
191
+ return bound ?? undefined;
192
+ }
193
+
194
+ /** Cached FFI binding; `null` records a failed load so it is not retried. */
195
+ let nativeRaise;
196
+
197
+ /**
198
+ * Read a window's title, or "" when it has none.
199
+ *
200
+ * @param native - the bound Win32 calls.
201
+ * @param handle - the window to read.
202
+ * @returns the title text.
203
+ */
204
+ export function windowTitle(native, handle) {
205
+ try {
206
+ const length = native.GetWindowTextLengthW(handle);
207
+ if (length <= 0) return "";
208
+ const buffer = new Uint16Array(length + 2);
209
+ const copied = native.GetWindowTextW(handle, buffer, buffer.length);
210
+ // The two calls are not atomic: between them the page can rewrite its
211
+ // title (switching sessions is exactly when it does), and this call
212
+ // then copies fewer characters than the first one counted. Slicing by
213
+ // the OLD count would hand back a string with NULs on the end — which
214
+ // no real title ever contains, and which silently stops `endsWith`
215
+ // matching, i.e. one click that does nothing at all. Slice by what was
216
+ // actually copied, and treat a nonsense return as "read the old count"
217
+ // rather than trusting it.
218
+ const written = Number.isFinite(copied) && copied >= 0 ? Math.min(copied, length) : length;
219
+ return Buffer.from(buffer.buffer, 0, written * 2).toString("utf16le");
220
+ } catch {
221
+ return "";
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Whether a window's title is the Desktop's.
227
+ *
228
+ * The title is whatever the page last set — the shell only overrides it for
229
+ * its own error screens — so it is `<session or screen title> — <app name>`
230
+ * while a session is open, and may be just the app name on a blank screen.
231
+ * Both shapes have to match, which is why the bare title is accepted too: a
232
+ * prefix-only rule silently stops finding the window on a screen without a
233
+ * session.
234
+ *
235
+ * @param title - the window's title.
236
+ * @returns whether it names this app.
237
+ *
238
+ * Exported for the Host-half tests: the one pure piece of the Win32 search.
239
+ */
240
+ export function isDesktopTitle(title) {
241
+ return title === DESKTOP_APP_TITLE
242
+ || title.endsWith(`— ${DESKTOP_APP_TITLE}`)
243
+ || title.endsWith(`- ${DESKTOP_APP_TITLE}`);
244
+ }
245
+
246
+ /**
247
+ * Find the Desktop's primary window.
248
+ *
249
+ * Matched by the title the page composes. This deliberately inspects nothing
250
+ * about a window beyond its title: resolving an owning process
251
+ * (`OpenProcess` + `GetModuleBaseNameW`) finds it too, but reading another
252
+ * process is what a security suite's behaviour engine flags — that version
253
+ * brought a detection dialog on every click, attributed to the Desktop's own
254
+ * executable.
255
+ *
256
+ * @param native - the bound Win32 calls.
257
+ * @returns the window handle, or undefined.
258
+ */
259
+ export function findDesktopWindow(native) {
260
+ let byTitleVisible;
261
+ let byTitleHidden;
262
+ let byPidVisible;
263
+ let byPidHidden;
264
+ const pidBuffer = new Uint32Array(1);
265
+ const visit = native.koffi.register((handle) => {
266
+ try {
267
+ const visible = native.IsWindowVisible(handle);
268
+ const title = windowTitle(native, handle);
269
+ if (isDesktopTitle(title)) {
270
+ if (visible) {
271
+ byTitleVisible = handle;
272
+ return false; // stop at the first visible title match
273
+ }
274
+ if (byTitleHidden === undefined) byTitleHidden = handle;
275
+ return true;
276
+ }
277
+ // A hidden or repainting window can transiently answer with no
278
+ // title; the shell process owns the window, so match that too.
279
+ if (SHELL_PID === undefined || SHELL_PID === 0) return true;
280
+ pidBuffer[0] = 0;
281
+ native.GetWindowThreadProcessId(handle, pidBuffer);
282
+ if (pidBuffer[0] !== SHELL_PID) return true;
283
+ if (visible) {
284
+ if (byPidVisible === undefined) byPidVisible = handle;
285
+ } else if (byPidHidden === undefined) {
286
+ byPidHidden = handle;
287
+ }
288
+ } catch {
289
+ // A window that cannot be inspected simply does not match.
290
+ }
291
+ return true;
292
+ }, native.koffi.pointer(native.EnumWindowsProc));
293
+ try {
294
+ native.EnumWindows(visit, 0);
295
+ } catch {
296
+ // Enumeration failing means no window is found.
297
+ } finally {
298
+ try {
299
+ native.koffi.unregister(visit);
300
+ } catch {}
301
+ }
302
+ // Which match wins when several windows qualify. A VISIBLE window owned
303
+ // by the shell outranks a hidden one matched only by its title: the
304
+ // hidden-title case exists for a window parked in the tray (1.8.3), and a
305
+ // stale hidden instance left over from an earlier run must not be the one
306
+ // brought forward while the live window is right there on screen.
307
+ return byTitleVisible ?? byPidVisible ?? byTitleHidden ?? byPidHidden;
308
+ }
309
+
310
+ /**
311
+ * Restore and focus the Desktop window from inside this process.
312
+ *
313
+ * @returns whether the window actually ended up in the foreground.
314
+ */
315
+ function raiseDesktopWindowNative() {
316
+ const native = loadNativeRaise();
317
+ if (native === undefined) return false;
318
+ const handle = findDesktopWindow(native);
319
+ if (handle === undefined) return false;
320
+ // Restore first: `ShowWindow(SW_RESTORE)` is not a foreground request, so it
321
+ // is granted even to a background process. Its return value is the window's
322
+ // PREVIOUS visibility, not a success flag, so it is deliberately ignored.
323
+ // SW_SHOW is not a foreground request, so a background process is granted
324
+ // it; it is what makes a tray-hidden window visible again.
325
+ if (!native.IsWindowVisible(handle)) native.ShowWindow(handle, SW_SHOW);
326
+ native.ShowWindow(handle, SW_RESTORE);
327
+ native.BringWindowToTop(handle);
328
+ // The foreground request is the part Windows polices: a background process
329
+ // may only take the foreground under some conditions, so on its own this is
330
+ // not reliable. When it is refused, the window is lifted in Z-order but
331
+ // stays unfocused, which the user reads as "nothing happened".
332
+ //
333
+ // Two documented ways to satisfy the lock are applied first. The ALT tap
334
+ // makes Windows treat this as user activity (ALT is not delivered to any
335
+ // window), and the thread attach makes the request appear to come from a
336
+ // thread already connected to the active window. Together they cover the
337
+ // case where the foreground grant the click carried has already lapsed.
338
+ //
339
+ // `AllowSetForegroundWindow` is deliberately absent: the documentation
340
+ // grants the right to ANOTHER process, so calling it before our own request
341
+ // buys nothing (measured: it returned false every time, while the request
342
+ // that followed still succeeded). The retry that does help is one level up,
343
+ // in the focus route, where the whole raise is repeated after a real pause.
344
+ //
345
+ // The attach/detach pair is guarded so a throw from the calls between them
346
+ // cannot leave this thread attached to the other window's input queue.
347
+ native.keybd_event(VK_MENU, 0, 0, 0); // ALT down
348
+ native.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0); // ALT up
349
+ const threadId = new Uint32Array(1);
350
+ const activate = () => {
351
+ const foreground = native.GetForegroundWindow();
352
+ threadId[0] = 0;
353
+ const foregroundThread = foreground === null ? 0 : native.GetWindowThreadProcessId(foreground, threadId);
354
+ const ownThread = native.GetCurrentThreadId();
355
+ let attached = false;
356
+ try {
357
+ attached = foregroundThread !== 0 && foregroundThread !== ownThread
358
+ && native.AttachThreadInput(foregroundThread, ownThread, true);
359
+ native.SetForegroundWindow(handle);
360
+ native.BringWindowToTop(handle);
361
+ } finally {
362
+ if (attached) native.AttachThreadInput(foregroundThread, ownThread, false);
363
+ }
364
+ };
365
+ activate();
366
+ // One attempt per call. The retry that matters lives one level up, in the
367
+ // focus route: it calls this whole raise up to three times with a real
368
+ // pause between attempts, which is what gives the ALT tap time to land.
369
+ // Repeating `activate()` back to back here bought nothing measurable.
370
+ return native.GetForegroundWindow() === handle;
371
+ }
372
+
373
+ /**
374
+ * Raise the Desktop window through the app's own deep link.
375
+ *
376
+ * Last resort, for a build that does not ship the FFI binding. The deep link
377
+ * goes through Windows, and the second instance's foreground request can be
378
+ * refused when the launch comes from a background process — which is why it is
379
+ * the fallback and not the primary path.
380
+ */
381
+ function raiseDesktopWindowDeepLink() {
382
+ // `start ""` — the empty quoted title keeps `start` from treating the URL as
383
+ // a window title.
384
+ //
385
+ // `spawn`, NOT `spawnSync`: this is the last resort, and blocking the Host's
386
+ // event loop for up to a timeout would stall every other HTTP request —
387
+ // including streamed output — while the user waits on a click. `detached`
388
+ // is deliberately NOT set either: measured earlier, a detached child here
389
+ // exits 0 without the launch taking effect. The child exits on its own once
390
+ // `cmd /c start` has handed the URL to Windows, and holding the reference
391
+ // keeps it alive until then.
392
+ launcher("cmd.exe", ["/d", "/s", "/c", "start", "", FOCUS_DEEP_LINK]);
393
+ }
394
+
395
+ /**
396
+ * Start a short-lived launcher without blocking, reporting failures once.
397
+ *
398
+ * The exit code matters as much as the spawn: `open` exits non-zero when no
399
+ * application handles the request (macOS: "Unable to find application for
400
+ * URL"), and a launch that never happened must not look exactly like one
401
+ * that did.
402
+ *
403
+ * @param command - the executable to run.
404
+ * @param args - its arguments.
405
+ * @param done - called with whether the launcher exited cleanly; optional,
406
+ * so a caller can chain a fallback.
407
+ */
408
+ function launcher(command, args, done) {
409
+ try {
410
+ const child = spawn(command, args, { windowsHide: true, stdio: "ignore" });
411
+ child.on("error", (error) => {
412
+ warn(`could not launch ${command}: ${error?.message ?? error}`);
413
+ done?.(false);
414
+ });
415
+ child.on("exit", (code) => {
416
+ const ok = code === 0;
417
+ if (!ok) warn(`${command} exited ${code}: ${args.join(" ")}`);
418
+ done?.(ok);
419
+ });
420
+ child.unref();
421
+ } catch (error) {
422
+ warn(`could not launch ${command}: ${error?.message ?? error}`);
423
+ done?.(false);
424
+ }
425
+ }
426
+
427
+ /**
428
+ * One line in the Host log.
429
+ *
430
+ * Unlike `warn`, deliberately NOT deduplicated: routine events are the point.
431
+ * For a click that reports "nothing happened", one line per attempt answers
432
+ * whether the request ever reached this process — `focus click` says it
433
+ * arrived. The update check uses the same channel for its one-per-day
434
+ * `asking <registry>` line, which is what a "why is the version stale?"
435
+ * report needs.
436
+ *
437
+ * @param message - what happened.
438
+ */
439
+ function log(message) {
440
+ try {
441
+ // eslint-disable-next-line no-console -- the Host has no logger on this context
442
+ console.log(`notify-sounds: ${message}`);
443
+ } catch {
444
+ // Reporting must never become the failure.
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Report a degraded path once, without failing the click.
450
+ *
451
+ * The raise has several ways to fall back silently, and a user's only symptom
452
+ * is "clicking did nothing" — which cannot be diagnosed from the UI. One line
453
+ * per distinct problem is enough to explain it from the Host log.
454
+ *
455
+ * @param message - what went wrong.
456
+ */
457
+ function warn(message) {
458
+ try {
459
+ if (warned.has(message)) return;
460
+ warned.add(message);
461
+ // eslint-disable-next-line no-console -- the Host has no logger on this context
462
+ console.warn(`notify-sounds: ${message}`);
463
+ } catch {
464
+ // Reporting must never become the failure.
465
+ }
466
+ }
467
+
468
+ /** Messages already reported, so a repeated click does not repeat the warning. */
469
+ const warned = new Set();
470
+
471
+ //#endregion
472
+
473
+ /**
474
+ * Raise the Desktop's primary window.
475
+ *
476
+ * The renderer cannot do this itself: `window.focus()` only focuses the page
477
+ * inside the window, and the sandboxed preload bridge exposes no window
478
+ * control. On Windows this process raises the window itself over FFI, falling
479
+ * back to the app's deep link. macOS and Linux use the platform opener.
480
+ *
481
+ * Every failure mode leaves the click a harmless no-op.
482
+ */
483
+ function tryRaiseNative() {
484
+ try {
485
+ if (process.platform !== "win32") return false;
486
+ return raiseDesktopWindowNative();
487
+ } catch (error) {
488
+ warn(`native raise failed: ${error?.message ?? error}`);
489
+ return false;
490
+ }
491
+ }
492
+
493
+ /** Raise the window through the app's own deep link; last resort. */
494
+ function focusDesktopWindowDeepLink() {
495
+ try {
496
+ if (process.platform === "win32") {
497
+ raiseDesktopWindowDeepLink();
498
+ } else if (process.platform === "darwin") {
499
+ log("raising via open -b");
500
+ launcher("open", ["-b", MACOS_BUNDLE_ID], (ok) => {
501
+ if (!ok) launcher("open", [FOCUS_DEEP_LINK]);
502
+ });
503
+ } else {
504
+ log("raising via xdg-open + deep link");
505
+ launcher("xdg-open", [FOCUS_DEEP_LINK]);
506
+ }
507
+ } catch (error) {
508
+ warn(`focus fallback failed: ${error?.message ?? error}`);
509
+ }
510
+ }
511
+
512
+ //#region update check
513
+
514
+ /** Route the browser half reads the update check through. */
515
+ export const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
516
+
517
+ /** Route the browser half reads a session's goal phase through. */
518
+ export const GOAL_PHASE_PATH = "/notify-sounds/goal";
519
+
520
+ /** The npm package this plugin publishes as — the only name the check ever asks for. */
521
+ const UPDATE_PACKAGE = "dsh-plugin-notify-sounds";
522
+
523
+ /**
524
+ * Registries the update check consults, in order.
525
+ *
526
+ * HTTPS and hardcoded on purpose: this is the one place the plugin asks the
527
+ * outside world for something it may later act on, so nothing about it is
528
+ * configurable. The second entry is the mainland-China mirror, matching the
529
+ * install source DSH itself offers.
530
+ */
531
+ const UPDATE_REGISTRIES = ["https://registry.npmjs.org", "https://registry.npmmirror.com"];
532
+
533
+ /**
534
+ * How long one successful check result is trusted before the next check runs.
535
+ *
536
+ * A FAILURE is not trusted this long — see `UPDATE_FAILURE_TTL_MS`.
537
+ */
538
+ const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
539
+
540
+ /**
541
+ * How long a failed check is cached.
542
+ *
543
+ * Deliberately short. A failure is usually a transient network or proxy
544
+ * problem, and caching it for the full day meant one blip left the Settings
545
+ * row reading "check failed" until tomorrow — the row looked broken long
546
+ * after the cause was gone. A minute is long enough to collapse the pair of
547
+ * callers a page load produces (the row's own mount plus a user click), and
548
+ * short enough that the next look is a fresh answer.
549
+ */
550
+ const UPDATE_FAILURE_TTL_MS = 60 * 1000;
551
+
552
+ /** Minimum spacing between real requests, so a held-down button cannot hammer a registry. */
553
+ const UPDATE_CHECK_SPACING_MS = 15 * 1000;
554
+
555
+ /** Bounds on the single request a check makes. */
556
+ const UPDATE_FETCH_TIMEOUT_MS = 10 * 1000;
557
+ const UPDATE_MAX_RESPONSE_BYTES = 256 * 1024;
558
+
559
+ /** Result of the last check, and when the last real request went out. */
560
+ const updateCheck = { checkedAt: 0, requestedAt: 0, result: undefined };
561
+
562
+ /** The check currently in flight, if any — callers join it instead of starting a second one. */
563
+ let checkInFlight;
564
+
565
+ /**
566
+ * Parse `major.minor.patch` with an optional prerelease tag.
567
+ *
568
+ * Anchored at both ends on purpose: this validates an answer that arrived
569
+ * over the network, and `1.8.5-junk-here` or `1.8.5.9` must be rejected
570
+ * rather than read as `1.8.5`. Build metadata (`+sha`) is accepted and
571
+ * ignored, as semver says it must be for ordering.
572
+ *
573
+ * @returns the parts, or undefined when the text is not a version.
574
+ */
575
+ export function parseVersion(text) {
576
+ if (typeof text !== "string") return undefined;
577
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
578
+ if (match === null) return undefined;
579
+ return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] ?? ""];
580
+ }
581
+
582
+ /**
583
+ * Compare two versions the way an npm `latest` tag is meant to be compared.
584
+ *
585
+ * @returns negative when `a` sorts before `b`, 0 when they are the same
586
+ * release, positive when `a` sorts after; `NaN` when either is not a version.
587
+ */
588
+ export function compareVersions(a, b) {
589
+ const left = parseVersion(a);
590
+ const right = parseVersion(b);
591
+ if (left === undefined || right === undefined) return NaN;
592
+ for (let i = 0; i < 3; i++) {
593
+ if (left[i] !== right[i]) return left[i] - right[i];
594
+ }
595
+ return comparePrerelease(left[3], right[3]);
596
+ }
597
+
598
+ /**
599
+ * Compare two prerelease fields the way semver defines it, not as strings.
600
+ *
601
+ * Plain string order gets the common case wrong: `rc.10` sorts BEFORE `rc.9`
602
+ * ("1" < "9"), so a registry whose `latest` points at a later release candidate
603
+ * would look older than the one already installed and the update would never be
604
+ * offered. The rules are: identifiers are compared field by field; a numeric
605
+ * field compares numerically and ranks below an alphanumeric one; an
606
+ * alphanumeric field compares in ASCII order; and when every shared field is
607
+ * equal, the one with MORE fields wins (`1.0.0-rc.1` < `1.0.0-rc.1.1`).
608
+ *
609
+ * @param left - the prerelease field of the first version, "" for a release.
610
+ * @param right - the prerelease field of the second version.
611
+ * @returns negative, 0, or positive.
612
+ */
613
+ function comparePrerelease(left, right) {
614
+ if (left === right) return 0;
615
+ // A release outranks any of its own prereleases (1.5.0 > 1.5.0-rc.1).
616
+ if (left === "") return 1;
617
+ if (right === "") return -1;
618
+ const a = left.split(".");
619
+ const b = right.split(".");
620
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
621
+ const x = a[i];
622
+ const y = b[i];
623
+ if (x === undefined) return -1;
624
+ if (y === undefined) return 1;
625
+ const numericX = /^\d+$/.test(x);
626
+ const numericY = /^\d+$/.test(y);
627
+ if (numericX && numericY) {
628
+ const dx = Number(x);
629
+ const dy = Number(y);
630
+ if (dx !== dy) return dx < dy ? -1 : 1;
631
+ continue;
632
+ }
633
+ // A numeric identifier always ranks below an alphanumeric one.
634
+ if (numericX !== numericY) return numericX ? -1 : 1;
635
+ if (x !== y) return x < y ? -1 : 1;
636
+ }
637
+ return 0;
638
+ }
639
+
640
+ /**
641
+ * The version this installation ships, read from the package's own manifest.
642
+ *
643
+ * @returns the version, or undefined when the manifest is unreadable.
644
+ */
645
+ function readOwnVersion(root) {
646
+ try {
647
+ const manifest = root === undefined
648
+ ? new URL("../package.json", import.meta.url)
649
+ : join(root, "package.json");
650
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
651
+ return typeof parsed.version === "string" ? parsed.version : undefined;
652
+ } catch {
653
+ return undefined;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Read a response body under a hard size cap.
659
+ *
660
+ * The check asks for one small JSON document; anything longer is either not
661
+ * that document or is trying to be more than one, and neither is worth
662
+ * buffering. Chunks are counted as they arrive and the read is abandoned at
663
+ * the cap.
664
+ */
665
+ async function readCappedBytes(response, cap) {
666
+ const declared = Number(response.headers.get("content-length") ?? 0);
667
+ if (Number.isFinite(declared) && declared > cap) throw new Error("response too large");
668
+ const reader = response.body?.getReader?.();
669
+ if (reader === undefined) throw new Error("response has no body");
670
+ const chunks = [];
671
+ let size = 0;
672
+ for (;;) {
673
+ const { done, value } = await reader.read();
674
+ if (done) break;
675
+ size += value.byteLength;
676
+ if (size > cap) {
677
+ try {
678
+ await reader.cancel();
679
+ } catch {}
680
+ throw new Error("response too large");
681
+ }
682
+ chunks.push(value);
683
+ }
684
+ const merged = new Uint8Array(size);
685
+ let offset = 0;
686
+ for (const chunk of chunks) {
687
+ merged.set(chunk, offset);
688
+ offset += chunk.byteLength;
689
+ }
690
+ return merged;
691
+ }
692
+
693
+ /**
694
+ * The profile directory that owns this installation, when there is one.
695
+ *
696
+ * Two sources, in this order, and the order matters:
697
+ *
698
+ * - `DSH_PROFILE_DIR`, when it is set. In 0.2.1-rc.2 the only code that
699
+ * produces that variable is `@deepseek-ai/dsh-shell-env`, and it injects
700
+ * it into the MODEL's shell calls — nothing in the application puts it in
701
+ * this process. So for the Host this is a bonus, not the mechanism.
702
+ * - the filesystem. A profile-installed plugin is
703
+ * `<profile>/node_modules/<name>` (a real directory in DSH's own
704
+ * installer, and `.pnpm/<name>@<version>/node_modules/<name>` under a
705
+ * plain pnpm install), so the nearest ancestor holding `pnpm-lock.yaml`
706
+ * IS the profile. Walking up finds both layouts without knowing which
707
+ * one this is, and finds nothing for a dev checkout or a bundle shipped
708
+ * inside the app — which is exactly right, since neither has a lockfile
709
+ * that could roll this install back.
710
+ *
711
+ * @param moduleUrl - this module's URL; the test passes a fixture's.
712
+ * @returns the profile directory, or undefined.
713
+ */
714
+ export function profileDirectory(moduleUrl = import.meta.url) {
715
+ const fromEnv = process.env.DSH_PROFILE_DIR;
716
+ if (typeof fromEnv === "string" && fromEnv !== "") return fromEnv;
717
+ try {
718
+ let dir = fileURLToPath(new URL("..", moduleUrl));
719
+ for (let level = 0; level < 6; level += 1) {
720
+ const parent = dirname(dir);
721
+ if (parent === dir) break;
722
+ if (existsSync(join(parent, "pnpm-lock.yaml"))) return parent;
723
+ dir = parent;
724
+ }
725
+ } catch {
726
+ // A URL that cannot be resolved simply reports no profile.
727
+ }
728
+ return undefined;
729
+ }
730
+
731
+ /**
732
+ * How the running package relates to what the profile's package manager
733
+ * recorded for it.
734
+ *
735
+ * This plugin no longer installs its own releases (as of 1.8.0): a release
736
+ * reaches a profile through the package manager, which also owns the
737
+ * lockfile. A tree that arrived by any other route looks identical on disk
738
+ * while the lockfile still pins an older version — and the next
739
+ * `pnpm install` in that profile silently restores the old one. Reading the
740
+ * version the lockfile would restore lets the Settings row say so instead of
741
+ * leaving the user to discover it after the next plugin operation.
742
+ *
743
+ * This is the prompt that turns a self-update into a permanent one: a
744
+ * 1.7.0 install that updated itself to 1.8.6 has the new files but the old
745
+ * record, and the row is where it says so.
746
+ *
747
+ * Every part is optional: no profile directory, no lockfile or an unreadable
748
+ * one simply reports nothing, and the row keeps its normal wording.
749
+ *
750
+ * @param current - the version actually on disk.
751
+ * @returns the fields the check payload mirrors to the row.
752
+ */
753
+ export function installationProvenance(current) {
754
+ try {
755
+ const profileDir = profileDirectory();
756
+ if (profileDir === undefined) return {};
757
+ const lock = readFileSync(join(profileDir, "pnpm-lock.yaml"), "utf8");
758
+ // Only the version segment, deliberately. Dependency keys in a pnpm
759
+ // lockfile can carry a peer suffix — `name@1.8.4(@scope/peer@3.18.4)`
760
+ // — and a looser `[^:\s]*` swallowed that whole parenthetical. The
761
+ // comparison below then saw a "recorded" version that never existed
762
+ // and told the user to reinstall a perfectly good installation.
763
+ const recorded = new RegExp(`^\\s*${UPDATE_PACKAGE}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`, "m").exec(lock)?.[1];
764
+ return {
765
+ recorded,
766
+ unregistered: current !== undefined && recorded !== undefined && recorded !== current,
767
+ // A profile is `$DSH_HOME/profiles/<name>`, so the directory's own
768
+ // name is the profile name when the variable is absent — which,
769
+ // on the Host, it is. The row only uses this to spell out
770
+ // `dsh plugin --profile <name>`; without it the command simply
771
+ // omits the flag.
772
+ profile: typeof process.env.DSH_PROFILE === "string" && process.env.DSH_PROFILE !== ""
773
+ ? process.env.DSH_PROFILE
774
+ : basename(profileDir),
775
+ };
776
+ } catch {
777
+ return {};
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Ask the registries for the newest published version of this plugin.
783
+ *
784
+ * Every failure mode — offline, refused, oversized, malformed — becomes a
785
+ * result the UI can show, never a throw. This stage only reads: nothing is
786
+ * written to disk and nothing is executed. Since 1.8.0 installation belongs
787
+ * to the package manager, so the answer is a version number and nothing else:
788
+ * no tarball URL is needed, and one is not asked for.
789
+ *
790
+ * A successful answer is cached for a day; a failure for one minute (see
791
+ * `UPDATE_FAILURE_TTL_MS`) — a blip must not blank the row until tomorrow.
792
+ *
793
+ * @param options - `force` asks again inside the freshness window (the
794
+ * button); otherwise a recent result is returned as-is.
795
+ * @returns `{ ok, current, latest, updateAvailable, checkedAt }`, or
796
+ * `{ ok: false, current, error }` when no registry answered.
797
+ */
798
+ export async function checkForUpdate(options = {}) {
799
+ const now = Date.now();
800
+ const ttl = updateCheck.result?.ok === false ? UPDATE_FAILURE_TTL_MS : UPDATE_CHECK_TTL_MS;
801
+ if (options.force !== true && updateCheck.result !== undefined && now - updateCheck.checkedAt < ttl) {
802
+ return updateCheck.result;
803
+ }
804
+ // Spacing applies to real requests of every kind: a forced check may
805
+ // refresh, it may not become a load generator.
806
+ if (now - updateCheck.requestedAt < UPDATE_CHECK_SPACING_MS && updateCheck.result !== undefined) {
807
+ return updateCheck.result;
808
+ }
809
+ // Two callers arriving together — a forced check landing on an automatic
810
+ // one — must not become two requests. The spacing rule above cannot cover
811
+ // that: it only sees a finished result, and while the first request is
812
+ // still in the air there is none. So a second caller joins the first.
813
+ if (checkInFlight !== undefined) return checkInFlight;
814
+ updateCheck.requestedAt = now;
815
+ checkInFlight = (async () => {
816
+ const current = readOwnVersion(options.root);
817
+ const answer = await fetchRegistryAnswer();
818
+ if (answer === undefined) {
819
+ const result = { ok: false, current, error: "no registry answered", ...installationProvenance(current) };
820
+ updateCheck.checkedAt = now;
821
+ updateCheck.result = result;
822
+ return result;
823
+ }
824
+ const ordering = current === undefined ? NaN : compareVersions(current, answer.version);
825
+ const result = {
826
+ ok: true,
827
+ current,
828
+ latest: answer.version,
829
+ updateAvailable: Number.isFinite(ordering) && ordering < 0,
830
+ checkedAt: now,
831
+ ...installationProvenance(current),
832
+ };
833
+ updateCheck.checkedAt = now;
834
+ updateCheck.result = result;
835
+ return result;
836
+ })();
837
+ try {
838
+ return await checkInFlight;
839
+ } finally {
840
+ checkInFlight = undefined;
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Ask the registries for this plugin's newest release and believe only a
846
+ * well-formed answer.
847
+ *
848
+ * The response is untrusted input, so the version has to parse as a version
849
+ * (`parseVersion` is anchored). Anything else is skipped, so a hostile or
850
+ * broken answer is never the one the row reports.
851
+ *
852
+ * @returns `{ registry, version }`, or undefined when no registry produced an
853
+ * answer worth believing.
854
+ */
855
+ async function fetchRegistryAnswer() {
856
+ for (const registry of UPDATE_REGISTRIES) {
857
+ try {
858
+ log(`update check: asking ${registry}`);
859
+ const response = await fetch(`${registry}/${UPDATE_PACKAGE}/latest`, {
860
+ signal: AbortSignal.timeout(UPDATE_FETCH_TIMEOUT_MS),
861
+ redirect: "error",
862
+ headers: { accept: "application/json" },
863
+ });
864
+ if (!response.ok) continue;
865
+ const payload = JSON.parse(new TextDecoder().decode(await readCappedBytes(response, UPDATE_MAX_RESPONSE_BYTES)));
866
+ const version = payload?.version;
867
+ if (typeof version !== "string" || parseVersion(version) === undefined) continue;
868
+ return { registry, version };
869
+ } catch {
870
+ // One registry failing is the ordinary case for the next one to
871
+ // answer; running out of registries is the failure worth reporting.
872
+ }
873
+ }
874
+ return undefined;
875
+ }
876
+
877
+ //#endregion
878
+
879
+ /**
880
+ * Live preferences projected to the browser. Every field is volatile: the
881
+ * Settings document serves them, form edits reach them without restarting
882
+ * this entry, and the Client mirrors them through `configForms`.
883
+ */
884
+ export const Config = z.object({
885
+ /** Master switch; `false` mutes every notification sound. */
886
+ enabled: z.boolean().default(true).volatile(),
887
+ /** Playback loudness in percent. */
888
+ volume: z.number().step(1).min(0).max(100).default(60).volatile(),
889
+ /** Preset played when a Session gains a pending approval or plan review. */
890
+ decisionSound: z.union(SOUND_IDS).default("bell").volatile(),
891
+ /** Preset played when a Session gains a pending question. */
892
+ questionSound: z.union(SOUND_IDS).default("chime").volatile(),
893
+ /** Preset played when a Session's agent finishes (running → idle). */
894
+ completionSound: z.union(SOUND_IDS).default("ding").volatile(),
895
+ /** Also alert about sessions the agent spawned (sub-agents); off = only the user's own conversations. */
896
+ subagentAlerts: z.boolean().default(false).volatile(),
897
+ /** Check npm for a new version on its own (at most one real check a day); off = only when the user asks. */
898
+ updateAutoCheck: z.boolean().default(true).volatile(),
899
+ /** Play only while the page is not visible (background tab or window). */
900
+ onlyWhenHidden: z.boolean().default(false).volatile(),
901
+ /** Uploaded audio for the `custom` preset, as base64; empty when none. */
902
+ customSound: z.string().default("").volatile(),
903
+ /** Original file name of the uploaded custom sound, for display. */
904
+ customSoundName: z.string().default("").volatile(),
905
+ /** Show an OS notification (when the page is hidden) in addition to the sound. */
906
+ systemNotify: z.boolean().default(false).volatile(),
907
+ /** Prefix the window title with a word (待处理 / 已完成) while the page is hidden. */
908
+ titleFlash: z.boolean().default(false).volatile(),
909
+ /** Re-ping unanswered decisions at a fixed interval, until one is handled or seen. */
910
+ renotify: z.boolean().default(false).volatile(),
911
+ /** Re-ping interval in minutes. */
912
+ renotifyMinutes: z.number().step(1).min(1).max(60).default(5).volatile(),
913
+ /** Suppress every sound and notification during a daily time range. */
914
+ quietHours: z.boolean().default(false).volatile(),
915
+ /** Quiet range start, "HH:MM" 24-hour. */
916
+ quietStart: z.string().pattern(TIME_OF_DAY).default("22:00").volatile(),
917
+ /** Quiet range end, "HH:MM" 24-hour. */
918
+ quietEnd: z.string().pattern(TIME_OF_DAY).default("08:00").volatile(),
919
+ /** In goal mode, stay silent on intermediate rounds and chime once when the goal ends. */
920
+ goalFinalOnly: z.boolean().default(false).volatile(),
921
+ });
922
+
923
+ /**
924
+ * Host plugin body. Registers the page policy and the focus route; the
925
+ * sounds and the Settings section exist in the browser entry.
926
+ *
927
+ * @param ctx - the Host plugin context.
928
+ */
929
+ export function apply(ctx) {
930
+ // The Client half registers a dedicated `settings.section` page; this asks
931
+ // the plugin inventory to hold off an auto-generated form for the same
932
+ // namespace so the preference is editable from exactly one place. Note:
933
+ // `autoGenerate` is not yet consumed by any shipped client (runtime README
934
+ // confirms), so this is a forward-compatible declaration rather than an
935
+ // effect that is already in force.
936
+ ctx.inject(["settings"], (child) => {
937
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
938
+ });
939
+
940
+ // The focus route: the browser half posts here when a system notification
941
+ // is clicked. It carries the composition's connection fence — the same
942
+ // Host/Origin checks plus login-token cookie the open-in-app routes rely
943
+ // on — so only the authenticated GUI can trip it, and it answers before
944
+ // raising so the click never waits.
945
+ ctx.inject(["webServer", "connection"], (child) => {
946
+ child.effect(() => child.webServer.register({
947
+ kind: "exact",
948
+ path: FOCUS_PATH,
949
+ handler: async (req, res) => {
950
+ const rejection = child.connection.requestRejection(req);
951
+ if (rejection !== undefined) {
952
+ res.statusCode = rejection;
953
+ res.end();
954
+ return;
955
+ }
956
+ if (req.method !== "POST") {
957
+ res.statusCode = 405;
958
+ res.setHeader("allow", "POST");
959
+ res.end();
960
+ return;
961
+ }
962
+ // Which client asked? The Desktop's renderer fetches from its
963
+ // own `dsh-app://app/` origin, and the shell STRIPS Origin and
964
+ // Sec-Fetch-Site before forwarding to this server (the shell's
965
+ // `forwardWebRequest` in `lib/main.js`), so a Desktop click
966
+ // arrives with neither header while a real browser always sends
967
+ // at least one. Checking BOTH keeps this working if the shell
968
+ // ever stops stripping only one of them: the day it strips
969
+ // neither, a Desktop click would silently start looking like a
970
+ // browser one and clicks would stop raising the window — with no
971
+ // other symptom to explain it.
972
+ //
973
+ // The two need opposite things, so the choice is derived here
974
+ // rather than configured: a browser click needs no help, since
975
+ // activating the notification raises the browser window itself
976
+ // and raising the Desktop window instead would be the wrong
977
+ // window entirely.
978
+ const headers = req.headers ?? {};
979
+ const fromBrowser = headers.origin !== undefined || headers["sec-fetch-site"] !== undefined;
980
+ res.statusCode = 204;
981
+ res.end();
982
+ log(`focus click (fromBrowser=${fromBrowser})`);
983
+ if (!fromBrowser) {
984
+ // Foreground requests can be refused while another app owns
985
+ // the input queue; retry before paying for a deep-link launch.
986
+ let raised = false;
987
+ for (let attempt = 0; attempt < 3 && !raised; attempt += 1) {
988
+ raised = tryRaiseNative();
989
+ if (!raised) await new Promise((resolve) => setTimeout(resolve, 180));
990
+ }
991
+ if (!raised) focusDesktopWindowDeepLink();
992
+ }
993
+ },
994
+ }), "notify-sounds: focus route");
995
+
996
+ // The update check route: a fixed, read-only question about this
997
+ // plugin's own npm package. Same fence as the focus route. It takes no
998
+ // URL, package name or version from the caller — there is nothing here
999
+ // to aim anywhere else.
1000
+ child.effect(() => child.webServer.register({
1001
+ kind: "exact",
1002
+ path: UPDATE_CHECK_PATH,
1003
+ handler: async (req, res) => {
1004
+ const rejection = child.connection.requestRejection(req);
1005
+ if (rejection !== undefined) {
1006
+ res.statusCode = rejection;
1007
+ res.end();
1008
+ return;
1009
+ }
1010
+ if (req.method !== "GET") {
1011
+ res.statusCode = 405;
1012
+ res.setHeader("allow", "GET");
1013
+ res.end();
1014
+ return;
1015
+ }
1016
+ try {
1017
+ const force = new URL(req.url ?? "/", "http://localhost").searchParams.get("force") === "1";
1018
+ const result = await checkForUpdate({ force });
1019
+ res.statusCode = 200;
1020
+ res.setHeader("content-type", "application/json");
1021
+ res.end(JSON.stringify(result));
1022
+ } catch (error) {
1023
+ warn(`update check failed: ${error?.message ?? error}`);
1024
+ res.statusCode = 502;
1025
+ res.end();
1026
+ }
1027
+ },
1028
+ }), "notify-sounds: update check route");
1029
+
1030
+ });
1031
+ // The goal phase route (1.8.1): the browser cannot rely on its own
1032
+ // session projections for a session it never opened, so goal mode asks
1033
+ // the Host, whose projection store is authoritative. Read-only, one
1034
+ // session id, fenced like every other route here.
1035
+ ctx.inject(["webServer", "connection", "sessions", "sessionProjections"], (child) => {
1036
+ /** The goal phase for one session, or undefined when there is none. */
1037
+ const phaseOf = (sessionId) => {
1038
+ try {
1039
+ const session = child.sessions.get(sessionId);
1040
+ if (session === undefined) return undefined;
1041
+ const projection = child.sessionProjections.stateOf(session, "goal");
1042
+ if (projection === undefined || projection.failure != null) return undefined;
1043
+ const phase = projection.current?.goal?.phase ?? projection.current?.phase;
1044
+ return typeof phase === "string" ? phase : undefined;
1045
+ } catch (error) {
1046
+ // The projection API is internal, and it has already changed
1047
+ // shape once (1.8.2). A throw here would otherwise be invisible:
1048
+ // the browser reads "no phase" as "no goal" and goal mode goes
1049
+ // back to chiming on every round, with nothing to explain it.
1050
+ // One deduplicated line in the Host log is the difference
1051
+ // between "a DSH update broke this" and "the plugin is buggy".
1052
+ warn(`goal projection unreadable: ${error?.message ?? error}`);
1053
+ return undefined;
1054
+ }
1055
+ };
1056
+ child.effect(() => child.webServer.register({
1057
+ kind: "exact",
1058
+ path: GOAL_PHASE_PATH,
1059
+ handler: (req, res) => {
1060
+ const rejection = child.connection.requestRejection(req);
1061
+ if (rejection !== undefined) {
1062
+ res.statusCode = rejection;
1063
+ res.end();
1064
+ return;
1065
+ }
1066
+ if (req.method !== "GET") {
1067
+ res.statusCode = 405;
1068
+ res.setHeader("allow", "GET");
1069
+ res.end();
1070
+ return;
1071
+ }
1072
+ const sessionId = new URL(req.url ?? "/", "http://localhost").searchParams.get("session") ?? "";
1073
+ const phase = sessionId === "" ? undefined : phaseOf(sessionId);
1074
+ res.statusCode = 200;
1075
+ res.setHeader("content-type", "application/json");
1076
+ res.end(JSON.stringify({ phase: phase ?? null }));
1077
+ },
1078
+ }), "notify-sounds: goal phase route");
1079
+ });
1080
+ }