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/CHANGELOG.md +24 -0
- package/README.md +12 -1
- package/README.zh.md +12 -1
- package/lib/client.js +594 -1
- package/lib/dependency.js +66 -5
- package/lib/index.js +119 -6
- package/lib/mirrors.js +9 -1
- package/lib/settings.js +251 -0
- package/lib/updater.js +9 -3
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
|
|
372
|
-
|
|
424
|
+
bridgeArgsLine(values.excludeTools),
|
|
425
|
+
` toolCallTimeoutMs: ${values.toolCallTimeoutMs}`,
|
|
373
426
|
" failOnStartupError: false",
|
|
374
427
|
" reconnect: { enabled: true, initialDelayMs: 500, maxDelayMs: 30000, maxAttempts: 10 }",
|
|
375
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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) {
|
|
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);
|
package/lib/settings.js
ADDED
|
@@ -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
|
-
|
|
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.
|
|
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",
|