dsh-deepseek-style-theme 2.0.85 → 2.0.87

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