dsh-remote-web 0.7.0 → 0.7.1-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.
- package/lib/index.js +337 -16
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +273 -22
package/lib/index.js
CHANGED
|
@@ -215,19 +215,28 @@ async function discoverUpstreamSelf(relayDir, { force = false } = {}) {
|
|
|
215
215
|
* 开机自启的 watcher(Windows 登录任务 / launchd)由系统拉起,拿不到本进程的 env,
|
|
216
216
|
* 只有插件半知道 dsh web 真实监听在哪个端口。运行时(0.6.12+)读这个文件当第二优先来源。
|
|
217
217
|
* 内容没变就不写(面板每轮都会走到这里,别把磁盘写热)。
|
|
218
|
+
*
|
|
219
|
+
* @returns {{changed:boolean, url:string}} `changed=true` = **这一次真的改写了文件**。
|
|
220
|
+
* ★ 2026-10-13 为什么必须回报这个:修「12 秒重启风暴」时,唯一还站得住的"需要重启一个
|
|
221
|
+
* **正在健康运行**的 bridge"的理由就是"它的上游地址变了(进程环境里是旧地址,不重启不生效)"。
|
|
222
|
+
* 而这个判据必须是**磁盘上的真实变化**,不能是"我觉得它可能变了" —— 后者又会变成
|
|
223
|
+
* 每轮自愈都重启一次。调用方一律忽略返回值也没关系(旧行为不变)。
|
|
218
224
|
*/
|
|
219
225
|
const UPSTREAM_FILE = ".dsh-upstream";
|
|
220
226
|
function publishUpstream(relayDir) {
|
|
221
|
-
if (skipsSystemOps()) return; // 测试/诊断隔离:不得对磁盘产生任何副作用(含创建配置目录)
|
|
227
|
+
if (skipsSystemOps()) return { changed: false, url: "", skipped: true }; // 测试/诊断隔离:不得对磁盘产生任何副作用(含创建配置目录)
|
|
222
228
|
try {
|
|
223
229
|
const url = upstreamUrl();
|
|
224
230
|
const file = join(relayDir, UPSTREAM_FILE);
|
|
225
231
|
let cur = "";
|
|
226
232
|
try { cur = readFileSync(file, "utf8").trim(); } catch { /* 首次 */ }
|
|
227
|
-
if (cur === url) return;
|
|
233
|
+
if (cur === url) return { changed: false, url };
|
|
228
234
|
mkdirSync(relayDir, { recursive: true });
|
|
229
235
|
writeFileSync(file, url + "\n", { mode: 0o600 });
|
|
230
|
-
|
|
236
|
+
return { changed: true, url };
|
|
237
|
+
} catch { /* 非关键:读不到就沿用运行时的回退链 */
|
|
238
|
+
return { changed: false, url: "" };
|
|
239
|
+
}
|
|
231
240
|
}
|
|
232
241
|
|
|
233
242
|
/**
|
|
@@ -1102,6 +1111,41 @@ function systemdUserAvailableCached(ttlMs = 60_000) {
|
|
|
1102
1111
|
return ok;
|
|
1103
1112
|
}
|
|
1104
1113
|
|
|
1114
|
+
/**
|
|
1115
|
+
* ★★ 2026-10-13 修复(线上反馈 fb_3c4c5a13bc47 / 用户 842):Linux 上「bridge 到底在不在跑」的
|
|
1116
|
+
* **权威**判据 —— 问服务管理器本人,而不是猜进程名。
|
|
1117
|
+
*
|
|
1118
|
+
* 为什么 `pgrep` 不能当就绪判据:自愈 watcher 每 `selfhealIntervalMs()`(默认 12s)拍一次,
|
|
1119
|
+
* 旧就绪判据是 `manualStatus(relayDir).watcher.length > 0`,它靠
|
|
1120
|
+
* `pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs'` 的名称匹配。这个探测**极易拿到空结果**
|
|
1121
|
+
* (PATH 里没有 /usr/bin、pgrep 不存在、进程名与模式不匹配、进程属于别的会话……),
|
|
1122
|
+
* 而拿到空结果后的动作是 `systemctl --user restart dsh-bridge` —— **restart 会杀掉正在
|
|
1123
|
+
* 健康运行的 bridge**。实测后果:该设备 `audit_log` 的 `device.claim`(= bridge 进程启动的
|
|
1124
|
+
* 必然产物)一天 **3567** 次、相邻间隔 p50 **12001ms**、连续 10 小时整点 300 次/小时;
|
|
1125
|
+
* 全站上百台设备每天 1.1~1.5 万次重登记。每次重启都以新连接顶掉旧隧道,中继随即把该设备
|
|
1126
|
+
* **所有手机镜像 WS 关掉(close 1012 "bridge offline")**,镜像页每 ~12.3 秒断线重连并全量重放
|
|
1127
|
+
* 界面状态 —— 用户看到的就是"选项窗口一会儿显示、一会儿消失"。
|
|
1128
|
+
* 而中继侧对这类 churn 只做了日志抑制,所以运维侧长期看不见。
|
|
1129
|
+
*
|
|
1130
|
+
* 🔴 结论:**探测失败 ≠ 进程死了**,绝不允许把前者翻译成"每 12 秒杀一次隧道"。
|
|
1131
|
+
*
|
|
1132
|
+
* 为什么必须是三态而不是布尔:`known:false`(问不到 systemd —— NAS/容器里没有 user manager、
|
|
1133
|
+
* 甚至没有 systemctl 命令)与「问了,明确不是 active」在语义上完全不同:
|
|
1134
|
+
* · known=false → 回退到进程探测(与旧行为一致,不能因为问不到就认为它死了);
|
|
1135
|
+
* · known=true & active=false → 才可以动手拉起。
|
|
1136
|
+
* 把这两种混成一个 `false`,等于又制造了一次"判据失败就重启"的事故。
|
|
1137
|
+
*/
|
|
1138
|
+
function linuxSystemdUnitState() {
|
|
1139
|
+
if (!isLinux()) return { known: false, active: false, raw: "" };
|
|
1140
|
+
const r = sh("systemctl --user is-active dsh-bridge");
|
|
1141
|
+
const raw = String(r.stdout || "").trim();
|
|
1142
|
+
if (raw === "active") return { known: true, active: true, raw };
|
|
1143
|
+
// is-active 对 inactive/failed/activating… 会以非 0 退出,但**会打印状态字** —— 那是有效回答。
|
|
1144
|
+
// 一个字都没打印 = systemctl 根本没回答(没有 user manager / 没有这个命令)→ 未知,不是"死"。
|
|
1145
|
+
if (!raw) return { known: false, active: false, raw };
|
|
1146
|
+
return { known: true, active: false, raw };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1105
1149
|
/** 写 Linux systemd user unit(与 dsh-setup.mjs 同内容)。写不了返回 null(不致命,还有脱离进程兜底)。 */
|
|
1106
1150
|
function writeLinuxUnit(relayDir) {
|
|
1107
1151
|
const unitPath = linuxUnitPath();
|
|
@@ -1160,18 +1204,64 @@ function startBridgeLinux(relayDir) {
|
|
|
1160
1204
|
detail: "桌面运行环境(bridge)尚未安装,已在后台自动安装,完成后会自动启动 bridge——请稍候刷新面板(也可点下方「一键更新」查看进度)",
|
|
1161
1205
|
};
|
|
1162
1206
|
}
|
|
1163
|
-
publishUpstream(relayDir); // 脱离进程拉起的 watcher 没有本进程 env,只认这个文件
|
|
1207
|
+
const up = publishUpstream(relayDir); // 脱离进程拉起的 watcher 没有本进程 env,只认这个文件
|
|
1164
1208
|
const unit = writeLinuxUnit(relayDir);
|
|
1165
1209
|
if (unit && systemdUserAvailable()) {
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1210
|
+
// ★★ 2026-10-13 幂等闸门(修「12 秒重启风暴」的核心):
|
|
1211
|
+
// 先问**服务管理器本人**这个 unit 是不是 active,而不是拿 pgrep 猜进程名。
|
|
1212
|
+
// active = systemd 把它拉起来了、正在跑 —— 此时绝不能 restart(restart 会杀掉旧进程、
|
|
1213
|
+
// 新建隧道,中继随即 close(1012) 掉该设备所有手机镜像 WS,镜像页全量重放)。
|
|
1214
|
+
// 旧代码在这里**无条件 restart**:只要上游判据(pgrep)拿不到,它就每 12 秒把一台
|
|
1215
|
+
// 健康运行的 bridge 杀掉重建一次,一天 3567 次(见 linuxSystemdUnitState 的长注释)。
|
|
1216
|
+
const state = linuxSystemdUnitState();
|
|
1217
|
+
if (state.active && !up.changed) {
|
|
1218
|
+
// 唯一还站得住的"需要重启"的理由不存在 → 什么都不做,如实回报"在跑、没重启"。
|
|
1219
|
+
return {
|
|
1220
|
+
ok: true, status: "running", pid: null, reused: true,
|
|
1221
|
+
detail: "已由 systemd --user 托管 bridge(运行中,未重启)",
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
const why = state.active
|
|
1225
|
+
// 上游地址**确实变了**(运行中的进程环境里是旧地址,不重启就会对着旧端口转发)——
|
|
1226
|
+
// 这是唯一允许动一个健康服务的理由,所以必须过护栏:万一上游地址在抖,
|
|
1227
|
+
// 也不能退化成"每 12 秒重启一次"(那正是本次故障的形态)。
|
|
1228
|
+
? `上游地址变更为 ${up.url}`
|
|
1229
|
+
// 没在跑(含 is-active=activating/failed 这类瞬时态):本该起它,但**反复 restart 一个
|
|
1230
|
+
// 起不来的 unit** 每次都会派生一个新 bridge 进程(= 一次 device.claim = 顶掉一次隧道),
|
|
1231
|
+
// 所以这里的 restart 同样要过护栏 —— 护栏就该放在"破坏性动作"的调用点上,
|
|
1232
|
+
// 而不是指望每个调用者自己记得限流。
|
|
1233
|
+
: `systemd --user 说它没在跑(is-active=${state.raw || "无回答"})`;
|
|
1234
|
+
const g = guardedSystemdRestart(relayDir, why);
|
|
1235
|
+
if (!g.allowed) {
|
|
1236
|
+
// ⚠️ 被护栏拦下时**绝不能**再落到下面的"脱离进程"兜底:那条路会直接派生一个新 watcher,
|
|
1237
|
+
// 每拍一个 —— 护栏就形同虚设(这正是我们要止住的东西)。
|
|
1238
|
+
// 而回报要分两种:unit 本来就 active 时它**仍在运行**,不能因为"我们没重启"就报失败
|
|
1239
|
+
// (否则面板会把一台好机器显示成故障)。
|
|
1240
|
+
if (state.active) {
|
|
1241
|
+
return {
|
|
1242
|
+
ok: true, status: "running", pid: null, reused: true, staleUpstream: true,
|
|
1243
|
+
detail: `bridge 正在运行;上游地址已变更为 ${up.url},但为避免重启风暴暂不重启它(重开 dsh web 后自然生效)`,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return {
|
|
1247
|
+
ok: false, status: "selfheal-throttled",
|
|
1248
|
+
detail: `自愈护栏拦下了对 systemd unit 的重启(${g.gate.reason === "exhausted" ? "已达本进程重启上限" : "距上次重启过近"}),本轮不重启 bridge`,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
if (g.ok) {
|
|
1252
|
+
// accounted: true = 这次破坏性重启已经在 guardedSystemdRestart 里记过账(护栏额度 + 可见日志)。
|
|
1253
|
+
// 调用方(自愈 watcher)据此**不得再记一次**,否则一次尝试吃掉两格额度,5 分钟退避缩水成 2.5 分钟。
|
|
1254
|
+
return { ok: true, status: "running", pid: null, accounted: true, detail: "已由 systemd --user 托管 bridge(开机自启 + 崩溃自愈)" };
|
|
1255
|
+
}
|
|
1256
|
+
if (state.active) {
|
|
1257
|
+
// 因上游变更而重启、但重启后仍不是 active:如实回报(已在 guardedSystemdRestart 里记账)
|
|
1258
|
+
return {
|
|
1259
|
+
ok: true, status: "running", pid: null, reused: true, staleUpstream: true,
|
|
1260
|
+
detail: `bridge 仍在运行,但上游地址已变更为 ${up.url} 且本次重启未确认生效(详见安装日志)`,
|
|
1261
|
+
};
|
|
1172
1262
|
}
|
|
1173
1263
|
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
1174
|
-
`[dsh-remote-web] systemd --user 拉起 bridge 失败(
|
|
1264
|
+
`[dsh-remote-web] systemd --user 拉起 bridge 失败(restart=${g.rs.ok ? "ok" : "fail"}),改用后台进程`);
|
|
1175
1265
|
}
|
|
1176
1266
|
const d = startBridgeDetached(relayDir, unit
|
|
1177
1267
|
? "本机没有可用的 user systemd 会话(NAS / 容器 / 精简系统常见),已改为后台进程"
|
|
@@ -1179,6 +1269,30 @@ function startBridgeLinux(relayDir) {
|
|
|
1179
1269
|
return d;
|
|
1180
1270
|
}
|
|
1181
1271
|
|
|
1272
|
+
/**
|
|
1273
|
+
* 执行一次「systemd 破坏性重启 bridge」:**先过护栏,过了才真的 restart**,并记账 + 写可见日志。
|
|
1274
|
+
*
|
|
1275
|
+
* 为什么要把 `systemctl --user restart` 收敛到这一个函数里:这条命令是本次故障里唯一真正
|
|
1276
|
+
* 造成伤害的动作(杀旧 bridge → 新进程 → 新隧道 → 中继 close(1012) 掉手机镜像)。
|
|
1277
|
+
* 它散落在多处调用点时,任何一处忘了限流就又是一次"十二秒杀一次隧道";
|
|
1278
|
+
* 收到一处之后,"什么情况下允许执行它"就有了唯一答案,也能被单测直接钉住。
|
|
1279
|
+
* @returns {{allowed:boolean, gate:object, ok:boolean, en:object, rs:object, act:object}}
|
|
1280
|
+
*/
|
|
1281
|
+
function guardedSystemdRestart(relayDir, why) {
|
|
1282
|
+
const gate = selfhealRestartGate(relayDir);
|
|
1283
|
+
if (!gate.allowed) {
|
|
1284
|
+
noteSelfhealBlocked(relayDir, gate);
|
|
1285
|
+
return { allowed: false, gate, ok: false, en: null, rs: null, act: null };
|
|
1286
|
+
}
|
|
1287
|
+
sh("systemctl --user daemon-reload");
|
|
1288
|
+
const en = sh("systemctl --user enable dsh-bridge");
|
|
1289
|
+
const rs = sh("systemctl --user restart dsh-bridge");
|
|
1290
|
+
const act = rs.ok ? sh("systemctl --user is-active dsh-bridge") : { ok: false, stdout: "", stderr: "" };
|
|
1291
|
+
const active = Boolean(rs.ok && act.ok && String(act.stdout).trim() === "active");
|
|
1292
|
+
noteSelfhealRestart(relayDir, why, active ? "running" : `restart=${rs.ok ? "ok" : "fail"}/is-active=${String(act.stdout).trim() || "?"}`);
|
|
1293
|
+
return { allowed: true, gate, ok: active, en, rs, act };
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1182
1296
|
/**
|
|
1183
1297
|
* launchd 作业 target(`gui/<uid>/<label>`)。
|
|
1184
1298
|
* **Windows/Linux 上没有 launchctl、也没有 process.getuid**——旧实现无条件调
|
|
@@ -1427,6 +1541,97 @@ function selfhealIntervalMs() {
|
|
|
1427
1541
|
return Math.max(200, Number(process.env.DSH_RELAY_SELFHEAL_MS) || 12_000);
|
|
1428
1542
|
}
|
|
1429
1543
|
|
|
1544
|
+
/**
|
|
1545
|
+
* ★★ 2026-10-13 自愈重启护栏(熔断 + 退避)—— 修「12 秒重启风暴」的第二道闸。
|
|
1546
|
+
*
|
|
1547
|
+
* 为什么在"权威存活判据"之外还必须再有一道:
|
|
1548
|
+
* 权威判据解决的是"**活着却被判成死**"(见 `linuxSystemdUnitState()`)。
|
|
1549
|
+
* 但如果 bridge **真的**起不来(上游端口不对、运行环境半装、unit 写不进去、unit 一直 failed),
|
|
1550
|
+
* 旧的 12s 循环同样会每拍重启一次 —— 那是同一个病的另一种形态:
|
|
1551
|
+
* **一次真实故障被翻译成"每天 7200 次重启"**(历史上 Windows 补装失败就出过 12 秒一次、
|
|
1552
|
+
* 累计 606 次的失败风暴,见 `provisionRetryGate` 的注释)。所以:
|
|
1553
|
+
* · 同一 relayDir、**本进程生命周期内**的破坏性重启最多 `SELFHEAL_MAX_RESTARTS` 次;
|
|
1554
|
+
* · 两次之间至少隔 `selfhealMinRestartMs()`(默认 5 分钟);
|
|
1555
|
+
* · 超限就**停手**,并把原因明确写进安装日志(面板「复制诊断信息」与用户主动上报的
|
|
1556
|
+
* 诊断包里都会带上它)——而不是静默地每 12 秒再来一次。
|
|
1557
|
+
*
|
|
1558
|
+
* 为什么上限按"本进程生命周期"而不是"每天":合法的上游变更(dsh web 换端口)几乎总伴随着
|
|
1559
|
+
* dsh web 重启,而重启就会重置这份记录 —— 所以 3 次对正常用户绰绰有余,对故障循环刚好够呛。
|
|
1560
|
+
* `DSH_RELAY_SELFHEAL_MIN_RESTART_MS` 仅供本仓库测试把节奏压到亚秒级
|
|
1561
|
+
* (与 `DSH_RELAY_SELFHEAL_MS` / `DSH_RELAY_WEDGE_MS` 同一约定):生产不设置。
|
|
1562
|
+
*/
|
|
1563
|
+
const SELFHEAL_MAX_RESTARTS = 3;
|
|
1564
|
+
const SELFHEAL_MIN_RESTART_MS = 5 * 60 * 1000;
|
|
1565
|
+
function selfhealMinRestartMs() {
|
|
1566
|
+
const v = Number(process.env.DSH_RELAY_SELFHEAL_MIN_RESTART_MS);
|
|
1567
|
+
return Number.isFinite(v) && v >= 0 ? v : SELFHEAL_MIN_RESTART_MS;
|
|
1568
|
+
}
|
|
1569
|
+
/** relayDir → { count, lastAt, blockedLine };blockedLine = 已经告知过的"被拦下"结论(防刷屏)。 */
|
|
1570
|
+
const selfhealRestarts = new Map();
|
|
1571
|
+
function selfhealGuardOf(relayDir) {
|
|
1572
|
+
return selfhealRestarts.get(relayDir) || { count: 0, lastAt: 0, blockedLine: "" };
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* 现在允许对这个 relayDir 做**破坏性重启**吗?(纯查询,不改状态 —— 调用方在真的重启之后
|
|
1577
|
+
* 用 `noteSelfhealRestart` 记账,这样"只查不记"的路径不会凭空消耗额度。)
|
|
1578
|
+
* @returns {{allowed:boolean, reason:"ok"|"too-soon"|"exhausted", count:number, waitMs:number}}
|
|
1579
|
+
*/
|
|
1580
|
+
function selfhealRestartGate(relayDir, now = Date.now()) {
|
|
1581
|
+
const rec = selfhealGuardOf(relayDir);
|
|
1582
|
+
if (rec.count >= SELFHEAL_MAX_RESTARTS) return { allowed: false, reason: "exhausted", count: rec.count, waitMs: 0 };
|
|
1583
|
+
const min = selfhealMinRestartMs();
|
|
1584
|
+
const since = now - (rec.lastAt || 0);
|
|
1585
|
+
if (rec.lastAt && since < min) return { allowed: false, reason: "too-soon", count: rec.count, waitMs: min - since };
|
|
1586
|
+
return { allowed: true, reason: "ok", count: rec.count, waitMs: 0 };
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* 记一次**真的做了**破坏性重启,并留下一行可见日志。
|
|
1591
|
+
*
|
|
1592
|
+
* ★ 可观测性是这次修复的一半:这个故障拖了 3 个月才被定位,直接原因就是"重连风暴在中继侧
|
|
1593
|
+
* 只被日志抑制、在客户端侧又什么都没记"——运维侧全程看不见。所以重启事件必须留痕:
|
|
1594
|
+
* 写既有的安装日志(`.dsh-setup-install.log`,面板诊断与用户上报都会带它的尾部),不新造通道。
|
|
1595
|
+
* @param {string} why 人类可读的原因("权威判据说没在跑" / "上游地址变更")
|
|
1596
|
+
* @param {string} outcome 重启后的结果("running" / "failed: …")
|
|
1597
|
+
*/
|
|
1598
|
+
function noteSelfhealRestart(relayDir, why, outcome = "") {
|
|
1599
|
+
const rec = selfhealGuardOf(relayDir);
|
|
1600
|
+
rec.count += 1;
|
|
1601
|
+
rec.lastAt = Date.now();
|
|
1602
|
+
rec.blockedLine = ""; // 结论变了:下一次"被拦下"要重新说一遍
|
|
1603
|
+
selfhealRestarts.set(relayDir, rec);
|
|
1604
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
1605
|
+
`[dsh-remote-web] 自愈重启 bridge(第 ${rec.count}/${SELFHEAL_MAX_RESTARTS} 次·本进程生命周期内,`
|
|
1606
|
+
+ `之后 ${Math.round(selfhealMinRestartMs() / 1000)} 秒内不再重启):${why}${outcome ? ` → ${outcome}` : ""}`);
|
|
1607
|
+
return rec;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* 「这一拍本来想重启,但被护栏拦下了」——**同一条结论只说一次**(理由同 `noteAutostartFailure`:
|
|
1612
|
+
* 每 12 秒写一行的日志等于没有日志,用户复制过来的诊断里会全是重复行,第一现场反而被淹没)。
|
|
1613
|
+
* 注意:文案里**不得带倒计时秒数**,否则每一拍的文本都不同,去重就失效了。
|
|
1614
|
+
*/
|
|
1615
|
+
function noteSelfhealBlocked(relayDir, gate) {
|
|
1616
|
+
const rec = selfhealGuardOf(relayDir);
|
|
1617
|
+
const line = gate.reason === "exhausted"
|
|
1618
|
+
? `已停止自愈重启 bridge:本进程生命周期内已重启 ${SELFHEAL_MAX_RESTARTS} 次仍未就绪,`
|
|
1619
|
+
+ `为避免"每 ${Math.round(selfhealIntervalMs() / 1000)} 秒杀掉一次正在跑的隧道"已熔断;`
|
|
1620
|
+
+ `请点面板「一键更新」,或查看 bridge 日志确认真正的失败原因`
|
|
1621
|
+
: `已暂缓自愈重启 bridge:距上次重启不足 ${Math.round(selfhealMinRestartMs() / 1000)} 秒`
|
|
1622
|
+
+ `(不会每 ${Math.round(selfhealIntervalMs() / 1000)} 秒重启一次,那样会反复切断手机镜像连接)`;
|
|
1623
|
+
if (rec.blockedLine === line) return false;
|
|
1624
|
+
rec.blockedLine = line;
|
|
1625
|
+
selfhealRestarts.set(relayDir, rec);
|
|
1626
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG, `[dsh-remote-web] ${line}`);
|
|
1627
|
+
return true;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** 桥已确认就绪:清掉护栏状态,让"后来真的再挂一次"能重新自愈(否则额度会被历史故障吃光)。 */
|
|
1631
|
+
function resetSelfhealGuard(relayDir) {
|
|
1632
|
+
selfhealRestarts.delete(relayDir);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1430
1635
|
/** 向日志追加一行(多个子进程写同一日志用 append 模式,互不覆盖)。 */
|
|
1431
1636
|
function appendLogLine(relayDir, name, line) {
|
|
1432
1637
|
try {
|
|
@@ -1936,6 +2141,26 @@ function startBridge(relayDir) {
|
|
|
1936
2141
|
* → watcher `done = true` 永久停摆,自动补装(ensureRuntime)再也不会执行,
|
|
1937
2142
|
* 这正是「市场装完插件、bridge 却起不来」且永不恢复的直接原因;
|
|
1938
2143
|
* - 补装运行环境不该等用户先登录:市场安装只给到插件半,登录那一刻就会 startBridge。
|
|
2144
|
+
*
|
|
2145
|
+
* ★★ 2026-10-13 重写就绪判据与"动手"策略(修线上反馈 fb_3c4c5a13bc47 的 12 秒重启风暴):
|
|
2146
|
+
* 【症状】Linux 用户(npx 安装,0.6.18)的 bridge 被本函数**每 12.001 秒重启一次**,
|
|
2147
|
+
* 一天 3567 次;每次重启换一条新隧道 → 中继 close(1012) 掉该设备所有手机镜像 WS →
|
|
2148
|
+
* 镜像页每 ~12.3 秒断线重连并全量重放 → 用户看到的交互选择窗口"一会显示、一会儿消失"。
|
|
2149
|
+
* 全站上百台设备每天 1.1~1.5 万次重登记,多台设备间隔 p50 精确 ≈12.0s。
|
|
2150
|
+
* 【成因】就绪判据只有 `launchdStatus().running`(macOS)与
|
|
2151
|
+
* `manualStatus().watcher.length > 0`(靠 pgrep 名称匹配,Linux/Windows 共用)。
|
|
2152
|
+
* 这两个判据都**可能在服务其实健康时返回"没在跑"**,而拿到"没在跑"之后直接
|
|
2153
|
+
* `startBridge()` → `systemctl --user restart`(**破坏性**:杀旧进程、建新隧道)。
|
|
2154
|
+
* 于是"判据失败"被逐字翻译成"每 12 秒杀一次隧道",而且 `done` 永远不置位。
|
|
2155
|
+
* 【现在】三层,缺一不可:
|
|
2156
|
+
* ① **权威判据优先**:Linux 问 `systemctl --user is-active dsh-bridge`(见
|
|
2157
|
+
* `linuxSystemdUnitState()`),pgrep 只作为"问不到 systemd 时"的兜底;
|
|
2158
|
+
* ② **成功即止**:任一判据确认就绪 → 立刻 `done = true; clearInterval`,
|
|
2159
|
+
* 不再依赖下一拍去重新猜(旧代码即使这次 startBridge 成功,也要等下一拍 pgrep 认得出来
|
|
2160
|
+
* 才算成功,而它恰恰认不出来 → 永远重启);
|
|
2161
|
+
* ③ **护栏**:确需动手时先过 `selfhealRestartGate()`(≥5 分钟最小间隔 + 本进程内最多 3 次),
|
|
2162
|
+
* 被拦下或触顶都写入**可见**的安装日志(这个故障之所以拖了三个月,正是因为客户端侧
|
|
2163
|
+
* 什么都没记、中继侧又把 churn 日志抑制了,运维全程看不见)。
|
|
1939
2164
|
*/
|
|
1940
2165
|
function scheduleRuntime(relayDir) {
|
|
1941
2166
|
let done = false;
|
|
@@ -1963,21 +2188,85 @@ function scheduleRuntime(relayDir) {
|
|
|
1963
2188
|
const cfg = loadConfig(relayDir);
|
|
1964
2189
|
const hasAcct = Boolean((cfg.phone || cfg.email) && cfg.password) || Boolean(cfg.local_key);
|
|
1965
2190
|
if (!hasAcct) return;
|
|
2191
|
+
// ── ① 权威判据(按平台各问各的服务管理器)──
|
|
1966
2192
|
// 运行环境已在,看服务是否已在运行(runtime 可能位于 npx 缓存/固化目录,不必重复安装)
|
|
1967
2193
|
const st = launchdStatus();
|
|
1968
|
-
if (st.running) {
|
|
2194
|
+
if (st.running) {
|
|
2195
|
+
done = true; clearInterval(iv); resetSelfhealGuard(relayDir);
|
|
2196
|
+
logSelfhealReady(relayDir, cfg, `launchd state=${st.state || "running"}`);
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
// Linux:**systemd 才是权威**。它说 active 就是 bridge 真的在跑(隧道是它的职责),
|
|
2200
|
+
// 此时哪怕 pgrep 一个进程都没匹配上,也必须认为"就绪"并停摆 —— 这正是本次故障的正解。
|
|
2201
|
+
if (isLinux()) {
|
|
2202
|
+
const sys = linuxSystemdUnitState();
|
|
2203
|
+
if (sys.active) {
|
|
2204
|
+
done = true; clearInterval(iv); resetSelfhealGuard(relayDir);
|
|
2205
|
+
logSelfhealReady(relayDir, cfg, "systemd --user is-active=active");
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
// ── ② 兜底判据(问不到服务管理器时,还是得靠进程探测;Windows 只有这一条)──
|
|
1969
2210
|
// 非 macOS(Windows **与 Linux**)没有 launchd:以「watcher 进程在跑」为就绪判据。
|
|
1970
2211
|
// 否则 launchdStatus 永远是 false,done 永远不置位,watcher 会一直空转。
|
|
1971
2212
|
// ★ Linux 也必须走这一支:`startBridge` 现在能在 Linux 上真正拉起 bridge(0.6.15),
|
|
1972
2213
|
// 但就绪判据漏了 Linux 的话,每轮自愈都会重复走一遍"写 unit + 起进程"。
|
|
1973
|
-
if (!isDarwin() && manualStatus(relayDir).watcher.length > 0) {
|
|
1974
|
-
|
|
2214
|
+
if (!isDarwin() && manualStatus(relayDir).watcher.length > 0) {
|
|
2215
|
+
done = true; clearInterval(iv); resetSelfhealGuard(relayDir);
|
|
2216
|
+
logSelfhealReady(relayDir, cfg, "进程探测(pgrep/pid 文件)");
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
// ── ③ 确实没在跑 → 过护栏后才动手(绝不允许"判据失败"直接变成"每 12 秒杀一次隧道")──
|
|
2220
|
+
const gate = selfhealRestartGate(relayDir);
|
|
2221
|
+
if (!gate.allowed) { noteSelfhealBlocked(relayDir, gate); return; }
|
|
2222
|
+
const r = startBridge(relayDir); // 环境在但服务没起 → 拉起
|
|
2223
|
+
if (r && r.ok) {
|
|
2224
|
+
// 起来了:立刻置位 + 记账 + 清掉失败去重状态。**不再等下一拍重新判据** ——
|
|
2225
|
+
// 旧代码把"是否成功"交给下一拍的 pgrep 去回答,而它答不出来,于是永远重启。
|
|
2226
|
+
// `accounted` = 这次重启已经在 startBridgeLinux/guardedSystemdRestart 里记过账,别再记一次。
|
|
2227
|
+
if (!r.reused && !r.accounted) noteSelfhealRestart(relayDir, "权威判据说没在跑", r.status || "running");
|
|
2228
|
+
clearAutostartFailure(relayDir);
|
|
2229
|
+
done = true; clearInterval(iv); resetSelfhealGuard(relayDir);
|
|
2230
|
+
logSelfhealReady(relayDir, cfg, `startBridge=${r.status || "running"}${r.reused ? "(幂等:本来就在跑)" : ""}`);
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
// 测试隔离(DSH_RELAY_SKIP_SERVICE=1):startBridge 只会返回 `skipped` —— 它**什么都没做**,
|
|
2234
|
+
// 既不该吃掉护栏额度,也不该往安装日志里写"自愈重启"(生产不会出现这个状态)。
|
|
2235
|
+
if (r && r.status === "skipped") return;
|
|
2236
|
+
// Linux 的 systemd 重启已经在 `guardedSystemdRestart` 里记过账、也写过日志(含"被拦下"),
|
|
2237
|
+
// 这里不能再记一次 —— 否则一次尝试吃掉两格额度,5 分钟的退避会缩水成 2.5 分钟。
|
|
2238
|
+
if (r && r.status === "selfheal-throttled") return;
|
|
2239
|
+
// 其它路径(macOS launchd 失败 / Windows / 脱离进程)没有自带护栏:在此记账 + 写可见日志,
|
|
2240
|
+
// 否则"拉起失败 → 下一拍再试 → 每 12 秒一次"就是同一个病换个平台复发。
|
|
2241
|
+
noteSelfhealRestart(relayDir, "权威判据说没在跑", (r && r.detail) || (r && r.status) || "未知原因");
|
|
2242
|
+
// 触顶:护栏会在下一拍开始拦住我们并写一行"已停止自愈重启",这里不再做任何动作。
|
|
1975
2243
|
} catch { /* 下一轮再试 */ }
|
|
1976
2244
|
}, selfhealIntervalMs());
|
|
1977
2245
|
iv.unref?.();
|
|
1978
2246
|
return () => clearInterval(iv);
|
|
1979
2247
|
}
|
|
1980
2248
|
|
|
2249
|
+
/**
|
|
2250
|
+
* 自愈判定「已就绪、循环停摆」时留一行可见日志(同一条结论只写一次)。
|
|
2251
|
+
*
|
|
2252
|
+
* 为什么值得单独记一行:用户反馈里"连接是不是自己在修、修好了没有"无从判断 ——
|
|
2253
|
+
* 旧代码在就绪时**什么都不写**,于是"自愈watcher 判据失败→每 12 秒重启"这种故障
|
|
2254
|
+
* 在日志里只留下 bridge 反复重启的痕迹,看不出是插件在动手。这一行把"是我判定的、依据是什么"钉住。
|
|
2255
|
+
* ⚠️ 带上**隧道注册**证据(bridge 状态文件里的 device_id / 日志尾部),但不拿它当就绪判据:
|
|
2256
|
+
* 它比进程存活更容易闪断,若用它当 done 的条件,等于又造一个会抖的判据(正是本次故障的病因)。
|
|
2257
|
+
*/
|
|
2258
|
+
function logSelfhealReady(relayDir, cfg, basis) {
|
|
2259
|
+
if (lastSelfhealReadyLine.get(relayDir) === basis) return;
|
|
2260
|
+
lastSelfhealReadyLine.set(relayDir, basis);
|
|
2261
|
+
const state = bridgeStateFile(relayDir);
|
|
2262
|
+
const registered = Boolean(state && state.device_id) && (!cfg || !cfg.device_id || String(state.device_id) === String(cfg.device_id));
|
|
2263
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
2264
|
+
`[dsh-remote-web] 自愈判定 bridge 已就绪,已停止自愈循环(依据:${basis};`
|
|
2265
|
+
+ `隧道注册:${registered ? `已登记(device_id=${state.device_id})` : "尚无登记证据(不影响就绪判定,注册由 bridge/watcher 自己负责)"})`);
|
|
2266
|
+
}
|
|
2267
|
+
/** `logSelfhealReady` 的去重状态:同一个 relayDir 只有判据变了才再写一行。 */
|
|
2268
|
+
const lastSelfhealReadyLine = new Map();
|
|
2269
|
+
|
|
1981
2270
|
/** 停止 bridge:macOS 走 launchctl bootout;Linux 走 systemd/脱离进程;Windows 结束 watcher/bridge 进程。 */
|
|
1982
2271
|
function stopBridge(relayDir) {
|
|
1983
2272
|
if (isWindows()) return stopBridgeDetached(relayDir);
|
|
@@ -4096,6 +4385,10 @@ function accountFingerprintOf(cfg) {
|
|
|
4096
4385
|
* 自愈也就永远不会去纠正);③ 交给调用方随后重新启动。
|
|
4097
4386
|
*/
|
|
4098
4387
|
function resetBridgeForAccountChange(relayDir, why) {
|
|
4388
|
+
// ★ 2026-10-13:账号/模式变更是**人**做的决定(登录、切换账号、改自建模式),
|
|
4389
|
+
// 它必须无条件生效 —— 所以顺手清掉自愈护栏额度,绝不允许"自愈循环之前用完了 3 次额度"
|
|
4390
|
+
// 导致切换账号后新 bridge 起不来(那会让设备继续留在旧账号里,是 2026-09-19 那个老故障的翻版)。
|
|
4391
|
+
resetSelfhealGuard(relayDir);
|
|
4099
4392
|
const stopped = stopBridge(relayDir);
|
|
4100
4393
|
// 脱离进程形态(launchd 托管不住时的兜底)也要收干净,否则它会用旧凭据继续跑
|
|
4101
4394
|
try { stopBridgeDetached(relayDir, { force: true }); } catch { /* 非关键 */ }
|
|
@@ -4626,6 +4919,10 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
4626
4919
|
watcherAgeMs: wedge.known ? wedge.ageMs : 0,
|
|
4627
4920
|
watcherWedged: Boolean(wedge.wedged),
|
|
4628
4921
|
watcherRestarts: wedge.restarts,
|
|
4922
|
+
// 自愈循环自己的重启账本(见 scheduleRuntime 的长注释):面板诊断据此如实说明
|
|
4923
|
+
// "插件为这台机器重启过几次 bridge、是不是已经熔断停手"。默认 0 = 正常,不产生任何文案。
|
|
4924
|
+
selfhealRestarts: selfhealGuardOf(relayDir).count,
|
|
4925
|
+
selfhealExhausted: selfhealGuardOf(relayDir).count >= SELFHEAL_MAX_RESTARTS,
|
|
4629
4926
|
upstreamUrl: upstreamUrl(),
|
|
4630
4927
|
upstreamSource: upstreamSourceLabel(),
|
|
4631
4928
|
// 宿主形态:桌面端(Electron)与 Web 版(node)在若干动作上语义不同 ——
|
|
@@ -4723,6 +5020,12 @@ function buildConnectDiagnostics(relayDir, conn, log) {
|
|
|
4723
5020
|
: `运行环境缺文件: ${conn.runtimeMissing.slice(0, 8).join(", ")}${conn.runtimeMissing.length > 8 ? ` 等 ${conn.runtimeMissing.length} 个` : ""}`,
|
|
4724
5021
|
`bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
|
|
4725
5022
|
`后台守护: ${conn.watcherRunning ? `在运行(pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `,已 ${Math.round(conn.watcherAgeMs / 1000)} 秒` : ""}${conn.watcherWedged ? ",⚠️ 迟迟未拉起 bridge" : ""}${conn.watcherRestarts ? `,已自动重启 ${conn.watcherRestarts} 次` : ""})` : "未运行"}`,
|
|
5023
|
+
// 只在真的重启过 bridge 时才出现这一行(正常用户零新增文案,不制造噪音):
|
|
5024
|
+
// 它回答的是"插件自己有没有在反复重启 bridge"—— 2026-10-13 那个故障里,这个问题
|
|
5025
|
+
// 在客户端侧完全没有答案,运维只能从服务端 audit_log 反推。
|
|
5026
|
+
conn.selfhealRestarts
|
|
5027
|
+
? `自愈重启: 本进程内已重启 bridge ${conn.selfhealRestarts} 次${conn.selfhealExhausted ? "(⚠️ 已达上限,已停止自动重启,请点「一键更新」)" : ""}`
|
|
5028
|
+
: "",
|
|
4726
5029
|
`上游 dsh web: ${conn.upstreamUrl}(${conn.upstreamSource}${conn.upstreamReachable === null ? "" : conn.upstreamReachable ? ",可达" : `,⚠️ 不可达:${conn.upstreamError}`})`,
|
|
4727
5030
|
`目录选择器: ${pickerPinState.kind === "native" ? "⚠️ 系统原生(远程时对话框弹在电脑那台机器上 → 手机上点「添加工作区」会像没反应)" : (pickerPinState.kind || "未知")}${pickerPinState.detail ? `(${pickerPinState.detail})` : ""}`,
|
|
4728
5031
|
`中继注册: ${conn.registered ? "已注册(" + conn.registerSource + ")" : "未注册"}`,
|
|
@@ -4782,7 +5085,7 @@ async function buildDiagReport(relayDir, opts = {}) {
|
|
|
4782
5085
|
`【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} 宿主 ${harnessVersion() || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
|
|
4783
5086
|
`【阶段】${conn.phase}(${conn.text})`,
|
|
4784
5087
|
`【设备】ID=${conn.deviceId || "(未生成)"} 运行环境=${conn.runtimeReady ? "就绪" : `缺失${conn.runtimeMissing && conn.runtimeMissing.length ? `(缺 ${conn.runtimeMissing.slice(0, 3).join("、")})` : ""}`}`,
|
|
4785
|
-
`【bridge】${conn.bridgeRunning ? `在运行 pid=${conn.bridgePids.join(",")}` : "未运行"} 守护=${conn.watcherRunning ? `在运行 pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `(${Math.round(conn.watcherAgeMs / 1000)}s)` : ""}` : "未运行"}${conn.watcherWedged ? " ⚠️活着但拉不起 bridge" : ""}${conn.watcherRestarts ? ` 已自动重启 ${conn.watcherRestarts} 次` : ""}`,
|
|
5088
|
+
`【bridge】${conn.bridgeRunning ? `在运行 pid=${conn.bridgePids.join(",")}` : "未运行"} 守护=${conn.watcherRunning ? `在运行 pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `(${Math.round(conn.watcherAgeMs / 1000)}s)` : ""}` : "未运行"}${conn.watcherWedged ? " ⚠️活着但拉不起 bridge" : ""}${conn.watcherRestarts ? ` 已自动重启 ${conn.watcherRestarts} 次` : ""}${conn.selfhealRestarts ? ` 【自愈重启】本进程内 ${conn.selfhealRestarts} 次${conn.selfhealExhausted ? "(已达上限,已停止自动重启)" : ""}` : ""}`,
|
|
4786
5089
|
`【上游】${conn.upstreamUrl}(${conn.upstreamSource}${conn.upstreamReachable === null ? "" : conn.upstreamReachable ? ",可达" : `,⚠️不可达:${conn.upstreamError}`})`,
|
|
4787
5090
|
`【中继】${conn.registered ? `已注册(${conn.registerSource})` : "未注册"} 账号=${conn.accountCurrent === false ? "⚠️上一个账号(需重启 bridge)" : "当前账号"} 自动重试=${conn.attempts} 次`,
|
|
4788
5091
|
`【错误】${conn.error ? `${conn.error.code}: ${conn.error.message}` : (conn.detail ? "无(见上方阶段说明)" : "无")}`,
|
|
@@ -4868,6 +5171,10 @@ function ensureConnection(relayDir, opts = {}) {
|
|
|
4868
5171
|
// 顺手治一次「卡住的更新」(复用面板已有的轮询,不需要新定时器 —— 诊断报告 F5)
|
|
4869
5172
|
healStalledJobs(relayDir);
|
|
4870
5173
|
const book = connectBook(relayDir);
|
|
5174
|
+
// ★ 2026-10-13:**人工操作永远立即放行**(与 provisionRetryGate 的 resetProvisionRetry 同一原则)。
|
|
5175
|
+
// 面板的「重试 / 一键修复」走的就是 force=true;若不放行,护栏触顶后用户点重试会静默无效 ——
|
|
5176
|
+
// 那就把"安全阀"变成了"用户被自己的插件锁在门外"。护栏只约束**自动**自愈,不约束人。
|
|
5177
|
+
if (opts.force) resetSelfhealGuard(relayDir);
|
|
4871
5178
|
if (opts.force) { book.attempts = 0; book.lastAttemptAt = 0; }
|
|
4872
5179
|
const now = Date.now();
|
|
4873
5180
|
if (!opts.force && book.lastAttemptAt && now - book.lastAttemptAt < connectBackoffMs(book.attempts)) return null;
|
|
@@ -6651,6 +6958,9 @@ function registerRoutes(ctx, relayDir) {
|
|
|
6651
6958
|
method: "POST",
|
|
6652
6959
|
path: "/dsh-remote/start",
|
|
6653
6960
|
handler: async (_req, res) => {
|
|
6961
|
+
// 用户点了「启动」= 人工指令:清掉自愈护栏额度再动手(同 resetProvisionRetry 的原则),
|
|
6962
|
+
// 否则"自动自愈刚刚熔断"会把用户这一次明确的操作也一起挡掉。
|
|
6963
|
+
resetSelfhealGuard(relayDir);
|
|
6654
6964
|
const r = startBridge(relayDir);
|
|
6655
6965
|
// 【0.6.2】composeStatus 自带 ok:true(那是「状态查询成功」),必须先展开再覆盖,
|
|
6656
6966
|
// 否则不管启动成没成功都回 ok:true → 面板一律弹「✅ bridge 已启动」。
|
|
@@ -7210,4 +7520,15 @@ export function apply(ctx, config = {}) {
|
|
|
7210
7520
|
// 单独导出才能把「**先摘 auto、后建 browse**」这个顺序契约钉死。
|
|
7211
7521
|
export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerPin as __schedulePickerPin, pickerPinState as __pickerPinState, directoryPickerKind as __directoryPickerKind, PICKER_AUTO_ENTRY_ID as __PICKER_AUTO_ENTRY_ID, PICKER_AUTO_PACKAGE as __PICKER_AUTO_PACKAGE, PICKER_BROWSE_PACKAGES as __PICKER_BROWSE_PACKAGES,
|
|
7212
7522
|
// 日志去重(同一条失败不刷屏)也要能被单测钉住 —— 它属于「诊断可读性」,见 test/linux-bridge.test.mjs
|
|
7213
|
-
noteAutostartFailure as __noteAutostartFailure, clearAutostartFailure as __clearAutostartFailure
|
|
7523
|
+
noteAutostartFailure as __noteAutostartFailure, clearAutostartFailure as __clearAutostartFailure,
|
|
7524
|
+
// ★ 2026-10-13 自愈重启策略(修 12 秒重启风暴)必须能被**行为级**单测钉住,见 test/selfheal-restart-guard.test.mjs。
|
|
7525
|
+
// 为什么必须导出而不是只靠 apply():这一组用例要断言的是"定时器跑 N 拍之后,
|
|
7526
|
+
// `systemctl restart` 到底被调用了几次"——只有把定时器本体拿到用例里才能确定性地驱动它,
|
|
7527
|
+
// 靠 apply() 间接跑就得靠真实 sleep 猜拍数(脆弱且慢)。
|
|
7528
|
+
// 系统命令仍然走**假 PATH 上的假 systemctl**(与 test/linux-bridge.test.mjs 同一套手法),
|
|
7529
|
+
// 所以不需要为了可测性往生产代码里插入任何依赖注入层。
|
|
7530
|
+
scheduleRuntime as __scheduleRuntime, startBridge as __startBridge, manualStatus as __manualStatus,
|
|
7531
|
+
linuxSystemdUnitState as __linuxSystemdUnitState,
|
|
7532
|
+
selfhealRestartGate as __selfhealRestartGate, noteSelfhealRestart as __noteSelfhealRestart,
|
|
7533
|
+
noteSelfhealBlocked as __noteSelfhealBlocked, resetSelfhealGuard as __resetSelfhealGuard,
|
|
7534
|
+
selfhealGuardOf as __selfhealGuardOf, SELFHEAL_MAX_RESTARTS as __SELFHEAL_MAX_RESTARTS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1-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",
|
|
@@ -85,6 +85,30 @@
|
|
|
85
85
|
* ③ 遮罩不变量:只要遮罩在拦截点击而视口内没有任何可交互抽屉,立刻摘掉(800ms 看门狗 +
|
|
86
86
|
* 300ms 复核),且**只摘遮罩不动展开 class**(不破坏抽屉的滑入动画与"点一下就打开")。
|
|
87
87
|
*
|
|
88
|
+
* 🔴 2026-10-13 手机端设置面板「挤成一条竖线」根治(用户反馈,真机 390×844 + 本机 0.2 宿主实测):
|
|
89
|
+
* 现象:窄屏打开「设置」,左侧 nav(通用设置/模型/内置插件/…)是一条竖列,右侧内容被压成
|
|
90
|
+
* 四五字宽的竖条并向右溢出被裁。用户明确要求的目标形态:**tab 放顶部(横排)+ 下方内容满宽**。
|
|
91
|
+
* 根因(实测数字):官方设置弹层是**桌面两栏**布局 —— 本机 0.2.0-rc.2(dsh-web-frontend
|
|
92
|
+
* 0.2 + dsh-client-ui-settings-general 0.2)实测 panel 342×796、`nav` 188 宽(flex column)、
|
|
93
|
+
* `content` 可视宽只剩 **154px**。而适配层只挂了 `[class~="VOzbGW_*"]` 精确类,那是
|
|
94
|
+
* **0.1.2–0.1.5** 的混淆前缀;0.2 重新打包后前缀变成 **`Dws9Sa_*`**(结构、类名一一对应:
|
|
95
|
+
* Dws9Sa_overlay/panel/nav/navTitle/navList/navCell/content/header/close/options)
|
|
96
|
+
* → 整段设置区 CSS **静默失效**。这正是文件开头"类名变化退化策略"预言过的失效模式:
|
|
97
|
+
* 页面照常打开、只有真机看得出布局坏,而且没人会收到任何信号。
|
|
98
|
+
* 修法(三层识别,任一层命中即可成型,不再可能整段静默失效):
|
|
99
|
+
* ① 已知前缀:`[class~="VOzbGW_*"]` 与 `[class~="Dws9Sa_*"]` 并挂(CSS 与 JS 都改);
|
|
100
|
+
* ② **结构兜底**:JS 按 `[role=dialog]/[aria-modal] + 直接子 <nav> + 近似全屏` 认面板,
|
|
101
|
+
* 再按"子元素最多者为 tab 列表"认 navList,打 `dsh-ma-settings-*` 稳定类,由 CSS 成型
|
|
102
|
+
* —— 前缀再改也是"仍然基本可用",而不是彻底坏掉;
|
|
103
|
+
* ③ CSS `:has()` 兜底(`div[role="dialog"]:has(> nav)` 等):支持 :has 的浏览器连 JS 都不等。
|
|
104
|
+
* 降级诊断(**可见**,但不引入任何覆盖层 —— 护栏明令禁止):① 前缀认不出 → 报
|
|
105
|
+
* `console.warn("[dsh-mobile-adapter]", …注意 …混淆前缀已变化…)` 一次;② 有"大弹层"却认不出
|
|
106
|
+
* 设置面板结构 → 报一次"手机端可能仍是桌面两栏布局"。两条都只在 ≤820px 发生。
|
|
107
|
+
* 同批:窄屏**通用弹层**按手机尺寸收敛(`[role=dialog]/[role=alertdialog]/[aria-modal]/.dru-popup-card`
|
|
108
|
+
* 宽 ≤ 100vw-24px、高 ≤ 100dvh-24px 且内部可滚、圆角收敛 12px),修掉"内容裁在右边"。
|
|
109
|
+
* ⚠️ 顺序有讲究:通用弹层规则必须排在设置弹层规则**之前** —— 两者特异性相同,靠顺序让
|
|
110
|
+
* "设置面板铺满全屏"赢(否则设置面板会被 100vw-24px 钉住,又变成带边的浮层)。
|
|
111
|
+
*
|
|
88
112
|
* 开关:环境变量 DSH_MOBILE_ADAPTER=0 整体关闭(默认开启)。
|
|
89
113
|
*/
|
|
90
114
|
|
|
@@ -99,6 +123,17 @@ const HOST_FEATURE_RE =
|
|
|
99
123
|
const RUNTIME_HOST_RE =
|
|
100
124
|
/(data-sidebar-collapsed|data-details-collapsed|data-rightbar-collapsed|data-shell-overlay)|(pI_x6G_frame|hHd-Xa_root|hHd-Xa_toggle)/;
|
|
101
125
|
|
|
126
|
+
/**
|
|
127
|
+
* 【可选】官方锚点清单 —— 供 scripts/check-ui-anchors.mjs 分类用(它直接读这个常量)。
|
|
128
|
+
* 这些名字**存在就用、不存在就按结构/几何退化**,所以它们缺失**不算故障**:
|
|
129
|
+
* · data-details-collapsed —— 官方 0.1.5-rc.2 已改名 data-rightbar-collapsed,这里只为兼容旧版保留;
|
|
130
|
+
* · data-sidebar-open —— 官方是否输出这个属性本身就不确定(见下面对 sidebarAttrSeen 的注释),
|
|
131
|
+
* 适配层刻意做了"认不到就只信几何"的分支;
|
|
132
|
+
* · data-rtt —— 适配层**自己**的延迟角标属性(不是官方锚点),列出只为让自检脚本不去它身上找。
|
|
133
|
+
* 约定:这里**只放**这种"缺了也无所谓"的锚点;凡是缺了会让布局坏掉的锚点,一律不要写进来。
|
|
134
|
+
*/
|
|
135
|
+
const OPTIONAL_UI_ANCHORS = ["data-details-collapsed", "data-sidebar-open", "data-rtt"];
|
|
136
|
+
|
|
102
137
|
const STYLE = `
|
|
103
138
|
|
|
104
139
|
@media (max-width: 820px) {
|
|
@@ -226,11 +261,42 @@ const STYLE = `
|
|
|
226
261
|
div.wSkVaW_heroWorkspaceRow > * { max-width: calc(100vw - 32px); }
|
|
227
262
|
div.wSkVaW_composerStack, div.wSkVaW_composerHero, div.uV2eYG_card { min-width: 0; }
|
|
228
263
|
|
|
229
|
-
/*
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
264
|
+
/* ===== 通用弹层(dialog/modal)按手机尺寸收敛(2026-10-13)=====
|
|
265
|
+
官方弹层(设置 / 审批 / 确认 / 各类 modal)在桌面上写死 px 宽高,窄屏会右溢被裁;
|
|
266
|
+
本项目自己的 .dru-popup 卡片同理。这里只加三条**尺寸**硬约束,不动配色与结构:
|
|
267
|
+
· 宽 ≤ 100vw - 24px(两侧各留 12px)—— 用户报的"内容裁在右边"根治;
|
|
268
|
+
· 高 ≤ 100dvh - 24px 且自身可滚(先写 100vh 给不认 dvh 的老浏览器兜底);
|
|
269
|
+
· 窄屏圆角收敛到 12px(官方 --dsw-radius-panel 在手机上偏大)。
|
|
270
|
+
⚠️ 必须放在设置弹层规则**之前**:两者特异性相同,靠顺序让"设置面板铺满全屏"赢。 */
|
|
271
|
+
[role="dialog"], [role="alertdialog"], [aria-modal="true"], .dru-popup-card {
|
|
272
|
+
box-sizing: border-box;
|
|
273
|
+
max-width: calc(100vw - 24px) !important;
|
|
274
|
+
max-height: calc(100vh - 24px) !important;
|
|
275
|
+
max-height: calc(100dvh - 24px) !important;
|
|
276
|
+
border-radius: 12px !important;
|
|
277
|
+
overflow: auto !important;
|
|
278
|
+
overscroll-behavior: contain;
|
|
279
|
+
-webkit-overflow-scrolling: touch;
|
|
280
|
+
}
|
|
281
|
+
/* .dru-popup 是遮罩层,卡片 .dru-popup-card 才是"弹窗":窄屏把 24px 外边距收到 12px */
|
|
282
|
+
.dru-popup { padding: 12px !important; }
|
|
283
|
+
.dru-popup-card { width: 100% !important; max-width: calc(100vw - 24px) !important; }
|
|
284
|
+
|
|
285
|
+
/* ===== 设置弹层:窄屏「顶部横向 tab 行 + 下方内容区满宽」(2026-10-13 重写)=====
|
|
286
|
+
🔴 根因(真机 390×844 实测,用户报「设置界面挤成一条竖线」):
|
|
287
|
+
官方弹层是**桌面两栏** —— 0.2.0-rc.2 实测 panel 342×796、nav 188 宽竖排、
|
|
288
|
+
content(内容区)只剩 **154px** 可视宽 → 内容被压成竖条并向右溢出被裁。
|
|
289
|
+
旧实现只有 [class~="VOzbGW_*"](0.1.2–0.1.5 的混淆前缀);0.2.0-rc.2 重新打包后
|
|
290
|
+
前缀变成 Dws9Sa_*,**整段 CSS 静默失效** —— 页面照常打开,只有真机看得出坏。
|
|
291
|
+
所以识别分三层,任一层命中即可成型(这是"不再静默失效"的核心):
|
|
292
|
+
① 已知前缀 [class~="VOzbGW_*"] / [class~="Dws9Sa_*"](最短路径,随版本追加);
|
|
293
|
+
② JS 结构识别后打的稳定类 .dsh-ma-settings-*(role=dialog + 直接子 <nav> + 几何,
|
|
294
|
+
见脚本里的 maTagSettingsDialog)—— 前缀再改也照样生效;
|
|
295
|
+
③ CSS :has() 兜底(div[role="dialog"]:has(> nav) 等),不必等 JS 就能成型。
|
|
296
|
+
⚠️ 全部 !important:官方模块 CSS 是**运行期**注入 head 的(晚于本 <style>),
|
|
297
|
+
同特异性下后加载者赢 —— fix2 之前 nav 选择器"从未命中"正是这个原因。 */
|
|
298
|
+
[class~="VOzbGW_overlay"], [class~="Dws9Sa_overlay"], .dsh-ma-settings-overlay,
|
|
299
|
+
div[role="presentation"]:has(> div[role="dialog"] > nav) {
|
|
234
300
|
position: fixed !important;
|
|
235
301
|
inset: 0 !important;
|
|
236
302
|
width: auto !important; max-width: none !important;
|
|
@@ -240,31 +306,37 @@ const STYLE = `
|
|
|
240
306
|
box-sizing: border-box;
|
|
241
307
|
justify-content: center; align-items: stretch;
|
|
242
308
|
}
|
|
243
|
-
|
|
309
|
+
/* 面板:nav 在上、content 在下的**竖排**,并铺满视口(圆角/外边距在窄屏收敛为 0) */
|
|
310
|
+
[class~="VOzbGW_panel"], [class~="Dws9Sa_panel"], .dsh-ma-settings-panel,
|
|
311
|
+
div[role="dialog"]:has(> nav) {
|
|
244
312
|
box-sizing: border-box;
|
|
245
313
|
width: 100% !important; max-width: 100% !important;
|
|
246
314
|
height: 100% !important; max-height: 100% !important;
|
|
247
315
|
margin: 0 !important; border-radius: 0 !important;
|
|
248
316
|
flex-direction: column !important;
|
|
317
|
+
overflow: hidden !important; /* 内层 options 自己滚 */
|
|
249
318
|
}
|
|
250
319
|
/* 顶部 tab 行:整行铺满,字号调小(≤13px),横向可滚动 */
|
|
251
|
-
[class~="VOzbGW_nav"]
|
|
320
|
+
[class~="VOzbGW_nav"], [class~="Dws9Sa_nav"], .dsh-ma-settings-nav,
|
|
321
|
+
div[role="dialog"] > nav {
|
|
252
322
|
box-sizing: border-box;
|
|
253
323
|
flex: none;
|
|
254
324
|
width: 100% !important;
|
|
255
325
|
flex-direction: row !important;
|
|
256
326
|
align-items: center;
|
|
257
327
|
gap: 4px;
|
|
258
|
-
padding: max(4px, env(safe-area-inset-top))
|
|
328
|
+
padding: max(4px, env(safe-area-inset-top)) 8px 6px;
|
|
259
329
|
overflow: visible;
|
|
260
330
|
}
|
|
261
|
-
[class~="VOzbGW_navTitle"]
|
|
331
|
+
[class~="VOzbGW_navTitle"], [class~="Dws9Sa_navTitle"], .dsh-ma-settings-navtitle,
|
|
332
|
+
div[role="dialog"] > nav > :first-child {
|
|
262
333
|
flex: none;
|
|
263
334
|
font-size: 13px !important; line-height: 1.2;
|
|
264
335
|
padding: 0 4px 0 8px;
|
|
265
336
|
white-space: nowrap;
|
|
266
337
|
}
|
|
267
|
-
[class~="VOzbGW_navList"]
|
|
338
|
+
[class~="VOzbGW_navList"], [class~="Dws9Sa_navList"], .dsh-ma-settings-navlist,
|
|
339
|
+
div[role="dialog"] > nav > :last-child {
|
|
268
340
|
box-sizing: border-box;
|
|
269
341
|
flex: 1 1 auto; min-width: 0;
|
|
270
342
|
flex-direction: row !important;
|
|
@@ -274,33 +346,40 @@ const STYLE = `
|
|
|
274
346
|
scrollbar-width: none;
|
|
275
347
|
padding: 0 0 6px;
|
|
276
348
|
}
|
|
277
|
-
[class~="VOzbGW_navList"]::-webkit-scrollbar
|
|
278
|
-
[class~="
|
|
349
|
+
[class~="VOzbGW_navList"]::-webkit-scrollbar,
|
|
350
|
+
[class~="Dws9Sa_navList"]::-webkit-scrollbar,
|
|
351
|
+
.dsh-ma-settings-navlist::-webkit-scrollbar { display: none; }
|
|
352
|
+
[class~="VOzbGW_navCell"], [class~="Dws9Sa_navCell"], .dsh-ma-settings-cell,
|
|
353
|
+
div[role="dialog"] > nav > :last-child > * {
|
|
279
354
|
flex: none; min-width: max-content;
|
|
280
355
|
box-sizing: border-box;
|
|
281
356
|
font-size: 13px !important;
|
|
282
357
|
height: 32px !important;
|
|
283
358
|
padding: 0 12px !important;
|
|
359
|
+
white-space: nowrap;
|
|
284
360
|
}
|
|
285
361
|
/* 内容区:全宽拉满,收敛内边距,最大化有效宽度 */
|
|
286
|
-
[class~="VOzbGW_content"]
|
|
362
|
+
[class~="VOzbGW_content"], [class~="Dws9Sa_content"], .dsh-ma-settings-content,
|
|
363
|
+
div[role="dialog"]:has(> nav) > div {
|
|
287
364
|
box-sizing: border-box;
|
|
288
365
|
width: 100% !important; max-width: none !important;
|
|
289
366
|
flex: 1 1 auto; min-width: 0; min-height: 0;
|
|
290
367
|
}
|
|
291
|
-
[class~="VOzbGW_header"] {
|
|
368
|
+
[class~="VOzbGW_header"], [class~="Dws9Sa_header"], .dsh-ma-settings-header {
|
|
292
369
|
box-sizing: border-box;
|
|
293
370
|
flex: none;
|
|
294
|
-
min-height: 40px;
|
|
371
|
+
min-height: 40px; height: auto !important;
|
|
295
372
|
padding: 6px 8px 4px 14px !important;
|
|
296
373
|
align-items: center;
|
|
297
374
|
}
|
|
298
|
-
[class~="VOzbGW_close"]
|
|
299
|
-
|
|
375
|
+
[class~="VOzbGW_close"], [class~="Dws9Sa_close"], .dsh-ma-settings-close {
|
|
376
|
+
width: 32px !important; height: 32px !important;
|
|
377
|
+
}
|
|
378
|
+
[class~="VOzbGW_options"], [class~="Dws9Sa_options"], .dsh-ma-settings-options {
|
|
300
379
|
box-sizing: border-box;
|
|
301
380
|
flex: 1 1 auto; min-height: 0; overflow-y: auto;
|
|
302
381
|
-webkit-overflow-scrolling: touch;
|
|
303
|
-
padding:
|
|
382
|
+
padding: 4px 14px calc(14px + env(safe-area-inset-bottom)) !important;
|
|
304
383
|
}
|
|
305
384
|
}
|
|
306
385
|
|
|
@@ -995,13 +1074,177 @@ const SCRIPT = `(() => {
|
|
|
995
1074
|
whaleNativeDragToBottomRight();
|
|
996
1075
|
}
|
|
997
1076
|
|
|
1077
|
+
/* ===== 设置面板:结构识别 + 稳定类(2026-10-13;根因见 CSS 里那段长注释)=====
|
|
1078
|
+
为什么不能只靠 [class~="VOzbGW_*"]:官方重新打包会换混淆前缀(0.1.5 = VOzbGW_,
|
|
1079
|
+
0.2.0-rc.2 = Dws9Sa_),改名后整段 CSS **静默失效** —— 页面照常打开,
|
|
1080
|
+
只是手机端布局坏掉,而且只有真机看得出。所以这里再按**结构 + 几何**认一遍:
|
|
1081
|
+
· 面板 = [role=dialog]/[aria-modal] 且**直接子元素里有 <nav>**、尺寸接近全屏
|
|
1082
|
+
(0.1.5 与 0.2 的面板都是 div[role=dialog] > nav + div 两栏,且都是固定像素宽);
|
|
1083
|
+
· 列表 = nav 里子元素最多的那个孩子(单元格 ≥3 个);
|
|
1084
|
+
· content = 面板里除 nav 之外的孩子;header/options = content 的首/末孩子。
|
|
1085
|
+
命中 → 打 .dsh-ma-settings-* 稳定类,由 CSS 接管(前缀改名后**仍然基本可用**);
|
|
1086
|
+
命中不到 → 原样放过(绝不破坏官方默认),只 warn 一次作为**可见的降级诊断**
|
|
1087
|
+
—— 护栏禁止适配层引入全屏覆盖层,所以诊断只能走 console / 既有上报通道。 */
|
|
1088
|
+
var MA_SETTINGS_PREFIX_RE = /(^|\s)(VOzbGW_|Dws9Sa_)/;
|
|
1089
|
+
var maSettingsPanelEl = null; // 已识别的面板(靠 isConnected 判存活,官方重渲染后自动重认)
|
|
1090
|
+
var maSettingsCells = []; // 已识别的 tab 单元格
|
|
1091
|
+
var maSettingsNextScan = 0; // 未命中时的扫描节流(见 maSettingsMaybeTag)
|
|
1092
|
+
var maSettingsWarned = "";
|
|
1093
|
+
function maSettingsWarn(msg) {
|
|
1094
|
+
if (maSettingsWarned === msg) return;
|
|
1095
|
+
maSettingsWarned = msg;
|
|
1096
|
+
/* window.console 优先(镜像页里它就是页面控制台),拿不到再退到全局 console ——
|
|
1097
|
+
诊断**必须**能出得来,否则"静默失效"只是换了个地方静默。 */
|
|
1098
|
+
try {
|
|
1099
|
+
var c = (typeof window !== "undefined" && window && window.console) || (typeof console !== "undefined" ? console : null);
|
|
1100
|
+
if (c && c.warn) c.warn("[dsh-mobile-adapter]", msg);
|
|
1101
|
+
} catch (eW) {}
|
|
1102
|
+
}
|
|
1103
|
+
function maClsOf(el) { try { return (el && typeof el.className === "string") ? el.className : ""; } catch (eC) { return ""; } }
|
|
1104
|
+
function maKidsOf(el) { try { return (el && el.children) ? [].slice.call(el.children) : []; } catch (eK) { return []; } }
|
|
1105
|
+
/** 面板级几何门:排除小气泡/菜单/下拉,只认接近全屏的弹层。 */
|
|
1106
|
+
function maPanelSized(el) {
|
|
1107
|
+
try {
|
|
1108
|
+
var r = el.getBoundingClientRect();
|
|
1109
|
+
if (!r || !(r.width > 0) || !(r.height > 0)) return false;
|
|
1110
|
+
return r.width >= Math.max(240, window.innerWidth * 0.5) && r.height >= 160;
|
|
1111
|
+
} catch (eG) { return false; }
|
|
1112
|
+
}
|
|
1113
|
+
/** 结构识别:找到官方设置面板(flex 两栏 = 直接子 <nav> + 另一栏)。 */
|
|
1114
|
+
function maFindSettingsPanel() {
|
|
1115
|
+
var cands = [];
|
|
1116
|
+
try {
|
|
1117
|
+
cands = [].slice.call(document.querySelectorAll('[role="dialog"], [role="alertdialog"], [aria-modal="true"]'));
|
|
1118
|
+
} catch (eQ) { return null; }
|
|
1119
|
+
var best = null, bestArea = 0;
|
|
1120
|
+
for (var i = 0; i < cands.length; i++) {
|
|
1121
|
+
var el = cands[i];
|
|
1122
|
+
var kids = maKidsOf(el);
|
|
1123
|
+
if (kids.length < 2) continue; // 两栏才有 content 可谈
|
|
1124
|
+
var nav = null;
|
|
1125
|
+
for (var j = 0; j < kids.length; j++) { if (kids[j] && kids[j].tagName === "NAV") { nav = kids[j]; break; } }
|
|
1126
|
+
if (!nav || maKidsOf(nav).length < 2) continue; // 设置面板的 nav = 标题 + tab 列表
|
|
1127
|
+
if (!maPanelSized(el)) continue;
|
|
1128
|
+
var r = el.getBoundingClientRect();
|
|
1129
|
+
var area = r.width * r.height;
|
|
1130
|
+
if (area > bestArea) { bestArea = area; best = el; }
|
|
1131
|
+
}
|
|
1132
|
+
return best;
|
|
1133
|
+
}
|
|
1134
|
+
/** nav 里的 tab 列表 = 子元素最多的那个孩子(标题只有一个文本孩子)。 */
|
|
1135
|
+
function maNavListOf(nav) {
|
|
1136
|
+
var kids = maKidsOf(nav);
|
|
1137
|
+
if (!kids.length) return null;
|
|
1138
|
+
var best = null, bestN = 0;
|
|
1139
|
+
for (var i = 0; i < kids.length; i++) {
|
|
1140
|
+
var n = maKidsOf(kids[i]).length;
|
|
1141
|
+
if (n > bestN) { bestN = n; best = kids[i]; }
|
|
1142
|
+
}
|
|
1143
|
+
return bestN >= 2 ? best : (kids.length > 1 ? kids[kids.length - 1] : null);
|
|
1144
|
+
}
|
|
1145
|
+
/** 当前选中项:官方状态类(任意前缀 + _active)或 aria-current —— 两者都不依赖前缀。 */
|
|
1146
|
+
function maCellActive(cell) {
|
|
1147
|
+
try {
|
|
1148
|
+
if (cell.getAttribute && cell.getAttribute("aria-current") === "true") return true;
|
|
1149
|
+
/* 只认「整段 token 以 _active 结尾」或「token 就叫 active」——
|
|
1150
|
+
不能用 *active 这种宽松匹配:inactive 也会命中,会把没选中的 tab 当成当前项。
|
|
1151
|
+
⚠️ 本文件是模板字符串,注释里**不得出现反引号**(会提前闭合注入体,见 AGENTS.md)。 */
|
|
1152
|
+
return /(^|\s)(?:[A-Za-z0-9_-]+_active|active)(\s|$)/.test(maClsOf(cell));
|
|
1153
|
+
} catch (eA) { return false; }
|
|
1154
|
+
}
|
|
1155
|
+
function maMarkActiveCells() {
|
|
1156
|
+
for (var i = 0; i < maSettingsCells.length; i++) {
|
|
1157
|
+
var c = maSettingsCells[i];
|
|
1158
|
+
try { if (c && c.isConnected && maCellActive(c)) c.classList.add("dsh-ma-settings-active"); } catch (eM) {}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
function maTagSettingsDialog() {
|
|
1162
|
+
var panel = maFindSettingsPanel();
|
|
1163
|
+
if (!panel) {
|
|
1164
|
+
/* 认不到面板时给一条**可见的**降级诊断:现场是"大弹层 + 首栏比次栏窄"的桌面两栏形态,
|
|
1165
|
+
说明官方又换了结构(或换成了我们不认识的前缀)—— 这条 warn 就是下次定位问题的起点。 */
|
|
1166
|
+
try {
|
|
1167
|
+
var any = document.querySelector('[role="dialog"], [aria-modal="true"]');
|
|
1168
|
+
if (any && maPanelSized(any)) {
|
|
1169
|
+
maSettingsWarn("检测到大弹层但未能识别为设置面板(官方结构/类名可能已变化):手机端可能仍是桌面两栏布局。");
|
|
1170
|
+
}
|
|
1171
|
+
} catch (eAny) {}
|
|
1172
|
+
maSettingsPanelEl = null; maSettingsCells = [];
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
var kids = maKidsOf(panel);
|
|
1176
|
+
var nav = null, content = null;
|
|
1177
|
+
for (var i = 0; i < kids.length; i++) {
|
|
1178
|
+
if (!nav && kids[i].tagName === "NAV") { nav = kids[i]; continue; }
|
|
1179
|
+
if (!content) content = kids[i];
|
|
1180
|
+
}
|
|
1181
|
+
var list = nav ? maNavListOf(nav) : null;
|
|
1182
|
+
var title = null;
|
|
1183
|
+
var navKids = maKidsOf(nav);
|
|
1184
|
+
for (var k = 0; k < navKids.length; k++) { if (navKids[k] !== list) { title = navKids[k]; break; } }
|
|
1185
|
+
var contentKids = maKidsOf(content);
|
|
1186
|
+
var header = contentKids[0] || null;
|
|
1187
|
+
var options = contentKids.length > 1 ? contentKids[contentKids.length - 1] : null;
|
|
1188
|
+
var overlay = null;
|
|
1189
|
+
try {
|
|
1190
|
+
var p = panel.parentElement || panel.parent || null; // parentElement 是标准属性;parent 兜底给极简 DOM
|
|
1191
|
+
if (p && p.getBoundingClientRect) {
|
|
1192
|
+
var pr = p.getBoundingClientRect();
|
|
1193
|
+
if (pr && pr.width >= window.innerWidth * 0.9 && pr.height >= window.innerHeight * 0.9) overlay = p;
|
|
1194
|
+
}
|
|
1195
|
+
} catch (eP) {}
|
|
1196
|
+
try {
|
|
1197
|
+
panel.classList.add("dsh-ma-settings-panel");
|
|
1198
|
+
if (nav) nav.classList.add("dsh-ma-settings-nav");
|
|
1199
|
+
if (title) title.classList.add("dsh-ma-settings-navtitle");
|
|
1200
|
+
if (list) list.classList.add("dsh-ma-settings-navlist");
|
|
1201
|
+
if (content) content.classList.add("dsh-ma-settings-content");
|
|
1202
|
+
if (header) header.classList.add("dsh-ma-settings-header");
|
|
1203
|
+
if (options) options.classList.add("dsh-ma-settings-options");
|
|
1204
|
+
if (overlay) overlay.classList.add("dsh-ma-settings-overlay");
|
|
1205
|
+
maSettingsCells = list ? maKidsOf(list) : [];
|
|
1206
|
+
for (var c = 0; c < maSettingsCells.length; c++) maSettingsCells[c].classList.add("dsh-ma-settings-cell");
|
|
1207
|
+
maMarkActiveCells();
|
|
1208
|
+
} catch (eT) { /* 打类失败也要继续:已知前缀那条 CSS 仍可能命中 */ }
|
|
1209
|
+
maSettingsPanelEl = panel;
|
|
1210
|
+
/* 前缀认不出 = 官方改名的**直接信号** —— 这时布局靠结构类仍然可用,所以只警告不降级。 */
|
|
1211
|
+
if (!MA_SETTINGS_PREFIX_RE.test(maClsOf(panel) + " " + maClsOf(nav))) {
|
|
1212
|
+
maSettingsWarn("设置面板已按**结构**识别(官方混淆前缀已变化:既不是 VOzbGW_ 也不是 Dws9Sa_);布局仍可用,但请把新前缀补进适配层。");
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* 每拍调用(节流):已识别且面板还在文档里 → 只刷新选中态;否则限频重扫。
|
|
1217
|
+
* 为什么要节流:未命中时每拍都要 querySelectorAll 一遍全文档,流式输出时每帧一拍。
|
|
1218
|
+
*/
|
|
1219
|
+
function maSettingsMaybeTag() {
|
|
1220
|
+
if (!NARROW()) return;
|
|
1221
|
+
try {
|
|
1222
|
+
if (maSettingsPanelEl && maSettingsPanelEl.isConnected) { maMarkActiveCells(); return; }
|
|
1223
|
+
} catch (eLive) { /* 掉进重扫 */ }
|
|
1224
|
+
var now = Date.now();
|
|
1225
|
+
if (now < maSettingsNextScan) return;
|
|
1226
|
+
maSettingsNextScan = now + 400; // 未命中时最多 2.5 次/秒;命中后面板引用直接短路,不再全扫
|
|
1227
|
+
maTagSettingsDialog();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
998
1230
|
/* 设置面板打开时,把当前 tab 滚进横向可视区(顶部 tab 行手机横向滚动) */
|
|
999
1231
|
let settingsWatchOn = false;
|
|
1000
1232
|
let settingsTick = 0;
|
|
1001
1233
|
function scrollActiveSettingsTab() {
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1234
|
+
/* 逐个尝试而不是写一条长选择器:结构类(前缀无关)优先,已知前缀退居兜底;
|
|
1235
|
+
任何一条在旧浏览器上解析失败都不影响其余。 */
|
|
1236
|
+
const SEL = [
|
|
1237
|
+
".dsh-ma-settings-cell.dsh-ma-settings-active",
|
|
1238
|
+
'[class~="Dws9Sa_navCell"].Dws9Sa_active',
|
|
1239
|
+
'[class~="VOzbGW_navCell"].VOzbGW_active',
|
|
1240
|
+
'[class~="Dws9Sa_navCell"][aria-current="true"]',
|
|
1241
|
+
'[class~="VOzbGW_navCell"][aria-current="true"]'
|
|
1242
|
+
];
|
|
1243
|
+
let act = null;
|
|
1244
|
+
for (const sel of SEL) {
|
|
1245
|
+
try { act = document.querySelector(sel); } catch (e13a) { act = null; }
|
|
1246
|
+
if (act) break;
|
|
1247
|
+
}
|
|
1005
1248
|
if (act && act.scrollIntoView) {
|
|
1006
1249
|
try { act.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (e13) {
|
|
1007
1250
|
try { act.scrollIntoView(); } catch (e14) {}
|
|
@@ -1014,7 +1257,12 @@ const SCRIPT = `(() => {
|
|
|
1014
1257
|
try {
|
|
1015
1258
|
const ob = new MutationObserver(function () {
|
|
1016
1259
|
clearTimeout(settingsTick);
|
|
1017
|
-
settingsTick = setTimeout(function () {
|
|
1260
|
+
settingsTick = setTimeout(function () {
|
|
1261
|
+
if (!NARROW()) return;
|
|
1262
|
+
maSettingsNextScan = 0; // 官方刚挂上面板:别被节流挡在门外
|
|
1263
|
+
try { maSettingsMaybeTag(); } catch (e16) {}
|
|
1264
|
+
scrollActiveSettingsTab();
|
|
1265
|
+
}, 140);
|
|
1018
1266
|
});
|
|
1019
1267
|
ob.observe(document.body, { childList: true, subtree: true });
|
|
1020
1268
|
} catch (e15) {}
|
|
@@ -2056,6 +2304,9 @@ const SCRIPT = `(() => {
|
|
|
2056
2304
|
if (!whaleGuardsOn || !settingsWatchOn) {
|
|
2057
2305
|
try { whaleHookInit(); watchSettings(); } catch (eHook) { /* 两者内部都自带一次性守卫 */ }
|
|
2058
2306
|
}
|
|
2307
|
+
/* 设置面板结构识别(自带节流,幂等):官方每次重新打包都换混淆前缀,
|
|
2308
|
+
这一步保证"改名后仍然基本可用",而不是整段静默失效(2026-10-13 根因)。 */
|
|
2309
|
+
try { maSettingsMaybeTag(); } catch (eSetTag) { /* 忽略 */ }
|
|
2059
2310
|
};
|
|
2060
2311
|
/* 节流:一次 DOM 变更往往产生几十条记录,逐条 boot() 就是忙轮询。
|
|
2061
2312
|
统一合并成"本帧最多跑一次"(有 rAF 用 rAF,没有就退化成 0ms 宏任务) —— 天然去重。 */
|