dsh-windows-mcp 0.2.0 → 0.2.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/dependency.js CHANGED
@@ -355,10 +355,63 @@ export function yamlScalar(value) {
355
355
  return `'${String(value).replace(/'/gu, "''")}'`;
356
356
  }
357
357
 
358
- /** The complete bridge row, pointing at an installed executable. */
358
+ /**
359
+ * The bridge row values this package ships. The settings page may change them;
360
+ * `lib/settings.js` normalises user input against these same defaults.
361
+ */
362
+ export const BRIDGE_DEFAULTS = Object.freeze({
363
+ toolCallTimeoutMs: 120000,
364
+ excludeTools: Object.freeze(["App", "PowerShell", "FileSystem"]),
365
+ screenshotScale: 0.75,
366
+ screenshotBackend: "auto",
367
+ disableFlash: true,
368
+ });
369
+
370
+ /** Render `args:` — `--exclude-tools` takes one comma-separated argv. */
371
+ function bridgeArgsLine(excludeTools) {
372
+ const names = Array.from(excludeTools, (name) => String(name).trim()).filter((name) => name !== "");
373
+ if (names.length === 0) return " args: [serve]";
374
+ return ` args: [serve, --exclude-tools, ${JSON.stringify(names.join(","))}]`;
375
+ }
376
+
377
+ /** Render `env:` — only the keys that are set, in a stable order. */
378
+ function bridgeEnvLine(settings) {
379
+ const pairs = ['ANONYMIZED_TELEMETRY: "false"'];
380
+ if (settings.disableFlash !== false) pairs.push('WINDOWS_MCP_DISABLE_FLASH: "1"');
381
+ pairs.push(`WINDOWS_MCP_SCREENSHOT_SCALE: ${JSON.stringify(String(settings.screenshotScale))}`);
382
+ if (typeof settings.screenshotBackend === "string" && settings.screenshotBackend !== "auto") {
383
+ pairs.push(`WINDOWS_MCP_SCREENSHOT_BACKEND: ${JSON.stringify(settings.screenshotBackend)}`);
384
+ }
385
+ return ` env: { ${pairs.join(", ")} }`;
386
+ }
387
+
388
+ /** Fold user settings (or their absence) into one complete bridge value set. */
389
+ function bridgeValues(meta) {
390
+ const source = meta.settings !== null && typeof meta.settings === "object" ? meta.settings : null;
391
+ const merged = { ...BRIDGE_DEFAULTS };
392
+ if (source === null) return merged;
393
+ if (typeof source.toolCallTimeoutMs === "number" && Number.isFinite(source.toolCallTimeoutMs)) {
394
+ merged.toolCallTimeoutMs = Math.round(source.toolCallTimeoutMs);
395
+ }
396
+ if (Array.isArray(source.excludeTools)) merged.excludeTools = source.excludeTools.slice();
397
+ if (typeof source.screenshotScale === "number" && Number.isFinite(source.screenshotScale)) {
398
+ merged.screenshotScale = source.screenshotScale;
399
+ }
400
+ if (typeof source.screenshotBackend === "string" && source.screenshotBackend !== "") {
401
+ merged.screenshotBackend = source.screenshotBackend;
402
+ }
403
+ if (typeof source.disableFlash === "boolean") merged.disableFlash = source.disableFlash;
404
+ return merged;
405
+ }
406
+
407
+ /**
408
+ * The complete bridge row, pointing at an installed executable.
409
+ * `meta.settings` is the optional `bridge` half of the user settings.
410
+ */
359
411
  export function bridgeOverrideBlock(executable, meta = {}) {
360
412
  const stamp = typeof meta.stamp === "string" ? meta.stamp : new Date().toISOString();
361
413
  const version = typeof meta.version === "string" ? meta.version : "unknown";
414
+ const values = bridgeValues(meta);
362
415
  return [
363
416
  PROFILE_BLOCK_BEGIN,
364
417
  `# Written by ${PACKAGE_ID} ${version} at ${stamp}.`,
@@ -368,11 +421,11 @@ export function bridgeOverrideBlock(executable, meta = {}) {
368
421
  " serverName: windows",
369
422
  " transport: stdio",
370
423
  ` command: ${yamlScalar(executable)}`,
371
- ' args: [serve, --exclude-tools, "App,PowerShell,FileSystem"]',
372
- " toolCallTimeoutMs: 120000",
424
+ bridgeArgsLine(values.excludeTools),
425
+ ` toolCallTimeoutMs: ${values.toolCallTimeoutMs}`,
373
426
  " failOnStartupError: false",
374
427
  " reconnect: { enabled: true, initialDelayMs: 500, maxDelayMs: 30000, maxAttempts: 10 }",
375
- ' env: { ANONYMIZED_TELEMETRY: "false", WINDOWS_MCP_DISABLE_FLASH: "1", WINDOWS_MCP_SCREENSHOT_SCALE: "0.75" }',
428
+ bridgeEnvLine(values),
376
429
  PROFILE_BLOCK_END,
377
430
  ].join("\n");
378
431
  }
@@ -526,7 +579,15 @@ export function writeBridgeOverride(executable, options = {}) {
526
579
  backup = null;
527
580
  }
528
581
  }
529
- writeFileSync(file, withBridgeOverride(current, bridgeOverrideBlock(executable, { stamp: new Date().toISOString(), version }), eol), "utf8");
582
+ writeFileSync(
583
+ file,
584
+ withBridgeOverride(
585
+ current,
586
+ bridgeOverrideBlock(executable, { stamp: new Date().toISOString(), version, settings: options.settings }),
587
+ eol,
588
+ ),
589
+ "utf8",
590
+ );
530
591
  return { ok: true, path: file, backup, executable, restartRequired: true };
531
592
  } catch (error) {
532
593
  warn(`could not point the bridge at the installed Windows-MCP: ${String(error)}`);
package/lib/index.js CHANGED
@@ -39,6 +39,15 @@ import {
39
39
  writeBridgeOverride,
40
40
  } from "./dependency.js";
41
41
  import { UPDATE_ACTIONS, UPDATE_ROUTE, createUpdater } from "./updater.js";
42
+ import {
43
+ SETTINGS_ROUTE,
44
+ bridgeSettingsMatch,
45
+ bridgeSettingsOf,
46
+ normalizeSettings,
47
+ readBridgeSettings,
48
+ readSettings,
49
+ writeSettings,
50
+ } from "./settings.js";
42
51
 
43
52
  const PACKAGE_ID = "dsh-windows-mcp";
44
53
  const BRIDGE_ROW_ID = "windows-mcp-bridge";
@@ -62,11 +71,15 @@ const stateDirectory = (() => {
62
71
  const statePath = join(stateDirectory, "dsh-windows-mcp.state.json");
63
72
  const STATE_ROUTE = "/dsh-windows-mcp/state";
64
73
  const MAX_STATE_BODY_BYTES = 1024;
74
+ const MAX_SETTINGS_BODY_BYTES = 8192;
65
75
 
66
76
  /** Mirrors the persisted flag; read once at boot, updated by the state route.
67
77
  * Desktop control is opt-in: a fresh install starts OFF. */
68
78
  const computerUse = { enabled: false };
69
79
 
80
+ /** User settings; read once at boot, replaced by the settings route. */
81
+ let settings = null;
82
+
70
83
  /** The Windows-MCP version manager, created in apply() once the logger exists. */
71
84
  let updater = null;
72
85
 
@@ -525,6 +538,23 @@ function registerStateRoute(ctx, warn) {
525
538
  };
526
539
  };
527
540
 
541
+ /**
542
+ * Settings plus what the profile patch actually says: the two only
543
+ * match after a restart, because the MCP client reads its row at boot.
544
+ */
545
+ const settingsView = () => {
546
+ const current = settings === null ? normalizeSettings(null).settings : settings;
547
+ const patchText = readText(patchPath);
548
+ const applied = bridgeSettingsMatch(current, patchText);
549
+ return {
550
+ settings: current,
551
+ bridge: readBridgeSettings(patchText),
552
+ applied,
553
+ restartRequired: !applied,
554
+ pluginVersion: selfVersion(),
555
+ };
556
+ };
557
+
528
558
  const snapshot = () => {
529
559
  const legacy = dependencySnapshot(which, windowsMcpVersionPin(readText(patchPath)));
530
560
  const update = updater === null ? null : updater.view();
@@ -535,6 +565,7 @@ function registerStateRoute(ctx, warn) {
535
565
  dependency: updater === null ? legacy : updater.dependencyView(legacy),
536
566
  prepare: prepareSnapshot(),
537
567
  update,
568
+ settings: settingsView(),
538
569
  };
539
570
  };
540
571
 
@@ -704,11 +735,83 @@ function registerStateRoute(ctx, warn) {
704
735
  request.on("error", () => respond(response, 400, { error: "request failed" }));
705
736
  };
706
737
 
738
+ /**
739
+ * Read and update the user settings. Plugin settings apply at once;
740
+ * bridge settings are written into the profile patch row and only take
741
+ * effect after DSH restarts.
742
+ */
743
+ const settingsHandler = (request, response) => {
744
+ if (request.method === "GET") {
745
+ respond(response, 200, settingsView());
746
+ return;
747
+ }
748
+ if (request.method !== "POST") {
749
+ respond(response, 405, { error: "method not allowed" });
750
+ return;
751
+ }
752
+ const chunks = [];
753
+ let size = 0;
754
+ request.on("data", (chunk) => {
755
+ size += chunk.length;
756
+ if (size > MAX_SETTINGS_BODY_BYTES) {
757
+ request.destroy();
758
+ return;
759
+ }
760
+ chunks.push(chunk);
761
+ });
762
+ request.on("end", () => {
763
+ let body;
764
+ try {
765
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
766
+ } catch {
767
+ respond(response, 400, { error: "invalid JSON body" });
768
+ return;
769
+ }
770
+ const incoming = body !== null && typeof body === "object" && body.settings !== null && typeof body.settings === "object" ? body.settings : body;
771
+ const normalized = normalizeSettings(incoming);
772
+ const stored = writeSettings(normalized.settings, stateDirectory);
773
+ settings = stored.settings;
774
+ const wanted = bridgeSettingsOf(settings);
775
+ if (bridgeSettingsMatch(settings, readText(patchPath))) {
776
+ respond(response, 200, {
777
+ ok: true,
778
+ saved: stored.written,
779
+ problems: normalized.problems,
780
+ write: null,
781
+ ...settingsView(),
782
+ });
783
+ return;
784
+ }
785
+ const view = updater === null ? null : updater.view();
786
+ const executable = view?.versions?.activeExecutable ?? windowsMcpExecutable();
787
+ if (executable === null) {
788
+ respond(response, 200, {
789
+ ok: true,
790
+ saved: stored.written,
791
+ problems: normalized.problems,
792
+ write: { ok: false, error: "Windows-MCP is not installed yet, so the bridge row was left untouched" },
793
+ ...settingsView(),
794
+ });
795
+ return;
796
+ }
797
+ const written = writeBridgeOverride(executable, { warn, version: selfVersion(), settings: wanted });
798
+ respond(response, 200, {
799
+ ok: written.ok === true,
800
+ saved: stored.written,
801
+ problems: normalized.problems,
802
+ write: written,
803
+ ...settingsView(),
804
+ });
805
+ });
806
+ request.on("error", () => respond(response, 400, { error: "request failed" }));
807
+ };
808
+
707
809
  scoped.webServer.register({ kind: "exact", path: STATE_ROUTE, handler });
708
810
  scoped.webServer.register({ kind: "exact", path: PREPARE_ROUTE, handler: prepareHandler });
709
811
  scoped.webServer.register({ kind: "exact", path: PREPARE_CANCEL_ROUTE, handler: cancelHandler });
710
812
  scoped.webServer.register({ kind: "exact", path: BRIDGE_ROUTE, handler: bridgeHandler });
711
813
  scoped.webServer.register({ kind: "exact", path: UPDATE_ROUTE, handler: updateHandler });
814
+ scoped.webServer.register({ kind: "exact", path: SETTINGS_ROUTE, handler: settingsHandler });
712
815
  } catch (error) {
713
816
  warn(`could not register the Computer Use state route: ${String(error)}`);
714
817
  }
@@ -726,11 +829,18 @@ export function apply(ctx) {
726
829
  try {
727
830
  const warn = makeLogger(ctx);
728
831
  computerUse.enabled = readComputerUse().enabled;
832
+ settings = readSettings(stateDirectory);
729
833
  updater = createUpdater({
730
834
  warn,
731
835
  resolver: which,
732
836
  pinned: windowsMcpVersionPin(readText(patchPath)),
733
- writeBridge: (executable) => writeBridgeOverride(executable, { warn, version: selfVersion() }),
837
+ preferredMirror: () => (settings === null ? "auto" : settings.plugin.preferredMirror),
838
+ writeBridge: (executable) =>
839
+ writeBridgeOverride(executable, {
840
+ warn,
841
+ version: selfVersion(),
842
+ settings: settings === null ? null : bridgeSettingsOf(settings),
843
+ }),
734
844
  });
735
845
  auditStartup(warn);
736
846
  registerDoctor(ctx, warn);
@@ -738,11 +848,14 @@ export function apply(ctx) {
738
848
  registerToolGate(ctx, warn);
739
849
  // Check for a newer Windows-MCP build in the background (cheap: a ranged
740
850
  // read per mirror, cached for hours). The page shows the result; the boot
741
- // never waits for it, and a failure only costs the last known answer.
742
- try {
743
- void updater.check({ force: false }).catch(() => {});
744
- } catch {
745
- /* a failed check is not a boot problem */
851
+ // never waits for it, and a failure only costs the last known answer. The
852
+ // settings page can turn this off.
853
+ if (settings === null || settings.plugin.autoCheckUpdates === true) {
854
+ try {
855
+ void updater.check({ force: false }).catch(() => {});
856
+ } catch {
857
+ /* a failed check is not a boot problem */
858
+ }
746
859
  }
747
860
  } catch {
748
861
  /* apply() must not throw, whatever happens */
package/lib/mirrors.js CHANGED
@@ -205,8 +205,16 @@ export async function fetchProjectIndex(options = {}) {
205
205
  return { indexUrl, pageUrl, entries, versions, latest: latestStableVersion(versions) };
206
206
  }
207
207
 
208
+ /** Candidate mirrors in probe order, honouring a user preference. */
209
+ export function mirrorCandidates(preferred = "auto", candidates = MIRROR_CANDIDATES) {
210
+ const wanted = mirrorById(preferred, candidates);
211
+ if (wanted === null) return candidates.slice();
212
+ return [wanted].concat(candidates.filter((mirror) => mirror.id !== wanted.id));
213
+ }
214
+
208
215
  /** Mirrors in the order an install should try them: ranked first, official last. */
209
- export function mirrorOrder(probe, candidates = MIRROR_CANDIDATES) { const seen = new Set();
216
+ export function mirrorOrder(probe, candidates = MIRROR_CANDIDATES) {
217
+ const seen = new Set();
210
218
  const order = [];
211
219
  for (const id of Array.isArray(probe?.ranked) ? probe.ranked : []) {
212
220
  const mirror = mirrorById(id, candidates);
@@ -0,0 +1,251 @@
1
+ /**
2
+ * dsh-windows-mcp — user-adjustable settings (host side).
3
+ *
4
+ * The plugin's settings page (Settings → left rail) writes this file. There are
5
+ * two families, and they differ in when they take effect:
6
+ *
7
+ * - `bridge` values are baked into the machine-written row in the profile
8
+ * patch, so they only take effect after DSH restarts — the MCP client reads
9
+ * that row once, at boot.
10
+ * - `plugin` values belong to this plugin itself and apply immediately.
11
+ *
12
+ * Everything is validated and clamped before it is stored or rendered: a
13
+ * hand-edited, truncated or future-version file must never break the host, and
14
+ * a bad value must never produce a patch row the client cannot parse.
15
+ */
16
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { BRIDGE_DEFAULTS, PROFILE_BLOCK_BEGIN, PROFILE_BLOCK_END } from "./dependency.js";
20
+ import { MIRROR_CANDIDATES } from "./mirrors.js";
21
+
22
+ export const SETTINGS_ROUTE = "/dsh-windows-mcp/settings";
23
+ export const SETTINGS_VERSION = 1;
24
+ export const SETTINGS_FILE = "dsh-windows-mcp.settings.json";
25
+
26
+ /** Screenshot downscales the upstream server accepts. */
27
+ export const SCREENSHOT_SCALES = Object.freeze([0.25, 0.5, 0.75, 1]);
28
+ /** Upstream capture backends, `auto` lets the server pick. */
29
+ export const SCREENSHOT_BACKENDS = Object.freeze(["auto", "dxcam", "mss", "pillow"]);
30
+ /** Tools whose registration costs the most context; all on by default. */
31
+ export const EXCLUDABLE_TOOLS = Object.freeze(["App", "PowerShell", "FileSystem"]);
32
+ /** `auto` means "whatever the speed probe ranks first". */
33
+ export const MIRROR_CHOICES = Object.freeze(["auto"].concat(MIRROR_CANDIDATES.map((mirror) => mirror.id)));
34
+ /** `toolCallTimeoutMs` bounds — generous, but not unbounded. */
35
+ export const TIMEOUT_RANGE = Object.freeze({ min: 5000, max: 600000 });
36
+
37
+ export const DEFAULT_SETTINGS = Object.freeze({
38
+ version: SETTINGS_VERSION,
39
+ bridge: Object.freeze({
40
+ toolCallTimeoutMs: BRIDGE_DEFAULTS.toolCallTimeoutMs,
41
+ excludeTools: Object.freeze(BRIDGE_DEFAULTS.excludeTools.slice()),
42
+ screenshotScale: BRIDGE_DEFAULTS.screenshotScale,
43
+ screenshotBackend: BRIDGE_DEFAULTS.screenshotBackend,
44
+ disableFlash: BRIDGE_DEFAULTS.disableFlash,
45
+ }),
46
+ plugin: Object.freeze({
47
+ autoCheckUpdates: true,
48
+ preferredMirror: "auto",
49
+ }),
50
+ });
51
+
52
+ /** DSH home: `$DSH_HOME`, else `~/.dsh`. */
53
+ export function dshHome(env = process.env) {
54
+ const fromEnvironment = String(env?.DSH_HOME ?? "").trim();
55
+ return fromEnvironment === "" ? join(homedir(), ".dsh") : fromEnvironment;
56
+ }
57
+
58
+ /** Where the settings file lives for a DSH home. */
59
+ export function settingsPath(home = dshHome()) {
60
+ return join(home, SETTINGS_FILE);
61
+ }
62
+
63
+ /** A mutable copy of the defaults. */
64
+ export function defaultSettings() {
65
+ return {
66
+ version: SETTINGS_VERSION,
67
+ bridge: {
68
+ toolCallTimeoutMs: DEFAULT_SETTINGS.bridge.toolCallTimeoutMs,
69
+ excludeTools: DEFAULT_SETTINGS.bridge.excludeTools.slice(),
70
+ screenshotScale: DEFAULT_SETTINGS.bridge.screenshotScale,
71
+ screenshotBackend: DEFAULT_SETTINGS.bridge.screenshotBackend,
72
+ disableFlash: DEFAULT_SETTINGS.bridge.disableFlash,
73
+ },
74
+ plugin: {
75
+ autoCheckUpdates: DEFAULT_SETTINGS.plugin.autoCheckUpdates,
76
+ preferredMirror: DEFAULT_SETTINGS.plugin.preferredMirror,
77
+ },
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Coerce anything into a complete, valid settings object.
83
+ * @returns `{ settings, problems }` — `problems` names every value that was
84
+ * replaced by its default, so the page can say what it ignored.
85
+ */
86
+ export function normalizeSettings(input) {
87
+ const settings = defaultSettings();
88
+ const problems = [];
89
+ if (input === null || typeof input !== "object") return { settings, problems };
90
+ const bridge = input.bridge !== null && typeof input.bridge === "object" ? input.bridge : {};
91
+ const plugin = input.plugin !== null && typeof input.plugin === "object" ? input.plugin : {};
92
+
93
+ const timeout = Number(bridge.toolCallTimeoutMs);
94
+ if (Number.isFinite(timeout) && timeout >= TIMEOUT_RANGE.min && timeout <= TIMEOUT_RANGE.max) {
95
+ settings.bridge.toolCallTimeoutMs = Math.round(timeout);
96
+ } else if (bridge.toolCallTimeoutMs !== undefined) {
97
+ problems.push("bridge.toolCallTimeoutMs");
98
+ }
99
+
100
+ if (Array.isArray(bridge.excludeTools)) {
101
+ const wanted = [];
102
+ for (const value of bridge.excludeTools) {
103
+ const name = typeof value === "string" ? value.trim() : "";
104
+ if (EXCLUDABLE_TOOLS.includes(name) && !wanted.includes(name)) wanted.push(name);
105
+ }
106
+ const ignored = bridge.excludeTools.length - wanted.length;
107
+ if (ignored > 0) problems.push("bridge.excludeTools");
108
+ settings.bridge.excludeTools = wanted;
109
+ } else if (bridge.excludeTools !== undefined) {
110
+ problems.push("bridge.excludeTools");
111
+ }
112
+
113
+ const scale = Number(bridge.screenshotScale);
114
+ if (SCREENSHOT_SCALES.includes(scale)) settings.bridge.screenshotScale = scale;
115
+ else if (bridge.screenshotScale !== undefined) problems.push("bridge.screenshotScale");
116
+
117
+ if (SCREENSHOT_BACKENDS.includes(bridge.screenshotBackend)) settings.bridge.screenshotBackend = bridge.screenshotBackend;
118
+ else if (bridge.screenshotBackend !== undefined) problems.push("bridge.screenshotBackend");
119
+
120
+ if (typeof bridge.disableFlash === "boolean") settings.bridge.disableFlash = bridge.disableFlash;
121
+ else if (bridge.disableFlash !== undefined) problems.push("bridge.disableFlash");
122
+
123
+ if (typeof plugin.autoCheckUpdates === "boolean") settings.plugin.autoCheckUpdates = plugin.autoCheckUpdates;
124
+ else if (plugin.autoCheckUpdates !== undefined) problems.push("plugin.autoCheckUpdates");
125
+
126
+ if (MIRROR_CHOICES.includes(plugin.preferredMirror)) settings.plugin.preferredMirror = plugin.preferredMirror;
127
+ else if (plugin.preferredMirror !== undefined) problems.push("plugin.preferredMirror");
128
+
129
+ return { settings, problems };
130
+ }
131
+
132
+ /** Read the stored settings; a missing or corrupt file means "defaults". */
133
+ export function readSettings(home = dshHome()) {
134
+ try {
135
+ const text = readFileSync(settingsPath(home), "utf8");
136
+ return normalizeSettings(JSON.parse(text)).settings;
137
+ } catch {
138
+ return defaultSettings();
139
+ }
140
+ }
141
+
142
+ /** Validate, store (atomically) and return the settings that were kept. */
143
+ export function writeSettings(input, home = dshHome()) {
144
+ const { settings } = normalizeSettings(input);
145
+ const file = settingsPath(home);
146
+ const payload = `${JSON.stringify(settings, null, 2)}\n`;
147
+ try {
148
+ mkdirSync(dirname(file), { recursive: true });
149
+ const temporary = `${file}.${process.pid}.tmp`;
150
+ writeFileSync(temporary, payload, "utf8");
151
+ renameSync(temporary, file);
152
+ } catch (error) {
153
+ return { settings, written: false, error: String(error), path: file };
154
+ }
155
+ return { settings, written: true, error: null, path: file };
156
+ }
157
+
158
+ /** The five values that end up in the bridge row. */
159
+ export function bridgeSettingsOf(settings) {
160
+ const source = settings !== null && typeof settings === "object" && settings.bridge !== null && typeof settings.bridge === "object" ? settings.bridge : DEFAULT_SETTINGS.bridge;
161
+ return {
162
+ toolCallTimeoutMs: typeof source.toolCallTimeoutMs === "number" ? source.toolCallTimeoutMs : DEFAULT_SETTINGS.bridge.toolCallTimeoutMs,
163
+ excludeTools: Array.isArray(source.excludeTools) ? source.excludeTools.slice() : DEFAULT_SETTINGS.bridge.excludeTools.slice(),
164
+ screenshotScale: typeof source.screenshotScale === "number" ? source.screenshotScale : DEFAULT_SETTINGS.bridge.screenshotScale,
165
+ screenshotBackend: typeof source.screenshotBackend === "string" ? source.screenshotBackend : DEFAULT_SETTINGS.bridge.screenshotBackend,
166
+ disableFlash: typeof source.disableFlash === "boolean" ? source.disableFlash : DEFAULT_SETTINGS.bridge.disableFlash,
167
+ };
168
+ }
169
+
170
+ /** Split the YAML flow sequence in `args: [...]`, keeping quoted commas intact. */
171
+ function splitFlowList(raw) {
172
+ const parts = [];
173
+ let current = "";
174
+ let quoted = false;
175
+ for (const character of String(raw)) {
176
+ if (character === '"') quoted = !quoted;
177
+ if (character === "," && !quoted) {
178
+ parts.push(current.trim());
179
+ current = "";
180
+ continue;
181
+ }
182
+ current += character;
183
+ }
184
+ if (current.trim() !== "") parts.push(current.trim());
185
+ return parts.map((part) => part.replace(/^["']|["']$/gu, "").trim()).filter((part) => part !== "");
186
+ }
187
+
188
+ /**
189
+ * Read the bridge values back out of a profile patch layer.
190
+ * @returns `{ toolCallTimeoutMs, excludeTools, screenshotScale,
191
+ * screenshotBackend, disableFlash }` — missing fields stay `undefined`.
192
+ */
193
+ export function readBridgeSettings(patchText) {
194
+ const text = String(patchText ?? "");
195
+ const begin = text.indexOf(PROFILE_BLOCK_BEGIN);
196
+ const end = text.indexOf(PROFILE_BLOCK_END);
197
+ const block = begin === -1 || end === -1 || end < begin ? "" : text.slice(begin, end);
198
+ const found = {
199
+ toolCallTimeoutMs: undefined,
200
+ excludeTools: undefined,
201
+ screenshotScale: undefined,
202
+ screenshotBackend: undefined,
203
+ disableFlash: undefined,
204
+ };
205
+
206
+ const timeout = /(?:^|\n)\s*toolCallTimeoutMs:\s*(\d+)/u.exec(block);
207
+ if (timeout !== null) found.toolCallTimeoutMs = Number(timeout[1]);
208
+
209
+ const args = /(?:^|\n)\s*args:\s*\[([^\]]*)\]/u.exec(block);
210
+ if (args !== null) {
211
+ const tokens = splitFlowList(args[1]);
212
+ const index = tokens.indexOf("--exclude-tools");
213
+ if (index === -1) found.excludeTools = [];
214
+ else if (index + 1 < tokens.length) found.excludeTools = tokens[index + 1].split(",").map((name) => name.trim()).filter((name) => name !== "");
215
+ }
216
+
217
+ const scale = /WINDOWS_MCP_SCREENSHOT_SCALE:\s*"([0-9.]+)"/u.exec(block);
218
+ if (scale !== null) found.screenshotScale = Number(scale[1]);
219
+
220
+ const backend = /WINDOWS_MCP_SCREENSHOT_BACKEND:\s*"([a-z]+)"/u.exec(block);
221
+ if (backend !== null) found.screenshotBackend = backend[1];
222
+
223
+ const flash = /WINDOWS_MCP_DISABLE_FLASH:\s*"([01])"/u.exec(block);
224
+ if (flash !== null) found.disableFlash = flash[1] === "1";
225
+ else if (block !== "") found.disableFlash = false;
226
+
227
+ return found;
228
+ }
229
+
230
+ /**
231
+ * Do the stored settings already match the bridge row in `patchText`? A field the
232
+ * row does not declare falls back to this package's shipped default, so a patch
233
+ * layer without a machine-written block still compares equal to the defaults.
234
+ */
235
+ export function bridgeSettingsMatch(settings, patchText) {
236
+ const wanted = bridgeSettingsOf(settings);
237
+ const found = readBridgeSettings(patchText);
238
+ const actual = {
239
+ toolCallTimeoutMs: found.toolCallTimeoutMs === undefined ? BRIDGE_DEFAULTS.toolCallTimeoutMs : found.toolCallTimeoutMs,
240
+ excludeTools: found.excludeTools === undefined ? BRIDGE_DEFAULTS.excludeTools.slice() : found.excludeTools,
241
+ screenshotScale: found.screenshotScale === undefined ? BRIDGE_DEFAULTS.screenshotScale : found.screenshotScale,
242
+ screenshotBackend: found.screenshotBackend === undefined ? BRIDGE_DEFAULTS.screenshotBackend : found.screenshotBackend,
243
+ disableFlash: found.disableFlash === undefined ? BRIDGE_DEFAULTS.disableFlash : found.disableFlash,
244
+ };
245
+ if (actual.toolCallTimeoutMs !== wanted.toolCallTimeoutMs) return false;
246
+ if (actual.screenshotScale !== wanted.screenshotScale) return false;
247
+ if (actual.disableFlash !== wanted.disableFlash) return false;
248
+ if (actual.screenshotBackend !== wanted.screenshotBackend) return false;
249
+ if (actual.excludeTools.length !== wanted.excludeTools.length) return false;
250
+ return wanted.excludeTools.every((name) => actual.excludeTools.includes(name));
251
+ }
package/lib/updater.js CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  writeManifest,
33
33
  writeVersionsState,
34
34
  } from "./installs.js";
35
- import { fetchProjectIndex, describeMirror, formatBytesPerSecond, mirrorOrder, probeMirrors } from "./mirrors.js";
35
+ import { fetchProjectIndex, describeMirror, formatBytesPerSecond, mirrorCandidates, mirrorOrder, probeMirrors } from "./mirrors.js";
36
36
  import { latestStableVersion } from "./pypi.js";
37
37
 
38
38
  /** Route the page posts update actions to. */
@@ -75,6 +75,8 @@ function summarizeProbe(probe) {
75
75
  * @param options.readIndex - project index reader (tests inject one).
76
76
  * @param options.spawnInstall - `uv tool install` starter (tests inject one).
77
77
  * @param options.now - clock (tests inject one).
78
+ * @param options.preferredMirror - `() => id`; a mirror the user pinned, or
79
+ * `"auto"` to keep whatever the speed probe ranks first.
78
80
  */
79
81
  export function createUpdater(options = {}) {
80
82
  const home = options.home ?? dshHome();
@@ -85,6 +87,7 @@ export function createUpdater(options = {}) {
85
87
  const readIndex = typeof options.readIndex === "function" ? options.readIndex : fetchProjectIndex;
86
88
  const startInstall = typeof options.spawnInstall === "function" ? options.spawnInstall : spawnUvInstall;
87
89
  const now = typeof options.now === "function" ? options.now : () => Date.now();
90
+ const preferredMirrorId = typeof options.preferredMirror === "function" ? options.preferredMirror : () => "auto";
88
91
  const pinned = typeof options.pinned === "string" && options.pinned !== "" ? options.pinned : null;
89
92
  const root = managedToolchainRoot(home);
90
93
 
@@ -194,8 +197,11 @@ export function createUpdater(options = {}) {
194
197
  checkState.phase = "checking";
195
198
  checkState.error = null;
196
199
  let probeResult = null;
200
+ let candidates = mirrorCandidates("auto");
197
201
  try {
198
- probeResult = await probe({ project: WINDOWS_MCP_TOOL });
202
+ const preferred = preferredMirrorId();
203
+ candidates = mirrorCandidates(typeof preferred === "string" ? preferred : "auto");
204
+ probeResult = await probe({ project: WINDOWS_MCP_TOOL, candidates });
199
205
  lastProbe = probeResult;
200
206
  } catch (error) {
201
207
  checkState.phase = "failed";
@@ -206,7 +212,7 @@ export function createUpdater(options = {}) {
206
212
  let latest = null;
207
213
  let best = null;
208
214
  let lastError = "no mirror answered";
209
- for (const mirror of mirrorOrder(probeResult)) {
215
+ for (const mirror of mirrorOrder(probeResult, candidates)) {
210
216
  try {
211
217
  const index = await readIndex({ indexUrl: mirror.indexUrl, project: WINDOWS_MCP_TOOL });
212
218
  latest = index.latest ?? latestStableVersion(index.versions ?? []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-windows-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Windows desktop automation for DeepSeek Harness — plugs CursorTouch/Windows-MCP (screenshot, UI automation, input, clipboard, processes, registry) into DSH through the official MCP client bridge.",
5
5
  "license": "MIT",
6
6
  "author": "dsh-windows-mcp contributors",