Repository navigation
Expand file tree
/
Copy pathcontroller.mjs
More file actions
582 lines (563 loc) · 26.3 KB
/
Copy pathcontroller.mjs
File metadata and controls
582 lines (563 loc) · 26.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
#!/usr/bin/env node
/*
ZCode 提示词增强 · CDP 控制器(zcode-plus-cdp)
与 asar 注入版的关系:
- 服务端(enhance-http.mjs / enhance-core.mjs / zcode-config.mjs)**完全复用**,一字未改
- 页面脚本(enhance-prompt-inject.js)功能不变,只是"怎么进页面"换了通道
- asar 版:脚本打进 app.asar 的 out/renderer/index.html(改一次重打包一次)
CDP 版:本控制器以 --remote-debugging-port 拉起 ZCode,经 CDP 注入(零侵入,
不碰安装目录、不破坏签名、不影响自动更新;改脚本重启即生效)
职责:
1. 探测/拉起 ZCode.exe(--remote-debugging-port,端口 9333-9350 bind 预检顺延)
2. 拉起本地增强服务 enhance-http.mjs:19836(服务端保留看门狗,随 ZCode 存亡)
3. CDP 连接并注入页面脚本(新文档自动注入 + 当前文档立即注入)
4. 提供受信输入通道 insertText:ZCode 3.11+ 的 Lexical 输入框对程序化改写不认账,
只有受信按键走真实输入管线才可靠(页面脚本优先走此通道,失败回退原写入逻辑)
零第三方依赖:Node 18+(推荐 22+,原生 WebSocket / fetch)。
*/
import { spawn, execSync } from "node:child_process";
import { createServer } from "node:net";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import http from "node:http";
import { createHash } from "node:crypto";
import { Script } from "node:vm";
import { fileURLToPath } from "node:url";
const INSTALL_DIR = path.dirname(fileURLToPath(import.meta.url));
const LOG_FILE = path.join(INSTALL_DIR, "zcode-plus-cdp.log");
const CONTROLLER_VERSION = "1.0.0";
const SERVICE_PORT = Number(process.env.ENHANCE_HTTP_PORT || 19836);
const SERVICE_SCRIPT = path.join(INSTALL_DIR, "mcp", "enhance-http.mjs");
const INJECT_SOURCE_FILE = path.join(INSTALL_DIR, "mcp", "enhance-prompt-inject.js");
const CONFIG_FILE = path.join(INSTALL_DIR, "zcode-plus-cdp.json");
const PORT_RANGE = [9333, 9350];
const CDP_BOOT_TIMEOUT_MS = 30000;
// ---- 配置(首次运行生成)----
function ensureDefaultConfig() {
if (fs.existsSync(CONFIG_FILE)) return;
const config = {
_readme: [
"ZCode 提示词增强 · CDP 模式配置文件(JSON,不支持注释)",
"zcodePath:ZCode.exe 完整路径;留空 \"\" 表示自动探测。推荐正斜杠,如 E:/zcode/ZCode.exe",
"port:调试端口,默认 9333;被占用自动顺延(9334-9350)",
],
zcodePath: "",
port: PORT_RANGE[0],
};
try {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
log(`已生成默认配置文件:${CONFIG_FILE}`);
} catch (error) { log("生成默认配置文件失败:", safeError(error)); }
}
ensureDefaultConfig();
function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
}
function log(...parts) {
const line = `[${new Date().toISOString()}] ` + parts.join(" ");
try { fs.appendFileSync(LOG_FILE, line + "\n"); } catch {}
}
function safeError(error, secrets = []) {
let text = String(error?.message || error || "未知错误");
for (const secret of secrets) {
if (secret && secret.length > 8) text = text.split(secret).join("[redacted]");
}
return text.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-[redacted]").slice(0, 500);
}
// 弹窗文本走 base64(UTF-16LE):规避中文/换行/引号在 cmd→PowerShell 间的多层转义
function showMessageBox(text, buttons = "OK") {
const b64 = Buffer.from(text, "utf16le").toString("base64");
const cmd = `powershell -NoProfile -Command "$t=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${b64}')); Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show($t,'ZCode 提示词增强','${buttons}','Warning')"`;
try { return execSync(cmd, { encoding: "utf8", timeout: 120000 }).trim(); }
catch { return ""; }
}
// ---- ZCode.exe 探测 ----
function configuredZcodePath() {
// 优先级:环境变量 > zcode-plus-cdp.json > mcp/config.json(配置页「设置」里保存的 ZCode 路径,2026-09-09 接入)
return String(
process.env.ZCODE_PATH
|| readJson(CONFIG_FILE)?.zcodePath
|| readJson(path.join(INSTALL_DIR, "mcp", "config.json"))?.zcodePath
|| ""
).trim() || null;
}
function listDriveRoots() {
const roots = [];
for (let code = "C".charCodeAt(0); code <= "Z".charCodeAt(0); code++) {
const root = `${String.fromCharCode(code)}:\\`;
try { if (fs.existsSync(root)) roots.push(root); } catch {}
}
return roots;
}
function isFile(p) {
try { return fs.statSync(p).isFile(); } catch { return false; }
}
function findZcodePath() {
const configured = configuredZcodePath();
if (configured) {
if (isFile(configured)) return { path: configured, source: "手动配置" };
return { path: null, error: `配置的 zcodePath 无效(不是可用的 ZCode.exe):${configured}` };
}
const candidates = [];
const add = (p, source) => { if (p) candidates.push({ p, source }); };
// 1) 本目录及逐级向上
let cur = INSTALL_DIR;
for (let depth = 0; depth < 6 && cur; depth++) {
add(path.join(cur, "ZCode.exe"), "控制器所在位置");
const parent = path.dirname(cur);
if (parent === cur) break;
cur = parent;
}
// 2) 各盘符常见目录(注意 Windows 文件名大小写不敏感,\zcode 与 \ZCode 等价,无需重复)
for (const root of listDriveRoots()) add(path.join(root, "zcode", "ZCode.exe"), "盘符常见位置");
// 3) 各盘符 Program Files(用户把"装到非 C 盘"勾上时 NSIS 默认落点,2026-09-09 补)
for (const root of listDriveRoots()) {
add(path.join(root, "Program Files", "ZCode", "ZCode.exe"), "盘符 Program Files");
add(path.join(root, "Program Files (x86)", "ZCode", "ZCode.exe"), "盘符 Program Files");
}
// 4) 标准安装位置
add("C:\\Program Files\\ZCode\\ZCode.exe", "标准安装位置");
add("C:\\Program Files (x86)\\ZCode\\ZCode.exe", "标准安装位置");
add(path.join(os.homedir(), "AppData", "Local", "Programs", "ZCode", "ZCode.exe"), "标准安装位置");
// 5) 注册表 App Paths(部分安装器会注册,优先级低于磁盘直查)
for (const hive of ["HKCU", "HKLM"]) {
try {
const out = execSync(
`reg query "${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\ZCode.exe" /ve`,
{ encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"] }
);
const m = out.match(/REG_SZ\s+(.+)/);
if (m) add(m[1].trim().replace(/^"|"$/g, ""), "注册表 App Paths");
} catch {}
}
// 6) PATH
try {
const out = execSync("where ZCode.exe", { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "pipe"] });
add(out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0], "PATH");
} catch {}
const tried = [];
for (const { p, source } of candidates) {
tried.push(p);
try { if (isFile(p)) return { path: p, source, tried }; } catch {}
}
return { path: null, tried };
}
// ---- 本地增强服务(enhance-http.mjs:19836)----
async function pingService(timeoutMs = 1500) {
return new Promise((resolve) => {
const req = http.get({ hostname: "127.0.0.1", port: SERVICE_PORT, path: "/api/ping", timeout: timeoutMs }, (res) => {
let data = "";
res.on("data", (c) => { data += c; });
res.on("end", () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
});
req.on("error", () => resolve(null));
req.on("timeout", () => { req.destroy(); resolve(null); });
});
}
async function ensureService(maxWaitMs = 15000) {
const alive = await pingService();
if (alive?.ok) { log(`增强服务已在运行(:${SERVICE_PORT})`); return true; }
if (!fs.existsSync(SERVICE_SCRIPT)) {
log("增强服务脚本缺失:", SERVICE_SCRIPT);
return false;
}
log("启动增强服务:", SERVICE_SCRIPT);
const child = spawn(process.execPath, [SERVICE_SCRIPT], {
cwd: path.dirname(SERVICE_SCRIPT),
env: { ...process.env, ENHANCE_HTTP_PORT: String(SERVICE_PORT) },
stdio: "ignore", detached: false,
});
child.once("error", (error) => log("增强服务启动失败:", safeError(error)));
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
const pong = await pingService(1000);
if (pong?.ok) { log("增强服务已就绪"); return true; }
await new Promise((r) => setTimeout(r, 500));
}
log("增强服务启动超时");
return false;
}
// ---- 独立设置窗口的 CDP 等效替代 ----
// asar 版由 Electron 主进程消费 /api/window/open 弹原生 BrowserWindow;
// CDP 模式没有主进程,改为控制器消费同一标志后弹**独立应用窗口**:
// 优先 Edge/Chrome 的 --app 模式(无地址栏、无标签栏的独立窗口,视觉与 asar 的
// BrowserWindow 基本一致,可拖动、可放 ZCode 旁边),都没有时回退默认浏览器。
function httpGetJson(path, timeoutMs = 2000) {
return new Promise((resolve, reject) => {
const req = http.get({ hostname: "127.0.0.1", port: SERVICE_PORT, path, timeout: timeoutMs }, (res) => {
let data = "";
res.on("data", (c) => { data += c; });
res.on("end", () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
});
}
function findAppBrowser() {
const candidates = [
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
];
for (const p of candidates) { try { if (isFile(p)) return p; } catch {} }
return null;
}
function openGuiWindow() {
const url = `http://127.0.0.1:${SERVICE_PORT}/gui?ts=${Date.now()}`;
const exe = findAppBrowser();
if (exe) {
try { spawn(exe, [`--app=${url}`], { detached: true, stdio: "ignore" }).unref(); log("已弹独立应用窗口(--app):", exe); return; }
catch (error) { log("应用窗口启动失败:", safeError(error), ",回退默认浏览器"); }
}
try { spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); log("未找到 Edge/Chrome,已用默认浏览器打开配置页"); }
catch (error) { log("打开配置页失败:", safeError(error)); }
}
function startWindowFlagWatcher() {
setInterval(() => {
httpGetJson("/api/window/open").then((data) => {
if (data && data.open) openGuiWindow();
}).catch(() => {});
}, 1200);
}
// ---- 端口与进程 ----
async function portInUse(port) {
return new Promise((resolve) => {
const srv = createServer();
srv.once("error", () => resolve(true));
srv.once("listening", () => srv.close(() => resolve(false)));
srv.listen(port, "127.0.0.1");
});
}
async function findFreePort(preferred) {
for (let p = Math.max(PORT_RANGE[0], preferred); p <= PORT_RANGE[1]; p++) {
if (!(await portInUse(p))) return p;
}
throw new Error(`端口 ${PORT_RANGE[0]}-${PORT_RANGE[1]} 全部被占用,无法启动`);
}
function portPreferred() {
return Number(process.env.ZCODE_DEBUG_PORT) || readJson(CONFIG_FILE)?.port || PORT_RANGE[0];
}
async function fetchJson(url, timeoutMs = 3000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try { const res = await fetch(url, { signal: controller.signal }); if (!res.ok) return null; return await res.json(); }
catch { return null; } finally { clearTimeout(timer); }
}
async function cdpVersion(port) {
const data = await fetchJson(`http://127.0.0.1:${port}/json/version`, 3000);
return data?.webSocketDebuggerUrl ? data : null;
}
async function isZcodeBrowser(port) {
const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`, 3000);
return (Array.isArray(targets) ? targets : []).some((t) => {
const title = String(t?.title || "").toLowerCase();
const url = String(t?.url || "").toLowerCase();
return title.includes("zcode") || url.includes("zcode");
});
}
// 已有带调试端口的 ZCode 实例:直接附着,不重复拉起
async function findRunningInstance() {
for (let p = PORT_RANGE[0]; p <= PORT_RANGE[1]; p++) {
const version = await cdpVersion(p);
if (!version) continue;
if (await isZcodeBrowser(p)) return { port: p, version };
}
return null;
}
function zcodeProcessesRunning() {
try {
const out = execSync('tasklist /fi "IMAGENAME eq ZCode.exe" /fo csv /nh', { encoding: "utf8" });
return /ZCode\.exe/i.test(out);
} catch { return false; }
}
async function askCloseOriginal() {
return showMessageBox(
"ZCode 原版正在运行(无调试端口)。是否关闭并以 CDP 模式重启?\n未发送的草稿会丢失,请先确认。",
"YesNo",
).includes("Yes");
}
function launchZcode(port, zcodePath) {
const child = spawn(zcodePath, [`--remote-debugging-port=${port}`], {
cwd: path.dirname(zcodePath), detached: false, stdio: "ignore", shell: false,
});
child.once("error", (error) => {
log(`拉起 ZCode 失败: ${safeError(error)}(zcodePath=${zcodePath})`);
showMessageBox(`拉起 ZCode 失败:${safeError(error)}\n\n请检查配置中的 zcodePath:\n${CONFIG_FILE}`, "OK");
process.exit(1);
});
child.once("exit", (code) => {
log(`ZCode 退出 (code=${code}),控制器随之退出`);
process.exit(0);
});
return child;
}
async function waitForCdp(port, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const version = await cdpVersion(port);
if (version) return version;
await new Promise((r) => setTimeout(r, 800));
}
return null;
}
// ---- CDP 客户端(Node 22+ 原生 WebSocket)----
let INJECT_SOURCE = "";
let injectMtime = 0;
// 内容指纹随脚本一起注入页面:页面脚本的重装守卫据此判断"是不是同一份脚本"。
// 只靠控制器版本号不行——改 inject.js 而版本不变时,页面文档未重载的话旧 runtime
// 会以"版本相同"为由挡掉新脚本,表现为改了永远不生效。
const VERSION_PREFIX = (hash) => `globalThis.__zcodeCdpControllerVersion = ${JSON.stringify(CONTROLLER_VERSION)};\nglobalThis.__zcodeCdpInjectHash = ${JSON.stringify(hash || "")};\n`;
function loadInjectSource() {
try {
const src = fs.readFileSync(INJECT_SOURCE_FILE, "utf8");
// 语法预检:解析不过就别往页面推(只挡语法错误,挡不住引用未声明变量——那靠防抖)
try { new Script(src, { filename: INJECT_SOURCE_FILE }); }
catch (error) {
log("注入脚本语法错误,保持上一版不变:", safeError(error));
return false;
}
const hash = createHash("sha256").update(src, "utf8").digest("hex").slice(0, 16);
INJECT_SOURCE = VERSION_PREFIX(hash) + src;
injectMtime = fs.statSync(INJECT_SOURCE_FILE).mtimeMs;
return true;
} catch (error) { log("无法读取页面脚本:", safeError(error)); return false; }
}
if (!loadInjectSource()) process.exit(1);
class CdpConnection {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.nextId = 1;
this.pending = new Map();
this.sessions = new Map(); // targetId -> sessionId(热重载要用 sessionId 发命令,存错就会报 Session not found)
this.injectIds = new Map(); // sessionId -> addScriptToEvaluateOnNewDocument identifier(热重载时替换)
this.bindingHandler = null;
this.closed = false;
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
const timer = setTimeout(() => reject(new Error("连接 CDP 超时")), 15000);
this.ws.addEventListener("open", () => { clearTimeout(timer); resolve(); });
this.ws.addEventListener("error", () => { clearTimeout(timer); reject(new Error("CDP WebSocket 连接失败")); });
this.ws.addEventListener("close", () => {
this.closed = true;
for (const { reject: fn } of this.pending.values()) fn(new Error("CDP 连接已关闭"));
this.pending.clear();
});
this.ws.addEventListener("message", (ev) => this.onMessage(ev.data));
});
}
onMessage(raw) {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
if (msg.id && this.pending.has(msg.id)) {
const entry = this.pending.get(msg.id);
this.pending.delete(msg.id);
if (msg.error) entry.reject(new Error(`CDP ${msg.error.message || "调用失败"}`));
else entry.resolve(msg.result);
return;
}
if (msg.method === "Runtime.bindingCalled" && msg.params?.name === "__wbEnhance") {
this.bindingHandler?.(msg.sessionId, msg.params.payload);
} else if (msg.method === "Target.attachedToTarget") {
this.onAttached(msg.params);
} else if (msg.method === "Target.targetDestroyed") {
this.sessions.delete(msg.params?.targetId);
}
}
send(method, params = {}, sessionId) {
if (this.closed) return Promise.reject(new Error("CDP 连接已关闭"));
const id = this.nextId++;
const message = { id, method, params };
if (sessionId) message.sessionId = sessionId;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try { this.ws.send(JSON.stringify(message)); }
catch (error) { this.pending.delete(id); reject(error); }
});
}
async onAttached(params) {
const info = params?.targetInfo;
if (!info || (info.type !== "page" && info.type !== "webview")) return;
const sessionId = params.sessionId;
if (this.sessions.has(info.targetId)) return;
this.sessions.set(info.targetId, sessionId);
try {
await this.send("Runtime.enable", {}, sessionId);
await this.send("Runtime.addBinding", { name: "__wbEnhance" }, sessionId);
await this.send("Page.enable", {}, sessionId);
// 新文档自动注入 + 当前文档立即注入(脚本幂等,重复执行无副作用)
const reg = await this.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SOURCE }, sessionId);
this.injectIds.set(sessionId, reg?.identifier || null);
await this.send("Runtime.evaluate", { expression: INJECT_SOURCE }, sessionId);
log(`已注入页面 target=${info.targetId}`);
} catch (error) {
log("注入页面失败:", safeError(error));
}
}
// 热重载:inject 源文件变化后,对所有已连接页面先卸载旧运行时再注入新脚本
async reinjectAll() {
for (const [targetId, sessionId] of this.sessions) {
try {
const oldId = this.injectIds.get(sessionId);
if (oldId) await this.send("Page.removeScriptToEvaluateOnNewDocument", { identifier: oldId }, sessionId).catch(() => {});
const reg = await this.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SOURCE }, sessionId);
this.injectIds.set(sessionId, reg?.identifier || null);
await this.send("Runtime.evaluate", {
expression: "try{globalThis.__zcodePlusCdpEnhanceRuntime&&globalThis.__zcodePlusCdpEnhanceRuntime.dispose&&globalThis.__zcodePlusCdpEnhanceRuntime.dispose()}catch(e){}; true",
}, sessionId);
await this.send("Runtime.evaluate", { expression: INJECT_SOURCE }, sessionId);
} catch (error) {
log("热重载注入失败:", safeError(error));
}
}
}
reply(sessionId, requestId, result) {
const expression = `window.__wbEnhanceReply && window.__wbEnhanceReply(${Number(requestId) || 0}, ${JSON.stringify(JSON.stringify(result))})`;
return this.send("Runtime.evaluate", { expression }, sessionId).catch((error) => {
log("回复页面失败:", safeError(error));
});
}
}
// ---- 页面请求分发 ----
// 页面只经此通道请求"受信输入";LLM 调用/历史/收藏/配置仍走本地 HTTP 服务(服务端逻辑不变)
async function handleBinding(cdp, sessionId, payload) {
let msg;
try { msg = JSON.parse(payload); } catch { return; }
const id = msg.id;
if (!Number.isFinite(id)) return;
try {
if (msg.type === "insertText") {
const text = String(msg.text ?? "");
if (!text || text.length > 100000) throw new Error("无效的 insertText 请求");
// 受信 Ctrl+A 全选 → 插入文本:镜像真实用户操作,Lexical 输入框才认账
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 }, sessionId);
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: "a", code: "KeyA", windowsVirtualKeyCode: 65, modifiers: 2 }, sessionId);
await cdp.send("Input.insertText", { text }, sessionId);
await cdp.reply(sessionId, id, { ok: true });
} else if (msg.type === "ping") {
await cdp.reply(sessionId, id, { ok: true, version: CONTROLLER_VERSION });
} else {
await cdp.reply(sessionId, id, { ok: false, error: "未知请求类型" });
}
} catch (error) {
log("请求处理失败:", safeError(error));
await cdp.reply(sessionId, id, { ok: false, error: safeError(error) });
}
}
async function serveCdp(wsUrl) {
for (let attempt = 1; attempt <= 5; attempt++) {
const cdp = new CdpConnection(wsUrl);
try {
await cdp.connect();
cdp.bindingHandler = (sessionId, payload) => { void handleBinding(cdp, sessionId, payload); };
await cdp.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
const targets = await cdp.send("Target.getTargets");
for (const info of targets.targetInfos || []) {
if (info.type === "page" || info.type === "webview") {
try { await cdp.send("Target.attachToTarget", { targetId: info.targetId, flatten: true }); }
catch (error) { log("附着已有页面失败:", safeError(error)); }
}
}
log("CDP 已连接并开始服务");
// 注入源热重载:inject.js 改动后自动重新注入所有页面(先卸载旧运行时,无需重启控制器)
// ⚠ 必须防抖:编辑工具保存文件是"多次写入",watchFile 可能抓到**编辑中间态**
//(例如先加用法、后加声明的那一瞬),语法检查也查不出(少声明不是语法错误),
// 注入后页面就会报 "xxx is not defined"。故等文件稳定 1.2 秒再重载,并做语法预检。
let currentCdp = cdp;
let reloadTimer = 0;
fs.watchFile(INJECT_SOURCE_FILE, { interval: 2000 }, () => {
let m = 0;
try { m = fs.statSync(INJECT_SOURCE_FILE).mtimeMs; } catch {}
if (!m || m === injectMtime) return;
clearTimeout(reloadTimer);
reloadTimer = setTimeout(() => {
if (loadInjectSource()) {
log("检测到注入脚本更新,热重载中…");
currentCdp.reinjectAll().then(() => log("热重载完成"));
}
}, 1200);
});
await new Promise((resolve) => {
const check = setInterval(() => { if (cdp.closed) { clearInterval(check); resolve(); } }, 1000);
});
log("CDP 连接断开,2 秒后重试");
} catch (error) {
log(`CDP 连接失败 (第 ${attempt} 次):`, safeError(error));
}
if (attempt >= 5) { log("CDP 重试次数用尽,控制器退出"); process.exit(1); }
await new Promise((r) => setTimeout(r, 2000));
}
}
// ---- 主流程 ----
async function main() {
let found = findZcodePath();
if (found.error || !found.path) {
log(found.error || `未找到 ZCode.exe,已尝试:${found.tried.join("; ")}`);
const guide = found.error || "自动探测未找到 ZCode.exe(已尝试控制器所在位置、各盘符常见目录、标准安装位置、PATH)。";
const choice = showMessageBox(
guide + `\n\n请在配置文件中手动填写 zcodePath:\n${CONFIG_FILE}\n\n`
+ `示例(推荐正斜杠):\n"zcodePath": "E:/zcode/ZCode.exe"\n\n`
+ `是否现在用记事本打开配置文件编辑?(保存后自动重试)`,
"YesNo",
);
if (choice.includes("Yes")) {
try { spawn("notepad.exe", [CONFIG_FILE], { detached: true, stdio: "ignore" }).unref(); } catch {}
let mtimeBefore = 0;
try { mtimeBefore = fs.statSync(CONFIG_FILE).mtimeMs; } catch {}
const deadline = Date.now() + 180000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1000));
const cfg = configuredZcodePath();
if (cfg && isFile(cfg)) break;
try { if (fs.statSync(CONFIG_FILE).mtimeMs !== mtimeBefore) break; } catch {}
}
found = findZcodePath();
}
}
if (found.error || !found.path) {
log(found.error || "仍未找到 ZCode.exe");
showMessageBox(`启动失败:${found.error || "未找到 ZCode.exe。"}\n\n请在配置中检查 zcodePath 后重新运行:\n${CONFIG_FILE}\n\n日志:${LOG_FILE}`, "OK");
process.exit(1);
}
log(`控制器启动 (zcode=${found.path}, 来源=${found.source}, 版本=${CONTROLLER_VERSION})`);
// 增强服务先就绪(页面脚本一进页面就会请求 /api/config)
await ensureService();
// 接管"双击星芒按钮 → 设置窗口":改由默认浏览器打开 GUI 配置页
startWindowFlagWatcher();
// 1) 已有 CDP 实例 → 直接附着
const running = await findRunningInstance();
if (running) {
log(`检测到已运行的 ZCode 调试实例 (port=${running.port}),直接附着`);
await serveCdp(running.version.webSocketDebuggerUrl);
return;
}
// 2) 原版在跑且无调试端口 → 询问是否重启
if (zcodeProcessesRunning()) {
const close = await askCloseOriginal();
if (!close) { log("用户拒绝关闭原版 ZCode,退出"); process.exit(0); }
try { execSync("taskkill /IM ZCode.exe /F", { stdio: "ignore" }); } catch {}
await new Promise((r) => setTimeout(r, 2500));
}
// 3) 分配端口并拉起
const port = await findFreePort(portPreferred());
log(`使用调试端口 ${port},拉起 ZCode(CDP 模式)`);
launchZcode(port, found.path);
const version = await waitForCdp(port, CDP_BOOT_TIMEOUT_MS);
if (!version) {
log(`等待 CDP 就绪超时(${CDP_BOOT_TIMEOUT_MS / 1000}s),退出`);
showMessageBox("等待 CDP 就绪超时,请检查 ZCode 是否正常启动", "OK");
process.exit(1);
}
await serveCdp(version.webSocketDebuggerUrl);
}
if (process.argv.includes("--version")) {
console.log(`zcode-plus-cdp controller ${CONTROLLER_VERSION}`);
process.exit(0);
}
main().catch((error) => {
log("控制器异常退出:", safeError(error));
process.exit(1);
});