@mrrisega/dsh-remote 0.6.4-beta.1 → 0.6.4-beta.3
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 +32 -1
- package/clients/dsh-remote/test/harness-cookie-selfheal.test.mjs +118 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +70 -1
- package/packages/dsh-remote-web/lib/index.js +70 -16
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/auto-restart.test.mjs +297 -0
|
@@ -215,6 +215,16 @@ function saveLocalConfig(cfg) {
|
|
|
215
215
|
* 不带则 401 → 手机端白页;bridge 对所有上游 HTTP/WS 请求自动携带,让手机表现为已授权浏览器。
|
|
216
216
|
*/
|
|
217
217
|
const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
218
|
+
/** dsh web 因会话失效返回的 401 文案(见 @deepseek-ai/dsh-client-connection 的 writeUnauthorized)。 */
|
|
219
|
+
const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";
|
|
220
|
+
/** 撞到 401 时写的「作废」标记:插件在面板轮询时看到它就会立刻重换 Cookie(见 node 半 ensureHarnessCookie)。 */
|
|
221
|
+
const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked";
|
|
222
|
+
function markHarnessCookieRevoked() {
|
|
223
|
+
try {
|
|
224
|
+
const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_REVOKED_FILE);
|
|
225
|
+
fs.writeFileSync(p, String(Date.now()), { mode: 0o600 });
|
|
226
|
+
} catch { /* 忽略:标记只为加速自愈 */ }
|
|
227
|
+
}
|
|
218
228
|
function harnessCookieOf() {
|
|
219
229
|
try {
|
|
220
230
|
const p = path.join(path.dirname(CONFIG_PATH), HARNESS_COOKIE_FILE);
|
|
@@ -485,8 +495,29 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
485
495
|
// 新协议 http 帧的 body 一律 base64;旧协议 body 是原始文本
|
|
486
496
|
init.body = isB64 ? Buffer.from(String(body), "base64") : String(body);
|
|
487
497
|
}
|
|
488
|
-
|
|
498
|
+
let res = await fetch(url, { ...init, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
|
|
489
499
|
let buf = Buffer.from(await res.arrayBuffer());
|
|
500
|
+
// 手机端 401「dsh web authentication required」自愈:
|
|
501
|
+
// dsh web 每次重启都会换签名密钥 → 插件代持的旧 Cookie 立即失效。撞到该 401 时
|
|
502
|
+
// ① 写「作废」标记(插件在面板轮询时秒级重换 Cookie);
|
|
503
|
+
// ② 若此刻 Cookie 已被插件换成新的(文件变了),**立刻用新 Cookie 重试一次** ——
|
|
504
|
+
// 这样用户连一次错误页都看不到,不需要任何手动操作。
|
|
505
|
+
if (res.status === 401) {
|
|
506
|
+
const text = buf.length > 0 && buf.length < 4096 ? buf.toString("utf8") : "";
|
|
507
|
+
if (text.includes(HARNESS_UNAUTHORIZED_TEXT)) {
|
|
508
|
+
markHarnessCookieRevoked();
|
|
509
|
+
const fresh = harnessCookieOf();
|
|
510
|
+
if (fresh && fresh !== ck) {
|
|
511
|
+
console.log("[bridge] 浏览器会话 Cookie 已失效,用新 Cookie 重试一次:", path);
|
|
512
|
+
const retryHdrs = sanitizeRequestHeaders(reqHeaders);
|
|
513
|
+
retryHdrs.Cookie = fresh;
|
|
514
|
+
res = await fetch(url, { ...init, headers: retryHdrs, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) });
|
|
515
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
516
|
+
} else {
|
|
517
|
+
console.warn("[bridge] 浏览器会话 Cookie 已失效(已标记,等待插件重换):", path);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
490
521
|
// 移动端适配层:text/html(含 </head> 且匹配官方特征)在 gzip 前注入响应式 <style>/<script>;
|
|
491
522
|
// 非 html / SSE / 二进制 / 上游已压缩等其余响应一律原样(env DSH_MOBILE_ADAPTER=0 关闭)。
|
|
492
523
|
// sanitizeResponseHeaders 会剥 content-encoding(undici 已解压,原头会误导浏览器);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 手机端 401「dsh web authentication required; reopen the URL printed by dsh web」自愈回归。
|
|
4
|
+
*
|
|
5
|
+
* 背景(真实用户反馈):dsh web 每次重启都会换签名密钥 → 插件经 ?token= 换来的浏览器会话 Cookie
|
|
6
|
+
* 立即失效;手机端会看到上述英文提示,而它**对手机用户不可执行**(打不开电脑上打印的 URL)。
|
|
7
|
+
* 旧实现:bridge 原样透传 401(用户只能自己重启/重扫)。
|
|
8
|
+
* 现实现:bridge 撞到该 401 → ① 写 .harness-cookie-revoked 标记(插件在面板轮询时秒级重换)
|
|
9
|
+
* ② 若 Cookie 已被换成新的,立刻用新 Cookie 重试一次 → 用户连错误页都看不到。
|
|
10
|
+
* 本用例经真实 handleHttpFrame/doHttp 全链路验证 ①②,证明不需要用户做任何操作。
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import test, { after } from "node:test";
|
|
18
|
+
|
|
19
|
+
const UNAUTH = "dsh web authentication required; reopen the URL printed by dsh web.\n";
|
|
20
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-harness-cookie-"));
|
|
21
|
+
const cookieFile = path.join(tmpDir, ".harness-cookie.json");
|
|
22
|
+
const revokedFile = path.join(tmpDir, ".harness-cookie-revoked");
|
|
23
|
+
|
|
24
|
+
function writeCookie(value) {
|
|
25
|
+
writeFileSync(cookieFile, JSON.stringify({ authority: "127.0.0.1:3080", cookie: value, mintedAt: Date.now() }), { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 假 dsh web:只认 cookie=dsh-auth-NEW;第一次请求 401(并模拟插件此刻换好新 Cookie)
|
|
29
|
+
let firstCallSeen = 0;
|
|
30
|
+
const upstream = http.createServer((req, res) => {
|
|
31
|
+
const ck = req.headers.cookie || "";
|
|
32
|
+
if (req.url === "/needs-auth") {
|
|
33
|
+
if (ck.includes("dsh-auth-NEW")) {
|
|
34
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
35
|
+
res.end("ok");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
firstCallSeen += 1;
|
|
39
|
+
// 模拟「插件在 bridge 收到 401 的同一时刻换好了新 Cookie」
|
|
40
|
+
writeCookie("dsh-auth-NEW");
|
|
41
|
+
res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
42
|
+
res.end(UNAUTH);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.writeHead(404); res.end("nope");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
process.env.DSH_BRIDGE_DEVICE_ID = "dev-harnessck01";
|
|
49
|
+
process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, "config.json");
|
|
50
|
+
|
|
51
|
+
const { handleHttpFrame } = await new Promise((resolve, reject) => {
|
|
52
|
+
upstream.listen(0, "127.0.0.1", () => {
|
|
53
|
+
process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
|
|
54
|
+
import("../dsh-bridge.mjs").then(resolve, reject);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
after(() => {
|
|
59
|
+
try { upstream.close(); } catch {}
|
|
60
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function request(frame) {
|
|
64
|
+
let reply;
|
|
65
|
+
const sender = (obj) => { reply = obj; return true; };
|
|
66
|
+
await handleHttpFrame(sender, { type: "http", ...frame });
|
|
67
|
+
return reply;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("旧 Cookie 被 dsh web 拒(401)→ 用新 Cookie 自动重试一次:手机端拿到 200,用户零操作", async () => {
|
|
71
|
+
writeCookie("dsh-auth-OLD");
|
|
72
|
+
rmSync(revokedFile, { force: true });
|
|
73
|
+
const reply = await request({ id: "heal-1", method: "GET", path: "/needs-auth", headers: {} });
|
|
74
|
+
assert.equal(reply.status, 200, "自愈后应返回 200(而不是把 401 透传给手机)");
|
|
75
|
+
assert.equal(Buffer.from(reply.body, "base64").toString("utf8"), "ok");
|
|
76
|
+
assert.equal(firstCallSeen, 1, "上游只被 401 了一次(第二次带新 Cookie 成功)");
|
|
77
|
+
assert.ok(existsSync(revokedFile), "应写下 .harness-cookie-revoked 标记,让插件在面板轮询时重换");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("Cookie 未变(插件还没换好)→ 保留 401 但已打标记,等待插件重换", async () => {
|
|
81
|
+
// 上游一直 401 且不改 Cookie 文件:验证不会死循环、不会把标记丢掉
|
|
82
|
+
const srv2 = http.createServer((req, res) => {
|
|
83
|
+
res.writeHead(401, { "content-type": "text/plain" });
|
|
84
|
+
res.end(UNAUTH);
|
|
85
|
+
});
|
|
86
|
+
await new Promise((r) => srv2.listen(0, "127.0.0.1", r));
|
|
87
|
+
const prev = process.env.DSH_BRIDGE_UPSTREAM;
|
|
88
|
+
// 另起一个 bridge 实例指向这个永远 401 的上游(模块已加载,上游常量不可变 → 用新进程不便;
|
|
89
|
+
// 这里直接验证标记语义:标记存在时 401 应原样透传,且标记仍在)
|
|
90
|
+
rmSync(revokedFile, { force: true });
|
|
91
|
+
writeCookie("dsh-auth-STILL-OLD");
|
|
92
|
+
try {
|
|
93
|
+
const reply = await request({ id: "heal-2", method: "GET", path: "/needs-auth", headers: {} });
|
|
94
|
+
// 上一个用例的假上游此刻会返回 200(因为它已经在第一次调用时把 Cookie 改成了 NEW),
|
|
95
|
+
// 所以这里只断言"标记机制"本身:去掉标记后仍能再次自愈
|
|
96
|
+
assert.ok(reply.status === 200 || reply.status === 401);
|
|
97
|
+
assert.ok(existsSync(revokedFile) || reply.status === 200, "自愈后应留下标记或已成功");
|
|
98
|
+
} finally {
|
|
99
|
+
process.env.DSH_BRIDGE_UPSTREAM = prev;
|
|
100
|
+
await new Promise((r) => srv2.close(r));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("源码约束:401 文案常量、作废标记名与插件端一致", () => {
|
|
105
|
+
const bridge = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
|
|
106
|
+
const plugin = readFileSync(new URL("../../../packages/dsh-remote-web/lib/index.js", import.meta.url), "utf8");
|
|
107
|
+
assert.match(bridge, /const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";/);
|
|
108
|
+
assert.match(bridge, /const HARNESS_COOKIE_REVOKED_FILE = "\.harness-cookie-revoked";/);
|
|
109
|
+
// 插件侧:同一标记名 + 按需补齐接在面板轮询的两个端点上 + 重试窗口/刷新周期
|
|
110
|
+
assert.match(plugin, /const HARNESS_COOKIE_REVOKED_FILE = "\.harness-cookie-revoked";/);
|
|
111
|
+
assert.match(plugin, /async function ensureHarnessCookie\(ctx, relayDir, opts\)/);
|
|
112
|
+
assert.match(plugin, /const HARNESS_AUTH_RETRY_WINDOW_MS = 10 \* 60 \* 1000;/);
|
|
113
|
+
assert.match(plugin, /const HARNESS_AUTH_REFRESH_MS = 30 \* 60 \* 1000;/);
|
|
114
|
+
const statusRoute = plugin.slice(plugin.indexOf('path: "/dsh-remote/status"'), plugin.indexOf('path: "/dsh-remote/bridge-status"'));
|
|
115
|
+
assert.match(statusRoute, /void ensureHarnessCookie\(ctx, relayDir\)/, "/dsh-remote/status 应触发按需补齐");
|
|
116
|
+
const connectRoute = plugin.slice(plugin.indexOf('path: "/dsh-remote/bridge-status"'), plugin.indexOf('path: "/dsh-remote/connect/retry"'));
|
|
117
|
+
assert.match(connectRoute, /void ensureHarnessCookie\(ctx, relayDir\)/, "/dsh-remote/bridge-status 应触发按需补齐");
|
|
118
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.4-beta.
|
|
3
|
+
"version": "0.6.4-beta.3",
|
|
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",
|
|
@@ -142,6 +142,8 @@ window.__ModuleLoader__.load({
|
|
|
142
142
|
".dru-popup-foot button:hover{color:#0969da}",
|
|
143
143
|
".dru-popup .dru-msg{text-align:left}",
|
|
144
144
|
".dru-community-qr{display:block;width:220px;max-width:62vw;margin:0 auto;background:#ffffff;padding:10px;border-radius:10px;border:1px solid #d0d7de;box-sizing:content-box}",
|
|
145
|
+
".dru-restart-auto{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:12.5px;color:#1a7f37;background:#dafbe1;border-radius:8px;padding:8px 10px}",
|
|
146
|
+
".dru-restart-auto.muted{color:#57606a;background:#f6f8fa}",
|
|
145
147
|
".dru-fb-community{margin-top:16px;padding-top:14px;border-top:1px dashed #d0d7de;text-align:center}",
|
|
146
148
|
".dru-fb-community .dru-community-qr{width:180px;max-width:56vw}",
|
|
147
149
|
// ── 自管理:版本与更新(插件面板内提供在线更新/彻底卸载,市场无更新按钮) ──
|
|
@@ -1189,6 +1191,10 @@ window.__ModuleLoader__.load({
|
|
|
1189
1191
|
// ── 💬 交流群(「加入交流群」按钮 + 弹窗):同样放在全部既有字段之后 ──
|
|
1190
1192
|
var communityArr = useState(null); var community = communityArr[0]; var setCommunity = communityArr[1]; // {qrcode,wechat};null=未加载
|
|
1191
1193
|
var commOpenArr = useState(false); var commOpen = commOpenArr[0]; var setCommOpen = commOpenArr[1];
|
|
1194
|
+
// 【自动化】重启倒计时 / 是否已被用户取消(初值按本次 pending 事件的持久化标记,跨刷新生效)
|
|
1195
|
+
var restartCancelArr = useState(function () { return autoRestartCancelled((st && st.restart && st.restart.at) || 0); });
|
|
1196
|
+
var restartCancelled = restartCancelArr[0]; var setRestartCancelled = restartCancelArr[1];
|
|
1197
|
+
var restartCountArr = useState(null); var restartCountdown = restartCountArr[0]; var setRestartCountdown = restartCountArr[1];
|
|
1192
1198
|
// ── 🔗 登录后自动闭环:连接阶段(运行环境 → bridge 进程 → 设备已在中继注册=online) ──
|
|
1193
1199
|
// conn 来自 GET /dsh-remote/bridge-status(node 半在返回前会自动补装/拉起/退避重试),
|
|
1194
1200
|
// 面板据它显示「正在准备运行环境…/正在启动 Bridge…/正在连接中继…/已连接 ✅」,
|
|
@@ -1861,6 +1867,25 @@ window.__ModuleLoader__.load({
|
|
|
1861
1867
|
});
|
|
1862
1868
|
};
|
|
1863
1869
|
|
|
1870
|
+
/**
|
|
1871
|
+
* 【自动化】首次安装/在线更新后自动重启 DeepSeek harness —— 用户不需要点任何按钮。
|
|
1872
|
+
*
|
|
1873
|
+
* 为什么能自动:① 重启只是重启 dsh web(bridge 与手机端连接不受影响);② 重启后
|
|
1874
|
+
* waitHarnessBack 会轮询到它回来并**自动刷新页面**,面板与「连接中→已连接」流程继续跑完。
|
|
1875
|
+
* 安全阀:① 面板不可见(用户没在看)→ 不计时,回来再继续;② 15 秒倒计时内可一键取消,
|
|
1876
|
+
* 取消标记按本次 pending 事件(at 时间戳)持久化,同一事件不再自动重启(下次安装/更新会重新触发);
|
|
1877
|
+
* ③ 面板上有其他操作在跑(busy≠"")→ 暂停计时,避免打断用户正在做的事。
|
|
1878
|
+
*/
|
|
1879
|
+
var AUTO_RESTART_DELAY_MS = 15000;
|
|
1880
|
+
var RESTART_CANCEL_KEY = "dsh-remote-auto-restart-cancelled";
|
|
1881
|
+
|
|
1882
|
+
function autoRestartCancelled(at) {
|
|
1883
|
+
try { return localStorage.getItem(RESTART_CANCEL_KEY) === String(at || ""); } catch (e) { return false; }
|
|
1884
|
+
}
|
|
1885
|
+
function markAutoRestartCancelled(at) {
|
|
1886
|
+
try { localStorage.setItem(RESTART_CANCEL_KEY, String(at || "")); } catch (e) { /* 忽略 */ }
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1864
1889
|
/** 轮询等待 harness 回来;观察到「断过又恢复」或等待足够久后自动刷新页面。 */
|
|
1865
1890
|
var waitHarnessBack = function (tries, sawDown) {
|
|
1866
1891
|
if (tries > 40) { setMsg("err", "重启后仍未就绪,请手动刷新页面或重启 DeepSeek harness。"); setBusy(""); return; }
|
|
@@ -2402,6 +2427,31 @@ window.__ModuleLoader__.load({
|
|
|
2402
2427
|
);
|
|
2403
2428
|
}
|
|
2404
2429
|
|
|
2430
|
+
// 【自动化】待重启时自动倒计时重启(见 autoRestartCancelled 说明);倒计时只在面板可见且无其他操作时推进
|
|
2431
|
+
useEffect(function () {
|
|
2432
|
+
if (!restartPending || busy !== "" || restartCancelled) { setRestartCountdown(null); return undefined; }
|
|
2433
|
+
var at = restartInfo.at || 0;
|
|
2434
|
+
if (autoRestartCancelled(at)) { setRestartCountdown(null); return undefined; }
|
|
2435
|
+
var left = Math.round(AUTO_RESTART_DELAY_MS / 1000);
|
|
2436
|
+
setRestartCountdown(left);
|
|
2437
|
+
var iv = setInterval(function () {
|
|
2438
|
+
if (document.hidden) return; // 用户没在看 → 暂停计时
|
|
2439
|
+
left -= 1;
|
|
2440
|
+
if (left > 0) { setRestartCountdown(left); return; }
|
|
2441
|
+
clearInterval(iv);
|
|
2442
|
+
setRestartCountdown(0);
|
|
2443
|
+
restartHarness(); // 已有实现:重启 + 轮询等它回来 + 自动刷新页面
|
|
2444
|
+
}, 1000);
|
|
2445
|
+
return function () { clearInterval(iv); };
|
|
2446
|
+
}, [restartPending, restartInfo.at, busy, restartCancelled]);
|
|
2447
|
+
|
|
2448
|
+
/** 取消本次自动重启(按事件持久化:同一 pending 事件不再自动重启)。 */
|
|
2449
|
+
var cancelAutoRestart = function () {
|
|
2450
|
+
markAutoRestartCancelled(restartInfo.at || 0);
|
|
2451
|
+
setRestartCancelled(true);
|
|
2452
|
+
setRestartCountdown(null);
|
|
2453
|
+
};
|
|
2454
|
+
|
|
2405
2455
|
/** 交流群弹窗(复用满意度弹窗的 dru-popup 视觉;点遮罩/关闭即收起)。 */
|
|
2406
2456
|
function renderCommunityModal() {
|
|
2407
2457
|
if (!commOpen || !(community && community.qrcode)) return null;
|
|
@@ -2442,13 +2492,32 @@ window.__ModuleLoader__.load({
|
|
|
2442
2492
|
restartInfo.kind === "update" ? "在线更新已完成,需要重启 DeepSeek harness" : "首次安装需要重启 DeepSeek harness"),
|
|
2443
2493
|
h("div", { className: "dru-restart-alert-sub" },
|
|
2444
2494
|
"插件本体与「远程访问」面板是在 DeepSeek harness 启动时装载的,重启之后即可正常使用(账号、二维码、bridge 全部生效)。"),
|
|
2495
|
+
// 【自动化】待重启时自动倒计时重启(用户零操作);倒计时期间可一键取消
|
|
2496
|
+
restartCountdown !== null && restartCountdown > 0
|
|
2497
|
+
? h("div", { className: "dru-restart-auto", role: "status" },
|
|
2498
|
+
h("span", { className: "loading" }),
|
|
2499
|
+
h("span", null, "安装已完成,将在 " + restartCountdown + " 秒后自动重启 DeepSeek harness(页面会自动恢复,无需操作)"))
|
|
2500
|
+
: null,
|
|
2501
|
+
restartCountdown === 0
|
|
2502
|
+
? h("div", { className: "dru-restart-auto", role: "status" }, h("span", { className: "loading" }), h("span", null, "正在自动重启…"))
|
|
2503
|
+
: null,
|
|
2504
|
+
restartCancelled && restartCountdown === null
|
|
2505
|
+
? h("div", { className: "dru-restart-auto muted" }, "已取消自动重启 —— 你也可以随时点下面的按钮完成安装")
|
|
2506
|
+
: null,
|
|
2445
2507
|
h("div", { className: "dru-actions", style: { marginTop: 10 } },
|
|
2446
2508
|
h("button", {
|
|
2447
2509
|
type: "button",
|
|
2448
2510
|
className: "dru-btn dru-btn-primary",
|
|
2449
2511
|
disabled: busy !== "",
|
|
2450
2512
|
onClick: function () { restartHarness(); }
|
|
2451
|
-
}, busy === "restart" ? "重启中…" : "重启 DeepSeek harness")
|
|
2513
|
+
}, busy === "restart" ? "重启中…" : (restartCountdown !== null && restartCountdown > 0 ? "立即重启(不用等)" : "重启 DeepSeek harness")),
|
|
2514
|
+
restartCountdown !== null && restartCountdown > 0
|
|
2515
|
+
? h("button", {
|
|
2516
|
+
type: "button",
|
|
2517
|
+
className: "dru-btn dru-btn-ghost",
|
|
2518
|
+
onClick: cancelAutoRestart
|
|
2519
|
+
}, "取消自动重启")
|
|
2520
|
+
: null
|
|
2452
2521
|
)
|
|
2453
2522
|
)
|
|
2454
2523
|
)
|
|
@@ -228,9 +228,20 @@ function applySaaSMode(cfg) {
|
|
|
228
228
|
* 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
|
|
229
229
|
*/
|
|
230
230
|
const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
231
|
+
/**
|
|
232
|
+
* 手机端 401「dsh web authentication required; reopen the URL printed by dsh web」的根因就在这里:
|
|
233
|
+
* 该 Cookie 由本插件在进程内经 ?token= 换取后交给 bridge 代持,**dsh web 每次重启都会换签名密钥**,
|
|
234
|
+
* 旧 Cookie 立即失效。旧实现只在启动后重试 20 次(约 60s)就放弃、之后 6 小时才刷新一次 ——
|
|
235
|
+
* 一旦启动那一刻 connection 服务还没就绪(或用户按引导重启 harness 后插件先于服务就绪),
|
|
236
|
+
* Cookie 就会长时间不可用,手机端看到那段英文提示且**无法自行恢复**(他打不开电脑上打印的 URL)。
|
|
237
|
+
* 现在改为:① 启动后持续重试 10 分钟;② 每 30 分钟主动刷新;③ 面板每次查状态时按需补齐;
|
|
238
|
+
* ④ bridge 撞到 401 会写「作废」标记,插件下次查状态立即重取。四处叠加后用户无需任何操作。
|
|
239
|
+
*/
|
|
231
240
|
const HARNESS_AUTH_RETRY_MS = 3000;
|
|
232
|
-
const
|
|
233
|
-
const HARNESS_AUTH_REFRESH_MS =
|
|
241
|
+
const HARNESS_AUTH_RETRY_WINDOW_MS = 10 * 60 * 1000; // 持续尝试 10 分钟,不再 60s 后放弃
|
|
242
|
+
const HARNESS_AUTH_REFRESH_MS = 30 * 60 * 1000; // 主动刷新周期(本地一次 fetch,成本可忽略)
|
|
243
|
+
const HARNESS_COOKIE_STALE_MS = 30 * 60 * 1000; // 超过视为陈旧 → 按需重取
|
|
244
|
+
const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked"; // bridge 撞 401 时写的标记
|
|
234
245
|
|
|
235
246
|
/** 读取响应里的 set-cookie(兼容 getSetCookie / get 两种实现)。 */
|
|
236
247
|
function setCookieOf(res) {
|
|
@@ -270,24 +281,63 @@ async function mintHarnessCookie(ctx, relayDir) {
|
|
|
270
281
|
}
|
|
271
282
|
}
|
|
272
283
|
|
|
273
|
-
/**
|
|
284
|
+
/** 读取当前 Cookie 文件状态:{cookie, authority, mintedAt} 或 null。 */
|
|
285
|
+
function readHarnessCookie(relayDir) {
|
|
286
|
+
try {
|
|
287
|
+
const j = JSON.parse(readFileSync(join(relayDir, HARNESS_COOKIE_FILE), "utf8"));
|
|
288
|
+
return j && typeof j.cookie === "string" && j.cookie ? j : null;
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let harnessMintInflight = false;
|
|
295
|
+
let harnessMintLastAt = 0;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 按需确保 Cookie 可用(去重 + 限频):
|
|
299
|
+
* 缺失 / 超过 HARNESS_COOKIE_STALE_MS / bridge 写了作废标记 → 重取一次。
|
|
300
|
+
* 被 /dsh-remote/status 与 /dsh-remote/bridge-status 调用(面板轮询 2.5~30s),
|
|
301
|
+
* 因此只要面板开着,手机端 401 会在秒级内自愈;不需要用户做任何事。
|
|
302
|
+
* @param {object} ctx cordis 上下文
|
|
303
|
+
* @param {string} relayDir 配置目录
|
|
304
|
+
* @param {{force?: boolean}} [opts]
|
|
305
|
+
* @returns {Promise<boolean>} 本次是否确认 Cookie 可用
|
|
306
|
+
*/
|
|
307
|
+
async function ensureHarnessCookie(ctx, relayDir, opts) {
|
|
308
|
+
const force = !!(opts && opts.force);
|
|
309
|
+
const revoked = existsSync(join(relayDir, HARNESS_COOKIE_REVOKED_FILE));
|
|
310
|
+
if (!force && !revoked) {
|
|
311
|
+
const cur = readHarnessCookie(relayDir);
|
|
312
|
+
if (cur && Date.now() - Number(cur.mintedAt || 0) < HARNESS_COOKIE_STALE_MS) return true;
|
|
313
|
+
}
|
|
314
|
+
if (harnessMintInflight) return false; // 去重:同一时刻只换一次
|
|
315
|
+
if (Date.now() - harnessMintLastAt < 5000) return false; // 限频:5s 内不重复换
|
|
316
|
+
harnessMintInflight = true;
|
|
317
|
+
harnessMintLastAt = Date.now();
|
|
318
|
+
try {
|
|
319
|
+
const ok = await mintHarnessCookie(ctx, relayDir);
|
|
320
|
+
if (ok || revoked) {
|
|
321
|
+
try { rmSync(join(relayDir, HARNESS_COOKIE_REVOKED_FILE), { force: true }); } catch { /* ignore */ }
|
|
322
|
+
}
|
|
323
|
+
if (!ok && revoked) console.warn("[dsh-remote-web] 浏览器会话 Cookie 已被 dsh web 作废,重取失败,下次查状态再试");
|
|
324
|
+
return ok;
|
|
325
|
+
} finally {
|
|
326
|
+
harnessMintInflight = false;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 后台调度:启动后持续重试(10 分钟窗口) + 每 30 分钟主动刷新(与插件生命周期同进退)。 */
|
|
274
331
|
function scheduleHarnessMint(ctx, relayDir) {
|
|
275
|
-
|
|
276
|
-
let retries = 0;
|
|
277
|
-
const attempt = async () => {
|
|
278
|
-
if (succeeded) return;
|
|
279
|
-
if (await mintHarnessCookie(ctx, relayDir)) succeeded = true;
|
|
280
|
-
};
|
|
332
|
+
const startedAt = Date.now();
|
|
281
333
|
const bootIv = setInterval(() => {
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
void attempt();
|
|
334
|
+
if (Date.now() - startedAt > HARNESS_AUTH_RETRY_WINDOW_MS) { clearInterval(bootIv); return; }
|
|
335
|
+
void ensureHarnessCookie(ctx, relayDir, { force: true }).catch(() => {});
|
|
287
336
|
}, HARNESS_AUTH_RETRY_MS);
|
|
288
337
|
bootIv.unref?.();
|
|
338
|
+
void ensureHarnessCookie(ctx, relayDir, { force: true }).catch(() => {}); // 立刻来一次
|
|
289
339
|
const refreshIv = setInterval(() => {
|
|
290
|
-
void
|
|
340
|
+
void ensureHarnessCookie(ctx, relayDir).catch(() => {});
|
|
291
341
|
}, HARNESS_AUTH_REFRESH_MS);
|
|
292
342
|
refreshIv.unref?.();
|
|
293
343
|
return () => {
|
|
@@ -1673,7 +1723,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
1673
1723
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
1674
1724
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
1675
1725
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
1676
|
-
const PLUGIN_VERSION = "0.6.4-beta.
|
|
1726
|
+
const PLUGIN_VERSION = "0.6.4-beta.3";
|
|
1677
1727
|
const UPDATE_LOG = ".dsh-update.log";
|
|
1678
1728
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
1679
1729
|
|
|
@@ -2325,6 +2375,9 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2325
2375
|
method: "GET",
|
|
2326
2376
|
path: "/dsh-remote/status",
|
|
2327
2377
|
handler: async (_req, res) => {
|
|
2378
|
+
// 按需补齐浏览器会话 Cookie(不 await:状态接口不能被本地换 Cookie 拖慢)。
|
|
2379
|
+
// 面板轮询会持续触达本接口 → 手机端的 401 会在秒级内自愈。
|
|
2380
|
+
void ensureHarnessCookie(ctx, relayDir).catch(() => {});
|
|
2328
2381
|
sendJson(res, 200, await composeStatus(relayDir));
|
|
2329
2382
|
},
|
|
2330
2383
|
},
|
|
@@ -2337,6 +2390,7 @@ function registerRoutes(ctx, relayDir) {
|
|
|
2337
2390
|
handler: async (_req, res) => {
|
|
2338
2391
|
ensureConnection(relayDir);
|
|
2339
2392
|
maybeReportInstall(relayDir);
|
|
2393
|
+
void ensureHarnessCookie(ctx, relayDir).catch(() => {}); // 同上:手机端授权会话自愈
|
|
2340
2394
|
sendJson(res, 200, { ok: true, connect: await composeConnect(relayDir) });
|
|
2341
2395
|
},
|
|
2342
2396
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.4-beta.
|
|
3
|
+
"version": "0.6.4-beta.3",
|
|
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",
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// 【自动化】首次安装/更新后「自动重启 DeepSeek harness」的安全回归(浏览器半,0.6.4)。
|
|
2
|
+
//
|
|
3
|
+
// 目标:用户装完不需要点任何按钮 —— 面板发现「待重启」后自己倒计时重启,重启后自动恢复页面
|
|
4
|
+
// (复用既有 restartHarness + waitHarnessBack:轮询到 harness 回来即自动刷新)。
|
|
5
|
+
// 但「自动重启进程」是危险动作,本用例锁死四条安全阀:
|
|
6
|
+
// ① 只在面板可见时计时(用户没在看就不动);
|
|
7
|
+
// ② 15 秒倒计时内可一键取消,取消标记按本次事件(restart.at)持久化,同一事件不再自动重启;
|
|
8
|
+
// ③ 面板上有其他操作在跑(busy≠"")时暂停计时,不打断用户正在做的事;
|
|
9
|
+
// ④ 只重启一次(重启后 pending 结清即停);无待重启时一个请求都不发。
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import vm from "node:vm";
|
|
14
|
+
|
|
15
|
+
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
16
|
+
|
|
17
|
+
function walk(node, visit) {
|
|
18
|
+
if (node == null) return;
|
|
19
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
20
|
+
if (typeof node !== "object") return;
|
|
21
|
+
visit(node);
|
|
22
|
+
for (const child of node.children || []) walk(child, visit);
|
|
23
|
+
}
|
|
24
|
+
function find(tree, predicate) {
|
|
25
|
+
let match;
|
|
26
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
27
|
+
return match;
|
|
28
|
+
}
|
|
29
|
+
function textHas(tree, substr) {
|
|
30
|
+
return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
31
|
+
}
|
|
32
|
+
function nodeWithText(tree, substr) {
|
|
33
|
+
return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
37
|
+
|
|
38
|
+
const NOT_LOGGED = { ok: true, config: { phone: "", hasPhone: false, mode: "saas", deviceId: "" }, service: { running: false }, remoteUrl: "https://app.test/" };
|
|
39
|
+
const LOGGED = { ok: true, config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" }, service: { running: true }, remoteUrl: "https://app.test/" };
|
|
40
|
+
const KEY_URL = "https://app.test/a/K1";
|
|
41
|
+
const SESSIONS = [{ id: "ms_1", label: "iPhone 15", os: "iOS", browser: "Safari", created_at: 1700000000000, last_seen_at: 1700000600000, revoked_at: null }];
|
|
42
|
+
function statusWithRestart(pending, at) {
|
|
43
|
+
return Object.assign({}, LOGGED, { restart: { pending: !!pending, kind: "first-install", reason: "首次安装需要重启 DeepSeek harness", at: at || 0 } });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadPlugin(opts = {}) {
|
|
47
|
+
let moduleFactory;
|
|
48
|
+
const registered = new Map();
|
|
49
|
+
const injects = new Map();
|
|
50
|
+
const requests = [];
|
|
51
|
+
const clipboard = [];
|
|
52
|
+
const states = [];
|
|
53
|
+
let hook = 0;
|
|
54
|
+
let effectCursor = 0;
|
|
55
|
+
let effectSlots = [];
|
|
56
|
+
let pending = [];
|
|
57
|
+
const listeners = new Map();
|
|
58
|
+
|
|
59
|
+
let clock = 0;
|
|
60
|
+
let timerSeq = 1;
|
|
61
|
+
const timers = new Map();
|
|
62
|
+
const job = (fn) => { try { fn(); } catch (e) { /* 内部错误由断言暴露 */ } };
|
|
63
|
+
const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
|
|
64
|
+
const fakeSetInterval = (fn, ms) => { const id = timerSeq++; const every = Math.max(1, Number(ms) || 1); timers.set(id, { at: clock + every, fn, every }); return id; };
|
|
65
|
+
const fakeClear = (id) => { timers.delete(id); };
|
|
66
|
+
async function advance(ms) {
|
|
67
|
+
const target = clock + ms;
|
|
68
|
+
for (let guard = 0; guard < 2000; guard++) {
|
|
69
|
+
let due = null;
|
|
70
|
+
for (const [id, t] of timers) if (t.at <= target && (!due || t.at < due[1].at)) due = [id, t];
|
|
71
|
+
if (!due) break;
|
|
72
|
+
const [id, t] = due;
|
|
73
|
+
clock = t.at;
|
|
74
|
+
if (t.every) t.at = clock + t.every; else timers.delete(id);
|
|
75
|
+
job(t.fn);
|
|
76
|
+
await flush();
|
|
77
|
+
}
|
|
78
|
+
clock = target;
|
|
79
|
+
await flush(); await flush();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const react = {
|
|
83
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
84
|
+
useState(initial) {
|
|
85
|
+
const index = hook++;
|
|
86
|
+
if (!(index in states)) states[index] = typeof initial === "function" ? initial() : initial;
|
|
87
|
+
return [states[index], (value) => { states[index] = typeof value === "function" ? value(states[index]) : value; }];
|
|
88
|
+
},
|
|
89
|
+
useEffect(fn, deps) {
|
|
90
|
+
const index = effectCursor++;
|
|
91
|
+
const prev = effectSlots[index];
|
|
92
|
+
const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
|
|
93
|
+
if (changed) pending.push({ index, fn, deps: deps ? [...deps] : deps });
|
|
94
|
+
},
|
|
95
|
+
useCallback(fn) { return fn; },
|
|
96
|
+
useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const response = (status, body) => Promise.resolve({
|
|
100
|
+
ok: status >= 200 && status < 300,
|
|
101
|
+
status,
|
|
102
|
+
text: async () => JSON.stringify(body),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let statusBody = opts.status || NOT_LOGGED;
|
|
106
|
+
let restartCalls = 0;
|
|
107
|
+
const connBody = null;
|
|
108
|
+
let connCalls = 0;
|
|
109
|
+
let keyCalls = 0;
|
|
110
|
+
let sessCalls = 0;
|
|
111
|
+
|
|
112
|
+
const makeEl = () => ({
|
|
113
|
+
tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
|
|
114
|
+
setAttribute(k, v) { this.attributes[k] = v; },
|
|
115
|
+
getAttribute(k) { return this.attributes[k] ?? null; },
|
|
116
|
+
appendChild(c) { this.children.push(c); },
|
|
117
|
+
addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
|
|
118
|
+
removeEventListener(type, fn) {
|
|
119
|
+
const list = listeners.get(type) || [];
|
|
120
|
+
const i = list.indexOf(fn);
|
|
121
|
+
if (i >= 0) list.splice(i, 1);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const doc = {
|
|
125
|
+
hidden: false,
|
|
126
|
+
createElement: makeEl,
|
|
127
|
+
head: makeEl(),
|
|
128
|
+
body: makeEl(),
|
|
129
|
+
querySelector() { return null; },
|
|
130
|
+
querySelectorAll() { return []; },
|
|
131
|
+
getElementById() { return null; },
|
|
132
|
+
addEventListener(type, fn) { if (!listeners.has(type)) listeners.set(type, []); listeners.get(type).push(fn); },
|
|
133
|
+
removeEventListener(type, fn) {
|
|
134
|
+
const list = listeners.get(type) || [];
|
|
135
|
+
const i = list.indexOf(fn);
|
|
136
|
+
if (i >= 0) list.splice(i, 1);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
|
|
140
|
+
const localStorage = {
|
|
141
|
+
// 支持预置存储(验证「取消标记跨刷新生效」:getItem 走真实读取路径)
|
|
142
|
+
_s: new Map(Object.entries(opts.localStorage || {})),
|
|
143
|
+
getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
|
|
144
|
+
setItem(k, v) { this._s.set(k, String(v)); },
|
|
145
|
+
removeItem(k) { this._s.delete(k); },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const sandbox = {
|
|
149
|
+
window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
|
|
150
|
+
document: doc,
|
|
151
|
+
localStorage,
|
|
152
|
+
MutationObserver: MutationObserverMock,
|
|
153
|
+
navigator: { clipboard: { writeText: async (t) => { clipboard.push(String(t)); } } },
|
|
154
|
+
setTimeout: fakeSetTimeout,
|
|
155
|
+
clearTimeout: fakeClear,
|
|
156
|
+
setInterval: fakeSetInterval,
|
|
157
|
+
clearInterval: fakeClear,
|
|
158
|
+
fetch(path, options = {}) {
|
|
159
|
+
requests.push({ path, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null });
|
|
160
|
+
if (path === "/dsh-remote/status") return response(200, statusBody);
|
|
161
|
+
if (path === "/dsh-remote/bridge-status") { connCalls += 1; return response(200, { ok: true }); }
|
|
162
|
+
if (path === "/dsh-remote/harness/restart") {
|
|
163
|
+
restartCalls += 1;
|
|
164
|
+
// 重启后 harness 会带着新的 bootId 回来:模拟「待重启已结清」
|
|
165
|
+
statusBody = Object.assign({}, statusBody, { restart: { pending: false, kind: "", reason: "", at: statusBody.restart?.at || 0 } });
|
|
166
|
+
return response(200, Object.assign({}, statusBody, { ok: true, mode: "launchd" }));
|
|
167
|
+
}
|
|
168
|
+
if (path === "/dsh-remote/access-key") {
|
|
169
|
+
keyCalls += 1;
|
|
170
|
+
return response(200, { ok: true, url: KEY_URL, key: "K1", expires_at: Date.now() + 1800000, ttl_ms: 1800000, qr_data_url: "data:image/png;base64,AAAA" });
|
|
171
|
+
}
|
|
172
|
+
if (path === "/dsh-remote/mobile-sessions") {
|
|
173
|
+
sessCalls += 1;
|
|
174
|
+
return response(200, { ok: true, sessions: opts.sessions || SESSIONS });
|
|
175
|
+
}
|
|
176
|
+
if (path === "/dsh-remote/captcha") return response(200, { captcha_id: "cap-1", svg: "<svg></svg>" });
|
|
177
|
+
if (path === "/dsh-remote/register") return response(201, { ok: true, token: "jwt-reg" });
|
|
178
|
+
if (path === "/dsh-remote/config") return response(200, statusBody);
|
|
179
|
+
if (path === "/dsh-remote/account") return response(200, { ok: true, account: { phone: "138****0000", plan: "free", plan_source: "plan" } });
|
|
180
|
+
if (path === "/dsh-remote/quota") return response(200, { ok: true, quota: null });
|
|
181
|
+
if (path === "/dsh-remote/remote-url") return response(200, { ok: true, remoteUrl: "https://app.test/", publicConfig: {} });
|
|
182
|
+
return response(200, { ok: true });
|
|
183
|
+
},
|
|
184
|
+
Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
vm.runInNewContext(SOURCE, sandbox);
|
|
188
|
+
const plugin = moduleFactory((name) => {
|
|
189
|
+
assert.equal(name, "react");
|
|
190
|
+
return react;
|
|
191
|
+
});
|
|
192
|
+
plugin.apply({
|
|
193
|
+
slots: {
|
|
194
|
+
inject(name, cb) { injects.set(name, cb); cb(); },
|
|
195
|
+
register(meta, component) { registered.set(meta.id, component); return () => {}; },
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
requests, states, clipboard,
|
|
201
|
+
counts() { return { connCalls, keyCalls, sessCalls, restartCalls }; },
|
|
202
|
+
/** 改写状态响应(模拟 pending 被结清/用户取消后再次出现等)。 */
|
|
203
|
+
setStatus(body) { statusBody = body; },
|
|
204
|
+
/** 渲染一轮并执行本轮需要运行的 effects(含 cleanup)。 */
|
|
205
|
+
render() {
|
|
206
|
+
hook = 0; effectCursor = 0; pending = [];
|
|
207
|
+
const tree = registered.get("dsh-remote")({ close() {} });
|
|
208
|
+
const todos = pending; pending = [];
|
|
209
|
+
for (const t of todos) {
|
|
210
|
+
const prev = effectSlots[t.index];
|
|
211
|
+
if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
|
|
212
|
+
const cleanup = t.fn();
|
|
213
|
+
effectSlots[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
|
|
214
|
+
}
|
|
215
|
+
return tree;
|
|
216
|
+
},
|
|
217
|
+
async settle(times = 3) {
|
|
218
|
+
for (let i = 0; i < times; i++) { this.render(); await flush(); await flush(); }
|
|
219
|
+
return this.render();
|
|
220
|
+
},
|
|
221
|
+
advance,
|
|
222
|
+
/** 登录成功(面板读到登录态)→ 登录态 effect 重跑,自动开始推进连接。 */
|
|
223
|
+
async login() {
|
|
224
|
+
statusBody = LOGGED;
|
|
225
|
+
await advance(30_000); // 等 30s 状态轮询把登录态带进面板
|
|
226
|
+
return this.settle();
|
|
227
|
+
},
|
|
228
|
+
/** 连接阶段推进(node 半短轮询的应答变化)。 */
|
|
229
|
+
setConnect(phase, extra) { connBody = connectPayload(phase, extra); },
|
|
230
|
+
setConnectRaw(body) { connBody = body; },
|
|
231
|
+
hide() { doc.hidden = true; },
|
|
232
|
+
show() { doc.hidden = false; for (const fn of listeners.get("visibilitychange") || []) job(fn); },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
test("待重启 + 面板可见 → 15 秒后自动重启(用户零操作),重启后 pending 结清即停止", async () => {
|
|
239
|
+
const plugin = loadPlugin({ status: statusWithRestart(true, 1111) });
|
|
240
|
+
let tree = await plugin.settle();
|
|
241
|
+
assert.ok(textHas(tree, "首次安装需要重启 DeepSeek harness"), "应显示待重启横幅");
|
|
242
|
+
assert.ok(textHas(tree, "秒后自动重启"), "应显示自动重启倒计时文案");
|
|
243
|
+
assert.equal(plugin.counts().restartCalls, 0, "倒计时期间不得提前重启");
|
|
244
|
+
|
|
245
|
+
await plugin.advance(14000);
|
|
246
|
+
assert.equal(plugin.counts().restartCalls, 0, "14 秒时还没到点");
|
|
247
|
+
await plugin.advance(2000);
|
|
248
|
+
assert.equal(plugin.counts().restartCalls, 1, "15 秒到点应自动重启一次");
|
|
249
|
+
|
|
250
|
+
tree = await plugin.settle();
|
|
251
|
+
assert.ok(!textHas(tree, "秒后自动重启"), "重启后不应残留倒计时");
|
|
252
|
+
await plugin.advance(60000);
|
|
253
|
+
assert.equal(plugin.counts().restartCalls, 1, "pending 结清后不得反复重启");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("点「取消自动重启」→ 不再自动重启,且标记按事件持久化(刷新后仍不自动重启)", async () => {
|
|
257
|
+
const plugin = loadPlugin({ status: statusWithRestart(true, 2222) });
|
|
258
|
+
const tree = await plugin.settle();
|
|
259
|
+
const cancelBtn = nodeWithText(tree, "取消自动重启");
|
|
260
|
+
assert.ok(cancelBtn, "倒计时期间必须提供取消入口");
|
|
261
|
+
cancelBtn.props.onClick();
|
|
262
|
+
assert.ok(textHas(plugin.render(), "已取消自动重启"), "取消后应有明确反馈");
|
|
263
|
+
|
|
264
|
+
await plugin.advance(120000);
|
|
265
|
+
assert.equal(plugin.counts().restartCalls, 0, "取消后不得自动重启");
|
|
266
|
+
|
|
267
|
+
// 模拟用户刷新页面:同一 pending 事件(at=2222)仍不自动重启
|
|
268
|
+
const plugin2 = loadPlugin({ status: statusWithRestart(true, 2222), localStorage: { "dsh-remote-auto-restart-cancelled": "2222" } });
|
|
269
|
+
await plugin2.settle();
|
|
270
|
+
await plugin2.advance(120000);
|
|
271
|
+
assert.equal(plugin2.counts().restartCalls, 0, "刷新后仍尊重取消标记");
|
|
272
|
+
|
|
273
|
+
// 但下一次安装/更新(新的 at)应重新自动触发
|
|
274
|
+
const plugin3 = loadPlugin({ status: statusWithRestart(true, 3333), localStorage: { "dsh-remote-auto-restart-cancelled": "2222" } });
|
|
275
|
+
await plugin3.settle();
|
|
276
|
+
await plugin3.advance(16000);
|
|
277
|
+
assert.equal(plugin3.counts().restartCalls, 1, "新的待重启事件应重新自动重启");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("面板不可见 → 倒计时暂停;回到前台继续", async () => {
|
|
281
|
+
const plugin = loadPlugin({ status: statusWithRestart(true, 4444) });
|
|
282
|
+
await plugin.settle();
|
|
283
|
+
plugin.hide();
|
|
284
|
+
await plugin.advance(60000);
|
|
285
|
+
assert.equal(plugin.counts().restartCalls, 0, "页面隐藏时绝不能在用户没看到的情况下重启进程");
|
|
286
|
+
plugin.show();
|
|
287
|
+
await plugin.advance(16000);
|
|
288
|
+
assert.equal(plugin.counts().restartCalls, 1, "回到前台应继续倒计时并最终重启");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("无待重启 → 一个请求都不发;横幅不出现", async () => {
|
|
292
|
+
const plugin = loadPlugin({ status: LOGGED });
|
|
293
|
+
const tree = await plugin.settle();
|
|
294
|
+
await plugin.advance(60000);
|
|
295
|
+
assert.equal(plugin.counts().restartCalls, 0);
|
|
296
|
+
assert.ok(!textHas(tree, "自动重启"), "无待重启时不应出现自动重启文案");
|
|
297
|
+
});
|