@mrrisega/dsh-remote 0.7.0-beta.2 → 0.7.0-beta.4
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/clients/dsh-remote/dsh-bridge.mjs +20 -0
- package/clients/dsh-remote/mobile-adapter.mjs +115 -31
- package/clients/dsh-remote/test/device-registration-report.test.mjs +133 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +74 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +87 -1
- package/clients/dsh-remote/wechat-runtime.mjs +75 -11
- package/dsh-setup.mjs +53 -4
- package/package.json +7 -6
- package/packages/dsh-remote-web/lib/client.js +167 -63
- package/packages/dsh-remote-web/lib/index.js +162 -10
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +20 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +115 -31
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +75 -11
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +53 -4
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +185 -0
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +22 -8
- package/packages/dsh-remote-web/test/setup-output.test.mjs +37 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +38 -0
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +9 -1
|
@@ -199,10 +199,22 @@ const SPECIAL_EVENT_KINDS = Object.freeze([
|
|
|
199
199
|
const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive
|
|
203
|
-
*
|
|
202
|
+
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive`):只留**最近几条**。
|
|
203
|
+
*
|
|
204
|
+
* 2026-10-09 业主口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
|
|
205
|
+
* 从 20 收到 5 的理由:一轮里真正还值得看的就最近那么两三条;攒到 20 条的结果是
|
|
206
|
+
* 平台一恢复就**一次连打十几条** —— 实测正是这种突发批量触发 `ret=-2 prepare failed`
|
|
207
|
+
* 被限流,于是补发又失败、队列再涨,越积越多永远还不上(现场观察到的就是这个循环)。
|
|
208
|
+
*/
|
|
209
|
+
const MAX_OUTBOX = 5;
|
|
210
|
+
/**
|
|
211
|
+
* 待补发条目的**保质期**:超过这个时长还没送达就丢弃(先丢最旧的)。
|
|
212
|
+
*
|
|
213
|
+
* 为什么必须有(同一条业主口径):一条"任务跑完了"的通知迟到几小时就**毫无价值** ——
|
|
214
|
+
* 补发出去只会让用户以为刚刚才发生;而队列的价值在于"网络抖动期间的短暂失败",
|
|
215
|
+
* 那是分钟级的。2 小时足够覆盖抖动与短时离线,又不会把半天前的旧事翻出来刷屏。
|
|
204
216
|
*/
|
|
205
|
-
const
|
|
217
|
+
const OUTBOX_TTL_MS = 2 * 60 * 60 * 1000;
|
|
206
218
|
/** 补发时的正文前缀(迟到必须如实说,不能让用户以为刚刚才发生)。 */
|
|
207
219
|
const OUTBOX_MARK = "(补发)";
|
|
208
220
|
|
|
@@ -337,6 +349,12 @@ function sendJson(res, status, body) {
|
|
|
337
349
|
}
|
|
338
350
|
|
|
339
351
|
export class WeChatRuntime {
|
|
352
|
+
/**
|
|
353
|
+
* 「预期内的订阅拒绝」已报过的 (code|sessionId) 集合 —— 见 fault 处理器。
|
|
354
|
+
* 只是防刷屏:每生成一个**子代理**会话都会被订阅层拒一次,不记集合就是每个子代理刷一行 warn。
|
|
355
|
+
*/
|
|
356
|
+
#expectedFaultSeen = new Set();
|
|
357
|
+
|
|
340
358
|
/**
|
|
341
359
|
* @param {{
|
|
342
360
|
* relayDir: string,
|
|
@@ -508,7 +526,9 @@ export class WeChatRuntime {
|
|
|
508
526
|
followed_sessions: this.subscriber && typeof this.subscriber.sessions === "function"
|
|
509
527
|
? this.subscriber.sessions()
|
|
510
528
|
: [],
|
|
511
|
-
|
|
529
|
+
// 报**有效**条数(按保质期过滤后):否则进程重启后,面板会一直显示那几条早已过期、
|
|
530
|
+
// 永远不会被补发的条目,看起来像"队列卡住了"。真正的清理由 #trimOutbox 落盘。
|
|
531
|
+
pending_outbox: this.#pruneOutbox(state.pending_outbox).kept.length,
|
|
512
532
|
last_fault: state.last_fault || null,
|
|
513
533
|
last_completed_session_id: state.last_completed_session_id || "",
|
|
514
534
|
last_completed_at: Number(state.last_completed_at || 0),
|
|
@@ -1547,14 +1567,44 @@ export class WeChatRuntime {
|
|
|
1547
1567
|
return list;
|
|
1548
1568
|
}
|
|
1549
1569
|
|
|
1570
|
+
/**
|
|
1571
|
+
* 丢掉**过期**条目(返回保留的与丢掉的数量)。
|
|
1572
|
+
* `at` 缺失/非法 = 更早的落盘格式(那时还没有 at 字段)→ 一律视为过期:
|
|
1573
|
+
* 留着只会把"最近几条"挤掉,而它们必然比 TTL 更老。
|
|
1574
|
+
*/
|
|
1575
|
+
#pruneOutbox(list) {
|
|
1576
|
+
const now = this.clock();
|
|
1577
|
+
const kept = [];
|
|
1578
|
+
let dropped = 0;
|
|
1579
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
1580
|
+
const at = Number(item && item.at);
|
|
1581
|
+
if (!Number.isFinite(at) || at <= 0 || now - at > OUTBOX_TTL_MS) { dropped += 1; continue; }
|
|
1582
|
+
kept.push(item);
|
|
1583
|
+
}
|
|
1584
|
+
return { kept, dropped };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** 丢弃过期的、并按上限只留最近几条;丢了就**如实记一行日志**(绝不静默丢)。 */
|
|
1588
|
+
#trimOutbox(list) {
|
|
1589
|
+
const { kept, dropped } = this.#pruneOutbox(list);
|
|
1590
|
+
let overflow = 0;
|
|
1591
|
+
while (kept.length > MAX_OUTBOX) { kept.shift(); overflow += 1; }
|
|
1592
|
+
const lost = dropped + overflow;
|
|
1593
|
+
if (lost > 0) {
|
|
1594
|
+
const mins = Math.round(OUTBOX_TTL_MS / 60000);
|
|
1595
|
+
const why = [dropped ? `${dropped} 条超 ${mins} 分钟未送达` : "", overflow ? `${overflow} 条超出最近 ${MAX_OUTBOX} 条上限` : ""]
|
|
1596
|
+
.filter(Boolean).join("、");
|
|
1597
|
+
this.logger.warn(`[wechat] 待补发队列丢弃 ${lost} 条(${why}),保留最近 ${kept.length} 条`);
|
|
1598
|
+
}
|
|
1599
|
+
return kept;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1550
1602
|
#enqueueOutbox(text, kind = "") {
|
|
1551
1603
|
const body = String(text || "");
|
|
1552
1604
|
if (!body) return 0;
|
|
1553
1605
|
const list = this.#outbox();
|
|
1554
1606
|
list.push({ text: body, kind: String(kind || ""), at: this.clock() });
|
|
1555
|
-
|
|
1556
|
-
while (list.length > MAX_OUTBOX) list.shift();
|
|
1557
|
-
return this.#writeOutbox(list).length;
|
|
1607
|
+
return this.#writeOutbox(this.#trimOutbox(list)).length;
|
|
1558
1608
|
}
|
|
1559
1609
|
|
|
1560
1610
|
/**
|
|
@@ -1562,7 +1612,11 @@ export class WeChatRuntime {
|
|
|
1562
1612
|
* @returns {Promise<number>} 这次补发成功的条数
|
|
1563
1613
|
*/
|
|
1564
1614
|
async #flushOutbox(to) {
|
|
1565
|
-
|
|
1615
|
+
// 补发前先按保质期清一遍:**过期的不发**(迟到几小时的"任务跑完了"只会误导用户),
|
|
1616
|
+
// 也避免一次性连打十几条把平台打限流(那正是队列越积越多的成因)。
|
|
1617
|
+
const raw = this.#outbox();
|
|
1618
|
+
const list = this.#trimOutbox(raw); // 只在真丢了东西时才落盘(避免每轮都写状态文件)
|
|
1619
|
+
if (list.length !== raw.length) this.#writeOutbox(list);
|
|
1566
1620
|
if (!list.length || !to) return 0;
|
|
1567
1621
|
let sent = 0;
|
|
1568
1622
|
const rest = [...list];
|
|
@@ -1645,10 +1699,20 @@ export class WeChatRuntime {
|
|
|
1645
1699
|
const code = String((info && info.code) || "unknown");
|
|
1646
1700
|
const message = String((info && info.message) || "").slice(0, 160);
|
|
1647
1701
|
const sessionId = String((info && info.sessionId) || "");
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1702
|
+
// 预期内的拒绝(子代理会话必然被拒 `session/agent-busy` / 会话刚被删 `session/not-found`)
|
|
1703
|
+
// **不是故障**:每生成一个子代理就会来一条,写成 warn 会把真正的故障淹掉
|
|
1704
|
+
// (2026-10-09 现场:日志里成片 `订阅异常 session/agent-busy … require their durable parent address`)。
|
|
1705
|
+
// 但也不能完全静默(2026-09-25 的教训:fault 以前没人接,全丢)→ 降为 info,且同一对只报一次。
|
|
1706
|
+
if (info && info.expected === true) {
|
|
1707
|
+
const key = `${code}|${sessionId}`;
|
|
1708
|
+
if (this.#expectedFaultSeen.size < 200 && !this.#expectedFaultSeen.has(key)) {
|
|
1709
|
+
this.#expectedFaultSeen.add(key);
|
|
1710
|
+
this.logger.info(`[wechat/events] 订阅跳过(预期,不重试) ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1651
1713
|
}
|
|
1714
|
+
this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
|
|
1715
|
+
this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
|
|
1652
1716
|
});
|
|
1653
1717
|
}
|
|
1654
1718
|
|
|
@@ -362,9 +362,13 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
|
|
|
362
362
|
* 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
|
|
363
363
|
* DSH_BRIDGE_INSTALL_SOURCE=plugin_market,这里原样透传(外部注入 > 硬编码默认)。
|
|
364
364
|
* install_version:优先取外部注入(插件/在线更新器),否则取本包 package.json 版本。
|
|
365
|
+
* harness_version:**只能由外部注入** —— 宿主(DeepSeek Harness)版本要从宿主进程的 argv /
|
|
366
|
+
* 安装目录推出来,而安装器是独立进程,推不出来。插件会在宿主进程内问出来再传进来;
|
|
367
|
+
* 用户直接跑 `npx @mrrisega/dsh-remote` 时没有这个值 → 留空(= 未上报,绝不猜一个错的)。
|
|
365
368
|
*/
|
|
366
369
|
const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
|
|
367
370
|
const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
|
|
371
|
+
const HARNESS_VERSION = String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
|
|
368
372
|
|
|
369
373
|
/**
|
|
370
374
|
* 把文件权限收紧到「只有本人可读写」。
|
|
@@ -708,7 +712,7 @@ function writeAutostartFile() {
|
|
|
708
712
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
709
713
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
710
714
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
711
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
|
|
715
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
|
|
712
716
|
</dict></plist>`;
|
|
713
717
|
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
714
718
|
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
@@ -724,7 +728,7 @@ function writeAutostartFile() {
|
|
|
724
728
|
if (process.platform === "linux") {
|
|
725
729
|
const dir = path.join(os.homedir(), ".config/systemd/user");
|
|
726
730
|
fs.mkdirSync(dir, { recursive: true });
|
|
727
|
-
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
731
|
+
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\nEnvironment=DSH_BRIDGE_HARNESS_VERSION=${HARNESS_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
728
732
|
const unitPath = autostartFilePath();
|
|
729
733
|
fs.writeFileSync(unitPath, unit);
|
|
730
734
|
return unitPath; // 路径在最终汇总里统一展示
|
|
@@ -803,6 +807,28 @@ function spawnDetachedBridge() {
|
|
|
803
807
|
* 但实测 kickstart 只解决"这一次",进程被杀后不会重建(KeepAlive 已被系统绕过)→ 如实告知
|
|
804
808
|
* ③ 后台进程 ← 连 launchd 都托管不了时保证可用
|
|
805
809
|
*/
|
|
810
|
+
/**
|
|
811
|
+
* 🔴 标签占用保护(2026-10-10 事故):`com.dshremote.bridge` 这个 launchd label 是**全局唯一**的,
|
|
812
|
+
* 谁 bootstrap 谁就接管它。当天跑测试时,某个用例用**假 HOME + 真 launchctl** 把测试临时目录的
|
|
813
|
+
* plist 登记到了同一个 label 上 → **真桥被顶掉**、远程访问中断,而日志里看不出任何异常。
|
|
814
|
+
*
|
|
815
|
+
* 所以 bootstrap 之前先问一句:这个 label 现在登记的 plist 是**本 relayDir 的**那份吗?
|
|
816
|
+
* · 不是(别人家的)→ **拒绝接管**并明确报错。要强行接管得显式设
|
|
817
|
+
* `DSH_BRIDGE_ALLOW_LABEL_TAKEOVER=1`(给人留后门,但默认安全)。
|
|
818
|
+
* · 查不出来(launchctl 不可用/未登记)→ 照旧继续,不误伤正常安装。
|
|
819
|
+
*/
|
|
820
|
+
function labelOwnedByOtherDir(plistPath, domain) {
|
|
821
|
+
try {
|
|
822
|
+
const r = sh(`launchctl print ${domain}/${BRIDGE_LABEL}`);
|
|
823
|
+
if (!r.ok) return null; // 未登记 → 不是"别人家的"
|
|
824
|
+
const cur = String(r.stdout || "").match(/^\s*path = (.+)$/m);
|
|
825
|
+
if (!cur) return null; // 读不出路径 → 不拦
|
|
826
|
+
const a = cur[1].trim();
|
|
827
|
+
const b = String(plistPath).trim();
|
|
828
|
+
return a === b ? false : a; // 返回"占用者的 plist 路径"= 别人家的
|
|
829
|
+
} catch { return null; }
|
|
830
|
+
}
|
|
831
|
+
|
|
806
832
|
function startBridgeDarwin(plistPath) {
|
|
807
833
|
const uid = process.getuid();
|
|
808
834
|
const q = (s) => "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
@@ -813,6 +839,16 @@ function startBridgeDarwin(plistPath) {
|
|
|
813
839
|
for (const d of [`user/${uid}`, `gui/${uid}`]) {
|
|
814
840
|
if (d !== domain) sh(`launchctl bootout ${d}/${BRIDGE_LABEL}`);
|
|
815
841
|
}
|
|
842
|
+
// 先确认这个 label 不是别人(别的 relayDir)的,否则**拒绝接管**——见 labelOwnedByOtherDir
|
|
843
|
+
const foreign = labelOwnedByOtherDir(plistPath, domain);
|
|
844
|
+
if (foreign && String(process.env.DSH_BRIDGE_ALLOW_LABEL_TAKEOVER || "") !== "1") {
|
|
845
|
+
return {
|
|
846
|
+
ok: false,
|
|
847
|
+
status: "label-owned-by-other",
|
|
848
|
+
detail: `${domain}/${BRIDGE_LABEL} 已被另一个 relayDir 占用(${foreign})—— 拒绝接管。`
|
|
849
|
+
+ `真要用本目录接管,请先停掉那边的桥,或显式设 DSH_BRIDGE_ALLOW_LABEL_TAKEOVER=1。`,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
816
852
|
sh(`launchctl bootout ${target}`);
|
|
817
853
|
let boot = sh(`launchctl bootstrap ${domain} ${q(plistPath)}`);
|
|
818
854
|
if (!boot.ok) {
|
|
@@ -1505,6 +1541,8 @@ async function runBridge() {
|
|
|
1505
1541
|
// 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
|
|
1506
1542
|
DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
|
|
1507
1543
|
DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
|
|
1544
|
+
// 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
|
|
1545
|
+
DSH_BRIDGE_HARNESS_VERSION: HARNESS_VERSION,
|
|
1508
1546
|
...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
|
|
1509
1547
|
...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
|
|
1510
1548
|
};
|
|
@@ -2191,10 +2229,21 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
2191
2229
|
// 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
|
|
2192
2230
|
// 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
|
|
2193
2231
|
// 否则会把插件文件全过滤掉、拷出空目录(此前反复“插件入口缺失”的总根因)。
|
|
2232
|
+
//
|
|
2233
|
+
// 🔴 2026-10-10 修:**插件自带的 runtime/node_modules 不能被剔掉**。
|
|
2234
|
+
// 旧规则是"任何一段叫 node_modules 就过滤",这条原本只想剔掉插件自己的依赖目录
|
|
2235
|
+
// (那些由 profile 的包管理器管),却把 `runtime/node_modules/ws` 一起剔了 ——
|
|
2236
|
+
// 而那份运行时正是"只有插件半"的机器**自愈补装时的来源**。剔掉它 = 自愈出来的运行环境
|
|
2237
|
+
// 缺 ws(bridge 顶层 `import WebSocket from "ws"`)→ 一启动就崩,还"自检通过"。
|
|
2238
|
+
// 现在:`runtime/` 之内一律保留;只过滤 runtime 之外的 node_modules。
|
|
2194
2239
|
filter: (src) => {
|
|
2195
2240
|
if (src === pluginDir) return true;
|
|
2196
|
-
const rel = path.relative(pluginDir, src);
|
|
2197
|
-
|
|
2241
|
+
const rel = String(path.relative(pluginDir, src));
|
|
2242
|
+
const segs = rel.split(path.sep);
|
|
2243
|
+
if (segs.some((seg) => seg === ".git" || seg === ".DS_Store")) return false;
|
|
2244
|
+
const inBundledRuntime = segs[0] === "runtime";
|
|
2245
|
+
if (!inBundledRuntime && segs.some((seg) => seg === "node_modules")) return false;
|
|
2246
|
+
return true;
|
|
2198
2247
|
}
|
|
2199
2248
|
};
|
|
2200
2249
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// dsh-remote-web 侧栏快捷入口「落点」回归(2026-10-09 桌面端事故)。
|
|
2
|
+
//
|
|
3
|
+
// 【事故】桌面端(DSH 0.2)设置面板本身就是 `role="dialog" aria-modal="true"` 的弹层,
|
|
4
|
+
// 它左侧栏目列表的第一项叫 **「通用设置」**。旧代码用「文本包含『设置』」(`indexOf !== -1`)
|
|
5
|
+
// 去找侧栏那颗齿轮按钮 → **命中的是弹层里的「通用设置」**,于是我们的「📱 远程访问」
|
|
6
|
+
// 快捷按钮被插进了设置面板栏目列表的最上面。业主截图反馈:「植入错了地方」。
|
|
7
|
+
//
|
|
8
|
+
// 本用例把 client.js 里的导航注入辅助函数**原样切出来**,喂一个最小 DOM,逐条锁住:
|
|
9
|
+
// ① 官方「设置」触发器只按**语义锚点**(aria-haspopup="dialog")识别,且必须在弹层之外;
|
|
10
|
+
// ② 弹层内的「通用设置」**永远不能**被当成落点;
|
|
11
|
+
// ③ 精确相等优先于子串匹配;
|
|
12
|
+
// ④ 栏目项(在弹层内)仍能被找到 —— 修好落点不能把「点开栏目」这条路径弄坏。
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
|
|
17
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
18
|
+
|
|
19
|
+
/** 把 `var NAV_ENTRY_ID` 到 `openRemoteSettings` 之前的那段辅助函数切出来(含依赖的常量)。 */
|
|
20
|
+
function navHelpersSource() {
|
|
21
|
+
const start = SOURCE.indexOf('var NAV_ENTRY_ID = "dru-nav-remote";');
|
|
22
|
+
// 末端锚点用**函数声明**而不是注释(注释会被重写,2026-10-10 就因此失效过一次)
|
|
23
|
+
const end = SOURCE.indexOf("function openRemoteSettings()");
|
|
24
|
+
assert.ok(start > 0 && end > start, "切片锚点失效:侧栏入口辅助函数结构变了");
|
|
25
|
+
return SOURCE.slice(start, end);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ───────────────────────── 最小 DOM ─────────────────────────
|
|
29
|
+
// 只实现被测代码真正用到的那几个能力:属性/类名/文本、closest、querySelectorAll。
|
|
30
|
+
class El {
|
|
31
|
+
constructor(tag, opts = {}) {
|
|
32
|
+
this.tagName = String(tag).toUpperCase();
|
|
33
|
+
this.id = opts.id || "";
|
|
34
|
+
this.className = opts.cls || "";
|
|
35
|
+
this.attrs = opts.attrs || {};
|
|
36
|
+
this.ownText = opts.text || "";
|
|
37
|
+
this.parentNode = null;
|
|
38
|
+
this.children = [];
|
|
39
|
+
}
|
|
40
|
+
get textContent() {
|
|
41
|
+
return this.ownText + this.children.map((c) => c.textContent).join("");
|
|
42
|
+
}
|
|
43
|
+
getAttribute(name) {
|
|
44
|
+
if (name === "class") return this.className;
|
|
45
|
+
if (name === "id") return this.id;
|
|
46
|
+
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? String(this.attrs[name]) : null;
|
|
47
|
+
}
|
|
48
|
+
append(...nodes) {
|
|
49
|
+
for (const n of nodes) { n.parentNode = this; this.children.push(n); }
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
/** 冒泡查最近的祖先(含自身)是否匹配选择器。 */
|
|
53
|
+
closest(selector) {
|
|
54
|
+
let cur = this;
|
|
55
|
+
while (cur) {
|
|
56
|
+
if (matches(cur, selector)) return cur;
|
|
57
|
+
cur = cur.parentNode;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function descendants(root, out = []) {
|
|
63
|
+
for (const c of root.children) { out.push(c); descendants(c, out); }
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** 单条复合选择器:tag + [attr] / [attr="v"] / [class*="x" i]。够用即可,不求完整 CSS。 */
|
|
67
|
+
function matchesSimple(el, sel) {
|
|
68
|
+
const m = /^([a-zA-Z]*)((?:\[[^\]]*\])*)$/.exec(sel.trim());
|
|
69
|
+
if (!m) return false;
|
|
70
|
+
const [, tag, attrPart] = m;
|
|
71
|
+
if (tag && el.tagName !== tag.toUpperCase()) return false;
|
|
72
|
+
const attrs = attrPart.match(/\[[^\]]*\]/g) || [];
|
|
73
|
+
for (const a of attrs) {
|
|
74
|
+
const inner = a.slice(1, -1);
|
|
75
|
+
const eq = /^([^=*^$|~]+)(?:([*^$|~]?=)"?([^"]*)"?)?(?:\s+i)?$/.exec(inner);
|
|
76
|
+
if (!eq) return false;
|
|
77
|
+
const [, name, op, val] = eq;
|
|
78
|
+
const caseInsensitive = /\si$/.test(inner);
|
|
79
|
+
const actual = el.getAttribute(name.trim());
|
|
80
|
+
if (op === undefined) { if (actual === null) return false; continue; }
|
|
81
|
+
const hay = String(actual ?? "");
|
|
82
|
+
const needle = String(val ?? "");
|
|
83
|
+
const [H, N] = caseInsensitive ? [hay.toLowerCase(), needle.toLowerCase()] : [hay, needle];
|
|
84
|
+
if (op === "=" && H !== N) return false;
|
|
85
|
+
if (op === "*=" && H.indexOf(N) === -1) return false;
|
|
86
|
+
if (op === "^=" && H.indexOf(N) !== 0) return false;
|
|
87
|
+
if (op === "$=" && !H.endsWith(N)) return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
function matches(el, selector) {
|
|
92
|
+
return String(selector).split(",").some((one) => matchesSimple(el, one));
|
|
93
|
+
}
|
|
94
|
+
function makeDocument(roots) {
|
|
95
|
+
const all = roots.flatMap((r) => [r, ...descendants(r)]);
|
|
96
|
+
return {
|
|
97
|
+
_all: all,
|
|
98
|
+
getElementById: (id) => all.find((e) => e.id === id) || null,
|
|
99
|
+
querySelectorAll: (selector) => all.filter((e) => matches(e, selector)),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function loadNavHelpers(doc) {
|
|
103
|
+
const src = navHelpersSource() + "\nreturn { pickSettingsTrigger, pickNavByToken, navInDialog };";
|
|
104
|
+
// eslint-disable-next-line no-new-func
|
|
105
|
+
return new Function("document", "window", src)(doc, {});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 复刻桌面端 0.2 的真实结构:侧栏一颗齿轮 + 设置弹层(首项「通用设置」)。 */
|
|
109
|
+
function desktopFixture() {
|
|
110
|
+
const sidebar = new El("div", { cls: "sidebar" });
|
|
111
|
+
const trigger = new El("button", {
|
|
112
|
+
cls: "Dws9Sa_trigger",
|
|
113
|
+
attrs: { "aria-label": "设置", "aria-haspopup": "dialog", "aria-expanded": "false" },
|
|
114
|
+
text: "设置"
|
|
115
|
+
});
|
|
116
|
+
sidebar.append(trigger);
|
|
117
|
+
|
|
118
|
+
const dialog = new El("div", { attrs: { role: "dialog", "aria-modal": "true" } });
|
|
119
|
+
const nav = new El("nav", { cls: "Dws9Sa_nav" });
|
|
120
|
+
const general = new El("button", { cls: "Dws9Sa_navCell", text: "通用设置" });
|
|
121
|
+
const remote = new El("button", { cls: "Dws9Sa_navCell", text: "📱 远程访问" });
|
|
122
|
+
const models = new El("button", { cls: "Dws9Sa_navCell", text: "模型" });
|
|
123
|
+
nav.append(general, models, remote);
|
|
124
|
+
dialog.append(nav);
|
|
125
|
+
|
|
126
|
+
const doc = makeDocument([sidebar, dialog]);
|
|
127
|
+
return { doc, trigger, dialog, general, remote };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ───────────────────────── 用例 ─────────────────────────
|
|
131
|
+
|
|
132
|
+
test("★ 事故锁:官方「设置」触发器只认语义锚点,且必须落在**设置弹层之外**", () => {
|
|
133
|
+
const { doc, trigger } = desktopFixture();
|
|
134
|
+
const { pickSettingsTrigger } = loadNavHelpers(doc);
|
|
135
|
+
assert.equal(pickSettingsTrigger(), trigger, "必须命中侧栏那颗 aria-haspopup=dialog 的齿轮按钮");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("★ 事故锁:弹层里的「通用设置」永远不能成为落点", () => {
|
|
139
|
+
const { doc, general } = desktopFixture();
|
|
140
|
+
const { pickSettingsTrigger, pickNavByToken } = loadNavHelpers(doc);
|
|
141
|
+
assert.notEqual(pickSettingsTrigger(), general, "「通用设置」在弹层内,绝不能被当成侧栏设置按钮");
|
|
142
|
+
// 严格等值 + 只要弹层外:结果只能是侧栏触发器,不能是「通用设置」
|
|
143
|
+
assert.notEqual(pickNavByToken("设置", { exactOnly: true, insideDialog: false }), general);
|
|
144
|
+
// 老写法(子串匹配)现在只能作为**兜底**,精确命中优先 —— 先保证不误伤
|
|
145
|
+
assert.notEqual(pickNavByToken("设置"), general, "子串匹配不得再压过精确匹配");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("弹层内没有 aria-haspopup 的栏目项,不能被当成「设置」触发器", () => {
|
|
149
|
+
const dialogOnly = new El("div", { attrs: { role: "dialog", "aria-modal": "true" } });
|
|
150
|
+
const nav = new El("nav");
|
|
151
|
+
nav.append(new El("button", { cls: "navCell", text: "通用设置" }));
|
|
152
|
+
dialogOnly.append(nav);
|
|
153
|
+
const { pickSettingsTrigger } = loadNavHelpers(makeDocument([dialogOnly]));
|
|
154
|
+
assert.equal(pickSettingsTrigger(), null, "找不到可信锚点时必须**不注入**,绝不将就一个错落点");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("栏目项仍在弹层内被找到(修落点不能把「打开远程访问栏目」这条路弄坏)", () => {
|
|
158
|
+
const { doc, remote } = desktopFixture();
|
|
159
|
+
const { pickNavByToken } = loadNavHelpers(doc);
|
|
160
|
+
assert.equal(pickNavByToken("远程访问", { insideDialog: true }), remote);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("精确相等优先于子串:给了 exactOnly 时子串一律不算", () => {
|
|
164
|
+
const root = new El("div", { cls: "sidebar" });
|
|
165
|
+
const general = new El("button", { cls: "navCell", text: "通用设置" });
|
|
166
|
+
const settings = new El("button", { cls: "navCell", text: "设置" });
|
|
167
|
+
root.append(general, settings);
|
|
168
|
+
const { pickNavByToken } = loadNavHelpers(makeDocument([root]));
|
|
169
|
+
assert.equal(pickNavByToken("设置", { exactOnly: true, insideDialog: false }), settings);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("源码约束:入口不再自己找 DOM 落点,而是注册官方槽位(2026-10-10 三修后的事实)", () => {
|
|
173
|
+
// 一修/二修都是"自己找落点",先后落错到设置面板顶部、以及"找不到锚点干脆不注入"。
|
|
174
|
+
// 三修起改用官方槽位 sidebar.footer.action —— 本用例把这条设计锁住,防止有人再退回 DOM 注入。
|
|
175
|
+
assert.match(SOURCE, /ctx\.slots\.inject\("sidebar\.footer\.action"/,
|
|
176
|
+
"侧栏入口必须注册进官方槽位 sidebar.footer.action");
|
|
177
|
+
assert.match(SOURCE, /id:\s*"dsh-remote-nav"/, "槽位条目 id 应为 dsh-remote-nav");
|
|
178
|
+
assert.ok(!/var host = pickSettingsTrigger\(\)/.test(SOURCE),
|
|
179
|
+
"不得再出现「每 2s 找锚点注入」的老写法(那正是两次落错位置的来源)");
|
|
180
|
+
assert.ok(!/if \(navInDialog\(host\)\) return;/.test(SOURCE),
|
|
181
|
+
"老的 DOM 注入兜底应已删除(入口改由槽位渲染)");
|
|
182
|
+
// 仍保留的:打开栏目的点击路径必须还能工作,且不会把「通用设置」当成设置触发器
|
|
183
|
+
assert.match(SOURCE, /aria-haspopup="dialog"/, "打开栏目时仍按 aria-haspopup=dialog 认官方设置触发器");
|
|
184
|
+
assert.match(SOURCE, /\[role="dialog"\], \[aria-modal="true"\]/, "必须保留「在设置弹层内」的判据");
|
|
185
|
+
});
|
|
@@ -162,11 +162,22 @@ function loadPlugin(opts = {}) {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
test("
|
|
165
|
+
test("入口:settings.section 栏目(id/order/label)+ 侧栏底部官方槽 sidebar.footer.action", () => {
|
|
166
166
|
const plugin = loadPlugin();
|
|
167
167
|
|
|
168
|
-
//
|
|
169
|
-
|
|
168
|
+
// 2026-10-10 三修(业主口径「就要放侧栏底部那个位置」):入口改注册进**官方列表槽**
|
|
169
|
+
// `sidebar.footer.action`(宿主为侧栏底部区域提供的扩展点;0.2 实测该槽当前为空)。
|
|
170
|
+
// 不再自己注入 DOM —— 前两次都是"自己找落点"找错的。
|
|
171
|
+
assert.ok(plugin.injects.has("sidebar.footer.action"), "应注入官方侧栏底部槽");
|
|
172
|
+
const disposeNav = plugin.injects.get("sidebar.footer.action")();
|
|
173
|
+
assert.equal(typeof disposeNav, "function", "register 应返回 disposer");
|
|
174
|
+
const navMeta = plugin.metas.get("dsh-remote-nav");
|
|
175
|
+
assert.ok(navMeta, "侧栏入口应以 id=dsh-remote-nav 注册");
|
|
176
|
+
assert.equal(navMeta.name, "sidebar.footer.action");
|
|
177
|
+
assert.equal(typeof navMeta.label, "function");
|
|
178
|
+
assert.ok(String(navMeta.label()).includes("远程访问"), `槽位 label 应为「远程访问」,实际 ${navMeta.label()}`);
|
|
179
|
+
// 按钮 id 必须仍复用 NAV_ENTRY_ID(=dru-nav-remote):公告徽标/首次红点按 id 取节点的逻辑依赖它
|
|
180
|
+
assert.match(SOURCE, /id:\s*NAV_ENTRY_ID/, "入口按钮的 id 必须复用 NAV_ENTRY_ID(=dru-nav-remote)");
|
|
170
181
|
|
|
171
182
|
// settings.section 官方扩展点:id=dsh-remote、order=30(> Agent 预设 20,位于其下方)、label=📱 远程访问
|
|
172
183
|
assert.ok(plugin.injects.has("settings.section"), "应注入 settings.section 扩展点");
|
|
@@ -202,8 +213,9 @@ test("入口迁移:settings.section 里**只有一个**栏目(id=dsh-remote
|
|
|
202
213
|
// 搬进去之后仍然存在:面板 tab 条里的第三个 tab + 微信绿泡泡图标(行为断言见 wechat-bot-ui.test.mjs)
|
|
203
214
|
assert.match(SOURCE, /"微信机器人通道"/, "微信机器人 tab 必须在面板里");
|
|
204
215
|
assert.match(SOURCE, /dru-wx-ico/, "微信 tab 必须带绿泡泡图标 class");
|
|
205
|
-
//
|
|
206
|
-
assert.equal(plugin.injects.size,
|
|
216
|
+
// 槽注入次数:settings.section + shell.overlay + sidebar.footer.action(2026-10-10 加的侧栏入口)
|
|
217
|
+
assert.equal(plugin.injects.size, 3,
|
|
218
|
+
`inject 的槽名应是 settings.section + shell.overlay + sidebar.footer.action,实际:${[...plugin.injects.keys()].join(",")}`);
|
|
207
219
|
});
|
|
208
220
|
|
|
209
221
|
test("登录态账号区:无「切换账号」,有「退出登录」,头部/关于卡文案为「远程访问」", () => {
|
|
@@ -345,9 +357,11 @@ test("源码约束:侧栏注入「远程访问」按钮与官方「设置」
|
|
|
345
357
|
assert.match(SOURCE, /dsh-remote-seen-dot/);
|
|
346
358
|
});
|
|
347
359
|
|
|
348
|
-
test("
|
|
349
|
-
//
|
|
350
|
-
|
|
360
|
+
test("源码约束:侧栏入口走官方槽位/无浮动面板/无切换账号;命名统一为「远程访问」", () => {
|
|
361
|
+
// 2026-10-10 三修:侧栏入口**改回**官方槽位(业主口径「就要放侧栏底部那个位置」),
|
|
362
|
+
// 所以这条从"不得出现 footer.action"翻转为"必须走官方槽位、不得自己注入 DOM"。
|
|
363
|
+
assert.match(SOURCE, /slots\.inject\("sidebar\.footer\.action"/);
|
|
364
|
+
assert.doesNotMatch(SOURCE, /insertBefore\(btn, host\)/, "不得再自己插 DOM 找落点");
|
|
351
365
|
assert.doesNotMatch(SOURCE, /dru-backdrop\{/);
|
|
352
366
|
assert.doesNotMatch(SOURCE, /切换账号/);
|
|
353
367
|
assert.doesNotMatch(SOURCE, /dsh-remote-panel/);
|
|
@@ -156,3 +156,40 @@ test("★ 插件覆盖必须原子:staging → 必需文件自检 → rename
|
|
|
156
156
|
"🔴 不得再出现「先删 dest 再原地逐文件拷贝」——那正是这次事故的成因");
|
|
157
157
|
assert.match(fn, /\.staging-\$\{process\.pid\}|\.staging-/, "被强杀留下的 staging 要能清理");
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
test("★ 插件拷贝必须保留**自带的 runtime/node_modules**(ws 是桥唯一的裸依赖)", () => {
|
|
161
|
+
// 2026-10-10 事故形态:过滤规则写的是"任何一段叫 node_modules 就剔除",
|
|
162
|
+
// 本意只想去掉插件自己的依赖目录(那些由 profile 的包管理器管),
|
|
163
|
+
// 却把 `runtime/node_modules/ws` 一起剔了 —— 而这份运行时正是"只有插件半"的机器
|
|
164
|
+
// **自愈补装时的唯一来源**。剔掉它 → 自愈出来的运行环境缺 ws
|
|
165
|
+
// (bridge 顶层 `import WebSocket from "ws"`)→ 一启动就崩,而且自检还说"已就绪"。
|
|
166
|
+
const src = readFileSync(SETUP, "utf8");
|
|
167
|
+
const i = src.indexOf("const cpOpts = {");
|
|
168
|
+
assert.ok(i > 0, "应能定位 cpOpts");
|
|
169
|
+
const j = src.indexOf("};", i);
|
|
170
|
+
const objSrc = src.slice(i, j + 2);
|
|
171
|
+
const pluginDir = "/some/node_modules/dsh-remote-web"; // 源本身就在 node_modules 之下(历史坑)
|
|
172
|
+
const path = require$path();
|
|
173
|
+
// eslint-disable-next-line no-new-func
|
|
174
|
+
const make = new Function("path", "pluginDir", `${objSrc}\nreturn cpOpts;`);
|
|
175
|
+
const cpOpts = make(path, pluginDir);
|
|
176
|
+
const keep = (p) => cpOpts.filter(p) === true;
|
|
177
|
+
|
|
178
|
+
assert.equal(keep(pluginDir), true, "插件根目录本身必须保留");
|
|
179
|
+
assert.equal(keep(path.join(pluginDir, "lib", "index.js")), true, "普通文件必须保留");
|
|
180
|
+
// ① 自带运行时的 node_modules **必须保留**(本次修复的核心)
|
|
181
|
+
assert.equal(keep(path.join(pluginDir, "runtime", "node_modules")), true, "runtime/node_modules 必须保留");
|
|
182
|
+
assert.equal(keep(path.join(pluginDir, "runtime", "node_modules", "ws", "package.json")), true,
|
|
183
|
+
"★ runtime/node_modules/ws 必须被拷进 profile —— 否则自愈出来的运行环境起不来");
|
|
184
|
+
// ② runtime 之外的 node_modules 仍然要剔除(profile 的包管理器管那部分)
|
|
185
|
+
assert.equal(keep(path.join(pluginDir, "node_modules", "foo", "index.js")), false,
|
|
186
|
+
"runtime 之外的依赖目录仍应剔除(避免把解析器裁决权抢过来)");
|
|
187
|
+
// ③ 垃圾照旧剔除
|
|
188
|
+
assert.equal(keep(path.join(pluginDir, ".git", "config")), false, ".git 必须剔除");
|
|
189
|
+
assert.equal(keep(path.join(pluginDir, "lib", ".DS_Store")), false, ".DS_Store 必须剔除");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** 只在本用例里用到的 path 注入(避免为一行断言引入模块级 require 语义)。 */
|
|
193
|
+
function require$path() {
|
|
194
|
+
return { join: (...a) => a.join("/"), sep: "/", relative: (from, to) => to.startsWith(from + "/") ? to.slice(from.length + 1) : to };
|
|
195
|
+
}
|
|
@@ -414,6 +414,44 @@ const m = await import(${JSON.stringify(INDEX_URL)});
|
|
|
414
414
|
console.log(JSON.stringify(m.__telemetryInternals.eventOf("plugin_loaded")));
|
|
415
415
|
`;
|
|
416
416
|
|
|
417
|
+
test("★ 宿主版本(桌面端 0.2 真机形态):argv[1] 是开关时,仍能从 argv 里的宿主根取到版本", async () => {
|
|
418
|
+
// 真机实测(DeepSeek Harness 0.2,Electron)宿主进程 argv:
|
|
419
|
+
// [0] …/MacOS/DeepSeek Harness
|
|
420
|
+
// [1] --expose-internals ← 开关,不是路径
|
|
421
|
+
// [2] …/app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/index.js
|
|
422
|
+
// [3] …/app.asar/dsh ← 宿主根(含 node_modules/@deepseek-ai/dsh)
|
|
423
|
+
// 旧实现只看 argv[1] → 桌面端**必然**取不到版本(这正是生产反馈里答不出宿主版本的原因)。
|
|
424
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-harness-desktop-"));
|
|
425
|
+
try {
|
|
426
|
+
const hostRoot = path.join(root, "app.asar", "dsh");
|
|
427
|
+
await fakeHarness(path.join(hostRoot, "node_modules", "@deepseek-ai", "dsh"), { version: "0.2.0-rc.2" });
|
|
428
|
+
assert.equal(TELEMETRY.harnessVersionFromRoot(hostRoot), "0.2.0-rc.2", "宿主根里应能读到 @deepseek-ai/dsh 的版本");
|
|
429
|
+
|
|
430
|
+
const argv = [
|
|
431
|
+
"/Applications/DeepSeek Harness.app/Contents/MacOS/DeepSeek Harness",
|
|
432
|
+
"--expose-internals",
|
|
433
|
+
path.join(hostRoot, "node_modules", "@deepseek-ai", "dsh-desktop-host", "lib", "index.js"),
|
|
434
|
+
hostRoot,
|
|
435
|
+
path.join(root, "profiles", "desktop"),
|
|
436
|
+
];
|
|
437
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv(argv), "0.2.0-rc.2", "★桌面端 argv 必须能解析出宿主版本");
|
|
438
|
+
|
|
439
|
+
// 开关/相对路径绝不能被当成路径去 realpath(否则抛错或被当成"未知"掩盖真实问题)
|
|
440
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv(["--expose-internals", "-x"]), "", "全是开关 → 空串");
|
|
441
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv(["relative/path", ""]), "", "非绝对路径 → 空串");
|
|
442
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv([]), "", "空 argv → 空串");
|
|
443
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv(undefined), "", "undefined → 空串(绝不抛)");
|
|
444
|
+
// 不是 DSH 的宿主根(有 package.json 但名字不对)→ 空串,绝不猜
|
|
445
|
+
const otherRoot = path.join(root, "other-root");
|
|
446
|
+
await fakeHarness(path.join(otherRoot, "node_modules", "@deepseek-ai", "dsh"), { name: "not-dsh", version: "1.0.0" });
|
|
447
|
+
assert.equal(TELEMETRY.harnessVersionFromRoot(otherRoot), "", "名字不是 @deepseek-ai/dsh → 空串");
|
|
448
|
+
// Web 版形态仍然照旧可用(argv[1] 是 DSH 主模块)
|
|
449
|
+
const webEntry = await fakeHarness(path.join(root, "web", "node_modules", "@deepseek-ai", "dsh"), { version: "0.1.5-rc.2" });
|
|
450
|
+
assert.equal(TELEMETRY.harnessVersionFromArgv([webEntry]), "0.1.5-rc.2", "Web 版(argv[1]=主模块)路径不得被改坏");
|
|
451
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
|
|
417
455
|
test("宿主版本:运行时从主模块解析(argv[1] → 上一级 package.json);未知一律空串且不抛", async () => {
|
|
418
456
|
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-harness-"));
|
|
419
457
|
try {
|
|
@@ -264,7 +264,15 @@ exit 0
|
|
|
264
264
|
// 当前 HOME 的 LaunchAgents 里有 plist(服务此前装过,但现在未运行)
|
|
265
265
|
const plist = path.join(tempHome, "Library", "LaunchAgents", "com.dshremote.bridge.plist");
|
|
266
266
|
await mkdir(path.dirname(plist), { recursive: true });
|
|
267
|
-
|
|
267
|
+
/* ⚠️ 2026-10-10:夹具必须写成**真实形态的 plist** —— 内容里要含 `<relayDir>/dsh-setup.mjs`。
|
|
268
|
+
清理助手(与插件里的两条路径)现在会先校验"这份 plist 是不是给本 relayDir 装的":
|
|
269
|
+
只有含本 relayDir 入口脚本的 plist 才允许删。以前这里写的是空 `<dict/>`,
|
|
270
|
+
于是它同时**掩盖**了两件事:① 夹具不像真 plist;② 真实世界里那个守卫若缺失,
|
|
271
|
+
这条件会真的去删开发者的 plist(当天就发生了)。 */
|
|
272
|
+
await writeFile(plist, `<?xml version="1.0" encoding="UTF-8"?>
|
|
273
|
+
<plist version="1.0"><dict>
|
|
274
|
+
<key>ProgramArguments</key><array><string>/usr/bin/node</string><string>${path.join(relayDir, "dsh-setup.mjs")}</string><string>run</string></array>
|
|
275
|
+
</dict></plist>`);
|
|
268
276
|
|
|
269
277
|
const routes = boot(relayDir);
|
|
270
278
|
const { host, base } = await serve(routes);
|