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

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,6 +644,11 @@ 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 = "";
647
652
  }
648
653
 
649
654
  /** 静态工厂:启动派生(失败即禁用并说明原因;绝不抛)。 */
@@ -718,6 +723,130 @@ export class E2eeService {
718
723
  return s || null;
719
724
  }
720
725
 
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
+
721
850
  /**
722
851
  * 桥端应答 e2ee-hello(§5.2 步骤 1→2)。
723
852
  * @returns {{session:E2eeSession, ack:object}} 或抛 E2eeError
@@ -747,6 +876,7 @@ export class E2eeService {
747
876
  const session = new E2eeSession({ sessId, shk, saltH, profile: this.profile, epoch: this.epoch });
748
877
  this.sessions.set(sessId, session);
749
878
  this.ctrlCounters.set(sessId, { p2b: new SequenceGuard(), b2p: new SequenceGuard() });
879
+ this.persistSoon(); // 新会话立刻落盘:紧接着的 bridge 崩溃不该让手机重新解锁
750
880
  const ack = {
751
881
  v: E2EE_VERSION,
752
882
  type: CTRL_HELLO_ACK,
@@ -784,6 +914,7 @@ export class E2eeService {
784
914
  data: JSON.stringify({ p: "dsh-e2ee-probe-ok", t: pt.t ?? 0, c: 0 })
785
915
  });
786
916
  this.ctrlCounters.set(sessId, guards);
917
+ this.persistSoon(); // verified 也落盘:重启后数据 WS 直接可用,不必再探测
787
918
  return { reply, session };
788
919
  }
789
920
 
@@ -797,6 +928,126 @@ export class E2eeService {
797
928
  }
798
929
  }
799
930
 
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
+
800
1051
  /** 把 E2EE 开关状态写入 relayDir/.e2ee-state.json(供 Phase-4 面板 UI 读取)。 */
801
1052
  export function writeE2eeStateFile(dir, state) {
802
1053
  try {
@@ -3973,6 +3973,267 @@ 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
+
3976
4237
  } catch (e) { if (window.console) console.warn("[dsh-mobile-adapter]", e && e.message); }
3977
4238
  })();`;
3978
4239