@mrrisega/dsh-remote 0.7.3-beta.3 → 0.7.4-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +206 -5
- package/clients/dsh-remote/e2ee-shim-script.js +109 -43
- package/clients/dsh-remote/mobile-adapter.mjs +454 -1
- package/clients/dsh-remote/test/bridge-file-transfer.test.mjs +281 -0
- package/clients/dsh-remote/test/e2ee-shim-retry.test.mjs +249 -0
- package/clients/dsh-remote/test/lib-e2ee-shim.mjs +1 -0
- package/clients/dsh-remote/test/mobile-adapter-resume.test.mjs +460 -0
- package/clients/dsh-remote/test/mobile-adapter-upload-hint.test.mjs +157 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +206 -5
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +109 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +454 -1
- package/packages/dsh-remote-web/test/mobile-adapter-resume.test.mjs +125 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 镜像页 E2EE 死会话自动恢复 / 回前台重连(2026-10-23,iOS 断线重连体验)。
|
|
4
|
+
*
|
|
5
|
+
* 现场:iOS 用户切后台被杀 / 切走一两分钟再回来 → 镜像页重载;桥端 E2EE 会话表是**进程内存**
|
|
6
|
+
* (24h TTL + CAP 32 LRU,电脑端重启即丢)→ 重载后撞上「加密会话已失效,需要重新解锁」,
|
|
7
|
+
* 旧行为只能回设备列表手动重新解锁(生产实测某设备 3964 次 e2ee_session_locked)。
|
|
8
|
+
*
|
|
9
|
+
* 本用例跑**真实注入脚本里的恢复层**(假 window/document,指令级断言,不是文本 grep):
|
|
10
|
+
* ① 死会话 → location.replace("/app/?resume=<deviceId>&from=mirror"),本页最多一次 + 时间窗闸门;
|
|
11
|
+
* ② 纯明文(用户主动选「暂不加密」)**绝不**跳转 —— 只有"本该加密却因死会话被降级成明文"
|
|
12
|
+
* (shim 的 dead-reload 闸门 + 本机有派生 MK)才抢修;
|
|
13
|
+
* ③ 非镜像页路径 → 不安装监听、什么都不做;
|
|
14
|
+
* ④ 回前台:后台 ≥8s 才动作、一次轻量探针;探针失败 → 唤醒官方客户端再探一次 →
|
|
15
|
+
* 仍失败只给**可见**提示,绝不做静默无限重连;本页最多 3 次。
|
|
16
|
+
*/
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import vm from "node:vm";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
22
|
+
|
|
23
|
+
/** 从注入结果里取出真正的注入脚本(原样执行)。 */
|
|
24
|
+
function adapterScript() {
|
|
25
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
26
|
+
const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
27
|
+
assert.ok(m, "应能取出注入脚本");
|
|
28
|
+
return m[1];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sliceBetween(src, from, to) {
|
|
32
|
+
const a = src.indexOf(from);
|
|
33
|
+
const b = src.indexOf(to, a);
|
|
34
|
+
assert.ok(a !== -1, `找不到起始锚点: ${from}`);
|
|
35
|
+
assert.ok(b !== -1 && b > a, `找不到结束锚点: ${to}`);
|
|
36
|
+
return src.slice(a, b);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SRC = adapterScript();
|
|
40
|
+
const PURE_SRC = sliceBetween(SRC, "/* ===DSH-MA-RESUME-PURE-START=== */", "/* ===DSH-MA-RESUME-PURE-END=== */");
|
|
41
|
+
const ENGINE_SRC = sliceBetween(SRC, "/* ===DSH-MA-RESUME-ENGINE-START=== */", "/* ===DSH-MA-RESUME-ENGINE-END=== */");
|
|
42
|
+
|
|
43
|
+
// ── 纯函数:设备 id 解析 + 是否需要恢复 ─────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function loadPure() {
|
|
46
|
+
return vm.runInNewContext(
|
|
47
|
+
PURE_SRC + "\n; ({ maDeviceIdOfMirrorPath, maE2eeResumeDecision })",
|
|
48
|
+
{},
|
|
49
|
+
{ filename: "mobile-adapter#resume-pure" }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("设备 id 只从 /remote/<id>/ 解析,且只接受 URL 安全字符", () => {
|
|
54
|
+
const { maDeviceIdOfMirrorPath } = loadPure();
|
|
55
|
+
assert.equal(maDeviceIdOfMirrorPath("/remote/dev-1b8cc3ac183b/"), "dev-1b8cc3ac183b");
|
|
56
|
+
assert.equal(maDeviceIdOfMirrorPath("/remote/dev-abc/session/x"), "dev-abc", "带子路径也要取到设备段");
|
|
57
|
+
assert.equal(maDeviceIdOfMirrorPath("/remote/dev_abc.1:2"), "dev_abc.1:2");
|
|
58
|
+
assert.equal(maDeviceIdOfMirrorPath("/remote/"), "", "没有设备段 → 空串(不跳转)");
|
|
59
|
+
assert.equal(maDeviceIdOfMirrorPath("/remotex/dev"), "", "前缀必须整段匹配");
|
|
60
|
+
assert.equal(maDeviceIdOfMirrorPath("/app/"), "", "设备列表页不是镜像页");
|
|
61
|
+
assert.equal(maDeviceIdOfMirrorPath(""), "");
|
|
62
|
+
assert.equal(maDeviceIdOfMirrorPath(null), "");
|
|
63
|
+
assert.equal(maDeviceIdOfMirrorPath("/remote/..%2F..%2Fadmin"), "", "非法解码结果必须拒绝");
|
|
64
|
+
assert.equal(maDeviceIdOfMirrorPath("/remote/" + "a".repeat(200)), "", "超长 id 必须拒绝");
|
|
65
|
+
assert.equal(maDeviceIdOfMirrorPath("/remote/-leading"), "", "不得以符号开头(避免拼出奇怪路径)");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("死会话文案 → 需要恢复;明文/未解锁默认绝不打扰", () => {
|
|
69
|
+
const { maE2eeResumeDecision } = loadPure();
|
|
70
|
+
const now = 1_700_000_000_000;
|
|
71
|
+
// ① 桥端明确答复(两种 shim 文案)
|
|
72
|
+
assert.equal(maE2eeResumeDecision({ mode: "warn", text: "加密会话已失效,需要重新解锁" }, { now }), "dead_session");
|
|
73
|
+
assert.equal(maE2eeResumeDecision({ mode: "warn", text: "加密会话已失效(unknown_session):本机保存的会话已不可用,需要重新解锁。" }, { now }), "dead_session");
|
|
74
|
+
// ② 状态正常 / 没有状态 → 不动作
|
|
75
|
+
assert.equal(maE2eeResumeDecision({ mode: "ok", text: "端到端加密已开启" }, { now }), "");
|
|
76
|
+
assert.equal(maE2eeResumeDecision(null, { now }), "");
|
|
77
|
+
assert.equal(maE2eeResumeDecision({ mode: "warn", text: "" }, { now }), "");
|
|
78
|
+
// ③ 纯明文/未解锁/交接单无效 —— 用户可能主动选了「暂不加密」,没有别的证据时绝不跳转
|
|
79
|
+
for (const text of ["未加密:未解锁(明文)", "未加密:环境不支持", "未加密:交接单无效", "本次为明文连接"]) {
|
|
80
|
+
assert.equal(maE2eeResumeDecision({ mode: "warn", text }, { now }), "", `明文态不得触发: ${text}`);
|
|
81
|
+
}
|
|
82
|
+
// ④ 瞬时故障类(通道未建立/无法解密)不是"确认不可用" → 不跳转
|
|
83
|
+
assert.equal(maE2eeResumeDecision({ mode: "warn", text: "加密数据通道未建立(第 2 次)" }, { now }), "");
|
|
84
|
+
assert.equal(maE2eeResumeDecision({ mode: "err", text: "无法解密" }, { now }), "");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("只有「死会话重载 + 本机有 MK」才把降级后的明文当成待抢修", () => {
|
|
88
|
+
const { maE2eeResumeDecision } = loadPure();
|
|
89
|
+
const now = 1_700_000_000_000;
|
|
90
|
+
const st = { mode: "warn", text: "未加密:未解锁(明文)" };
|
|
91
|
+
assert.equal(maE2eeResumeDecision(st, { mk: true, deadReloadAt: now - 5_000, now }), "plaintext_after_dead");
|
|
92
|
+
assert.equal(maE2eeResumeDecision(st, { mk: false, deadReloadAt: now - 5_000, now }), "", "本机没有 MK → 解锁也做不了,不打扰");
|
|
93
|
+
assert.equal(maE2eeResumeDecision(st, { mk: true, deadReloadAt: now - 10 * 60 * 1000, now }), "", "重载闸门太旧 → 大概率是用户自己选的明文");
|
|
94
|
+
assert.equal(maE2eeResumeDecision(st, { mk: true, deadReloadAt: now + 5_000, now }), "", "时间戳在未来 → 不可信");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── 恢复层(真实引擎 + 假 DOM)─────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 加载恢复层并让它自己装好监听(ENGINE_SRC 末尾就会调用 maArmE2eeResume)。
|
|
101
|
+
* 时间/定时器/网络全部可注入,断言的是"真的发了哪个导航/请求"。
|
|
102
|
+
*/
|
|
103
|
+
function loadEngine(opts) {
|
|
104
|
+
const o = opts || {};
|
|
105
|
+
const calls = { replace: [], assign: [], href: [], fetchUrls: [], fetchInits: [], online: 0, timers: [], timersDropped: 0 };
|
|
106
|
+
const clock = { now: o.now || 1_700_000_000_000 };
|
|
107
|
+
let timerSeq = 0;
|
|
108
|
+
const makeStore = (seed) => {
|
|
109
|
+
const m = new Map(Object.entries(seed || {}));
|
|
110
|
+
return {
|
|
111
|
+
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
|
112
|
+
setItem: (k, v) => { m.set(k, String(v)); },
|
|
113
|
+
removeItem: (k) => { m.delete(k); },
|
|
114
|
+
_map: m
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
const localStorage = makeStore(o.localStorage);
|
|
118
|
+
const sessionStorage = makeStore(o.sessionStorage);
|
|
119
|
+
const listeners = { window: {}, document: {} };
|
|
120
|
+
const on = (bag) => (type, fn) => { (bag[type] = bag[type] || []).push(fn); };
|
|
121
|
+
|
|
122
|
+
const location = {
|
|
123
|
+
pathname: o.pathname === undefined ? "/remote/dev-abc123/" : o.pathname,
|
|
124
|
+
search: o.search || "",
|
|
125
|
+
hash: o.hash || "",
|
|
126
|
+
replace: (u) => calls.replace.push(String(u)),
|
|
127
|
+
assign: (u) => calls.assign.push(String(u))
|
|
128
|
+
};
|
|
129
|
+
let href = "";
|
|
130
|
+
Object.defineProperty(location, "href", {
|
|
131
|
+
get: () => href,
|
|
132
|
+
set: (v) => { href = String(v); calls.href.push(href); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const win = {
|
|
136
|
+
location,
|
|
137
|
+
localStorage,
|
|
138
|
+
sessionStorage,
|
|
139
|
+
innerWidth: 390,
|
|
140
|
+
innerHeight: 844,
|
|
141
|
+
addEventListener: on(listeners.window),
|
|
142
|
+
dispatchEvent: (ev) => { if (ev && ev.type === "online") calls.online += 1; return true; },
|
|
143
|
+
fetch: (url, init) => {
|
|
144
|
+
calls.fetchUrls.push(String(url));
|
|
145
|
+
calls.fetchInits.push(init || {});
|
|
146
|
+
if (typeof o.fetch === "function") return o.fetch(String(url), init);
|
|
147
|
+
return Promise.resolve({ status: 200 });
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const doc = {
|
|
151
|
+
visibilityState: "visible",
|
|
152
|
+
addEventListener: on(listeners.document)
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const loadWatch = { dismissed: false, slow: false, barEl: null, barTxt: null, hiddenCalled: 0 };
|
|
156
|
+
const sandbox = {
|
|
157
|
+
window: win,
|
|
158
|
+
document: doc,
|
|
159
|
+
localStorage,
|
|
160
|
+
sessionStorage,
|
|
161
|
+
maPublishDiag: () => {},
|
|
162
|
+
maLoadWatch: loadWatch,
|
|
163
|
+
maLoadMountBar: () => {
|
|
164
|
+
const bar = { hidden: true, text: "" };
|
|
165
|
+
loadWatch.barEl = bar;
|
|
166
|
+
loadWatch.barTxt = {
|
|
167
|
+
get textContent() { return bar.text; },
|
|
168
|
+
set textContent(v) { bar.text = String(v); }
|
|
169
|
+
};
|
|
170
|
+
return bar;
|
|
171
|
+
},
|
|
172
|
+
maLoadHideBar: () => { loadWatch.hiddenCalled += 1; },
|
|
173
|
+
Date: { now: () => clock.now },
|
|
174
|
+
Promise,
|
|
175
|
+
JSON,
|
|
176
|
+
Number,
|
|
177
|
+
String,
|
|
178
|
+
Array,
|
|
179
|
+
Object,
|
|
180
|
+
isFinite,
|
|
181
|
+
encodeURIComponent,
|
|
182
|
+
decodeURIComponent,
|
|
183
|
+
// 假定时器:记 due 时间并支持真正取消 —— 否则"探针超时"会在 fetch resolve 之前被
|
|
184
|
+
// 测试自己跑掉,把成功场景误判成失败(第一版用例就踩了这个假红)。
|
|
185
|
+
setTimeout: (fn, ms) => {
|
|
186
|
+
const id = ++timerSeq;
|
|
187
|
+
calls.timers.push({ id, fn, ms, due: clock.now + Number(ms || 0) });
|
|
188
|
+
return id;
|
|
189
|
+
},
|
|
190
|
+
clearTimeout: (id) => {
|
|
191
|
+
const i = calls.timers.findIndex((t) => t.id === id);
|
|
192
|
+
if (i >= 0) calls.timers.splice(i, 1);
|
|
193
|
+
},
|
|
194
|
+
AbortController: class { constructor() { this.signal = { aborted: false }; } abort() { this.signal.aborted = true; } },
|
|
195
|
+
Event: class { constructor(type) { this.type = type; } }
|
|
196
|
+
};
|
|
197
|
+
const api = vm.runInContext(
|
|
198
|
+
ENGINE_SRC + "\n; ({ maArmE2eeResume, maE2eeTryResume, maForegroundResume, maFgState, maResumeState,"
|
|
199
|
+
+ " MA_RESUME_DELAY_MS, MA_RESUME_WINDOW_MS, MA_RESUME_MAX_IN_WINDOW, MA_RESUME_COOLDOWN_MS,"
|
|
200
|
+
+ " MA_FG_MIN_BG_MS, MA_FG_COOLDOWN_MS, MA_FG_MAX_PER_PAGE, MA_PROBE_TIMEOUT_MS })",
|
|
201
|
+
vm.createContext(sandbox),
|
|
202
|
+
{ filename: "mobile-adapter#resume-engine" }
|
|
203
|
+
);
|
|
204
|
+
return {
|
|
205
|
+
...api, sandbox, win, doc, calls, listeners, localStorage, sessionStorage, loadWatch,
|
|
206
|
+
now: () => clock.now,
|
|
207
|
+
setNow: (v) => { clock.now = v; },
|
|
208
|
+
fire: (bag, type) => {
|
|
209
|
+
const list = listeners[bag][type] || [];
|
|
210
|
+
for (const fn of list.slice()) fn({ type, persisted: true });
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 把恢复层排队的所有定时器/微任务跑完(异步链:探针 → 延时 → 再探针 → 提示)。
|
|
216
|
+
* 规则:**先**让已 resolve 的 promise 链跑完(setImmediate),**再**把假时钟推进到最近的
|
|
217
|
+
* 到期定时器并执行 —— 顺序反了会把"4 秒探针超时"跑在"已成功的 fetch"之前,制造假红。 */
|
|
218
|
+
async function drain(e, rounds = 60) {
|
|
219
|
+
for (let i = 0; i < rounds; i += 1) {
|
|
220
|
+
await new Promise((r) => setImmediate(r));
|
|
221
|
+
if (!e.calls.timers.length) {
|
|
222
|
+
await new Promise((r) => setImmediate(r));
|
|
223
|
+
if (!e.calls.timers.length) return;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const earliest = Math.min(...e.calls.timers.map((t) => t.due));
|
|
227
|
+
if (earliest > e.now()) e.setNow(earliest);
|
|
228
|
+
const due = e.calls.timers.filter((t) => t.due <= e.now());
|
|
229
|
+
e.calls.timers = e.calls.timers.filter((t) => t.due > e.now());
|
|
230
|
+
for (const t of due) {
|
|
231
|
+
try { t.fn(); } catch { /* 定时器里的异常由被测代码自己吞 */ }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const DEAD = { mode: "warn", text: "加密会话已失效,需要重新解锁" };
|
|
237
|
+
const PLAIN = { mode: "warn", text: "未加密:未解锁(明文)" };
|
|
238
|
+
const OK = { mode: "ok", text: "端到端加密已开启" };
|
|
239
|
+
|
|
240
|
+
test("死会话 → 自动回 /app/?resume=<deviceId>&from=mirror(本页只一次)", async () => {
|
|
241
|
+
const e = loadEngine({ pathname: "/remote/dev-1b8cc3ac183b/" });
|
|
242
|
+
assert.equal(e.maResumeState.armed, true, "镜像页必须装上恢复层");
|
|
243
|
+
e.win.__dshE2eeBadge = { state: () => DEAD };
|
|
244
|
+
e.fire("document", "dsh-e2ee-badge");
|
|
245
|
+
assert.deepEqual(e.calls.replace, [], "必须先给 shim 收尾一点时间,不在事件回调里同步跳转");
|
|
246
|
+
await drain(e);
|
|
247
|
+
assert.deepEqual(e.calls.replace, ["/app/?resume=dev-1b8cc3ac183b&from=mirror"]);
|
|
248
|
+
assert.equal(e.maResumeState.reason, "dead_session");
|
|
249
|
+
// 本页再次发布同样状态 → 不再跳第二次
|
|
250
|
+
e.fire("document", "dsh-e2ee-badge");
|
|
251
|
+
await drain(e);
|
|
252
|
+
assert.equal(e.calls.replace.length, 1, "每页最多一次");
|
|
253
|
+
// 闸门真的落进了 sessionStorage(跨重载仍然有效)
|
|
254
|
+
const hits = JSON.parse(e.sessionStorage.getItem("dsh-ma-e2ee-resume"));
|
|
255
|
+
assert.equal(hits.length, 1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("纯明文(用户选过「暂不加密」)绝不跳转 —— 即使反复收到徽标事件", async () => {
|
|
259
|
+
const e = loadEngine({ pathname: "/remote/dev-abc123/" });
|
|
260
|
+
e.win.__dshE2eeBadge = { state: () => PLAIN };
|
|
261
|
+
e.fire("document", "dsh-e2ee-badge");
|
|
262
|
+
e.fire("window", "dsh-e2ee-badge");
|
|
263
|
+
await drain(e);
|
|
264
|
+
assert.deepEqual(e.calls.replace, [], "明文模式功能可用,不得把用户弹回设备列表");
|
|
265
|
+
assert.deepEqual(e.calls.assign, []);
|
|
266
|
+
assert.deepEqual(e.calls.href, []);
|
|
267
|
+
assert.equal(e.sessionStorage.getItem("dsh-ma-e2ee-resume"), null, "没有恢复就不该占用闸门额度");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("死会话重载后被降级成明文,且本机有 MK → 抢修一次", async () => {
|
|
271
|
+
const now = 1_700_000_000_000;
|
|
272
|
+
const e = loadEngine({
|
|
273
|
+
pathname: "/remote/dev-abc123/",
|
|
274
|
+
now,
|
|
275
|
+
localStorage: { "dsh-e2ee-mk-v1": "x".repeat(44) },
|
|
276
|
+
sessionStorage: { "dsh-e2ee-dead-reload": String(now - 3000) }
|
|
277
|
+
});
|
|
278
|
+
e.win.__dshE2eeBadge = { state: () => PLAIN };
|
|
279
|
+
// 兜底检查(800ms)就会触发;这里直接手动触发一次更稳
|
|
280
|
+
e.fire("document", "dsh-e2ee-badge");
|
|
281
|
+
await drain(e);
|
|
282
|
+
assert.deepEqual(e.calls.replace, ["/app/?resume=dev-abc123&from=mirror"]);
|
|
283
|
+
assert.equal(e.maResumeState.reason, "plaintext_after_dead");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("不是镜像页 → 不装监听、不跳转(设备列表/其它页面零影响)", async () => {
|
|
287
|
+
const e = loadEngine({ pathname: "/app/" });
|
|
288
|
+
assert.equal(e.maResumeState.armed, false);
|
|
289
|
+
e.win.__dshE2eeBadge = { state: () => DEAD };
|
|
290
|
+
e.fire("document", "dsh-e2ee-badge");
|
|
291
|
+
e.fire("window", "dsh-e2ee-badge");
|
|
292
|
+
await drain(e);
|
|
293
|
+
assert.deepEqual(e.calls.replace, []);
|
|
294
|
+
assert.deepEqual(e.listeners.document["dsh-e2ee-badge"] || [], [], "非镜像页不得挂监听");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("闸门:60 秒窗口内最多 2 次 / 冷却期内不重复恢复", async () => {
|
|
298
|
+
const now = 1_700_000_000_000;
|
|
299
|
+
const e = loadEngine({
|
|
300
|
+
pathname: "/remote/dev-abc123/",
|
|
301
|
+
now,
|
|
302
|
+
sessionStorage: { "dsh-ma-e2ee-resume": JSON.stringify([now - 1000, now - 2000]) }
|
|
303
|
+
});
|
|
304
|
+
e.win.__dshE2eeBadge = { state: () => DEAD };
|
|
305
|
+
e.fire("document", "dsh-e2ee-badge");
|
|
306
|
+
await drain(e);
|
|
307
|
+
assert.deepEqual(e.calls.replace, [], "窗口内已用完 2 次额度 → 不跳(防重定向风暴)");
|
|
308
|
+
|
|
309
|
+
const e2 = loadEngine({ pathname: "/remote/dev-abc123/", now });
|
|
310
|
+
e2.maResumeState.lastAt = now - 5_000; // 冷却期内
|
|
311
|
+
e2.win.__dshE2eeBadge = { state: () => DEAD };
|
|
312
|
+
e2.fire("document", "dsh-e2ee-badge");
|
|
313
|
+
await drain(e2);
|
|
314
|
+
assert.deepEqual(e2.calls.replace, [], "冷却期内不重复跳转");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("回前台:短暂切走(<8s)不动作;超过 8s 才做一次轻量探针", async () => {
|
|
318
|
+
const e = loadEngine({ pathname: "/remote/dev-abc123/" });
|
|
319
|
+
e.win.__dshE2eeBadge = { state: () => OK };
|
|
320
|
+
e.fire("document", "visibilitychange"); // 初始可见 → gap=0,不动作
|
|
321
|
+
await drain(e);
|
|
322
|
+
assert.deepEqual(e.calls.fetchUrls, [], "初始可见不得探针");
|
|
323
|
+
|
|
324
|
+
// 切后台 3 秒 → 回来:不动作
|
|
325
|
+
e.doc.visibilityState = "hidden";
|
|
326
|
+
e.fire("document", "visibilitychange");
|
|
327
|
+
e.setNow(e.now() + 3_000);
|
|
328
|
+
e.doc.visibilityState = "visible";
|
|
329
|
+
e.fire("document", "visibilitychange");
|
|
330
|
+
await drain(e);
|
|
331
|
+
assert.deepEqual(e.calls.fetchUrls, [], "短暂切走不算断线");
|
|
332
|
+
|
|
333
|
+
// 切后台 30 秒 → 回来:一次探针(隧道 manifest),成功则不打扰
|
|
334
|
+
e.doc.visibilityState = "hidden";
|
|
335
|
+
e.fire("document", "visibilitychange");
|
|
336
|
+
e.setNow(e.now() + 30_000);
|
|
337
|
+
e.doc.visibilityState = "visible";
|
|
338
|
+
e.fire("document", "visibilitychange");
|
|
339
|
+
await drain(e);
|
|
340
|
+
assert.equal(e.calls.fetchUrls.length, 1, "回前台应做一次轻量探针");
|
|
341
|
+
// 必须打**过桥**的路径:HEAD /remote/<dev>/(manifest 那类会被中继本地直出,证明不了隧道)
|
|
342
|
+
assert.ok(e.calls.fetchUrls[0].indexOf("/remote/dev-abc123/?dshProbe=") === 0,
|
|
343
|
+
"探针必须打 /remote/<dev>/ 而不是中继本地直出的静态资源");
|
|
344
|
+
assert.equal(e.calls.fetchInits[0].method, "HEAD", "首探用 HEAD(无 body,成本最低)");
|
|
345
|
+
assert.equal(e.calls.fetchInits[0].headers.accept, "application/json",
|
|
346
|
+
"accept 不能是 html —— 否则设备离线时会被 router 判成页面导航走 302 引导页,探针看不到 502");
|
|
347
|
+
assert.equal(e.loadWatch.hiddenCalled, 0, "链路正常时不得弹提示条");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("回前台:探针失败 → 唤醒官方客户端再探一次;仍失败只给可见提示(不静默无限重连)", async () => {
|
|
351
|
+
let allow = false;
|
|
352
|
+
const e = loadEngine({
|
|
353
|
+
pathname: "/remote/dev-abc123/",
|
|
354
|
+
fetch: () => (allow ? Promise.resolve({ status: 200 }) : Promise.reject(new Error("offline")))
|
|
355
|
+
});
|
|
356
|
+
e.win.__dshE2eeBadge = { state: () => OK };
|
|
357
|
+
e.doc.visibilityState = "hidden";
|
|
358
|
+
e.fire("document", "visibilitychange");
|
|
359
|
+
e.setNow(e.now() + 30_000);
|
|
360
|
+
e.doc.visibilityState = "visible";
|
|
361
|
+
e.fire("document", "visibilitychange");
|
|
362
|
+
await drain(e);
|
|
363
|
+
assert.equal(e.calls.online, 1, "探针失败时派发一次 online 让官方客户端自己重连");
|
|
364
|
+
assert.equal(e.calls.fetchUrls.length, 2, "只再探一次,不是无限重试");
|
|
365
|
+
assert.ok(e.calls.fetchUrls[0].indexOf("/remote/dev-abc123/?dshProbe=") === 0);
|
|
366
|
+
assert.ok(e.calls.fetchUrls[1].indexOf("/remote/dev-abc123/favicon.ico?dshProbe=") === 0,
|
|
367
|
+
"第二次退化为 GET favicon.ico(同样是过桥路径)");
|
|
368
|
+
assert.equal(e.calls.fetchInits[1].method, "GET");
|
|
369
|
+
assert.ok(e.loadWatch.barTxt && /连接没有恢复/.test(e.loadWatch.barTxt.textContent),
|
|
370
|
+
"仍失败必须给可见提示(复用底部提示条的重试/返回出口)");
|
|
371
|
+
assert.equal(e.loadWatch.barEl.hidden, false, "提示条要真的显示出来");
|
|
372
|
+
// 再切一次前台:冷却期内不再动作
|
|
373
|
+
e.doc.visibilityState = "hidden";
|
|
374
|
+
e.fire("document", "visibilitychange");
|
|
375
|
+
e.setNow(e.now() + 30_000);
|
|
376
|
+
e.doc.visibilityState = "visible";
|
|
377
|
+
e.fire("document", "visibilitychange");
|
|
378
|
+
await drain(e);
|
|
379
|
+
assert.equal(e.calls.fetchUrls.length, 2, "冷却期内不得再探(不打扰 / 不耗流量)");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("回前台:页面上是死会话 → 直接走 resume(不先探针)", async () => {
|
|
383
|
+
const e = loadEngine({ pathname: "/remote/dev-abc123/" });
|
|
384
|
+
e.win.__dshE2eeBadge = { state: () => DEAD };
|
|
385
|
+
e.doc.visibilityState = "hidden";
|
|
386
|
+
e.fire("document", "visibilitychange");
|
|
387
|
+
e.setNow(e.now() + 30_000);
|
|
388
|
+
e.doc.visibilityState = "visible";
|
|
389
|
+
e.fire("document", "visibilitychange");
|
|
390
|
+
await drain(e);
|
|
391
|
+
assert.deepEqual(e.calls.replace, ["/app/?resume=dev-abc123&from=mirror"]);
|
|
392
|
+
assert.deepEqual(e.calls.fetchUrls, [], "确认不可用时不做多余探针");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("回前台闸门:本页最多 3 次,额度用完就交还用户", async () => {
|
|
396
|
+
const e = loadEngine({ pathname: "/remote/dev-abc123/" });
|
|
397
|
+
e.win.__dshE2eeBadge = { state: () => OK };
|
|
398
|
+
for (let i = 0; i < 5; i += 1) {
|
|
399
|
+
e.doc.visibilityState = "hidden";
|
|
400
|
+
e.fire("document", "visibilitychange");
|
|
401
|
+
e.setNow(e.now() + 70_000); // 跨过 60 秒冷却,确保限的是"本页次数"而不是冷却期
|
|
402
|
+
e.doc.visibilityState = "visible";
|
|
403
|
+
e.fire("document", "visibilitychange");
|
|
404
|
+
await drain(e);
|
|
405
|
+
}
|
|
406
|
+
assert.equal(e.calls.fetchUrls.length, 3, "本页最多 3 次自动恢复,绝不无限重连");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("探针判定口径:502/504 = 隧道不可达;200/401/403/404 都算\"有回应\"", async () => {
|
|
410
|
+
const run = async (status) => {
|
|
411
|
+
const e = loadEngine({ pathname: "/remote/dev-abc123/", fetch: () => Promise.resolve({ status }) });
|
|
412
|
+
e.win.__dshE2eeBadge = { state: () => OK };
|
|
413
|
+
e.doc.visibilityState = "hidden";
|
|
414
|
+
e.fire("document", "visibilitychange");
|
|
415
|
+
e.setNow(e.now() + 30_000);
|
|
416
|
+
e.doc.visibilityState = "visible";
|
|
417
|
+
e.fire("document", "visibilitychange");
|
|
418
|
+
await drain(e);
|
|
419
|
+
return e;
|
|
420
|
+
};
|
|
421
|
+
for (const status of [200, 204, 401, 403, 404]) {
|
|
422
|
+
const e = await run(status);
|
|
423
|
+
assert.equal(e.calls.fetchUrls.length, 1, `status=${status} 应视为隧道可用(不重试)`);
|
|
424
|
+
assert.equal(e.calls.online, 0, `status=${status} 不该唤醒重连`);
|
|
425
|
+
}
|
|
426
|
+
for (const status of [502, 504]) {
|
|
427
|
+
const e = await run(status);
|
|
428
|
+
assert.equal(e.calls.fetchUrls.length, 2, `status=${status} 是隧道不可达 → 唤醒 + 再探一次`);
|
|
429
|
+
assert.equal(e.calls.online, 1);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("探针 URL 的键与 native.html / e2ee-shim 的字面量逐字一致(跨仓契约)", () => {
|
|
434
|
+
// 只读两个闸门的**键**;值(时间戳/MK)永远不进 URL 或日志。
|
|
435
|
+
assert.ok(ENGINE_SRC.includes('"dsh-e2ee-mk-v1"'), "MA_MK_KEY 必须与 native.html 的 E2EE_MK_STORE_KEY 一致");
|
|
436
|
+
assert.ok(ENGINE_SRC.includes('"dsh-e2ee-dead-reload"'), "MA_DEAD_RELOAD_KEY 必须与 e2ee-shim 的 SE_RELOAD_GUARD_KEY 一致");
|
|
437
|
+
// 与同仓 shim 源码比对(跨仓那份 native.html 由 relay-mobile 的用例锁)
|
|
438
|
+
const shim = readFileSync(new URL("../e2ee-shim-script.js", import.meta.url), "utf8");
|
|
439
|
+
assert.ok(shim.includes('SE_RELOAD_GUARD_KEY = "dsh-e2ee-dead-reload"'),
|
|
440
|
+
"shim 的重载闸门键名变了 → 本层的 dsh-e2ee-dead-reload 必须跟着改");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("恢复层阈值是常量且与 native.html 侧闸门同量级", () => {
|
|
444
|
+
const e = loadEngine({ pathname: "/remote/dev-abc123/" });
|
|
445
|
+
assert.equal(e.MA_RESUME_DELAY_MS, 400);
|
|
446
|
+
assert.equal(e.MA_RESUME_WINDOW_MS, 60 * 1000);
|
|
447
|
+
assert.equal(e.MA_RESUME_MAX_IN_WINDOW, 2);
|
|
448
|
+
assert.equal(e.MA_RESUME_COOLDOWN_MS, 60 * 1000);
|
|
449
|
+
assert.equal(e.MA_FG_MIN_BG_MS, 8000);
|
|
450
|
+
assert.equal(e.MA_FG_MAX_PER_PAGE, 3);
|
|
451
|
+
assert.equal(e.MA_PROBE_TIMEOUT_MS, 4000);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("注入体纪律:恢复层不含反引号 / 不出现 </script> / 只读 E2EE 状态", () => {
|
|
455
|
+
assert.ok(ENGINE_SRC.indexOf("`") === -1, "注入体里的反引号会提前闭合模板字符串");
|
|
456
|
+
assert.ok(ENGINE_SRC.indexOf("</script") === -1);
|
|
457
|
+
assert.ok(ENGINE_SRC.indexOf("__dshE2eeBadge") > -1, "状态必须来自 e2ee-shim 的只读出口");
|
|
458
|
+
assert.ok(!/crypto\.subtle|deriveKey|decrypt\(/i.test(ENGINE_SRC), "恢复层绝不做加解密(那属于 shim)");
|
|
459
|
+
assert.ok(!/new WebSocket/i.test(ENGINE_SRC), "恢复层绝不自己重连 WS");
|
|
460
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 上传失败「可读提示」(2026-10-23,文件上传反馈 fb_930b950aed94)。
|
|
4
|
+
*
|
|
5
|
+
* 现场:手机上传失败只看到「上传失败,点击重试」。dsh 上游客户端在 status !== 200 时
|
|
6
|
+
* 直接抛错并**丢弃响应体**(dsh-client-file-upload/lib/client.js),而中继/桥端本轮
|
|
7
|
+
* 已能给出可读原因(>64MB 的 413 JSON、上游不可达的分类 502 JSON)—— 这些文字到不了用户。
|
|
8
|
+
*
|
|
9
|
+
* 本用例跑**真实注入脚本里的上传提示层**(纯函数 + 引擎在 vm 里执行,不是文本 grep):
|
|
10
|
+
* ① 状态码 → 人话的映射(413/502/401/带 JSON body 的其它 5xx/成功不提示);
|
|
11
|
+
* ② 只接管 name=dsh-file-upload 的 Worker,其它 Worker 原样返回;
|
|
12
|
+
* ③ 完成消息 status!==200 时弹出底部提示,status===200 时不弹;
|
|
13
|
+
* ④ dsh 原有 onmessage 仍被**原样调用**(本层只旁听,绝不改变上传语义);
|
|
14
|
+
* ⑤ terminate() 等成员仍绑定原生对象;重复安装幂等;boot 与看门狗都接线。
|
|
15
|
+
*/
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import vm from "node:vm";
|
|
19
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
20
|
+
|
|
21
|
+
function adapterScript() {
|
|
22
|
+
const { html } = injectMobileAdapter("<!doctype html><html><head></head><body></body></html>");
|
|
23
|
+
const m = /<script id="[^"]*-js"[^>]*>([\s\S]*?)<\/script>/.exec(html);
|
|
24
|
+
assert.ok(m, "应能取出注入脚本");
|
|
25
|
+
return m[1];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sliceBetween(src, from, to) {
|
|
29
|
+
const a = src.indexOf(from);
|
|
30
|
+
const b = src.indexOf(to, a);
|
|
31
|
+
assert.ok(a !== -1, `找不到起始锚点: ${from}`);
|
|
32
|
+
assert.ok(b !== -1 && b > a, `找不到结束锚点: ${to}`);
|
|
33
|
+
return src.slice(a, b);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SRC = adapterScript();
|
|
37
|
+
const PURE_SRC = sliceBetween(SRC, "/* ===DSH-MA-UPLOAD-HINT-PURE-START=== */", "/* ===DSH-MA-UPLOAD-HINT-PURE-END=== */");
|
|
38
|
+
const ENGINE_SRC = sliceBetween(SRC, "/* ===DSH-MA-UPLOAD-HINT-ENGINE-START=== */", "/* ===DSH-MA-UPLOAD-HINT-ENGINE-END=== */");
|
|
39
|
+
|
|
40
|
+
/** 造一个假 DOM + 假 sessionStorage + 假 Worker,把纯函数与引擎一起跑起来。 */
|
|
41
|
+
function makeSandbox() {
|
|
42
|
+
const toasts = [];
|
|
43
|
+
const workers = [];
|
|
44
|
+
class FakeWorker {
|
|
45
|
+
constructor(url, opts) {
|
|
46
|
+
this.url = url;
|
|
47
|
+
this.opts = opts || {};
|
|
48
|
+
this.terminated = false;
|
|
49
|
+
this.onmessage = null;
|
|
50
|
+
this.onerror = null;
|
|
51
|
+
workers.push(this);
|
|
52
|
+
}
|
|
53
|
+
terminate() { this.terminated = true; }
|
|
54
|
+
postMessage() { /* noop */ }
|
|
55
|
+
}
|
|
56
|
+
const makeEl = () => ({
|
|
57
|
+
id: "", textContent: "", style: { cssText: "", opacity: "" },
|
|
58
|
+
parentNode: null,
|
|
59
|
+
setAttribute(k, v) { if (k === "id") this.id = String(v); },
|
|
60
|
+
appendChild(child) { toasts.push(child); child.parentNode = this; },
|
|
61
|
+
removeChild(child) { child.parentNode = null; }
|
|
62
|
+
});
|
|
63
|
+
const bodyEl = makeEl();
|
|
64
|
+
const doc = {
|
|
65
|
+
body: bodyEl,
|
|
66
|
+
documentElement: bodyEl,
|
|
67
|
+
getElementById: (id) => toasts.find((t) => t.id === id) || null,
|
|
68
|
+
createElement: () => makeEl()
|
|
69
|
+
};
|
|
70
|
+
const store = new Map();
|
|
71
|
+
const sandbox = {
|
|
72
|
+
window: { Worker: FakeWorker },
|
|
73
|
+
document: doc,
|
|
74
|
+
sessionStorage: {
|
|
75
|
+
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
|
76
|
+
setItem: (k, v) => store.set(k, String(v)),
|
|
77
|
+
removeItem: (k) => store.delete(k)
|
|
78
|
+
},
|
|
79
|
+
setTimeout: () => 0, // 不让提示自动消失,便于断言
|
|
80
|
+
clearTimeout: () => {},
|
|
81
|
+
console,
|
|
82
|
+
Date, JSON, String, Number, RegExp, Proxy, Reflect, Error, Math
|
|
83
|
+
};
|
|
84
|
+
const ctx = vm.createContext(sandbox);
|
|
85
|
+
vm.runInContext(
|
|
86
|
+
PURE_SRC + "\n" + ENGINE_SRC
|
|
87
|
+
+ "\n; ({ maUploadFailureHint, maUploadToast, maTapUploadWorker, maInstallUploadTap });",
|
|
88
|
+
ctx,
|
|
89
|
+
{ filename: "mobile-adapter#upload-hint" }
|
|
90
|
+
);
|
|
91
|
+
const api = vm.runInContext("({ maUploadFailureHint, maInstallUploadTap })", ctx);
|
|
92
|
+
return { api, sandbox, ctx, workers, toasts, store };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("状态码/响应体 → 人话(413/502/401/其它 JSON/成功)", () => {
|
|
96
|
+
const { api } = makeSandbox();
|
|
97
|
+
const h = api.maUploadFailureHint;
|
|
98
|
+
assert.match(h(413, ""), /64MB/, "413 必须说清 64MB 上限");
|
|
99
|
+
assert.match(h(413, ""), /电脑端|压缩/, "413 要给出可执行的下一步");
|
|
100
|
+
assert.match(h(502, ""), /连接中断/, "502 要说明是连接中断");
|
|
101
|
+
assert.match(h(504, ""), /连接中断/, "504 同样按连接中断");
|
|
102
|
+
assert.match(h(401, ""), /登录状态已过期/, "401 指向重新登录");
|
|
103
|
+
assert.match(h(403, ""), /登录状态已过期/, "403 同 401");
|
|
104
|
+
assert.equal(h(200, ""), "", "成功不提示");
|
|
105
|
+
assert.equal(h(201, ""), "", "201 也不提示");
|
|
106
|
+
assert.equal(h(0, ""), "", "没有状态码不提示");
|
|
107
|
+
const custom = h(500, JSON.stringify({ error: { code: "upstream_timeout", message: "本机 dsh web 在 120 秒内没有响应" } }));
|
|
108
|
+
assert.match(custom, /本机 dsh web 在 120 秒内没有响应/, "其它 5xx 要透出服务端 message");
|
|
109
|
+
assert.match(h(500, "not-json"), /HTTP 500/, "非 JSON body 也要给状态码");
|
|
110
|
+
assert.match(h(413, "file exceeds byte limit"), /64MB/, "上游 dsh 的字节上限文案也要归类为「太大」");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("只接管 name=dsh-file-upload 的 Worker;其它 Worker 原样返回", () => {
|
|
114
|
+
const { api, sandbox, workers } = makeSandbox();
|
|
115
|
+
assert.equal(api.maInstallUploadTap(), true, "装着成功");
|
|
116
|
+
const Native = sandbox.window.Worker;
|
|
117
|
+
const other = new sandbox.window.Worker("blob:x", { name: "some-other-worker" });
|
|
118
|
+
const plain = new sandbox.window.Worker("blob:y");
|
|
119
|
+
assert.equal(workers.length, 2, "两个 Worker 都真的构造了");
|
|
120
|
+
assert.ok(other instanceof Native, "非上传 Worker 仍是原生实例");
|
|
121
|
+
assert.ok(plain instanceof Native, "无名 Worker 仍是原生实例");
|
|
122
|
+
const up = new sandbox.window.Worker("blob:z", { name: "dsh-file-upload" });
|
|
123
|
+
assert.equal(up.url, "blob:z", "url 透传");
|
|
124
|
+
assert.equal(typeof up.postMessage, "function", "postMessage 透传");
|
|
125
|
+
up.terminate();
|
|
126
|
+
assert.equal(workers[2].terminated, true, "terminate 绑定到原生对象后真的生效");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("完成消息 status!==200 → 弹可读提示;status===200 / progress → 不弹", () => {
|
|
130
|
+
const { sandbox, ctx, workers, toasts } = makeSandbox();
|
|
131
|
+
vm.runInContext("maInstallUploadTap();", ctx);
|
|
132
|
+
const up = new sandbox.window.Worker("blob:upload", { name: "dsh-file-upload" });
|
|
133
|
+
const raw = workers[workers.length - 1];
|
|
134
|
+
const seen = [];
|
|
135
|
+
up.onmessage = (ev) => seen.push(ev.data);
|
|
136
|
+
raw.onmessage({ data: { kind: "complete", status: 200, body: "{\"ok\":true}" } });
|
|
137
|
+
assert.equal(seen.length, 1, "dsh 的 onmessage 必须照常收到消息");
|
|
138
|
+
assert.equal(toasts.length, 0, "200 不弹提示");
|
|
139
|
+
raw.onmessage({ data: { kind: "complete", status: 413, body: "{\"error\":{\"code\":\"payload_too_large\"}}" } });
|
|
140
|
+
assert.equal(seen.length, 2, "dsh 的 onmessage 仍照常收到消息");
|
|
141
|
+
assert.equal(toasts.length, 1, "413 应弹一条提示");
|
|
142
|
+
assert.equal(toasts[0].id, "dsh-ma-upload-toast");
|
|
143
|
+
assert.match(String(toasts[0].textContent), /64MB/, "提示文案要说清 64MB 上限");
|
|
144
|
+
raw.onmessage({ data: { kind: "progress", loaded: 10 } });
|
|
145
|
+
assert.equal(toasts.length, 1, "progress 不弹提示");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("重复安装幂等;boot 与看门狗都接线(源码契约)", () => {
|
|
149
|
+
const { api, sandbox } = makeSandbox();
|
|
150
|
+
assert.equal(api.maInstallUploadTap(), true);
|
|
151
|
+
const first = sandbox.window.Worker;
|
|
152
|
+
assert.equal(api.maInstallUploadTap(), true, "第二次安装仍返回 true");
|
|
153
|
+
assert.equal(sandbox.window.Worker, first, "不得二次包装(否则消息会被旁听两遍)");
|
|
154
|
+
const calls = SRC.match(/maInstallUploadTap\(\)/g) || [];
|
|
155
|
+
assert.ok(calls.length >= 2, `boot 与看门狗都要接线(实际 ${calls.length} 处)`);
|
|
156
|
+
assert.match(SRC, /name !== "dsh-file-upload"/, "只认 dsh 的上传 Worker 名字");
|
|
157
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4-beta.1",
|
|
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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4-beta.1",
|
|
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",
|