@mrrisega/dsh-remote 0.7.5-beta.1 → 0.7.6-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -265,11 +265,6 @@ window.__ModuleLoader__.load({
265
265
  ".dru-msg-err{color:var(--dru-danger)}",
266
266
  ".dru-msg-warn{color:var(--dru-warn)}",
267
267
  ".dru-hint{font-size:12px;color:var(--dru-fg-muted);line-height:1.6}",
268
- // 📄 购买入口的《会员服务购买协议》小字链接(购买/升级按钮旁边,不抢眼;只用既有设计令牌)
269
- ".dru-legal{font-size:12px;color:var(--dru-fg-muted);line-height:1.6;margin-top:8px}",
270
- ".dru-legal a{color:var(--dru-accent);text-decoration:underline;text-underline-offset:2px;border-radius:4px}",
271
- ".dru-legal a:hover{color:var(--dru-accent-fg)}",
272
- ".dru-legal a:focus-visible{outline:2px solid var(--dru-focus);outline-offset:2px}",
273
268
  // ── 重启 DeepSeek harness(首次安装/更新后置顶提醒 + 底部常驻按钮) ──
274
269
  ".dru-restart-alert{display:flex;gap:10px;align-items:flex-start;background:var(--dru-warn-soft);border:1px solid var(--dru-warn-border-2);border-radius:10px;padding:12px 14px;margin-bottom:12px}",
275
270
  ".dru-restart-alert-icon{font-size:18px;line-height:1.2}",
@@ -2744,103 +2739,6 @@ window.__ModuleLoader__.load({
2744
2739
  return { kind: "off", text: text };
2745
2740
  }
2746
2741
 
2747
- // ══ 🎉 加入用户交流群引导(实际使用满 2 小时后弹一次;2026-10-23 业主需求)═════
2748
- //
2749
- // 与手机 Web 端共用同一套企业端接口;桌面端浏览器半没有企业端用户 token 直连,
2750
- // 由 node 半 `/dsh-remote/community-invite/*` 用 device-login token 代调(见 lib/index.js 同名注释)。
2751
- // GET /dsh-remote/community-invite/status → { ok, show, reason, used_seconds, required_seconds, qr_url }
2752
- // POST /dsh-remote/community-invite/shown → { ok:true }(幂等)
2753
- //
2754
- // 纪律(与手机端保持同一套,别改松):
2755
- // ① **弹不弹完全由服务端 show 决定**:时长口径(relay_sessions 累计 ≥ required_seconds)只在服务端算,
2756
- // 前端一个字节都不自己算;show 非严格 true 一律不弹(fail closed)。
2757
- // ② **每桌面会话一次**:sessionStorage + 内存双标记;服务端 already_shown(show:false)是第二道保险。
2758
- // ③ **「看过即算」**:弹层一显示就 POST shown(幂等)——用户直接关页面/不点按钮也不会被反复骚扰;
2759
- // 两个按钮关闭时**再调一次**同样幂等。
2760
- // ④ **一切失败静默**:不弹、不报错、不阻塞面板主流程(连日志都不往用户面前抛)。
2761
- // ⑤ 二维码降级:qr_url 为空 / 图片加载失败 → 隐藏图片但**保留文案与按钮**(绝不弹空白二维码区)。
2762
- var CI_SEEN_KEY = "dsh-remote-community-invite-seen-v1";
2763
- var ciSeenInMemory = false; // sessionStorage 不可用(隐私模式)时的兜底:每桌面会话仍只弹一次
2764
- var ciReported = false; // 已上报 shown 的本地标记(幂等;失败允许关闭时再补一次)
2765
- /** 本桌面会话内是否已弹过(内存 + sessionStorage 双标记)。 */
2766
- function ciSeen() {
2767
- if (ciSeenInMemory) return true;
2768
- try { return sessionStorage.getItem(CI_SEEN_KEY) === "1"; } catch (e) { return false; }
2769
- }
2770
- function ciMarkSeen() {
2771
- ciSeenInMemory = true;
2772
- try { sessionStorage.setItem(CI_SEEN_KEY, "1"); } catch (e) { /* 隐私模式忽略 */ }
2773
- }
2774
- /** 二维码 URL 安全化:只接受 http(s) 绝对地址或站内绝对路径;data:/javascript: 一律不加载。 */
2775
- function ciSafeQrUrl(raw) {
2776
- var s = String(raw || "").trim();
2777
- if (!s) return "";
2778
- if (/^https?:\/\//i.test(s)) return s;
2779
- if (s.charAt(0) === "/" && s.charAt(1) !== "/") {
2780
- var origin = (typeof location !== "undefined" && location && location.origin) || "";
2781
- return origin + s;
2782
- }
2783
- return "";
2784
- }
2785
- /** POST /dsh-remote/community-invite/shown(幂等)。失败静默,并允许关闭时再补一次。 */
2786
- function ciReportShown() {
2787
- if (ciReported) return;
2788
- ciReported = true;
2789
- api("/dsh-remote/community-invite/shown", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })
2790
- .then(function (b) { if (!b || b.ok === false) ciReported = false; })
2791
- .catch(function () { ciReported = false; /* 网络失败:关闭时再试一次即可 */ });
2792
- }
2793
- /**
2794
- * 问服务端要不要弹。`alive` 由 effect 的 cleanup 控制:面板卸载后回来的响应直接丢弃。
2795
- * @returns {Promise<{qrUrl:string}|null>} 需要弹 → 返回弹层数据;不弹/失败 → null
2796
- */
2797
- function ciMaybeShow() {
2798
- if (ciSeen()) return Promise.resolve(null);
2799
- return api("/dsh-remote/community-invite/status")
2800
- .then(function (b) {
2801
- if (!b || b.ok === false || b.show !== true) return null; // 服务端说不弹就不弹
2802
- return { qrUrl: ciSafeQrUrl(b.qr_url) };
2803
- })
2804
- .catch(function () { return null; }); // 静默:接口/网络/字段异常一律不弹
2805
- }
2806
-
2807
- // ══ 📄 会员服务购买协议(购买/升级入口的《会员服务购买协议》链接)════════════════
2808
- //
2809
- // 业主要求:购买入口必须能看到协议链接(客户端 0.7.5-beta.1)。正式协议由企业端提供:
2810
- // · 优先用 node 半 `GET /dsh-remote/purchase-agreement` 下发的 URL ——
2811
- // 它读企业端 public-config 的 `agreement_url`(新字段,没有就忽略);
2812
- // · 兜底常量(企业端还没上线/读不到时也永远可点)。
2813
- //
2814
- // 纪律:
2815
- // ① **只允许 http(s) 绝对地址**:这个值最终进 `<a href>`,`javascript:`/`data:`/协议相对
2816
- // 一律丢弃回兜底(服务端字段被改坏也不能变成 XSS 面)。
2817
- // ② **失败不影响购买**:取不到就用兜底;链接打不开也不影响旁边的「升级/续费」按钮 ——
2818
- // 这里没有任何会阻塞购买流程的 await/分支。
2819
- // ③ 30 分钟模块级缓存:协议地址不会频繁变,面板反复打开不重复打本机 node 半。
2820
- var PURCHASE_AGREEMENT_FALLBACK = "https://n.risegao.cn:13443/relay-api/api/purchase-agreement";
2821
- var PURCHASE_AGREEMENT_TTL_MS = 30 * 60 * 1000;
2822
- var purchaseAgreementCache = { at: 0, url: "" };
2823
- /** 只认 http(s) 绝对地址;其余(含空串/相对路径/协议相对)一律返回空串。 */
2824
- function purchaseAgreementUrlSafe(raw) {
2825
- var s = String(raw == null ? "" : raw).trim();
2826
- return /^https?:\/\/[^\s]+$/i.test(s) ? s : "";
2827
- }
2828
- /** 取协议 URL(永不 reject;失败/非法 → 兜底常量)。 */
2829
- function loadPurchaseAgreementUrl() {
2830
- var now = Date.now();
2831
- if (purchaseAgreementCache.url && now - purchaseAgreementCache.at < PURCHASE_AGREEMENT_TTL_MS) {
2832
- return Promise.resolve(purchaseAgreementCache.url);
2833
- }
2834
- return api("/dsh-remote/purchase-agreement").then(function (b) {
2835
- var url = purchaseAgreementUrlSafe(b && b.url) || PURCHASE_AGREEMENT_FALLBACK;
2836
- purchaseAgreementCache = { at: Date.now(), url: url };
2837
- return url;
2838
- }).catch(function () {
2839
- // 失败不缓存:下次进面板还能拿到服务端后来配好的地址;这次先用兜底。
2840
- return PURCHASE_AGREEMENT_FALLBACK;
2841
- });
2842
- }
2843
-
2844
2742
  // ── 面板主体(渲染于设置页 settings.section 栏目内) ─────────────────────
2845
2743
  // 匿名装机统计的「面板打开」只上报一次/每页(node 半还会每进程去重)。
2846
2744
  var panelOpenedSent = false;
@@ -3296,27 +3194,6 @@ window.__ModuleLoader__.load({
3296
3194
  }).finally(function () { setBusy(""); });
3297
3195
  };
3298
3196
 
3299
- /**
3300
- * 📄 购买入口的《会员服务购买协议》小字链接(新标签打开;不抢眼、不阻塞购买)。
3301
- *
3302
- * 用**原生 `<a href target=_blank rel=noopener noreferrer>`** 而不是 button+window.open:
3303
- * · 语义正确(用户可复制链接/中键打开),且与 Electron 的 window.open 拦截走同一条路径;
3304
- * · 纯 HTML 无 JS,链接打不开/被拦截也不会抛错、更不会影响旁边的「升级 / 续费」按钮。
3305
- * href 永远有值:node 半取不到时用兜底常量(见 loadPurchaseAgreementUrl)。
3306
- */
3307
- function renderAgreementLink() {
3308
- return h("div", { className: "dru-legal" },
3309
- "购买即表示同意",
3310
- h("a", {
3311
- className: "dru-legal-a",
3312
- href: agreementUrl,
3313
- target: "_blank",
3314
- rel: "noopener noreferrer",
3315
- title: "《会员服务购买协议》(在新标签打开)"
3316
- }, "《会员服务购买协议》")
3317
- );
3318
- }
3319
-
3320
3197
  // 停留主视图(home)时:轮询连接状态(15s,审计降频) + 每秒刷新倒计时;隐藏暂停、回前台立即刷新
3321
3198
  useEffect(function () {
3322
3199
  if (view !== "home") return;
@@ -4083,8 +3960,6 @@ window.__ModuleLoader__.load({
4083
3960
  onClick: function () { setView("invite"); loadInvite(); }
4084
3961
  }, inviteLabel)
4085
3962
  : null),
4086
- // 📄 有购买(升级)这条路时,同一块里给协议链接(拉新-only 态不摆购买协议,避免误导)
4087
- up ? renderAgreementLink() : null,
4088
3963
  // 交换说明只在拉新可用时出现(关闭态或纯升级态不该再讲拉新)
4089
3964
  inviteOk
4090
3965
  ? h("div", { className: "dru-dual-note" },
@@ -4264,8 +4139,6 @@ window.__ModuleLoader__.load({
4264
4139
  h("span", { className: "dru-disclose-caret", "aria-hidden": "true" }, moreOpen ? "▲" : "▼")
4265
4140
  )
4266
4141
  ),
4267
- // 📄 购买入口的协议链接(紧贴主行动按钮下方;小字、不抢眼,业主要求购买入口必须可见)
4268
- renderAgreementLink(),
4269
4142
  // 🎁 邀请引导气泡:紧跟按钮行(不遮挡按钮)。点 ✕ 只做"不再提示",不再改变入口位置。
4270
4143
  renderInviteTip(),
4271
4144
  // 🧰 「更多」折叠体:带新用户 / 修改密码 / 加入交流群 + 修改密码表单
@@ -5192,36 +5065,6 @@ window.__ModuleLoader__.load({
5192
5065
  // annVer 只用于让 useSyncExternalStore 的重渲染生效,本身不参与任何计算 —— 显式丢弃,免得被读成漏用。
5193
5066
  void annVer;
5194
5067
 
5195
- // ── 🎉 交流群引导:hook 追加在**最后一个既有 hook 之后**(同上"hook 序号铁律")──────
5196
- // 触发时机 = 「面板加载完成 + 已登录 SaaS 账号」:st 由 refresh() 拉回、loggedIn 由它派生;
5197
- // 这里不写定时器、不抢首屏(数据回调后再 setState 渲染弹层)。本地模式(自建)不弹:
5198
- // node 半对这种模式直接回 ok:false/reason:local_mode(没有企业端账号体系)。
5199
- var ciArr = useState(null); var ciInvite = ciArr[0]; var setCiInvite = ciArr[1]; // {qrUrl}|null
5200
- useEffect(function () {
5201
- if (!loggedIn || mode !== "saas") return undefined;
5202
- var alive = true;
5203
- ciMaybeShow().then(function (data) {
5204
- if (!alive || !data) return;
5205
- ciMarkSeen(); // 先标记再展示:同一次桌面会话绝不重复弹
5206
- setCiInvite(data);
5207
- ciReportShown(); // 看过即算(幂等):用户不点按钮、直接关页面也不会被反复骚扰
5208
- });
5209
- return function () { alive = false; };
5210
- }, [loggedIn, mode, phoneKey]);
5211
-
5212
- // ── 📄 会员服务购买协议 URL:hook 同样追加在**最后一个既有 hook 之后** ─────────────
5213
- // 初值直接用兜底常量 → 购买入口第一帧就有可点链接(不闪、不等待)。
5214
- // 登录后才去问 node 半(企业端 public-config 可能配了正式地址);失败静默保持兜底。
5215
- var agrArr = useState(PURCHASE_AGREEMENT_FALLBACK); var agreementUrl = agrArr[0]; var setAgreementUrl = agrArr[1];
5216
- useEffect(function () {
5217
- if (!loggedIn) return undefined;
5218
- var alive = true;
5219
- loadPurchaseAgreementUrl().then(function (u) {
5220
- if (alive && u && u !== agreementUrl) setAgreementUrl(u);
5221
- });
5222
- return function () { alive = false; };
5223
- }, [loggedIn, phoneKey]);
5224
-
5225
5068
  /** 取消本次自动重启(按事件持久化:同一 pending 事件不再自动重启)。 */
5226
5069
  var cancelAutoRestart = function () {
5227
5070
  markAutoRestartCancelled(restartInfo.at || 0);
@@ -5252,42 +5095,6 @@ window.__ModuleLoader__.load({
5252
5095
  );
5253
5096
  }
5254
5097
 
5255
- /**
5256
- * 🎉 交流群引导弹层(使用满 2 小时后由服务端 show 触发;与上面的「加入交流群」二维码弹窗
5257
- * 共用 dru-popup 视觉,不引入新设计语言)。
5258
- * 关闭纪律:两个按钮 + 点遮罩都走同一个 close —— 关闭时**调用**幂等上报 shown 再收起
5259
- * (展示时已成功报过则本地标记跳过网络;那次失败时这里正好补报)。
5260
- * 展示时已经报过一次,所以即使这次上报失败(网络抖动)也已经是"看过即算"。
5261
- */
5262
- function renderCommunityInviteModal() {
5263
- if (!ciInvite) return null;
5264
- var close = function () { ciReportShown(); setCiInvite(null); };
5265
- var qrUrl = ciInvite.qrUrl;
5266
- return h("div", { className: "dru-popup", "data-dru-theme": theme, onMouseDown: function (e) { if (e.target === e.currentTarget) close(); } },
5267
- h("div", { className: "dru-popup-card", role: "dialog", "aria-label": "加入用户交流群" },
5268
- h("div", { className: "dru-popup-body" },
5269
- h("div", { className: "dru-popup-title" }, "🎉 加入用户交流群"),
5270
- h("div", { className: "dru-popup-sub" }, "在群里可以交流使用心得、提想法和建议,遇到问题也更容易找到我们"),
5271
- qrUrl
5272
- ? h("img", {
5273
- className: "dru-community-qr",
5274
- src: qrUrl,
5275
- alt: "用户交流群二维码",
5276
- // 二维码加载失败:只藏图片,文案与按钮照旧(绝不因为一张图挂了就把引导整块丢掉)
5277
- onError: function (e) { try { if (e && e.target) e.target.style.display = "none"; } catch (err) { /* 忽略 */ } }
5278
- })
5279
- : null,
5280
- h("div", { className: "dru-hint", style: { marginTop: 10 } },
5281
- qrUrl ? "用手机微信/企业微信扫码即可入群。" : "二维码暂不可用,可稍后再看(不影响正常使用)。")
5282
- ),
5283
- h("div", { className: "dru-popup-actions", style: { padding: "0 22px 20px" } },
5284
- h("button", { type: "button", className: "dru-btn dru-btn-primary", onClick: close }, "加入交流群"),
5285
- h("button", { type: "button", className: "dru-btn dru-btn-ghost", onClick: close }, "稍后再看")
5286
- )
5287
- )
5288
- );
5289
- }
5290
-
5291
5098
  // ── 📣 公告(0.6.18):面板入口卡 / 详情弹窗 / 远程访问卡里的提醒入口 ──────────
5292
5099
  //
5293
5100
  // 数据来源只有一个:模块级的 annState(由 apply() 里的一次性拉取填充,见 annPull)。
@@ -5518,8 +5325,6 @@ window.__ModuleLoader__.load({
5518
5325
  }, busy === "restart" ? "重启中…" : "🔄 重启 dsh web")
5519
5326
  ),
5520
5327
  renderCommunityModal(),
5521
- // 🎉 使用满 2 小时的交流群引导(服务端 show 决定的自动弹层;同一层,点遮罩即收起)
5522
- renderCommunityInviteModal(),
5523
5328
  // 📣 公告详情弹窗(与交流群弹窗同一层:都挂在面板根上,点遮罩即收起)
5524
5329
  renderAnnouncementDetail()
5525
5330
  );
@@ -3417,51 +3417,6 @@ async function communityInfo(relayDir) {
3417
3417
  return data;
3418
3418
  }
3419
3419
 
3420
- // ---------- 📄 会员服务购买协议 URL(购买入口的《会员服务购买协议》链接) ----------
3421
-
3422
- /**
3423
- * 购买入口必须带《会员服务购买协议》链接(业主要求,2026-10-23 客户端 0.7.5-beta.1)。
3424
- *
3425
- * URL 来源优先级:
3426
- * 1) 企业端 `public-config` 的 `agreement_url`(**新字段**;企业端还没上线时没有 → 忽略);
3427
- * 2) 固定兜底 `DEFAULT_PURCHASE_AGREEMENT_URL`(正式协议由企业端提供,未就绪也能点开)。
3428
- *
3429
- * 安全:只允许 **http(s) 绝对地址**;`javascript:` / `data:` / 协议相对(`//host`)/ 相对路径
3430
- * 一律回兜底 —— 这个值最终会进 `<a href>`,绝不能吃进可执行协议(与 qr_url 同一套纪律)。
3431
- *
3432
- * 软着陆:public-config 不可达 / 非对象 / 字段缺失全部回兜底并 `ok:true`。
3433
- * 协议链接**永远不该**成为购买流程的故障点(打不开也不影响旁边的「升级 / 续费」按钮)。
3434
- */
3435
- export const DEFAULT_PURCHASE_AGREEMENT_URL = "https://n.risegao.cn:13443/relay-api/api/purchase-agreement";
3436
-
3437
- /** 从 public-config 解析协议 URL(纯函数,便于单测:优先级 + 安全过滤 + 降级一次看完)。 */
3438
- export function purchaseAgreementUrlFrom(publicConfig) {
3439
- const raw = publicConfig && typeof publicConfig === "object"
3440
- ? String(publicConfig.agreement_url == null ? "" : publicConfig.agreement_url).trim()
3441
- : "";
3442
- if (/^https?:\/\/[^\s]+$/i.test(raw)) return raw;
3443
- return DEFAULT_PURCHASE_AGREEMENT_URL;
3444
- }
3445
-
3446
- /** GET /dsh-remote/purchase-agreement 的实现:读公开配置 → 安全过滤 → 兜底。 */
3447
- async function purchaseAgreementInfo(relayDir) {
3448
- let body = null;
3449
- try {
3450
- const r = await relayFetch(relayDir, "/api/public-config");
3451
- body = r && r.ok && r.body && typeof r.body === "object" ? r.body : null;
3452
- } catch {
3453
- body = null; // relayFetch 本身不抛;这里只是防御(协议链接不能把面板打挂)
3454
- }
3455
- const raw = body && typeof body === "object" ? String(body.agreement_url == null ? "" : body.agreement_url).trim() : "";
3456
- const url = purchaseAgreementUrlFrom(body);
3457
- return {
3458
- ok: true,
3459
- url,
3460
- // 便于排障:这个 URL 是服务端配的,还是走兜底(面板不展示,只出现在响应里)
3461
- source: url !== DEFAULT_PURCHASE_AGREEMENT_URL ? "public_config" : (raw ? "public_config_invalid" : "fallback"),
3462
- };
3463
- }
3464
-
3465
3420
  // ---------- 一次性访问密钥 / 已授权设备代理(E1 企业端新增 auth-key / mobile-sessions) ----------
3466
3421
 
3467
3422
  /** 从 relay 响应里尽量提取人类可读错误信息(兼容 {error:{message}} / {error:".."} / {message} / 纯文本)。 */
@@ -3975,84 +3930,6 @@ async function proxyAnnouncements(relayDir, req, res, pathname) {
3975
3930
  }
3976
3931
  }
3977
3932
 
3978
- // ---------- 🎉 企微交流群引导代理(实际使用满 2 小时弹一次;2026-10-23 业主需求) ----------
3979
- /**
3980
- * 桌面端插件面板的「加入企业微信群」引导数据来自企业端**用户级**接口:
3981
- * GET /api/community-invite/status → { ok, show, reason, used_seconds, required_seconds, qr_url }
3982
- * POST /api/community-invite/shown → { ok:true }(幂等)
3983
- * 浏览器半没有企业端用户 token(它跑在 dsh web 同源页里,只认本机 node 半的路由),
3984
- * 所以由 node 半用 **device-login token** 代调(企业端 device token 的 sub 就是 user,
3985
- * 与 /api/me、/api/mobile-sessions 走的是同一条身份链)。
3986
- *
3987
- * 三条纪律:
3988
- * ① **不透传浏览器带来的 Authorization**:这是纯用户级接口,身份只应该是"本机登录账号"。
3989
- * 面板浏览器手里没有任何企业端用户 token,透传只会让"谁的身份"变得含混
3990
- * (与 announcements 的 user/install 二选一不同,那个接口两种维度都合法)。
3991
- * ② **判定与记账全在服务端**:show=false(未满 2h / 已弹过 / 后台没配二维码)原样透传,
3992
- * 前端绝不自己算时长;`shown` 幂等,展示时与关闭时各调一次是安全超集。
3993
- * ③ **失败一律软着陆**:未登录/自建(没有企业端账号体系)→ 200 + ok:false + reason
3994
- * (面板静默、不弹不报错);上游 4xx/5xx → 透传状态码 + 可读 error(排障能看见,
3995
- * 面板依旧静默);网络不可达 → 502 JSON,绝不抛回 webServer。
3996
- */
3997
- async function proxyCommunityInvite(relayDir, req, res, pathname) {
3998
- const method = String(req.method || "GET").toUpperCase();
3999
- const suffix = pathname.replace(/^\/dsh-remote\/community-invite/, "") || "/";
4000
- const isStatus = method === "GET" && suffix === "/status";
4001
- const isShown = method === "POST" && suffix === "/shown";
4002
- if (!isStatus && !isShown) {
4003
- return sendJson(res, 404, { ok: false, show: false, reason: "unknown_route", error: "unknown community-invite route" });
4004
- }
4005
- const cfg = loadConfig(relayDir);
4006
- // 自建部署:没有企业端账号体系,交流群引导不适用 —— 不发请求、也不让面板弹
4007
- if (cfg.local_key) return sendJson(res, 200, { ok: false, show: false, reason: "local_mode" });
4008
- // 没配账号:面板登录前也可能误调 —— 静默拒绝(前端本来就只在 loggedIn 后调)
4009
- if (!(cfg.phone && cfg.password)) return sendJson(res, 200, { ok: false, show: false, reason: "not_logged_in" });
4010
- const token = await relayToken(relayDir).catch(() => "");
4011
- if (!token) return sendJson(res, 200, { ok: false, show: false, reason: "not_logged_in" });
4012
-
4013
- const upstreamPath = isShown ? "/api/community-invite/shown" : "/api/community-invite/status";
4014
- const init = { method: isShown ? "POST" : "GET", headers: { authorization: `Bearer ${token}` } };
4015
- if (isShown) {
4016
- init.headers["content-type"] = "application/json";
4017
- init.body = "{}";
4018
- }
4019
- const r = await relayFetch(relayDir, upstreamPath, init);
4020
- const d = flattenRelayBody(r);
4021
- if (!r.ok) {
4022
- // 透传状态码 + 可读错误:面板仍然静默(它只看 ok/show),但排障时能直接看到 401/404/500
4023
- return sendJson(res, (r && r.status) || 502, {
4024
- ok: false,
4025
- show: false,
4026
- reason: isShown ? "report_failed" : "status_failed",
4027
- error: relayErrorMessage(r),
4028
- relayStatus: (r && r.status) || 0
4029
- });
4030
- }
4031
- if (isShown) {
4032
- // 幂等上报:不关心上游回什么(只认 ok:false 才报失败),面板两个按钮都会调
4033
- return sendJson(res, 200, { ok: d.ok !== false, relayStatus: r.status || 200 });
4034
- }
4035
- // status:只挑面板需要的字段(不整包透传,避免把内部字段带进浏览器;字段容错见契约)
4036
- // qr_url 归一化:企业端后台配的可能是站内相对路径(如 /qr/group-qr.png),
4037
- // 与 /dsh-remote/community 同一约定拼成**企业端绝对地址**再下发 —— 面板跑在 dsh web 源上,
4038
- // 相对路径会被浏览器解析到 127.0.0.1,图片必然挂。其它协议(data:/javascript:/协议相对)一律丢弃,
4039
- // 前端按"二维码不可用"降级(保留文案与按钮)。
4040
- const apiBase = String(cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
4041
- const rawQr = typeof d.qr_url === "string" ? d.qr_url.trim() : "";
4042
- let qrUrl = "";
4043
- if (/^https?:\/\//i.test(rawQr)) qrUrl = rawQr;
4044
- else if (rawQr.charAt(0) === "/" && rawQr.charAt(1) !== "/") qrUrl = apiBase + rawQr;
4045
- return sendJson(res, 200, {
4046
- ok: d.ok !== false,
4047
- show: d.show === true, // fail closed:非严格 true 一律不弹
4048
- reason: typeof d.reason === "string" ? d.reason : "",
4049
- used_seconds: Number(d.used_seconds) || 0,
4050
- required_seconds: Number(d.required_seconds) || 0,
4051
- qr_url: qrUrl,
4052
- relayStatus: r.status || 200
4053
- });
4054
- }
4055
-
4056
3933
  // ---------- 自管理:版本 / 在线更新 / 彻底卸载(面板内“版本与更新”卡片) ----------
4057
3934
 
4058
3935
  /** 插件 id / 包名(2026-09 由 dsh-remote-ui 更名)。 */
@@ -7408,15 +7285,6 @@ function registerRoutes(ctx, relayDir) {
7408
7285
  sendJson(res, 200, { ok: true, ...(await communityInfo(relayDir)) });
7409
7286
  },
7410
7287
  },
7411
- // 📄 会员服务购买协议 URL(购买入口的《会员服务购买协议》链接;public-config.agreement_url → 兜底)
7412
- // 永远 200 ok:true + 一个可点 URL:协议链接不得成为购买流程的故障点。
7413
- {
7414
- method: "GET",
7415
- path: "/dsh-remote/purchase-agreement",
7416
- handler: async (_req, res) => {
7417
- sendJson(res, 200, await purchaseAgreementInfo(relayDir));
7418
- },
7419
- },
7420
7288
  // 一次性访问密钥(📱 远程访问卡):GET 即创建新 key,企业端 POST /api/auth-key(Bearer)
7421
7289
  {
7422
7290
  method: "GET",
@@ -7786,19 +7654,6 @@ function registerRoutes(ctx, relayDir) {
7786
7654
  await proxyAnnouncements(relayDir, req, res, url.pathname);
7787
7655
  },
7788
7656
  },
7789
- // 🎉 企微交流群引导(桌面端面板使用满 2h 后弹一次;与手机 Web 共用同一套企业端接口):
7790
- // GET /dsh-remote/community-invite/status → 上游 GET {api}/api/community-invite/status
7791
- // POST /dsh-remote/community-invite/shown → 上游 POST {api}/api/community-invite/shown(幂等)
7792
- // 与 feedback/announcements 同一形态(prefix + handler 内判别),以后加子接口不用动路由表。
7793
- {
7794
- method: "ALL",
7795
- path: "/dsh-remote/community-invite",
7796
- prefix: true,
7797
- handler: async (req, res) => {
7798
- const url = new URL(req.url ?? "/", "http://x");
7799
- await proxyCommunityInvite(relayDir, req, res, url.pathname);
7800
- },
7801
- },
7802
7657
  ];
7803
7658
 
7804
7659
  const disposers = [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.5-beta.1",
3
+ "version": "0.7.6-beta.1",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1,206 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * 隧道发送侧「控制帧快速通道」回归(2026-10-23)。
4
- *
5
- * 生产事故(手机连不上/断线主因链 #1):
6
- * 手机首屏的 /plugins/?? 聚合包最大 16.33MB。旧发送器把它切成 200KB 分块后在**同一个
7
- * tick 里连续 ws.send**:内核发送缓冲一满,剩余分块全堆进 ws 的内存队列(弱网下十几秒
8
- * 的量)。这期间 router 发来的 ws-open,bridge 只需几毫秒就能回,但**回包被排在 16MB
9
- * 分块后面** → router 20s 超时(ws_open_timeout),页面一直转圈。
10
- *
11
- * 本文件用假 socket + 可控调度器锁三条契约:
12
- * ① 大帧传输中插入的 ws-open 回包,在**下一轮**就被发出(不会排到整帧传完之后);
13
- * ② 发送缓冲高水位以上时停手,控制帧前面最多只有「高水位 + 一片」待发字节;
14
- * ③ 分块信封仍能被接收侧 makeFrameReceiver 完整还原(协议未被改坏)。
15
- */
16
- import assert from "node:assert/strict";
17
- import test from "node:test";
18
-
19
- import {
20
- CONTROL_FRAME_TYPES,
21
- TUNNEL_BULK_HWM_DEFAULT,
22
- isControlFrame,
23
- makeFrameReceiver,
24
- makePrioritySender,
25
- tunnelBulkOptionsFromEnv
26
- } from "../dsh-bridge.mjs";
27
-
28
- /** 假 socket:记录每一帧,可选"慢速排空"模型(bufferedAmount 缓慢下降)。 */
29
- function fakeSocket({ drainPerTick = Infinity } = {}) {
30
- const frames = [];
31
- let pending = 0;
32
- return {
33
- frames,
34
- rawSend(s) {
35
- frames.push(s);
36
- pending += s.length;
37
- },
38
- bufferedAmount: () => pending,
39
- /** 模拟一次内核把缓冲排掉一些。 */
40
- drain(bytes = drainPerTick) {
41
- pending = Math.max(0, pending - bytes);
42
- return pending;
43
- },
44
- get pending() { return pending; }
45
- };
46
- }
47
-
48
- function makeScheduler() {
49
- const queue = [];
50
- return {
51
- schedule: (fn) => { queue.push({ fn, kind: "immediate" }); },
52
- setTimer: (fn) => { queue.push({ fn, kind: "timer" }); },
53
- /** 跑一个排队的回调(模拟一个事件循环轮次)。 */
54
- tick() {
55
- const job = queue.shift();
56
- if (!job) return false;
57
- job.fn();
58
- return true;
59
- },
60
- runAll(limit = 5000) {
61
- let n = 0;
62
- while (n < limit && this.tick()) n += 1;
63
- return n;
64
- },
65
- pending: () => queue.length
66
- };
67
- }
68
-
69
- /** 从已发帧里还原出每个分块信封所属的逻辑帧(用真实接收侧实现,顺便守住协议)。 */
70
- function reassemble(frames) {
71
- const out = [];
72
- const recv = makeFrameReceiver();
73
- for (const raw of frames) recv(raw, (frame) => out.push(frame));
74
- return out;
75
- }
76
-
77
- test("契约①:大响应传输中插入的 ws-open 回包在下一轮就发出(不排到整帧之后)", () => {
78
- const sock = fakeSocket();
79
- const sched = makeScheduler();
80
- const sender = makePrioritySender({
81
- rawSend: sock.rawSend,
82
- bufferedAmount: sock.bufferedAmount,
83
- bulkHighWater: TUNNEL_BULK_HWM_DEFAULT,
84
- chunkSize: 64 * 1024,
85
- schedule: sched.schedule,
86
- setTimer: sched.setTimer
87
- });
88
-
89
- /** 快速链路:每轮把内核缓冲排空(同时证明"高水位以上才停手"不影响正常链路)。 */
90
- const tickDrain = () => { sock.drain(2 * 1024 * 1024); return sched.tick(); };
91
-
92
- // 一条 4MB 的大响应(≈64 个分块)
93
- const big = { id: "big", type: "http", status: 200, body: "A".repeat(4 * 1024 * 1024), bodyBase64: true };
94
- assert.equal(sender.send(big), true);
95
- assert.equal(sock.frames.length, 1, "第一片当场发出");
96
- const chunksTotal = Math.ceil(JSON.stringify(big).length / (64 * 1024));
97
- assert.ok(chunksTotal >= 60, `应被切成很多片(实际 ${chunksTotal})`);
98
-
99
- // 让事件循环转几轮(还没传完)
100
- for (let i = 0; i < 5; i += 1) assert.equal(tickDrain(), true);
101
- const sentBefore = sock.frames.length;
102
- assert.ok(sentBefore >= 4 && sentBefore < chunksTotal, `此时应只发了一小部分(${sentBefore}/${chunksTotal})`);
103
-
104
- // router 的 ws-open 到达 → bridge 处理 → 回包入控制车道
105
- sender.send({ id: "w1", type: "ws-open", ok: true, fast: true });
106
- const sentAfterControl = sock.frames.length;
107
- const pos = sock.frames.findIndex((s) => s.includes('"id":"w1"') && s.includes('"ok":true'));
108
- assert.ok(pos >= 0, "控制帧必须已经发出");
109
- assert.equal(pos, sentAfterControl - 1, "控制帧应当**立刻**发出(不等当前轮次的剩余分块)");
110
- assert.ok(pos < sentBefore + 3, `控制帧位置 ${pos} 必须远早于大帧剩余分块(已发 ${sentBefore})`);
111
-
112
- // 继续跑完
113
- for (let i = 0; i < 5000 && sched.pending() > 0; i += 1) tickDrain();
114
- assert.equal(sock.frames.length, chunksTotal + 1);
115
- const frames = reassemble(sock.frames);
116
- assert.equal(frames.length, 2, "大帧 + ws-open 两帧都应能被接收侧还原");
117
- const bigFrame = frames.find((f) => f.id === "big");
118
- const ctrlFrame = frames.find((f) => f.id === "w1");
119
- assert.ok(bigFrame && ctrlFrame, "两条逻辑帧都要还原出来");
120
- assert.equal(ctrlFrame.ok, true);
121
- assert.equal(bigFrame.body.length, big.body.length, "分块重组后正文逐字节一致");
122
- });
123
-
124
- test("契约②:发送缓冲高水位以上时停手(控制帧不会被埋进几十秒的队列)", () => {
125
- // 慢链路:每发一片,缓冲就积压 100KB(> 64KB 高水位)
126
- const sock = fakeSocket();
127
- const sched = makeScheduler();
128
- const sender = makePrioritySender({
129
- rawSend: sock.rawSend,
130
- bufferedAmount: sock.bufferedAmount,
131
- bulkHighWater: 64 * 1024,
132
- chunkSize: 64 * 1024,
133
- schedule: sched.schedule,
134
- setTimer: sched.setTimer
135
- });
136
-
137
- const big = { id: "big2", type: "http", body: "B".repeat(1024 * 1024) };
138
- sender.send(big);
139
- // 第一片发出后 bufferedAmount = 片长(>64KB) → 下一轮必须停手等排空
140
- sched.tick();
141
- const afterFirst = sock.frames.length;
142
- sched.tick();
143
- assert.equal(sock.frames.length, afterFirst, "超过高水位时不得继续灌分块(只能等排空)");
144
- assert.ok(sender.stats().waits >= 1, "应记录过背压等待");
145
-
146
- // 此时控制帧到达:它仍然必须立刻出去(控制车道不受批量车道背压影响)
147
- sock.drain(10 * 1024 * 1024); // 缓冲排空
148
- const beforeControl = sock.frames.length;
149
- sender.send({ id: "w2", type: "ws-open", ok: false, code: 1008, reason: "test" });
150
- const pos = sock.frames.findIndex((s) => s.includes('"id":"w2"'));
151
- assert.equal(pos, beforeControl, "排空后第一时间发控制帧(之前的背压不能把它排到分块后面)");
152
- });
153
-
154
- test("契约③:大帧传输中小帧优先(交互不被整帧传输拖住);close() 丢弃未发内容", () => {
155
- const sock = fakeSocket();
156
- const sched = makeScheduler();
157
- const sender = makePrioritySender({
158
- rawSend: sock.rawSend,
159
- bufferedAmount: sock.bufferedAmount,
160
- schedule: sched.schedule,
161
- setTimer: sched.setTimer
162
- });
163
- // 大帧刚发出第一片(之后每轮只发一片)
164
- sender.send({ id: "big3", type: "http", body: "C".repeat(512 * 1024) });
165
- // 传输中插入两条小帧(交互消息 / SSE 块):必须插在大帧的下一片之前,而不是排在整帧之后
166
- sender.send({ id: "i1", type: "ws-msg", data: "hello" });
167
- sender.send({ id: "i2", type: "http-chunk", body: "chunk" });
168
- const ids = sock.frames.map((s) => {
169
- const o = JSON.parse(s);
170
- return o.__chunk ? "__chunk" : o.id;
171
- });
172
- assert.deepEqual(ids.slice(0, 3), ["__chunk", "i1", "i2"], "小帧必须插在大帧的下一片之前");
173
- assert.equal(sender.stats().bigChunks, 1, "此时大帧只发了 1 片");
174
-
175
- // close 后:未发内容丢弃,后续 send 返回 false(重连后不能把旧分片发到新连接上)
176
- const sock2 = fakeSocket();
177
- const sched2 = makeScheduler();
178
- const sender2 = makePrioritySender({
179
- rawSend: sock2.rawSend,
180
- bufferedAmount: sock2.bufferedAmount,
181
- schedule: sched2.schedule,
182
- setTimer: sched2.setTimer
183
- });
184
- sender2.send({ id: "big4", type: "http", body: "D".repeat(512 * 1024) });
185
- const before = sock2.frames.length;
186
- sender2.close();
187
- assert.equal(sender2.send({ id: "after", type: "ws-msg", data: "x" }), false, "close 后不再受理");
188
- sched2.runAll();
189
- assert.equal(sock2.frames.length, before, "close 后不得再发剩余分片");
190
- });
191
-
192
- test("控制帧类型/环境变量解析(纯函数)", () => {
193
- for (const t of ["ws-open", "ws-close", "http-abort", "http-end"]) {
194
- assert.equal(isControlFrame({ id: 1, type: t }), true, `${t} 必须走控制车道`);
195
- }
196
- for (const t of ["http", "ws-msg", "http-chunk"]) {
197
- assert.equal(isControlFrame({ id: 1, type: t }), false, `${t} 不该抢控制车道`);
198
- }
199
- assert.equal(isControlFrame(null), false);
200
- assert.deepEqual(CONTROL_FRAME_TYPES.slice(0, 2), ["ws-open", "ws-close"]);
201
-
202
- assert.equal(tunnelBulkOptionsFromEnv({}).bulkHighWater, TUNNEL_BULK_HWM_DEFAULT);
203
- assert.equal(tunnelBulkOptionsFromEnv({ DSH_BRIDGE_TUNNEL_BULK_HWM_KB: "32" }).bulkHighWater, 32 * 1024);
204
- assert.equal(tunnelBulkOptionsFromEnv({ DSH_BRIDGE_TUNNEL_CHUNK_KB: "16" }).chunkSize, 16 * 1024);
205
- assert.equal(tunnelBulkOptionsFromEnv({ DSH_BRIDGE_TUNNEL_CHUNK_KB: "abc" }).chunkSize, 64 * 1024, "非法值回默认");
206
- });