@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.
@@ -644,11 +644,6 @@ export class E2eeService {
644
644
  this.sessions = new Map(); // sessId → E2eeSession
645
645
  this.ctrlCounters = new Map(); // sessId → { p2b: SequenceGuard, b2p: SequenceGuard }
646
646
  this.httpNonces = new HttpNonceGuard();
647
- // ── 会话持久化(可选,见文件末尾「会话持久化」段)──
648
- this.storeDir = "";
649
- this.storeTimer = null;
650
- this.storeErrorLogged = false;
651
- this.lastStoreReason = "";
652
647
  }
653
648
 
654
649
  /** 静态工厂:启动派生(失败即禁用并说明原因;绝不抛)。 */
@@ -723,130 +718,6 @@ export class E2eeService {
723
718
  return s || null;
724
719
  }
725
720
 
726
- /* ── 会话持久化(2026-10-23)────────────────────────────────────────────────────
727
- *
728
- * 【为什么】生产日志:bridge 重启/崩溃后,手机端保存的 sessId 在内存表里找不到 →
729
- * 每个请求 502 `unknown_session`、每条数据 WS 被 1008 `e2ee_session_locked` 拒,
730
- * 用户被迫回设备列表重新解锁(实测某设备 3964 次)。
731
- * 【怎么存】只存**不可推导的会话材料**(SHK / saltH / 验证标志 / 时间戳 / ctrl 计数器),
732
- * 用 MK 派生出的 storeKey 做 AES-256-GCM 加密后落盘到 relay 数据目录
733
- * `.e2ee-sessions.enc`(0600,原子写)。文件里没有 MK、没有密码、没有 storeKey ——
734
- * 拿到文件也解不开(同一份文件在不同账号/改密后的 MK 下 GCM 认证必失败)。
735
- * 【失效纪律】解不开 / 版本不符 / salt 变了 → 丢弃文件 + 清空内存会话,并让上层给手机
736
- * 回 `retryable:true, action:"rehandshake"` 的信号(自动重握手,而不是反复撞死票)。
737
- * HTTP 的防重放 nonce 窗口**不**持久化:它只有 5 分钟,重启丢窗的代价是"同一请求可能
738
- * 在这 5 分钟内被重放一次",而重放的前提是攻击者已经拿到那条完整密文请求 —— 与把
739
- * 整份会话密钥落盘相比不改变信任边界(见 docs/e2ee-protocol.md §9.1)。
740
- */
741
- attachStore(dir, { enabled = true, flushMs = 60_000 } = {}) {
742
- if (!enabled) return { restored: 0, discarded: "", skipped: "disabled" };
743
- if (!this.enabled) return { restored: 0, discarded: "", skipped: "e2ee_disabled" };
744
- if (!dir) return { restored: 0, discarded: "", skipped: "no_dir" };
745
- this.storeDir = String(dir);
746
- const r = this.restoreFromStore();
747
- if (flushMs > 0) {
748
- try {
749
- this.storeTimer = setInterval(() => this.flushStore(), flushMs);
750
- this.storeTimer.unref?.();
751
- } catch { /* 定时器不可用 → 只有退出时 flush(不致命) */ }
752
- }
753
- return r;
754
- }
755
-
756
- /** 退出/测试用:停掉周期落盘。 */
757
- disposeStore() {
758
- if (this.storeTimer) { try { clearInterval(this.storeTimer); } catch { /* 忽略 */ } }
759
- this.storeTimer = null;
760
- }
761
-
762
- /** 从磁盘恢复会话(同步、绝不抛)。返回 { restored, discarded, skipped? }。 */
763
- restoreFromStore() {
764
- if (!this.storeDir || !this.enabled) return { restored: 0, discarded: "" };
765
- const loaded = loadSessionStore(this.storeDir, this.mk);
766
- if (!loaded.ok) {
767
- if (loaded.reason === "absent") return { restored: 0, discarded: "" };
768
- // 解不开/损坏:删掉它,别让下一位继续撞。原因留给上层打日志 + 给手机 rehandshake 信号。
769
- clearSessionStore(this.storeDir);
770
- this.lastStoreReason = loaded.reason;
771
- return { restored: 0, discarded: loaded.reason };
772
- }
773
- const payload = loaded.payload || {};
774
- if (Number(payload.v) !== SESSION_STORE_VERSION || String(payload.salt || "") !== String(this.salt || "")) {
775
- clearSessionStore(this.storeDir);
776
- this.lastStoreReason = "params_changed";
777
- return { restored: 0, discarded: "params_changed" };
778
- }
779
- const now = Date.now();
780
- this.sessions.clear();
781
- this.ctrlCounters.clear();
782
- for (const row of Array.isArray(payload.sessions) ? payload.sessions : []) {
783
- try {
784
- const sessId = String(row && row.s || "");
785
- if (!SESS_ID_RE.test(sessId)) continue;
786
- const usedAt = Number(row.usedAt) || 0;
787
- if (!usedAt || now - usedAt > SESSION_TTL_MS) continue; // 过期(sliding TTL)
788
- const session = new E2eeSession({
789
- sessId,
790
- shk: Buffer.from(String(row.shk || ""), "base64url"),
791
- saltH: Buffer.from(String(row.saltH || ""), "base64url"),
792
- profile: String(row.profile || this.profile),
793
- epoch: Number(row.epoch) || 0
794
- });
795
- session.verified = row.verified === true;
796
- session.createdAt = Number(row.createdAt) || usedAt;
797
- session.usedAt = usedAt;
798
- this.sessions.set(sessId, session);
799
- const guards = { p2b: new SequenceGuard(), b2p: new SequenceGuard() };
800
- if (Number.isInteger(row.p2b)) guards.p2b.last = row.p2b;
801
- if (Number.isInteger(row.b2p)) guards.b2p.last = row.b2p;
802
- this.ctrlCounters.set(sessId, guards);
803
- } catch { /* 单条坏 → 跳过,不影响其它会话 */ }
804
- }
805
- this.prune();
806
- return { restored: this.sessions.size, discarded: "" };
807
- }
808
-
809
- /**
810
- * 落盘一次(同步、绝不抛)。force=false 时只在 storeDir 存在时写。
811
- * 写失败只记一次日志:会话库不是主链路,不能因为它把隧道拖挂。
812
- */
813
- flushStore() {
814
- if (!this.storeDir || !this.enabled) return { ok: false, skipped: true };
815
- try {
816
- const payload = {
817
- v: SESSION_STORE_VERSION,
818
- profile: this.profile,
819
- epoch: this.epoch,
820
- salt: this.salt,
821
- at: Date.now(),
822
- sessions: [...this.sessions.values()].map((s) => ({
823
- s: s.sessId,
824
- shk: s.shk.toString("base64url"),
825
- saltH: s.saltH.toString("base64url"),
826
- profile: s.profile,
827
- epoch: s.epoch,
828
- verified: s.verified === true,
829
- createdAt: s.createdAt,
830
- usedAt: s.usedAt,
831
- p2b: this.ctrlCounters.get(s.sessId)?.p2b?.last ?? -1,
832
- b2p: this.ctrlCounters.get(s.sessId)?.b2p?.last ?? -1
833
- }))
834
- };
835
- return saveSessionStore(this.storeDir, this.mk, payload);
836
- } catch (e) {
837
- if (!this.storeErrorLogged) {
838
- this.storeErrorLogged = true;
839
- console.warn(`[e2ee] 会话库落盘失败(不影响隧道,仅本次重启需重新解锁): ${e?.code || e?.message || e}`);
840
- }
841
- return { ok: false, error: String(e?.code || e?.message || e) };
842
- }
843
- }
844
-
845
- /** 新会话建立/验证通过后立即落盘(解锁不常有,同步写 2KB 可接受;崩溃也不丢新会话)。 */
846
- persistSoon() {
847
- try { this.flushStore(); } catch { /* 已内部兜底 */ }
848
- }
849
-
850
721
  /**
851
722
  * 桥端应答 e2ee-hello(§5.2 步骤 1→2)。
852
723
  * @returns {{session:E2eeSession, ack:object}} 或抛 E2eeError
@@ -876,7 +747,6 @@ export class E2eeService {
876
747
  const session = new E2eeSession({ sessId, shk, saltH, profile: this.profile, epoch: this.epoch });
877
748
  this.sessions.set(sessId, session);
878
749
  this.ctrlCounters.set(sessId, { p2b: new SequenceGuard(), b2p: new SequenceGuard() });
879
- this.persistSoon(); // 新会话立刻落盘:紧接着的 bridge 崩溃不该让手机重新解锁
880
750
  const ack = {
881
751
  v: E2EE_VERSION,
882
752
  type: CTRL_HELLO_ACK,
@@ -914,7 +784,6 @@ export class E2eeService {
914
784
  data: JSON.stringify({ p: "dsh-e2ee-probe-ok", t: pt.t ?? 0, c: 0 })
915
785
  });
916
786
  this.ctrlCounters.set(sessId, guards);
917
- this.persistSoon(); // verified 也落盘:重启后数据 WS 直接可用,不必再探测
918
787
  return { reply, session };
919
788
  }
920
789
 
@@ -928,126 +797,6 @@ export class E2eeService {
928
797
  }
929
798
  }
930
799
 
931
- // ============================================================
932
- // 会话持久化原语(2026-10-23;方案/信任边界见 E2eeService.attachStore 上方长注释)
933
- // ============================================================
934
-
935
- export const SESSION_STORE_VERSION = 1;
936
- export const SESSION_STORE_FILE = ".e2ee-sessions.enc";
937
- /** 会话库 AAD(域分隔;换格式/换用途必须改它,旧文件自动解不开 → 丢弃重握手)。 */
938
- export const SESSION_STORE_AAD = Buffer.from(`${E2EE_DOMAIN}\x00sessions-store-v1`, "utf8");
939
-
940
- /** 会话库路径(与 .e2ee-state.json 同级,relay 数据目录)。 */
941
- export function sessionStorePath(dir) {
942
- return `${String(dir || "").replace(/[\\/]+$/, "")}/${SESSION_STORE_FILE}`;
943
- }
944
-
945
- /**
946
- * 由 MK 派生会话库密钥:
947
- * storeKey = HKDF-SHA256(ikm=MK, salt=sha256("dsh-e2ee/v1\0sessions-store-salt"),
948
- * info="dsh-e2ee/v1\0sessions-store", 32)
949
- * 与 SHK/子密钥域完全隔离;storeKey 只驻内存,绝不写文件、不写日志。
950
- */
951
- export function deriveSessionStoreKey(mk) {
952
- if (!Buffer.isBuffer(mk) || mk.length !== MK_BYTES) throw new E2eeError("bad_mk", "e2ee: 会话库 MK 非法");
953
- const salt = sha256(Buffer.from(`${E2EE_DOMAIN}\x00sessions-store-salt`, "utf8"));
954
- return Buffer.from(hkdfSync("sha256", mk, salt, Buffer.from(`${E2EE_DOMAIN}\x00sessions-store`, "utf8"), MK_BYTES));
955
- }
956
-
957
- /** 加密会话库 → 可直接 JSON.stringify 的对象(明文永不落盘)。 */
958
- export function encryptSessionStore(mk, payload) {
959
- const key = deriveSessionStoreKey(mk);
960
- const iv = randomBytes(NONCE_BYTES);
961
- const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
962
- const c = createCipheriv("aes-256-gcm", key, iv);
963
- c.setAAD(SESSION_STORE_AAD);
964
- const ct = Buffer.concat([c.update(plaintext), c.final()]);
965
- return {
966
- v: SESSION_STORE_VERSION,
967
- alg: "aes-256-gcm",
968
- iv: iv.toString("base64url"),
969
- tag: c.getAuthTag().toString("base64url"),
970
- ct: ct.toString("base64url")
971
- };
972
- }
973
-
974
- /** 解密会话库;任何异常都抛 E2eeError(调用方据此丢弃文件)。 */
975
- export function decryptSessionStore(mk, blob) {
976
- if (!blob || typeof blob !== "object") throw new E2eeError("bad_store", "e2ee: 会话库格式非法");
977
- if (Number(blob.v) !== SESSION_STORE_VERSION) throw new E2eeError("bad_store", `e2ee: 会话库版本 ${blob.v} 不受支持`);
978
- if (String(blob.alg || "") !== "aes-256-gcm") throw new E2eeError("bad_store", "e2ee: 会话库算法不受支持");
979
- const key = deriveSessionStoreKey(mk);
980
- const iv = Buffer.from(String(blob.iv || ""), "base64url");
981
- const tag = Buffer.from(String(blob.tag || ""), "base64url");
982
- const ct = Buffer.from(String(blob.ct || ""), "base64url");
983
- if (iv.length !== NONCE_BYTES || tag.length !== TAG_BYTES) throw new E2eeError("bad_store", "e2ee: 会话库 nonce/tag 非法");
984
- const d = createDecipheriv("aes-256-gcm", key, iv);
985
- d.setAAD(SESSION_STORE_AAD);
986
- d.setAuthTag(tag);
987
- let plaintext;
988
- try {
989
- plaintext = Buffer.concat([d.update(ct), d.final()]); // 认证失败(MK 变了/被篡改)在这里抛
990
- } catch {
991
- throw new E2eeError("store_auth_failed", "e2ee: 会话库无法解密(MK 已变或文件损坏)");
992
- }
993
- try {
994
- return JSON.parse(plaintext.toString("utf8"));
995
- } catch {
996
- throw new E2eeError("bad_store", "e2ee: 会话库明文不是 JSON");
997
- }
998
- }
999
-
1000
- /** 原子写会话库(mkdir + tmp 文件 + rename;0600)。失败抛(上层兜)。 */
1001
- export function saveSessionStore(dir, mk, payload) {
1002
- if (!dir) throw new E2eeError("no_store_dir", "e2ee: 会话库目录未设置");
1003
- const file = sessionStorePath(dir);
1004
- const body = JSON.stringify(encryptSessionStore(mk, payload));
1005
- const tmp = `${file}.tmp-${process.pid}`;
1006
- fs.mkdirSync(dir, { recursive: true });
1007
- fs.writeFileSync(tmp, body, { mode: 0o600 });
1008
- try { fs.chmodSync(tmp, 0o600); } catch { /* Windows 上可能不支持 */ }
1009
- fs.renameSync(tmp, file); // rename 原子:不会读到半截文件
1010
- return { ok: true, bytes: body.length, file };
1011
- }
1012
-
1013
- /** 读会话库。返回 { ok, reason: absent|unreadable|corrupt|key_mismatch, payload }(绝不抛)。 */
1014
- export function loadSessionStore(dir, mk) {
1015
- const file = sessionStorePath(dir);
1016
- let raw;
1017
- try {
1018
- raw = fs.readFileSync(file, "utf8");
1019
- } catch (e) {
1020
- return { ok: false, reason: e?.code === "ENOENT" ? "absent" : "unreadable", payload: null, file };
1021
- }
1022
- let blob;
1023
- try {
1024
- blob = JSON.parse(raw);
1025
- } catch {
1026
- return { ok: false, reason: "corrupt", payload: null, file };
1027
- }
1028
- try {
1029
- return { ok: true, reason: "", payload: decryptSessionStore(mk, blob), file };
1030
- } catch (e) {
1031
- return {
1032
- ok: false,
1033
- reason: e?.code === "store_auth_failed" ? "key_mismatch" : "corrupt",
1034
- payload: null,
1035
- file
1036
- };
1037
- }
1038
- }
1039
-
1040
- /** 删除会话库(丢弃死会话时用;返回是否真的删掉)。 */
1041
- export function clearSessionStore(dir) {
1042
- if (!dir) return false;
1043
- try {
1044
- fs.rmSync(sessionStorePath(dir), { force: true });
1045
- return true;
1046
- } catch {
1047
- return false;
1048
- }
1049
- }
1050
-
1051
800
  /** 把 E2EE 开关状态写入 relayDir/.e2ee-state.json(供 Phase-4 面板 UI 读取)。 */
1052
801
  export function writeE2eeStateFile(dir, state) {
1053
802
  try {
@@ -3973,267 +3973,6 @@ const SCRIPT = `(() => {
3973
3973
  } catch (ePs) { /* 忽略 */ }
3974
3974
  });
3975
3975
  } catch (ePs2) { /* 忽略 */ }
3976
- /* ===DSH-MA-SINGLEPAGE-START===
3977
- 同一设备只允许一个镜像页(2026-10-23,生产现场:手机端会出现同一 deviceId 的两个镜像页
3978
- 并发,互相取消资源加载 → 两边都一直转圈)。
3979
-
3980
- 做法(同步优先,绝不拖慢首个页面):
3981
- 1. 只对 /remote/<deviceId>/ 形态的镜像页生效;设备列表/登录页不参与。
3982
- 2. **同步租约**:localStorage 里一条 {id, at},本页心跳每 2s 刷新。读到"别人的租约且新鲜
3983
- (<6s)"→ 立刻进 blocked 态(不依赖任何异步 API 就能把第二页按住)。
3984
- 3. 再叠加 BroadcastChannel:新页 claim / takeover / release 实时互通,
3985
- 老页收到 claim/takeover 立刻让路(显示"已切到另一窗口")。
3986
- 4. 有 Web Locks 时再拿一把同名锁兜底(双开竞态的最后仲裁者)。
3987
- 5. blocked 态 = window.stop() + 拦掉 fetch/XHR send/WebSocket + 全屏提示(带"在本窗口继续"
3988
- 按钮,点了通知旧页让路 → 本页重载接管)。同时每 2s 复查租约:旧页关掉/失联后自动恢复。
3989
- 6. 老浏览器(没有 localStorage/BroadcastChannel,如部分隐私模式)→ 直接不启用,
3990
- 保持既有行为(宁可不管,也绝不能把唯一页面锁死)。
3991
-
3992
- 注入体纪律:本段不得出现反引号,只用 ES5 语法。 */
3993
- function maSinglePageGuard() {
3994
- try {
3995
- var LS_KEY_PREFIX = "dsh-remote-mirror-page:";
3996
- var BC_PREFIX = "dsh-remote-mirror-page-bc:";
3997
- var LEASE_TTL_MS = 6000;
3998
- var BEAT_MS = 2000;
3999
- var LOCK_PREFIX = "dsh-remote-mirror-lock:";
4000
- var OVERLAY_ID = "dsh-ma-singlepage";
4001
- var BTN_ID = "dsh-ma-singlepage-takeover";
4002
-
4003
- var loc = (typeof location !== "undefined" && location) ? location : (window.location || null);
4004
- if (!loc) return;
4005
- // 只认 /remote/<deviceId>/ 形态(用 split 解析:注入体是模板字符串,正则里的 \/ 会被吃掉)
4006
- var seg = String(loc.pathname || "").split("/").filter(Boolean);
4007
- if (seg[0] !== "remote" || !seg[1]) return;
4008
- var dev = "";
4009
- try { dev = decodeURIComponent(seg[1]); } catch (eDec) { dev = ""; }
4010
- if (!dev || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(dev)) return;
4011
- var key = LS_KEY_PREFIX + dev;
4012
- var takeoverKey = key + ":takeover";
4013
- var self = "p" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
4014
-
4015
- var ls = null;
4016
- try { ls = window.localStorage || null; } catch (e0) { ls = null; }
4017
- var bc = null;
4018
- try {
4019
- if (typeof window.BroadcastChannel === "function") bc = new window.BroadcastChannel(BC_PREFIX + dev);
4020
- } catch (e1) { bc = null; }
4021
- if (!ls || !bc) return; // 老浏览器/隐私模式:不改变既有行为(不锁)
4022
-
4023
- var state = "checking";
4024
- var beatTimer = 0;
4025
- var pollTimer = 0;
4026
- var claimedAt = 0;
4027
-
4028
- function publish(s) {
4029
- state = s;
4030
- try {
4031
- window.__dshSinglePage = { state: s, device: dev };
4032
- } catch (eP) { /* 忽略 */ }
4033
- }
4034
-
4035
- function readLease() {
4036
- try {
4037
- var raw = ls.getItem(key);
4038
- if (!raw) return null;
4039
- var v = JSON.parse(raw);
4040
- if (!v || typeof v.id !== "string") return null;
4041
- return { id: v.id, at: Number(v.at) || 0 };
4042
- } catch (eR) { return null; }
4043
- }
4044
- function writeLease() {
4045
- try { ls.setItem(key, JSON.stringify({ id: self, at: Date.now() })); } catch (eW) { /* 忽略 */ }
4046
- }
4047
- function releaseLease() {
4048
- try {
4049
- var cur = readLease();
4050
- if (cur && cur.id === self) ls.removeItem(key);
4051
- } catch (eL) { /* 忽略 */ }
4052
- }
4053
- function readTakeover() {
4054
- try { return Number(ls.getItem(takeoverKey)) || 0; } catch (eT) { return 0; }
4055
- }
4056
- function markTakeover() {
4057
- try { ls.setItem(takeoverKey, String(Date.now())); } catch (eM) { /* 忽略 */ }
4058
- }
4059
-
4060
- function post(msg) {
4061
- try { bc.postMessage(msg); } catch (ePo) { /* 忽略 */ }
4062
- }
4063
-
4064
- function reload() {
4065
- try {
4066
- if (typeof loc.reload === "function") { loc.reload(); return; }
4067
- loc.href = loc.href;
4068
- } catch (eRel) { /* 忽略 */ }
4069
- }
4070
-
4071
- /* ---- 第二页:即刻停掉加载 + 全屏提示(唯一出口是「在本窗口继续」或回原窗口)---- */
4072
- function installNetworkStop() {
4073
- try { if (typeof window.stop === "function") window.stop(); } catch (eS) { /* 忽略 */ }
4074
- try {
4075
- if (typeof window.fetch === "function") {
4076
- window.fetch = function () {
4077
- return Promise.reject(new Error("dsh-single-page: 同一设备的镜像页已在另一个窗口打开,本页已停用"));
4078
- };
4079
- }
4080
- } catch (eF) { /* 忽略 */ }
4081
- try {
4082
- var proto = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
4083
- if (proto && typeof proto.send === "function") {
4084
- proto.send = function () { try { this.abort(); } catch (eX) { /* 忽略 */ } };
4085
- }
4086
- } catch (eXhr) { /* 忽略 */ }
4087
- try {
4088
- if (typeof window.WebSocket === "function") {
4089
- var WsBlocked = function () {
4090
- throw new Error("dsh-single-page: 同一设备的镜像页已在另一个窗口打开,本页已停用");
4091
- };
4092
- WsBlocked.prototype = window.WebSocket.prototype;
4093
- window.WebSocket = WsBlocked;
4094
- }
4095
- } catch (eWs) { /* 忽略 */ }
4096
- }
4097
-
4098
- function buildOverlay() {
4099
- try {
4100
- if (!document || !document.body) return;
4101
- if (document.getElementById && document.getElementById(OVERLAY_ID)) return;
4102
- var wrap = document.createElement("div");
4103
- wrap.id = OVERLAY_ID;
4104
- wrap.setAttribute("data-dsh-single-page", "blocked");
4105
- wrap.style.cssText = "position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483001;"
4106
- + "background:#0f1115;color:#e8eaed;display:flex;align-items:center;justify-content:center;"
4107
- + "padding:24px;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;";
4108
- wrap.innerHTML = '<div style="max-width:420px;text-align:center;line-height:1.7">'
4109
- + '<div style="font-size:18px;font-weight:600;margin-bottom:10px">该设备已在本浏览器另一个窗口打开</div>'
4110
- + '<div style="font-size:14px;color:#9aa0a6;margin-bottom:18px">两个镜像页同时加载会互相取消资源加载,'
4111
- + '导致两边都卡住。请回到原来那个窗口继续使用。</div>'
4112
- + '<button id="' + BTN_ID + '" style="border:0;border-radius:10px;padding:12px 18px;'
4113
- + 'font-size:15px;background:#3b82f6;color:#fff;width:100%">在本窗口继续(切换到本页)</button>'
4114
- + '</div>';
4115
- document.body.appendChild(wrap);
4116
- var btn = document.getElementById ? document.getElementById(BTN_ID) : null;
4117
- if (btn && typeof btn.addEventListener === "function") btn.addEventListener("click", takeover, false);
4118
- } catch (eOv) { /* 提示失败也不影响"已停用"这个事实 */ }
4119
- }
4120
-
4121
- function showOverlay() {
4122
- try {
4123
- if (document && document.body) buildOverlay();
4124
- else if (document && typeof document.addEventListener === "function") {
4125
- document.addEventListener("DOMContentLoaded", buildOverlay, false);
4126
- }
4127
- } catch (eShow) { /* 忽略 */ }
4128
- }
4129
-
4130
- function becomeBlocked(why) {
4131
- if (state === "blocked") return;
4132
- publish("blocked");
4133
- try { if (beatTimer) { clearInterval(beatTimer); beatTimer = 0; } } catch (eB) { /* 忽略 */ }
4134
- releaseLease();
4135
- installNetworkStop();
4136
- showOverlay();
4137
- try {
4138
- if (!pollTimer) {
4139
- pollTimer = setInterval(function () {
4140
- var l = readLease();
4141
- var now = Date.now();
4142
- if (l && l.id !== self && now - l.at < LEASE_TTL_MS) return; // 人家还活着
4143
- // 原来的窗口关了/失联:允许自己接管(先打 takeover 标记,避免重载后又被自己挡住)
4144
- try { if (pollTimer) { clearInterval(pollTimer); pollTimer = 0; } } catch (eC) { /* 忽略 */ }
4145
- markTakeover();
4146
- reload();
4147
- }, 2000);
4148
- }
4149
- } catch (ePoll) { /* 忽略 */ }
4150
- if (window.console && console.info) console.info("[dsh-mobile-adapter] 单镜像页:第二页已停用(" + why + ")");
4151
- }
4152
-
4153
- function takeover() {
4154
- post({ k: "takeover", id: self, at: Date.now() });
4155
- markTakeover();
4156
- // 给旧页 400ms 收消息、释放租约、让出资源,然后本页重载(重载后按 takeover 标记直接接管)
4157
- setTimeout(reload, 400);
4158
- }
4159
-
4160
- function beat() {
4161
- writeLease();
4162
- }
4163
-
4164
- function becomeActive() {
4165
- publish("active");
4166
- claimedAt = Date.now();
4167
- // takeover 标记只用于「重载后允许接管一次」:拿到租约后立刻消费掉,
4168
- // 否则它在 15s 有效期内会让第三个页面也以为自己可以接管(并发又回来了)。
4169
- try { ls.removeItem(takeoverKey); } catch (eCl) { /* 忽略 */ }
4170
- writeLease();
4171
- try {
4172
- if (!beatTimer) beatTimer = setInterval(beat, BEAT_MS);
4173
- } catch (eBt) { /* 忽略 */ }
4174
- post({ k: "claim", id: self, at: claimedAt });
4175
- // Web Locks 兜底(可用时):并发双开时只有拿到锁的那个页面继续
4176
- try {
4177
- var nav = window.navigator;
4178
- if (nav && nav.locks && typeof nav.locks.request === "function") {
4179
- nav.locks.request(LOCK_PREFIX + dev, { ifAvailable: true }, function (lock) {
4180
- if (!lock) { becomeBlocked("web_locks_busy"); return undefined; }
4181
- return new Promise(function () { /* 持有到页面结束 */ });
4182
- }).catch(function () { /* 不支持 ifAvailable/被拒 → 租约已足够 */ });
4183
- }
4184
- } catch (eLocks) { /* 忽略 */ }
4185
- // 让路:另一页 claim(它后发/并发)或要求接管
4186
- try {
4187
- bc.onmessage = function (ev) {
4188
- var msg = ev && ev.data;
4189
- if (!msg || typeof msg !== "object") return;
4190
- if (msg.id === self) return;
4191
- if (msg.k === "takeover") {
4192
- becomeBlocked("takeover");
4193
- return;
4194
- }
4195
- if (msg.k === "claim" && Number(msg.at) >= claimedAt) {
4196
- // 同一时刻两个页面都认为自己是活跃页(同步租约竞态)→ 按 id 字典序让一个走
4197
- if (Number(msg.at) === claimedAt && String(msg.id) < String(self)) return;
4198
- becomeBlocked("other_claim");
4199
- }
4200
- };
4201
- } catch (eMsg) { /* 忽略 */ }
4202
- try {
4203
- window.addEventListener("pagehide", function () {
4204
- if (state !== "active") return;
4205
- // 进 bfcache / 切走:让出租约,别让另一个页面(或用户重开)被一张"僵尸租约"挡住
4206
- releaseLease();
4207
- post({ k: "release", id: self, at: Date.now() });
4208
- if (beatTimer) { try { clearInterval(beatTimer); } catch (eBt2) { /* 忽略 */ } beatTimer = 0; }
4209
- try { bc.onmessage = null; } catch (eBc2) { /* 忽略 */ } // 卸载旧监听,避免和 pageshow 后的新实例抢状态
4210
- publish("inactive");
4211
- });
4212
- // bfcache 回来:可能已经过了很久、也可能别的页面已经接管 → 重新判一次(幂等)
4213
- window.addEventListener("pageshow", function (ev) {
4214
- if (!ev || !ev.persisted) return;
4215
- if (state === "active") return;
4216
- try { maSinglePageGuard(); } catch (ePs) { /* 忽略 */ }
4217
- });
4218
- } catch (ePh) { /* 忽略 */ }
4219
- }
4220
-
4221
- /* ---- 启动:同步租约判定(关键:不 await,第二页在网络请求发出前就被按住)---- */
4222
- var now = Date.now();
4223
- var lease = readLease();
4224
- var tko = readTakeover();
4225
- var takeoverFresh = tko > 0 && now - tko < 15000 && tko <= now + 60000;
4226
- var foreignFresh = lease && lease.id !== self && lease.at <= now + 60000 && now - lease.at < LEASE_TTL_MS;
4227
- if (foreignFresh && !takeoverFresh) {
4228
- becomeBlocked("lease_held");
4229
- return;
4230
- }
4231
- becomeActive();
4232
- } catch (e) { /* 守卫绝不能把页面拖挂 */ }
4233
- }
4234
- maSinglePageGuard();
4235
- /* ===DSH-MA-SINGLEPAGE-END=== */
4236
-
4237
3976
  } catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
4238
3977
  })();`;
4239
3978
 
@@ -8,10 +8,6 @@
8
8
  * HTTP 失败路径早就会 refreshUpstream,**WS 失败路径之前漏了** —— 而镜像页常先开 WS。
9
9
  *
10
10
  * 这个用例守住:两条失败路径都必须触发重新发现(否则又回到"只能重启 bridge")。
11
- *
12
- * 【2026-10-23 契约变更】ws-open 改为「先快速 ack、再连上游」后,上游建立失败的收尾从
13
- * `{type:"ws-open", ok:false, code:502}` 变成 `{type:"ws-close", code:1011, retryable:true}`
14
- * —— 升级早已完成,再回 ws-open 失败帧没有意义。自愈要求不变:仍必须 refreshUpstream。
15
11
  */
16
12
  import test from "node:test";
17
13
  import assert from "node:assert/strict";
@@ -19,28 +15,14 @@ import { readFileSync } from "node:fs";
19
15
 
20
16
  const SRC = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
21
17
 
22
- /** 取某个锚点到下一处 `\n}` 的函数体(够做源代码契约断言,不追求通用解析)。 */
23
- function bodyAfter(anchor, maxLen = 2000) {
24
- const i = SRC.indexOf(anchor);
25
- assert.ok(i > 0, `找不到锚点:${anchor}`);
26
- return SRC.slice(i, i + maxLen);
27
- }
28
-
29
- test("★ WS 上游建立失败必须触发上游重新发现(快速应答后走 ws-close 1011)", () => {
30
- // ws.on("error") 的未建立分支 → 统一交给 failUpstream 收尾
31
- const errBranch = bodyAfter('ws.on("error"', 900);
32
- assert.match(errBranch, /!session\.opened/, "只对未建立的会话做失败收尾");
33
- assert.match(errBranch, /failUpstream\(/, "未建立失败必须走统一收尾(不要各写一份)");
34
- // failUpstream 内部:重发现 + ws-close(升级已 ack,不能再回 ws-open)
35
- const failFn = bodyAfter("const failUpstream =", 2200);
36
- assert.match(failFn, /refreshUpstream\(/, "★ 必须触发重新发现,否则端口变了会一直失败");
37
- assert.match(failFn, /failAckedWs\(/, "快速应答之后只能 ws-close 收尾");
38
- const ackFn = bodyAfter("function failAckedWs(", 600);
39
- assert.match(ackFn, /type: "ws-close"/, "收尾帧必须是 ws-close");
40
- assert.match(ackFn, /code: 1011/, "ws-close 用 1011(服务端内部错误)");
41
- // close 路径也要覆盖「还没 open 就断」:不能只靠 error 事件
42
- const closeBranch = bodyAfter('ws.on("close"', 900);
43
- assert.match(closeBranch, /failUpstream\(/, "上游没建立就 close 也要触发重发现");
18
+ test("★ WS 打开失败必须触发上游重新发现(不只是回 502)", () => {
19
+ // 找到 ws.on("error" ...) 的未建立分支
20
+ const i = SRC.indexOf('ws.on("error"');
21
+ assert.ok(i > 0, "必须有 ws error 处理");
22
+ const branch = SRC.slice(i, i + 1200);
23
+ assert.match(branch, /!session\.opened/, "只对未建立的会话做 502");
24
+ assert.match(branch, /refreshUpstream\(/, "★ 必须触发重新发现,否则端口变了会一直 502");
25
+ assert.match(branch, /code: 502/, "行为不变:本次仍然回 502");
44
26
  });
45
27
 
46
28
  test("★ HTTP 失败路径也要有重新发现(既有能力不得回退)", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.7.5-beta.1",
3
+ "version": "0.7.6-beta.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",