dsh-desktop-windows 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -1
- package/README.md +132 -104
- package/package.json +3 -1
- package/src/connect.html +88 -0
- package/src/main.js +1508 -914
- package/src/preload.js +186 -68
package/src/main.js
CHANGED
|
@@ -1,914 +1,1508 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* dsh-desktop — Electron 壳,包装 DeepSeek Harness Web UI。
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
let
|
|
43
|
-
let
|
|
44
|
-
let
|
|
45
|
-
let
|
|
46
|
-
let
|
|
47
|
-
let
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
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
|
-
const
|
|
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
|
-
function
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
const
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if (
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
)
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
{
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-desktop — Electron 壳,包装 DeepSeek Harness Web UI。
|
|
5
|
+
*
|
|
6
|
+
* 双模式:
|
|
7
|
+
* - 本机模式(默认):探测 127.0.0.1:3080 是否已有 dsh 服务在跑;
|
|
8
|
+
* 没有则启动全局 `dsh web --port 3080`(日志写入 userData/dsh.log);
|
|
9
|
+
* 等服务就绪后打开 BrowserWindow 加载 Web UI;窗口退出时只杀掉由本壳
|
|
10
|
+
* 启动的 dsh 进程树;首次环境缺失时走引导窗口。
|
|
11
|
+
* - 远程模式(可选):DSH 跑在远程服务器(NAS / Docker / 内网主机等),
|
|
12
|
+
* 壳作为纯客户端连接任意 http(s) 地址;隐藏本机管理项(会话管理、
|
|
13
|
+
* 重启服务、DSH 更新),断线自动回退连接页。
|
|
14
|
+
*
|
|
15
|
+
* 连接逻辑:
|
|
16
|
+
* - 目标地址优先级:命令行 --url > 配置文件(userData/config.json 记住上次)
|
|
17
|
+
* - 首次使用(无保存地址)显示连接页:可选择「本机连接」或输入远程地址;
|
|
18
|
+
* 选「本机连接」完全走上方本机模式流程,现有本地用户零感知
|
|
19
|
+
* - 远程地址智能补全:局域网 IP / loopback 补 http://,域名补 https://
|
|
20
|
+
* - 服务器不可达时回到连接页离线模式,可勾选每 8 秒自动重试;
|
|
21
|
+
* 运行中页面加载失败(断网/关机)也自动回到连接页
|
|
22
|
+
*
|
|
23
|
+
* DSH 主动更新(本机模式,仅对由本壳启动的 dsh 生效):
|
|
24
|
+
* - 检查 npm 最新版 → 弹窗确认 → npm install -g → 重启托管的 dsh 进程
|
|
25
|
+
* → 轮询端口就绪 → 自动重连;也可从托盘 / 文件菜单手动触发。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { app, BrowserWindow, dialog, shell, Tray, Menu, nativeImage, ipcMain, desktopCapturer, clipboard, screen } = require('electron')
|
|
29
|
+
const { spawn, execFile } = require('node:child_process')
|
|
30
|
+
const http = require('node:http')
|
|
31
|
+
const https = require('node:https')
|
|
32
|
+
const fs = require('node:fs')
|
|
33
|
+
const path = require('node:path')
|
|
34
|
+
const os = require('node:os')
|
|
35
|
+
|
|
36
|
+
const DEFAULT_PORT = 3080
|
|
37
|
+
const START_TIMEOUT_MS = 90_000
|
|
38
|
+
const POLL_INTERVAL_MS = 500
|
|
39
|
+
const LOG_PREFIX = '[dsh-desktop]'
|
|
40
|
+
const PROBE_URL_TIMEOUT_MS = 5_000
|
|
41
|
+
|
|
42
|
+
let mainWindow = null
|
|
43
|
+
let ownedDsh = null // 本壳启动的 dsh 子进程;null 表示复用了外部已有服务
|
|
44
|
+
let tray = null // 系统托盘
|
|
45
|
+
let isQuitting = false // 用户从托盘选择退出时置 true,放行窗口关闭
|
|
46
|
+
let setupWindow = null // 首次环境引导窗口
|
|
47
|
+
let sessionsWin = null // 会话管理窗口
|
|
48
|
+
let shotWindow = null // 选区截图窗口
|
|
49
|
+
let pendingShot = null // 全屏截图(nativeImage),选区确认后裁剪
|
|
50
|
+
|
|
51
|
+
/** 当前 DSH 目标(规范化后的完整 URL);空串表示尚未连接。 */
|
|
52
|
+
let target = ''
|
|
53
|
+
/** 目标是否为本机(loopback),决定是否展示本机管理功能。 */
|
|
54
|
+
let targetIsLocal = false
|
|
55
|
+
/** 目标的 HTTP Basic Auth 凭据(从 URL 内嵌提取,如 https://user:pass@host/);无则 null。 */
|
|
56
|
+
let basicAuth = null
|
|
57
|
+
/** 连接页当前状态,由 preload 的 file:// 分支经 IPC 读取。 */
|
|
58
|
+
let connectState = { mode: 'first', url: '', error: '' }
|
|
59
|
+
|
|
60
|
+
/* ------------------------------------------------------------------ *
|
|
61
|
+
* 工具:日志
|
|
62
|
+
* ------------------------------------------------------------------ */
|
|
63
|
+
|
|
64
|
+
function log(...args) {
|
|
65
|
+
console.log(LOG_PREFIX, ...args)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 截图流程日志(追加写入 ~/.dsh/logs/screenshot.log,便于诊断)。 */
|
|
69
|
+
function shotLog(...args) {
|
|
70
|
+
try {
|
|
71
|
+
const line = `[${new Date().toISOString()}] ${args.join(' ')}`
|
|
72
|
+
console.log('[shot]', ...args)
|
|
73
|
+
fs.appendFileSync(path.join(os.homedir(), '.dsh', 'logs', 'screenshot.log'), line + '\n')
|
|
74
|
+
} catch { /* 忽略日志错误 */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ------------------------------------------------------------------ *
|
|
78
|
+
* 目标地址:解析 / 规范化 / 持久化
|
|
79
|
+
* ------------------------------------------------------------------ */
|
|
80
|
+
|
|
81
|
+
function hostOf(urlStr) {
|
|
82
|
+
try { return new URL(urlStr).host } catch { return urlStr }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isLocalHost(hostname) {
|
|
86
|
+
const h = String(hostname || '').toLowerCase()
|
|
87
|
+
return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '[::1]'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 把用户输入规范成完整 URL:
|
|
92
|
+
* 已带协议 → 原样采用
|
|
93
|
+
* loopback/内网地址 → 补 http://(局域网自建服务几乎都是明文)
|
|
94
|
+
* 其他(域名等) → 补 https://(公网默认走加密)
|
|
95
|
+
* 非法输入返回 null。
|
|
96
|
+
* 支持 URL 内嵌 HTTP Basic Auth(https://user:pass@host/):凭据提取进
|
|
97
|
+
* basicAuth(不进 target,避免泄漏到日志/标题),加载时经 app.on('login')
|
|
98
|
+
* 自动回填;无内嵌凭据时不改动现有 basicAuth(启动恢复场景需要保留)。
|
|
99
|
+
*/
|
|
100
|
+
function normalizeTarget(raw) {
|
|
101
|
+
if (typeof raw !== 'string') return null
|
|
102
|
+
let s = raw.trim()
|
|
103
|
+
if (!s) return null
|
|
104
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
|
|
105
|
+
const head = s.split(/[/?#]/)[0]
|
|
106
|
+
const insecure = /^(localhost|127\.|::1|\[::1\]|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(head)
|
|
107
|
+
s = (insecure ? 'http://' : 'https://') + s
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const u = new URL(s)
|
|
111
|
+
if ((u.protocol !== 'http:' && u.protocol !== 'https:') || !u.hostname) return null
|
|
112
|
+
if (u.username || u.password) {
|
|
113
|
+
basicAuth = {
|
|
114
|
+
username: decodeURIComponent(u.username || ''),
|
|
115
|
+
password: decodeURIComponent(u.password || ''),
|
|
116
|
+
}
|
|
117
|
+
u.username = ''
|
|
118
|
+
u.password = ''
|
|
119
|
+
return u.toString().replace(/\/$/, '')
|
|
120
|
+
}
|
|
121
|
+
return u.toString().replace(/\/$/, '')
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function configPath() {
|
|
128
|
+
return path.join(app.getPath('userData'), 'config.json')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function loadConfig() {
|
|
132
|
+
try { return JSON.parse(fs.readFileSync(configPath(), 'utf8')) } catch { return {} }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function saveConfig(patch) {
|
|
136
|
+
try {
|
|
137
|
+
const next = Object.assign(loadConfig(), patch)
|
|
138
|
+
fs.mkdirSync(path.dirname(configPath()), { recursive: true })
|
|
139
|
+
fs.writeFileSync(configPath(), JSON.stringify(next, null, 2))
|
|
140
|
+
} catch (e) { log('save config failed:', e && e.message) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 命令行目标:--url <u>(临时生效,不覆盖已保存配置)。--port <n> 仍由 resolvePort() 处理。 */
|
|
144
|
+
function resolveCliUrl() {
|
|
145
|
+
const iu = process.argv.indexOf('--url')
|
|
146
|
+
if (iu !== -1 && process.argv[iu + 1]) return process.argv[iu + 1]
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 应用新目标并按需持久化(凭据与目标成对保存,存在 userData/config.json 本机私有目录)。 */
|
|
151
|
+
function applyTarget(urlStr, { persist = true } = {}) {
|
|
152
|
+
target = urlStr
|
|
153
|
+
targetIsLocal = isLocalHost(new URL(urlStr).hostname)
|
|
154
|
+
if (persist) {
|
|
155
|
+
saveConfig({
|
|
156
|
+
url: urlStr,
|
|
157
|
+
// 显式写 null 以覆盖旧凭据(切换到无凭据地址时清掉)
|
|
158
|
+
...(basicAuth ? { basicAuth } : { basicAuth: null }),
|
|
159
|
+
savedAt: new Date().toISOString(),
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
log(`target: ${target} (local=${targetIsLocal}${basicAuth ? ', basicAuth=yes' : ''})`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ------------------------------------------------------------------ *
|
|
166
|
+
* 自动更新检查(启动时异步检测 GitHub Releases 最新版)
|
|
167
|
+
* ------------------------------------------------------------------ */
|
|
168
|
+
|
|
169
|
+
const REPO = 'ReachGa0/dsh-desktop'
|
|
170
|
+
const RELEASES_URL = `https://api.github.com/repos/${REPO}/releases/latest`
|
|
171
|
+
const RELEASES_PAGE = `https://github.com/${REPO}/releases/latest`
|
|
172
|
+
const UPDATE_CHECK_TIMEOUT_MS = 10_000
|
|
173
|
+
|
|
174
|
+
/** GET 一个 JSON(带超时与 UA,GitHub API 要求 UA)。resolve null 表示失败。 */
|
|
175
|
+
function fetchJson(url) {
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
// 首次用系统证书;代理/反代环境证书校验可能失败,降级为不校验重试一次
|
|
178
|
+
// (仅更新检查用,不降低应用其他部分的安全级别)
|
|
179
|
+
const attempt = (rejectUnauthorized) => {
|
|
180
|
+
const req = https.get(
|
|
181
|
+
url,
|
|
182
|
+
{ headers: { 'User-Agent': 'dsh-desktop-update-check', Accept: 'application/vnd.github+json' }, timeout: UPDATE_CHECK_TIMEOUT_MS, rejectUnauthorized },
|
|
183
|
+
(res) => {
|
|
184
|
+
if (res.statusCode !== 200) {
|
|
185
|
+
res.resume()
|
|
186
|
+
resolve(null)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
let body = ''
|
|
190
|
+
res.setEncoding('utf8')
|
|
191
|
+
res.on('data', (d) => (body += d))
|
|
192
|
+
res.on('end', () => {
|
|
193
|
+
try { resolve(JSON.parse(body)) } catch { resolve(null) }
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
req.on('timeout', () => req.destroy())
|
|
198
|
+
req.on('error', () => {
|
|
199
|
+
// 证书类错误:再试一次宽松模式;其他错误直接放弃
|
|
200
|
+
if (rejectUnauthorized) attempt(false)
|
|
201
|
+
else resolve(null)
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
attempt(true)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 解析语义化版本号(含可选预发布段 -rc.N / -beta.1 / -alpha 等,如 0.1.1-rc.2)。 */
|
|
209
|
+
function parseVersion(v) {
|
|
210
|
+
if (typeof v !== 'string') return null
|
|
211
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z]+)\.?(\d+)?)?(?:[-+.].*)?$/.exec(v.trim())
|
|
212
|
+
if (!m) return null
|
|
213
|
+
const preRank = m[4] ? { alpha: 0, beta: 1, rc: 2 }[m[4].toLowerCase()] ?? -1 : Infinity
|
|
214
|
+
return [Number(m[1]), Number(m[2]), Number(m[3]), preRank, m[5] ? Number(m[5]) : 0]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** a 比 b 新?都解析失败时返回 false(宁可漏报不可误报)。 */
|
|
218
|
+
function isNewer(a, b) {
|
|
219
|
+
const pa = parseVersion(a)
|
|
220
|
+
const pb = parseVersion(b)
|
|
221
|
+
if (!pa || !pb) return false
|
|
222
|
+
for (let i = 0; i < pa.length; i++) {
|
|
223
|
+
if (pa[i] !== pb[i]) return pa[i] > pb[i]
|
|
224
|
+
}
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 检查更新:查询 GitHub Releases 最新版,若比当前版本新则弹窗提示。
|
|
229
|
+
* 全程静默失败(网络不通 / 解析失败 / 无新版都不打扰用户)。每次会话最多检查一次。 */
|
|
230
|
+
let didCheckAppUpdate = false
|
|
231
|
+
function checkForUpdatesOnce() {
|
|
232
|
+
if (didCheckAppUpdate) return
|
|
233
|
+
didCheckAppUpdate = true
|
|
234
|
+
checkForUpdates()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function checkForUpdates() {
|
|
238
|
+
const current = app.getVersion()
|
|
239
|
+
try {
|
|
240
|
+
const release = await fetchJson(RELEASES_URL)
|
|
241
|
+
if (!release) {
|
|
242
|
+
log('update check: no release data (offline or API error)')
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const latest = release.tag_name
|
|
246
|
+
log(`update check: current=${current} latest=${latest}`)
|
|
247
|
+
if (!isNewer(latest, current)) return
|
|
248
|
+
const name = release.name && release.name !== latest ? `「${release.name}」` : ''
|
|
249
|
+
const detail = (release.body || '').split('\n').slice(0, 6).join('\n').trim()
|
|
250
|
+
const { response } = await dialog.showMessageBox(mainWindow || undefined, {
|
|
251
|
+
type: 'info',
|
|
252
|
+
title: '发现新版本',
|
|
253
|
+
message: `DeepSeek Harness Desktop 有新版本可用(${latest} ${name})`,
|
|
254
|
+
detail: detail ? `更新内容:\n${detail}\n\n当前版本:${current}` : `当前版本:${current}`,
|
|
255
|
+
buttons: ['前往下载', '以后再说'],
|
|
256
|
+
defaultId: 0,
|
|
257
|
+
cancelId: 1,
|
|
258
|
+
noLink: true,
|
|
259
|
+
})
|
|
260
|
+
if (response === 0) {
|
|
261
|
+
shell.openExternal(RELEASES_PAGE)
|
|
262
|
+
}
|
|
263
|
+
} catch (e) {
|
|
264
|
+
log('update check failed:', e && e.message)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ------------------------------------------------------------------ *
|
|
269
|
+
* 工具:探测 / 等待 HTTP 服务
|
|
270
|
+
* ------------------------------------------------------------------ */
|
|
271
|
+
|
|
272
|
+
/** 探测本机端口是否有 dsh 服务(轮询等待用)。 */
|
|
273
|
+
function probe(port, timeoutMs = 1500) {
|
|
274
|
+
return new Promise((resolve) => {
|
|
275
|
+
const req = http.get(
|
|
276
|
+
{ host: '127.0.0.1', port, path: '/', timeout: timeoutMs },
|
|
277
|
+
(res) => {
|
|
278
|
+
res.resume()
|
|
279
|
+
resolve(true)
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
req.on('timeout', () => req.destroy())
|
|
283
|
+
req.on('error', () => resolve(false))
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 轮询直到服务就绪或超时。 */
|
|
288
|
+
async function waitForServer(port, timeoutMs) {
|
|
289
|
+
const deadline = Date.now() + timeoutMs
|
|
290
|
+
while (Date.now() < deadline) {
|
|
291
|
+
if (await probe(port)) return true
|
|
292
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
|
|
293
|
+
}
|
|
294
|
+
return false
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 探测一个 http(s) 地址是否可达(远程目标用;任何 HTTP 响应都算活着,
|
|
299
|
+
* 包括网关 401 等)。自签名证书的内网服务放宽 TLS 校验重试一次。
|
|
300
|
+
*/
|
|
301
|
+
function probeUrl(urlStr, timeoutMs = PROBE_URL_TIMEOUT_MS) {
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
let settled = false
|
|
304
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v) } }
|
|
305
|
+
const attempt = (allowBadTls) => {
|
|
306
|
+
let u
|
|
307
|
+
try { u = new URL(urlStr) } catch { return done(false) }
|
|
308
|
+
const mod = u.protocol === 'https:' ? https : http
|
|
309
|
+
const req = mod.get(u, { timeout: timeoutMs, rejectUnauthorized: !allowBadTls }, (res) => {
|
|
310
|
+
res.resume()
|
|
311
|
+
done(true)
|
|
312
|
+
})
|
|
313
|
+
req.on('timeout', () => req.destroy())
|
|
314
|
+
req.on('error', () => {
|
|
315
|
+
if (!allowBadTls && u.protocol === 'https:') attempt(true)
|
|
316
|
+
else done(false)
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
attempt(false)
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ------------------------------------------------------------------ *
|
|
324
|
+
* dsh 服务进程管理(本机模式)
|
|
325
|
+
* ------------------------------------------------------------------ */
|
|
326
|
+
|
|
327
|
+
/** 解析 dsh 可执行文件:优先 DSH_BIN,否则走 PATH(Windows 下是 dsh.cmd)。 */
|
|
328
|
+
function resolveDshBin() {
|
|
329
|
+
if (process.env.DSH_BIN) return process.env.DSH_BIN
|
|
330
|
+
// 全局 npm 安装的常见位置
|
|
331
|
+
if (process.env.APPDATA) {
|
|
332
|
+
const global = path.join(process.env.APPDATA, 'npm', 'dsh.cmd')
|
|
333
|
+
if (fs.existsSync(global)) return global
|
|
334
|
+
}
|
|
335
|
+
return 'dsh.cmd'
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 解析端口:优先命令行 --port <n>,否则用默认值。 */
|
|
339
|
+
function resolvePort() {
|
|
340
|
+
const idx = process.argv.indexOf('--port')
|
|
341
|
+
if (idx !== -1 && process.argv[idx + 1]) {
|
|
342
|
+
const n = Number(process.argv[idx + 1])
|
|
343
|
+
if (Number.isInteger(n) && n > 0 && n < 65536) return n
|
|
344
|
+
console.warn(LOG_PREFIX, `invalid --port value "${process.argv[idx + 1]}", fallback to ${DEFAULT_PORT}`)
|
|
345
|
+
}
|
|
346
|
+
return DEFAULT_PORT
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 主动关停 dsh 时的标记(杀进程也会触发 exit,需区分「主动关」和「意外崩」)
|
|
350
|
+
let isShuttingDownDsh = false
|
|
351
|
+
// 已自动重启过一次的标志(同一次会话最多自愈一次,避免崩溃循环)
|
|
352
|
+
let didAutoRestartDsh = false
|
|
353
|
+
|
|
354
|
+
/** 启动 dsh web,日志追加到 userData/dsh.log。返回子进程。 */
|
|
355
|
+
function startDsh(port) {
|
|
356
|
+
const bin = resolveDshBin()
|
|
357
|
+
const logFile = path.join(app.getPath('userData'), 'dsh.log')
|
|
358
|
+
const out = fs.openSync(logFile, 'a')
|
|
359
|
+
// shell:true 时把参数拼进命令字符串(参数均为常量),避免 DEP0190 警告
|
|
360
|
+
// --no-open 关闭 dsh 默认的自动打开浏览器行为(桌面壳自己用 BrowserWindow 加载,不弹浏览器)
|
|
361
|
+
const cmd = `"${bin}" web --port ${port} --no-open`
|
|
362
|
+
const child = spawn(cmd, {
|
|
363
|
+
shell: true,
|
|
364
|
+
windowsHide: true,
|
|
365
|
+
stdio: ['ignore', out, out],
|
|
366
|
+
env: { ...process.env },
|
|
367
|
+
})
|
|
368
|
+
log(`spawned dsh: ${cmd} (pid ${child.pid})`)
|
|
369
|
+
log(`dsh log: ${logFile}`)
|
|
370
|
+
child.on('exit', (code, signal) => {
|
|
371
|
+
log(`dsh exited: code=${code} signal=${signal}`)
|
|
372
|
+
if (ownedDsh !== child) return
|
|
373
|
+
ownedDsh = null
|
|
374
|
+
// 崩溃自愈:本壳启动的 dsh 意外退出(非主动关闭/非退出中)时自动重启一次
|
|
375
|
+
if (!isQuitting && !isShuttingDownDsh) {
|
|
376
|
+
restartOwnedDsh(port, code)
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
child.on('error', (err) => {
|
|
380
|
+
log(`dsh spawn error: ${err.message}`)
|
|
381
|
+
})
|
|
382
|
+
return child
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** dsh 意外退出后自动重启一次;二次退出则提示用户并放弃。 */
|
|
386
|
+
function restartOwnedDsh(port, exitCode) {
|
|
387
|
+
if (didAutoRestartDsh) {
|
|
388
|
+
log('dsh crashed again after auto-restart; giving up')
|
|
389
|
+
const logFile = path.join(app.getPath('userData'), 'dsh.log')
|
|
390
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
391
|
+
type: 'error',
|
|
392
|
+
title: 'dsh 服务异常',
|
|
393
|
+
message: 'dsh 服务启动后再次退出,已停止自动重启。',
|
|
394
|
+
detail: `退出码:${exitCode ?? '未知'}\n\n日志文件:${logFile}\n\n请查看日志定位问题,或手动在终端运行 dsh web 排查。`,
|
|
395
|
+
buttons: ['打开日志目录', '知道了'],
|
|
396
|
+
noLink: true,
|
|
397
|
+
}).then(({ response }) => {
|
|
398
|
+
if (response === 0) shell.showItemInFolder(logFile)
|
|
399
|
+
})
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
log(`dsh exited unexpectedly (code=${exitCode}); restarting in 3s…`)
|
|
403
|
+
didAutoRestartDsh = true
|
|
404
|
+
setTimeout(() => {
|
|
405
|
+
if (isQuitting) return
|
|
406
|
+
// 重启前再确认端口没有被外部服务接管(避免重复启动)
|
|
407
|
+
probe(port).then((up) => {
|
|
408
|
+
if (up) {
|
|
409
|
+
log('port already serving after crash; not restarting')
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
const child = startDsh(port)
|
|
413
|
+
ownedDsh = child
|
|
414
|
+
// 等它就绪;若起不来则等 exit 回调里的二次提示
|
|
415
|
+
waitForServer(port, START_TIMEOUT_MS).then((ready) => {
|
|
416
|
+
if (ready) {
|
|
417
|
+
log('dsh auto-restarted successfully')
|
|
418
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
419
|
+
type: 'info',
|
|
420
|
+
title: 'dsh 已自动重启',
|
|
421
|
+
message: 'dsh 服务意外退出,已自动恢复。',
|
|
422
|
+
buttons: ['好'],
|
|
423
|
+
noLink: true,
|
|
424
|
+
}).catch(() => {})
|
|
425
|
+
}
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
}, 3000)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 杀掉进程树(Windows 用 taskkill /T 覆盖子孙进程),带超时兜底。 */
|
|
432
|
+
function killProcessTree(pid) {
|
|
433
|
+
return new Promise((resolve) => {
|
|
434
|
+
execFile(
|
|
435
|
+
'taskkill',
|
|
436
|
+
['/pid', String(pid), '/T', '/F'],
|
|
437
|
+
{ windowsHide: true },
|
|
438
|
+
() => resolve() // 无论成败都继续退出流程
|
|
439
|
+
)
|
|
440
|
+
setTimeout(resolve, 2000)
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function shutdownOwnedDsh() {
|
|
445
|
+
if (ownedDsh && ownedDsh.pid) {
|
|
446
|
+
const pid = ownedDsh.pid
|
|
447
|
+
ownedDsh = null
|
|
448
|
+
isShuttingDownDsh = true
|
|
449
|
+
await killProcessTree(pid)
|
|
450
|
+
log(`killed owned dsh pid ${pid}`)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 重启 dsh 服务:杀掉当前由本壳启动的 dsh → 重新拉起 → 等就绪 → 刷新页面。
|
|
456
|
+
* 供「重启 dsh 服务」菜单/托盘项使用(后端插件/服务改动后需要)。
|
|
457
|
+
*/
|
|
458
|
+
async function restartDshService() {
|
|
459
|
+
if (!ownedDsh) {
|
|
460
|
+
log('restart: no owned dsh to restart')
|
|
461
|
+
// 没有自管的 dsh(可能复用了外部服务)→ 无法安全重启,提示用户
|
|
462
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
463
|
+
type: 'info',
|
|
464
|
+
title: '无法重启',
|
|
465
|
+
message: '当前使用的是外部已有的 dsh 服务,不是本应用启动的,无法从这里重启。',
|
|
466
|
+
detail: '请在外部终端手动重启该服务,然后刷新页面(F5)。',
|
|
467
|
+
buttons: ['好'],
|
|
468
|
+
noLink: true,
|
|
469
|
+
})
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
const port = resolvePort()
|
|
473
|
+
log(`restart: restarting dsh service on port ${port}…`)
|
|
474
|
+
// 先停(标记主动关停,避免触发崩溃自愈逻辑)
|
|
475
|
+
await shutdownOwnedDsh()
|
|
476
|
+
isShuttingDownDsh = false // 重置,让后续退出能触发自愈
|
|
477
|
+
// 重新启动
|
|
478
|
+
ownedDsh = startDsh(port)
|
|
479
|
+
const ready = await waitForServer(port, START_TIMEOUT_MS)
|
|
480
|
+
if (!ready) {
|
|
481
|
+
log('restart: dsh failed to come back')
|
|
482
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
483
|
+
type: 'error',
|
|
484
|
+
title: '重启失败',
|
|
485
|
+
message: 'dsh 服务重启后未在预期时间内就绪。',
|
|
486
|
+
detail: `日志文件:${path.join(app.getPath('userData'), 'dsh.log')}`,
|
|
487
|
+
buttons: ['好'],
|
|
488
|
+
noLink: true,
|
|
489
|
+
})
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
log('restart: dsh back up; reloading window')
|
|
493
|
+
// 等页面也能加载后刷新
|
|
494
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
495
|
+
mainWindow.loadURL(`http://127.0.0.1:${port}`)
|
|
496
|
+
}
|
|
497
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
498
|
+
type: 'info',
|
|
499
|
+
title: 'dsh 服务已重启',
|
|
500
|
+
message: 'dsh 服务已重启完成,页面已刷新。',
|
|
501
|
+
buttons: ['好'],
|
|
502
|
+
noLink: true,
|
|
503
|
+
}).catch(() => {})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* ------------------------------------------------------------------ *
|
|
507
|
+
* DSH 主动更新(仅本机模式,且仅对由本壳启动的 dsh 生效)
|
|
508
|
+
*
|
|
509
|
+
* 流程:检查 npm 最新版 → 弹窗确认 → npm 更新全局包 → 重启本壳托管的
|
|
510
|
+
* dsh 进程 → 轮询端口就绪 → 自动重连。远程模式与外部服务整体禁用。
|
|
511
|
+
* ------------------------------------------------------------------ */
|
|
512
|
+
|
|
513
|
+
const DSH_NPM_PACKAGE = '@deepseek-ai/dsh'
|
|
514
|
+
const DSH_CHECK_TIMEOUT_MS = 30_000
|
|
515
|
+
const DSH_INSTALL_TIMEOUT_MS = 300_000
|
|
516
|
+
const DSH_RECONNECT_TIMEOUT_MS = 90_000
|
|
517
|
+
const DSH_RECONNECT_INTERVAL_MS = 1_500
|
|
518
|
+
|
|
519
|
+
let updatingDsh = false
|
|
520
|
+
|
|
521
|
+
/** 读取本机已安装的 DSH 版本(如 "0.1.1-rc.2");读不到返回 null。 */
|
|
522
|
+
async function getInstalledDshVersion() {
|
|
523
|
+
const r = await runCmd(resolveDshBin(), ['--version'], DSH_CHECK_TIMEOUT_MS)
|
|
524
|
+
if (!r || r.code !== 0) return null
|
|
525
|
+
const m = /\d+\.\d+\.\d+(?:-[A-Za-z]+\.\d+)?/.exec(r.out)
|
|
526
|
+
return m ? m[0] : null
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** 查询 npm 上 DSH 最新版本号;失败返回 null。 */
|
|
530
|
+
async function getNpmLatestVersion() {
|
|
531
|
+
const r = await runCmd('npm.cmd', ['view', DSH_NPM_PACKAGE, 'version'], DSH_CHECK_TIMEOUT_MS)
|
|
532
|
+
if (!r || r.code !== 0) return null
|
|
533
|
+
const m = /\d+\.\d+\.\d+(?:-[A-Za-z]+\.\d+)?/.exec(r.out)
|
|
534
|
+
return m ? m[0] : null
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 更新期间把主窗口切到轻量进度页,避免暴露连接错误页。 */
|
|
538
|
+
function showUpdateSplash(stepText) {
|
|
539
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
540
|
+
const page = `<!doctype html><html><meta charset="utf-8"><body style="margin:0;display:flex;align-items:center;justify-content:center;height:100vh;background:#0d1117;color:#e6edf3;font-family:system-ui,'Microsoft YaHei';user-select:none">
|
|
541
|
+
<div style="text-align:center"><div style="font-size:44px;margin-bottom:18px">⬆️</div>
|
|
542
|
+
<div style="font-size:20px;margin-bottom:10px">正在更新 DSH</div>
|
|
543
|
+
<div id="s" style="font-size:13px;color:#8b949e">${stepText}</div>
|
|
544
|
+
<div style="margin-top:22px;font-size:12px;color:#484f58">完成后将自动重连,请勿关闭应用</div></div></body></html>`
|
|
545
|
+
mainWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(page)).catch(() => {})
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** 任务栏进度指示:0-1 确定进度,2 = 不确定态,-1 = 清除。 */
|
|
549
|
+
function setUpdateProgress(value) {
|
|
550
|
+
try { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setProgressBar(value) } catch { /* ignore */ }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* 执行更新:npm 安装新版 → 校验版本 → 重启本壳托管的 dsh 进程 → 等端口
|
|
555
|
+
* 恢复 → 重载页面。抛错时由调用方负责展示(错误里附带手动修复命令)。
|
|
556
|
+
*/
|
|
557
|
+
async function performDshUpdate(targetVersion) {
|
|
558
|
+
const steps = [
|
|
559
|
+
`npm 安装 ${DSH_NPM_PACKAGE}@latest(最长 5 分钟)`,
|
|
560
|
+
'校验新版本',
|
|
561
|
+
'重启 dsh 服务',
|
|
562
|
+
'等待服务就绪并重连',
|
|
563
|
+
]
|
|
564
|
+
log(`dsh update: begin, target=${targetVersion || 'latest'}`)
|
|
565
|
+
showUpdateSplash(steps[0])
|
|
566
|
+
setUpdateProgress(2)
|
|
567
|
+
|
|
568
|
+
// 1) npm 全局更新(--no-fund/--no-audit 减少网络依赖与耗时)
|
|
569
|
+
const install = await runCmd('npm.cmd', ['install', '-g', `${DSH_NPM_PACKAGE}@latest`, '--no-fund', '--no-audit'], DSH_INSTALL_TIMEOUT_MS)
|
|
570
|
+
if (!install) {
|
|
571
|
+
throw new Error(`npm 安装超时(${DSH_INSTALL_TIMEOUT_MS / 1000}s)或无法调用 npm。\n可手动执行:npm install -g ${DSH_NPM_PACKAGE}@latest`)
|
|
572
|
+
}
|
|
573
|
+
if (install.code !== 0) {
|
|
574
|
+
throw new Error(`npm 安装失败(exit ${install.code}):\n${install.out.split('\n').slice(-6).join('\n')}`)
|
|
575
|
+
}
|
|
576
|
+
log('dsh update: npm install done')
|
|
577
|
+
|
|
578
|
+
// 2) 校验版本确实换新(npm 偶发装了旧版缓存时能及时发现)
|
|
579
|
+
showUpdateSplash(steps[1])
|
|
580
|
+
const installed = await getInstalledDshVersion()
|
|
581
|
+
log(`dsh update: installed=${installed || '?'}`)
|
|
582
|
+
if (installed && targetVersion && installed !== targetVersion) {
|
|
583
|
+
log('dsh update: version mismatch after install, continuing anyway')
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// 3) 重启本壳托管的 dsh 进程(杀掉旧进程 → 重新拉起)
|
|
587
|
+
showUpdateSplash(steps[2])
|
|
588
|
+
const port = resolvePort()
|
|
589
|
+
await shutdownOwnedDsh()
|
|
590
|
+
isShuttingDownDsh = false // 重置,让后续崩溃自愈逻辑仍生效
|
|
591
|
+
ownedDsh = startDsh(port)
|
|
592
|
+
|
|
593
|
+
// 4) 轮询直到服务恢复,然后重载页面
|
|
594
|
+
showUpdateSplash(steps[3])
|
|
595
|
+
const deadline = Date.now() + DSH_RECONNECT_TIMEOUT_MS
|
|
596
|
+
let back = false
|
|
597
|
+
while (Date.now() < deadline) {
|
|
598
|
+
if (await probe(port, 1200)) { back = true; break }
|
|
599
|
+
await new Promise((r) => setTimeout(r, DSH_RECONNECT_INTERVAL_MS))
|
|
600
|
+
}
|
|
601
|
+
setUpdateProgress(-1)
|
|
602
|
+
if (back) {
|
|
603
|
+
loadApp()
|
|
604
|
+
} else {
|
|
605
|
+
log('dsh update: service did not come back within timeout')
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const finalVersion = installed || targetVersion || '最新版'
|
|
609
|
+
if (back) {
|
|
610
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
611
|
+
type: 'info', title: '更新完成',
|
|
612
|
+
message: `DSH 已更新到 ${finalVersion},服务已重启并重新连接。`,
|
|
613
|
+
buttons: ['好'], noLink: true,
|
|
614
|
+
})
|
|
615
|
+
} else {
|
|
616
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
617
|
+
type: 'warning', title: '更新完成,等待服务就绪',
|
|
618
|
+
message: `DSH 已更新到 ${finalVersion},但服务在 ${Math.round(DSH_RECONNECT_TIMEOUT_MS / 1000)}s 内未恢复。`,
|
|
619
|
+
detail: '可能仍在启动中。稍后按 F5 刷新即可;也可查看日志:' + path.join(app.getPath('userData'), 'dsh.log'),
|
|
620
|
+
buttons: ['好'], noLink: true,
|
|
621
|
+
})
|
|
622
|
+
}
|
|
623
|
+
log(`dsh update: done, version=${finalVersion} reconnected=${back}`)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* 检查并(经确认后)更新 DSH。仅本机模式且 dsh 由本壳托管时可用。
|
|
628
|
+
* @param silent 无新版/无法检测时不弹窗(用于启动时自动检查;发现新版仍会询问)
|
|
629
|
+
*/
|
|
630
|
+
async function checkDshUpdate(silent = false) {
|
|
631
|
+
if (!targetIsLocal) {
|
|
632
|
+
if (!silent) {
|
|
633
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
634
|
+
type: 'info', title: '远程模式',
|
|
635
|
+
message: '当前连接的是远程服务器上的 DSH。',
|
|
636
|
+
detail: '「检查 DSH 更新」仅在本机模式下可用。\n远程部署请在服务器上更新(如 docker compose 拉取新镜像后重建容器)。',
|
|
637
|
+
buttons: ['好'], noLink: true,
|
|
638
|
+
})
|
|
639
|
+
}
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
if (!ownedDsh) {
|
|
643
|
+
// 外部已有的本机服务(用户手动启动 / WSL 等):本壳无法安全重启,交还用户
|
|
644
|
+
if (!silent) {
|
|
645
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
646
|
+
type: 'info', title: '无法自动更新',
|
|
647
|
+
message: '当前使用的是外部已有的 dsh 服务,不是本应用启动的。',
|
|
648
|
+
detail: '请在对应环境手动更新(npm install -g @deepseek-ai/dsh@latest)并重启该服务,然后刷新页面(F5)。',
|
|
649
|
+
buttons: ['好'], noLink: true,
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
if (updatingDsh) {
|
|
655
|
+
if (!silent) dialog.showMessageBox(mainWindow || undefined, { type: 'info', message: 'DSH 正在更新中,请稍候。', buttons: ['好'], noLink: true })
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
const [current, latest] = await Promise.all([getInstalledDshVersion(), getNpmLatestVersion()])
|
|
659
|
+
log(`dsh update check: current=${current || '?'} latest=${latest || '?'}`)
|
|
660
|
+
|
|
661
|
+
if (!current || !latest) {
|
|
662
|
+
if (!silent) {
|
|
663
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
664
|
+
type: 'warning', title: '无法检查 DSH 更新',
|
|
665
|
+
message: !current ? '未能读取本机安装的 DSH 版本。' : '未能获取 npm 上的最新版本(检查网络)。',
|
|
666
|
+
detail: !current ? `请确认 dsh 可用(${resolveDshBin()} --version)。` : `手动查询:https://www.npmjs.com/package/${DSH_NPM_PACKAGE}`,
|
|
667
|
+
buttons: ['好'], noLink: true,
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
if (!isNewer(latest, current)) {
|
|
673
|
+
if (!silent) dialog.showMessageBox(mainWindow || undefined, { type: 'info', title: '已是最新', message: `本机的 DSH(${current})已是最新版本。`, buttons: ['好'], noLink: true })
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const { response } = await dialog.showMessageBox(mainWindow || undefined, {
|
|
678
|
+
type: 'question', title: '发现 DSH 新版本',
|
|
679
|
+
message: `本机的 DSH 可以更新:${current} → ${latest}`,
|
|
680
|
+
detail: `更新过程约 1-3 分钟:下载安装新包 → 自动重启 dsh 服务 → 自动重连窗口。\n期间 Web 页面会短暂不可用,请勿关闭应用。\n\n手动执行等价于:\n npm install -g ${DSH_NPM_PACKAGE}@latest`,
|
|
681
|
+
buttons: ['立即更新', '暂不'], defaultId: 0, cancelId: 1, noLink: true,
|
|
682
|
+
})
|
|
683
|
+
if (response === 0) {
|
|
684
|
+
updatingDsh = true
|
|
685
|
+
try {
|
|
686
|
+
await performDshUpdate(latest)
|
|
687
|
+
} catch (e) {
|
|
688
|
+
setUpdateProgress(-1)
|
|
689
|
+
log('dsh update failed:', e && e.message)
|
|
690
|
+
dialog.showMessageBox(mainWindow || undefined, {
|
|
691
|
+
type: 'error', title: 'DSH 更新失败',
|
|
692
|
+
message: '更新过程中出现问题,可稍后重试',
|
|
693
|
+
detail: `${String(e && e.message || e)}\n\n也可手动执行:\n npm install -g ${DSH_NPM_PACKAGE}@latest\n然后从菜单「重启 dsh 服务」或手动重启后按 F5。`,
|
|
694
|
+
buttons: ['好'], noLink: true,
|
|
695
|
+
})
|
|
696
|
+
loadApp()
|
|
697
|
+
} finally {
|
|
698
|
+
updatingDsh = false
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/* ------------------------------------------------------------------ *
|
|
704
|
+
* 会话管理(读取/删除 ~/.dsh/sessions 下的会话;仅本机模式入口可见,
|
|
705
|
+
* 远程模式下会话在服务器上,此处列表不适用故隐藏)
|
|
706
|
+
* ------------------------------------------------------------------ */
|
|
707
|
+
|
|
708
|
+
function sessionsRoot() {
|
|
709
|
+
return path.join(process.env.USERPROFILE || process.env.HOME || '', '.dsh', 'sessions')
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** 将工作区目录名(--C-Users-...-- 编码)还原为可读路径。 */
|
|
713
|
+
function decodeWorkspaceName(name) {
|
|
714
|
+
let s = name
|
|
715
|
+
if (s.startsWith('--')) s = s.slice(2)
|
|
716
|
+
if (s.endsWith('--')) s = s.slice(0, -2)
|
|
717
|
+
s = s.replace(/-/g, '\\')
|
|
718
|
+
s = s.replace(/^C\\/, 'C:\\')
|
|
719
|
+
return s
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** 列出所有会话。 */
|
|
723
|
+
function listSessions() {
|
|
724
|
+
const root = sessionsRoot()
|
|
725
|
+
const list = []
|
|
726
|
+
if (!fs.existsSync(root)) return list
|
|
727
|
+
const workspaces = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory())
|
|
728
|
+
for (const ws of workspaces) {
|
|
729
|
+
const wsPath = path.join(root, ws.name)
|
|
730
|
+
const sDirs = fs.readdirSync(wsPath, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name.startsWith('session-'))
|
|
731
|
+
for (const s of sDirs) {
|
|
732
|
+
const sPath = path.join(root, ws.name, s.name)
|
|
733
|
+
const stat = fs.statSync(sPath)
|
|
734
|
+
let sizeBytes = 0
|
|
735
|
+
const walk = (dir) => {
|
|
736
|
+
for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
737
|
+
const fp = path.join(dir, f.name)
|
|
738
|
+
if (f.isDirectory()) walk(fp)
|
|
739
|
+
else sizeBytes += fs.statSync(fp).size
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
try { walk(sPath) } catch { /* ignore */ }
|
|
743
|
+
list.push({
|
|
744
|
+
id: s.name,
|
|
745
|
+
workspace: decodeWorkspaceName(ws.name),
|
|
746
|
+
modified: stat.mtimeMs,
|
|
747
|
+
sizeKB: Math.round(sizeBytes / 1024),
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return list.sort((a, b) => b.modified - a.modified)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** 删除指定会话(按 id 定位目录并递归删除)。 */
|
|
755
|
+
function deleteSession(id) {
|
|
756
|
+
const root = sessionsRoot()
|
|
757
|
+
if (!fs.existsSync(root) || !/^session-[0-9a-f-]+$/i.test(id)) return { ok: false, error: '非法会话 ID' }
|
|
758
|
+
const workspaces = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory())
|
|
759
|
+
for (const ws of workspaces) {
|
|
760
|
+
const target = path.join(root, ws.name, id)
|
|
761
|
+
if (fs.existsSync(target)) {
|
|
762
|
+
try {
|
|
763
|
+
fs.rmSync(target, { recursive: true, force: true })
|
|
764
|
+
log(`deleted session ${id} (${ws.name})`)
|
|
765
|
+
return { ok: true }
|
|
766
|
+
} catch (e) {
|
|
767
|
+
return { ok: false, error: e.message }
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return { ok: false, error: '未找到该会话' }
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/* ------------------------------------------------------------------ *
|
|
775
|
+
* 环境检测与首次引导(本机模式)
|
|
776
|
+
* ------------------------------------------------------------------ */
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* 执行一条命令并收集输出(Windows 下经 shell 运行,参数均为常量)。
|
|
780
|
+
* timeoutMs > 0 时超时返回 null(与 spawn 失败同形,调用方统一处理)。
|
|
781
|
+
*/
|
|
782
|
+
function runCmd(cmd, args, timeoutMs = 0) {
|
|
783
|
+
return new Promise((resolve) => {
|
|
784
|
+
const full = args.length ? `"${cmd}" ${args.join(' ')}` : `"${cmd}"`
|
|
785
|
+
const child = spawn(full, { shell: true, windowsHide: true })
|
|
786
|
+
let out = ''
|
|
787
|
+
let settled = false
|
|
788
|
+
const timer = timeoutMs > 0
|
|
789
|
+
? setTimeout(() => {
|
|
790
|
+
if (settled) return
|
|
791
|
+
settled = true
|
|
792
|
+
try { child.kill() } catch { /* ignore */ }
|
|
793
|
+
resolve(null)
|
|
794
|
+
}, timeoutMs)
|
|
795
|
+
: null
|
|
796
|
+
child.stdout.on('data', (d) => (out += d.toString()))
|
|
797
|
+
child.stderr.on('data', (d) => (out += d.toString()))
|
|
798
|
+
child.on('error', () => {
|
|
799
|
+
if (settled) return
|
|
800
|
+
settled = true
|
|
801
|
+
if (timer) clearTimeout(timer)
|
|
802
|
+
resolve(null)
|
|
803
|
+
})
|
|
804
|
+
child.on('close', (code) => {
|
|
805
|
+
if (settled) return
|
|
806
|
+
settled = true
|
|
807
|
+
if (timer) clearTimeout(timer)
|
|
808
|
+
resolve({ code, out: out.trim() })
|
|
809
|
+
})
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** 检测 Node.js 与 dsh 是否可用。 */
|
|
814
|
+
async function checkEnvironment() {
|
|
815
|
+
const node = await runCmd('node', ['--version'])
|
|
816
|
+
const dshBin = resolveDshBin()
|
|
817
|
+
const dsh = await runCmd(dshBin, ['--version'])
|
|
818
|
+
return {
|
|
819
|
+
node:
|
|
820
|
+
node && node.code === 0
|
|
821
|
+
? { ok: true, version: node.out.replace(/^v/i, '') }
|
|
822
|
+
: { ok: false, error: '未检测到 Node.js,请先安装(≥ 22)' },
|
|
823
|
+
dsh:
|
|
824
|
+
dsh && dsh.code === 0
|
|
825
|
+
? { ok: true, version: (dsh.out.split('\n')[0] || 'dsh').trim() }
|
|
826
|
+
: { ok: false, error: `未检测到 dsh(${dshBin}),点击右侧按钮自动安装` },
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** 自动安装 dsh(npm 全局安装),输出流式回传引导窗口。 */
|
|
831
|
+
function installDsh() {
|
|
832
|
+
return new Promise((resolve) => {
|
|
833
|
+
const child = spawn('npm.cmd', ['i', '-g', '@deepseek-ai/dsh'], { shell: true, windowsHide: true })
|
|
834
|
+
const send = (line) => {
|
|
835
|
+
if (setupWindow && !setupWindow.isDestroyed()) {
|
|
836
|
+
setupWindow.webContents.send('setup:install-log', line)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
child.stdout.on('data', (d) => send(d.toString().trim()))
|
|
840
|
+
child.stderr.on('data', (d) => send(d.toString().trim()))
|
|
841
|
+
child.on('error', (e) => resolve({ ok: false, error: e.message }))
|
|
842
|
+
child.on('close', (code) =>
|
|
843
|
+
resolve(code === 0 ? { ok: true } : { ok: false, error: `npm 退出码 ${code}` })
|
|
844
|
+
)
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** 打开首次环境引导窗口;resolve('ready')=环境就绪,resolve('closed')=用户关闭。 */
|
|
849
|
+
function startSetupFlow() {
|
|
850
|
+
return new Promise((resolve) => {
|
|
851
|
+
setupWindow = new BrowserWindow({
|
|
852
|
+
width: 580,
|
|
853
|
+
height: 660,
|
|
854
|
+
resizable: false,
|
|
855
|
+
maximizable: false,
|
|
856
|
+
minimizable: false,
|
|
857
|
+
title: 'DeepSeek Harness 环境设置',
|
|
858
|
+
autoHideMenuBar: true,
|
|
859
|
+
icon: path.join(__dirname, '..', 'assets', 'icon.ico'),
|
|
860
|
+
webPreferences: {
|
|
861
|
+
contextIsolation: true,
|
|
862
|
+
nodeIntegration: false,
|
|
863
|
+
sandbox: true,
|
|
864
|
+
preload: path.join(__dirname, 'setup-preload.js'),
|
|
865
|
+
},
|
|
866
|
+
})
|
|
867
|
+
setupWindow.setMenuBarVisibility(false)
|
|
868
|
+
setupWindow.loadFile(path.join(__dirname, 'setup.html'))
|
|
869
|
+
setupWindow.on('closed', () => {
|
|
870
|
+
setupWindow = null
|
|
871
|
+
resolve('closed')
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
ipcMain.removeHandler('setup:check')
|
|
875
|
+
ipcMain.removeHandler('setup:install-dsh')
|
|
876
|
+
ipcMain.removeHandler('setup:open-node')
|
|
877
|
+
ipcMain.removeHandler('setup:open-settings')
|
|
878
|
+
ipcMain.removeHandler('setup:finish')
|
|
879
|
+
|
|
880
|
+
ipcMain.handle('setup:check', () => checkEnvironment())
|
|
881
|
+
ipcMain.handle('setup:install-dsh', () => installDsh())
|
|
882
|
+
ipcMain.handle('setup:open-node', () => shell.openExternal('https://nodejs.org'))
|
|
883
|
+
ipcMain.handle('setup:open-settings', () => shell.openExternal('https://platform.deepseek.com'))
|
|
884
|
+
ipcMain.handle('setup:finish', async () => {
|
|
885
|
+
const env = await checkEnvironment()
|
|
886
|
+
if (env.node.ok && env.dsh.ok) {
|
|
887
|
+
if (setupWindow && !setupWindow.isDestroyed()) setupWindow.destroy()
|
|
888
|
+
resolve('ready')
|
|
889
|
+
return { ok: true }
|
|
890
|
+
}
|
|
891
|
+
return { ok: false, env }
|
|
892
|
+
})
|
|
893
|
+
})
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/* ------------------------------------------------------------------ *
|
|
897
|
+
* 窗口
|
|
898
|
+
* ------------------------------------------------------------------ */
|
|
899
|
+
|
|
900
|
+
function createWindow(init) {
|
|
901
|
+
mainWindow = new BrowserWindow({
|
|
902
|
+
width: 1280,
|
|
903
|
+
height: 840,
|
|
904
|
+
minWidth: 960,
|
|
905
|
+
minHeight: 600,
|
|
906
|
+
title: 'DeepSeek Harness',
|
|
907
|
+
autoHideMenuBar: true,
|
|
908
|
+
backgroundColor: '#0d1117',
|
|
909
|
+
icon: path.join(__dirname, '..', 'assets', 'icon.ico'),
|
|
910
|
+
webPreferences: {
|
|
911
|
+
contextIsolation: true,
|
|
912
|
+
nodeIntegration: false,
|
|
913
|
+
sandbox: true,
|
|
914
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
915
|
+
},
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
// init.mode !== 'app' 时先显示连接页(首次使用 / 离线 / 主动切换)
|
|
919
|
+
if (!init || init.mode !== 'app') showConnectPage(init || {})
|
|
920
|
+
mainWindow.on('closed', () => {
|
|
921
|
+
mainWindow = null
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
// F5 刷新(仅对应用页生效;连接页交给其自身的重试逻辑)
|
|
925
|
+
mainWindow.webContents.on('before-input-event', (event, input) => {
|
|
926
|
+
if (input.type === 'keyDown' && input.key === 'F5') {
|
|
927
|
+
event.preventDefault()
|
|
928
|
+
const cur = mainWindow.webContents.getURL()
|
|
929
|
+
if (target && cur.startsWith(target)) loadApp()
|
|
930
|
+
else if (!cur.startsWith('file://') && !cur.startsWith('data:')) loadApp()
|
|
931
|
+
}
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
// 关窗口 → 最小化到托盘(不退出应用);从托盘菜单选"退出"才真正退出
|
|
935
|
+
mainWindow.on('close', (event) => {
|
|
936
|
+
if (!isQuitting) {
|
|
937
|
+
event.preventDefault()
|
|
938
|
+
mainWindow.hide()
|
|
939
|
+
log('window hidden to tray')
|
|
940
|
+
}
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
// 外部链接(非当前目标)用系统浏览器打开,不在壳内导航;站内允许弹窗
|
|
944
|
+
mainWindow.webContents.setWindowOpenHandler(({ url: t }) => {
|
|
945
|
+
if (target && t.startsWith(target)) return { action: 'allow' }
|
|
946
|
+
if (/^https?:\/\//i.test(t)) shell.openExternal(t)
|
|
947
|
+
return { action: 'deny' }
|
|
948
|
+
})
|
|
949
|
+
mainWindow.webContents.on('will-navigate', (event, t) => {
|
|
950
|
+
if (t.startsWith('file://') || t.startsWith('data:')) return // 连接页等内部页面
|
|
951
|
+
if (target && t.startsWith(target)) return
|
|
952
|
+
event.preventDefault()
|
|
953
|
+
if (/^https?:\/\//i.test(t)) shell.openExternal(t)
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
// 应用页主框架加载失败(远程服务器关机/断网等)→ 回到连接页离线模式
|
|
957
|
+
mainWindow.webContents.on('did-fail-load', (_e, errorCode, errorDesc, validatedURL, isMainFrame) => {
|
|
958
|
+
if (!isMainFrame || errorCode === -3) return // -3 = ERR_ABORTED,多为自身跳转引起
|
|
959
|
+
if (!target || !validatedURL || !validatedURL.startsWith(target)) return
|
|
960
|
+
log(`app page failed to load (${errorCode}): ${errorDesc}`)
|
|
961
|
+
showConnectPage({
|
|
962
|
+
mode: 'offline',
|
|
963
|
+
url: target,
|
|
964
|
+
error: `连接 ${hostOf(target)} 失败(${errorDesc || '错误码 ' + errorCode})。\n恢复后点「连接」或等待自动重试即可。`,
|
|
965
|
+
})
|
|
966
|
+
})
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** 打开会话管理窗口。 */
|
|
970
|
+
function openSessionsWindow() {
|
|
971
|
+
if (sessionsWin && !sessionsWin.isDestroyed()) {
|
|
972
|
+
sessionsWin.focus()
|
|
973
|
+
return
|
|
974
|
+
}
|
|
975
|
+
sessionsWin = new BrowserWindow({
|
|
976
|
+
width: 620,
|
|
977
|
+
height: 680,
|
|
978
|
+
title: '会话管理 — DeepSeek Harness Desktop',
|
|
979
|
+
autoHideMenuBar: true,
|
|
980
|
+
icon: path.join(__dirname, '..', 'assets', 'icon.ico'),
|
|
981
|
+
webPreferences: {
|
|
982
|
+
contextIsolation: true,
|
|
983
|
+
nodeIntegration: false,
|
|
984
|
+
sandbox: true,
|
|
985
|
+
preload: path.join(__dirname, 'sessions-preload.js'),
|
|
986
|
+
},
|
|
987
|
+
})
|
|
988
|
+
sessionsWin.setMenuBarVisibility(false)
|
|
989
|
+
sessionsWin.loadFile(path.join(__dirname, 'sessions.html'))
|
|
990
|
+
sessionsWin.on('closed', () => {
|
|
991
|
+
sessionsWin = null
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
ipcMain.removeHandler('sessions:list')
|
|
995
|
+
ipcMain.removeHandler('sessions:remove')
|
|
996
|
+
ipcMain.handle('sessions:list', () => listSessions())
|
|
997
|
+
ipcMain.handle('sessions:remove', (_e, id) => deleteSession(id))
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/* ------------------------------------------------------------------ *
|
|
1001
|
+
* 选区截图窗口
|
|
1002
|
+
* ------------------------------------------------------------------ */
|
|
1003
|
+
|
|
1004
|
+
function openShotWindow() {
|
|
1005
|
+
if (shotWindow && !shotWindow.isDestroyed()) {
|
|
1006
|
+
shotWindow.focus()
|
|
1007
|
+
return
|
|
1008
|
+
}
|
|
1009
|
+
// 用显示器精确尺寸铺满屏幕(不用 fullscreen,Windows 上更可靠)
|
|
1010
|
+
const display = screen.getPrimaryDisplay()
|
|
1011
|
+
const bounds = display.bounds
|
|
1012
|
+
shotWindow = new BrowserWindow({
|
|
1013
|
+
x: bounds.x,
|
|
1014
|
+
y: bounds.y,
|
|
1015
|
+
width: bounds.width,
|
|
1016
|
+
height: bounds.height,
|
|
1017
|
+
frame: false,
|
|
1018
|
+
resizable: false,
|
|
1019
|
+
movable: false,
|
|
1020
|
+
alwaysOnTop: true,
|
|
1021
|
+
skipTaskbar: true,
|
|
1022
|
+
show: false, // 截图加载完成后再显示,避免黑屏闪烁
|
|
1023
|
+
webPreferences: {
|
|
1024
|
+
contextIsolation: true,
|
|
1025
|
+
nodeIntegration: false,
|
|
1026
|
+
sandbox: true,
|
|
1027
|
+
preload: path.join(__dirname, 'screenshot-preload.js'),
|
|
1028
|
+
},
|
|
1029
|
+
})
|
|
1030
|
+
shotWindow.setAlwaysOnTop(true, 'screen-saver')
|
|
1031
|
+
shotWindow.setBounds(bounds) // 再次确保铺满
|
|
1032
|
+
shotWindow.loadFile(path.join(__dirname, 'screenshot.html'))
|
|
1033
|
+
shotWindow.webContents.once('did-finish-load', () => {
|
|
1034
|
+
if (pendingShot && !shotWindow.isDestroyed()) {
|
|
1035
|
+
shotWindow.webContents.send('screenshot:data', pendingShot.toDataURL())
|
|
1036
|
+
shotLog('shot: data sent to selector window')
|
|
1037
|
+
}
|
|
1038
|
+
})
|
|
1039
|
+
shotWindow.on('closed', () => {
|
|
1040
|
+
shotWindow = null
|
|
1041
|
+
})
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// 截图在选区窗口内加载完成 → 再显示窗口(避免黑屏"小窗口")
|
|
1045
|
+
function onShotLoaded() {
|
|
1046
|
+
if (shotWindow && !shotWindow.isDestroyed()) {
|
|
1047
|
+
shotWindow.show()
|
|
1048
|
+
shotWindow.focus()
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** 选区确认:裁剪 → 剪贴板 → 恢复主窗口并粘贴。 */
|
|
1053
|
+
function onShotDone(rect) {
|
|
1054
|
+
shotLog('shot: done called', rect ? JSON.stringify(rect) : 'null')
|
|
1055
|
+
const image = pendingShot
|
|
1056
|
+
pendingShot = null
|
|
1057
|
+
if (shotWindow && !shotWindow.isDestroyed()) shotWindow.destroy()
|
|
1058
|
+
showMainWindow() // 恢复显示聊天窗口
|
|
1059
|
+
if (!image || !rect) {
|
|
1060
|
+
shotLog('shot: done skipped (no image or rect)')
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
const sf = screen.getPrimaryDisplay().scaleFactor || 1
|
|
1064
|
+
const r = {
|
|
1065
|
+
x: Math.round(rect.x * sf),
|
|
1066
|
+
y: Math.round(rect.y * sf),
|
|
1067
|
+
width: Math.round(rect.w * sf),
|
|
1068
|
+
height: Math.round(rect.h * sf),
|
|
1069
|
+
}
|
|
1070
|
+
if (r.width <= 0 || r.height <= 0) {
|
|
1071
|
+
shotLog('shot: invalid crop rect after scale')
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
const cropped = image.crop(r)
|
|
1075
|
+
shotLog('shot: cropped', cropped.getSize().width + 'x' + cropped.getSize().height)
|
|
1076
|
+
// 保存到 ~/.dsh/screenshots/(持久保存,方便回顾;同时写剪贴板)
|
|
1077
|
+
try {
|
|
1078
|
+
const dir = path.join(os.homedir(), '.dsh', 'screenshots')
|
|
1079
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
1080
|
+
const file = path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, '-')}.png`)
|
|
1081
|
+
fs.writeFileSync(file, cropped.toPNG())
|
|
1082
|
+
shotLog('shot: saved', file)
|
|
1083
|
+
} catch (e) {
|
|
1084
|
+
shotLog('shot: save failed', String(e && e.message || e))
|
|
1085
|
+
}
|
|
1086
|
+
clipboard.writeImage(cropped)
|
|
1087
|
+
shotLog('shot: written to clipboard')
|
|
1088
|
+
// 等主窗口就绪后尝试自动粘贴;若聚焦失败则提示手动 Ctrl+V
|
|
1089
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
1090
|
+
setTimeout(async () => {
|
|
1091
|
+
let focused = false
|
|
1092
|
+
try {
|
|
1093
|
+
focused = await mainWindow.webContents.executeJavaScript(
|
|
1094
|
+
`(() => { const el = document.querySelector('textarea, [contenteditable="true"]'); if (el) { el.focus(); return true } return false })()`
|
|
1095
|
+
)
|
|
1096
|
+
} catch {
|
|
1097
|
+
focused = false
|
|
1098
|
+
}
|
|
1099
|
+
if (focused) {
|
|
1100
|
+
mainWindow.webContents.paste()
|
|
1101
|
+
} else {
|
|
1102
|
+
dialog.showMessageBox(mainWindow, {
|
|
1103
|
+
type: 'info',
|
|
1104
|
+
message: '截图已复制到剪贴板,请在聊天输入框按 Ctrl+V 粘贴',
|
|
1105
|
+
buttons: ['好'],
|
|
1106
|
+
})
|
|
1107
|
+
}
|
|
1108
|
+
}, 400)
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function onShotCancel() {
|
|
1113
|
+
pendingShot = null
|
|
1114
|
+
if (shotWindow && !shotWindow.isDestroyed()) shotWindow.destroy()
|
|
1115
|
+
showMainWindow() // 恢复显示聊天窗口
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/* ------------------------------------------------------------------ *
|
|
1119
|
+
* 系统托盘 / 应用菜单(按连接模式裁剪)
|
|
1120
|
+
* ------------------------------------------------------------------ */
|
|
1121
|
+
|
|
1122
|
+
/** 从应用切到连接页(菜单/托盘入口)。 */
|
|
1123
|
+
function openConnectSettings() {
|
|
1124
|
+
showMainWindow()
|
|
1125
|
+
showConnectPage({ mode: target ? 'switch' : 'first', url: target })
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function buildTray() {
|
|
1129
|
+
const iconPath = path.join(__dirname, '..', 'assets', 'tray.png')
|
|
1130
|
+
if (!tray) {
|
|
1131
|
+
tray = new Tray(nativeImage.createFromPath(iconPath))
|
|
1132
|
+
tray.on('double-click', showMainWindow)
|
|
1133
|
+
}
|
|
1134
|
+
tray.setToolTip(target ? `DeepSeek Harness — ${hostOf(target)}` : 'DeepSeek Harness — 未连接')
|
|
1135
|
+
const template = [
|
|
1136
|
+
{ label: '显示主窗口', click: showMainWindow },
|
|
1137
|
+
{ type: 'separator' },
|
|
1138
|
+
{ label: target ? `服务器:${hostOf(target)}${targetIsLocal ? '(本机)' : ''}` : '服务器:未连接', enabled: false },
|
|
1139
|
+
{ label: '连接设置…', click: openConnectSettings },
|
|
1140
|
+
...(targetIsLocal ? [
|
|
1141
|
+
{ label: '检查 DSH 更新', click: () => checkDshUpdate(false) },
|
|
1142
|
+
{ label: '重启 dsh 服务', click: () => { restartDshService() } },
|
|
1143
|
+
] : []),
|
|
1144
|
+
{
|
|
1145
|
+
label: '打开截图目录',
|
|
1146
|
+
click: () => {
|
|
1147
|
+
const dir = path.join(os.homedir(), '.dsh', 'screenshots')
|
|
1148
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
1149
|
+
shell.openPath(dir)
|
|
1150
|
+
},
|
|
1151
|
+
},
|
|
1152
|
+
{ type: 'separator' },
|
|
1153
|
+
{
|
|
1154
|
+
label: '退出',
|
|
1155
|
+
click: () => {
|
|
1156
|
+
isQuitting = true
|
|
1157
|
+
app.quit()
|
|
1158
|
+
},
|
|
1159
|
+
},
|
|
1160
|
+
]
|
|
1161
|
+
tray.setContextMenu(Menu.buildFromTemplate(template))
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function buildApplicationMenu() {
|
|
1165
|
+
const template = [
|
|
1166
|
+
{
|
|
1167
|
+
label: '文件',
|
|
1168
|
+
submenu: [
|
|
1169
|
+
...(targetIsLocal ? [{ label: '会话管理…', click: () => openSessionsWindow() }] : []),
|
|
1170
|
+
{ label: '连接设置…', click: openConnectSettings },
|
|
1171
|
+
...(targetIsLocal ? [
|
|
1172
|
+
{ label: '检查 DSH 更新…', click: () => checkDshUpdate(false) },
|
|
1173
|
+
{ label: '重启 dsh 服务…', click: () => restartDshService() },
|
|
1174
|
+
] : []),
|
|
1175
|
+
{ type: 'separator' },
|
|
1176
|
+
{
|
|
1177
|
+
label: '退出',
|
|
1178
|
+
accelerator: 'Alt+F4',
|
|
1179
|
+
click: () => {
|
|
1180
|
+
isQuitting = true
|
|
1181
|
+
app.quit()
|
|
1182
|
+
},
|
|
1183
|
+
},
|
|
1184
|
+
],
|
|
1185
|
+
},
|
|
1186
|
+
{
|
|
1187
|
+
label: '视图',
|
|
1188
|
+
submenu: [
|
|
1189
|
+
{
|
|
1190
|
+
label: '重新加载',
|
|
1191
|
+
accelerator: 'CmdOrCtrl+R',
|
|
1192
|
+
click: () => loadApp(),
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
label: '强制重新加载(清缓存)',
|
|
1196
|
+
accelerator: 'CmdOrCtrl+Shift+R',
|
|
1197
|
+
click: () => {
|
|
1198
|
+
if (mainWindow) mainWindow.webContents.reloadIgnoringCache()
|
|
1199
|
+
},
|
|
1200
|
+
},
|
|
1201
|
+
{ type: 'separator' },
|
|
1202
|
+
{ role: 'togglefullscreen', label: '全屏' },
|
|
1203
|
+
{
|
|
1204
|
+
label: '开发者工具',
|
|
1205
|
+
accelerator: 'F12',
|
|
1206
|
+
click: () => {
|
|
1207
|
+
if (mainWindow) mainWindow.webContents.toggleDevTools()
|
|
1208
|
+
},
|
|
1209
|
+
},
|
|
1210
|
+
],
|
|
1211
|
+
},
|
|
1212
|
+
]
|
|
1213
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function showMainWindow() {
|
|
1217
|
+
if (!mainWindow) return
|
|
1218
|
+
mainWindow.show()
|
|
1219
|
+
mainWindow.focus()
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/* ------------------------------------------------------------------ *
|
|
1223
|
+
* 连接流程
|
|
1224
|
+
* ------------------------------------------------------------------ */
|
|
1225
|
+
|
|
1226
|
+
/** 回到应用主页面(不能用 reload()——它会把临时页如更新进度页再刷一遍,把人困住)。 */
|
|
1227
|
+
function loadApp() {
|
|
1228
|
+
if (mainWindow && !mainWindow.isDestroyed() && target) mainWindow.loadURL(target).catch(() => {})
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** 显示连接页。mode: first=首次使用 / offline=连不上 / switch=从应用主动换服务器 */
|
|
1232
|
+
function showConnectPage(opts = {}) {
|
|
1233
|
+
connectState = {
|
|
1234
|
+
mode: opts.mode || 'first',
|
|
1235
|
+
url: opts.url || '',
|
|
1236
|
+
error: opts.error || '',
|
|
1237
|
+
}
|
|
1238
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
1239
|
+
mainWindow.loadFile(path.join(__dirname, 'connect.html')).catch(() => {})
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** 探测并连接用户输入的远程地址;成功则记住并进入应用。由连接页经 IPC 调用。 */
|
|
1243
|
+
async function attemptConnect(raw, { persist = true } = {}) {
|
|
1244
|
+
const prevAuth = basicAuth
|
|
1245
|
+
const t = normalizeTarget(typeof raw === 'string' ? raw : '')
|
|
1246
|
+
if (!t) {
|
|
1247
|
+
return { ok: false, error: '地址无效:请输入完整地址,如 https://dsh.example.com 或 http://192.168.31.10:3080' }
|
|
1248
|
+
}
|
|
1249
|
+
// 换主机且新地址未内嵌凭据时清掉旧凭据,避免误发给新主机;
|
|
1250
|
+
// 同主机重试(离线自动重连等)保留已保存凭据。
|
|
1251
|
+
if (basicAuth === prevAuth && (!target || hostOf(target) !== hostOf(t))) basicAuth = null
|
|
1252
|
+
log('probing', t)
|
|
1253
|
+
const up = await probeUrl(t)
|
|
1254
|
+
if (!up) {
|
|
1255
|
+
basicAuth = prevAuth // 连接失败回滚,保持原状态
|
|
1256
|
+
return { ok: false, url: t, error: `无法连接 ${t}(无响应或超时)。\n请确认服务已启动、地址与端口正确、本机网络可达该服务器。` }
|
|
1257
|
+
}
|
|
1258
|
+
applyTarget(t, { persist })
|
|
1259
|
+
enterApp()
|
|
1260
|
+
return { ok: true, url: t }
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** 目标就绪:按模式重建托盘/菜单,进入应用页。 */
|
|
1264
|
+
function enterApp() {
|
|
1265
|
+
connectState = { mode: 'app', url: target, error: '' }
|
|
1266
|
+
buildTray()
|
|
1267
|
+
buildApplicationMenu()
|
|
1268
|
+
loadApp()
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* 本机模式启动(连接页「本机连接」与保存过本机地址的启动共用):
|
|
1273
|
+
* 环境检查/引导 → 探测 3080 → 没有则本壳启动 dsh → 进入应用。
|
|
1274
|
+
* @returns 是否成功进入应用
|
|
1275
|
+
*/
|
|
1276
|
+
async function startLocalMode({ persist = true, fromStartup = false } = {}) {
|
|
1277
|
+
// 0) 环境检查:Node / dsh 缺失时先走引导窗口
|
|
1278
|
+
let env = await checkEnvironment()
|
|
1279
|
+
if (!env.node.ok || !env.dsh.ok) {
|
|
1280
|
+
const result = await startSetupFlow()
|
|
1281
|
+
if (result !== 'ready') {
|
|
1282
|
+
// 用户关闭引导窗口:启动场景退出应用;连接页场景留在连接页
|
|
1283
|
+
if (fromStartup) app.quit()
|
|
1284
|
+
return false
|
|
1285
|
+
}
|
|
1286
|
+
env = await checkEnvironment()
|
|
1287
|
+
if (!env.node.ok || !env.dsh.ok) {
|
|
1288
|
+
if (fromStartup) app.quit()
|
|
1289
|
+
return false
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const port = resolvePort()
|
|
1294
|
+
applyTarget(`http://127.0.0.1:${port}`, { persist })
|
|
1295
|
+
|
|
1296
|
+
// 1) 已有服务(可能是用户手动起的 dsh web / WSL 等)→ 直接复用
|
|
1297
|
+
let external = await probe(port)
|
|
1298
|
+
if (!external) {
|
|
1299
|
+
// 2) 没有 → 自己启动,等就绪
|
|
1300
|
+
ownedDsh = startDsh(port)
|
|
1301
|
+
const ready = await waitForServer(port, START_TIMEOUT_MS)
|
|
1302
|
+
if (!ready) {
|
|
1303
|
+
const logFile = path.join(app.getPath('userData'), 'dsh.log')
|
|
1304
|
+
dialog.showErrorBox(
|
|
1305
|
+
'DeepSeek Harness 启动失败',
|
|
1306
|
+
`dsh web 在 ${START_TIMEOUT_MS / 1000}s 内没有就绪。\n\n` +
|
|
1307
|
+
`日志文件:${logFile}\n\n` +
|
|
1308
|
+
'请确认已全局安装 dsh(npm i -g @deepseek-ai/dsh),或用 DSH_BIN 指定路径。\n' +
|
|
1309
|
+
`若端口 ${port} 被其他程序占用,可换端口启动:dsh-desktop --port <新端口>`
|
|
1310
|
+
)
|
|
1311
|
+
if (fromStartup) {
|
|
1312
|
+
await shutdownOwnedDsh()
|
|
1313
|
+
app.quit()
|
|
1314
|
+
}
|
|
1315
|
+
return false
|
|
1316
|
+
}
|
|
1317
|
+
} else {
|
|
1318
|
+
log('reusing external dsh service on port ' + port)
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// 3) 就绪:首次创建窗口(连接页触发时窗口已存在,直接进入)
|
|
1322
|
+
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
1323
|
+
createWindow({ mode: 'app' })
|
|
1324
|
+
buildTray()
|
|
1325
|
+
buildApplicationMenu()
|
|
1326
|
+
}
|
|
1327
|
+
enterApp()
|
|
1328
|
+
checkForUpdatesOnce()
|
|
1329
|
+
// 启动 3s 后静默检查本机 DSH 是否有新版本(仅本机模式实际生效)
|
|
1330
|
+
setTimeout(() => checkDshUpdate(true).catch((e) => log('dsh update check error:', e && e.message)), 3_000)
|
|
1331
|
+
return true
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/* ------------------------------------------------------------------ *
|
|
1335
|
+
* IPC(工具条 / 截图 / 连接页)
|
|
1336
|
+
* ------------------------------------------------------------------ */
|
|
1337
|
+
|
|
1338
|
+
function registerIpc() {
|
|
1339
|
+
// preload 注入的悬浮刷新按钮 → 重新加载页面
|
|
1340
|
+
ipcMain.on('dsh-desktop:reload', () => {
|
|
1341
|
+
if (target) loadApp()
|
|
1342
|
+
})
|
|
1343
|
+
// 选区截图:隐藏主窗口 → 截全屏 → 选区窗口 → 裁剪 → 剪贴板 → 粘贴
|
|
1344
|
+
ipcMain.handle('dsh-desktop:capture', async () => {
|
|
1345
|
+
shotLog('capture: requested')
|
|
1346
|
+
try {
|
|
1347
|
+
// 先隐藏主窗口,避免截到聊天框本身
|
|
1348
|
+
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide()
|
|
1349
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
1350
|
+
const display = screen.getPrimaryDisplay()
|
|
1351
|
+
const sf = display.scaleFactor || 1
|
|
1352
|
+
const sources = await desktopCapturer.getSources({
|
|
1353
|
+
types: ['screen'],
|
|
1354
|
+
thumbnailSize: {
|
|
1355
|
+
width: Math.round(display.size.width * sf),
|
|
1356
|
+
height: Math.round(display.size.height * sf),
|
|
1357
|
+
},
|
|
1358
|
+
})
|
|
1359
|
+
if (!sources.length) {
|
|
1360
|
+
showMainWindow()
|
|
1361
|
+
shotLog('capture: no screen source')
|
|
1362
|
+
return { ok: false, error: '未找到屏幕源' }
|
|
1363
|
+
}
|
|
1364
|
+
pendingShot = sources[0].thumbnail
|
|
1365
|
+
if (pendingShot.isEmpty()) {
|
|
1366
|
+
showMainWindow()
|
|
1367
|
+
shotLog('capture: empty thumbnail')
|
|
1368
|
+
return { ok: false, error: '截图为空' }
|
|
1369
|
+
}
|
|
1370
|
+
shotLog('capture: captured', pendingShot.getSize().width + 'x' + pendingShot.getSize().height)
|
|
1371
|
+
openShotWindow()
|
|
1372
|
+
return { ok: true }
|
|
1373
|
+
} catch (e) {
|
|
1374
|
+
showMainWindow()
|
|
1375
|
+
shotLog('capture: error', String((e && e.message) || e))
|
|
1376
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
1377
|
+
}
|
|
1378
|
+
})
|
|
1379
|
+
// 轻提示(截图失败等)
|
|
1380
|
+
ipcMain.on('dsh-desktop:toast', (_e, msg) => {
|
|
1381
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
1382
|
+
dialog.showMessageBox(mainWindow, { type: 'info', message: String(msg), buttons: ['好'] })
|
|
1383
|
+
}
|
|
1384
|
+
})
|
|
1385
|
+
// 选区截图结果
|
|
1386
|
+
ipcMain.on('screenshot:done', (_e, rect) => onShotDone(rect))
|
|
1387
|
+
ipcMain.on('screenshot:cancel', () => onShotCancel())
|
|
1388
|
+
ipcMain.on('screenshot:loaded', () => onShotLoaded())
|
|
1389
|
+
|
|
1390
|
+
// 连接页:读取状态 / 连接远程地址 / 本机连接 / 返回应用
|
|
1391
|
+
ipcMain.handle('connect:state', () => ({
|
|
1392
|
+
mode: connectState.mode,
|
|
1393
|
+
url: connectState.url || '',
|
|
1394
|
+
error: connectState.error || '',
|
|
1395
|
+
}))
|
|
1396
|
+
ipcMain.handle('connect:attempt', (_e, raw) => attemptConnect(raw))
|
|
1397
|
+
ipcMain.handle('connect:local', async () => {
|
|
1398
|
+
const ok = await startLocalMode({ persist: true })
|
|
1399
|
+
return ok
|
|
1400
|
+
? { ok: true, url: target }
|
|
1401
|
+
: { ok: false, error: '本机连接未能就绪(详情见弹窗与日志)。可稍后重试,或改用远程地址。' }
|
|
1402
|
+
})
|
|
1403
|
+
ipcMain.on('connect:cancel', () => { if (target) loadApp() })
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/* ------------------------------------------------------------------ *
|
|
1407
|
+
* 应用生命周期
|
|
1408
|
+
* ------------------------------------------------------------------ */
|
|
1409
|
+
|
|
1410
|
+
// Windows 任务栏固定/通知规范:AppUserModelID 与打包 appId 保持一致
|
|
1411
|
+
app.setAppUserModelId('com.dsh.desktop')
|
|
1412
|
+
|
|
1413
|
+
// 自建服务常见自签名证书:仅当证书错误发生在「当前目标主机」上时放行,
|
|
1414
|
+
// 其余一律拒绝(不扩大面)。
|
|
1415
|
+
app.on('certificate-error', (event, _wc, url, error, _cert, callback) => {
|
|
1416
|
+
let u, t
|
|
1417
|
+
try { u = new URL(url); t = new URL(target) } catch { callback(false); return }
|
|
1418
|
+
if (target && u.host === t.host && u.protocol === 'https:') {
|
|
1419
|
+
log(`accepting certificate error on configured host (${error})`)
|
|
1420
|
+
event.preventDefault()
|
|
1421
|
+
callback(true)
|
|
1422
|
+
return
|
|
1423
|
+
}
|
|
1424
|
+
callback(false)
|
|
1425
|
+
})
|
|
1426
|
+
|
|
1427
|
+
const gotLock = app.requestSingleInstanceLock()
|
|
1428
|
+
if (!gotLock) {
|
|
1429
|
+
app.quit()
|
|
1430
|
+
} else {
|
|
1431
|
+
app.on('second-instance', () => {
|
|
1432
|
+
// 窗口可能隐藏到了托盘:必须 show() 才能从任务栏图标唤起
|
|
1433
|
+
if (mainWindow) {
|
|
1434
|
+
mainWindow.show()
|
|
1435
|
+
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
1436
|
+
mainWindow.focus()
|
|
1437
|
+
}
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
app.whenReady().then(async () => {
|
|
1441
|
+
registerIpc()
|
|
1442
|
+
|
|
1443
|
+
// HTTP Basic Auth 自动应答:服务器返回 401 时回填 basicAuth 凭据
|
|
1444
|
+
// (凭据来自连接 URL 内嵌的 user:pass@host 形式,见 normalizeTarget)。
|
|
1445
|
+
// 每次会话浏览器内核只问一次;凭据仅存内存,不写日志。
|
|
1446
|
+
app.on('login', (_event, _webContents, _details, _authInfo, callback) => {
|
|
1447
|
+
if (basicAuth) callback(basicAuth.username, basicAuth.password)
|
|
1448
|
+
else callback() // 无凭据则取消,交给 401 页面展示
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1451
|
+
// 解析初始目标:命令行 --url > 上次保存(config.json)
|
|
1452
|
+
const cliUrl = resolveCliUrl()
|
|
1453
|
+
const savedUrl = String(loadConfig().url || '')
|
|
1454
|
+
// 恢复上次会话保存的 BasicAuth 凭据(URL 未内嵌凭据时使用)
|
|
1455
|
+
const savedAuth = loadConfig().basicAuth
|
|
1456
|
+
if (savedAuth && typeof savedAuth === 'object' && savedAuth.username) basicAuth = savedAuth
|
|
1457
|
+
const candidate = cliUrl ? normalizeTarget(cliUrl) : normalizeTarget(savedUrl)
|
|
1458
|
+
log(cliUrl ? `cli target: ${cliUrl}` : `saved target: ${savedUrl || '(none)'}`)
|
|
1459
|
+
|
|
1460
|
+
if (candidate && !isLocalHost(new URL(candidate).hostname)) {
|
|
1461
|
+
// ── 远程模式:先探测避免闪一下连接页;可达直接进,不可达离线连接页 ──
|
|
1462
|
+
const up = await probeUrl(candidate)
|
|
1463
|
+
const init = up
|
|
1464
|
+
? { mode: 'app' }
|
|
1465
|
+
: {
|
|
1466
|
+
mode: 'offline',
|
|
1467
|
+
url: candidate,
|
|
1468
|
+
error: `无法连接 ${candidate}(未响应或超时)。\n服务器可能没开机,或网络不可达。恢复后点「连接」,或改用其他地址。`,
|
|
1469
|
+
}
|
|
1470
|
+
createWindow(init)
|
|
1471
|
+
buildTray()
|
|
1472
|
+
buildApplicationMenu()
|
|
1473
|
+
if (up) {
|
|
1474
|
+
applyTarget(candidate, { persist: !cliUrl }) // 命令行临时指定不覆盖已存配置
|
|
1475
|
+
enterApp()
|
|
1476
|
+
}
|
|
1477
|
+
checkForUpdatesOnce()
|
|
1478
|
+
return
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
if (candidate) {
|
|
1482
|
+
// ── 本机模式(保存过本机地址 / --url 指向本机):走完整本地启动流程 ──
|
|
1483
|
+
await startLocalMode({ persist: !cliUrl, fromStartup: true })
|
|
1484
|
+
return
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// ── 首次使用(无保存地址):显示连接页,选择「本机连接」或远程地址 ──
|
|
1488
|
+
createWindow({ mode: 'first' })
|
|
1489
|
+
buildTray()
|
|
1490
|
+
buildApplicationMenu()
|
|
1491
|
+
checkForUpdatesOnce()
|
|
1492
|
+
})
|
|
1493
|
+
|
|
1494
|
+
// 托盘常驻:窗口关闭不退出应用;只有用户从托盘选"退出"才真正退出
|
|
1495
|
+
app.on('window-all-closed', () => {
|
|
1496
|
+
if (isQuitting) {
|
|
1497
|
+
shutdownOwnedDsh().finally(() => app.quit())
|
|
1498
|
+
}
|
|
1499
|
+
})
|
|
1500
|
+
|
|
1501
|
+
app.on('before-quit', (event) => {
|
|
1502
|
+
// 防止退出竞态:先杀掉子进程再放行
|
|
1503
|
+
if (ownedDsh) {
|
|
1504
|
+
event.preventDefault()
|
|
1505
|
+
shutdownOwnedDsh().finally(() => app.quit())
|
|
1506
|
+
}
|
|
1507
|
+
})
|
|
1508
|
+
}
|