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