@mrrisega/dsh-remote 0.6.5 → 0.6.6-beta.2
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/mobile-adapter.mjs +49 -8
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +208 -0
- package/package.json +2 -4
- package/packages/dsh-remote-web/lib/client.js +9 -1
- package/packages/dsh-remote-web/lib/index.js +150 -14
- package/packages/dsh-remote-web/package.json +2 -5
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +131 -0
|
@@ -478,13 +478,31 @@ const SCRIPT = `(() => {
|
|
|
478
478
|
|
|
479
479
|
const toggleSel = '[aria-label="打开侧边栏"], [aria-label="关闭侧边栏"], [aria-label="Open sidebar"], [aria-label="Close sidebar"]';
|
|
480
480
|
const toggleOf = () => document.querySelector(toggleSel) || document.querySelector(".hHd-Xa_toggle");
|
|
481
|
-
|
|
481
|
+
/* 官方用 data-sidebar-collapsed 表达折叠态,但写法有两种可能:只输出属性本身,
|
|
482
|
+
或输出 attribute 的值(例如 =false)。旧实现只看 hasAttribute 是否存在,
|
|
483
|
+
只兼容前一种:一旦官方用带值写法,就会被永久判定为「展开」→ 遮罩常亮且拦截点击、
|
|
484
|
+
汉堡按钮被隐藏 → 手机端整屏阴影、点哪都没反应(用户实测)。
|
|
485
|
+
这里改成读值,两种写法都能正确判断。 */
|
|
486
|
+
const truthyAttr = (el, name) => {
|
|
487
|
+
const v = el.getAttribute(name);
|
|
488
|
+
if (v === null) return false;
|
|
489
|
+
const s = String(v).trim().toLowerCase();
|
|
490
|
+
return !(s === "" || s === "false" || s === "0" || s === "off" || s === "no");
|
|
491
|
+
};
|
|
492
|
+
const collapsed = () => truthyAttr(frame, "data-sidebar-collapsed");
|
|
493
|
+
const expanded = () => !collapsed();
|
|
482
494
|
|
|
483
495
|
/* 遮罩:点击 = 点官方 toggle(走官方 store,无私有状态) */
|
|
484
496
|
const scrim = document.createElement("div");
|
|
485
497
|
scrim.className = "dsh-ma-scrim";
|
|
486
498
|
document.body.appendChild(scrim);
|
|
487
|
-
scrim.addEventListener("click", (e) => {
|
|
499
|
+
scrim.addEventListener("click", (e) => {
|
|
500
|
+
e.stopPropagation();
|
|
501
|
+
const t = toggleOf();
|
|
502
|
+
if (t) { t.click(); return; }
|
|
503
|
+
/* 兜底:找不到官方 toggle 时**至少让用户能自救**(否则就是"点哪都没反应"的死局) */
|
|
504
|
+
document.documentElement.classList.remove("dsh-ma-sidebar-open", "dsh-ma-details-open");
|
|
505
|
+
});
|
|
488
506
|
|
|
489
507
|
/* 顶部小鲸鱼/菜单按钮 */
|
|
490
508
|
const hamburger = document.createElement("button");
|
|
@@ -496,21 +514,44 @@ const SCRIPT = `(() => {
|
|
|
496
514
|
hamburger.addEventListener("click", (e) => {
|
|
497
515
|
e.stopPropagation(); e.preventDefault();
|
|
498
516
|
const t = toggleOf();
|
|
499
|
-
if (t) t.click();
|
|
500
|
-
|
|
517
|
+
if (t) { t.click(); return; }
|
|
518
|
+
/* 兜底(无官方 toggle):按当前状态切换,保证"开得了也关得掉" */
|
|
519
|
+
const open = document.documentElement.classList.contains("dsh-ma-sidebar-open");
|
|
520
|
+
if (open) document.documentElement.classList.remove("dsh-ma-sidebar-open", "dsh-ma-details-open");
|
|
521
|
+
else document.documentElement.classList.add("dsh-ma-sidebar-open");
|
|
501
522
|
});
|
|
502
523
|
|
|
503
|
-
/*
|
|
524
|
+
/* 侧栏是否**真的**滑进了视口。这是"遮罩能不能拦截点击"的最后一道保险:
|
|
525
|
+
只要官方属性语义与我们理解的不一致,光看属性就可能误判;而遮罩一旦拦截整屏,
|
|
526
|
+
用户看到的就是"整屏阴影 + 点哪都没反应"。以实测几何为准,误判也不会挡住用户。 */
|
|
527
|
+
const inView = (el) => {
|
|
528
|
+
if (!el) return false;
|
|
529
|
+
try { const r = el.getBoundingClientRect(); return !!r && r.right > 8 && r.width > 40; }
|
|
530
|
+
catch (e2) { return false; }
|
|
531
|
+
};
|
|
532
|
+
const sidebarInView = () => inView(sidebar || document.querySelector(".dsh-ma-sidebar"));
|
|
533
|
+
/* 状态唯一真源 = 官方 frame 的 data 属性(判定)+ 真实几何(放行遮罩) */
|
|
504
534
|
const sync = () => {
|
|
505
535
|
const open = expanded();
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
536
|
+
const scrimOn = open && sidebarInView();
|
|
537
|
+
document.documentElement.classList.toggle("dsh-ma-sidebar-open", scrimOn);
|
|
538
|
+
/* 汉堡按钮**始终可见**:以前判定为"展开"时会把它 display:none 隐藏,
|
|
539
|
+
于是判定一旦出错(或遮罩因异常常亮),用户既点不动内容、也没有任何入口 —— 死局。
|
|
540
|
+
保持可见 + 点它可开可关(见其 click 处理),任何异常状态下都留一条出路。 */
|
|
541
|
+
if (hamburger) hamburger.style.display = "";
|
|
542
|
+
// 详情栏同理:只有"判定为展开"且"真的在视口里"才让遮罩拦截
|
|
543
|
+
const dOpen = !truthyAttr(frame, "data-details-collapsed") && !!details && inView(details);
|
|
509
544
|
document.documentElement.classList.toggle("dsh-ma-details-open", dOpen);
|
|
510
545
|
};
|
|
511
546
|
try {
|
|
512
547
|
new MutationObserver(sync).observe(frame, { attributes: true, attributeFilter: ["data-sidebar-collapsed", "data-details-collapsed"] });
|
|
513
548
|
} catch (e2) { /* 退化:仅在下次 boot 同步 */ }
|
|
549
|
+
// 几何变化(旋转屏幕 / 窗口缩放 / 侧栏动画结束)也要重算,否则会把 boot 时的判定一直沿用
|
|
550
|
+
try {
|
|
551
|
+
window.addEventListener("resize", sync, { passive: true });
|
|
552
|
+
window.addEventListener("orientationchange", sync, { passive: true });
|
|
553
|
+
if (sidebar && typeof ResizeObserver === "function") new ResizeObserver(sync).observe(sidebar);
|
|
554
|
+
} catch (e2) { /* 非关键 */ }
|
|
514
555
|
sync();
|
|
515
556
|
/* 窄屏抽屉:点选会话(激活行)/点侧栏内「新建会话」后自动收起(官方不做;适配层点官方 toggle)。
|
|
516
557
|
仅当:窄屏 && 抽屉展开 && 点击发生在侧栏抽屉内;行内「⋯」按钮 stopPropagation 不会误触。 */
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mobile-adapter **运行时**契约测试(假 DOM + vm 执行真实注入脚本)。
|
|
4
|
+
*
|
|
5
|
+
* 事故背景(2026-09-15 生产反馈):
|
|
6
|
+
* 手机端用户打开后**整屏被阴影覆盖、点哪都没反应**,只有左侧栏(z-index 300,在遮罩之上)能点。
|
|
7
|
+
* 根因是适配层自己创建的全屏 scrim(div.dsh-ma-scrim, z-index 290):
|
|
8
|
+
* · 判定「侧栏是否展开」用的是 `!frame.hasAttribute("data-sidebar-collapsed")`,
|
|
9
|
+
* 只兼容"折叠时才输出属性"这一种写法;官方若用带值写法,就永久判定为展开 →
|
|
10
|
+
* html 上的 dsh-ma-sidebar-open 常亮 → scrim 永久 pointer-events:auto(拦截整屏点击);
|
|
11
|
+
* · 且判定为展开时还会把汉堡按钮 display:none 隐藏掉,"打开菜单/关掉遮罩"的入口也没了;
|
|
12
|
+
* · 唯一关闭路径是"点 scrim → 点官方 toggle",找不到 toggle 就彻底卡死。
|
|
13
|
+
*
|
|
14
|
+
* 本用例直接拿 injectMobileAdapter() 注入的**真实脚本**在假 DOM 里跑,断言运行时行为
|
|
15
|
+
* (既有用例只测注入纯函数,覆盖不到这里,所以这个 bug 当初没被任何测试拦住)。
|
|
16
|
+
*/
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import vm from "node:vm";
|
|
20
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
21
|
+
|
|
22
|
+
/** 从注入结果里取出真正的 <script> 内容(不加壳,原样执行)。 */
|
|
23
|
+
function adapterScript() {
|
|
24
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
25
|
+
const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
26
|
+
assert.ok(m, "应能取出注入脚本");
|
|
27
|
+
return m[1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── 极简假 DOM ────────────────────────────────────────────────────────────────
|
|
31
|
+
class El {
|
|
32
|
+
constructor(tag = "div", attrs = {}) {
|
|
33
|
+
this.tagName = String(tag).toUpperCase();
|
|
34
|
+
this.attrs = { ...attrs };
|
|
35
|
+
this.children = [];
|
|
36
|
+
this._cls = new Set();
|
|
37
|
+
this._listeners = new Map();
|
|
38
|
+
this.style = {};
|
|
39
|
+
this._rect = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 };
|
|
40
|
+
this.classList = {
|
|
41
|
+
add: (...cs) => cs.forEach((c) => this._cls.add(c)),
|
|
42
|
+
remove: (...cs) => cs.forEach((c) => this._cls.delete(c)),
|
|
43
|
+
contains: (c) => this._cls.has(c),
|
|
44
|
+
toggle: (c, on) => { if (on === undefined) { this._cls.has(c) ? this._cls.delete(c) : this._cls.add(c); } else if (on) this._cls.add(c); else this._cls.delete(c); return this._cls.has(c); },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
get className() { return [...this._cls].join(" "); }
|
|
48
|
+
set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
|
|
49
|
+
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }
|
|
50
|
+
setAttribute(n, v) { this.attrs[n] = String(v); }
|
|
51
|
+
hasAttribute(n) { return n in this.attrs; }
|
|
52
|
+
appendChild(c) { c.parent = this; this.children.push(c); return c; }
|
|
53
|
+
addEventListener(t, fn) { (this._listeners.get(t) || this._listeners.set(t, []).get(t)).push(fn); }
|
|
54
|
+
removeEventListener() {}
|
|
55
|
+
dispatch(t, ev = {}) { for (const fn of this._listeners.get(t) || []) fn({ stopPropagation() {}, preventDefault() {}, ...ev }); }
|
|
56
|
+
getBoundingClientRect() { return this._rect; }
|
|
57
|
+
querySelector() { return null; }
|
|
58
|
+
querySelectorAll() { return []; }
|
|
59
|
+
matches() { return false; }
|
|
60
|
+
click() { this._clicked = (this._clicked || 0) + 1; this.dispatch("click"); }
|
|
61
|
+
get isConnected() { return true; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 搭一个最小 dsh web 环境:frame(带官方 data 属性)内含 sidebar/center 两列 + 可选官方 toggle。 */
|
|
65
|
+
function harness({ collapsedAttr = undefined, detailsAttr = undefined, sidebarRect = null, width = 390, withToggle = true } = {}) {
|
|
66
|
+
const html = new El("html");
|
|
67
|
+
// HOSTISH 门禁会读 documentElement.outerHTML 找官方特征(__ModuleLoader__ / DeepSeek Harness)
|
|
68
|
+
html.outerHTML = '<html><head><script>window.__ModuleLoader__ = { mode: "queue" }</script>'
|
|
69
|
+
+ "<title>DeepSeek Harness</title></head><body></body></html>";
|
|
70
|
+
const body = new El("body");
|
|
71
|
+
const frame = new El("div");
|
|
72
|
+
if (collapsedAttr !== undefined) frame.setAttribute("data-sidebar-collapsed", collapsedAttr);
|
|
73
|
+
if (detailsAttr !== undefined) frame.setAttribute("data-details-collapsed", detailsAttr);
|
|
74
|
+
|
|
75
|
+
const sidebar = new El("div", { class: "sidebarCol" });
|
|
76
|
+
const center = new El("div", { class: "centerCol" });
|
|
77
|
+
if (sidebarRect) sidebar._rect = sidebarRect;
|
|
78
|
+
frame.appendChild(sidebar);
|
|
79
|
+
frame.appendChild(center);
|
|
80
|
+
const toggle = new El("button");
|
|
81
|
+
toggle.setAttribute("aria-label", "打开侧边栏");
|
|
82
|
+
|
|
83
|
+
const pickByClass = (sel) => {
|
|
84
|
+
// 只支持本用例需要的极少数选择器(脚本里 querySelector 的用法就这些)
|
|
85
|
+
const s2 = String(sel);
|
|
86
|
+
if (/\.dsh-ma-sidebar/.test(s2)) return sidebar._cls.size ? sidebar : null;
|
|
87
|
+
if (/\.hHd-Xa_root|sidebar/i.test(s2)) return sidebar;
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
const frameQ = (sel) => pickByClass(sel);
|
|
91
|
+
frame.querySelector = frameQ;
|
|
92
|
+
frame.querySelectorAll = () => [];
|
|
93
|
+
const doc = {
|
|
94
|
+
documentElement: html,
|
|
95
|
+
body,
|
|
96
|
+
readyState: "complete",
|
|
97
|
+
addEventListener() {},
|
|
98
|
+
querySelector(sel) {
|
|
99
|
+
const s2 = String(sel);
|
|
100
|
+
if (s2.includes(".pI_x6G_frame") || s2.includes("[data-sidebar-collapsed], [data-details-collapsed]") || s2.includes("[data-shell-overlay]")) return frame;
|
|
101
|
+
if (s2.includes("aria-label=")) return withToggle ? toggle : null;
|
|
102
|
+
if (s2.includes(".hHd-Xa_toggle")) return withToggle ? toggle : null;
|
|
103
|
+
if (s2.includes("hHd-Xa")) return sidebar; // 子列识别用(真实页面里这是侧栏)
|
|
104
|
+
return pickByClass(s2);
|
|
105
|
+
},
|
|
106
|
+
querySelectorAll() { return []; },
|
|
107
|
+
createElement: (t) => new El(t),
|
|
108
|
+
createTextNode: (t) => ({ nodeValue: t }),
|
|
109
|
+
getElementById: () => null,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const timers = new Map();
|
|
113
|
+
const win = {
|
|
114
|
+
innerWidth: width,
|
|
115
|
+
innerHeight: 800,
|
|
116
|
+
devicePixelRatio: 1,
|
|
117
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
118
|
+
addEventListener() {},
|
|
119
|
+
removeEventListener() {},
|
|
120
|
+
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
|
121
|
+
requestAnimationFrame: (fn) => { const id = setImmediate(fn); return id; },
|
|
122
|
+
setTimeout: (fn, ms) => { const id = setTimeout(fn, ms); timers.set(id, fn); return id; },
|
|
123
|
+
clearTimeout: (id) => clearTimeout(id),
|
|
124
|
+
MutationObserver: class { observe() {} disconnect() {} },
|
|
125
|
+
ResizeObserver: class { observe() {} disconnect() {} },
|
|
126
|
+
};
|
|
127
|
+
const ctx = vm.createContext({
|
|
128
|
+
window: win, document: doc, console, setTimeout: win.setTimeout, clearTimeout: win.clearTimeout,
|
|
129
|
+
setInterval: () => 0, clearInterval() {}, MutationObserver: win.MutationObserver,
|
|
130
|
+
ResizeObserver: win.ResizeObserver, requestAnimationFrame: win.requestAnimationFrame,
|
|
131
|
+
navigator: { userAgent: "iPhone Safari", maxTouchPoints: 5, platform: "iPhone" },
|
|
132
|
+
location: { href: "https://n.risegao.cn:13443/app/", host: "n.risegao.cn" },
|
|
133
|
+
localStorage: win.localStorage, URL, Date, Math, JSON, Object, Array, String, Number, RegExp, Boolean, Promise, Error, isNaN, parseInt, parseFloat,
|
|
134
|
+
HTMLElement: El, // 脚本用 `c instanceof HTMLElement` 过滤 frame 的子列
|
|
135
|
+
});
|
|
136
|
+
win.onerror = (m) => { if (ADAPTER_DEBUG) console.error("ADAPTER onerror:", m); };
|
|
137
|
+
const dbgConsole = ADAPTER_DEBUG
|
|
138
|
+
? { log: (...a) => console.error("[adapter]", ...a), warn: (...a) => console.error("[adapter warn]", ...a), error: (...a) => console.error("[adapter err]", ...a) }
|
|
139
|
+
: console;
|
|
140
|
+
ctx.console = dbgConsole;
|
|
141
|
+
ctx.globalThis = ctx;
|
|
142
|
+
ctx.self = win;
|
|
143
|
+
return { ctx, html, body, frame, sidebar, toggle, win };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ADAPTER_DEBUG = process.env.DSH_ADAPTER_DEBUG === "1";
|
|
147
|
+
|
|
148
|
+
function runAdapter(env) {
|
|
149
|
+
try {
|
|
150
|
+
vm.runInContext(adapterScript(), env.ctx, { timeout: 5000 });
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (ADAPTER_DEBUG) console.error("ADAPTER THREW:", e && e.message);
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
// sync() 在 boot 时立即执行一次;脚本内用 setTimeout/rAF 的场景再推一轮
|
|
156
|
+
return env;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const findScrim = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-scrim"));
|
|
160
|
+
const findHamburger = (env) => env.body.children.find((c) => c._cls.has("dsh-ma-hamburger"));
|
|
161
|
+
|
|
162
|
+
// ── 用例 ─────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
test("运行时:官方属性带值写法 =false 时不得判定为展开 → 遮罩不拦截点击(用户报的整屏阴影)", () => {
|
|
165
|
+
// 官方若输出 data-sidebar-collapsed="false"(属性存在但语义为未折叠),旧实现 hasAttribute → 判定展开 → 遮罩常亮
|
|
166
|
+
const env = runAdapter(harness({ collapsedAttr: "false", sidebarRect: null }));
|
|
167
|
+
assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"),
|
|
168
|
+
"属性值 false = 未折叠 → 不应加 sidebar-open(否则遮罩会拦住整屏点击)");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("运行时:即使判定为展开,侧栏没真的滑进视口时也不得拦截点击", () => {
|
|
172
|
+
// 属性"存在且无值"在旧实现里就是展开;这里再叠加"侧栏几何不在视口内"(left:0/right:0)→ 仍不得拦截
|
|
173
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 0, top: 0, bottom: 800, width: 0, height: 800 } }));
|
|
174
|
+
assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"),
|
|
175
|
+
"侧栏未滑入视口 → 遮罩必须保持 pointer-events:none(几何兜底)");
|
|
176
|
+
const scrim = findScrim(env);
|
|
177
|
+
assert.ok(scrim, "遮罩元素本身仍应创建(关闭态只是不拦截)");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("运行时:侧栏真的滑入视口(right>8 且有宽度)时才允许拦截点击", () => {
|
|
181
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 } }));
|
|
182
|
+
assert.ok(env.html.classList.contains("dsh-ma-sidebar-open"),
|
|
183
|
+
"抽屉确实展开时遮罩才应显示并拦截(正常行为不能被改坏)");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("运行时:判定为展开时不再隐藏汉堡按钮(避免用户没有任何入口)", () => {
|
|
187
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 } }));
|
|
188
|
+
const ham = findHamburger(env);
|
|
189
|
+
assert.ok(ham, "汉堡按钮应存在");
|
|
190
|
+
assert.notEqual(ham.style.display, "none",
|
|
191
|
+
"抽屉展开时也不应把汉堡隐藏 —— 判定一旦出错,用户会连入口都没有(事故现场就是这个死局)");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("运行时:找不到官方 toggle 时,点遮罩必须能自救(摘掉遮挡类)", () => {
|
|
195
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 }, withToggle: false }));
|
|
196
|
+
assert.ok(env.html.classList.contains("dsh-ma-sidebar-open"), "前提:先处于遮挡态");
|
|
197
|
+
const scrim = findScrim(env);
|
|
198
|
+
scrim.dispatch("click");
|
|
199
|
+
assert.ok(!env.html.classList.contains("dsh-ma-sidebar-open"),
|
|
200
|
+
"找不到官方 toggle 时,点遮罩应直接摘掉遮挡类(否则点哪都没反应)");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("运行时:能拿到官方 toggle 时,点遮罩仍走官方 toggle(不引入私有状态)", () => {
|
|
204
|
+
const env = runAdapter(harness({ collapsedAttr: "", sidebarRect: { left: 0, right: 300, top: 0, bottom: 800, width: 300, height: 800 }, withToggle: true }));
|
|
205
|
+
const scrim = findScrim(env);
|
|
206
|
+
scrim.dispatch("click");
|
|
207
|
+
assert.ok(env.toggle._clicked >= 1, "应点击官方 toggle");
|
|
208
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6-beta.2",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -54,9 +54,7 @@
|
|
|
54
54
|
"_comment:test:plugin": "DSH_RELAY_SKIP_SERVICE=1 = 测试隔离:插件加载时不做真实系统操作(npx 补装 / launchctl / 重启 harness);需要真实分支的用例自行 unset(见 account-switch / runtime-provision / harness-restart 用例注释)",
|
|
55
55
|
"test:plugin": "DSH_REMOTE_TELEMETRY=0 DSH_RELAY_SKIP_SERVICE=1 node --test --test-concurrency=1 packages/dsh-remote-web/test/*.test.mjs",
|
|
56
56
|
"test:bridge": "node --test clients/dsh-remote/test/*.test.mjs",
|
|
57
|
-
"check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && bash -n deploy/install-open.sh
|
|
58
|
-
"sync:alias": "node scripts/sync-legacy-alias.mjs",
|
|
59
|
-
"check:alias": "node scripts/sync-legacy-alias.mjs --check",
|
|
57
|
+
"check": "node --check clients/dsh-remote/dsh-bridge.mjs && node --check clients/dsh-remote/e2ee-shim.mjs && node --check dsh-setup.mjs && node --check packages/dsh-remote-web/lib/index.js && node --check packages/dsh-remote-web/lib/client.js && bash -n deploy/install-open.sh",
|
|
60
58
|
"release:tarballs": "bash scripts/release-tarballs.sh"
|
|
61
59
|
},
|
|
62
60
|
"dependencies": {
|
|
@@ -1010,7 +1010,15 @@ window.__ModuleLoader__.load({
|
|
|
1010
1010
|
if (!b.running) {
|
|
1011
1011
|
clearInterval(iv);
|
|
1012
1012
|
setUpdating(false);
|
|
1013
|
-
|
|
1013
|
+
// 关键:进程结束 ≠ 更新成功。以前这里无条件显示"已更新完成",
|
|
1014
|
+
// 于是 spawn 失败(例如 npx 不可用)时界面只报成功、真实原因躺在日志里 ——
|
|
1015
|
+
// 用户看到的就是"点了一键修复没反应"。现在按失败信息与版本是否变化如实反馈。
|
|
1016
|
+
if (b.failure && b.failure.detail) {
|
|
1017
|
+
setUpdated(false);
|
|
1018
|
+
setSelfMsg({ kind: "err", text: "更新失败:" + b.failure.detail + "(可点「复制诊断信息」,或查看下方日志)" });
|
|
1019
|
+
} else {
|
|
1020
|
+
setUpdated(true);
|
|
1021
|
+
}
|
|
1014
1022
|
loadVer();
|
|
1015
1023
|
doCheck();
|
|
1016
1024
|
}
|
|
@@ -499,10 +499,56 @@ function appendLogLine(relayDir, name, line) {
|
|
|
499
499
|
* 已有环境(包括手动 npx 装过)直接跳过。返回 true=已就绪。
|
|
500
500
|
* 注意:默认优先官方源——镜像(npmmirror)可能滞后于刚发布的版本,装到旧版会把
|
|
501
501
|
* 已被 0.4.5 移除的“用户 include”重新写回 profile(历史上造成 dsh web 重复 ID 崩溃)。 */
|
|
502
|
+
/**
|
|
503
|
+
* 补装失败退避(2026-09-15 生产事故)。
|
|
504
|
+
*
|
|
505
|
+
* 背景:Windows 用户补装 `npx @mrrisega/dsh-remote` 持续失败时,自愈调度每轮都重试,
|
|
506
|
+
* 生产遥测里看到单机 12 秒一次、累计 606 次的失败风暴(1659 次 install_failed 全在 Windows)。
|
|
507
|
+
* 这既刷屏日志、也让真实失败原因淹没在噪声里。
|
|
508
|
+
*
|
|
509
|
+
* 策略:连续失败后按 30s → 2m → 10m 退避(封顶 10 分钟);用户点「一键修复 / 一键更新」时
|
|
510
|
+
* 显式放行(resetProvisionRetry),保证人工操作永远能立即重试。
|
|
511
|
+
*/
|
|
512
|
+
const PROVISION_RETRY_BACKOFF_MS = [30_000, 120_000, 600_000];
|
|
513
|
+
|
|
514
|
+
function provisionRetryGate(relayDir) {
|
|
515
|
+
const key = String(relayDir);
|
|
516
|
+
let st = provisionRetry.get(key);
|
|
517
|
+
if (!st) { st = { fails: 0, nextAt: 0 }; provisionRetry.set(key, st); }
|
|
518
|
+
if (Date.now() < st.nextAt) {
|
|
519
|
+
return { ok: false, waitMs: st.nextAt - Date.now(), fails: st.fails };
|
|
520
|
+
}
|
|
521
|
+
return { ok: true, st };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function noteProvisionFailure(relayDir) {
|
|
525
|
+
const key = String(relayDir);
|
|
526
|
+
const st = provisionRetry.get(key) || { fails: 0, nextAt: 0 };
|
|
527
|
+
st.fails += 1;
|
|
528
|
+
const wait = PROVISION_RETRY_BACKOFF_MS[Math.min(st.fails, PROVISION_RETRY_BACKOFF_MS.length) - 1];
|
|
529
|
+
st.nextAt = Date.now() + wait;
|
|
530
|
+
provisionRetry.set(key, st);
|
|
531
|
+
return { fails: st.fails, waitMs: wait };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function noteProvisionSuccess(relayDir) {
|
|
535
|
+
provisionRetry.delete(String(relayDir));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 用户显式要求重试(一键修复/一键更新)→ 清掉退避,让这次一定真的执行。 */
|
|
539
|
+
function resetProvisionRetry(relayDir) {
|
|
540
|
+
provisionRetry.delete(String(relayDir));
|
|
541
|
+
}
|
|
542
|
+
|
|
502
543
|
function ensureRuntime(relayDir) {
|
|
503
544
|
if (UNINSTALLED_DIRS.has(relayDir)) return false; // 已彻底卸载:不再自动安装运行环境
|
|
504
545
|
if (runtimeReady(relayDir)) return true;
|
|
505
546
|
if (skipsSystemOps()) return false; // 测试隔离:绝不在用例里 spawn 真实 npx 安装
|
|
547
|
+
const gate = provisionRetryGate(relayDir);
|
|
548
|
+
if (!gate.ok) {
|
|
549
|
+
// 退避中:不重复 spawn(避免失败风暴)。面板会据 provisionRetryInfo() 显示"上次失败、X 后可重试"。
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
506
552
|
const marker = join(relayDir, PROVISION_MARKER);
|
|
507
553
|
if (existsSync(marker)) return false; // 正在安装中
|
|
508
554
|
try {
|
|
@@ -531,13 +577,23 @@ function ensureRuntime(relayDir) {
|
|
|
531
577
|
child.on("exit", (code) => {
|
|
532
578
|
clear();
|
|
533
579
|
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
|
|
534
|
-
if (code === 0 && runtimeReady(relayDir))
|
|
535
|
-
|
|
580
|
+
if (code === 0 && runtimeReady(relayDir)) {
|
|
581
|
+
noteProvisionSuccess(relayDir);
|
|
582
|
+
telemetryRuntimeReady(relayDir); // 退出码 0 ≠ 装好了
|
|
583
|
+
} else {
|
|
584
|
+
// 退出码非 0,或退出码 0 但安装脚本没落盘(装到一半/装错包)→ 都算失败并进入退避
|
|
585
|
+
const code2 = code === 0 ? "install_script_missing" : telemetryFailCodeFromText(readTail(log));
|
|
586
|
+
const back = noteProvisionFailure(relayDir);
|
|
587
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
588
|
+
`[auto-install] 第 ${back.fails} 次失败(归因 ${code2}),${Math.round(back.waitMs / 1000)}s 后才会重试`);
|
|
589
|
+
telemetryRecord(relayDir, "install_failed", { fail_code: code2 });
|
|
590
|
+
}
|
|
536
591
|
});
|
|
537
592
|
child.on("error", (e) => {
|
|
538
593
|
clear();
|
|
539
594
|
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] 启动失败: ${e.message}`);
|
|
540
595
|
console.warn(`[dsh-remote-web] 自动安装子进程启动失败: ${e.message}`);
|
|
596
|
+
noteProvisionFailure(relayDir);
|
|
541
597
|
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
542
598
|
});
|
|
543
599
|
child.unref();
|
|
@@ -546,6 +602,7 @@ function ensureRuntime(relayDir) {
|
|
|
546
602
|
} catch (e) {
|
|
547
603
|
console.warn(`[dsh-remote-web] 自动安装启动失败: ${e.message}`);
|
|
548
604
|
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
605
|
+
noteProvisionFailure(relayDir);
|
|
549
606
|
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
550
607
|
return false;
|
|
551
608
|
}
|
|
@@ -713,6 +770,8 @@ function stopBridge() {
|
|
|
713
770
|
* 直到 dsh web 重启(profile 引用已移除,插件整体不再加载)或重新激活(apply 时清除)。
|
|
714
771
|
*/
|
|
715
772
|
const UNINSTALLED_DIRS = new Set();
|
|
773
|
+
/** 补装失败退避状态(relayDir → {fails, nextAt});见 provisionRetryGate。 */
|
|
774
|
+
const provisionRetry = new Map();
|
|
716
775
|
|
|
717
776
|
/** 标记某 relayDir 已完成彻底卸载(其后续自愈/代持调度全部停摆)。 */
|
|
718
777
|
function markUninstalled(relayDir) {
|
|
@@ -1753,7 +1812,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1753
1812
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1754
1813
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1755
1814
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1756
|
-
const PLUGIN_VERSION = "0.6.
|
|
1815
|
+
const PLUGIN_VERSION = "0.6.6-beta.2";
|
|
1757
1816
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1758
1817
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1759
1818
|
|
|
@@ -1816,6 +1875,29 @@ function compareVersions(a, b) {
|
|
|
1816
1875
|
return 0;
|
|
1817
1876
|
}
|
|
1818
1877
|
|
|
1878
|
+
/**
|
|
1879
|
+
* 最近一次在线更新的失败信息(内存态,TTL 10 分钟)。
|
|
1880
|
+
*
|
|
1881
|
+
* 为什么需要它:此前"一键更新/一键修复"点了没反应 —— spawn 是异步的,npx 解析失败时
|
|
1882
|
+
* 同步分支仍然返回 `{ok:true}`,而错误只写进 `.dsh-update.log`;前端看到 ok:true 就显示
|
|
1883
|
+
* 「更新已开始」,2 秒后轮询到 marker 被清(running:false)便显示「已更新完成」——
|
|
1884
|
+
* 版本没变、运行环境仍缺失,界面上没有任何失败提示(用户实测:点了没反应)。
|
|
1885
|
+
* 现在失败原因进内存态并可查询,前端在"没成功"时能如实报错。
|
|
1886
|
+
*/
|
|
1887
|
+
const updateFailures = new Map(); // relayDir → { at, detail, failCode }
|
|
1888
|
+
const UPDATE_FAILURE_TTL_MS = 10 * 60 * 1000;
|
|
1889
|
+
|
|
1890
|
+
function noteUpdateFailure(relayDir, detail, failCode) {
|
|
1891
|
+
updateFailures.set(String(relayDir), { at: Date.now(), detail: String(detail || "未知原因"), failCode: failCode || "unknown" });
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function recentUpdateFailure(relayDir) {
|
|
1895
|
+
const rec = updateFailures.get(String(relayDir));
|
|
1896
|
+
if (!rec) return null;
|
|
1897
|
+
if (Date.now() - rec.at > UPDATE_FAILURE_TTL_MS) { updateFailures.delete(String(relayDir)); return null; }
|
|
1898
|
+
return rec;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1819
1901
|
/** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。 */
|
|
1820
1902
|
function spawnUpdater(relayDir, extraEnv) {
|
|
1821
1903
|
const log = join(relayDir, UPDATE_LOG);
|
|
@@ -1842,7 +1924,18 @@ function runOnlineUpdate(relayDir) {
|
|
|
1842
1924
|
try {
|
|
1843
1925
|
mkdirSync(relayDir, { recursive: true });
|
|
1844
1926
|
const marker = join(relayDir, UPDATE_MARKER);
|
|
1845
|
-
if (existsSync(marker))
|
|
1927
|
+
if (existsSync(marker)) {
|
|
1928
|
+
// 残留 marker(进程被强杀/上次异常退出)会让点击**永久**被拒,前端还会把它当成
|
|
1929
|
+
// "正在跟踪进度"而只转圈不报错。超过 10 分钟一律视为陈旧并清理。
|
|
1930
|
+
let stale = false;
|
|
1931
|
+
try { stale = Date.now() - statSync(marker).mtimeMs > 10 * 60 * 1000; } catch { /* ignore */ }
|
|
1932
|
+
if (stale) {
|
|
1933
|
+
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
1934
|
+
appendLogLine(relayDir, UPDATE_LOG, "[update] 清理超过 10 分钟的陈旧 in-progress 标记");
|
|
1935
|
+
} else {
|
|
1936
|
+
return { ok: false, detail: "已有更新在进行中,请稍候(若长时间无进展,最多 10 分钟后可重试)" };
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1846
1939
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
|
|
1847
1940
|
telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
|
|
1848
1941
|
|
|
@@ -1866,17 +1959,34 @@ function runOnlineUpdate(relayDir) {
|
|
|
1866
1959
|
// 插件走 patch 热加载、bridge 是独立进程,重启 harness 已无必要(用户实测反馈)。
|
|
1867
1960
|
// 成功不发事件(update_started 已发过,失败才发 update_failed;白名单里没有 update_done,
|
|
1868
1961
|
// 硬发只会被服务端静默丢弃)。
|
|
1869
|
-
if (code !== 0)
|
|
1962
|
+
if (code !== 0) {
|
|
1963
|
+
const tail = readTail(join(relayDir, UPDATE_LOG));
|
|
1964
|
+
const fc = telemetryFailCodeFromText(tail);
|
|
1965
|
+
// 只把最后的可读片段给用户看(不含完整路径/用户名);分类码仍按白名单上报
|
|
1966
|
+
const lastLine = String(tail || "").split("\n").map((l) => l.trim()).filter(Boolean).slice(-1)[0] || "";
|
|
1967
|
+
noteUpdateFailure(relayDir, lastLine || `安装进程退出码 ${code}`, fc);
|
|
1968
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
1969
|
+
} else {
|
|
1970
|
+
updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
|
|
1971
|
+
}
|
|
1870
1972
|
});
|
|
1871
1973
|
child.on("error", (e) => {
|
|
1872
1974
|
appendLogLine(relayDir, UPDATE_LOG, `[update] 子进程启动失败: ${e.message}`);
|
|
1873
1975
|
clear();
|
|
1874
|
-
|
|
1976
|
+
const fc = telemetryFailCodeFromError(e);
|
|
1977
|
+
noteUpdateFailure(relayDir, e.message, fc); // ← 面板可查询,不再只躺在日志里
|
|
1978
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
1875
1979
|
});
|
|
1876
1980
|
child.unref();
|
|
1877
1981
|
return child;
|
|
1878
1982
|
};
|
|
1879
1983
|
const child = run();
|
|
1984
|
+
// spawn 是异步的:解析失败时 pid 可能是 undefined。此时**不能**同步返回 ok:true,
|
|
1985
|
+
// 否则前端会显示"更新已开始"、随后看到 running:false 便报"已完成"(实测的静默失败)。
|
|
1986
|
+
if (!child || !child.pid) {
|
|
1987
|
+
const rec = recentUpdateFailure(relayDir);
|
|
1988
|
+
return { ok: false, detail: (rec && rec.detail) || "无法启动更新进程(npx 不可用?)", failCode: rec ? rec.failCode : "unknown" };
|
|
1989
|
+
}
|
|
1880
1990
|
return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
|
|
1881
1991
|
} catch (e) {
|
|
1882
1992
|
try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
|
|
@@ -2517,14 +2627,29 @@ function telemetryEventOf(name, extra = {}) {
|
|
|
2517
2627
|
/** 从既有日志/错误文本归类 fail_code(白名单内);原始文本绝不外发(可能含路径/主机名)。 */
|
|
2518
2628
|
function telemetryFailCodeFromText(text) {
|
|
2519
2629
|
const t = String(text || "");
|
|
2520
|
-
|
|
2630
|
+
// 顺序有意义:从"最具体"到"最泛"。
|
|
2631
|
+
// 2026-09-15:Windows 装机失败此前**全部**落到 unknown(1659 次 install_failed 无法定位),
|
|
2632
|
+
// 所以把真实会遇到的形态拆开。仍然只上报枚举,原始文本不出机器(见下方注释与隐私测试)。
|
|
2633
|
+
// ① Windows 上 `npx.cmd` 根本调不起来(不是内部命令 / 找不到 cmd / EINVAL 等)
|
|
2634
|
+
if (/npx(\.cmd)?\s+(is not recognized|不是内部或外部命令)|不是内部或外部命令|无法将.*识别为/i.test(t)) return "npx_cmd_unavailable";
|
|
2635
|
+
if (/EINVAL|spawn .*\.cmd/i.test(t)) return "npx_cmd_unavailable";
|
|
2636
|
+
// ② 拿到的包不对:404 / 版本被 deprecate / 装完仍没有安装脚本
|
|
2637
|
+
if (/404|Not Found - GET|ETARGET|No matching version/i.test(t)) return "install_script_missing";
|
|
2638
|
+
// ③ 纯网络层问题(含 npm ERR! network / 超时 / TLS)
|
|
2639
|
+
if (/ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ETIMEOUT|ECONNREFUSED|ENETUNREACH|ECONNRESET|ERR_SOCKET_TIMEOUT|npm ERR! network|registry\.npmjs\.org.*(timeout|timed out)|超时/i.test(t)) {
|
|
2640
|
+
return "registry_timeout";
|
|
2641
|
+
}
|
|
2642
|
+
if (/network|registry|EAI|socket hang up/i.test(t)) return "npm_unreachable";
|
|
2643
|
+
// ④ 权限(含只读目录 / 沙箱拦截)
|
|
2644
|
+
if (/EACCES|EPERM|permission denied|权限不足|read-only file system/i.test(t)) return "npm_eacces";
|
|
2521
2645
|
if (/platform|不支持的平台/i.test(t)) return "platform_unsupported";
|
|
2522
|
-
if (/too old|版本过低|engine/i.test(t)) return "node_too_old";
|
|
2523
|
-
if (/ENOENT|not found|No such file/i.test(t)) return "node_missing";
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2646
|
+
if (/too old|版本过低|engine|Unsupported engine/i.test(t)) return "node_too_old";
|
|
2647
|
+
if (/ENOENT|not found|No such file|Cannot find module/i.test(t)) return "node_missing";
|
|
2648
|
+
// ⑤ 输出是乱码(Windows GBK 代码页下 npm 输出可能整段不可读)→ 谁都没法归因,单独标出来
|
|
2649
|
+
if (/[\uFFFD]/.test(t)) return "npx_output_encoding";
|
|
2650
|
+
// ⑥ 兜底:进程确实退出了非零码,但日志里没有可识别的特征 —— 标成 npx_exit_nonzero,
|
|
2651
|
+
// 与"启动都没起来"(npx_cmd_unavailable / node_missing)区分开,便于下轮定位。
|
|
2652
|
+
return "npx_exit_nonzero";
|
|
2528
2653
|
}
|
|
2529
2654
|
/** 子进程启动失败(spawn error)→ fail_code。 */
|
|
2530
2655
|
function telemetryFailCodeFromError(e) {
|
|
@@ -2777,6 +2902,9 @@ export const __telemetryInternals = {
|
|
|
2777
2902
|
installId: (relayDir) => telemetryInstallId(relayDir),
|
|
2778
2903
|
queueOf: (relayDir) => telemetryLoadQueue(relayDir, telemetryBook(relayDir)).slice(),
|
|
2779
2904
|
eventOf: (name, extra) => telemetryEventOf(name, extra),
|
|
2905
|
+
// 归因函数也暴露出来:Windows 装机失败此前全落到 unknown,需要能被用例逐条锁住
|
|
2906
|
+
failCodeFromText: (text) => telemetryFailCodeFromText(text),
|
|
2907
|
+
failCodeFromError: (e) => telemetryFailCodeFromError(e),
|
|
2780
2908
|
eventNames: [...TELEMETRY_EVENT_NAMES],
|
|
2781
2909
|
failCodes: [...TELEMETRY_FAIL_CODES],
|
|
2782
2910
|
queueMax: TELEMETRY_QUEUE_MAX,
|
|
@@ -2913,7 +3041,15 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2913
3041
|
method: "GET",
|
|
2914
3042
|
path: "/dsh-remote/self/update-log",
|
|
2915
3043
|
handler: async (_req, res) => {
|
|
2916
|
-
|
|
3044
|
+
const failure = recentUpdateFailure(relayDir);
|
|
3045
|
+
sendJson(res, 200, {
|
|
3046
|
+
ok: true,
|
|
3047
|
+
running: existsSync(join(relayDir, UPDATE_MARKER)),
|
|
3048
|
+
log: tailOf(join(relayDir, UPDATE_LOG)),
|
|
3049
|
+
// 供前端在"没成功"时如实报错(此前失败只写日志,界面显示"已完成")
|
|
3050
|
+
failure: failure ? { detail: failure.detail, failCode: failure.failCode, at: failure.at } : null,
|
|
3051
|
+
version: PLUGIN_VERSION
|
|
3052
|
+
});
|
|
2917
3053
|
},
|
|
2918
3054
|
},
|
|
2919
3055
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6-beta.2",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -24,10 +24,7 @@
|
|
|
24
24
|
"patch": "./cordis.patch.yml"
|
|
25
25
|
},
|
|
26
26
|
"client": {
|
|
27
|
-
"platform": "web"
|
|
28
|
-
"inject": [
|
|
29
|
-
"@deepseek-ai/dsh-client-runtime"
|
|
30
|
-
]
|
|
27
|
+
"platform": "web"
|
|
31
28
|
}
|
|
32
29
|
},
|
|
33
30
|
"repository": {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件清单与「一键更新静默失败」契约测试(node:test,零框架)。
|
|
3
|
+
*
|
|
4
|
+
* 事故背景(2026-09-15 生产反馈):
|
|
5
|
+
* A. 用户扫码后报「fail to load plugin / DeepSeek client collection」。
|
|
6
|
+
* 排查发现 package.json 的 dsh.client.inject 声明了 `@deepseek-ai/dsh-client-runtime`,
|
|
7
|
+
* 而该包在 harness 安装树里**根本不存在**(harness 只有 dsh-client-ui-* 等真包)。
|
|
8
|
+
* inject 取不到图行时该插件永不 arrive(面板不出现),且清单与真实依赖不符 ——
|
|
9
|
+
* 一旦 harness 侧改为严格校验,就会直接变成启动期加载失败。
|
|
10
|
+
* B. 用户报「桌面运行环境缺失 + 一键修复点了没反应」。
|
|
11
|
+
* 根因链:spawn 是异步的,npx 解析失败时同步分支仍返回 ok:true;错误只写进日志;
|
|
12
|
+
* 前端看到 ok:true 显示"更新已开始",2 秒后轮询到 marker 被清便显示"已完成"——
|
|
13
|
+
* 版本没变、环境仍缺,界面零反馈。另:残留 marker 会让点击被永久拒绝。
|
|
14
|
+
*/
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const REPO = join(HERE, "..", "..", "..");
|
|
24
|
+
const INDEX = join(HERE, "..", "lib", "index.js");
|
|
25
|
+
const CLIENT = join(HERE, "..", "lib", "client.js");
|
|
26
|
+
|
|
27
|
+
const indexSrc = readFileSync(INDEX, "utf8");
|
|
28
|
+
const clientSrc = readFileSync(CLIENT, "utf8");
|
|
29
|
+
|
|
30
|
+
test("清单:dsh.client 不得声明不存在的宿主包(幻影 inject 会让插件永不装载)", () => {
|
|
31
|
+
// 注:旧名别名包 dsh-remote-ui 已于 2026-09-15 退役并从仓库删除(npm 上已 deprecate),
|
|
32
|
+
// 因此这里只校验当前插件包 dsh-remote-web。
|
|
33
|
+
const pkg = "packages/dsh-remote-web/package.json";
|
|
34
|
+
const manifest = JSON.parse(readFileSync(join(REPO, pkg), "utf8"));
|
|
35
|
+
const client = manifest.dsh && manifest.dsh.client;
|
|
36
|
+
assert.ok(client, `${pkg} 应有 dsh.client`);
|
|
37
|
+
assert.equal(client.platform, "web");
|
|
38
|
+
const inject = client.inject || [];
|
|
39
|
+
assert.deepEqual(inject, [], `${pkg} 的 inject 必须为空:我们的浏览器半只 require("react"),不需要任何宿主包`);
|
|
40
|
+
for (const name of inject) {
|
|
41
|
+
assert.ok(!/dsh-client-runtime|dsh-client-collection/.test(name), `inject 里不得出现不存在的包名:${name}`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("旧名别名包确已退役:目录/同步脚本/发布步骤都不应再存在", () => {
|
|
46
|
+
assert.ok(!existsSync(join(REPO, "packages", "dsh-remote-ui")), "别名目录应已删除");
|
|
47
|
+
assert.ok(!existsSync(join(REPO, "scripts", "sync-legacy-alias.mjs")), "别名同步脚本应已删除");
|
|
48
|
+
const root = JSON.parse(readFileSync(join(REPO, "package.json"), "utf8"));
|
|
49
|
+
for (const k of ["sync:alias", "check:alias"]) {
|
|
50
|
+
assert.ok(!(k in root.scripts), `package.json 不应再有 ${k}`);
|
|
51
|
+
}
|
|
52
|
+
assert.ok(!/check:alias/.test(root.scripts.check || ""), "check 不应再引用别名校验");
|
|
53
|
+
// 但**旧名清理/迁移逻辑必须保留**:老用户升级依赖它
|
|
54
|
+
const setup = readFileSync(join(REPO, "dsh-setup.mjs"), "utf8");
|
|
55
|
+
assert.match(setup, /PLUGIN_LEGACY_IDS/, "安装器必须保留旧名清理(老用户升级路径)");
|
|
56
|
+
assert.match(setup, /dsh-remote-ui/, "旧名清理里应仍认得 dsh-remote-ui");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("清单:浏览器半只依赖 shell 提供的 react(不 require 宿主客户端包)", () => {
|
|
60
|
+
const requires = [...clientSrc.matchAll(/require\("([^"]+)"\)/g)].map((m) => m[1]);
|
|
61
|
+
assert.deepEqual([...new Set(requires)], ["react"], `实际 require: ${[...new Set(requires)].join(",")}`);
|
|
62
|
+
// exports["./client"] 必须存在,否则 harness 报 "declares dsh.client but exports no ./client bundle"
|
|
63
|
+
const manifest = JSON.parse(readFileSync(join(REPO, "packages/dsh-remote-web/package.json"), "utf8"));
|
|
64
|
+
assert.ok(manifest.exports && manifest.exports["./client"], "必须声明 exports['./client']");
|
|
65
|
+
assert.ok(existsSync(join(HERE, "..", "lib", "client.js")), "client bundle 文件必须存在");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("一键更新:spawn 失败不得同步返回 ok:true(否则界面显示已开始、却什么也没发生)", () => {
|
|
69
|
+
assert.match(indexSrc, /if \(!child \|\| !child\.pid\)/, "pid 缺失时必须返回失败");
|
|
70
|
+
assert.match(indexSrc, /noteUpdateFailure\(relayDir/, "失败原因要进可查询状态,不能只写日志");
|
|
71
|
+
assert.match(indexSrc, /recentUpdateFailure\(relayDir\)/, "应能查出最近的失败");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("一键更新:残留 marker 必须有过期判定(否则点击被永久拒绝、界面只转圈)", () => {
|
|
75
|
+
assert.match(indexSrc, /10 \* 60 \* 1000/, "应有 10 分钟陈旧判定");
|
|
76
|
+
assert.match(indexSrc, /清理超过 10 分钟的陈旧 in-progress 标记/, "应真的清理陈旧 marker");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("一键更新:前端在进程结束但没成功时必须报错,而不是显示已完成", () => {
|
|
80
|
+
assert.match(clientSrc, /if \(b\.failure && b\.failure\.detail\)/, "前端应消费 failure");
|
|
81
|
+
assert.match(clientSrc, /"更新失败:" \+ b\.failure\.detail/, "应把失败原因显示出来");
|
|
82
|
+
// update-log 端点要下发 failure
|
|
83
|
+
assert.match(indexSrc, /failure: failure \? \{ detail: failure\.detail/, "update-log 应下发 failure");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("补装失败退避:Windows 的失败风暴不再每轮重试", () => {
|
|
87
|
+
assert.match(indexSrc, /PROVISION_RETRY_BACKOFF_MS\s*=\s*\[30_000, 120_000, 600_000\]/, "应有 30s→2m→10m 退避");
|
|
88
|
+
assert.match(indexSrc, /function provisionRetryGate/, "应有退避闸门");
|
|
89
|
+
assert.match(indexSrc, /noteProvisionFailure\(relayDir\)/, "失败要记退避");
|
|
90
|
+
assert.match(indexSrc, /noteProvisionSuccess\(relayDir\)/, "成功要清退避");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("失败归因:Windows 真实错误不再全部落到 unknown(1659 次无法定位的教训)", () => {
|
|
94
|
+
const codes = ["npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding"];
|
|
95
|
+
for (const c of codes) {
|
|
96
|
+
assert.ok(indexSrc.includes(`"${c}"`), `客户端归因应包含 ${c}`);
|
|
97
|
+
}
|
|
98
|
+
// 与服务端白名单保持一致(服务端不认的码会被静默丢弃 → 又变成 unknown)
|
|
99
|
+
const server = join(REPO, "..", "dsh-relay-enterprise", "relay-enterprise", "src", "api.js");
|
|
100
|
+
if (existsSync(server)) {
|
|
101
|
+
const apiSrc = readFileSync(server, "utf8");
|
|
102
|
+
for (const c of codes) {
|
|
103
|
+
assert.ok(apiSrc.includes(`"${c}"`), `服务端 TELEMETRY_FAIL_CODES 必须接受 ${c}(否则上报被丢弃)`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("失败归因:逐条映射真实日志(不联网、不起进程)", () => {
|
|
109
|
+
const mod = JSON.parse(execFileSync(process.execPath, ["-e", `
|
|
110
|
+
import("${INDEX}").then((m) => {
|
|
111
|
+
const f = m.__telemetryInternals.failCodeFromText;
|
|
112
|
+
console.log(JSON.stringify([
|
|
113
|
+
["'npx.cmd' 不是内部或外部命令", f("'npx.cmd' 不是内部或外部命令,也不是可运行的程序")],
|
|
114
|
+
["spawn EINVAL", f("spawn npx.cmd EINVAL")],
|
|
115
|
+
["404", f("npm ERR! 404 Not Found - GET https://registry.npmjs.org/@mrrisega%2fdsh-remote")],
|
|
116
|
+
["ETIMEDOUT", f("npm error code ETIMEDOUT")],
|
|
117
|
+
["EACCES", f("npm ERR! EACCES: permission denied")],
|
|
118
|
+
["engine", f("Unsupported engine: wanted node >=22.13.0")],
|
|
119
|
+
["other", f("something unfamiliar")]
|
|
120
|
+
]));
|
|
121
|
+
});
|
|
122
|
+
`], { encoding: "utf8" }).trim());
|
|
123
|
+
const map = new Map(mod);
|
|
124
|
+
assert.equal(map.get("'npx.cmd' 不是内部或外部命令"), "npx_cmd_unavailable");
|
|
125
|
+
assert.equal(map.get("spawn EINVAL"), "npx_cmd_unavailable");
|
|
126
|
+
assert.equal(map.get("404"), "install_script_missing");
|
|
127
|
+
assert.equal(map.get("ETIMEDOUT"), "registry_timeout");
|
|
128
|
+
assert.equal(map.get("EACCES"), "npm_eacces");
|
|
129
|
+
assert.equal(map.get("engine"), "node_too_old");
|
|
130
|
+
assert.equal(map.get("other"), "npx_exit_nonzero", "兜底不得再是 unknown");
|
|
131
|
+
});
|