@mrrisega/dsh-remote 0.6.1-beta.1 → 0.6.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/README.md CHANGED
@@ -28,7 +28,12 @@ dsh-remote 是一个轻量的**隧道模式**远程控制方案:电脑端运
28
28
  └→ bridge (电脑端) → 127.0.0.1:3080 (dsh web)
29
29
  ```
30
30
 
31
- ## 0.6.0 速览(用户最关心的新能力)
31
+ ## 0.6.1 速览(用户最关心的新能力)
32
+
33
+ - **首次安装体验修复**:装完插件立即登录,二维码与已授权设备列表不再报「尚未登录」红字;中继未就绪时面板自动退避重试,不用再手动刷新页面。
34
+ - **企微交流群**:README 底部扫码入群;服务端可在管理后台「推广」页上传交流群二维码,官网、付费页底部、设置面板「加入交流群」按钮与用户反馈页会同步展示。
35
+
36
+ **0.6.0 起已有**
32
37
 
33
38
  - **扫码即加密,不再要求再输一次密码**:用电脑生成的二维码/一次性链接进入后,由电脑自动授权开启端到端加密;密码登录点设备也自动解锁。全程不输 E2EE 密码、不存服务端。
34
39
  - **记住本机:刷新/重开不掉回明文**:解锁一次后,手机 App 刷新、镜像页刷新都自动恢复加密(退出登录即清除;浏览器无痕可不用此功能)。
@@ -151,6 +156,14 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
151
156
 
152
157
  测试:`npm test`(router 契约 + 插件 + bridge 全部单测与回归)。
153
158
 
159
+ ## 企微交流群
160
+
161
+ 扫码加入 **企微交流群**:安装/使用答疑、问题反馈、版本更新都会在群里同步,欢迎来聊。
162
+
163
+ <img src="image/企微交流群.jpg" alt="企微交流群" width="240">
164
+
165
+ > 也可以在仓库提 [Issue](https://github.com/mrRisega/dsh-remote/issues);安全相关问题请按 [SECURITY.md](SECURITY.md) 私下反馈。
166
+
154
167
  ## 文档
155
168
 
156
169
  - [docs/self-hosting.md](docs/self-hosting.md) — 开源自建完整指南(含安全提示)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.1-beta.1",
3
+ "version": "0.6.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",
@@ -134,6 +134,9 @@ window.__ModuleLoader__.load({
134
134
  ".dru-popup-foot button{border:none;background:none;color:#57606a;cursor:pointer;font-size:12px;font-family:inherit;padding:4px 6px}",
135
135
  ".dru-popup-foot button:hover{color:#0969da}",
136
136
  ".dru-popup .dru-msg{text-align:left}",
137
+ ".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}",
138
+ ".dru-fb-community{margin-top:16px;padding-top:14px;border-top:1px dashed #d0d7de;text-align:center}",
139
+ ".dru-fb-community .dru-community-qr{width:180px;max-width:56vw}",
137
140
  // ── 自管理:版本与更新(插件面板内提供在线更新/彻底卸载,市场无更新按钮) ──
138
141
  ".dru-ver-badge{display:inline-block;font-size:11px;border-radius:999px;padding:1px 8px;margin-left:6px;vertical-align:1px}",
139
142
  ".dru-ver-badge-new{color:#9a6700;background:#fff8c5;border:1px solid #eed888}",
@@ -347,7 +350,25 @@ window.__ModuleLoader__.load({
347
350
  return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) });
348
351
  };
349
352
 
350
- // ── 登录后首次加载的自愈(0.6.1-beta.1) ───────────────────────────────
353
+ // ── 交流群(运营二维码;管理后台统一上传,node 半经公开配置下发) ─────────
354
+ // 设置面板「加入交流群」按钮与用户反馈页都展示同一张码;未配置(qrcode 为空)→ 不展示任何入口。
355
+ var COMMUNITY_TTL_MS = 5 * 60 * 1000; // 面板/反馈卡共用缓存:5 分钟内不重复请求
356
+ var communityCache = { at: 0, data: null };
357
+ /** 取交流群信息(模块级缓存;失败视为未配置,不打扰用户)。 */
358
+ function loadCommunity(force) {
359
+ if (!force && communityCache.data && Date.now() - communityCache.at < COMMUNITY_TTL_MS) {
360
+ return Promise.resolve(communityCache.data);
361
+ }
362
+ return api("/dsh-remote/community").then(function (b) {
363
+ communityCache = { at: Date.now(), data: { qrcode: (b && b.qrcode) || "", wechat: (b && b.wechat) || "" } };
364
+ return communityCache.data;
365
+ }).catch(function () {
366
+ communityCache = { at: Date.now(), data: { qrcode: "", wechat: "" } };
367
+ return communityCache.data;
368
+ });
369
+ }
370
+
371
+ // ── 登录后首次加载的自愈(0.6.1) ───────────────────────────────
351
372
  // 现象(首次安装后立即登录):面板在 loggedIn 翻真的瞬间就请求 /dsh-remote/access-key 与
352
373
  // /dsh-remote/mobile-sessions;此时中继握手可能尚未就绪(bridge 刚被重启、device-login 共享密钥
353
374
  // 刚补齐或正在轮换、中继冷启动/网络抖动),旧版只报一次红字且不重试——必须手动刷新页面才行。
@@ -478,6 +499,8 @@ window.__ModuleLoader__.load({
478
499
  var mineListArr = useState(null); var mineList = mineListArr[0]; var setMineList = mineListArr[1];
479
500
  var mineErrArr = useState(false); var mineErr = mineErrArr[0]; var setMineErr = mineErrArr[1];
480
501
  var mineBusyArr = useState(false); var mineBusy = mineBusyArr[0]; var setMineBusy = mineBusyArr[1];
502
+ // 💬 交流群:反馈页底部展示「加入交流群」二维码(与设置面板共用同一份缓存)
503
+ var commArr = useState(communityCache.data); var comm = commArr[0]; var setComm = commArr[1];
481
504
 
482
505
  function setMsg(kind, text) { setFbMsg({ kind: kind, text: text }); }
483
506
 
@@ -485,6 +508,12 @@ window.__ModuleLoader__.load({
485
508
  // 自建/未登录(cfg.phone 为空)→ 仍走本地 thread_token,不发 /mine。
486
509
  var fbAccount = !!(cfg && cfg.phone && fbAuth === "account");
487
510
 
511
+ useEffect(function () {
512
+ var alive = true;
513
+ loadCommunity().then(function (d) { if (alive) setComm(d); });
514
+ return function () { alive = false; };
515
+ }, []);
516
+
488
517
  var loadCfg = useCallback(function () {
489
518
  api("/dsh-remote/feedback-config").then(function (b) {
490
519
  setCfg(b);
@@ -724,7 +753,15 @@ window.__ModuleLoader__.load({
724
753
  h("button", { type: "button", className: "dru-btn dru-btn-primary", style: { width: "100%" }, disabled: busy !== "", onClick: doSubmit }, busy === "submit" ? "提交中…" : "提交反馈"),
725
754
  fbMsg && h("div", { className: "dru-msg dru-msg-" + fbMsg.kind }, fbMsg.text)
726
755
  )
727
- : renderMine()
756
+ : renderMine(),
757
+ // 反馈页底部:企微交流群二维码(后台上传后出现;未配置不展示)
758
+ comm && comm.qrcode
759
+ ? h("div", { className: "dru-fb-community" },
760
+ h("div", { className: "dru-fb-community-title" }, "💬 加入企微交流群"),
761
+ h("div", { className: "dru-hint", style: { marginBottom: 8 } }, "扫码入群:安装答疑 / 使用技巧 / 版本更新 / 问题反馈"),
762
+ h("img", { className: "dru-community-qr", src: comm.qrcode, alt: "企微交流群二维码" }),
763
+ comm.wechat ? h("div", { className: "dru-hint", style: { marginTop: 8 } }, "二维码失效或群满,可加客服微信:" + comm.wechat) : null)
764
+ : null
728
765
  );
729
766
  }
730
767
 
@@ -1135,6 +1172,9 @@ window.__ModuleLoader__.load({
1135
1172
  var pwdSmsArr = useState(""); var pwdSms = pwdSmsArr[0]; var setPwdSms = pwdSmsArr[1];
1136
1173
  var pwdSmsBtnArr = useState("获取验证码"); var pwdSmsBtn = pwdSmsBtnArr[0]; var setPwdSmsBtn = pwdSmsBtnArr[1];
1137
1174
  var pwdNewArr = useState(""); var pwdNew = pwdNewArr[0]; var setPwdNew = pwdNewArr[1]; // 新密码(≥8)
1175
+ // ── 💬 交流群(「加入交流群」按钮 + 弹窗):同样放在全部既有字段之后 ──
1176
+ var communityArr = useState(null); var community = communityArr[0]; var setCommunity = communityArr[1]; // {qrcode,wechat};null=未加载
1177
+ var commOpenArr = useState(false); var commOpen = commOpenArr[0]; var setCommOpen = commOpenArr[1];
1138
1178
 
1139
1179
  var refresh = useCallback(function () {
1140
1180
  setBusy("status");
@@ -1175,6 +1215,13 @@ window.__ModuleLoader__.load({
1175
1215
  }, [st && st.config && st.config.phone]);
1176
1216
 
1177
1217
  useEffect(function () { refresh(); }, [refresh]);
1218
+
1219
+ // 交流群二维码(后台上传即可展示;未配置 → community.qrcode 为空,入口不出现)
1220
+ useEffect(function () {
1221
+ var alive = true;
1222
+ loadCommunity().then(function (d) { if (alive) setCommunity(d); });
1223
+ return function () { alive = false; };
1224
+ }, []);
1178
1225
  useEffect(function () {
1179
1226
  // 展开「忘记密码」重置表单时不用预载登录表单验证码(收起的 reset 表单用独立 pwdCap)
1180
1227
  if (st !== null && !loggedIn && mode === "saas" && authTab === "login" && !pwdOpen && !lcap) loadCaptcha("login");
@@ -2117,7 +2164,17 @@ window.__ModuleLoader__.load({
2117
2164
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "📱 远程访问:用手机或另一台电脑的浏览器,随时随地使用同一份 dsh web——人在哪都能用(免公网 IP、免内网穿透);官方托管中继,4G/5G 即用,也可自建服务。"),
2118
2165
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。"),
2119
2166
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。"),
2120
- h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。")
2167
+ h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。"),
2168
+ // 加入交流群(后台上传二维码后出现;点击弹出二维码大图便于扫码)
2169
+ community && community.qrcode
2170
+ ? h("div", { className: "dru-actions", style: { marginTop: 10 } },
2171
+ h("button", {
2172
+ type: "button",
2173
+ className: "dru-btn dru-btn-ghost",
2174
+ style: { width: "100%" },
2175
+ onClick: function () { setCommOpen(true); }
2176
+ }, "💬 加入交流群"))
2177
+ : null
2121
2178
  ]),
2122
2179
  // 版本与更新(自管理:检测新版 / 一键在线更新 / 彻底卸载)
2123
2180
  h(SelfManageCard, null),
@@ -2125,6 +2182,29 @@ window.__ModuleLoader__.load({
2125
2182
  );
2126
2183
  }
2127
2184
 
2185
+ /** 交流群弹窗(复用满意度弹窗的 dru-popup 视觉;点遮罩/关闭即收起)。 */
2186
+ function renderCommunityModal() {
2187
+ if (!commOpen || !(community && community.qrcode)) return null;
2188
+ var close = function () { setCommOpen(false); };
2189
+ return h("div", { className: "dru-popup", onMouseDown: function (e) { if (e.target === e.currentTarget) close(); } },
2190
+ h("div", { className: "dru-popup-card", role: "dialog", "aria-label": "加入企微交流群" },
2191
+ h("div", { className: "dru-popup-body" },
2192
+ h("div", { className: "dru-popup-icon" }, "💬"),
2193
+ h("div", { className: "dru-popup-title" }, "加入企微交流群"),
2194
+ h("div", { className: "dru-popup-sub" }, "扫码入群:安装答疑 / 使用技巧 / 版本更新 / 问题反馈"),
2195
+ h("img", { className: "dru-community-qr", src: community.qrcode, alt: "企微交流群二维码" }),
2196
+ community.wechat
2197
+ ? h("div", { className: "dru-hint", style: { marginTop: 10 } }, "二维码失效或群满,可加客服微信:" + community.wechat)
2198
+ : h("div", { className: "dru-hint", style: { marginTop: 10 } }, "二维码失效或群满,可到 GitHub 提 Issue 联系作者")
2199
+ ),
2200
+ h("div", { className: "dru-popup-foot" },
2201
+ h("span", null, "手机微信/企业微信扫码即可入群"),
2202
+ h("button", { type: "button", onClick: close }, "关闭")
2203
+ )
2204
+ )
2205
+ );
2206
+ }
2207
+
2128
2208
  return h("div", { className: "dru-settings-section", role: "region", "aria-label": "远程访问" },
2129
2209
  h("div", { className: "dru-settings-head" },
2130
2210
  h("span", { className: "dru-settings-icon" }, "📱"),
@@ -2135,7 +2215,8 @@ window.__ModuleLoader__.load({
2135
2215
  ),
2136
2216
  h("div", { className: "dru-settings-body" },
2137
2217
  view === "feedback" ? renderFeedback() : view === "invite" ? renderInvite() : renderHome()
2138
- )
2218
+ ),
2219
+ renderCommunityModal()
2139
2220
  );
2140
2221
  }
2141
2222
 
@@ -859,6 +859,35 @@ async function relayInviteRecords(relayDir) {
859
859
  return { records: r.body.records || [], rewards: r.body.rewards || [] };
860
860
  }
861
861
 
862
+ // ---------- 交流群二维码(运营配置,公开读取) ----------
863
+
864
+ /**
865
+ * 交流群二维码/客服微信:企业端配置经公开配置 /api/public-config 的 community 字段下发
866
+ * (community.qrcode 形如 "/qr/group-qr.png")。面板与反馈页展示「加入交流群」用。
867
+ *
868
+ * 面板跑在电脑本机 dsh web(http://127.0.0.1:3080),相对路径无法直接当 <img src>,
869
+ * 故这里按企业端公开约定拼成绝对地址:<api_url><qrcode>(与手机端 PWA 的 API_BASE + qr 一致)。
870
+ * 30s 内存缓存:面板与反馈卡都会取,避免重复打公开配置。
871
+ */
872
+ const COMMUNITY_TTL_MS = 30_000;
873
+ let communityCache = { dir: "", at: 0, data: null };
874
+
875
+ async function communityInfo(relayDir) {
876
+ if (communityCache.data && communityCache.dir === relayDir && Date.now() - communityCache.at < COMMUNITY_TTL_MS) {
877
+ return communityCache.data;
878
+ }
879
+ const cfg = loadConfig(relayDir);
880
+ const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
881
+ const r = await relayFetch(relayDir, "/api/public-config");
882
+ const b = r.ok && r.body && typeof r.body === "object" ? r.body : {};
883
+ const c = b.community && typeof b.community === "object" ? b.community : {};
884
+ const raw = String(c.qrcode || "").trim();
885
+ const qrcode = raw && !/^https?:/i.test(raw) ? api + (raw.startsWith("/") ? raw : `/${raw}`) : raw;
886
+ const data = { qrcode, wechat: String(c.wechat || "") };
887
+ communityCache = { dir: relayDir, at: Date.now(), data };
888
+ return data;
889
+ }
890
+
862
891
  // ---------- 一次性访问密钥 / 已授权设备代理(E1 企业端新增 auth-key / mobile-sessions) ----------
863
892
 
864
893
  /** 从 relay 响应里尽量提取人类可读错误信息(兼容 {error:{message}} / {error:".."} / {message} / 纯文本)。 */
@@ -1240,7 +1269,7 @@ const PLUGIN_ID = "dsh-remote-web";
1240
1269
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1241
1270
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1242
1271
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1243
- const PLUGIN_VERSION = "0.6.1-beta.1";
1272
+ const PLUGIN_VERSION = "0.6.1";
1244
1273
  const UPDATE_LOG = ".dsh-update.log";
1245
1274
  const UPDATE_MARKER = ".dsh-update-running";
1246
1275
 
@@ -1492,6 +1521,14 @@ function registerRoutes(ctx, relayDir) {
1492
1521
  });
1493
1522
  },
1494
1523
  },
1524
+ // 交流群二维码(设置面板「加入交流群」按钮 + 用户反馈页展示;未配置返回空串,UI 不展示入口)
1525
+ {
1526
+ method: "GET",
1527
+ path: "/dsh-remote/community",
1528
+ handler: async (_req, res) => {
1529
+ sendJson(res, 200, { ok: true, ...(await communityInfo(relayDir)) });
1530
+ },
1531
+ },
1495
1532
  // 一次性访问密钥(📱 远程访问卡):GET 即创建新 key,企业端 POST /api/auth-key(Bearer)
1496
1533
  {
1497
1534
  method: "GET",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.1-beta.1",
3
+ "version": "0.6.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",
@@ -0,0 +1,287 @@
1
+ // 交流群二维码(运营配置)在插件侧的两处展示回归:
2
+ // ① 设置面板:「💬 加入交流群」按钮 → 点击弹出二维码大图(未配置后台码 → 按钮不存在);
3
+ // ② 用户反馈页:底部直接展示企微交流群二维码 + 文案。
4
+ // 数据来源:node 半 GET /dsh-remote/community(读公开配置 community.qrcode 并拼绝对地址)。
5
+ // 采用真实 useEffect(依赖比较 + 提交后执行),与 login-selfheal-ui.test.mjs 同一套 vm 挂载方式。
6
+ import assert from "node:assert/strict";
7
+ import http from "node:http";
8
+ import { readFileSync } from "node:fs";
9
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import test from "node:test";
13
+ import vm from "node:vm";
14
+ import { apply } from "../lib/index.js";
15
+
16
+ const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
17
+ const QR_PATH = "/qr/group-qr.png";
18
+ const API_URL = "https://relay.test/relay-api";
19
+ const QR_ABS = API_URL + QR_PATH;
20
+
21
+ function walk(node, visit) {
22
+ if (node == null) return;
23
+ if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
24
+ if (typeof node !== "object") return;
25
+ visit(node);
26
+ for (const child of node.children || []) walk(child, visit);
27
+ }
28
+ function find(tree, predicate) {
29
+ let match;
30
+ walk(tree, (node) => { if (!match && predicate(node)) match = node; });
31
+ return match;
32
+ }
33
+ function textHas(tree, substr) {
34
+ return !!find(tree, (node) => (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
35
+ }
36
+ function nodeWithText(tree, substr) {
37
+ return find(tree, (node) => typeof node.props?.onClick === "function" && (node.children || []).some((c) => typeof c === "string" && c.includes(substr)));
38
+ }
39
+ const flush = () => new Promise((resolve) => setImmediate(resolve));
40
+
41
+ /**
42
+ * 迷你 React(真实 useEffect)+ 假定时器;fetch 覆盖 /dsh-remote/* 面板接口。
43
+ * @param {object} opts - community:/dsh-remote/community 的返回值(默认已配置二维码)
44
+ */
45
+ function loadPlugin(opts = {}) {
46
+ let moduleFactory;
47
+ const registered = new Map();
48
+ const injects = new Map();
49
+ const requests = [];
50
+
51
+ let clock = 0;
52
+ let timerSeq = 1;
53
+ const timers = new Map();
54
+ const job = (fn) => { try { fn(); } catch (e) { /* 断言会暴露问题 */ } };
55
+ const fakeSetTimeout = (fn, ms) => { const id = timerSeq++; timers.set(id, { at: clock + (Number(ms) || 0), fn, every: 0 }); return id; };
56
+ 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; };
57
+ const fakeClear = (id) => { timers.delete(id); };
58
+
59
+ // ── 迷你 React:真实 useState/useEffect + 递归渲染函数组件(FeedbackCard 等嵌套组件) ──
60
+ // 每个组件实例按「树中位置路径」持有自己的 hook 槽位(近似 React 的位置化协调),故子组件状态
61
+ // 可跨渲染保持;effects 在对应子树展开后执行(cleanup 语义与 React 一致)。
62
+ const scopes = new Map(); // key -> { hook, effectCursor, slots, pending }
63
+ const scopeStack = [];
64
+ const currentScope = () => scopeStack[scopeStack.length - 1];
65
+
66
+ const react = {
67
+ createElement(type, props, ...children) { return { type, props: props || {}, children }; },
68
+ useState(initial) {
69
+ const sc = currentScope();
70
+ const index = sc.hook++;
71
+ if (!(index in sc.state)) sc.state[index] = typeof initial === "function" ? initial() : initial;
72
+ return [sc.state[index], (value) => { sc.state[index] = typeof value === "function" ? value(sc.state[index]) : value; }];
73
+ },
74
+ useEffect(fn, deps) {
75
+ const sc = currentScope();
76
+ const index = sc.effectCursor++;
77
+ const prev = sc.effects[index];
78
+ const changed = !prev || !deps || deps.length !== prev.deps.length || deps.some((d, i) => !Object.is(d, prev.deps[i]));
79
+ if (changed) sc.pending.push({ index, fn, deps: deps ? [...deps] : deps });
80
+ },
81
+ useCallback(fn) { return fn; },
82
+ useSyncExternalStore(_subscribe, getSnapshot) { return getSnapshot(); },
83
+ };
84
+
85
+ /** 渲染一棵树:函数组件递归展开(含 effects),普通节点保留并递归子节点。 */
86
+ function renderNode(node, path) {
87
+ if (node == null) return node;
88
+ if (Array.isArray(node)) return node.map((n, i) => renderNode(n, path + "/" + i));
89
+ if (typeof node !== "object") return node;
90
+ if (typeof node.type === "function") {
91
+ const name = node.type.name || "anon";
92
+ const key = path + ":" + name;
93
+ const sc = scopes.get(key) || { hook: 0, effectCursor: 0, state: [], effects: [], pending: [] };
94
+ sc.hook = 0; sc.effectCursor = 0; sc.pending = [];
95
+ scopes.set(key, sc);
96
+ scopeStack.push(sc);
97
+ let out;
98
+ try {
99
+ out = node.type(node.props || {});
100
+ } finally {
101
+ scopeStack.pop();
102
+ }
103
+ const expanded = renderNode(out, key);
104
+ const todos = sc.pending;
105
+ sc.pending = [];
106
+ for (const t of todos) {
107
+ const prev = sc.effects[t.index];
108
+ if (prev && typeof prev.cleanup === "function") job(prev.cleanup);
109
+ const cleanup = t.fn();
110
+ sc.effects[t.index] = { deps: t.deps, cleanup: typeof cleanup === "function" ? cleanup : null };
111
+ }
112
+ return expanded;
113
+ }
114
+ return { ...node, children: (node.children || []).map((c, i) => renderNode(c, path + "/" + i)) };
115
+ }
116
+
117
+ const community = opts.community !== undefined ? opts.community : { ok: true, qrcode: QR_ABS, wechat: "dsh-remote-support" };
118
+ const status = opts.status || {
119
+ ok: true,
120
+ config: { phone: "138****0000", hasPhone: true, mode: "saas", deviceId: "dev-x" },
121
+ service: { running: true },
122
+ remoteUrl: "https://relay.test/app/",
123
+ };
124
+ const response = (s, body) => Promise.resolve({ ok: s >= 200 && s < 300, status: s, text: async () => JSON.stringify(body) });
125
+
126
+ const makeEl = () => ({
127
+ tag: "div", children: [], style: {}, className: "", attributes: {}, textContent: "",
128
+ setAttribute(k, v) { this.attributes[k] = v; },
129
+ getAttribute(k) { return this.attributes[k] ?? null; },
130
+ appendChild(c) { this.children.push(c); },
131
+ addEventListener() {}, removeEventListener() {},
132
+ });
133
+ const doc = {
134
+ hidden: false,
135
+ createElement: makeEl,
136
+ head: makeEl(),
137
+ body: makeEl(),
138
+ querySelector() { return null; },
139
+ querySelectorAll() { return []; },
140
+ getElementById() { return null; },
141
+ addEventListener() {}, removeEventListener() {},
142
+ };
143
+ class MutationObserverMock { constructor() {} observe() {} disconnect() {} }
144
+ const localStorage = {
145
+ _s: new Map(),
146
+ getItem(k) { return this._s.has(k) ? this._s.get(k) : null; },
147
+ setItem(k, v) { this._s.set(k, String(v)); },
148
+ removeItem(k) { this._s.delete(k); },
149
+ };
150
+
151
+ const sandbox = {
152
+ window: { __ModuleLoader__: { load(spec) { moduleFactory = spec.factory; } }, open() { return null; } },
153
+ document: doc,
154
+ localStorage,
155
+ MutationObserver: MutationObserverMock,
156
+ navigator: { clipboard: { writeText: async () => {} } },
157
+ setTimeout: fakeSetTimeout,
158
+ clearTimeout: fakeClear,
159
+ setInterval: fakeSetInterval,
160
+ clearInterval: fakeClear,
161
+ fetch(path, options = {}) {
162
+ requests.push({ path, method: options.method || "GET" });
163
+ if (path === "/dsh-remote/status") return response(200, status);
164
+ if (path === "/dsh-remote/community") return response(200, community);
165
+ return response(200, { ok: true });
166
+ },
167
+ Set, Symbol, Date, JSON, Math, Number, String, Object, Array, console,
168
+ };
169
+
170
+ vm.runInNewContext(SOURCE, sandbox);
171
+ const plugin = moduleFactory((name) => {
172
+ assert.equal(name, "react");
173
+ return react;
174
+ });
175
+ plugin.apply({
176
+ slots: {
177
+ inject(name, cb) { injects.set(name, cb); cb(); },
178
+ register(meta, component) { registered.set(meta.id, component); return () => {}; },
179
+ },
180
+ });
181
+
182
+ return {
183
+ requests,
184
+ /** 渲染 → 让在途请求落地 → 再渲染(返回最新树)。 */
185
+ async settle(times = 2) {
186
+ let tree = null;
187
+ for (let i = 0; i < times; i++) { tree = this.render(); await flush(); await flush(); }
188
+ return this.render();
189
+ },
190
+ render() {
191
+ const root = { type: registered.get("dsh-remote"), props: { close() {} }, children: [] };
192
+ return renderNode(root, "root");
193
+ },
194
+ };
195
+ }
196
+
197
+ test("node 半 /dsh-remote/community:读公开配置 community.qrcode 并拼成企业端绝对地址", async () => {
198
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-community-"));
199
+ const relay = http.createServer((req, res) => {
200
+ const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
201
+ if (req.url.startsWith("/api/public-config")) {
202
+ return send(200, {
203
+ service: "dsh-remote",
204
+ app_url: "https://relay.test/app/",
205
+ api_url: API_URL,
206
+ community: { qrcode: QR_PATH, wechat: "dsh-remote-support" },
207
+ });
208
+ }
209
+ send(404, { error: { code: "not_found" } });
210
+ });
211
+ await new Promise((r) => relay.listen(0, "127.0.0.1", r));
212
+ const port = relay.address().port;
213
+ await writeFile(path.join(tempDir, ".dsh-config.json"), JSON.stringify({
214
+ phone: "13800000000", password: "pw", api_url: `http://127.0.0.1:${port}`, bridge_secret: "s",
215
+ }));
216
+ const routes = new Map();
217
+ apply({ webServer: { register(rt) { routes.set(rt.path, rt.handler); return () => {}; } }, effect(reg) { return reg(); }, logger: { info() {}, warn() {} } }, { relayDir: tempDir });
218
+ const host = http.createServer((req, res) => {
219
+ const url = new URL(req.url, "http://x");
220
+ const h = routes.get(url.pathname);
221
+ (h || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
222
+ });
223
+ await new Promise((r) => host.listen(0, "127.0.0.1", r));
224
+ try {
225
+ const base = `http://127.0.0.1:${host.address().port}`;
226
+ const r = await (await fetch(`${base}/dsh-remote/community`)).json();
227
+ assert.equal(r.ok, true);
228
+ // 相对路径按「api_url + 路径」拼绝对地址(与手机端 PWA 的 API_BASE + qr 同一约定)
229
+ assert.equal(r.qrcode, `http://127.0.0.1:${port}${QR_PATH}`);
230
+ assert.equal(r.wechat, "dsh-remote-support");
231
+ } finally {
232
+ host.close();
233
+ relay.close();
234
+ await rm(tempDir, { recursive: true, force: true });
235
+ }
236
+ });
237
+
238
+ test("设置面板:后台已配置 → 「加入交流群」按钮出现;点击弹出二维码大图;关闭后收起", async () => {
239
+ const plugin = loadPlugin();
240
+ let tree = await plugin.settle();
241
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/community"), "面板应拉取交流群配置");
242
+
243
+ const btn = nodeWithText(tree, "加入交流群");
244
+ assert.ok(btn, "应出现「💬 加入交流群」按钮");
245
+ assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "未点击时不应有弹窗");
246
+
247
+ btn.props.onClick();
248
+ tree = plugin.render();
249
+ assert.ok(find(tree, (n) => n.props?.className === "dru-community-qr"), "点击后应弹出二维码");
250
+ const img = find(tree, (n) => n.props?.className === "dru-community-qr");
251
+ assert.equal(img.props.src, QR_ABS, "二维码用 node 半下发的绝对地址");
252
+ assert.equal(img.props.alt, "企微交流群二维码");
253
+ assert.ok(textHas(tree, "加入企微交流群"), "弹窗应有标题");
254
+ assert.ok(textHas(tree, "扫码入群"), "弹窗应有入群说明");
255
+
256
+ // 关闭:点「关闭」→ 弹窗消失
257
+ const closeBtn = nodeWithText(tree, "关闭");
258
+ assert.ok(closeBtn, "弹窗应有「关闭」按钮");
259
+ closeBtn.props.onClick();
260
+ tree = plugin.render();
261
+ assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "关闭后弹窗应消失");
262
+ });
263
+
264
+ test("设置面板:后台未配置二维码 → 不展示任何入口(不留空壳按钮)", async () => {
265
+ const plugin = loadPlugin({ community: { ok: true, qrcode: "", wechat: "" } });
266
+ const tree = await plugin.settle();
267
+ assert.ok(plugin.requests.some((r) => r.path === "/dsh-remote/community"), "仍应尝试拉取");
268
+ assert.ok(!nodeWithText(tree, "加入交流群"), "未配置时不展示按钮");
269
+ assert.ok(!find(tree, (n) => n.props?.className === "dru-community-qr"), "未配置时不展示二维码");
270
+ });
271
+
272
+ test("用户反馈页:底部展示企微交流群二维码(含客服微信兜底文案)", async () => {
273
+ const plugin = loadPlugin();
274
+ await plugin.settle();
275
+ // 进入「用户反馈」视图
276
+ let tree = plugin.render();
277
+ const entry = nodeWithText(tree, "用户反馈");
278
+ assert.ok(entry, "应有「用户反馈」入口");
279
+ entry.props.onClick();
280
+ tree = await plugin.settle();
281
+
282
+ assert.ok(textHas(tree, "加入企微交流群"), "反馈页应展示交流群标题");
283
+ const img = find(tree, (n) => n.props?.className === "dru-community-qr");
284
+ assert.ok(img, "反馈页应展示二维码图片");
285
+ assert.equal(img.props.src, QR_ABS);
286
+ assert.ok(textHas(tree, "dsh-remote-support"), "应展示客服微信兜底文案");
287
+ });