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