dsh-remote-web 0.7.9 → 0.7.10-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.
@@ -114,6 +114,7 @@ import {
114
114
  // ⚠️ 微信流量只走「腾讯 ilink ↔ 本机」,**不经我们的 relay**;DSH 事件走本机回环。
115
115
  // 总开关 DSH_WECHAT=0(默认开启);未绑定时只起控制面,不起长轮询。
116
116
  import { createWeChatRuntime } from "./wechat-runtime.mjs";
117
+ import { createPushReporter } from "./push-reporter.mjs";
117
118
 
118
119
  // ---------- 强制直连:清除代理环境变量 ----------
119
120
  // 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
@@ -288,7 +289,44 @@ function stampBridgeHeartbeat() {
288
289
  const now = Date.now();
289
290
  if (now - lastStateHeartbeatAt < STATE_HEARTBEAT_MIN_MS) return;
290
291
  lastStateHeartbeatAt = now;
291
- persistBridgeState({ pid: process.pid, heartbeat_at: now });
292
+ persistBridgeState({
293
+ pid: process.pid,
294
+ heartbeat_at: now,
295
+ // 【0.7.10 / W1-T3】E2EE 死会话拒绝计数(见 noteE2eeWsReject 的说明)。
296
+ // 跟着心跳一起落盘:不额外写盘、不加定时器;旧面板/旧插件读到多余字段一律忽略。
297
+ e2ee_ws_reject_locked: e2eeWsRejectStats.locked,
298
+ e2ee_ws_reject_disabled: e2eeWsRejectStats.disabled,
299
+ e2ee_ws_reject_at: e2eeWsRejectStats.lastAt
300
+ });
301
+ }
302
+
303
+ /**
304
+ * 【0.7.10 / W1-T3】E2EE 会话拒绝计数 —— **只做可观测,不改任何协议行为**。
305
+ *
306
+ * 生产复盘(2026-10):WS 升级被拒里 71.8% 是 `e2ee_session_locked`(死会话),涉 163 用户;
307
+ * 0.7.3+ 已闭环(手机侧清内存会话 + 连续 3 次失败作废交接单 + 每页一次受控 reload;
308
+ * bridge 侧回 `retryable:true, action:"rehandshake"` 让手机自动重握手)。但「死会话占比
309
+ * 降到多少」此前只在 nginx/router 侧可见,客户端自己答不出来。
310
+ *
311
+ * 这里把本进程的拒绝次数记下来、随心跳写进状态文件(面板「复制诊断信息」会带上一行),
312
+ * 于是支持包/诊断信息本身就能回答「这台机器还在被死会话打吗」。
313
+ * 落盘节流 ≥10s:死循环期一台设备一天能拒近万次,绝不能每拒一次就写盘。
314
+ */
315
+ const e2eeWsRejectStats = { locked: 0, disabled: 0, lastAt: 0 };
316
+ const E2EE_REJECT_FLUSH_MIN_MS = 10_000;
317
+ let lastE2eeRejectFlushAt = 0;
318
+ function noteE2eeWsReject(kind) {
319
+ if (kind === "disabled") e2eeWsRejectStats.disabled += 1;
320
+ else e2eeWsRejectStats.locked += 1;
321
+ const now = Date.now();
322
+ e2eeWsRejectStats.lastAt = now;
323
+ if (now - lastE2eeRejectFlushAt < E2EE_REJECT_FLUSH_MIN_MS) return;
324
+ lastE2eeRejectFlushAt = now;
325
+ persistBridgeState({
326
+ e2ee_ws_reject_locked: e2eeWsRejectStats.locked,
327
+ e2ee_ws_reject_disabled: e2eeWsRejectStats.disabled,
328
+ e2ee_ws_reject_at: e2eeWsRejectStats.lastAt
329
+ });
292
330
  }
293
331
 
294
332
  // ---------- 安装来源 / 版本(服务端设备行统计约定) ----------
@@ -1714,6 +1752,7 @@ export async function handleWsOpen(dchOrSend, frame) {
1714
1752
  const tagged = parseWsE2eeParams(path);
1715
1753
  if (tagged) {
1716
1754
  if (!e2ee.enabled) {
1755
+ noteE2eeWsReject("disabled"); // 可观测计数(见 noteE2eeWsReject;不改行为)
1717
1756
  sendWsOpenReject(send, id, 1008, "e2ee_disabled:bridge 未启用端到端加密(请用明文连接)");
1718
1757
  return;
1719
1758
  }
@@ -1721,6 +1760,7 @@ export async function handleWsOpen(dchOrSend, frame) {
1721
1760
  if (!esess || !esess.verified) {
1722
1761
  // 会话可能刚被重启/被 LRU 挤掉:把「可自动重握手」写成机器可读信号,
1723
1762
  // 手机侧据此自动重建会话,而不是弹「请重新解锁」让用户输密码(生产 3964 次/设备)。
1763
+ noteE2eeWsReject("locked");
1724
1764
  console.error(`[bridge] e2ee ws 拒绝(会话未解锁/未验证): ${path}`);
1725
1765
  sendWsOpenReject(send, id, 1008, "e2ee_session_locked:加密会话已失效,需要重新握手", {
1726
1766
  retryable: true,
@@ -2801,6 +2841,45 @@ function currentUpstreamForChannel() {
2801
2841
  return UPSTREAM;
2802
2842
  }
2803
2843
 
2844
+ /**
2845
+ * 推送上报(2026-11-05):把"任务完成/需要拍板/出错"上报给 SaaS,由服务端规则引擎决定是否推送。
2846
+ *
2847
+ * 为什么放在这里有独立开关:它是**可选增强**,任何失败都不得影响远程访问主链路。
2848
+ * 真正的启动条件由 push-reporter 自己问服务端(`/api/push/public-key` 的 enabled+subscribed),
2849
+ * 本地只有 `DSH_PUSH_REPORT=0` 能强制关闭。
2850
+ */
2851
+ let pushReporter = null;
2852
+ let PUSH_TOKEN = "";
2853
+
2854
+ function startPushReporter() {
2855
+ if (String(process.env.DSH_PUSH_REPORT || "") === "0") {
2856
+ console.log("[push] 上报已禁用(DSH_PUSH_REPORT=0)");
2857
+ return;
2858
+ }
2859
+ try {
2860
+ if (pushReporter) return;
2861
+ pushReporter = createPushReporter({
2862
+ apiBase: API_BASE,
2863
+ deviceId: DEVICE_ID,
2864
+ tokenProvider: () => PUSH_TOKEN,
2865
+ upstream: () => currentUpstreamForChannel(),
2866
+ cookie: harnessCookieOf,
2867
+ detail: String(process.env.DSH_PUSH_DETAIL || "") === "1",
2868
+ log: (level, message) => {
2869
+ try {
2870
+ if (level === "warn") console.warn(message);
2871
+ else console.log(message);
2872
+ } catch { /* 忽略 */ }
2873
+ }
2874
+ });
2875
+ // 不 await:启动探测失败绝不影响隧道
2876
+ void pushReporter.start().catch(() => {});
2877
+ } catch (e) {
2878
+ // 可选增强失败不得冒泡
2879
+ console.warn(`[push] 上报器初始化失败(已忽略):${e && e.message ? e.message : e}`);
2880
+ }
2881
+ }
2882
+
2804
2883
  function startWeChat() {
2805
2884
  if (WECHAT_DISABLED) {
2806
2885
  console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
@@ -2983,6 +3062,7 @@ async function runTunnel() {
2983
3062
  // (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
2984
3063
  // 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
2985
3064
  WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
3065
+ PUSH_TOKEN = token;
2986
3066
  // 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)。
2987
3067
  // `pid` 一并写入 = **bridge 自证身份**:插件可以用「state.pid 活着 + heartbeat_at 新鲜」
2988
3068
  // 判断桥是否真的在跑,不必依赖按镜像名(node.exe)扫描 —— 桌面端宿主拉起的进程名不是 node.exe。
@@ -2998,6 +3078,8 @@ async function runTunnel() {
2998
3078
  }
2999
3079
  // 隧道已发起(或已进入锁定等待)后再起微信:主链路优先,微信永不阻塞远程访问
3000
3080
  startWeChat();
3081
+ // 同理:推送上报也是可选增强,最后起、失败静默
3082
+ startPushReporter();
3001
3083
  setTimeout(() => {
3002
3084
  console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
3003
3085
  }, 1000);
@@ -3043,6 +3125,8 @@ async function main() {
3043
3125
  console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
3044
3126
  // 退出前把 E2EE 会话库刷一次(滑动 TTL 的 usedAt 要落盘;崩溃也能恢复到最多 60s 前)
3045
3127
  try { e2ee.flushStore(); } catch { /* 已内部兜底 */ }
3128
+ // 退出前尽力把待上报的事件发出去(最多等 1s,绝不阻塞退出)
3129
+ try { if (pushReporter) await pushReporter.stop(); } catch { /* 已内部兜底 */ }
3046
3130
  await stopWeChat();
3047
3131
  process.exit(0);
3048
3132
  };
@@ -3188,6 +3272,9 @@ export function __wsSessionState(id) {
3188
3272
  return { opened: s.opened === true, pending: s.pending ? s.pending.length : 0, cancelled: s.cancelled === true, failed: s.failed === true };
3189
3273
  }
3190
3274
 
3275
+ // test hook:E2EE 拒绝计数快照(W1-T3 可观测字段;状态文件里同名 e2ee_ws_reject_* 字段的来源)
3276
+ export function __e2eeRejectStatsForTest() { return { ...e2eeWsRejectStats }; }
3277
+
3191
3278
  // 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
3192
3279
  const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
3193
3280
  if (isMain) {