dsh-remote-web 0.7.6 → 0.7.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.6",
3
+ "version": "0.7.9",
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",
@@ -92,6 +92,8 @@ import { gzip as gzipCb } from "node:zlib";
92
92
  import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
93
93
  import { maybeInjectMirrorDiag } from "./mirror-diag.mjs";
94
94
  import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./upstream-discovery.mjs";
95
+ // 渠道归因码(安装器 --ref → .dsh-config.json 的 ref_code)的归一化规则,与安装器/插件同一份契约。
96
+ import { normalizeRefCode } from "./src/channel-ref.mjs";
95
97
  // 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
96
98
  import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
97
99
  // E2EE(端到端加密)客户端基建(Phase-2):MK 派生/会话密钥/信封/握手/开关
@@ -251,7 +253,14 @@ function clearBindError() {
251
253
  * bridge 运行状态文件:供 dsh web 插件面板判定「设备是否已在中继注册成功」(连接阶段 online)。
252
254
  * 面板用它把「bridge 进程在跑」与「设备已注册、手机端真的能用」区分开——后者才是用户关心的状态,
253
255
  * 只按进程存活判断会谎报可用。
254
- * { device_id, started_at, account_bound_at, tunnel_registered_at, phase, last_error? }
256
+ * { pid, heartbeat_at, device_id, started_at, account_bound_at, tunnel_registered_at, phase, last_error? }
257
+ *
258
+ * `pid` / `heartbeat_at` 是 **bridge 自证存活**的第二判据(2026-10 `bridge_wedged` 事故):
259
+ * · 进程启动时写一次 `pid: process.pid`;
260
+ * · 心跳拍里节流刷新 `heartbeat_at`(≥10s 一次,失败静默)。
261
+ * 插件侧可以据此用「state.pid 活着 + heartbeat 新鲜」判断桥是否在跑,**不必**依赖
262
+ * 按镜像名(node.exe)扫描 —— Electron 宿主拉起的进程名压根不是 node.exe,扫描会漏。
263
+ *
255
264
  * 每次进程启动都 reset 一次:上一轮进程的成功记录不能代表当前这一轮。
256
265
  */
257
266
  const BRIDGE_STATE_FILE = path.join(path.dirname(CONFIG_PATH), ".dsh-bridge-state.json");
@@ -266,6 +275,22 @@ function persistBridgeState(patch, opts = {}) {
266
275
  } catch { /* 非关键:面板侧还有日志/账号兜底判据 */ }
267
276
  }
268
277
 
278
+ /**
279
+ * 状态文件心跳:把「我这一秒确实还活着」写进 `.dsh-bridge-state.json`(节流 ≥10s)。
280
+ *
281
+ * 为什么要节流:心跳拍的默认间隔是 20s(TUNNEL_HEARTBEAT_MS),但 RTT 好时自适应会缩短;
282
+ * 每拍都整文件重写既没必要(面板轮询 2~3s,10s 粒度足够"新鲜")又增加与读侧撞车的机会。
283
+ * 失败一律静默:状态文件只是**附加**判据,写不进去不得影响隧道(旧行为保持)。
284
+ */
285
+ const STATE_HEARTBEAT_MIN_MS = 10_000;
286
+ let lastStateHeartbeatAt = 0;
287
+ function stampBridgeHeartbeat() {
288
+ const now = Date.now();
289
+ if (now - lastStateHeartbeatAt < STATE_HEARTBEAT_MIN_MS) return;
290
+ lastStateHeartbeatAt = now;
291
+ persistBridgeState({ pid: process.pid, heartbeat_at: now });
292
+ }
293
+
269
294
  // ---------- 安装来源 / 版本(服务端设备行统计约定) ----------
270
295
  // DSH_BRIDGE_INSTALL_SOURCE 由一键安装器(dsh-setup.mjs)注入 = npx;未设置表示这台电脑走的是
271
296
  // 「插件市场装面板插件 + 插件自愈补装运行环境」那条路径 = plugin_market;白名单外一律 unknown。
@@ -309,6 +334,15 @@ function loadLocalConfig() {
309
334
  try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
310
335
  }
311
336
 
337
+ /**
338
+ * 渠道归因码:安装器 `--ref` 写进配置的 `ref_code`(3~6 位数字)。
339
+ * 每次读配置(不缓存):配置由安装器/插件随时重写,缓存会让「装完没带、后补 --ref」
340
+ * 这种升级路径永远不上报。缺失/非法 → 空串,调用方据此**整个省略**该字段。
341
+ */
342
+ function refCodeFromConfig() {
343
+ try { return normalizeRefCode(loadLocalConfig().ref_code); } catch { return ""; }
344
+ }
345
+
312
346
  /**
313
347
  * 把文件权限收紧到「只有本人可读写」。
314
348
  *
@@ -1981,6 +2015,15 @@ let consecutive4000 = 0;
1981
2015
  export const DUPLICATE_CLOSE_CODE = 4000;
1982
2016
  /** bridge 判定"本机已有另一个同 deviceId 实例"后的退出码(必须与 dsh-setup.mjs 的常量一致)。 */
1983
2017
  export const DUPLICATE_INSTANCE_EXIT_CODE = 4;
2018
+ /**
2019
+ * 设备登记遇到**防滥用硬上限**(`device_bind_cap`)后的专用退出码(必须与 dsh-setup.mjs 的常量一致)。
2020
+ *
2021
+ * 为什么要跟普通失败(exit 1)分开:普通失败是瞬时的(网络/服务端抖动),watcher 10s 重拉是对的;
2022
+ * 而"已绑定条数 ≥ 硬上限"是**永久性**失败——用户不去手机端解绑就不会好。旧实现两者都走 exit(1),
2023
+ * watcher 于是每 10s 拉起一次、每 10s 撞一次 409,日志刷屏还把真正的第一现场淹没(契约 §6.4 P0)。
2024
+ * 用专用退出码让 watcher 做指数退避(10s→1min→5min→15min,封顶 30min),用户解绑后自动恢复。
2025
+ */
2026
+ export const DEVICE_BIND_CAP_EXIT_CODE = 5;
1984
2027
  export const DUPLICATE_RECONNECT_BASE_MS = 30_000;
1985
2028
  export const MAX_RECONNECT_DELAY_MS = 10_000;
1986
2029
  export const RECONNECT_JITTER_MS = 3_000;
@@ -2136,10 +2179,18 @@ function tunnelEndpoint(raw) {
2136
2179
  }
2137
2180
  }
2138
2181
 
2139
- /** 账号设备表登记:手机端 /api/devices 才能看到本设备(带稳定 device_id)。自建模式无账号体系,跳过。 */
2182
+ /** 账号设备表登记:手机端 /api/devices 才能看到本设备(带稳定 device_id)。自建模式无账号体系,跳过。
2183
+ *
2184
+ * @returns {Promise<{status:"ok"|"locked", locked?:object}>}
2185
+ * `locked` = 服务端按套餐额度判定本机不可用(旧版 409 `device_limit_exceeded` 或新契约 `device_locked`)。
2186
+ * 此时**不要**再去连隧道,由调用方进入解锁轮询(见 enterLockedWait)。
2187
+ */
2140
2188
  async function registerDeviceInAccount(token) {
2141
- if (process.env.DSH_BRIDGE_LOCAL_KEY) return; // 自建模式:设备列表来自 router 实时 WS 注册表
2189
+ if (process.env.DSH_BRIDGE_LOCAL_KEY) return { status: "ok", local: true }; // 自建模式:设备列表来自 router 实时 WS 注册表
2142
2190
  const pubKey = resolveDevicePubKey();
2191
+ // 渠道归因码(可选):安装器 `--ref 368` 写进配置,这里随设备登记原样带给服务端。
2192
+ // 契约:字段可选、3~6 位数字;没有/非法 → **整个省略**,绝不发空串或猜的值。
2193
+ const refCode = refCodeFromConfig();
2143
2194
  try {
2144
2195
  const r = await fetch(API_BASE + "/api/devices", {
2145
2196
  method: "POST",
@@ -2156,6 +2207,7 @@ async function registerDeviceInAccount(token) {
2156
2207
  // v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
2157
2208
  // 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
2158
2209
  harness_version: HARNESS_VERSION,
2210
+ ...(refCode ? { ref_code: refCode } : {}),
2159
2211
  host_os: process.platform,
2160
2212
  host_arch: process.arch
2161
2213
  })
@@ -2165,29 +2217,265 @@ async function registerDeviceInAccount(token) {
2165
2217
  clearBindError();
2166
2218
  persistBridgeState({ device_id: DEVICE_ID, account_bound_at: Date.now(), phase: "bound" });
2167
2219
  console.log(`[bridge] ✅ 设备已登记到账号: ${DEVICE_ID}`);
2168
- return;
2220
+ // v18 契约 §4.2:201 响应带 locked —— 但**不据此跳过隧道连接**(旧中继不做准入检查,
2221
+ // 直接跳过会让老中继下的用户被误锁)。锁定以 router 的 tunnel-register-err 为准。
2222
+ if (d.locked === true) {
2223
+ console.log("[bridge] 服务端标记本机已超额(等 router 准入结论)…");
2224
+ }
2225
+ return { status: "ok" };
2169
2226
  }
2170
2227
  if (r.status === 409) {
2171
2228
  const code = d.error?.code || "";
2172
- if (code === "device_limit_exceeded") {
2173
- const msg = d.error?.message || "当前套餐最多绑定 1 台设备";
2174
- persistBindError({ code, message: msg });
2175
- console.error(`[bridge] 设备数已达上限: ${msg}`);
2176
- console.error(" 同机重装会自动顶替旧设备;仍失败请到手机端设备管理解绑旧设备后重启(免费每月可解绑 3 次)。");
2177
- } else {
2178
- persistBindError({ code, status: 409, message: d.error?.message || "绑定冲突" });
2179
- console.error(`[bridge] 设备 ${DEVICE_ID} 绑定失败(${code || 409}): ${d.error?.message || "未知错误"}`);
2229
+ const message = d.error?.message || "绑定冲突";
2230
+ const policy = d.policy && typeof d.policy === "object" ? d.policy : {};
2231
+ // 详情一律带 device_id;plan/limit/upgrade_to 有才写(没有就不硬编造,面板按缺省文案兜底)
2232
+ const details = {
2233
+ device_id: DEVICE_ID,
2234
+ ...(d.error?.plan || policy.plan ? { plan: d.error?.plan || policy.plan } : {}),
2235
+ ...(Number.isFinite(Number(d.error?.limit ?? policy.limit)) ? { limit: Number(d.error?.limit ?? policy.limit) } : {}),
2236
+ ...(d.error?.upgrade_to || policy.upgrade_to ? { upgrade_to: d.error?.upgrade_to || policy.upgrade_to } : {})
2237
+ };
2238
+ if (code === "device_bind_cap") {
2239
+ // 防滥用硬上限:永久性失败 → 专用退出码,watcher 按指数退避重拉(契约 §6.3/§6.4)
2240
+ persistBindError({ code, status: 409, message, ...details });
2241
+ console.error(`[bridge] 设备绑定已达上限:${message}`);
2242
+ console.error("[bridge] 这是按套餐设定的防滥用硬上限(不是网络问题),本机不会反复空撞接口。");
2243
+ console.error("[bridge] 请在手机端「管理电脑」解绑不再使用的设备;守护会按指数退避自动重试,解绑后自动恢复。");
2244
+ process.exit(DEVICE_BIND_CAP_EXIT_CODE);
2180
2245
  }
2246
+ if (code === "device_limit_exceeded" || code === "device_locked") {
2247
+ // 旧版服务端 409 device_limit_exceeded / 新契约 device_locked:**不再 exit(1) 空转**,
2248
+ // 交给解锁轮询等待(向后兼容,契约 §6.2)。旧服务端不返回 limit,从文案里尽力解析。
2249
+ const limitFromMsg = (() => {
2250
+ const m = /(?:最多|上限|超过)[^\d]{0,6}(\d+)\s*台/.exec(String(message));
2251
+ return m ? Number(m[1]) : null;
2252
+ })();
2253
+ const locked = normalizeLockedInfo({
2254
+ message,
2255
+ plan: d.error?.plan || policy.plan,
2256
+ limit: d.error?.limit ?? policy.limit ?? limitFromMsg,
2257
+ upgrade_to: d.error?.upgrade_to || policy.upgrade_to || "pro_max"
2258
+ });
2259
+ console.error(`[bridge] 本机设备额度已满(${code}):${message}`);
2260
+ return { status: "locked", locked };
2261
+ }
2262
+ persistBindError({ code: code || "device_conflict", status: 409, message, ...details });
2263
+ console.error(`[bridge] 设备 ${DEVICE_ID} 绑定失败(${code || 409}): ${message}`);
2181
2264
  process.exit(1);
2182
2265
  }
2183
- persistBindError({ code: d.error?.code || `http_${r.status}`, status: r.status, message: d.error?.message || "设备登记失败" });
2266
+ persistBindError({ code: d.error?.code || `http_${r.status}`, status: r.status, message: d.error?.message || "设备登记失败", device_id: DEVICE_ID });
2184
2267
  console.warn(`[bridge] 设备登记失败(${r.status}): ${d.error?.message || "未知错误"}(手机端设备列表可能看不到本设备)`);
2185
2268
  } catch (e) {
2186
- persistBindError({ code: "api_unreachable", message: `无法连接账号 API: ${e.message}` });
2269
+ persistBindError({ code: "api_unreachable", message: `无法连接账号 API: ${e.message}`, device_id: DEVICE_ID });
2187
2270
  console.warn(`[bridge] 无法连接账号 API ${API_BASE}: ${e.message}(手机端设备列表可能看不到本设备)`);
2188
2271
  }
2272
+ return { status: "failed" };
2273
+ }
2274
+
2275
+ /* ══════════════════════════════════════════════════════════════════════════════
2276
+ * 「设备被超额锁定」等待态(契约 docs/device-overquota-lock-contract.md §6)
2277
+ *
2278
+ * 触发:router 回 `tunnel-register-err code=device_locked`(服务端按套餐额度判定本机不可用),
2279
+ * 或旧版服务端 409 `device_limit_exceeded`。
2280
+ * 行为(**不退出进程**——旧实现 exit(1),watcher 每 10s 重拉、每 10s 撞一次,用户侧表现为
2281
+ * "设备列表里能看到这台电脑但永远连不上",日志还被刷屏):
2282
+ * ① 关掉当前隧道,写状态 phase=locked + locked 详情(面板据此显示升级引导);
2283
+ * ② 每 30s `GET /api/devices`(Bearer token)找本机 device_id:
2284
+ * · locked === false → 清 locked 状态、立即 connectTunnel 重连;
2285
+ * · 行不存在(用户在手机端解绑了这台)→ registerDeviceInAccount 后重连;
2286
+ * · 仍 locked → 继续等。
2287
+ * ③ **轮询期间不重连隧道、不发心跳**(本模块只在 ws 已关闭后启动轮询;close 处理器
2288
+ * 看到 lockedWait.active 会直接 return,不排重连)。
2289
+ * ══════════════════════════════════════════════════════════════════════════════ */
2290
+
2291
+ /** 解锁轮询间隔(契约 §6.1:30s)。测试/诊断可用 DSH_BRIDGE_LOCK_POLL_MS 压缩。 */
2292
+ export const DEVICE_LOCKED_POLL_MS = 30_000;
2293
+ function lockedPollIntervalMs() {
2294
+ const n = Number(process.env.DSH_BRIDGE_LOCK_POLL_MS);
2295
+ return Number.isFinite(n) && n >= 20 ? Math.floor(n) : DEVICE_LOCKED_POLL_MS;
2296
+ }
2297
+
2298
+ /** 进程内等待态(同一时刻只允许一份轮询;测试钩子可读)。 */
2299
+ const lockedWait = { active: false, timer: null, token: "", detail: null, polls: 0, warnedUnknown: false };
2300
+
2301
+ /** 整理 locked 详情:字段可能来自 frame(message/plan/limit/upgrade_to)、409 error 或 policy。 */
2302
+ export function normalizeLockedInfo(src = {}, fallback = {}) {
2303
+ const pick = (k) => (src[k] !== undefined && src[k] !== null && src[k] !== "" ? src[k] : fallback[k]);
2304
+ const limitRaw = pick("limit");
2305
+ const limit = Number.isFinite(Number(limitRaw)) && Number(limitRaw) >= 0 ? Number(limitRaw) : null;
2306
+ return {
2307
+ message: String(pick("message") || ""),
2308
+ plan: String(pick("plan") || ""),
2309
+ limit,
2310
+ upgrade_to: String(pick("upgrade_to") || pick("upgradeTo") || "pro_max")
2311
+ };
2312
+ }
2313
+
2314
+ /** 面向用户的可操作中文日志(这就是他们唯一能看到的解释:为什么手机连不上)。 */
2315
+ function logLockedHelp(info) {
2316
+ const detail = normalizeLockedInfo(info);
2317
+ const limitTxt = detail.limit !== null && detail.limit >= 0 ? `${detail.limit} 台设备` : "套餐额度内的设备";
2318
+ console.error("[bridge] 🔒 本机已被锁定(超出当前套餐的设备额度),暂时无法连上中继。");
2319
+ if (detail.message) console.error(`[bridge] 服务端说明:${detail.message}`);
2320
+ console.error(`[bridge] 当前套餐(${detail.plan || "free"})可同时使用 ${limitTxt};本机排在额度之外。`);
2321
+ console.error("[bridge] 处理办法(二选一,任一步完成后本机会自动恢复,不需要重启):");
2322
+ console.error("[bridge] ① 打开手机端「管理电脑」,解绑不再使用的设备(先绑定的优先可用)→ 本机自动解锁;");
2323
+ console.error(`[bridge] ② 升级 ${detail.upgrade_to === "pro_max" ? "Pro Max 会员" : String(detail.upgrade_to).toUpperCase()} → 可多台设备同时使用。`);
2324
+ console.error(`[bridge] 已进入自动等待:每 ${Math.round(lockedPollIntervalMs() / 1000)} 秒检查一次解锁状态,期间不重连、不占用中继。`);
2325
+ }
2326
+
2327
+ /** 只有 `{status:"ok"}` 的登记才算登记成功(其余状态留给调用方决定下一步)。 */
2328
+ function registrationOk(reg) { return Boolean(reg && reg.status === "ok"); }
2329
+
2330
+ /**
2331
+ * 进入(或刷新)「设备被锁定」等待态。幂等:已在等待时只更新详情,不重复打日志、不重复排轮询。
2332
+ * @param {string} token 当前可用 JWT(轮询解锁用)
2333
+ * @param {object} info {message, plan, limit, upgrade_to}
2334
+ */
2335
+ function enterLockedWait(token, info) {
2336
+ const detail = normalizeLockedInfo(info || {});
2337
+ if (token) lockedWait.token = token;
2338
+ lockedWait.detail = detail;
2339
+ persistBridgeState({ device_id: DEVICE_ID, phase: "locked", locked: detail, last_error: null });
2340
+ // .bind-error.json 同步一份:面板即使读不到状态文件(旧版 bridge 状态 / 文件被删)也能识别
2341
+ persistBindError({
2342
+ code: "device_locked",
2343
+ message: detail.message || "本机已被锁定(超出当前套餐设备额度)",
2344
+ device_id: DEVICE_ID,
2345
+ ...(detail.plan ? { plan: detail.plan } : {}),
2346
+ ...(detail.limit !== null ? { limit: detail.limit } : {}),
2347
+ ...(detail.upgrade_to ? { upgrade_to: detail.upgrade_to } : {})
2348
+ });
2349
+ if (lockedWait.active) return; // 已在等待:只刷新详情
2350
+ lockedWait.active = true;
2351
+ lockedWait.polls = 0;
2352
+ lockedWait.warnedUnknown = false;
2353
+ logLockedHelp(detail);
2354
+ scheduleLockedPoll();
2355
+ }
2356
+
2357
+ /** 排下一拍解锁检查(不 unref:bridge 必须靠这个定时器保持进程存活)。 */
2358
+ function scheduleLockedPoll() {
2359
+ if (!lockedWait.active) return;
2360
+ if (lockedWait.timer) { try { clearTimeout(lockedWait.timer); } catch { /* 已触发 */ } }
2361
+ lockedWait.timer = setTimeout(() => {
2362
+ lockedWait.timer = null;
2363
+ lockedPollTick().catch((e) => {
2364
+ console.log(`[bridge] 解锁检查异常:${e.message};稍后再试`);
2365
+ scheduleLockedPoll();
2366
+ });
2367
+ }, lockedPollIntervalMs());
2368
+ }
2369
+
2370
+ /**
2371
+ * 等待期内换一张 JWT。**刻意不复用 resolveToken()**:它在失败时会 process.exit(1)/(3),
2372
+ * 而等待态的要求是"进程活着把轮询跑完",不能让一次刷新失败把 bridge 打死。
2373
+ */
2374
+ async function refreshTokenForLockedPoll() {
2375
+ if (!PHONE || !PASSWORD) return "";
2376
+ try {
2377
+ const r = await fetch(API_BASE + "/api/device-login", {
2378
+ method: "POST",
2379
+ headers: { "content-type": "application/json", ...(process.env.DSH_BRIDGE_SECRET ? { "x-dsh-bridge-secret": process.env.DSH_BRIDGE_SECRET } : {}) },
2380
+ body: JSON.stringify({ phone: PHONE, email: PHONE, password: PASSWORD })
2381
+ });
2382
+ const d = await r.json().catch(() => ({}));
2383
+ return r.status === 200 && d.token ? d.token : "";
2384
+ } catch { return ""; }
2385
+ }
2386
+
2387
+ /** 解锁(或设备行已不存在且重新登记成功)→ 退出等待态并立即重连隧道。 */
2388
+ function resumeTunnelFromLock(token) {
2389
+ if (!lockedWait.active) return;
2390
+ lockedWait.active = false;
2391
+ if (lockedWait.timer) { try { clearTimeout(lockedWait.timer); } catch { /* 已触发 */ } }
2392
+ lockedWait.timer = null;
2393
+ lockedWait.detail = null;
2394
+ lockedWait.polls = 0;
2395
+ lockedWait.warnedUnknown = false;
2396
+ clearBindError();
2397
+ persistBridgeState({ device_id: DEVICE_ID, phase: "connecting", locked: null, last_error: null });
2398
+ tunnelRetry = 0;
2399
+ consecutive4000 = 0;
2400
+ console.log("[bridge] 🔓 锁定已解除,重新连接中继…");
2401
+ connectTunnel(token || lockedWait.token);
2402
+ }
2403
+
2404
+ /**
2405
+ * 一拍解锁检查:`GET /api/devices` → 判断本机是否恢复可用。
2406
+ * 任何失败都不退出进程(网络/服务端抖动只是"这一拍没结论"),排下一拍继续。
2407
+ */
2408
+ async function lockedPollTick() {
2409
+ if (!lockedWait.active) return;
2410
+ lockedWait.polls += 1;
2411
+ const intervalMs = lockedPollIntervalMs();
2412
+ const retryLater = (why) => {
2413
+ if (why) console.log(`[bridge] 解锁检查:${why};${Math.round(intervalMs / 1000)} 秒后再试`);
2414
+ scheduleLockedPoll();
2415
+ };
2416
+ let status = 0;
2417
+ let body = null;
2418
+ try {
2419
+ const r = await fetch(API_BASE + "/api/devices", { headers: { authorization: `Bearer ${lockedWait.token}` } });
2420
+ status = r.status;
2421
+ body = await r.json().catch(() => null);
2422
+ } catch (e) {
2423
+ return retryLater(`无法连接账号 API(${e.message})`);
2424
+ }
2425
+ if (!lockedWait.active) return;
2426
+ if (status === 401) {
2427
+ const fresh = await refreshTokenForLockedPoll();
2428
+ if (fresh) {
2429
+ lockedWait.token = fresh;
2430
+ console.log("[bridge] 等待期间登录凭证已刷新,继续检查解锁状态。");
2431
+ return retryLater("");
2432
+ }
2433
+ return retryLater("登录凭证已失效(401),请在面板重新登录后重启后台服务");
2434
+ }
2435
+ if (status !== 200 || !body || typeof body !== "object") {
2436
+ return retryLater(`服务端返回 ${status || "无响应"}`);
2437
+ }
2438
+ const rows = Array.isArray(body.devices) ? body.devices : [];
2439
+ const policy = body.policy && typeof body.policy === "object" ? body.policy : {};
2440
+ if (policy.plan || Number.isFinite(Number(policy.limit))) {
2441
+ // 解绑/升级后 policy 会实时变化:同步刷新等待详情(面板的「可同时使用 N 台」跟着变)
2442
+ const merged = normalizeLockedInfo({}, {
2443
+ ...lockedWait.detail,
2444
+ ...(policy.plan ? { plan: policy.plan } : {}),
2445
+ ...(Number.isFinite(Number(policy.limit)) ? { limit: Number(policy.limit) } : {}),
2446
+ ...(policy.upgrade_to ? { upgrade_to: policy.upgrade_to } : {})
2447
+ });
2448
+ if (JSON.stringify(merged) !== JSON.stringify(lockedWait.detail)) {
2449
+ lockedWait.detail = merged;
2450
+ persistBridgeState({ device_id: DEVICE_ID, phase: "locked", locked: merged, last_error: null });
2451
+ }
2452
+ }
2453
+ const row = rows.find((d) => d && String(d.id || d.device_id || "") === DEVICE_ID);
2454
+ if (!row) {
2455
+ console.log("[bridge] 解锁检查:本机设备行已不存在(可能已在手机端解绑)→ 重新登记后重连…");
2456
+ const reg = await registerDeviceInAccount(lockedWait.token);
2457
+ if (registrationOk(reg)) { resumeTunnelFromLock(lockedWait.token); return; }
2458
+ if (reg && reg.status === "locked") { enterLockedWait(lockedWait.token, reg.locked); }
2459
+ return scheduleLockedPoll();
2460
+ }
2461
+ if (row.locked === false) {
2462
+ console.log("[bridge] ✅ 解锁检查:本机已恢复可用 → 立即重连中继…");
2463
+ resumeTunnelFromLock(lockedWait.token);
2464
+ return;
2465
+ }
2466
+ if (row.locked !== true && !lockedWait.warnedUnknown) {
2467
+ lockedWait.warnedUnknown = true;
2468
+ console.log("[bridge] 解锁检查:服务端未返回本机锁定状态(可能是旧版服务端),继续等待;长期不恢复请升级后台运行环境。");
2469
+ }
2470
+ scheduleLockedPoll();
2471
+ }
2472
+
2473
+ /** 测试钩子:读取等待态(不改变行为);用例据此断言「不退出 + 在轮询 + 解锁后重连」。 */
2474
+ export function __lockedWaitStateForTest() {
2475
+ return { active: lockedWait.active, polls: lockedWait.polls, detail: lockedWait.detail ? { ...lockedWait.detail } : null, hasTimer: Boolean(lockedWait.timer) };
2189
2476
  }
2190
2477
 
2478
+
2191
2479
  function connectTunnel(token) {
2192
2480
  const endpoint = tunnelEndpoint(TUNNEL_URL);
2193
2481
  // maxPayload 必须显式给:ws 客户端默认 100MiB,而 router→bridge 的单帧不分块,
@@ -2220,6 +2508,10 @@ function connectTunnel(token) {
2220
2508
  // 事件循环延迟逐拍自适应(见 adaptiveHeartbeat 长注释)。
2221
2509
  const beat = () => {
2222
2510
  heartbeat = null;
2511
+ // 自证存活:每一拍(节流 ≥10s)刷新状态文件里的 pid/heartbeat_at —— 插件用它做
2512
+ // 「state.pid 活着 + heartbeat 新鲜」的第二判据,不依赖按镜像名扫描进程。
2513
+ // 放在判定之前:即使这一拍即将判死重连,只要进程还活着,状态文件就该是新鲜的。
2514
+ stampBridgeHeartbeat();
2223
2515
  const plan = adaptiveHeartbeat({
2224
2516
  pongReceived,
2225
2517
  missedPongs,
@@ -2269,6 +2561,13 @@ function connectTunnel(token) {
2269
2561
  return;
2270
2562
  }
2271
2563
  if (frame?.type === "tunnel-register-err") {
2564
+ if (frame.code === "device_locked") {
2565
+ // 契约 §6.1:服务端按套餐额度判定本机不可用。**不退出进程**,关掉这条隧道后进入
2566
+ // 解锁轮询(30s 一次 GET /api/devices);期间不重连、不发心跳(close 处理器见等待态直接 return)。
2567
+ enterLockedWait(token, { message: frame.message, plan: frame.plan, limit: frame.limit, upgrade_to: frame.upgrade_to });
2568
+ try { ws.close(4009, "device locked"); } catch { /* 对端可能已经关了 */ }
2569
+ return;
2570
+ }
2272
2571
  persistBridgeState({ phase: "error", last_error: { code: frame.code || "register_err", message: frame.message || "router 拒绝注册" } });
2273
2572
  console.error(`[bridge] router 拒绝注册: ${frame.code} ${frame.message || ""}`);
2274
2573
  return;
@@ -2284,6 +2583,12 @@ function connectTunnel(token) {
2284
2583
  sender.close(); // 丢掉未发分片,别让重连后的新连接接到旧响应的剩余部分
2285
2584
  console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
2286
2585
  closeAllWsSessions();
2586
+ // 锁定等待中:**不排重连**(契约 §6.1 要求轮询期间不重连隧道)。解锁后由
2587
+ // resumeTunnelFromLock 统一重连一次,避免这里每 2s 再撞一次必然被拒的注册。
2588
+ if (lockedWait.active) {
2589
+ console.log("[bridge] 当前处于「设备被锁定」等待态,暂不重连中继(解锁后自动恢复)。");
2590
+ return;
2591
+ }
2287
2592
  // C2 + C5:4000(被同 deviceId 的新连接顶掉)走长退避/退出;其余走 2/4/8/10s + 抖动。
2288
2593
  const plan = reconnectPlan({ code, retry: tunnelRetry, consecutive4000 });
2289
2594
  consecutive4000 = plan.consecutive4000;
@@ -2678,12 +2983,20 @@ async function runTunnel() {
2678
2983
  // (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
2679
2984
  // 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
2680
2985
  WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
2681
- // 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)
2682
- persistBridgeState({ device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
2986
+ // 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)。
2987
+ // `pid` 一并写入 = **bridge 自证身份**:插件可以用「state.pid 活着 + heartbeat_at 新鲜」
2988
+ // 判断桥是否真的在跑,不必依赖按镜像名(node.exe)扫描 —— 桌面端宿主拉起的进程名不是 node.exe。
2989
+ persistBridgeState({ pid: process.pid, heartbeat_at: Date.now(), device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
2683
2990
  await initE2ee(token);
2684
- await registerDeviceInAccount(token);
2685
- connectTunnel(token);
2686
- // 隧道已发起后再起微信:主链路优先,微信永不阻塞远程访问
2991
+ const reg = await registerDeviceInAccount(token);
2992
+ if (reg && reg.status === "locked") {
2993
+ // 旧版服务端 409 device_limit_exceeded(新契约走 router 的 device_locked,见 connectTunnel):
2994
+ // 不再 process.exit(1) 空转,改为 30s 轮询等待解锁/解绑(契约 §6.2,向后兼容)
2995
+ enterLockedWait(token, reg.locked);
2996
+ } else {
2997
+ connectTunnel(token);
2998
+ }
2999
+ // 隧道已发起(或已进入锁定等待)后再起微信:主链路优先,微信永不阻塞远程访问
2687
3000
  startWeChat();
2688
3001
  setTimeout(() => {
2689
3002
  console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 渠道归因码(ref)的**唯一**解析规则。
3
+ *
4
+ * 契约见 dsh-relay-enterprise/docs/channel-referral-contract.md §1/§5:
5
+ * 安装器 `npx @mrrisega/dsh-remote --ref bili`
6
+ * → 写 ~/.dsh-remote/.dsh-config.json 的 `ref_code`(字符串)
7
+ * → bridge `POST /api/devices`、插件 `POST /api/install-report` 都带 `ref_code`
8
+ * → 面板注册 `POST /api/register` 带 `channel_code`。
9
+ * 服务端字段是**可选**的:非法值一律忽略,不报错、不阻断。
10
+ *
11
+ * 这里只做一件事:把任意输入归一化成「3~6 位数字的字符串」或空串。
12
+ * ⚠️ 绝不抛错、绝不因为归因码非法而让安装/上报失败 —— 归因是锦上添花,
13
+ * 主链路(装得上、连得上)永远优先。
14
+ */
15
+
16
+ /**
17
+ * 合法形态:3~12 位字母或数字(归一化后小写),与 channel_codes.code 契约一致。
18
+ *
19
+ * 业主 2026-10 口径:纯数字「368」太像渠道码,改用小写短词(bili/dsh/xhs8)更隐蔽。
20
+ * 老数字码仍是合法子集;`BILI` / `BILI` / ` Bili ` 统一归一化为 `bili`(与后端同口径)。
21
+ */
22
+ export const REF_CODE_RE = /^[a-z0-9]{3,12}$/;
23
+
24
+ /**
25
+ * 归一化 ref 码:合法返回字符串(保留前导零),否则返回 ""(调用方据此省略字段)。
26
+ * 兼容配置文件里被手改成数字的情况(`"ref_code": 368`)。
27
+ */
28
+ export function normalizeRefCode(raw) {
29
+ let v = "";
30
+ if (typeof raw === "number") {
31
+ if (!Number.isInteger(raw) || raw < 0) return "";
32
+ v = String(raw);
33
+ } else if (typeof raw === "string") {
34
+ v = raw;
35
+ } else {
36
+ return "";
37
+ }
38
+ // NFKC:全角字母/数字 → 半角;再 trim + 小写(与后端 channel.js normalizeCode 一致)
39
+ const normalized = v.normalize("NFKC").trim().toLowerCase();
40
+ return REF_CODE_RE.test(normalized) ? normalized : "";
41
+ }
42
+
43
+ /**
44
+ * 本次安装要写入的 ref_code:命令行 `--ref <code>` 优先,其次环境变量 `DSH_REF`。
45
+ *
46
+ * 「仅当命令行没给时」才用环境变量:命令行给了(即使是非法值)就按命令行来,
47
+ * 非法 → 空串(忽略),**不会**悄悄回退到环境变量 —— 否则用户显式写错时会得到一个
48
+ * 来路不明的归因码,比丢掉更难查。
49
+ */
50
+ export function resolveRefCode(argv, env = process.env) {
51
+ const list = Array.isArray(argv) ? argv.map(String) : [];
52
+ const i = list.indexOf("--ref");
53
+ if (i > -1) return normalizeRefCode(list[i + 1]);
54
+ return normalizeRefCode(env && env.DSH_REF);
55
+ }
56
+
57
+ /**
58
+ * 把本次安装解析到的 ref_code 合并进**已从磁盘读出的**配置对象。
59
+ *
60
+ * ⚠️ saveConfig() 是整对象覆盖写:必须先 loadConfig() 拿到旧对象,在这里合并,再整份写回。
61
+ * 否则「升级时没带 --ref」会把上一次写入的 ref_code 清空(归因只在第一次安装时存在,
62
+ * 之后就永久丢了)。本次没带/非法 → 原样保留配置里的旧值。
63
+ */
64
+ export function applyRefCodeToConfig(cfg, argv, env = process.env) {
65
+ const next = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? { ...cfg } : {};
66
+ const code = resolveRefCode(argv, env);
67
+ if (code) next.ref_code = code;
68
+ return next;
69
+ }