dsh-windows-mcp 0.1.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 +67 -1
- package/LICENSE +0 -6
- package/README.md +49 -2
- package/README.zh.md +48 -2
- package/lib/client.js +728 -11
- package/lib/dependency.js +150 -23
- package/lib/index.js +267 -27
- package/lib/installs.js +196 -0
- package/lib/mirrors.js +244 -0
- package/lib/pypi.js +171 -0
- package/lib/settings.js +251 -0
- package/lib/updater.js +502 -0
- package/package.json +1 -1
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
|
+
}
|