dsh-deepseek-style-theme 1.43.1

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 ADDED
@@ -0,0 +1,824 @@
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 { dirname, isAbsolute } from "node:path";
25
+
26
+ // Schemastery is this bundle's only runtime dependency, and it is loaded
27
+ // defensively. `settings.register()` genuinely needs a real schema — the
28
+ // settings service calls it as a function (`resolve`) and serializes it through
29
+ // `schema.toJSON()` plus a `redactSecrets` walk inside `describe()` — so a
30
+ // hand-rolled stand-in cannot work. What it must not do is take the whole
31
+ // plugin tree down when the package is unresolvable: with an empty profile
32
+ // (`autoInstallPeers: false`) or a `link:` install whose real path sits outside
33
+ // the profile, a bare `import` here fails the loader entry and the GUI refuses
34
+ // to start at all. The guarded dynamic import converts that into one lost
35
+ // preference.
36
+ let z = null;
37
+ try {
38
+ const schemastery = await import("@deepseek-ai/schemastery");
39
+ const schema = schemastery.default ?? null;
40
+ if (schema !== null && typeof schema.object === "function") z = schema;
41
+ } catch {
42
+ // Reported once, below, where the remedy is actionable.
43
+ }
44
+ if (z === null) {
45
+ console.warn(
46
+ "[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"
47
+ );
48
+ }
49
+
50
+ const CHANNEL = "/dshome-open-workspace";
51
+ /** Hard cap per opener process: a hung COM call must not hang the RPC forever. */
52
+ const OPEN_TIMEOUT_MS = 10000;
53
+
54
+ /**
55
+ * Durable DSTT (DeepSeekStyleTheme) preference section, registered into the
56
+ * host settings document exactly like the product's own namespaces
57
+ * (ui-theme/locale/ui-conversation): the client half binds this namespace
58
+ * through `settingsScope` and reads/writes the preference fields.
59
+ * A single `mode` enum drives the whole color story:
60
+ * - peakvalley-redblue → vivid red (鲜红) at peak hours, blue at valley;
61
+ * - peakvalley-redgreen → vivid red (鲜红) at peak hours, green at valley;
62
+ * - always-green → always green, no peak distinction;
63
+ * - always-blue → always blue, no peak distinction.
64
+ */
65
+ // `settingsNamespace` from @deepseek-ai/dsh-settings was deleted in dsh
66
+ // 0.1.2-alpha.1 (the settings service itself is unchanged - register /
67
+ // describe / mutate below still take the namespace string), so this is now
68
+ // a plain literal. Keep it in sync with the client half's DSTT_NS.
69
+ const DSTT_SETTINGS_NS = "deepseek-style-theme";
70
+ const DSTT_MODES = ["peakvalley-redblue", "peakvalley-redgreen", "always-green", "always-blue"];
71
+ const DSTT_MODE_DEFAULT = "peakvalley-redblue";
72
+ // 1.37.x persisted `auto` / `blue` / `green`. The schema still accepts them —
73
+ // otherwise register() validates the stored document and throws, killing DSTT
74
+ // persistence on upgrade — and apply() migrates them to the closest four-mode
75
+ // value right after registration.
76
+ const DSTT_LEGACY_MODES = { auto: "peakvalley-redblue", blue: "always-blue", green: "always-green" };
77
+ /**
78
+ * Catalog-sync policies, most automatic first:
79
+ * - auto → align the catalog with the endpoint, but only while every
80
+ * advertised id is one this plugin can describe (see syncDeepseekCatalog);
81
+ * - add → append-only: adopt newly advertised ids, remove nothing ever;
82
+ * - off → probe and report drift, write nothing.
83
+ */
84
+ const CATALOG_SYNC_POLICIES = ["auto", "add", "off"];
85
+ const CATALOG_SYNC_DEFAULT = "auto";
86
+ // Glass recipes. `liquid` is the Apple-style lens (edge refraction, a flowing
87
+ // edge highlight, high translucency); `frosted` is the white frosted pane with
88
+ // the brand-tinted shifting border this theme shipped before, kept byte for byte
89
+ // because that is the look the user picked. The client mirrors the choice onto
90
+ // <html data-dshome-glass> so one stylesheet can gate both recipes.
91
+ const GLASS_STYLES = ["liquid", "frosted"];
92
+ const GLASS_STYLE_DEFAULT = "liquid";
93
+ // `null` when Schemastery failed to load: registration is then skipped and the
94
+ // DSTT mode simply stops persisting instead of failing the plugin tree.
95
+ const DsttSettingsSchema = z === null ? null : z.object({
96
+ mode: z.union([
97
+ z.const("peakvalley-redblue"),
98
+ z.const("peakvalley-redgreen"),
99
+ z.const("always-green"),
100
+ z.const("always-blue"),
101
+ z.const("auto"),
102
+ z.const("blue"),
103
+ z.const("green")
104
+ ]).default(DSTT_MODE_DEFAULT),
105
+ catalogSync: z.union([
106
+ z.const("auto"),
107
+ z.const("add"),
108
+ z.const("off")
109
+ ]).default(CATALOG_SYNC_DEFAULT),
110
+ // The fluid background's pointer brush writes a velocity wake into the flow
111
+ // field wherever the cursor goes. It is both the most expensive part of the
112
+ // simulation and the most intrusive visually, so it ships OFF; this setting
113
+ // exists only to turn it back on.
114
+ fluidBrush: z.boolean().default(false),
115
+ glassStyle: z.union([
116
+ z.const("liquid"),
117
+ z.const("frosted")
118
+ ]).default(GLASS_STYLE_DEFAULT)
119
+ });
120
+
121
+ /** Map a legacy 1.37.x mode id onto a four-mode id, or null when nothing to do. */
122
+ function migrateLegacyMode(mode) {
123
+ return typeof mode === "string" && Object.prototype.hasOwnProperty.call(DSTT_LEGACY_MODES, mode)
124
+ ? DSTT_LEGACY_MODES[mode]
125
+ : null;
126
+ }
127
+
128
+ /** PowerShell single-quoted literal (doubles embedded quotes). */
129
+ function powershellLiteral(path) {
130
+ return `'${path.replace(/'/g, "''")}'`;
131
+ }
132
+
133
+ /**
134
+ * Open one path with Shell.Application, then focus the matching Explorer
135
+ * window. The open step is strict: any COM failure exits the script non-zero
136
+ * so the caller reports a real failure instead of a false success. Focusing
137
+ * stays best-effort — a directory that opened but could not be focused is
138
+ * still a successful open.
139
+ */
140
+ function openExplorerForeground(path) {
141
+ return new Promise((resolve) => {
142
+ const script = [
143
+ "$ErrorActionPreference = 'Stop'",
144
+ `$p = ${powershellLiteral(path)}`,
145
+ "$sh = New-Object -ComObject Shell.Application",
146
+ "try { $sh.Open($p) } catch { exit 1 }",
147
+ "Start-Sleep -Milliseconds 500",
148
+ "$f = $false",
149
+ "foreach ($w in @($sh.Windows())) {",
150
+ " try { if ($w.Document.Folder.Self.Path -ieq $p) { $null = $w.Focus(); $f = $true; break } } catch {}",
151
+ "}",
152
+ "if (-not $f) { $null = (New-Object -ComObject WScript.Shell).AppActivate((Split-Path $p -Leaf)) }"
153
+ ].join("; ");
154
+ execFile("powershell.exe", ["-NoProfile", "-Command", script], { windowsHide: true, timeout: OPEN_TIMEOUT_MS }, (error) => {
155
+ resolve(error === null || error === undefined);
156
+ });
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Run a simple command and resolve with whether it exited cleanly.
162
+ * `lenient` counts a numeric exit status as success: some platform tools
163
+ * (notably `explorer.exe`, which returns 1 after a successful `/select`) report
164
+ * failure while having done the work, so only a spawn failure is a failure.
165
+ */
166
+ function runOpener(command, args, lenient = false) {
167
+ return new Promise((resolve) => {
168
+ execFile(command, args, { windowsHide: true, timeout: OPEN_TIMEOUT_MS }, (error) => {
169
+ if (error === null || error === undefined) {
170
+ resolve(true);
171
+ return;
172
+ }
173
+ resolve(lenient && typeof error.code === "number");
174
+ });
175
+ });
176
+ }
177
+
178
+ /** Open a directory in the platform's default file manager. */
179
+ function openPathGeneric(path) {
180
+ switch (process.platform) {
181
+ case "win32":
182
+ return openExplorerForeground(path);
183
+ case "darwin":
184
+ return runOpener("open", [path]);
185
+ case "linux":
186
+ return runOpener("xdg-open", [path]);
187
+ default:
188
+ return Promise.resolve(false);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Open one file with the OS default application. Deliberately not
194
+ * {@link openPathGeneric}: on Windows `Shell.Application.Open` on a file is the
195
+ * shell's own gesture, while `Start-Process` hands the path to the registered
196
+ * handler, which is what "用默认应用打开" means.
197
+ */
198
+ function openFileGeneric(path) {
199
+ switch (process.platform) {
200
+ case "win32":
201
+ // `-FilePath`, not `-LiteralPath`: Start-Process has no -LiteralPath
202
+ // parameter (Windows PowerShell 5.1 rejects it with
203
+ // NamedParameterNotFound), so the literal quoting comes from
204
+ // powershellLiteral alone. Start-Process resolves through
205
+ // ShellExecute, which is what invokes the registered default handler.
206
+ return runOpener("powershell.exe", ["-NoProfile", "-Command", `Start-Process -FilePath ${powershellLiteral(path)}`]);
207
+ case "darwin":
208
+ return runOpener("open", [path]);
209
+ case "linux":
210
+ return runOpener("xdg-open", [path]);
211
+ default:
212
+ return Promise.resolve(false);
213
+ }
214
+ }
215
+
216
+ /** Reveal one file in the platform's file manager, selected where supported. */
217
+ function revealFileGeneric(path) {
218
+ switch (process.platform) {
219
+ case "win32":
220
+ return runOpener("explorer.exe", ["/select," + path], true);
221
+ case "darwin":
222
+ return runOpener("open", ["-R", path]);
223
+ case "linux":
224
+ return runOpener("xdg-open", [dirname(path)]);
225
+ default:
226
+ return Promise.resolve(false);
227
+ }
228
+ }
229
+
230
+ /** Uniform RpcResult failure branch. */
231
+ function failure(code, message, details) {
232
+ return { ok: false, error: { code, message, details } };
233
+ }
234
+
235
+ /**
236
+ * Advisory model-catalog sync for the official DeepSeek route.
237
+ *
238
+ * `dsh-llm-deepseek` deliberately never probes its gateway — `listModels()`
239
+ * returns the declared catalog and the README states the defaults are published
240
+ * "without probing gateway availability" — so the composer's model selector can
241
+ * drift from what the endpoint actually serves, and nothing in the harness would
242
+ * ever notice. On activation this plugin therefore asks the endpoint which ids
243
+ * it advertises (the same call the Models page makes: GET {baseURL}/models) and
244
+ * aligns `llm-deepseek.models` with the answer.
245
+ *
246
+ * Two properties keep that from being a liability:
247
+ *
248
+ * 1. It never invents capability metadata. The endpoint reports ids only, so
249
+ * it cannot supply `inputModalities`, `systemPromptUpdate` or a context
250
+ * window. Entries that already exist are preserved verbatim and new ids are
251
+ * filled from KNOWN_CATALOG_ENTRIES; an id this plugin cannot describe is
252
+ * reported rather than guessed, because a guessed entry silently downgrades
253
+ * a vision model to text-only.
254
+ * 2. It only rewrites the list wholesale while every advertised id is
255
+ * describable. An official route advertises a handful of DeepSeek ids; an
256
+ * aggregating gateway in front of `baseURL` advertises its entire
257
+ * catalogue, and "the endpoint is authoritative" would then replace a
258
+ * curated two-entry catalog with hundreds of unrelated rows. A
259
+ * foreign-looking answer degrades to append-only plus a warning.
260
+ *
261
+ * Every failure mode (no settings, no credential, offline gateway, renamed
262
+ * service, concurrent settings edit) is a no-op for the theme — a skin must
263
+ * never be able to break the model catalog — and it only writes on real drift.
264
+ * `catalogSync: "off"` probes and reports without ever writing.
265
+ */
266
+ const LLM_DEEPSEEK_NS = "llm-deepseek";
267
+ /** Public endpoint; a deployment may point elsewhere through `$DEEPSEEK_BASE_URL`. */
268
+ const PUBLIC_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
269
+ const DEEPSEEK_BASE_URL_ENV = "DEEPSEEK_BASE_URL";
270
+ const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
271
+ const CATALOG_SYNC_TIMEOUT_MS = 5000;
272
+ /** Retry offsets (ms) while a boot-order prerequisite is still missing. */
273
+ const CATALOG_SYNC_RETRIES = [0, 2000, 6000, 15000];
274
+ /**
275
+ * Outcomes worth retrying: activation order is not ours to choose, so the
276
+ * provider may not have registered `llm-deepseek` yet and the credentials
277
+ * service may not be reachable at the instant this plugin activates. A failed
278
+ * or answered probe (in-sync / updated / endpoint-unreachable) is final.
279
+ */
280
+ const CATALOG_SYNC_RETRYABLE = ["provider-absent", "no-credentials"];
281
+
282
+ /**
283
+ * Capability metadata for ids this plugin knows by name. Only consulted for ids
284
+ * the endpoint advertises that the stored catalog does not describe yet; an
285
+ * existing entry is never overwritten, so a user's own edits survive.
286
+ */
287
+ const KNOWN_CATALOG_ENTRIES = {
288
+ "deepseek-flash": {
289
+ name: "DeepSeek-V41-Flash",
290
+ contextWindow: 1000000,
291
+ inputModalities: ["text", "image"],
292
+ imagePixelBudget: 640000,
293
+ imageMaxBytes: 1048576,
294
+ systemPromptUpdate: "in-history"
295
+ },
296
+ "deepseek-v4-pro": {
297
+ name: "DeepSeek-V4-Pro",
298
+ contextWindow: 1000000,
299
+ inputModalities: ["text"]
300
+ }
301
+ };
302
+
303
+ /** One registered namespace's descriptor (resolved value plus revision), or null. */
304
+ function settingsDescriptor(ctx, ns) {
305
+ const settings = ctx.get("settings");
306
+ if (settings === undefined || settings === null || typeof settings.describe !== "function") return null;
307
+ let descriptors;
308
+ try {
309
+ descriptors = settings.describe({ redactSecrets: true });
310
+ } catch (error) {
311
+ return null;
312
+ }
313
+ if (!Array.isArray(descriptors)) return null;
314
+ const descriptor = descriptors.find((candidate) => String(candidate.ns) === ns);
315
+ if (descriptor === undefined || descriptor === null) return null;
316
+ const value = descriptor.value;
317
+ if (value === null || typeof value !== "object") return null;
318
+ return { value, revision: descriptor.revision };
319
+ }
320
+
321
+ /** The resolved `llm-deepseek` settings section, or null while it is absent. */
322
+ function llmDeepseekSection(ctx) {
323
+ return settingsDescriptor(ctx, LLM_DEEPSEEK_NS);
324
+ }
325
+
326
+ /** The configured catalog-sync policy, falling back to the schema default. */
327
+ function catalogSyncPolicy(ctx) {
328
+ const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
329
+ const policy = descriptor === null ? undefined : descriptor.value.catalogSync;
330
+ return CATALOG_SYNC_POLICIES.includes(policy) ? policy : CATALOG_SYNC_DEFAULT;
331
+ }
332
+
333
+ /**
334
+ * The endpoint the official adapter itself would dial, resolved in that
335
+ * adapter's own order (`dsh-llm-deepseek` resolves `config.baseURL ??
336
+ * launchEnvironment.get("DEEPSEEK_BASE_URL") ?? "https://api.deepseek.com"`).
337
+ * Falling straight back to the public host would post the resolved credential to
338
+ * api.deepseek.com on a deployment whose gateway lives behind that variable —
339
+ * and that credential may be a gateway token, not a DeepSeek key. The service's
340
+ * own fallback is a raw `process.env` snapshot, so consulting `process.env` only
341
+ * while the service is absent matches the adapter exactly instead of widening
342
+ * trust.
343
+ */
344
+ function resolveBaseURL(ctx, section) {
345
+ if (typeof section.baseURL === "string" && section.baseURL !== "") return section.baseURL;
346
+ const environment = ctx.get("launchEnvironment");
347
+ if (environment === undefined || environment === null || typeof environment.get !== "function") {
348
+ const ambient = process.env[DEEPSEEK_BASE_URL_ENV];
349
+ return typeof ambient === "string" && ambient !== "" ? ambient : PUBLIC_DEEPSEEK_BASE_URL;
350
+ }
351
+ try {
352
+ const hit = environment.get(DEEPSEEK_BASE_URL_ENV);
353
+ const value = hit === undefined || hit === null ? undefined : hit.value;
354
+ if (typeof value === "string" && value !== "") return value;
355
+ } catch (error) {
356
+ // Fall through to the public endpoint, exactly like the adapter does.
357
+ }
358
+ return PUBLIC_DEEPSEEK_BASE_URL;
359
+ }
360
+
361
+ /**
362
+ * Resolve the provider API key the same way `dsh-llm-deepseek` does: through the
363
+ * credentials service under the configured `apiKeyEnv`, falling back to the
364
+ * ambient environment of the launching process.
365
+ */
366
+ async function resolveProviderKey(ctx, apiKeyEnv) {
367
+ const ref = typeof apiKeyEnv === "string" && apiKeyEnv !== "" ? apiKeyEnv : DEFAULT_API_KEY_ENV;
368
+ const credentials = ctx.get("credentials");
369
+ if (credentials !== undefined && credentials !== null && typeof credentials.resolve === "function") {
370
+ try {
371
+ const hit = await credentials.resolve(ref);
372
+ if (hit !== undefined && hit !== null && typeof hit.value === "string" && hit.value !== "") return hit.value;
373
+ } catch (error) {
374
+ // Fall through to the ambient environment.
375
+ }
376
+ }
377
+ const ambient = typeof process !== "undefined" && process.env !== undefined ? process.env[ref] : undefined;
378
+ return typeof ambient === "string" && ambient !== "" ? ambient : null;
379
+ }
380
+
381
+ /** Ask the endpoint which model ids it advertises, in endpoint order. */
382
+ async function fetchEndpointModelIds(baseURL, key) {
383
+ if (typeof fetch !== "function") return null;
384
+ const url = String(baseURL).replace(/\/+$/, "") + "/models";
385
+ const signal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function"
386
+ ? AbortSignal.timeout(CATALOG_SYNC_TIMEOUT_MS)
387
+ : undefined;
388
+ const response = await fetch(url, {
389
+ headers: { authorization: "Bearer " + key, accept: "application/json" },
390
+ ...(signal === undefined ? {} : { signal })
391
+ });
392
+ if (!response.ok) throw new Error("endpoint answered " + String(response.status));
393
+ const body = await response.json();
394
+ const rows = body !== null && typeof body === "object" && Array.isArray(body.data) ? body.data : [];
395
+ const ids = [];
396
+ for (const row of rows) {
397
+ const id = row !== null && typeof row === "object" && typeof row.id === "string" ? row.id.trim() : "";
398
+ if (id !== "" && !ids.includes(id)) ids.push(id);
399
+ }
400
+ return ids;
401
+ }
402
+
403
+ /** One catalog entry for an id the stored catalog does not describe yet. */
404
+ function catalogEntryFor(id) {
405
+ const known = Object.prototype.hasOwnProperty.call(KNOWN_CATALOG_ENTRIES, id) ? KNOWN_CATALOG_ENTRIES[id] : undefined;
406
+ return known === undefined ? { id, name: id, inputModalities: ["text"] } : { id, ...known };
407
+ }
408
+
409
+ /** Whether this plugin can describe an id without inventing capability metadata. */
410
+ function isDescribable(id) {
411
+ return Object.prototype.hasOwnProperty.call(KNOWN_CATALOG_ENTRIES, id);
412
+ }
413
+
414
+ /** The ids of a catalog array, in order, ignoring malformed entries. */
415
+ function catalogIds(entries) {
416
+ return entries
417
+ .map((entry) => (entry !== null && typeof entry === "object" && typeof entry.id === "string" ? entry.id : ""))
418
+ .filter((id) => id !== "");
419
+ }
420
+
421
+ /**
422
+ * Align the stored catalog with the endpoint's id list, preserving surviving
423
+ * entries verbatim. Returns one of: provider-absent | no-credentials |
424
+ * endpoint-unreachable | in-sync | updated | conflict.
425
+ */
426
+ async function syncDeepseekCatalog(ctx) {
427
+ const section = llmDeepseekSection(ctx);
428
+ if (section === null) return "provider-absent";
429
+ const settings = ctx.get("settings");
430
+ if (settings === undefined || settings === null || typeof settings.mutate !== "function") return "provider-absent";
431
+ const policy = catalogSyncPolicy(ctx);
432
+ const baseURL = resolveBaseURL(ctx, section.value);
433
+ const key = await resolveProviderKey(ctx, section.value.apiKeyEnv);
434
+ if (key === null) return "no-credentials";
435
+ let ids;
436
+ try {
437
+ ids = await fetchEndpointModelIds(baseURL, key);
438
+ } catch (error) {
439
+ return "endpoint-unreachable";
440
+ }
441
+ if (ids === null || ids.length === 0) return "endpoint-unreachable";
442
+ const current = Array.isArray(section.value.models) ? section.value.models : [];
443
+ const byId = new Map();
444
+ for (const entry of current) {
445
+ if (entry !== null && typeof entry === "object" && typeof entry.id === "string") byId.set(entry.id, entry);
446
+ }
447
+ const currentIds = catalogIds(current);
448
+ // Advertised ids this plugin knows nothing about: reported, never guessed.
449
+ const undescribed = ids.filter((id) => !byId.has(id) && !isDescribable(id));
450
+
451
+ if (policy === "off") {
452
+ const missing = ids.filter((id) => !byId.has(id));
453
+ const stale = currentIds.filter((id) => !ids.includes(id));
454
+ if (missing.length === 0 && stale.length === 0) return "in-sync";
455
+ console.warn(
456
+ `[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"}`
457
+ );
458
+ return "in-sync";
459
+ }
460
+
461
+ // A wholesale rewrite is only safe while every advertised id is one this
462
+ // plugin can describe; otherwise the answer looks like a gateway catalogue
463
+ // rather than the official route, and aligning to it would replace a curated
464
+ // catalog with unrelated rows.
465
+ const rewrites = policy === "auto" && undescribed.length === 0;
466
+ let models;
467
+ if (rewrites) {
468
+ if (currentIds.length === ids.length && currentIds.every((id, index) => id === ids[index])) return "in-sync";
469
+ models = ids.map((id) => (byId.has(id) ? byId.get(id) : catalogEntryFor(id)));
470
+ } else {
471
+ const added = ids.filter((id) => !byId.has(id) && isDescribable(id));
472
+ if (added.length === 0) {
473
+ if (undescribed.length > 0) {
474
+ console.warn(
475
+ `[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.`
476
+ );
477
+ }
478
+ return "in-sync";
479
+ }
480
+ models = [...current, ...added.map((id) => catalogEntryFor(id))];
481
+ }
482
+
483
+ const removed = currentIds.filter((id) => !models.some((entry) => entry.id === id));
484
+ try {
485
+ // Pinned to the revision this decision was read from: a settings edit the
486
+ // user made in the meantime wins, and this activation simply does not sync.
487
+ await settings.mutate(LLM_DEEPSEEK_NS, [{ op: "set", path: ["models"], value: models }], section.revision);
488
+ } catch (error) {
489
+ return "conflict";
490
+ }
491
+ if (removed.length > 0) {
492
+ console.warn(
493
+ `[deepseek-style-theme] model catalog synced from ${baseURL}; removed id(s) the endpoint does not list: ${removed.join(", ")}`
494
+ );
495
+ } else {
496
+ console.info(`[deepseek-style-theme] model catalog synced from ${baseURL}`);
497
+ }
498
+ return "updated";
499
+ }
500
+
501
+ /**
502
+ * Run the catalog sync once per activation, retrying briefly while a boot-order
503
+ * prerequisite is still missing (the provider namespace or the credentials
504
+ * service). Disposal cancels any pending retry.
505
+ */
506
+ function startCatalogSync(ctx) {
507
+ return ctx.effect(() => {
508
+ let cancelled = false;
509
+ let timer = null;
510
+ const attempt = (step) => {
511
+ if (cancelled) return;
512
+ syncDeepseekCatalog(ctx).then((outcome) => {
513
+ if (cancelled) return;
514
+ // "updated" already logged exactly what it changed where it changed it.
515
+ if (outcome === "updated") return;
516
+ if (CATALOG_SYNC_RETRYABLE.includes(outcome) && step + 1 < CATALOG_SYNC_RETRIES.length) {
517
+ timer = setTimeout(() => attempt(step + 1), CATALOG_SYNC_RETRIES[step + 1]);
518
+ }
519
+ }).catch(() => {});
520
+ };
521
+ attempt(0);
522
+ return () => {
523
+ cancelled = true;
524
+ if (timer !== null) clearTimeout(timer);
525
+ };
526
+ }, "deepseek-style-theme: model catalog sync");
527
+ }
528
+
529
+ /**
530
+ * DSTT private-channel endpoints. The settings domain's RPC surface only
531
+ * serves namespaces in the core's hard-coded allowlist (`settings-not-exposed`
532
+ * otherwise), so the browser half reads/writes the DSTT preference through
533
+ * this plugin's own loopback channel, and the host half drives the settings
534
+ * service directly — the same shape as the explorer-open RPC above.
535
+ */
536
+ const DSTT_GET = "dstt.mode.get";
537
+ const DSTT_SET = "dstt.mode.set";
538
+ /** Delivered-file gestures for the card menu patched by the browser half. */
539
+ const FILE_OPEN = "dshome/file.open";
540
+ const FILE_REVEAL = "dshome/file.reveal";
541
+
542
+ /**
543
+ * Validate `payload.path` as a local absolute path, or return null. Every path
544
+ * endpoint shares this: a relative path, an embedded NUL, a UNC share or a
545
+ * device namespace is refused before it reaches any opener.
546
+ */
547
+ function localPathOf(payload) {
548
+ const path = payload === null || payload === undefined || typeof payload !== "object" ? undefined : payload.path;
549
+ if (typeof path !== "string" || path.trim() === "" || path.indexOf("\0") !== -1 || !isAbsolute(path) || isNetworkPath(path)) return null;
550
+ return path;
551
+ }
552
+
553
+ /**
554
+ * Read the durable DSTT mode. Reports the schema default whenever the stored
555
+ * value is not one of the four modes, so the reply always honors the RPC
556
+ * contract — a legacy 1.37.x value is migrated right after registration, and
557
+ * the client rejects any off-enum answer anyway.
558
+ */
559
+ function dsttRead(ctx) {
560
+ const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
561
+ if (descriptor === null) return failure("internal", "settings service unavailable", {});
562
+ const stored = descriptor.value.mode;
563
+ return {
564
+ ok: true,
565
+ value: {
566
+ mode: DSTT_MODES.includes(stored) ? stored : DSTT_MODE_DEFAULT,
567
+ fluidBrush: descriptor.value.fluidBrush === true,
568
+ glassStyle: dsttGlassValue(ctx)
569
+ }
570
+ };
571
+ }
572
+
573
+ /**
574
+ * Write the durable DSTT preferences through the host settings service directly.
575
+ * `fluidBrush` and `glassStyle` are both optional: when they carry a valid value
576
+ * the same write also sets them, so the settings panel can persist a toggle
577
+ * without a second round trip. `glassStyle` is deliberately NOT mandatory the
578
+ * way `mode` is — an older client that only knows about the mode keeps working.
579
+ */
580
+ async function dsttWrite(ctx, mode, fluidBrush, glassStyle) {
581
+ if (!DSTT_MODES.includes(mode)) {
582
+ return failure("bad-request", `mode must be one of ${DSTT_MODES.join("/")}`, { issues: [] });
583
+ }
584
+ const wantsGlass = GLASS_STYLES.includes(glassStyle);
585
+ const ops = [{ op: "set", path: ["mode"], value: mode }];
586
+ if (typeof fluidBrush === "boolean") ops.push({ op: "set", path: ["fluidBrush"], value: fluidBrush });
587
+ if (wantsGlass) ops.push({ op: "set", path: ["glassStyle"], value: glassStyle });
588
+ const settings = ctx.get("settings");
589
+ if (settings === undefined || settings === null || typeof settings.mutate !== "function") {
590
+ return failure("internal", "settings service unavailable", {});
591
+ }
592
+ try {
593
+ await settings.mutate(DSTT_SETTINGS_NS, ops);
594
+ } catch (error) {
595
+ return failure("internal", error instanceof Error ? error.message : "settings write failed", {});
596
+ }
597
+ return {
598
+ ok: true,
599
+ value: {
600
+ mode,
601
+ fluidBrush: typeof fluidBrush === "boolean" ? fluidBrush === true : dsttBrushValue(ctx),
602
+ glassStyle: wantsGlass ? glassStyle : dsttGlassValue(ctx)
603
+ }
604
+ };
605
+ }
606
+
607
+ /** The stored fluid-brush preference, defaulting to off. */
608
+ function dsttBrushValue(ctx) {
609
+ const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
610
+ return descriptor === null ? false : descriptor.value.fluidBrush === true;
611
+ }
612
+
613
+ /**
614
+ * The stored glass recipe. A missing or unrecognized value reads back as the
615
+ * default rather than as `undefined`, so the client always gets one of the two
616
+ * ids it can gate its stylesheet on.
617
+ */
618
+ function dsttGlassValue(ctx) {
619
+ const descriptor = settingsDescriptor(ctx, DSTT_SETTINGS_NS);
620
+ const stored = descriptor === null ? undefined : descriptor.value.glassStyle;
621
+ return GLASS_STYLES.includes(stored) ? stored : GLASS_STYLE_DEFAULT;
622
+ }
623
+
624
+ function apply(ctx) {
625
+ // Both registrations below are best-effort on purpose: dsh ships breaking
626
+ // changes to the settings / connection APIs between releases, and a throw
627
+ // here fails this bundle's loader entry — with it the whole plugin tree,
628
+ // which is far worse than a theme that merely loses its persistence.
629
+ ctx.inject(["settings"], (settingsCtx) => {
630
+ if (DsttSettingsSchema === null) {
631
+ console.warn("[deepseek-style-theme] DSTT settings not registered (Schemastery is missing, see above): the mode will not persist");
632
+ } else {
633
+ try {
634
+ settingsCtx.settings.register(DSTT_SETTINGS_NS, DsttSettingsSchema);
635
+ const descriptor = settingsDescriptor(settingsCtx, DSTT_SETTINGS_NS);
636
+ const stored = descriptor === null ? undefined : descriptor.value.mode;
637
+ const migrated = migrateLegacyMode(stored);
638
+ if (migrated !== null) {
639
+ settingsCtx.settings.mutate(DSTT_SETTINGS_NS, [{ op: "set", path: ["mode"], value: migrated }])
640
+ .then(() => console.warn(`[deepseek-style-theme] migrated DSTT mode "${stored}" -> "${migrated}"`))
641
+ .catch(() => {});
642
+ }
643
+ } catch (error) {
644
+ console.warn("[deepseek-style-theme] settings.register failed (DSTT mode will not persist):", error);
645
+ }
646
+ }
647
+ // Activation-time, best-effort, silent unless it really changed something.
648
+ try {
649
+ startCatalogSync(settingsCtx);
650
+ } catch (error) {
651
+ console.warn("[deepseek-style-theme] catalog sync unavailable:", error);
652
+ }
653
+ });
654
+
655
+ // The private bridge is a plain webServer prefix route. dsh 0.1.5's
656
+ // `connection.rpc.handle()` registers its route through the *provider's* ctx,
657
+ // so it trips cordis' guard — `cannot get property "webServer" without
658
+ // inject` — no matter what this plugin declares in its own inject list.
659
+ // ctx.webServer is the same mechanism the shipped plugin console uses, and
660
+ // the browser half reaches it with a same-origin fetch.
661
+ const webServer = ctx.webServer;
662
+ if (webServer === undefined || webServer === null || typeof webServer.register !== "function") {
663
+ console.warn("[deepseek-style-theme] webServer unavailable: 打开工作区与模式持久化将不可用");
664
+ return;
665
+ }
666
+ const handler = async (endpoint, payload) => {
667
+ try {
668
+ if (endpoint === DSTT_GET) return dsttRead(ctx);
669
+ if (endpoint === DSTT_SET) {
670
+ const payloadObject = payload === null || payload === undefined || typeof payload !== "object" ? {} : payload;
671
+ return await dsttWrite(ctx, payloadObject.mode, payloadObject.fluidBrush, payloadObject.glassStyle);
672
+ }
673
+ if (endpoint === FILE_OPEN || endpoint === FILE_REVEAL) {
674
+ const target = localPathOf(payload);
675
+ if (target === null) {
676
+ return failure("bad-request", "payload.path must be a non-empty absolute local path", { issues: [] });
677
+ }
678
+ const done = endpoint === FILE_OPEN ? await openFileGeneric(target) : await revealFileGeneric(target);
679
+ if (!done) {
680
+ return failure("internal", endpoint === FILE_OPEN
681
+ ? "the system failed to open the file with its default application"
682
+ : "the system file manager failed to reveal the file", {});
683
+ }
684
+ return { ok: true, value: { opened: true } };
685
+ }
686
+ if (endpoint !== "dshome/explorer.open") {
687
+ return failure("bad-request", `unknown endpoint "${endpoint}"`, { issues: [] });
688
+ }
689
+ const path = localPathOf(payload);
690
+ if (path === null) {
691
+ return failure("bad-request", "payload.path must be a non-empty absolute local path", { issues: [] });
692
+ }
693
+ const opened = await openPathGeneric(path);
694
+ if (!opened) {
695
+ return failure("internal", "the system file manager failed to open the path", {});
696
+ }
697
+ return { ok: true, value: { opened: true } };
698
+ } catch (error) {
699
+ const message = error instanceof Error ? error.message : "unexpected failure in the rpc handler";
700
+ return failure("internal", message, {});
701
+ }
702
+ };
703
+ ctx.effect(() => webServer.register({
704
+ kind: "prefix",
705
+ path: CHANNEL,
706
+ handler: async (req, res) => {
707
+ if (!isTrustedBridgeRequest(req)) {
708
+ sendJson(res, 403, failure("bad-request", "untrusted request origin: loopback and same-origin authorities only", {}));
709
+ return;
710
+ }
711
+ if (!isJsonRequest(req)) {
712
+ sendJson(res, 400, failure("bad-request", "content-type must be application/json", {}));
713
+ return;
714
+ }
715
+ try {
716
+ const body = await readJsonBody(req);
717
+ const endpoint = body === null || typeof body !== "object" ? undefined : body.endpoint;
718
+ const payload = body === null || typeof body !== "object" ? undefined : body.payload;
719
+ sendJson(res, 200, await handler(endpoint, payload));
720
+ } catch (error) {
721
+ sendJson(res, 200, failure("internal", error instanceof Error ? error.message : "bridge failure", {}));
722
+ }
723
+ }
724
+ }), "deepseek-style-theme: private bridge route");
725
+ }
726
+
727
+ /** One request header, or undefined when absent (Node lowercases header names). */
728
+ function headerValue(headers, name) {
729
+ const value = headers === undefined || headers === null ? undefined : headers[name];
730
+ return typeof value === "string" ? value : undefined;
731
+ }
732
+
733
+ /** Whether a TCP peer address is loopback. */
734
+ function isLoopbackAddress(address) {
735
+ const value = typeof address === "string" ? address : "";
736
+ return value === "127.0.0.1" || value === "::1" || value.startsWith("::ffff:127.") || value.startsWith("127.");
737
+ }
738
+
739
+ /** Whether one authority's hostname names this machine's own loopback interface. */
740
+ function isLoopbackHostname(hostname) {
741
+ const value = hostname.replace(/^\[/u, "").replace(/\]$/u, "").toLowerCase();
742
+ return value === "localhost" || value === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(value);
743
+ }
744
+
745
+ /**
746
+ * Fence for the private bridge route, mirroring the core's own
747
+ * `isTrustedApiRequest` (dsh-client-connection) instead of trusting the TCP peer
748
+ * alone. The peer address is necessary but not sufficient: a cross-site
749
+ * `fetch()` with a simple content type never triggers a preflight, so its side
750
+ * effects run even though the page cannot read the response, and a rebinding
751
+ * hostname resolves to 127.0.0.1 while still arriving with the attacker's name
752
+ * in `Host`.
753
+ *
754
+ * So the request must also carry a loopback authority, and any browser
755
+ * provenance headers must agree with it. A caller sending no `Origin` (curl, the
756
+ * CLI) is allowed, exactly as the core allows it. Loopback-only is also the
757
+ * contract the route has always had, so this narrows nothing that worked before.
758
+ */
759
+ function isTrustedBridgeRequest(req) {
760
+ const socket = req.socket === undefined || req.socket === null ? null : req.socket;
761
+ if (!isLoopbackAddress(socket === null ? "" : socket.remoteAddress)) return false;
762
+ const host = headerValue(req.headers, "host");
763
+ if (host === undefined) return false;
764
+ let hostUrl;
765
+ try {
766
+ hostUrl = new URL("http://" + host);
767
+ } catch (error) {
768
+ return false;
769
+ }
770
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
771
+ if (headerValue(req.headers, "sec-fetch-site") === "cross-site") return false;
772
+ const origin = headerValue(req.headers, "origin");
773
+ if (origin === undefined) return true;
774
+ try {
775
+ return new URL(origin).host === hostUrl.host;
776
+ } catch (error) {
777
+ return false;
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Whether a request body is JSON. Requiring it costs this plugin nothing (its own
783
+ * client always sends it) and makes every cross-site attempt a non-simple
784
+ * request, so the browser must preflight a route that never answers a preflight.
785
+ */
786
+ function isJsonRequest(req) {
787
+ const value = headerValue(req.headers, "content-type");
788
+ return value !== undefined && value.split(";")[0].trim().toLowerCase() === "application/json";
789
+ }
790
+
791
+ /**
792
+ * Whether a path addresses a network share or a device namespace. `isAbsolute`
793
+ * is true for `\\host\share` on Windows, and opening such a path makes Windows
794
+ * authenticate to that host — an NTLM hash leak reachable from a cross-site
795
+ * request. The theme only ever opens local workspace directories, so the whole
796
+ * `\\` and `//` namespace is refused.
797
+ */
798
+ function isNetworkPath(path) {
799
+ return path.startsWith("\\\\") || path.startsWith("//");
800
+ }
801
+
802
+ /** Read one JSON request body from the Node request stream (bounded). */
803
+ async function readJsonBody(req) {
804
+ let raw = "";
805
+ for await (const chunk of req) {
806
+ raw += chunk;
807
+ if (raw.length > 65536) throw new Error("request body too large");
808
+ }
809
+ return raw === "" ? null : JSON.parse(raw);
810
+ }
811
+
812
+ /** Write one JSON response. */
813
+ function sendJson(res, status, value) {
814
+ const payload = JSON.stringify(value);
815
+ res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
816
+ res.end(payload);
817
+ }
818
+
819
+ // The bridge rides `webServer` (declared hard: it is core to any web realm).
820
+ // `connection` is no longer injected — dsh 0.1.5's rpc carrier cannot host this
821
+ // plugin's channel without tripping the guard described in apply().
822
+ const inject = ["webServer"];
823
+
824
+ export { apply, inject };