dsh-deepseek-style-theme 2.0.84 → 2.0.86
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/THIRD-PARTY-NOTICES.md +30 -10
- package/lib/client.js +235 -44
- package/lib/index.js +1116 -1107
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,1107 +1,1116 @@
|
|
|
1
|
-
// Host side of the DeepSeek-style theme plugin.
|
|
2
|
-
// Registers a Package-private RPC channel the browser half uses to open a
|
|
3
|
-
// workspace's directory in the OS file manager and bring the window to the
|
|
4
|
-
// foreground. The built-in host.openPath (Invoke-Item) leaves the Explorer
|
|
5
|
-
// window in the background when spawned from this windowless service process,
|
|
6
|
-
// so this plugin owns the whole "open workspace" gesture instead. The same
|
|
7
|
-
// channel carries the delivered-file gestures the browser half's card menu
|
|
8
|
-
// needs: open with the default application, and reveal in the file manager.
|
|
9
|
-
// It also owns the DSTT settings section and, once per activation, aligns the
|
|
10
|
-
// official DeepSeek route's advisory model catalog with the ids the endpoint
|
|
11
|
-
// actually advertises (see the catalog-sync block below).
|
|
12
|
-
//
|
|
13
|
-
// Wire contract: every return is an RpcResult<T> per rpc.schema.js. Error
|
|
14
|
-
// codes must come from the closed enum (bad-request / internal) — an
|
|
15
|
-
// off-enum code would make the connection layer's serverResponseSchema.parse
|
|
16
|
-
// reject the frame and surface as a carrier failure instead of a clean error.
|
|
17
|
-
//
|
|
18
|
-
// The channel is fenced exactly like the core's own /api route: a loopback
|
|
19
|
-
// peer, an authority this server answers on, agreement between Host and any
|
|
20
|
-
// provenance headers, a JSON body, and local absolute paths only — see
|
|
21
|
-
// isTrustedBridgeRequest. Nothing here may widen that fence without widening
|
|
22
|
-
// the core's.
|
|
23
|
-
import { execFile } from "node:child_process";
|
|
24
|
-
import { readFile } from "node:fs/promises";
|
|
25
|
-
import { dirname, isAbsolute } from "node:path";
|
|
26
|
-
|
|
27
|
-
// Schemastery is this bundle's only runtime dependency, and it is loaded
|
|
28
|
-
// defensively. `settings.register()` genuinely needs a real schema — the
|
|
29
|
-
// settings service calls it as a function (`resolve`) and serializes it through
|
|
30
|
-
// `schema.toJSON()` plus a `redactSecrets` walk inside `describe()` — so a
|
|
31
|
-
// hand-rolled stand-in cannot work. What it must not do is take the whole
|
|
32
|
-
// plugin tree down when the package is unresolvable: with an empty profile
|
|
33
|
-
// (`autoInstallPeers: false`) or a `link:` install whose real path sits outside
|
|
34
|
-
// the profile, a bare `import` here fails the loader entry and the GUI refuses
|
|
35
|
-
// to start at all. The guarded dynamic import converts that into one lost
|
|
36
|
-
// preference.
|
|
37
|
-
let z = null;
|
|
38
|
-
try {
|
|
39
|
-
const schemastery = await import("@deepseek-ai/schemastery");
|
|
40
|
-
const schema = schemastery.default ?? null;
|
|
41
|
-
if (schema !== null && typeof schema.object === "function") z = schema;
|
|
42
|
-
} catch {
|
|
43
|
-
// Reported once, below, where the remedy is actionable.
|
|
44
|
-
}
|
|
45
|
-
if (z === null) {
|
|
46
|
-
console.warn(
|
|
47
|
-
"[deepseek-style-theme] @deepseek-ai/schemastery could not be loaded, so the DSTT mode will not persist (the theme itself still works). Install it beside the plugin: dsh plugin --profile web add @deepseek-ai/schemastery@^3.18.2"
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const CHANNEL = "/dshome-open-workspace";
|
|
52
|
-
/** Hard cap per opener process: a hung COM call must not hang the RPC forever. */
|
|
53
|
-
const OPEN_TIMEOUT_MS = 10000;
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Durable DSTT (DeepSeekStyleTheme) preference section, registered into the
|
|
57
|
-
* host settings document exactly like the product's own namespaces
|
|
58
|
-
* (ui-theme/locale/ui-conversation): the client half binds this namespace
|
|
59
|
-
* through `settingsScope` and reads/writes the preference fields.
|
|
60
|
-
* A single `mode` enum drives the whole color story:
|
|
61
|
-
* - peakvalley-redblue → vivid red (鲜红) at peak hours, blue at valley;
|
|
62
|
-
* - peakvalley-redgreen → vivid red (鲜红) at peak hours, green at valley;
|
|
63
|
-
* - always-green → always green, no peak distinction;
|
|
64
|
-
* - always-blue → always blue, no peak distinction.
|
|
65
|
-
*/
|
|
66
|
-
// `settingsNamespace` from @deepseek-ai/dsh-settings was deleted in dsh
|
|
67
|
-
// 0.1.2-alpha.1 (the settings service itself is unchanged - register /
|
|
68
|
-
// describe / mutate below still take the namespace string), so this is now
|
|
69
|
-
// a plain literal. Keep it in sync with the client half's DSTT_NS.
|
|
70
|
-
const DSTT_SETTINGS_NS = "deepseek-style-theme";
|
|
71
|
-
const DSTT_MODES = ["peakvalley-redblue", "peakvalley-redgreen", "always-green", "always-blue"];
|
|
72
|
-
const DSTT_MODE_DEFAULT = "peakvalley-redblue";
|
|
73
|
-
// 1.37.x persisted `auto` / `blue` / `green`. The schema still accepts them —
|
|
74
|
-
// otherwise register() validates the stored document and throws, killing DSTT
|
|
75
|
-
// persistence on upgrade — and apply() migrates them to the closest four-mode
|
|
76
|
-
// value right after registration.
|
|
77
|
-
const DSTT_LEGACY_MODES = { auto: "peakvalley-redblue", blue: "always-blue", green: "always-green" };
|
|
78
|
-
/**
|
|
79
|
-
* Catalog-sync policies, most automatic first:
|
|
80
|
-
* - auto → align the catalog with the endpoint, but only while every
|
|
81
|
-
* advertised id is one this plugin can describe (see syncDeepseekCatalog);
|
|
82
|
-
* - add → append-only: adopt newly advertised ids, remove nothing ever;
|
|
83
|
-
* - off → probe and report drift, write nothing.
|
|
84
|
-
*/
|
|
85
|
-
const CATALOG_SYNC_POLICIES = ["auto", "add", "off"];
|
|
86
|
-
const CATALOG_SYNC_DEFAULT = "auto";
|
|
87
|
-
// Glass recipes. `liquid` is the Apple-style lens (edge refraction, a flowing
|
|
88
|
-
// edge highlight, high translucency); `frosted` is the white frosted pane with
|
|
89
|
-
// the brand-tinted shifting border this theme shipped before, kept byte for byte
|
|
90
|
-
// because that is the look the user picked. The client mirrors the choice onto
|
|
91
|
-
// <html data-dshome-glass> so one stylesheet can gate both recipes.
|
|
92
|
-
const GLASS_STYLES = ["liquid", "frosted"];
|
|
93
|
-
const GLASS_STYLE_DEFAULT = "liquid";
|
|
94
|
-
// How wide the composer's edge refraction is under the liquid recipe, measured
|
|
95
|
-
// on the top and bottom edges -- the only two where displacing the backdrop has
|
|
96
|
-
// anything to show, because the card abuts the 任务 bar above and the status line
|
|
97
|
-
// below, while left and right sit over a continuous background. `narrow` is an
|
|
98
|
-
// 8px band, `wide` is 16px, `off` drops the dispersion from the card entirely.
|
|
99
|
-
const COMPOSER_REFRACTIONS = ["narrow", "wide", "off"];
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// the
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
z.const("
|
|
131
|
-
z.const("
|
|
132
|
-
z.const("
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
z.const("
|
|
141
|
-
z.const("
|
|
142
|
-
]).default(
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
z.const("
|
|
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
|
-
case "
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
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
|
-
const
|
|
374
|
-
if (
|
|
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
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (
|
|
452
|
-
const
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
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
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (
|
|
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
|
-
return
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
function
|
|
673
|
-
|
|
674
|
-
if (
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
if (
|
|
683
|
-
return "image/
|
|
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
|
-
res.writeHead(
|
|
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
|
-
if (
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
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
|
-
sendJson(res,
|
|
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
|
-
if (!
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
try {
|
|
1058
|
-
|
|
1059
|
-
} catch (error) {
|
|
1060
|
-
return false;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1
|
+
// Host side of the DeepSeek-style theme plugin.
|
|
2
|
+
// Registers a Package-private RPC channel the browser half uses to open a
|
|
3
|
+
// workspace's directory in the OS file manager and bring the window to the
|
|
4
|
+
// foreground. The built-in host.openPath (Invoke-Item) leaves the Explorer
|
|
5
|
+
// window in the background when spawned from this windowless service process,
|
|
6
|
+
// so this plugin owns the whole "open workspace" gesture instead. The same
|
|
7
|
+
// channel carries the delivered-file gestures the browser half's card menu
|
|
8
|
+
// needs: open with the default application, and reveal in the file manager.
|
|
9
|
+
// It also owns the DSTT settings section and, once per activation, aligns the
|
|
10
|
+
// official DeepSeek route's advisory model catalog with the ids the endpoint
|
|
11
|
+
// actually advertises (see the catalog-sync block below).
|
|
12
|
+
//
|
|
13
|
+
// Wire contract: every return is an RpcResult<T> per rpc.schema.js. Error
|
|
14
|
+
// codes must come from the closed enum (bad-request / internal) — an
|
|
15
|
+
// off-enum code would make the connection layer's serverResponseSchema.parse
|
|
16
|
+
// reject the frame and surface as a carrier failure instead of a clean error.
|
|
17
|
+
//
|
|
18
|
+
// The channel is fenced exactly like the core's own /api route: a loopback
|
|
19
|
+
// peer, an authority this server answers on, agreement between Host and any
|
|
20
|
+
// provenance headers, a JSON body, and local absolute paths only — see
|
|
21
|
+
// isTrustedBridgeRequest. Nothing here may widen that fence without widening
|
|
22
|
+
// the core's.
|
|
23
|
+
import { execFile } from "node:child_process";
|
|
24
|
+
import { readFile } from "node:fs/promises";
|
|
25
|
+
import { dirname, isAbsolute } from "node:path";
|
|
26
|
+
|
|
27
|
+
// Schemastery is this bundle's only runtime dependency, and it is loaded
|
|
28
|
+
// defensively. `settings.register()` genuinely needs a real schema — the
|
|
29
|
+
// settings service calls it as a function (`resolve`) and serializes it through
|
|
30
|
+
// `schema.toJSON()` plus a `redactSecrets` walk inside `describe()` — so a
|
|
31
|
+
// hand-rolled stand-in cannot work. What it must not do is take the whole
|
|
32
|
+
// plugin tree down when the package is unresolvable: with an empty profile
|
|
33
|
+
// (`autoInstallPeers: false`) or a `link:` install whose real path sits outside
|
|
34
|
+
// the profile, a bare `import` here fails the loader entry and the GUI refuses
|
|
35
|
+
// to start at all. The guarded dynamic import converts that into one lost
|
|
36
|
+
// preference.
|
|
37
|
+
let z = null;
|
|
38
|
+
try {
|
|
39
|
+
const schemastery = await import("@deepseek-ai/schemastery");
|
|
40
|
+
const schema = schemastery.default ?? null;
|
|
41
|
+
if (schema !== null && typeof schema.object === "function") z = schema;
|
|
42
|
+
} catch {
|
|
43
|
+
// Reported once, below, where the remedy is actionable.
|
|
44
|
+
}
|
|
45
|
+
if (z === null) {
|
|
46
|
+
console.warn(
|
|
47
|
+
"[deepseek-style-theme] @deepseek-ai/schemastery could not be loaded, so the DSTT mode will not persist (the theme itself still works). Install it beside the plugin: dsh plugin --profile web add @deepseek-ai/schemastery@^3.18.2"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CHANNEL = "/dshome-open-workspace";
|
|
52
|
+
/** Hard cap per opener process: a hung COM call must not hang the RPC forever. */
|
|
53
|
+
const OPEN_TIMEOUT_MS = 10000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Durable DSTT (DeepSeekStyleTheme) preference section, registered into the
|
|
57
|
+
* host settings document exactly like the product's own namespaces
|
|
58
|
+
* (ui-theme/locale/ui-conversation): the client half binds this namespace
|
|
59
|
+
* through `settingsScope` and reads/writes the preference fields.
|
|
60
|
+
* A single `mode` enum drives the whole color story:
|
|
61
|
+
* - peakvalley-redblue → vivid red (鲜红) at peak hours, blue at valley;
|
|
62
|
+
* - peakvalley-redgreen → vivid red (鲜红) at peak hours, green at valley;
|
|
63
|
+
* - always-green → always green, no peak distinction;
|
|
64
|
+
* - always-blue → always blue, no peak distinction.
|
|
65
|
+
*/
|
|
66
|
+
// `settingsNamespace` from @deepseek-ai/dsh-settings was deleted in dsh
|
|
67
|
+
// 0.1.2-alpha.1 (the settings service itself is unchanged - register /
|
|
68
|
+
// describe / mutate below still take the namespace string), so this is now
|
|
69
|
+
// a plain literal. Keep it in sync with the client half's DSTT_NS.
|
|
70
|
+
const DSTT_SETTINGS_NS = "deepseek-style-theme";
|
|
71
|
+
const DSTT_MODES = ["peakvalley-redblue", "peakvalley-redgreen", "always-green", "always-blue"];
|
|
72
|
+
const DSTT_MODE_DEFAULT = "peakvalley-redblue";
|
|
73
|
+
// 1.37.x persisted `auto` / `blue` / `green`. The schema still accepts them —
|
|
74
|
+
// otherwise register() validates the stored document and throws, killing DSTT
|
|
75
|
+
// persistence on upgrade — and apply() migrates them to the closest four-mode
|
|
76
|
+
// value right after registration.
|
|
77
|
+
const DSTT_LEGACY_MODES = { auto: "peakvalley-redblue", blue: "always-blue", green: "always-green" };
|
|
78
|
+
/**
|
|
79
|
+
* Catalog-sync policies, most automatic first:
|
|
80
|
+
* - auto → align the catalog with the endpoint, but only while every
|
|
81
|
+
* advertised id is one this plugin can describe (see syncDeepseekCatalog);
|
|
82
|
+
* - add → append-only: adopt newly advertised ids, remove nothing ever;
|
|
83
|
+
* - off → probe and report drift, write nothing.
|
|
84
|
+
*/
|
|
85
|
+
const CATALOG_SYNC_POLICIES = ["auto", "add", "off"];
|
|
86
|
+
const CATALOG_SYNC_DEFAULT = "auto";
|
|
87
|
+
// Glass recipes. `liquid` is the Apple-style lens (edge refraction, a flowing
|
|
88
|
+
// edge highlight, high translucency); `frosted` is the white frosted pane with
|
|
89
|
+
// the brand-tinted shifting border this theme shipped before, kept byte for byte
|
|
90
|
+
// because that is the look the user picked. The client mirrors the choice onto
|
|
91
|
+
// <html data-dshome-glass> so one stylesheet can gate both recipes.
|
|
92
|
+
const GLASS_STYLES = ["liquid", "frosted"];
|
|
93
|
+
const GLASS_STYLE_DEFAULT = "liquid";
|
|
94
|
+
// How wide the composer's edge refraction is under the liquid recipe, measured
|
|
95
|
+
// on the top and bottom edges -- the only two where displacing the backdrop has
|
|
96
|
+
// anything to show, because the card abuts the 任务 bar above and the status line
|
|
97
|
+
// below, while left and right sit over a continuous background. `narrow` is an
|
|
98
|
+
// 8px band, `wide` is 16px, `off` drops the dispersion from the card entirely.
|
|
99
|
+
const COMPOSER_REFRACTIONS = ["narrow", "wide", "off"];
|
|
100
|
+
// `narrow` was the default for as long as the refraction could not mount at all
|
|
101
|
+
// (ATTR was missing from 1.43.6 to 2.0.82), so nobody ever saw it -- and the first
|
|
102
|
+
// build that mounted it (2.0.84) did not look like refraction: over a gradient
|
|
103
|
+
// background the tint washed the whole pane ("我的玻璃怎么变成凝胶了"), and the
|
|
104
|
+
// SVG filter in the backdrop chain took the blur down with it ("没有背景模糊效果").
|
|
105
|
+
// So the default is `off`, the two bands are there to be tried deliberately, and
|
|
106
|
+
// `off` now also means "do not mount the filter chain at all" (see
|
|
107
|
+
// startGlassExtras in lib/client.js): a feature that has never rendered for anyone
|
|
108
|
+
// must not be able to degrade the glass.
|
|
109
|
+
const COMPOSER_REFRACTION_DEFAULT = "off";
|
|
110
|
+
// Background recipes. `classic` is the 1.43.11 palette byte for byte -- the
|
|
111
|
+
// soft lead plus pure white plus a near-white tint, i.e. the look the user
|
|
112
|
+
// called "buttons green, background green" -- and it is the default again.
|
|
113
|
+
// `white` is the same soft lead with pure white only. `custom` hands the page
|
|
114
|
+
// background to the user's own value: the colour token then drives the brand
|
|
115
|
+
// tokens (the buttons) and nothing else, and a dark scrim keeps the text
|
|
116
|
+
// readable in the dark scheme. `bold` is the 1.43.12 three-chroma set, which
|
|
117
|
+
// the user asked to keep selectable.
|
|
118
|
+
const BACKGROUND_MODES = ["classic", "white", "custom", "bold"];
|
|
119
|
+
const BACKGROUND_MODE_DEFAULT = "classic";
|
|
120
|
+
// A custom background is an image URL or any CSS background value. The browser
|
|
121
|
+
// owns validity -- the client classifies colour vs image with CSS.supports()
|
|
122
|
+
// and falls back to the recipe's own background when nothing is usable -- so
|
|
123
|
+
// the host only bounds the length and strips newlines and control characters,
|
|
124
|
+
// which would only ever be an accident in a settings file.
|
|
125
|
+
const CUSTOM_BACKGROUND_MAX = 2000;
|
|
126
|
+
// `null` when Schemastery failed to load: registration is then skipped and the
|
|
127
|
+
// DSTT mode simply stops persisting instead of failing the plugin tree.
|
|
128
|
+
const DsttSettingsSchema = z === null ? null : z.object({
|
|
129
|
+
mode: z.union([
|
|
130
|
+
z.const("peakvalley-redblue"),
|
|
131
|
+
z.const("peakvalley-redgreen"),
|
|
132
|
+
z.const("always-green"),
|
|
133
|
+
z.const("always-blue"),
|
|
134
|
+
z.const("auto"),
|
|
135
|
+
z.const("blue"),
|
|
136
|
+
z.const("green")
|
|
137
|
+
]).default(DSTT_MODE_DEFAULT),
|
|
138
|
+
catalogSync: z.union([
|
|
139
|
+
z.const("auto"),
|
|
140
|
+
z.const("add"),
|
|
141
|
+
z.const("off")
|
|
142
|
+
]).default(CATALOG_SYNC_DEFAULT),
|
|
143
|
+
// The fluid background's pointer brush writes a velocity wake into the flow
|
|
144
|
+
// field wherever the cursor goes. It is both the most expensive part of the
|
|
145
|
+
// simulation and the most intrusive visually, so it ships OFF; this setting
|
|
146
|
+
// exists only to turn it back on.
|
|
147
|
+
fluidBrush: z.boolean().default(false),
|
|
148
|
+
glassStyle: z.union([
|
|
149
|
+
z.const("liquid"),
|
|
150
|
+
z.const("frosted")
|
|
151
|
+
]).default(GLASS_STYLE_DEFAULT),
|
|
152
|
+
// Both ids are listed on purpose, and dropping either one breaks an upgrade:
|
|
153
|
+
// `wide` is what the write path accepts today, and `origin` is what a settings
|
|
154
|
+
// file written before the rename holds -- register() validates the STORED
|
|
155
|
+
// document, so an id the schema does not know throws there and takes DSTT
|
|
156
|
+
// persistence down with it (the same trap the legacy mode ids above exist for).
|
|
157
|
+
// The invariant: every value the write path allows must pass this schema.
|
|
158
|
+
composerRefraction: z.union([
|
|
159
|
+
z.const("narrow"),
|
|
160
|
+
z.const("wide"),
|
|
161
|
+
z.const("origin"),
|
|
162
|
+
z.const("off")
|
|
163
|
+
]).default(COMPOSER_REFRACTION_DEFAULT),
|
|
164
|
+
// The `blur()` term in every glass surface's backdrop chain. Turning it off
|
|
165
|
+
// keeps the translucency, the sheen and the edge refraction and drops only
|
|
166
|
+
// the frosting -- implemented as four variables driven to 0px, so nothing
|
|
167
|
+
// else about the recipes moves.
|
|
168
|
+
backdropBlur: z.boolean().default(true),
|
|
169
|
+
// The animated background layer itself: the WebGL2 fluid, or the 2D particle
|
|
170
|
+
// field on engines without it. Off leaves the static themed background (the
|
|
171
|
+
// per-colour gradients, or the user's own 自选背景) and changes nothing else.
|
|
172
|
+
// It exists because some integrated GPUs render the simulation wrong -- an
|
|
173
|
+
// Intel iGPU under ANGLE/D3D11 turned the noise into drifting squares even
|
|
174
|
+
// after the mediump fix -- and for those machines a switch beats a fight.
|
|
175
|
+
ambientBackground: z.boolean().default(true),
|
|
176
|
+
// Every id the write path accepts is listed here for the reason spelled out
|
|
177
|
+
// on composerRefraction above: register() validates the stored document, so
|
|
178
|
+
// an id the schema does not know throws there and takes DSTT persistence
|
|
179
|
+
// down with it.
|
|
180
|
+
backgroundMode: z.union([
|
|
181
|
+
z.const("classic"),
|
|
182
|
+
z.const("white"),
|
|
183
|
+
z.const("custom"),
|
|
184
|
+
z.const("bold")
|
|
185
|
+
]).default(BACKGROUND_MODE_DEFAULT),
|
|
186
|
+
customBackground: z.string().default("")
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/** Map a legacy 1.37.x mode id onto a four-mode id, or null when nothing to do. */
|
|
190
|
+
function migrateLegacyMode(mode) {
|
|
191
|
+
return typeof mode === "string" && Object.prototype.hasOwnProperty.call(DSTT_LEGACY_MODES, mode)
|
|
192
|
+
? DSTT_LEGACY_MODES[mode]
|
|
193
|
+
: null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
|
197
|
+
function powershellLiteral(path) {
|
|
198
|
+
return `'${path.replace(/'/g, "''")}'`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Open one path with Shell.Application, then focus the matching Explorer
|
|
203
|
+
* window. The open step is strict: any COM failure exits the script non-zero
|
|
204
|
+
* so the caller reports a real failure instead of a false success. Focusing
|
|
205
|
+
* stays best-effort — a directory that opened but could not be focused is
|
|
206
|
+
* still a successful open.
|
|
207
|
+
*/
|
|
208
|
+
function openExplorerForeground(path) {
|
|
209
|
+
return new Promise((resolve) => {
|
|
210
|
+
const script = [
|
|
211
|
+
"$ErrorActionPreference = 'Stop'",
|
|
212
|
+
`$p = ${powershellLiteral(path)}`,
|
|
213
|
+
"$sh = New-Object -ComObject Shell.Application",
|
|
214
|
+
"try { $sh.Open($p) } catch { exit 1 }",
|
|
215
|
+
"Start-Sleep -Milliseconds 500",
|
|
216
|
+
"$f = $false",
|
|
217
|
+
"foreach ($w in @($sh.Windows())) {",
|
|
218
|
+
" try { if ($w.Document.Folder.Self.Path -ieq $p) { $null = $w.Focus(); $f = $true; break } } catch {}",
|
|
219
|
+
"}",
|
|
220
|
+
"if (-not $f) { $null = (New-Object -ComObject WScript.Shell).AppActivate((Split-Path $p -Leaf)) }"
|
|
221
|
+
].join("; ");
|
|
222
|
+
execFile("powershell.exe", ["-NoProfile", "-Command", script], { windowsHide: true, timeout: OPEN_TIMEOUT_MS }, (error) => {
|
|
223
|
+
resolve(error === null || error === undefined);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Run a simple command and resolve with whether it exited cleanly.
|
|
230
|
+
* `lenient` counts a numeric exit status as success: some platform tools
|
|
231
|
+
* (notably `explorer.exe`, which returns 1 after a successful `/select`) report
|
|
232
|
+
* failure while having done the work, so only a spawn failure is a failure.
|
|
233
|
+
*/
|
|
234
|
+
function runOpener(command, args, lenient = false) {
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
execFile(command, args, { windowsHide: true, timeout: OPEN_TIMEOUT_MS }, (error) => {
|
|
237
|
+
if (error === null || error === undefined) {
|
|
238
|
+
resolve(true);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
resolve(lenient && typeof error.code === "number");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Open a directory in the platform's default file manager. */
|
|
247
|
+
function openPathGeneric(path) {
|
|
248
|
+
switch (process.platform) {
|
|
249
|
+
case "win32":
|
|
250
|
+
return openExplorerForeground(path);
|
|
251
|
+
case "darwin":
|
|
252
|
+
return runOpener("open", [path]);
|
|
253
|
+
case "linux":
|
|
254
|
+
return runOpener("xdg-open", [path]);
|
|
255
|
+
default:
|
|
256
|
+
return Promise.resolve(false);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Open one file with the OS default application. Deliberately not
|
|
262
|
+
* {@link openPathGeneric}: on Windows `Shell.Application.Open` on a file is the
|
|
263
|
+
* shell's own gesture, while `Start-Process` hands the path to the registered
|
|
264
|
+
* handler, which is what "用默认应用打开" means.
|
|
265
|
+
*/
|
|
266
|
+
function openFileGeneric(path) {
|
|
267
|
+
switch (process.platform) {
|
|
268
|
+
case "win32":
|
|
269
|
+
// `-FilePath`, not `-LiteralPath`: Start-Process has no -LiteralPath
|
|
270
|
+
// parameter (Windows PowerShell 5.1 rejects it with
|
|
271
|
+
// NamedParameterNotFound), so the literal quoting comes from
|
|
272
|
+
// powershellLiteral alone. Start-Process resolves through
|
|
273
|
+
// ShellExecute, which is what invokes the registered default handler.
|
|
274
|
+
return runOpener("powershell.exe", ["-NoProfile", "-Command", `Start-Process -FilePath ${powershellLiteral(path)}`]);
|
|
275
|
+
case "darwin":
|
|
276
|
+
return runOpener("open", [path]);
|
|
277
|
+
case "linux":
|
|
278
|
+
return runOpener("xdg-open", [path]);
|
|
279
|
+
default:
|
|
280
|
+
return Promise.resolve(false);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Reveal one file in the platform's file manager, selected where supported. */
|
|
285
|
+
function revealFileGeneric(path) {
|
|
286
|
+
switch (process.platform) {
|
|
287
|
+
case "win32":
|
|
288
|
+
return runOpener("explorer.exe", ["/select," + path], true);
|
|
289
|
+
case "darwin":
|
|
290
|
+
return runOpener("open", ["-R", path]);
|
|
291
|
+
case "linux":
|
|
292
|
+
return runOpener("xdg-open", [dirname(path)]);
|
|
293
|
+
default:
|
|
294
|
+
return Promise.resolve(false);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Uniform RpcResult failure branch. */
|
|
299
|
+
function failure(code, message, details) {
|
|
300
|
+
return { ok: false, error: { code, message, details } };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Advisory model-catalog sync for the official DeepSeek route.
|
|
305
|
+
*
|
|
306
|
+
* `dsh-llm-deepseek` deliberately never probes its gateway — `listModels()`
|
|
307
|
+
* returns the declared catalog and the README states the defaults are published
|
|
308
|
+
* "without probing gateway availability" — so the composer's model selector can
|
|
309
|
+
* drift from what the endpoint actually serves, and nothing in the harness would
|
|
310
|
+
* ever notice. On activation this plugin therefore asks the endpoint which ids
|
|
311
|
+
* it advertises (the same call the Models page makes: GET {baseURL}/models) and
|
|
312
|
+
* aligns `llm-deepseek.models` with the answer.
|
|
313
|
+
*
|
|
314
|
+
* Two properties keep that from being a liability:
|
|
315
|
+
*
|
|
316
|
+
* 1. It never invents capability metadata. The endpoint reports ids only, so
|
|
317
|
+
* it cannot supply `inputModalities`, `systemPromptUpdate` or a context
|
|
318
|
+
* window. Entries that already exist are preserved verbatim and new ids are
|
|
319
|
+
* filled from KNOWN_CATALOG_ENTRIES; an id this plugin cannot describe is
|
|
320
|
+
* reported rather than guessed, because a guessed entry silently downgrades
|
|
321
|
+
* a vision model to text-only.
|
|
322
|
+
* 2. It only rewrites the list wholesale while every advertised id is
|
|
323
|
+
* describable. An official route advertises a handful of DeepSeek ids; an
|
|
324
|
+
* aggregating gateway in front of `baseURL` advertises its entire
|
|
325
|
+
* catalogue, and "the endpoint is authoritative" would then replace a
|
|
326
|
+
* curated two-entry catalog with hundreds of unrelated rows. A
|
|
327
|
+
* foreign-looking answer degrades to append-only plus a warning.
|
|
328
|
+
*
|
|
329
|
+
* Every failure mode (no settings, no credential, offline gateway, renamed
|
|
330
|
+
* service, concurrent settings edit) is a no-op for the theme — a skin must
|
|
331
|
+
* never be able to break the model catalog — and it only writes on real drift.
|
|
332
|
+
* `catalogSync: "off"` probes and reports without ever writing.
|
|
333
|
+
*/
|
|
334
|
+
const LLM_DEEPSEEK_NS = "llm-deepseek";
|
|
335
|
+
/** Public endpoint; a deployment may point elsewhere through `$DEEPSEEK_BASE_URL`. */
|
|
336
|
+
const PUBLIC_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
337
|
+
const DEEPSEEK_BASE_URL_ENV = "DEEPSEEK_BASE_URL";
|
|
338
|
+
const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
339
|
+
const CATALOG_SYNC_TIMEOUT_MS = 5000;
|
|
340
|
+
/** Retry offsets (ms) while a boot-order prerequisite is still missing. */
|
|
341
|
+
const CATALOG_SYNC_RETRIES = [0, 2000, 6000, 15000];
|
|
342
|
+
/**
|
|
343
|
+
* Outcomes worth retrying: activation order is not ours to choose, so the
|
|
344
|
+
* provider may not have registered `llm-deepseek` yet and the credentials
|
|
345
|
+
* service may not be reachable at the instant this plugin activates. A failed
|
|
346
|
+
* or answered probe (in-sync / updated / endpoint-unreachable) is final.
|
|
347
|
+
*/
|
|
348
|
+
const CATALOG_SYNC_RETRYABLE = ["provider-absent", "no-credentials"];
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Capability metadata for ids this plugin knows by name. Only consulted for ids
|
|
352
|
+
* the endpoint advertises that the stored catalog does not describe yet; an
|
|
353
|
+
* existing entry is never overwritten, so a user's own edits survive.
|
|
354
|
+
*/
|
|
355
|
+
const KNOWN_CATALOG_ENTRIES = {
|
|
356
|
+
"deepseek-flash": {
|
|
357
|
+
name: "DeepSeek-V41-Flash",
|
|
358
|
+
contextWindow: 1000000,
|
|
359
|
+
inputModalities: ["text", "image"],
|
|
360
|
+
imagePixelBudget: 640000,
|
|
361
|
+
imageMaxBytes: 1048576,
|
|
362
|
+
systemPromptUpdate: "in-history"
|
|
363
|
+
},
|
|
364
|
+
"deepseek-v4-pro": {
|
|
365
|
+
name: "DeepSeek-V4-Pro",
|
|
366
|
+
contextWindow: 1000000,
|
|
367
|
+
inputModalities: ["text"]
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/** One registered namespace's descriptor (resolved value plus revision), or null. */
|
|
372
|
+
function settingsDescriptor(ctx, ns) {
|
|
373
|
+
const settings = ctx.get("settings");
|
|
374
|
+
if (settings === undefined || settings === null || typeof settings.describe !== "function") return null;
|
|
375
|
+
let descriptors;
|
|
376
|
+
try {
|
|
377
|
+
descriptors = settings.describe({ redactSecrets: true });
|
|
378
|
+
} catch (error) {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
if (!Array.isArray(descriptors)) return null;
|
|
382
|
+
const descriptor = descriptors.find((candidate) => String(candidate.ns) === ns);
|
|
383
|
+
if (descriptor === undefined || descriptor === null) return null;
|
|
384
|
+
const value = descriptor.value;
|
|
385
|
+
if (value === null || typeof value !== "object") return null;
|
|
386
|
+
return { value, revision: descriptor.revision };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The resolved `llm-deepseek` settings section, or null while it is absent. */
|
|
390
|
+
function llmDeepseekSection(ctx) {
|
|
391
|
+
return settingsDescriptor(ctx, LLM_DEEPSEEK_NS);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The configured catalog-sync policy, falling back to the schema default. */
|
|
395
|
+
function catalogSyncPolicy(ctx) {
|
|
396
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
397
|
+
const policy = descriptor === null ? undefined : descriptor.value.catalogSync;
|
|
398
|
+
return CATALOG_SYNC_POLICIES.includes(policy) ? policy : CATALOG_SYNC_DEFAULT;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The endpoint the official adapter itself would dial, resolved in that
|
|
403
|
+
* adapter's own order (`dsh-llm-deepseek` resolves `config.baseURL ??
|
|
404
|
+
* launchEnvironment.get("DEEPSEEK_BASE_URL") ?? "https://api.deepseek.com"`).
|
|
405
|
+
* Falling straight back to the public host would post the resolved credential to
|
|
406
|
+
* api.deepseek.com on a deployment whose gateway lives behind that variable —
|
|
407
|
+
* and that credential may be a gateway token, not a DeepSeek key. The service's
|
|
408
|
+
* own fallback is a raw `process.env` snapshot, so consulting `process.env` only
|
|
409
|
+
* while the service is absent matches the adapter exactly instead of widening
|
|
410
|
+
* trust.
|
|
411
|
+
*/
|
|
412
|
+
function resolveBaseURL(ctx, section) {
|
|
413
|
+
if (typeof section.baseURL === "string" && section.baseURL !== "") return section.baseURL;
|
|
414
|
+
const environment = ctx.get("launchEnvironment");
|
|
415
|
+
if (environment === undefined || environment === null || typeof environment.get !== "function") {
|
|
416
|
+
const ambient = process.env[DEEPSEEK_BASE_URL_ENV];
|
|
417
|
+
return typeof ambient === "string" && ambient !== "" ? ambient : PUBLIC_DEEPSEEK_BASE_URL;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const hit = environment.get(DEEPSEEK_BASE_URL_ENV);
|
|
421
|
+
const value = hit === undefined || hit === null ? undefined : hit.value;
|
|
422
|
+
if (typeof value === "string" && value !== "") return value;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
// Fall through to the public endpoint, exactly like the adapter does.
|
|
425
|
+
}
|
|
426
|
+
return PUBLIC_DEEPSEEK_BASE_URL;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Resolve the provider API key the same way `dsh-llm-deepseek` does: through the
|
|
431
|
+
* credentials service under the configured `apiKeyEnv`, falling back to the
|
|
432
|
+
* ambient environment of the launching process.
|
|
433
|
+
*/
|
|
434
|
+
async function resolveProviderKey(ctx, apiKeyEnv) {
|
|
435
|
+
const ref = typeof apiKeyEnv === "string" && apiKeyEnv !== "" ? apiKeyEnv : DEFAULT_API_KEY_ENV;
|
|
436
|
+
const credentials = ctx.get("credentials");
|
|
437
|
+
if (credentials !== undefined && credentials !== null && typeof credentials.resolve === "function") {
|
|
438
|
+
try {
|
|
439
|
+
const hit = await credentials.resolve(ref);
|
|
440
|
+
if (hit !== undefined && hit !== null && typeof hit.value === "string" && hit.value !== "") return hit.value;
|
|
441
|
+
} catch (error) {
|
|
442
|
+
// Fall through to the ambient environment.
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const ambient = typeof process !== "undefined" && process.env !== undefined ? process.env[ref] : undefined;
|
|
446
|
+
return typeof ambient === "string" && ambient !== "" ? ambient : null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Ask the endpoint which model ids it advertises, in endpoint order. */
|
|
450
|
+
async function fetchEndpointModelIds(baseURL, key) {
|
|
451
|
+
if (typeof fetch !== "function") return null;
|
|
452
|
+
const url = String(baseURL).replace(/\/+$/, "") + "/models";
|
|
453
|
+
const signal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
|
|
454
|
+
? AbortSignal.timeout(CATALOG_SYNC_TIMEOUT_MS)
|
|
455
|
+
: undefined;
|
|
456
|
+
const response = await fetch(url, {
|
|
457
|
+
headers: { authorization: "Bearer " + key, accept: "application/json" },
|
|
458
|
+
...(signal === undefined ? {} : { signal })
|
|
459
|
+
});
|
|
460
|
+
if (!response.ok) throw new Error("endpoint answered " + String(response.status));
|
|
461
|
+
const body = await response.json();
|
|
462
|
+
const rows = body !== null && typeof body === "object" && Array.isArray(body.data) ? body.data : [];
|
|
463
|
+
const ids = [];
|
|
464
|
+
for (const row of rows) {
|
|
465
|
+
const id = row !== null && typeof row === "object" && typeof row.id === "string" ? row.id.trim() : "";
|
|
466
|
+
if (id !== "" && !ids.includes(id)) ids.push(id);
|
|
467
|
+
}
|
|
468
|
+
return ids;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** One catalog entry for an id the stored catalog does not describe yet. */
|
|
472
|
+
function catalogEntryFor(id) {
|
|
473
|
+
const known = Object.prototype.hasOwnProperty.call(KNOWN_CATALOG_ENTRIES, id) ? KNOWN_CATALOG_ENTRIES[id] : undefined;
|
|
474
|
+
return known === undefined ? { id, name: id, inputModalities: ["text"] } : { id, ...known };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Whether this plugin can describe an id without inventing capability metadata. */
|
|
478
|
+
function isDescribable(id) {
|
|
479
|
+
return Object.prototype.hasOwnProperty.call(KNOWN_CATALOG_ENTRIES, id);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The ids of a catalog array, in order, ignoring malformed entries. */
|
|
483
|
+
function catalogIds(entries) {
|
|
484
|
+
return entries
|
|
485
|
+
.map((entry) => (entry !== null && typeof entry === "object" && typeof entry.id === "string" ? entry.id : ""))
|
|
486
|
+
.filter((id) => id !== "");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Align the stored catalog with the endpoint's id list, preserving surviving
|
|
491
|
+
* entries verbatim. Returns one of: provider-absent | no-credentials |
|
|
492
|
+
* endpoint-unreachable | in-sync | updated | conflict.
|
|
493
|
+
*/
|
|
494
|
+
async function syncDeepseekCatalog(ctx) {
|
|
495
|
+
const section = llmDeepseekSection(ctx);
|
|
496
|
+
if (section === null) return "provider-absent";
|
|
497
|
+
const settings = ctx.get("settings");
|
|
498
|
+
if (settings === undefined || settings === null || typeof settings.mutate !== "function") return "provider-absent";
|
|
499
|
+
const policy = catalogSyncPolicy(ctx);
|
|
500
|
+
const baseURL = resolveBaseURL(ctx, section.value);
|
|
501
|
+
const key = await resolveProviderKey(ctx, section.value.apiKeyEnv);
|
|
502
|
+
if (key === null) return "no-credentials";
|
|
503
|
+
let ids;
|
|
504
|
+
try {
|
|
505
|
+
ids = await fetchEndpointModelIds(baseURL, key);
|
|
506
|
+
} catch (error) {
|
|
507
|
+
return "endpoint-unreachable";
|
|
508
|
+
}
|
|
509
|
+
if (ids === null || ids.length === 0) return "endpoint-unreachable";
|
|
510
|
+
const current = Array.isArray(section.value.models) ? section.value.models : [];
|
|
511
|
+
const byId = new Map();
|
|
512
|
+
for (const entry of current) {
|
|
513
|
+
if (entry !== null && typeof entry === "object" && typeof entry.id === "string") byId.set(entry.id, entry);
|
|
514
|
+
}
|
|
515
|
+
const currentIds = catalogIds(current);
|
|
516
|
+
// Advertised ids this plugin knows nothing about: reported, never guessed.
|
|
517
|
+
const undescribed = ids.filter((id) => !byId.has(id) && !isDescribable(id));
|
|
518
|
+
|
|
519
|
+
if (policy === "off") {
|
|
520
|
+
const missing = ids.filter((id) => !byId.has(id));
|
|
521
|
+
const stale = currentIds.filter((id) => !ids.includes(id));
|
|
522
|
+
if (missing.length === 0 && stale.length === 0) return "in-sync";
|
|
523
|
+
console.warn(
|
|
524
|
+
`[deepseek-style-theme] model catalog drift at ${baseURL} (catalogSync is "off", nothing written) — endpoint adds: ${missing.join(", ") || "none"}; endpoint no longer lists: ${stale.join(", ") || "none"}`
|
|
525
|
+
);
|
|
526
|
+
return "in-sync";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// A wholesale rewrite is only safe while every advertised id is one this
|
|
530
|
+
// plugin can describe; otherwise the answer looks like a gateway catalogue
|
|
531
|
+
// rather than the official route, and aligning to it would replace a curated
|
|
532
|
+
// catalog with unrelated rows.
|
|
533
|
+
const rewrites = policy === "auto" && undescribed.length === 0;
|
|
534
|
+
let models;
|
|
535
|
+
if (rewrites) {
|
|
536
|
+
if (currentIds.length === ids.length && currentIds.every((id, index) => id === ids[index])) return "in-sync";
|
|
537
|
+
models = ids.map((id) => (byId.has(id) ? byId.get(id) : catalogEntryFor(id)));
|
|
538
|
+
} else {
|
|
539
|
+
const added = ids.filter((id) => !byId.has(id) && isDescribable(id));
|
|
540
|
+
if (added.length === 0) {
|
|
541
|
+
if (undescribed.length > 0) {
|
|
542
|
+
console.warn(
|
|
543
|
+
`[deepseek-style-theme] ${baseURL} advertises ${undescribed.length} model id(s) this plugin cannot describe (${undescribed.slice(0, 5).join(", ")}); leaving llm-deepseek.models untouched rather than guessing their capabilities.`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
return "in-sync";
|
|
547
|
+
}
|
|
548
|
+
models = [...current, ...added.map((id) => catalogEntryFor(id))];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const removed = currentIds.filter((id) => !models.some((entry) => entry.id === id));
|
|
552
|
+
try {
|
|
553
|
+
// Pinned to the revision this decision was read from: a settings edit the
|
|
554
|
+
// user made in the meantime wins, and this activation simply does not sync.
|
|
555
|
+
await settings.mutate(LLM_DEEPSEEK_NS, [{ op: "set", path: ["models"], value: models }], section.revision);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
return "conflict";
|
|
558
|
+
}
|
|
559
|
+
if (removed.length > 0) {
|
|
560
|
+
console.warn(
|
|
561
|
+
`[deepseek-style-theme] model catalog synced from ${baseURL}; removed id(s) the endpoint does not list: ${removed.join(", ")}`
|
|
562
|
+
);
|
|
563
|
+
} else {
|
|
564
|
+
console.info(`[deepseek-style-theme] model catalog synced from ${baseURL}`);
|
|
565
|
+
}
|
|
566
|
+
return "updated";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Run the catalog sync once per activation, retrying briefly while a boot-order
|
|
571
|
+
* prerequisite is still missing (the provider namespace or the credentials
|
|
572
|
+
* service). Disposal cancels any pending retry.
|
|
573
|
+
*/
|
|
574
|
+
function startCatalogSync(ctx) {
|
|
575
|
+
return ctx.effect(() => {
|
|
576
|
+
let cancelled = false;
|
|
577
|
+
let timer = null;
|
|
578
|
+
const attempt = (step) => {
|
|
579
|
+
if (cancelled) return;
|
|
580
|
+
syncDeepseekCatalog(ctx).then((outcome) => {
|
|
581
|
+
if (cancelled) return;
|
|
582
|
+
// "updated" already logged exactly what it changed where it changed it.
|
|
583
|
+
if (outcome === "updated") return;
|
|
584
|
+
if (CATALOG_SYNC_RETRYABLE.includes(outcome) && step + 1 < CATALOG_SYNC_RETRIES.length) {
|
|
585
|
+
timer = setTimeout(() => attempt(step + 1), CATALOG_SYNC_RETRIES[step + 1]);
|
|
586
|
+
}
|
|
587
|
+
}).catch(() => {});
|
|
588
|
+
};
|
|
589
|
+
attempt(0);
|
|
590
|
+
return () => {
|
|
591
|
+
cancelled = true;
|
|
592
|
+
if (timer !== null) clearTimeout(timer);
|
|
593
|
+
};
|
|
594
|
+
}, "deepseek-style-theme: model catalog sync");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* DSTT private-channel endpoints. The settings domain's RPC surface only
|
|
599
|
+
* serves namespaces in the core's hard-coded allowlist (`settings-not-exposed`
|
|
600
|
+
* otherwise), so the browser half reads/writes the DSTT preference through
|
|
601
|
+
* this plugin's own loopback channel, and the host half drives the settings
|
|
602
|
+
* service directly — the same shape as the explorer-open RPC above.
|
|
603
|
+
*/
|
|
604
|
+
const DSTT_GET = "dstt.mode.get";
|
|
605
|
+
const DSTT_SET = "dstt.mode.set";
|
|
606
|
+
/** Delivered-file gestures for the card menu patched by the browser half. */
|
|
607
|
+
const FILE_OPEN = "dshome/file.open";
|
|
608
|
+
const FILE_REVEAL = "dshome/file.reveal";
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Desktop wallpaper, resolved by the HOST half. A page cannot ask for the system
|
|
612
|
+
* wallpaper — there is no web API for it — so the browser half asks over this
|
|
613
|
+
* channel, and the bytes come back as a GET on the same fenced route (a CSS
|
|
614
|
+
* background image cannot carry a JSON body). Nothing about it is remote: the
|
|
615
|
+
* file is read on this machine and served to this page over loopback, and the
|
|
616
|
+
* endpoint takes no input at all, so the only file it can ever serve is the one
|
|
617
|
+
* the host itself resolved. Nothing is uploaded anywhere.
|
|
618
|
+
*/
|
|
619
|
+
const WALLPAPER_GET = "dshome/desktop.wallpaper";
|
|
620
|
+
const WALLPAPER_SUBPATH = "/wallpaper";
|
|
621
|
+
// Re-reading means spawning PowerShell, and a page load is the only trigger, so
|
|
622
|
+
// a short memo keeps a burst of reloads from spawning one process each. Ten
|
|
623
|
+
// seconds is far below how often anyone changes their wallpaper.
|
|
624
|
+
const WALLPAPER_MEMO_MS = 10000;
|
|
625
|
+
// Refuse pathological files rather than pushing hundreds of megabytes into the
|
|
626
|
+
// page: a 16K wallpaper is around 20 MB, and anything past this is not one.
|
|
627
|
+
const WALLPAPER_MAX_BYTES = 24 * 1024 * 1024;
|
|
628
|
+
let wallpaperMemo = { at: 0, has: false, value: null };
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Resolve the wallpaper Windows is currently showing, or null when this is not
|
|
632
|
+
* Windows or nothing could be read.
|
|
633
|
+
*
|
|
634
|
+
* `TranscodedWallpaper` is preferred over the registry path on purpose: Windows
|
|
635
|
+
* keeps a JPEG of whatever is actually on screen there — including the
|
|
636
|
+
* per-monitor crop, a slideshow frame and a solid colour — while
|
|
637
|
+
* `HKCU\Control Panel\Desktop\WallPaper` can be empty (slideshow, solid colour)
|
|
638
|
+
* or point at a file the user has since deleted. The solid colour, when there is
|
|
639
|
+
* no image at all, comes from `HKCU\Control Panel\Colors\Background`.
|
|
640
|
+
*/
|
|
641
|
+
function readDesktopWallpaper() {
|
|
642
|
+
if (process.platform !== "win32") return Promise.resolve(null);
|
|
643
|
+
const script = [
|
|
644
|
+
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
645
|
+
"$out = [ordered]@{ path = $null; source = $null; color = $null; style = 10; tile = 0 }",
|
|
646
|
+
"$desk = Get-ItemProperty -LiteralPath 'HKCU:\\Control Panel\\Desktop'",
|
|
647
|
+
"$transcoded = Join-Path $env:APPDATA 'Microsoft\\Windows\\Themes\\TranscodedWallpaper'",
|
|
648
|
+
"if ($transcoded -and (Test-Path -LiteralPath $transcoded)) { $out.path = $transcoded; $out.source = 'transcoded' }",
|
|
649
|
+
"elseif ($desk.WallPaper -and (Test-Path -LiteralPath $desk.WallPaper)) { $out.path = $desk.WallPaper; $out.source = 'registry' }",
|
|
650
|
+
"if ($null -ne $desk.WallpaperStyle) { $out.style = [int]$desk.WallpaperStyle }",
|
|
651
|
+
"if ($null -ne $desk.TileWallpaper) { $out.tile = [int]$desk.TileWallpaper }",
|
|
652
|
+
"if (-not $out.path) { $bg = (Get-ItemProperty -LiteralPath 'HKCU:\\Control Panel\\Colors').Background; if ($bg -match '^\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)') { $out.color = ('#{0:X2}{1:X2}{2:X2}' -f [int]$Matches[1], [int]$Matches[2], [int]$Matches[3]) } }",
|
|
653
|
+
"[pscustomobject]$out | ConvertTo-Json -Compress"
|
|
654
|
+
].join("; ");
|
|
655
|
+
return new Promise((resolve) => {
|
|
656
|
+
execFile("powershell.exe", ["-NoProfile", "-Command", script], { windowsHide: true, timeout: OPEN_TIMEOUT_MS }, (error, stdout) => {
|
|
657
|
+
if (error !== null && error !== undefined) {
|
|
658
|
+
resolve(null);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
const parsed = JSON.parse(String(stdout).trim());
|
|
663
|
+
resolve(parsed !== null && typeof parsed === "object" ? parsed : null);
|
|
664
|
+
} catch (parseError) {
|
|
665
|
+
resolve(null);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** The memoized wallpaper reading. */
|
|
672
|
+
async function desktopWallpaper() {
|
|
673
|
+
const now = Date.now();
|
|
674
|
+
if (wallpaperMemo.has && now - wallpaperMemo.at < WALLPAPER_MEMO_MS) return wallpaperMemo.value;
|
|
675
|
+
const value = await readDesktopWallpaper();
|
|
676
|
+
wallpaperMemo = { at: now, has: true, value };
|
|
677
|
+
return value;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Content type from the bytes first (the transcoded file has no extension), the path second. */
|
|
681
|
+
function wallpaperMime(data, path) {
|
|
682
|
+
if (data.length > 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) return "image/jpeg";
|
|
683
|
+
if (data.length > 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) return "image/png";
|
|
684
|
+
if (data.length > 12 && data.toString("ascii", 0, 4) === "RIFF" && data.toString("ascii", 8, 12) === "WEBP") return "image/webp";
|
|
685
|
+
if (data.length > 2 && data[0] === 0x42 && data[1] === 0x4d) return "image/bmp";
|
|
686
|
+
if (data.length > 4 && data.toString("ascii", 0, 4) === "GIF8") return "image/gif";
|
|
687
|
+
const lower = typeof path === "string" ? path.toLowerCase() : "";
|
|
688
|
+
if (lower.endsWith(".png")) return "image/png";
|
|
689
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
690
|
+
if (lower.endsWith(".bmp")) return "image/bmp";
|
|
691
|
+
if (lower.endsWith(".gif")) return "image/gif";
|
|
692
|
+
return "image/jpeg";
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Serve the resolved wallpaper bytes, or a status that says why not. */
|
|
696
|
+
async function sendWallpaper(res) {
|
|
697
|
+
try {
|
|
698
|
+
const info = await desktopWallpaper();
|
|
699
|
+
if (info === null || typeof info.path !== "string" || info.path === "") {
|
|
700
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
701
|
+
res.end("no desktop wallpaper image on this machine");
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const data = await readFile(info.path);
|
|
705
|
+
if (data.length === 0 || data.length > WALLPAPER_MAX_BYTES) {
|
|
706
|
+
res.writeHead(413, { "content-type": "text/plain; charset=utf-8" });
|
|
707
|
+
res.end("wallpaper file is empty or too large to serve");
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
res.writeHead(200, {
|
|
711
|
+
"content-type": wallpaperMime(data, info.path),
|
|
712
|
+
"content-length": String(data.length),
|
|
713
|
+
// No caching: the user may change their wallpaper and then reload. The
|
|
714
|
+
// memo above is what keeps the PowerShell spawn off the hot path.
|
|
715
|
+
"cache-control": "no-store"
|
|
716
|
+
});
|
|
717
|
+
res.end(data);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
720
|
+
res.end("wallpaper read failed");
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** The part of a request path that follows CHANNEL ("" for the channel itself). */
|
|
725
|
+
function subPathOf(req) {
|
|
726
|
+
const url = typeof req.url === "string" ? req.url : "";
|
|
727
|
+
const cut = url.indexOf("?");
|
|
728
|
+
const pathname = cut === -1 ? url : url.slice(0, cut);
|
|
729
|
+
return pathname.startsWith(CHANNEL) ? pathname.slice(CHANNEL.length) : "";
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Validate `payload.path` as a local absolute path, or return null. Every path
|
|
734
|
+
* endpoint shares this: a relative path, an embedded NUL, a UNC share or a
|
|
735
|
+
* device namespace is refused before it reaches any opener.
|
|
736
|
+
*/
|
|
737
|
+
function localPathOf(payload) {
|
|
738
|
+
const path = payload === null || payload === undefined || typeof payload !== "object" ? undefined : payload.path;
|
|
739
|
+
if (typeof path !== "string" || path.trim() === "" || path.indexOf("\0") !== -1 || !isAbsolute(path) || isNetworkPath(path)) return null;
|
|
740
|
+
return path;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Read the durable DSTT mode. Reports the schema default whenever the stored
|
|
745
|
+
* value is not one of the four modes, so the reply always honors the RPC
|
|
746
|
+
* contract — a legacy 1.37.x value is migrated right after registration, and
|
|
747
|
+
* the client rejects any off-enum answer anyway.
|
|
748
|
+
*/
|
|
749
|
+
function dsttRead(ctx) {
|
|
750
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
751
|
+
if (descriptor === null) return failure("internal", "settings service unavailable", {});
|
|
752
|
+
const stored = descriptor.value.mode;
|
|
753
|
+
return {
|
|
754
|
+
ok: true,
|
|
755
|
+
value: {
|
|
756
|
+
mode: DSTT_MODES.includes(stored) ? stored : DSTT_MODE_DEFAULT,
|
|
757
|
+
fluidBrush: descriptor.value.fluidBrush === true,
|
|
758
|
+
glassStyle: dsttGlassValue(ctx),
|
|
759
|
+
composerRefraction: dsttComposerValue(ctx),
|
|
760
|
+
backdropBlur: descriptor.value.backdropBlur !== false,
|
|
761
|
+
backgroundMode: dsttBackgroundValue(ctx),
|
|
762
|
+
customBackground: dsttCustomBackgroundValue(ctx),
|
|
763
|
+
ambientBackground: descriptor.value.ambientBackground !== false
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Write the durable DSTT preferences through the host settings service directly.
|
|
770
|
+
* `fluidBrush`, `glassStyle`, `composerRefraction`, `backgroundMode` and
|
|
771
|
+
* `customBackground` are all optional: when they carry a valid value the same
|
|
772
|
+
* write also sets them, so the settings panel can persist a choice without a
|
|
773
|
+
* second round trip. None of them is mandatory the way `mode` is — an older
|
|
774
|
+
* client that only knows about the mode keeps working.
|
|
775
|
+
*/
|
|
776
|
+
async function dsttWrite(ctx, mode, fluidBrush, glassStyle, composerRefraction, backdropBlur, backgroundMode, customBackground, ambientBackground) {
|
|
777
|
+
if (!DSTT_MODES.includes(mode)) {
|
|
778
|
+
return failure("bad-request", `mode must be one of ${DSTT_MODES.join("/")}`, { issues: [] });
|
|
779
|
+
}
|
|
780
|
+
const wantsGlass = GLASS_STYLES.includes(glassStyle);
|
|
781
|
+
const wantsComposer = COMPOSER_REFRACTIONS.includes(composerRefraction);
|
|
782
|
+
const wantsBackground = BACKGROUND_MODES.includes(backgroundMode);
|
|
783
|
+
const wantsCustom = typeof customBackground === "string";
|
|
784
|
+
const ops = [{ op: "set", path: ["mode"], value: mode }];
|
|
785
|
+
if (typeof fluidBrush === "boolean") ops.push({ op: "set", path: ["fluidBrush"], value: fluidBrush });
|
|
786
|
+
if (wantsGlass) ops.push({ op: "set", path: ["glassStyle"], value: glassStyle });
|
|
787
|
+
if (wantsComposer) ops.push({ op: "set", path: ["composerRefraction"], value: composerRefraction });
|
|
788
|
+
if (typeof backdropBlur === "boolean") ops.push({ op: "set", path: ["backdropBlur"], value: backdropBlur });
|
|
789
|
+
if (wantsBackground) ops.push({ op: "set", path: ["backgroundMode"], value: backgroundMode });
|
|
790
|
+
if (wantsCustom) ops.push({ op: "set", path: ["customBackground"], value: normalizeCustomBackground(customBackground) });
|
|
791
|
+
if (typeof ambientBackground === "boolean") ops.push({ op: "set", path: ["ambientBackground"], value: ambientBackground });
|
|
792
|
+
const settings = ctx.get("settings");
|
|
793
|
+
if (settings === undefined || settings === null || typeof settings.mutate !== "function") {
|
|
794
|
+
return failure("internal", "settings service unavailable", {});
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
await settings.mutate(DSTT_SETTINGS_NS, ops);
|
|
798
|
+
} catch (error) {
|
|
799
|
+
return failure("internal", error instanceof Error ? error.message : "settings write failed", {});
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
ok: true,
|
|
803
|
+
value: {
|
|
804
|
+
mode,
|
|
805
|
+
fluidBrush: typeof fluidBrush === "boolean" ? fluidBrush === true : dsttBrushValue(ctx),
|
|
806
|
+
glassStyle: wantsGlass ? glassStyle : dsttGlassValue(ctx),
|
|
807
|
+
composerRefraction: wantsComposer ? composerRefraction : dsttComposerValue(ctx),
|
|
808
|
+
backdropBlur: typeof backdropBlur === "boolean" ? backdropBlur : dsttBlurValue(ctx),
|
|
809
|
+
backgroundMode: wantsBackground ? backgroundMode : dsttBackgroundValue(ctx),
|
|
810
|
+
customBackground: wantsCustom ? normalizeCustomBackground(customBackground) : dsttCustomBackgroundValue(ctx),
|
|
811
|
+
ambientBackground: typeof ambientBackground === "boolean" ? ambientBackground : dsttAmbientValue(ctx)
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The stored background recipe, defaulting to the 1.43.11 palette. A missing or
|
|
818
|
+
* unrecognized value reads back as the default rather than as `undefined`, so
|
|
819
|
+
* the client always gets one of the four ids it can gate its stylesheet on.
|
|
820
|
+
*/
|
|
821
|
+
function dsttBackgroundValue(ctx) {
|
|
822
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
823
|
+
const stored = descriptor === null ? undefined : descriptor.value.backgroundMode;
|
|
824
|
+
return BACKGROUND_MODES.includes(stored) ? stored : BACKGROUND_MODE_DEFAULT;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Normalise a custom background for storage: trimmed, single-line and
|
|
829
|
+
* length-bounded. Whether the value is *usable* CSS is deliberately the
|
|
830
|
+
* client's call (it asks CSS.supports), never this half's — a wrong guess here
|
|
831
|
+
* would silently drop a value the browser would have accepted.
|
|
832
|
+
*/
|
|
833
|
+
function normalizeCustomBackground(value) {
|
|
834
|
+
if (typeof value !== "string") return "";
|
|
835
|
+
const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
836
|
+
return flat.length > CUSTOM_BACKGROUND_MAX ? flat.slice(0, CUSTOM_BACKGROUND_MAX) : flat;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** The stored custom background (empty string when nothing is set). */
|
|
840
|
+
function dsttCustomBackgroundValue(ctx) {
|
|
841
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
842
|
+
return normalizeCustomBackground(descriptor === null ? "" : descriptor.value.customBackground);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Whether the animated background layer may mount; on unless it was turned off. */
|
|
846
|
+
function dsttAmbientValue(ctx) {
|
|
847
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
848
|
+
return descriptor === null ? true : descriptor.value.ambientBackground !== false;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The stored backdrop-blur preference, defaulting to on. */
|
|
852
|
+
function dsttBlurValue(ctx) {
|
|
853
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
854
|
+
return descriptor === null ? true : descriptor.value.backdropBlur !== false;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* The stored composer-refraction width, defaulting to the 8px band. `origin` was
|
|
859
|
+
* this option's first id: it is still accepted by the schema so a settings file
|
|
860
|
+
* written before the rename keeps validating, and it reads back as `wide` so the
|
|
861
|
+
* client only ever sees one id per state.
|
|
862
|
+
*/
|
|
863
|
+
function dsttComposerValue(ctx) {
|
|
864
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
865
|
+
const stored = descriptor === null ? undefined : descriptor.value.composerRefraction;
|
|
866
|
+
if (stored === "origin") return "wide";
|
|
867
|
+
return COMPOSER_REFRACTIONS.includes(stored) ? stored : COMPOSER_REFRACTION_DEFAULT;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** The stored fluid-brush preference, defaulting to off. */
|
|
871
|
+
function dsttBrushValue(ctx) {
|
|
872
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
873
|
+
return descriptor === null ? false : descriptor.value.fluidBrush === true;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* The stored glass recipe. A missing or unrecognized value reads back as the
|
|
878
|
+
* default rather than as `undefined`, so the client always gets one of the two
|
|
879
|
+
* ids it can gate its stylesheet on.
|
|
880
|
+
*/
|
|
881
|
+
function dsttGlassValue(ctx) {
|
|
882
|
+
const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
|
|
883
|
+
const stored = descriptor === null ? undefined : descriptor.value.glassStyle;
|
|
884
|
+
return GLASS_STYLES.includes(stored) ? stored : GLASS_STYLE_DEFAULT;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function apply(ctx) {
|
|
888
|
+
// Both registrations below are best-effort on purpose: dsh ships breaking
|
|
889
|
+
// changes to the settings / connection APIs between releases, and a throw
|
|
890
|
+
// here fails this bundle's loader entry — with it the whole plugin tree,
|
|
891
|
+
// which is far worse than a theme that merely loses its persistence.
|
|
892
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
893
|
+
if (DsttSettingsSchema === null) {
|
|
894
|
+
console.warn("[deepseek-style-theme] DSTT settings not registered (Schemastery is missing, see above): the mode will not persist");
|
|
895
|
+
} else {
|
|
896
|
+
try {
|
|
897
|
+
settingsCtx.settings.register(DSTT_SETTINGS_NS, DsttSettingsSchema);
|
|
898
|
+
const descriptor = settingsDescriptor(settingsCtx, DSTT_SETTINGS_NS);
|
|
899
|
+
const stored = descriptor === null ? undefined : descriptor.value.mode;
|
|
900
|
+
const migrated = migrateLegacyMode(stored);
|
|
901
|
+
if (migrated !== null) {
|
|
902
|
+
settingsCtx.settings.mutate(DSTT_SETTINGS_NS, [{ op: "set", path: ["mode"], value: migrated }])
|
|
903
|
+
.then(() => console.warn(`[deepseek-style-theme] migrated DSTT mode "${stored}" -> "${migrated}"`))
|
|
904
|
+
.catch(() => {});
|
|
905
|
+
}
|
|
906
|
+
} catch (error) {
|
|
907
|
+
console.warn("[deepseek-style-theme] settings.register failed (DSTT mode will not persist):", error);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
// Activation-time, best-effort, silent unless it really changed something.
|
|
911
|
+
try {
|
|
912
|
+
startCatalogSync(settingsCtx);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
console.warn("[deepseek-style-theme] catalog sync unavailable:", error);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// The private bridge is a plain webServer prefix route. dsh 0.1.5's
|
|
919
|
+
// `connection.rpc.handle()` registers its route through the *provider's* ctx,
|
|
920
|
+
// so it trips cordis' guard — `cannot get property "webServer" without
|
|
921
|
+
// inject` — no matter what this plugin declares in its own inject list.
|
|
922
|
+
// ctx.webServer is the same mechanism the shipped plugin console uses, and
|
|
923
|
+
// the browser half reaches it with a same-origin fetch.
|
|
924
|
+
const webServer = ctx.webServer;
|
|
925
|
+
if (webServer === undefined || webServer === null || typeof webServer.register !== "function") {
|
|
926
|
+
console.warn("[deepseek-style-theme] webServer unavailable: 打开工作区与模式持久化将不可用");
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const handler = async (endpoint, payload) => {
|
|
930
|
+
try {
|
|
931
|
+
if (endpoint === DSTT_GET) return dsttRead(ctx);
|
|
932
|
+
if (endpoint === DSTT_SET) {
|
|
933
|
+
const payloadObject = payload === null || payload === undefined || typeof payload !== "object" ? {} : payload;
|
|
934
|
+
return await dsttWrite(ctx, payloadObject.mode, payloadObject.fluidBrush, payloadObject.glassStyle, payloadObject.composerRefraction, payloadObject.backdropBlur, payloadObject.backgroundMode, payloadObject.customBackground, payloadObject.ambientBackground);
|
|
935
|
+
}
|
|
936
|
+
if (endpoint === WALLPAPER_GET) {
|
|
937
|
+
const info = await desktopWallpaper();
|
|
938
|
+
if (info === null) return { ok: true, value: { kind: "none" } };
|
|
939
|
+
if (typeof info.path === "string" && info.path !== "") {
|
|
940
|
+
return {
|
|
941
|
+
ok: true,
|
|
942
|
+
value: {
|
|
943
|
+
kind: "image",
|
|
944
|
+
url: CHANNEL + WALLPAPER_SUBPATH,
|
|
945
|
+
source: typeof info.source === "string" ? info.source : "unknown",
|
|
946
|
+
// WallpaperStyle: 10 fill, 6 fit, 2 stretch, 0 centre, 22 span.
|
|
947
|
+
style: typeof info.style === "number" ? info.style : 10,
|
|
948
|
+
tile: info.tile === 1
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
if (typeof info.color === "string" && info.color !== "") {
|
|
953
|
+
return { ok: true, value: { kind: "color", color: info.color } };
|
|
954
|
+
}
|
|
955
|
+
return { ok: true, value: { kind: "none" } };
|
|
956
|
+
}
|
|
957
|
+
if (endpoint === FILE_OPEN || endpoint === FILE_REVEAL) {
|
|
958
|
+
const target = localPathOf(payload);
|
|
959
|
+
if (target === null) {
|
|
960
|
+
return failure("bad-request", "payload.path must be a non-empty absolute local path", { issues: [] });
|
|
961
|
+
}
|
|
962
|
+
const done = endpoint === FILE_OPEN ? await openFileGeneric(target) : await revealFileGeneric(target);
|
|
963
|
+
if (!done) {
|
|
964
|
+
return failure("internal", endpoint === FILE_OPEN
|
|
965
|
+
? "the system failed to open the file with its default application"
|
|
966
|
+
: "the system file manager failed to reveal the file", {});
|
|
967
|
+
}
|
|
968
|
+
return { ok: true, value: { opened: true } };
|
|
969
|
+
}
|
|
970
|
+
if (endpoint !== "dshome/explorer.open") {
|
|
971
|
+
return failure("bad-request", `unknown endpoint "${endpoint}"`, { issues: [] });
|
|
972
|
+
}
|
|
973
|
+
const path = localPathOf(payload);
|
|
974
|
+
if (path === null) {
|
|
975
|
+
return failure("bad-request", "payload.path must be a non-empty absolute local path", { issues: [] });
|
|
976
|
+
}
|
|
977
|
+
const opened = await openPathGeneric(path);
|
|
978
|
+
if (!opened) {
|
|
979
|
+
return failure("internal", "the system file manager failed to open the path", {});
|
|
980
|
+
}
|
|
981
|
+
return { ok: true, value: { opened: true } };
|
|
982
|
+
} catch (error) {
|
|
983
|
+
const message = error instanceof Error ? error.message : "unexpected failure in the rpc handler";
|
|
984
|
+
return failure("internal", message, {});
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
ctx.effect(() => webServer.register({
|
|
988
|
+
kind: "prefix",
|
|
989
|
+
path: CHANNEL,
|
|
990
|
+
handler: async (req, res) => {
|
|
991
|
+
if (!isTrustedBridgeRequest(req)) {
|
|
992
|
+
sendJson(res, 403, failure("bad-request", "untrusted request origin: loopback and same-origin authorities only", {}));
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
// The wallpaper bytes ride the same fenced route as a GET: the page uses
|
|
996
|
+
// that URL as a CSS background image, which cannot carry a JSON body. The
|
|
997
|
+
// endpoint takes no input, so the only file it can ever serve is the one
|
|
998
|
+
// the host itself resolved for the desktop -- never a path from the page.
|
|
999
|
+
if (req.method === "GET" && subPathOf(req) === WALLPAPER_SUBPATH) {
|
|
1000
|
+
await sendWallpaper(res);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (!isJsonRequest(req)) {
|
|
1004
|
+
sendJson(res, 400, failure("bad-request", "content-type must be application/json", {}));
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
const body = await readJsonBody(req);
|
|
1009
|
+
const endpoint = body === null || typeof body !== "object" ? undefined : body.endpoint;
|
|
1010
|
+
const payload = body === null || typeof body !== "object" ? undefined : body.payload;
|
|
1011
|
+
sendJson(res, 200, await handler(endpoint, payload));
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
sendJson(res, 200, failure("internal", error instanceof Error ? error.message : "bridge failure", {}));
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}), "deepseek-style-theme: private bridge route");
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** One request header, or undefined when absent (Node lowercases header names). */
|
|
1020
|
+
function headerValue(headers, name) {
|
|
1021
|
+
const value = headers === undefined || headers === null ? undefined : headers[name];
|
|
1022
|
+
return typeof value === "string" ? value : undefined;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Whether a TCP peer address is loopback. */
|
|
1026
|
+
function isLoopbackAddress(address) {
|
|
1027
|
+
const value = typeof address === "string" ? address : "";
|
|
1028
|
+
return value === "127.0.0.1" || value === "::1" || value.startsWith("::ffff:127.") || value.startsWith("127.");
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** Whether one authority's hostname names this machine's own loopback interface. */
|
|
1032
|
+
function isLoopbackHostname(hostname) {
|
|
1033
|
+
const value = hostname.replace(/^\[/u, "").replace(/\]$/u, "").toLowerCase();
|
|
1034
|
+
return value === "localhost" || value === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(value);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Fence for the private bridge route, mirroring the core's own
|
|
1039
|
+
* `isTrustedApiRequest` (dsh-client-connection) instead of trusting the TCP peer
|
|
1040
|
+
* alone. The peer address is necessary but not sufficient: a cross-site
|
|
1041
|
+
* `fetch()` with a simple content type never triggers a preflight, so its side
|
|
1042
|
+
* effects run even though the page cannot read the response, and a rebinding
|
|
1043
|
+
* hostname resolves to 127.0.0.1 while still arriving with the attacker's name
|
|
1044
|
+
* in `Host`.
|
|
1045
|
+
*
|
|
1046
|
+
* So the request must also carry a loopback authority, and any browser
|
|
1047
|
+
* provenance headers must agree with it. A caller sending no `Origin` (curl, the
|
|
1048
|
+
* CLI) is allowed, exactly as the core allows it. Loopback-only is also the
|
|
1049
|
+
* contract the route has always had, so this narrows nothing that worked before.
|
|
1050
|
+
*/
|
|
1051
|
+
function isTrustedBridgeRequest(req) {
|
|
1052
|
+
const socket = req.socket === undefined || req.socket === null ? null : req.socket;
|
|
1053
|
+
if (!isLoopbackAddress(socket === null ? "" : socket.remoteAddress)) return false;
|
|
1054
|
+
const host = headerValue(req.headers, "host");
|
|
1055
|
+
if (host === undefined) return false;
|
|
1056
|
+
let hostUrl;
|
|
1057
|
+
try {
|
|
1058
|
+
hostUrl = new URL("http://" + host);
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
return false;
|
|
1061
|
+
}
|
|
1062
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
1063
|
+
if (headerValue(req.headers, "sec-fetch-site") === "cross-site") return false;
|
|
1064
|
+
const origin = headerValue(req.headers, "origin");
|
|
1065
|
+
if (origin === undefined) return true;
|
|
1066
|
+
try {
|
|
1067
|
+
return new URL(origin).host === hostUrl.host;
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Whether a request body is JSON. Requiring it costs this plugin nothing (its own
|
|
1075
|
+
* client always sends it) and makes every cross-site attempt a non-simple
|
|
1076
|
+
* request, so the browser must preflight a route that never answers a preflight.
|
|
1077
|
+
*/
|
|
1078
|
+
function isJsonRequest(req) {
|
|
1079
|
+
const value = headerValue(req.headers, "content-type");
|
|
1080
|
+
return value !== undefined && value.split(";")[0].trim().toLowerCase() === "application/json";
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Whether a path addresses a network share or a device namespace. `isAbsolute`
|
|
1085
|
+
* is true for `\\host\share` on Windows, and opening such a path makes Windows
|
|
1086
|
+
* authenticate to that host — an NTLM hash leak reachable from a cross-site
|
|
1087
|
+
* request. The theme only ever opens local workspace directories, so the whole
|
|
1088
|
+
* `\\` and `//` namespace is refused.
|
|
1089
|
+
*/
|
|
1090
|
+
function isNetworkPath(path) {
|
|
1091
|
+
return path.startsWith("\\\\") || path.startsWith("//");
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Read one JSON request body from the Node request stream (bounded). */
|
|
1095
|
+
async function readJsonBody(req) {
|
|
1096
|
+
let raw = "";
|
|
1097
|
+
for await (const chunk of req) {
|
|
1098
|
+
raw += chunk;
|
|
1099
|
+
if (raw.length > 65536) throw new Error("request body too large");
|
|
1100
|
+
}
|
|
1101
|
+
return raw === "" ? null : JSON.parse(raw);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** Write one JSON response. */
|
|
1105
|
+
function sendJson(res, status, value) {
|
|
1106
|
+
const payload = JSON.stringify(value);
|
|
1107
|
+
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
|
|
1108
|
+
res.end(payload);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// The bridge rides `webServer` (declared hard: it is core to any web realm).
|
|
1112
|
+
// `connection` is no longer injected — dsh 0.1.5's rpc carrier cannot host this
|
|
1113
|
+
// plugin's channel without tripping the guard described in apply().
|
|
1114
|
+
const inject = ["webServer"];
|
|
1115
|
+
|
|
1116
|
+
export { apply, inject };
|