@mrrisega/dsh-remote 0.7.4 → 0.7.5
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 +540 -49
- package/clients/dsh-remote/e2ee-client.mjs +251 -0
- package/clients/dsh-remote/mobile-adapter.mjs +261 -0
- package/clients/dsh-remote/test/bridge-control-priority.test.mjs +206 -0
- package/clients/dsh-remote/test/bridge-heartbeat-adaptive.test.mjs +110 -0
- package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +26 -8
- package/clients/dsh-remote/test/bridge-ws-open-fast-ack.test.mjs +213 -0
- package/clients/dsh-remote/test/e2ee-session-store.test.mjs +254 -0
- package/clients/dsh-remote/test/mobile-adapter-single-page.test.mjs +328 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +119 -0
- package/packages/dsh-remote-web/lib/index.js +145 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +540 -49
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +251 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +261 -0
- package/packages/dsh-remote-web/test/community-invite-proxy.test.mjs +343 -0
- package/packages/dsh-remote-web/test/community-invite-ui.test.mjs +375 -0
- package/packages/dsh-remote-web/test/purchase-agreement-proxy.test.mjs +198 -0
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +336 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* E2EE 会话库(加密落盘 / 重启恢复 / 失效路径)回归(2026-10-23)。
|
|
4
|
+
*
|
|
5
|
+
* 生产事故(手机反复撞死票,主因链 #2):
|
|
6
|
+
* e2ee-client.mjs 的 sessions 只是内存 Map(24h 滑动 TTL)。bridge 重启/崩一次,
|
|
7
|
+
* 手机端保存的 sessId 就全成死票:HTTP 502 unknown_session、数据 WS 1008
|
|
8
|
+
* e2ee_session_locked,用户必须回设备列表重新解锁(实测某设备 3964 次)。
|
|
9
|
+
*
|
|
10
|
+
* 方案(本文件锁住):
|
|
11
|
+
* ① 会话材料用 MK 派生的 storeKey 做 AES-256-GCM 加密后落 relay 数据目录
|
|
12
|
+
* `.e2ee-sessions.enc`(0600、原子写);文件里**没有** MK/SHK 明文/密码;
|
|
13
|
+
* ② bridge 重启后恢复会话(含 verified 与 ctrl 防重放计数),手机旧票继续可用;
|
|
14
|
+
* ③ 解不开(改密/MK 变)/文件损坏/参数变化 → 丢弃文件并清空内存,返回 discarded 原因
|
|
15
|
+
* (上层据此给手机 retryable=rehandshake 信号,而不是让手机反复撞);
|
|
16
|
+
* ④ 24h 滑动 TTL 仍生效:过期会话不恢复。
|
|
17
|
+
*/
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
E2eeService,
|
|
26
|
+
E2eeSession,
|
|
27
|
+
SESSION_STORE_FILE,
|
|
28
|
+
SESSION_STORE_VERSION,
|
|
29
|
+
SESSION_TTL_MS,
|
|
30
|
+
clearSessionStore,
|
|
31
|
+
decryptSessionStore,
|
|
32
|
+
deriveSessionStoreKey,
|
|
33
|
+
deriveShk,
|
|
34
|
+
loadSessionStore,
|
|
35
|
+
newSessId,
|
|
36
|
+
randomB64,
|
|
37
|
+
saveSessionStore,
|
|
38
|
+
sessionStorePath
|
|
39
|
+
} from "../e2ee-client.mjs";
|
|
40
|
+
|
|
41
|
+
const DIRS = [];
|
|
42
|
+
function tmpDir() {
|
|
43
|
+
const d = mkdtempSync(path.join(os.tmpdir(), "dsh-e2ee-store-"));
|
|
44
|
+
DIRS.push(d);
|
|
45
|
+
return d;
|
|
46
|
+
}
|
|
47
|
+
test.after(() => {
|
|
48
|
+
for (const d of DIRS) { try { rmSync(d, { recursive: true, force: true }); } catch { /* 忽略 */ } }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const MK = Buffer.alloc(32, 9);
|
|
52
|
+
const SALT = "salt-store-test";
|
|
53
|
+
const PROFILE = "pbkdf2-sha256-600k";
|
|
54
|
+
const mkSvc = () => new E2eeService({ enabled: true, mk: MK, profile: PROFILE, salt: SALT, epoch: 1 });
|
|
55
|
+
|
|
56
|
+
/** 建一个"已解锁且探测通过"的会话(手机侧也留一份,便于断言加解密仍可用)。 */
|
|
57
|
+
function unlock(svc) {
|
|
58
|
+
const sessId = newSessId();
|
|
59
|
+
const a = randomB64(32);
|
|
60
|
+
const { session, ack } = svc.handleHello({ v: 2, type: "e2ee-hello", role: "phone", s: sessId, a: a, salt: SALT, profile: PROFILE });
|
|
61
|
+
const { shk, saltH } = deriveShk(MK, a, ack.b);
|
|
62
|
+
const client = new E2eeSession({ sessId, shk, saltH, profile: PROFILE, epoch: 1 });
|
|
63
|
+
const probe = client.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: JSON.stringify({ p: "dsh-e2ee-probe-v1", t: 1, c: 0 }) });
|
|
64
|
+
svc.handleProbe(sessId, probe);
|
|
65
|
+
assert.equal(session.verified, true);
|
|
66
|
+
return { sessId, client, session };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("① 加密落盘:文件里没有 MK/SHK/salt 明文,权限 0600,格式可自描述", () => {
|
|
70
|
+
const dir = tmpDir();
|
|
71
|
+
const svc = mkSvc();
|
|
72
|
+
const { sessId } = unlock(svc);
|
|
73
|
+
svc.attachStore(dir, { flushMs: 0 }); // unlock 时已即时落盘;attach 再恢复一次(空目录)
|
|
74
|
+
const r = svc.flushStore();
|
|
75
|
+
assert.equal(r.ok, true, "落盘应成功");
|
|
76
|
+
const file = sessionStorePath(dir);
|
|
77
|
+
assert.equal(path.basename(file), SESSION_STORE_FILE);
|
|
78
|
+
assert.ok(existsSync(file), "会话库文件应存在");
|
|
79
|
+
const raw = readFileSync(file, "utf8");
|
|
80
|
+
const shkB64 = svc.sessions.get(sessId).shk.toString("base64url");
|
|
81
|
+
assert.ok(!raw.includes(shkB64), "★ 文件里不得出现 SHK 明文");
|
|
82
|
+
assert.ok(!raw.includes(MK.toString("base64url")), "★ 文件里不得出现 MK 明文");
|
|
83
|
+
assert.ok(!raw.includes(SALT), "文件里不该有 salt 明文");
|
|
84
|
+
const blob = JSON.parse(raw);
|
|
85
|
+
assert.equal(blob.v, SESSION_STORE_VERSION);
|
|
86
|
+
assert.equal(blob.alg, "aes-256-gcm");
|
|
87
|
+
assert.ok(blob.iv && blob.tag && blob.ct, "iv/tag/ct 三件套齐全");
|
|
88
|
+
if (process.platform !== "win32") {
|
|
89
|
+
assert.equal(statSync(file).mode & 0o777, 0o600, "文件权限必须是 0600");
|
|
90
|
+
}
|
|
91
|
+
// 解密得回 payload(密钥只由 MK 派生)
|
|
92
|
+
const payload = decryptSessionStore(MK, blob);
|
|
93
|
+
assert.equal(payload.salt, SALT);
|
|
94
|
+
assert.equal(payload.sessions.length, 1);
|
|
95
|
+
assert.equal(payload.sessions[0].s, sessId);
|
|
96
|
+
// 用错误 MK 解密必须失败(信任边界:拿到文件也解不开)
|
|
97
|
+
assert.throws(() => decryptSessionStore(Buffer.alloc(32, 1), blob), /store_auth_failed|无法解密/);
|
|
98
|
+
svc.disposeStore();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("② 重启恢复:新 E2eeService 只凭 MK + 目录恢复 verified 会话 + ctrl 计数,旧票可直接用", () => {
|
|
102
|
+
const dir = tmpDir();
|
|
103
|
+
const svc1 = mkSvc();
|
|
104
|
+
const { sessId, client } = unlock(svc1);
|
|
105
|
+
svc1.attachStore(dir, { flushMs: 0 });
|
|
106
|
+
svc1.ctrlCounters.get(sessId).p2b.last = 3; // 模拟解锁后又跑过探测
|
|
107
|
+
svc1.flushStore();
|
|
108
|
+
svc1.disposeStore();
|
|
109
|
+
|
|
110
|
+
// "重启":全新服务实例,只凭 MK + 同一目录恢复
|
|
111
|
+
const svc2 = mkSvc();
|
|
112
|
+
const restored = svc2.attachStore(dir, { flushMs: 0 });
|
|
113
|
+
assert.equal(restored.restored, 1);
|
|
114
|
+
assert.equal(restored.discarded, "", "没有作废");
|
|
115
|
+
const session = svc2.sessionOf(sessId);
|
|
116
|
+
assert.ok(session, "★ 旧 sessId 必须仍可用(这正是持久化的目的)");
|
|
117
|
+
assert.equal(session.verified, true, "verified 也要恢复(否则数据 WS 仍被判 locked)");
|
|
118
|
+
assert.equal(svc2.ctrlCounters.get(sessId).p2b.last, 3, "ctrl 防重放计数必须恢复(重启不能重置)");
|
|
119
|
+
// 恢复的会话密钥仍能正确解密手机端的信封(HTTP 请求)
|
|
120
|
+
const plain = Buffer.from(JSON.stringify({ m: "GET", p: "/x", h: {}, b: "" }), "utf8");
|
|
121
|
+
const env = client.seal({ kind: "http", dir: "p2b", counter: 1, data: plain });
|
|
122
|
+
assert.doesNotThrow(() => svc2.guardHttpRequest(sessId, env), "恢复后的会话必须能过 guardHttpRequest");
|
|
123
|
+
const opened = session.open({ kind: "http", dir: "p2b", env, counter: env.c });
|
|
124
|
+
assert.equal(JSON.parse(opened.data.toString("utf8")).p, "/x");
|
|
125
|
+
svc2.disposeStore();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("③ 失效路径:MK 变了(改密)→ 丢弃文件 + 清空内存 + 返回 key_mismatch", () => {
|
|
129
|
+
const dir = tmpDir();
|
|
130
|
+
const svc1 = mkSvc();
|
|
131
|
+
unlock(svc1);
|
|
132
|
+
svc1.attachStore(dir, { flushMs: 0 });
|
|
133
|
+
svc1.flushStore();
|
|
134
|
+
svc1.disposeStore();
|
|
135
|
+
|
|
136
|
+
const svc2 = new E2eeService({ enabled: true, mk: Buffer.alloc(32, 1), profile: PROFILE, salt: SALT, epoch: 1 });
|
|
137
|
+
const r = svc2.attachStore(dir, { flushMs: 0 });
|
|
138
|
+
assert.equal(r.restored, 0);
|
|
139
|
+
assert.equal(r.discarded, "key_mismatch", "必须报告失效原因(上层据此给手机 rehandshake 信号)");
|
|
140
|
+
assert.equal(svc2.sessions.size, 0, "不得把解不开的旧会话当活会话");
|
|
141
|
+
assert.equal(existsSync(sessionStorePath(dir)), false, "坏文件必须删掉,别让下次继续撞");
|
|
142
|
+
svc2.disposeStore();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("④ 失效路径:salt 变化 → params_changed 丢弃;磁盘损坏 → corrupt/key_mismatch 丢弃", () => {
|
|
146
|
+
const dir = tmpDir();
|
|
147
|
+
const svc1 = mkSvc();
|
|
148
|
+
unlock(svc1);
|
|
149
|
+
svc1.attachStore(dir, { flushMs: 0 });
|
|
150
|
+
svc1.flushStore();
|
|
151
|
+
svc1.disposeStore();
|
|
152
|
+
|
|
153
|
+
// salt 变了(服务端参数更换)→ 即便 MK 碰巧能解开也不能复用(MK 域不同)
|
|
154
|
+
const svc2 = new E2eeService({ enabled: true, mk: MK, profile: PROFILE, salt: "salt-CHANGED", epoch: 1 });
|
|
155
|
+
const r2 = svc2.attachStore(dir, { flushMs: 0 });
|
|
156
|
+
assert.equal(r2.discarded, "params_changed");
|
|
157
|
+
assert.equal(svc2.sessions.size, 0);
|
|
158
|
+
assert.equal(existsSync(sessionStorePath(dir)), false);
|
|
159
|
+
svc2.disposeStore();
|
|
160
|
+
|
|
161
|
+
// 损坏文件(半截写 / 手工改):认证失败 → 丢弃
|
|
162
|
+
const dir2 = tmpDir();
|
|
163
|
+
saveSessionStore(dir2, MK, { v: SESSION_STORE_VERSION, salt: SALT, profile: PROFILE, epoch: 1, sessions: [] });
|
|
164
|
+
const file = sessionStorePath(dir2);
|
|
165
|
+
const blob = JSON.parse(readFileSync(file, "utf8"));
|
|
166
|
+
blob.ct = `${blob.ct.slice(0, 8)}${blob.ct.slice(8).replace(/[A-Za-z]/g, "Z")}`;
|
|
167
|
+
writeFileSync(file, JSON.stringify(blob));
|
|
168
|
+
const svc3 = mkSvc();
|
|
169
|
+
const r3 = svc3.attachStore(dir2, { flushMs: 0 });
|
|
170
|
+
assert.equal(r3.restored, 0);
|
|
171
|
+
assert.ok(["corrupt", "key_mismatch"].includes(r3.discarded), `损坏文件必须被丢弃(实际 ${r3.discarded})`);
|
|
172
|
+
assert.equal(svc3.sessions.size, 0);
|
|
173
|
+
svc3.disposeStore();
|
|
174
|
+
|
|
175
|
+
// 完全不是 JSON
|
|
176
|
+
const dir3 = tmpDir();
|
|
177
|
+
writeFileSync(sessionStorePath(dir3), "not-json");
|
|
178
|
+
const svc4 = mkSvc();
|
|
179
|
+
assert.equal(svc4.attachStore(dir3, { flushMs: 0 }).discarded, "corrupt");
|
|
180
|
+
svc4.disposeStore();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("⑤ 24h 滑动 TTL 仍生效:过期会话不恢复,未过期的保留", () => {
|
|
184
|
+
const dir = tmpDir();
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const row = (sessId, usedAt) => ({
|
|
187
|
+
s: sessId,
|
|
188
|
+
shk: Buffer.alloc(32, 5).toString("base64url"),
|
|
189
|
+
saltH: Buffer.alloc(32, 6).toString("base64url"),
|
|
190
|
+
profile: PROFILE,
|
|
191
|
+
epoch: 1,
|
|
192
|
+
verified: true,
|
|
193
|
+
createdAt: usedAt - 1000,
|
|
194
|
+
usedAt,
|
|
195
|
+
p2b: -1,
|
|
196
|
+
b2p: -1
|
|
197
|
+
});
|
|
198
|
+
const fresh = newSessId();
|
|
199
|
+
const stale = newSessId();
|
|
200
|
+
const nearBoundary = newSessId();
|
|
201
|
+
saveSessionStore(dir, MK, {
|
|
202
|
+
v: SESSION_STORE_VERSION,
|
|
203
|
+
salt: SALT,
|
|
204
|
+
profile: PROFILE,
|
|
205
|
+
epoch: 1,
|
|
206
|
+
at: now,
|
|
207
|
+
sessions: [row(fresh, now - 60_000), row(stale, now - SESSION_TTL_MS - 5_000), row(nearBoundary, now - SESSION_TTL_MS + 60_000)]
|
|
208
|
+
});
|
|
209
|
+
const svc = mkSvc();
|
|
210
|
+
const r = svc.attachStore(dir, { flushMs: 0 });
|
|
211
|
+
assert.equal(r.restored, 2, "过期的那条必须被丢掉");
|
|
212
|
+
assert.ok(svc.sessionOf(fresh));
|
|
213
|
+
assert.ok(svc.sessionOf(nearBoundary), "TTL 内(哪怕接近边界)都要保留");
|
|
214
|
+
assert.equal(svc.sessionOf(stale), null);
|
|
215
|
+
svc.disposeStore();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("⑥ 未启用 / 未给目录 / 目录不可写 → 静默降级(绝不因为会话库把隧道拖挂)", () => {
|
|
219
|
+
const dir = tmpDir();
|
|
220
|
+
const disabled = new E2eeService({ enabled: false, reason: "no_password" });
|
|
221
|
+
assert.equal(disabled.attachStore(dir).skipped, "e2ee_disabled");
|
|
222
|
+
assert.equal(existsSync(sessionStorePath(dir)), false, "未启用时不得写文件");
|
|
223
|
+
|
|
224
|
+
const svc = mkSvc();
|
|
225
|
+
assert.equal(svc.attachStore("").skipped, "no_dir");
|
|
226
|
+
assert.deepEqual(svc.flushStore(), { ok: false, skipped: true });
|
|
227
|
+
|
|
228
|
+
// 目录不可写(拿一个普通文件当目录)→ flush 返回 ok:false,但绝不抛
|
|
229
|
+
const fileAsDir = path.join(tmpDir(), "not-a-dir");
|
|
230
|
+
writeFileSync(fileAsDir, "x");
|
|
231
|
+
svc.attachStore(fileAsDir, { flushMs: 0 });
|
|
232
|
+
const r = svc.flushStore();
|
|
233
|
+
assert.equal(r.ok, false, "写失败只返回失败,不抛");
|
|
234
|
+
svc.disposeStore();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("⑦ 原语契约:save→load→clear 往返;版本/AAD 域隔离;storeKey 确定性", () => {
|
|
238
|
+
const dir = tmpDir();
|
|
239
|
+
const payload = { v: SESSION_STORE_VERSION, salt: SALT, profile: PROFILE, epoch: 1, sessions: [{ s: newSessId(), usedAt: Date.now() }] };
|
|
240
|
+
assert.equal(saveSessionStore(dir, MK, payload).ok, true);
|
|
241
|
+
const loaded = loadSessionStore(dir, MK);
|
|
242
|
+
assert.equal(loaded.ok, true);
|
|
243
|
+
assert.equal(loaded.payload.sessions.length, 1);
|
|
244
|
+
// 换版本号 → 解不开(旧格式不能冒名顶替)
|
|
245
|
+
const blob = JSON.parse(readFileSync(sessionStorePath(dir), "utf8"));
|
|
246
|
+
assert.throws(() => decryptSessionStore(MK, { ...blob, v: 2 }), /会话库版本|bad_store/);
|
|
247
|
+
// clear 后读回 absent
|
|
248
|
+
assert.equal(clearSessionStore(dir), true);
|
|
249
|
+
assert.equal(loadSessionStore(dir, MK).reason, "absent");
|
|
250
|
+
// storeKey:同一个 MK 确定性一致;MK 非法直接拒绝(不静默)
|
|
251
|
+
assert.deepEqual(deriveSessionStoreKey(MK), deriveSessionStoreKey(MK));
|
|
252
|
+
assert.notDeepEqual(deriveSessionStoreKey(MK), deriveSessionStoreKey(Buffer.alloc(32, 1)));
|
|
253
|
+
assert.throws(() => deriveSessionStoreKey(Buffer.alloc(16)), /MK 非法/);
|
|
254
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 手机端「同一 deviceId 只允许一个镜像页」回归(2026-10-23)。
|
|
4
|
+
*
|
|
5
|
+
* 生产现场:手机端会出现同一设备的两个镜像页并发(重复打开 / 从历史里点回来),
|
|
6
|
+
* 两边互相取消资源加载 → 都一直转圈。
|
|
7
|
+
*
|
|
8
|
+
* 注入体里的守卫(mobile-adapter 的 DSH-MA-SINGLEPAGE 段)契约:
|
|
9
|
+
* ① 第一个页面:同步写租约(localStorage)+ BroadcastChannel claim,正常加载;
|
|
10
|
+
* ② 第二个页面:同步读到"别人的新鲜租约"→ 立刻 blocked(window.stop + 拦 fetch/XHR/WS
|
|
11
|
+
* + 全屏提示),**不写租约、不加载**;
|
|
12
|
+
* ③ 「在本窗口继续」:广播 takeover → 旧页让路并释放租约 → 本页 400ms 后重载接管;
|
|
13
|
+
* ④ 旧页关掉/失联(租约过期)→ blocked 页自动重载接管,不会永久卡在提示层;
|
|
14
|
+
* ⑤ 非镜像页(设备列表/登录页)、老浏览器(无 localStorage/BroadcastChannel)→ 零行为。
|
|
15
|
+
*
|
|
16
|
+
* 本文件跑的是注入脚本里**真实的那一段**(vm + 假 DOM),断言的是行为不是文本。
|
|
17
|
+
*/
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
import vm from "node:vm";
|
|
21
|
+
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
22
|
+
|
|
23
|
+
/** 注入脚本里取出单页守卫那一段(含结尾的调用)。 */
|
|
24
|
+
function guardSource() {
|
|
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
|
+
const js = m[1];
|
|
29
|
+
const start = "/* ===DSH-MA-SINGLEPAGE-START===";
|
|
30
|
+
const end = "/* ===DSH-MA-SINGLEPAGE-END=== */";
|
|
31
|
+
const a = js.indexOf(start);
|
|
32
|
+
const b = js.indexOf(end);
|
|
33
|
+
assert.ok(a > 0 && b > a, "找不到单页守卫的段落标记(脚本结构变了,请同步更新本用例)");
|
|
34
|
+
return js.slice(a, b + end.length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SRC = guardSource();
|
|
38
|
+
|
|
39
|
+
// ── 假环境(同一"浏览器"的多个页面共享 storage 与 BroadcastChannel 总线)──────────
|
|
40
|
+
|
|
41
|
+
const NOW0 = 1_700_000_000_000;
|
|
42
|
+
|
|
43
|
+
function makeGroup() {
|
|
44
|
+
return {
|
|
45
|
+
store: new Map(),
|
|
46
|
+
channels: new Set(),
|
|
47
|
+
messages: [],
|
|
48
|
+
clock: { now: NOW0 }
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeStorage(group) {
|
|
53
|
+
return {
|
|
54
|
+
getItem(k) { return group.store.has(k) ? group.store.get(k) : null; },
|
|
55
|
+
setItem(k, v) { group.store.set(k, String(v)); },
|
|
56
|
+
removeItem(k) { group.store.delete(k); }
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class FakeBC {
|
|
61
|
+
constructor(group, name) {
|
|
62
|
+
this.group = group;
|
|
63
|
+
this.name = name;
|
|
64
|
+
this.onmessage = null;
|
|
65
|
+
group.channels.add(this);
|
|
66
|
+
}
|
|
67
|
+
postMessage(msg) {
|
|
68
|
+
this.group.messages.push(msg);
|
|
69
|
+
// 浏览器里是异步投递;用例里同步投递(同源多页面),断言更直接
|
|
70
|
+
for (const peer of this.group.channels) {
|
|
71
|
+
if (peer === this) continue;
|
|
72
|
+
if (typeof peer.onmessage === "function") peer.onmessage({ data: msg });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
close() { this.group.channels.delete(this); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 极简假 DOM:只支持守卫用到的那点能力(createElement/body.appendChild/getElementById)。 */
|
|
79
|
+
function makeDoc() {
|
|
80
|
+
const appended = [];
|
|
81
|
+
const buttons = new Map();
|
|
82
|
+
const makeEl = (tag) => {
|
|
83
|
+
const el = {
|
|
84
|
+
tagName: String(tag).toUpperCase(),
|
|
85
|
+
id: "",
|
|
86
|
+
attrs: {},
|
|
87
|
+
style: { cssText: "" },
|
|
88
|
+
innerHTML: "",
|
|
89
|
+
children: [],
|
|
90
|
+
_listeners: new Map(),
|
|
91
|
+
setAttribute(n, v) { this.attrs[n] = String(v); },
|
|
92
|
+
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; },
|
|
93
|
+
addEventListener(type, fn) { (this._listeners.get(type) || this._listeners.set(type, []).get(type)).push(fn); },
|
|
94
|
+
appendChild(c) { this.children.push(c); return c; }
|
|
95
|
+
};
|
|
96
|
+
return el;
|
|
97
|
+
};
|
|
98
|
+
const body = makeEl("body");
|
|
99
|
+
const doc = {
|
|
100
|
+
body,
|
|
101
|
+
_appended: appended,
|
|
102
|
+
createElement: makeEl,
|
|
103
|
+
addEventListener() {},
|
|
104
|
+
getElementById(id) {
|
|
105
|
+
const found = appended.find((el) => el.id === id || el.attrs.id === id);
|
|
106
|
+
if (found) return found;
|
|
107
|
+
// 提示层里的「在本窗口继续」按钮:innerHTML 里定义了 id,按需造一个可点击元素
|
|
108
|
+
const overlay = appended.find((el) => el.id === "dsh-ma-singlepage");
|
|
109
|
+
if (overlay && overlay.innerHTML.includes(`id="${id}"`)) {
|
|
110
|
+
if (!buttons.has(id)) {
|
|
111
|
+
const btn = makeEl("button");
|
|
112
|
+
btn.id = id;
|
|
113
|
+
buttons.set(id, btn);
|
|
114
|
+
}
|
|
115
|
+
return buttons.get(id);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
body.appendChild = (el) => { appended.push(el); return el; };
|
|
121
|
+
return doc;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function makeEnv(group, { pathname = "/remote/dev-A/", withBC = true, withStorage = true } = {}) {
|
|
125
|
+
const timers = { intervals: new Map(), timeouts: new Map(), seq: 0 };
|
|
126
|
+
const doc = makeDoc();
|
|
127
|
+
const storage = withStorage ? makeStorage(group) : null;
|
|
128
|
+
const state = { stops: 0, reloads: 0, messages: [] };
|
|
129
|
+
const loc = {
|
|
130
|
+
pathname,
|
|
131
|
+
href: `https://relay.example${pathname}`,
|
|
132
|
+
reload() { state.reloads += 1; }
|
|
133
|
+
};
|
|
134
|
+
const FakeDate = class extends Date {
|
|
135
|
+
static now() { return group.clock.now; }
|
|
136
|
+
};
|
|
137
|
+
const win = {
|
|
138
|
+
localStorage: storage,
|
|
139
|
+
// 真实 window.navigator 有 locks 就用(老 Safari 没有);这里默认不给,测租约路径
|
|
140
|
+
navigator: {},
|
|
141
|
+
location: loc,
|
|
142
|
+
addEventListener(type, fn) { state.messages.push(type); (win._ls || (win._ls = [])).push({ type, fn }); },
|
|
143
|
+
_ls: [],
|
|
144
|
+
stop() { state.stops += 1; },
|
|
145
|
+
fetch: () => Promise.resolve({ ok: true }),
|
|
146
|
+
XMLHttpRequest: class { send() {} abort() {} },
|
|
147
|
+
WebSocket: class { constructor() { this.readyState = 1; } },
|
|
148
|
+
console
|
|
149
|
+
};
|
|
150
|
+
if (withBC) win.BroadcastChannel = class extends FakeBC { constructor(name) { super(group, name); } };
|
|
151
|
+
const sandbox = {
|
|
152
|
+
window: win,
|
|
153
|
+
document: doc,
|
|
154
|
+
location: loc,
|
|
155
|
+
localStorage: storage,
|
|
156
|
+
console,
|
|
157
|
+
Promise,
|
|
158
|
+
Date: FakeDate,
|
|
159
|
+
Math,
|
|
160
|
+
JSON,
|
|
161
|
+
Object,
|
|
162
|
+
Array,
|
|
163
|
+
String,
|
|
164
|
+
Number,
|
|
165
|
+
Boolean,
|
|
166
|
+
RegExp,
|
|
167
|
+
Error,
|
|
168
|
+
decodeURIComponent,
|
|
169
|
+
isNaN,
|
|
170
|
+
parseInt,
|
|
171
|
+
parseFloat,
|
|
172
|
+
setInterval: (fn, ms) => { const id = ++timers.seq; timers.intervals.set(id, { fn, ms, ticks: 0 }); return id; },
|
|
173
|
+
clearInterval: (id) => { timers.intervals.delete(id); },
|
|
174
|
+
setTimeout: (fn, ms) => { const id = ++timers.seq; timers.timeouts.set(id, { fn, ms }); return id; },
|
|
175
|
+
clearTimeout: (id) => { timers.timeouts.delete(id); }
|
|
176
|
+
};
|
|
177
|
+
vm.runInNewContext(SRC, sandbox, { filename: "mobile-adapter#singlepage" });
|
|
178
|
+
const api = {
|
|
179
|
+
win,
|
|
180
|
+
doc,
|
|
181
|
+
loc,
|
|
182
|
+
sandbox,
|
|
183
|
+
timers,
|
|
184
|
+
state,
|
|
185
|
+
get appState() { return win.__dshSinglePage ? win.__dshSinglePage.state : ""; },
|
|
186
|
+
/** 跑一轮 2s 轮询(blocked 页的租约复查)。 */
|
|
187
|
+
tickPoll() {
|
|
188
|
+
for (const t of timers.intervals.values()) { if (t.ms === 2000) t.fn(); }
|
|
189
|
+
},
|
|
190
|
+
runTimeouts() {
|
|
191
|
+
for (const [id, t] of [...timers.timeouts]) { timers.timeouts.delete(id); t.fn(); }
|
|
192
|
+
},
|
|
193
|
+
clickTakeover() {
|
|
194
|
+
const btn = doc.getElementById("dsh-ma-singlepage-takeover");
|
|
195
|
+
assert.ok(btn, "提示层里必须有「在本窗口继续」按钮");
|
|
196
|
+
const fns = btn._listeners.get("click") || [];
|
|
197
|
+
assert.ok(fns.length > 0, "按钮必须绑定了点击处理");
|
|
198
|
+
for (const fn of fns) fn({ type: "click" });
|
|
199
|
+
},
|
|
200
|
+
dispatchWindow(type, extra = {}) {
|
|
201
|
+
for (const l of win._ls || []) if (l.type === type) l.fn({ type, ...extra });
|
|
202
|
+
},
|
|
203
|
+
lease() {
|
|
204
|
+
const raw = storage && storage.getItem(`dsh-remote-mirror-page:${pathname.split("/")[2]}`);
|
|
205
|
+
return raw ? JSON.parse(raw) : null;
|
|
206
|
+
},
|
|
207
|
+
storeGet(key) { return storage ? storage.getItem(key) : null; },
|
|
208
|
+
storeSet(key, val) { if (storage) storage.setItem(key, val); }
|
|
209
|
+
};
|
|
210
|
+
return api;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── 用例 ──────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
test("① 第一个页面:同步写租约 + 广播 claim,正常加载", () => {
|
|
216
|
+
const g = makeGroup();
|
|
217
|
+
const a = makeEnv(g);
|
|
218
|
+
assert.equal(a.appState, "active", "第一个页面必须是活跃页");
|
|
219
|
+
const lease = a.lease();
|
|
220
|
+
assert.ok(lease && typeof lease.id === "string", "必须写入租约");
|
|
221
|
+
assert.equal(lease.at, NOW0);
|
|
222
|
+
const claim = g.messages.find((m) => m.k === "claim");
|
|
223
|
+
assert.ok(claim, "必须广播 claim(让后来的页面知道有人)");
|
|
224
|
+
assert.equal(a.state.stops, 0, "活跃页不得停掉自己的加载");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("② 第二个页面:同步识破别人的租约 → 立刻 blocked(stop + 拦网络 + 提示),且不写租约", async () => {
|
|
228
|
+
const g = makeGroup();
|
|
229
|
+
const first = makeEnv(g);
|
|
230
|
+
const second = makeEnv(g);
|
|
231
|
+
assert.equal(second.appState, "blocked", "第二页必须被按住");
|
|
232
|
+
assert.equal(second.state.stops, 1, "必须 window.stop()(停止后续资源加载)");
|
|
233
|
+
const overlay = second.doc.getElementById("dsh-ma-singlepage");
|
|
234
|
+
assert.ok(overlay, "必须给出可见提示(不是静默卡住)");
|
|
235
|
+
assert.equal(overlay.getAttribute("data-dsh-single-page"), "blocked");
|
|
236
|
+
assert.match(overlay.innerHTML, /另一个窗口/, "提示文案要点明「已在另一个窗口打开」");
|
|
237
|
+
assert.match(overlay.innerHTML, /在本窗口继续/, "必须给「在本窗口继续」出口");
|
|
238
|
+
// 拦网络:fetch 拒绝、WebSocket 不可用
|
|
239
|
+
await assert.rejects(() => second.win.fetch("/api/x"), /本页已停用/);
|
|
240
|
+
assert.throws(() => new second.win.WebSocket("ws://x"), /本页已停用/);
|
|
241
|
+
// 不许抢租约:租约仍属于第一页
|
|
242
|
+
assert.equal(second.lease().id, first.lease().id, "第二页绝不能覆盖第一页的租约");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("③ 「在本窗口继续」:广播 takeover → 旧页让路并释放租约 → 本页重载接管", () => {
|
|
246
|
+
const g = makeGroup();
|
|
247
|
+
const first = makeEnv(g);
|
|
248
|
+
const second = makeEnv(g);
|
|
249
|
+
const firstLeaseId = first.lease().id;
|
|
250
|
+
|
|
251
|
+
second.clickTakeover();
|
|
252
|
+
// 旧页收到 takeover:立刻让路(进入 blocked、释放租约)
|
|
253
|
+
assert.equal(first.appState, "blocked", "旧页必须让路,不能两边都继续加载");
|
|
254
|
+
assert.equal(first.lease(), null, "旧页必须释放租约");
|
|
255
|
+
assert.ok(g.messages.some((m) => m.k === "takeover"), "必须广播 takeover");
|
|
256
|
+
// 本页 400ms 后重载(重载后按 takeover 标记直接接管)
|
|
257
|
+
assert.equal(second.state.reloads, 0, "还没到重载时间");
|
|
258
|
+
second.runTimeouts();
|
|
259
|
+
assert.equal(second.state.reloads, 1, "接管方必须重载一次,拿干净页面");
|
|
260
|
+
// 重载后的路径:租约可能还在 + 有 takeover 标记 → 允许接管(否则自己把自己锁死)
|
|
261
|
+
const reloaded = makeEnv(g);
|
|
262
|
+
assert.equal(reloaded.appState, "active", "takeover 标记有效期内的重载必须能接管");
|
|
263
|
+
assert.equal(reloaded.lease().id !== firstLeaseId, true, "接管后租约归属接管方");
|
|
264
|
+
// 关键:takeover 标记必须"用掉即消费",否则有效期内第三个页面也会以为自己能接管
|
|
265
|
+
const third = makeEnv(g);
|
|
266
|
+
assert.equal(third.appState, "blocked", "接管完成后,再开的页面仍必须被按住(不允许并发第三个)");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("④ 旧页关掉/失联(租约过期)→ blocked 页自动重载接管,不会永久卡住", () => {
|
|
270
|
+
const g = makeGroup();
|
|
271
|
+
makeEnv(g); // 第一页写完租约后"关掉了"(不再心跳)
|
|
272
|
+
const second = makeEnv(g);
|
|
273
|
+
assert.equal(second.appState, "blocked");
|
|
274
|
+
// 时间前进到租约过期之后
|
|
275
|
+
g.clock.now = NOW0 + 7000;
|
|
276
|
+
second.tickPoll();
|
|
277
|
+
assert.equal(second.state.reloads, 1, "租约过期后必须自动重载接管");
|
|
278
|
+
assert.ok(second.storeGet("dsh-remote-mirror-page:dev-A:takeover"), "重载前要留 takeover 标记(避免重载后又被自己挡住)");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("⑤ 活跃页 pagehide 时释放租约;bfcache 回来(pageshow persisted)重新判定", () => {
|
|
282
|
+
const g = makeGroup();
|
|
283
|
+
const a = makeEnv(g);
|
|
284
|
+
assert.ok(a.lease());
|
|
285
|
+
a.dispatchWindow("pagehide");
|
|
286
|
+
assert.equal(a.lease(), null, "pagehide 必须释放租约");
|
|
287
|
+
assert.equal(a.appState, "inactive", "pagehide 后不再是活跃页");
|
|
288
|
+
// bfcache 回来:租约已释放 → 重新判定后重新成为活跃页
|
|
289
|
+
a.dispatchWindow("pageshow", { persisted: true });
|
|
290
|
+
assert.equal(a.appState, "active", "bfcache 恢复后必须重新判定并接管");
|
|
291
|
+
assert.ok(a.lease(), "恢复后要重新写租约");
|
|
292
|
+
// 另一个页面此刻打开 → 它看到租约并被按住(不会两个页面并发加载)
|
|
293
|
+
const b = makeEnv(g);
|
|
294
|
+
assert.equal(b.appState, "blocked");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("⑥ 非镜像页 / 老浏览器 → 零行为(绝不把唯一页面锁死)", () => {
|
|
298
|
+
// /app/ 设备列表页不参与单页约束
|
|
299
|
+
const g1 = makeGroup();
|
|
300
|
+
const app = makeEnv(g1, { pathname: "/app/" });
|
|
301
|
+
assert.equal(app.appState, "", "非镜像页不得介入");
|
|
302
|
+
assert.equal(g1.store.size, 0, "非镜像页不得写任何 localStorage");
|
|
303
|
+
assert.equal(app.doc._appended.length, 0, "非镜像页不得往页面塞任何节点");
|
|
304
|
+
|
|
305
|
+
// 没有 BroadcastChannel(部分老浏览器/隐私模式)→ 不启用
|
|
306
|
+
const g2 = makeGroup();
|
|
307
|
+
const noBc = makeEnv(g2, { withBC: false });
|
|
308
|
+
assert.equal(noBc.appState, "", "没有 BroadcastChannel 时不得启用(不锁)");
|
|
309
|
+
assert.equal(g2.store.size, 0);
|
|
310
|
+
assert.equal(noBc.doc._appended.length, 0);
|
|
311
|
+
|
|
312
|
+
// 没有 localStorage → 不启用
|
|
313
|
+
const g3 = makeGroup();
|
|
314
|
+
const noLs = makeEnv(g3, { withStorage: false });
|
|
315
|
+
assert.equal(noLs.appState, "", "没有 localStorage 时不得启用");
|
|
316
|
+
assert.equal(noLs.doc._appended.length, 0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("⑦ 同一时刻两个页面都从空租约启动(并发竞态)→ 后到的看到租约后被按住", () => {
|
|
320
|
+
const g = makeGroup();
|
|
321
|
+
// 用同一份 group 串行模拟"几乎同时":第一个先写入租约(JS 单线程,真实世界也是先到先得)
|
|
322
|
+
const a = makeEnv(g);
|
|
323
|
+
const b = makeEnv(g);
|
|
324
|
+
assert.equal(a.appState, "active");
|
|
325
|
+
assert.equal(b.appState, "blocked");
|
|
326
|
+
// 两个页面不会互相写坏租约
|
|
327
|
+
assert.equal(a.lease().id, b.lease().id, "租约归属第 1 页,第 2 页不覆盖");
|
|
328
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
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",
|