dsh-remote-web 0.6.17 → 0.6.18-beta.2
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/client.js +754 -39
- package/lib/index.js +279 -6
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/runtime/dsh-setup.mjs +152 -3
|
@@ -1173,12 +1173,35 @@ export class WeChatRuntime {
|
|
|
1173
1173
|
} catch { return false; }
|
|
1174
1174
|
}
|
|
1175
1175
|
|
|
1176
|
-
/**
|
|
1176
|
+
/**
|
|
1177
|
+
* 可切换会话列表(`/ls` 与 `/use` **共用同一份**)—— 过滤必须收口在这里。
|
|
1178
|
+
*
|
|
1179
|
+
* 为什么(业主反馈「/ls 列出一堆(未命名)会话」):`listSessions` 返回的会话里有大量**空白会话**
|
|
1180
|
+
* (新建后一句话没说的坑),官方已经在数据里标了 `blank`(`dsh-events.mjs:1654` 解析出来却
|
|
1181
|
+
* 一直没人用)。规则:只留 **有名字 / 正在跑 / 当前选中** 的会话;`blank === true` 一律剔除。
|
|
1182
|
+
*
|
|
1183
|
+
* ⚠️ 过滤**只能**放在这里(或同步过滤 sessionIndex):`/ls` 渲染 1..N 编号、`/use <n>` 按下标取
|
|
1184
|
+
* 会话、而 `sessionIndex`(/use 的列表来源)就是本函数的返回值 —— 任何一处单独过滤都会让
|
|
1185
|
+
* 两边编号分叉(用户 `/ls` 看到第 2 条是 A,`/use 2` 却切到 B)。
|
|
1186
|
+
* ⚠️ 老版本 DSH 不带 `blank` 字段:`undefined === true` 为假 → 自动退化成"有名字/在跑/当前",
|
|
1187
|
+
* 不会因为升级或降级把整张列表清空。
|
|
1188
|
+
*/
|
|
1189
|
+
#usableSessions(list) {
|
|
1190
|
+
return (Array.isArray(list) ? list : []).filter((s) => {
|
|
1191
|
+
if (!s || typeof s !== "object") return false;
|
|
1192
|
+
if (s.blank === true) return false; // 官方明确标记的空白会话
|
|
1193
|
+
if (s.sessionId === this.currentSessionId) return true; // 当前选中:永远留在列表里
|
|
1194
|
+
if (s.running === true) return true; // 正在跑
|
|
1195
|
+
return Boolean(String(s.title || "").trim()); // 有名字
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** 拉一次会话列表(带标题),并记住 1-based 序号供 /use 使用(列表已按 #usableSessions 过滤)。 */
|
|
1177
1200
|
async #sessions() {
|
|
1178
1201
|
if (!this.subscriber || typeof this.subscriber.listSessions !== "function") return null;
|
|
1179
1202
|
const r = await this.subscriber.listSessions();
|
|
1180
1203
|
if (!r || !r.ok) return null;
|
|
1181
|
-
this.sessionIndex = r.sessions || [];
|
|
1204
|
+
this.sessionIndex = this.#usableSessions(r.sessions || []);
|
|
1182
1205
|
return this.sessionIndex;
|
|
1183
1206
|
}
|
|
1184
1207
|
|
package/runtime/dsh-setup.mjs
CHANGED
|
@@ -1125,6 +1125,61 @@ function loginRateLimitUntil() {
|
|
|
1125
1125
|
return 0;
|
|
1126
1126
|
}
|
|
1127
1127
|
|
|
1128
|
+
/* ── 守护启动锁:把"一堆守护同时被拉起"的惊群收敛成串行 ─────────────────────────────
|
|
1129
|
+
*
|
|
1130
|
+
* 🔴 2026-09-26 真机事故(dsh web 启动故障的连带伤害):一次重启窗口里同时出现了
|
|
1131
|
+
* **6 个 `dsh-setup.mjs run` 守护 + 6 个 bridge**,全部用同一个 deviceId 去 router 注册,
|
|
1132
|
+
* 互相把对方顶掉 —— bridge 日志刷满
|
|
1133
|
+
* `隧道断开(code=4000, replaced by new connection)` → `2s 后重连` → 再被顶掉,
|
|
1134
|
+
* 手机侧表现为隧道无限抖动、随机不可用。
|
|
1135
|
+
* 成因是 `runBridge()` 的去重是**先读 pid 文件、后写 pid 文件**:N 个实例在同一瞬间启动时,
|
|
1136
|
+
* 每个都读到"还没有别人" → 一起活下来。launchd 的 `com.dshremote.bridge`(KeepAlive) 与
|
|
1137
|
+
* 插件自愈拉起的脱离进程又都会各自拉一个,正好构成惊群。
|
|
1138
|
+
*
|
|
1139
|
+
* 修法:进入去重逻辑**之前**先抢一把原子锁(`wx` = 排他创建)。抢到的人才继续走原来的
|
|
1140
|
+
* supervisor 判定;抢不到的人只在"被监管者"时才稍等(它必须能接管),否则直接让位。
|
|
1141
|
+
* 锁文件带 `pid:时间戳`,超过 STALE 就视为残留(进程被强杀时留下的),可安全抢占 ——
|
|
1142
|
+
* **绝不允许一把陈旧的锁把 bridge 永久挡在门外**。
|
|
1143
|
+
*/
|
|
1144
|
+
const WATCHER_SPAWN_LOCK = ".dsh-watcher-spawn.lock";
|
|
1145
|
+
const SPAWN_LOCK_STALE_MS = 30_000;
|
|
1146
|
+
const SPAWN_LOCK_WAIT_MS = 15_000;
|
|
1147
|
+
|
|
1148
|
+
function tryAcquireSpawnLock() {
|
|
1149
|
+
const f = path.join(CONFIG_DIR, WATCHER_SPAWN_LOCK);
|
|
1150
|
+
try {
|
|
1151
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1152
|
+
const fd = fs.openSync(f, "wx"); // 排他创建:要么拿到,要么 EEXIST
|
|
1153
|
+
fs.writeSync(fd, `${process.pid}:${Date.now()}`);
|
|
1154
|
+
fs.closeSync(fd);
|
|
1155
|
+
return true;
|
|
1156
|
+
} catch {
|
|
1157
|
+
// 已存在:可能是残留(强杀留下的),也可能是真有人在启动
|
|
1158
|
+
try {
|
|
1159
|
+
const [pidStr, tsStr] = String(fs.readFileSync(f, "utf8")).split(":");
|
|
1160
|
+
const pid = Number(pidStr);
|
|
1161
|
+
const ts = Number(tsStr);
|
|
1162
|
+
const stale = !Number.isFinite(ts) || Date.now() - ts > SPAWN_LOCK_STALE_MS || !pidAlive(pid);
|
|
1163
|
+
if (stale) {
|
|
1164
|
+
fs.rmSync(f, { force: true });
|
|
1165
|
+
const fd = fs.openSync(f, "wx");
|
|
1166
|
+
fs.writeSync(fd, `${process.pid}:${Date.now()}`);
|
|
1167
|
+
fs.closeSync(fd);
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
} catch { /* 读不到/抢不到 → 当作没拿到 */ }
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function releaseSpawnLock() {
|
|
1176
|
+
const f = path.join(CONFIG_DIR, WATCHER_SPAWN_LOCK);
|
|
1177
|
+
try {
|
|
1178
|
+
const owner = Number(String(fs.readFileSync(f, "utf8")).split(":")[0]);
|
|
1179
|
+
if (owner === process.pid) fs.rmSync(f, { force: true }); // 只删自己那把
|
|
1180
|
+
} catch { /* 没锁/已被抢 → 忽略 */ }
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1128
1183
|
// ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
|
|
1129
1184
|
async function runBridge() {
|
|
1130
1185
|
// 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
|
|
@@ -1138,6 +1193,18 @@ async function runBridge() {
|
|
|
1138
1193
|
// 只剩插件面板的「启动」能救回来。
|
|
1139
1194
|
// 被监管者拉起 = 我们才是那个该活下来的实例 → 请游离实例让位,然后接管。
|
|
1140
1195
|
{
|
|
1196
|
+
// 抢启动锁(见上方长注释):把同时被拉起的多个实例串行化,杜绝"大家都读到没有别人"。
|
|
1197
|
+
const supervised = String(process.env.XPC_SERVICE_NAME || "");
|
|
1198
|
+
let locked = tryAcquireSpawnLock();
|
|
1199
|
+
if (!locked && supervised) {
|
|
1200
|
+
// 被监管者必须能接管:等一会儿再抢(游离实例写完 pid 文件就会释放)
|
|
1201
|
+
const deadline = Date.now() + SPAWN_LOCK_WAIT_MS;
|
|
1202
|
+
while (!locked && Date.now() < deadline) { sleepSync(150); locked = tryAcquireSpawnLock(); }
|
|
1203
|
+
}
|
|
1204
|
+
if (!locked) {
|
|
1205
|
+
console.log("[dsh-remote] 另一个守护正在启动(启动锁被占用),本进程直接退出,避免重复实例。");
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1141
1208
|
const other = readPidFile(WATCHER_PID_FILE);
|
|
1142
1209
|
if (other && other !== process.pid && pidAlive(other)) {
|
|
1143
1210
|
const supervisor = String(process.env.XPC_SERVICE_NAME || "");
|
|
@@ -1157,9 +1224,15 @@ async function runBridge() {
|
|
|
1157
1224
|
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1158
1225
|
} else {
|
|
1159
1226
|
console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
|
|
1227
|
+
releaseSpawnLock();
|
|
1160
1228
|
return;
|
|
1161
1229
|
}
|
|
1162
1230
|
}
|
|
1231
|
+
// 🔒 关键顺序:**先把自己的 pid 落盘,再释放启动锁**。
|
|
1232
|
+
// 否则"释放锁 → 下面 1373 行才 writePidFile"之间又是一个空窗,第二个实例会读到
|
|
1233
|
+
// "没有别人" → 惊群只是被缩小、并没有消除。1373 行那次写入保持原样(它还负责注册退出清理)。
|
|
1234
|
+
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1235
|
+
releaseSpawnLock();
|
|
1163
1236
|
// ── 回收「上一任留下的 bridge 子进程」────────────────────────────────────
|
|
1164
1237
|
//
|
|
1165
1238
|
// 🔴 真机 bug(2026-09-23 用户实测):一条微信指令**回两条**。
|
|
@@ -1899,10 +1972,50 @@ function normalizePatchBase(patch) {
|
|
|
1899
1972
|
.trimEnd();
|
|
1900
1973
|
}
|
|
1901
1974
|
|
|
1975
|
+
/**
|
|
1976
|
+
* 插件目录里**必须存在且非空**的文件。缺任何一个都说明这次拷贝是"半个插件"。
|
|
1977
|
+
*
|
|
1978
|
+
* 🔴 为什么必须有这一步(2026-09-26 23:46 真实事故):当时是"先 rmSync 整个 dest、再逐文件 cpSync",
|
|
1979
|
+
* 而 dsh 恰好在这个空窗里启动 → `package.json` 已经声明 bundle,但 `cordis.patch.yml` 还没拷到
|
|
1980
|
+
* → dsh 读 overlay 直接 `ENOENT: no such file or directory` 退出,**整个 GUI 起不来**:
|
|
1981
|
+
*
|
|
1982
|
+
* Error: dsh: failed to read overlay
|
|
1983
|
+
* .../node_modules/dsh-remote-web/cordis.patch.yml: ENOENT
|
|
1984
|
+
*
|
|
1985
|
+
* 拷贝完成时间 23:46:05,dsh 报错时间 23:46:03 —— 差 2 秒。
|
|
1986
|
+
* 所以"拷完必须先自检,再切换"不是优化,是**防止宿主起不来**的硬闸门。
|
|
1987
|
+
*/
|
|
1988
|
+
const PLUGIN_REQUIRED_FILES = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
|
|
1989
|
+
|
|
1990
|
+
/** 校验某个插件目录是否"完整可加载";返回缺失/为空的相对路径数组(空数组 = 通过)。 */
|
|
1991
|
+
function missingPluginFiles(dir) {
|
|
1992
|
+
const missing = [];
|
|
1993
|
+
for (const rel of PLUGIN_REQUIRED_FILES) {
|
|
1994
|
+
try {
|
|
1995
|
+
const st = fs.statSync(path.join(dir, rel));
|
|
1996
|
+
if (!st.isFile() || st.size === 0) missing.push(`${rel}(空)`);
|
|
1997
|
+
} catch {
|
|
1998
|
+
missing.push(rel);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
return missing;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1902
2004
|
/**
|
|
1903
2005
|
* 把插件整目录拷贝进 profile 的固定子目录并返回该目录。
|
|
1904
2006
|
* 源(本包 packages/${PLUGIN_ID})可能在 npx 临时缓存里,因此必须拷到 profile 内持久化,
|
|
1905
2007
|
* include 用相对路径直接指向拷贝出的入口,不写依赖、不跑任何包管理器。
|
|
2008
|
+
*
|
|
2009
|
+
* 🔴 **原子覆盖(2026-09-26 事故后的硬要求)**:
|
|
2010
|
+
* 1. 先整份拷到同级的 `.staging-<pid>`,**全程不碰 dest** —— dsh 任何时刻读到的
|
|
2011
|
+
* 要么是完整的旧版、要么是完整的新版;
|
|
2012
|
+
* 2. 拷完**校验**必需文件(见 `PLUGIN_REQUIRED_FILES`),不通过就丢弃 staging、抛错、
|
|
2013
|
+
* **保留旧版可用**;
|
|
2014
|
+
* 3. 再 `renameSync` 原子切换(同目录内 rename 是原子操作,空窗只有两次 syscall 之间,
|
|
2015
|
+
* 相比旧实现"整个拷贝过程都处于半空状态"是数量级的改善);
|
|
2016
|
+
* 4. 切换失败 → 立刻把 backup 改回 dest;成功 → 删 backup。
|
|
2017
|
+
*
|
|
2018
|
+
* 另外清掉上次被强杀留下的 `.staging-*` / `.backup-*`,不留垃圾。
|
|
1906
2019
|
*/
|
|
1907
2020
|
function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
1908
2021
|
// 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
|
|
@@ -1910,8 +2023,9 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
1910
2023
|
try { fs.rmSync(path.join(profileDir, `${id}-plugin`), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1911
2024
|
}
|
|
1912
2025
|
const dest = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1913
|
-
|
|
1914
|
-
|
|
2026
|
+
const stage = `${dest}.staging-${process.pid}`;
|
|
2027
|
+
const backup = `${dest}.backup-${process.pid}`;
|
|
2028
|
+
const cpOpts = {
|
|
1915
2029
|
recursive: true,
|
|
1916
2030
|
// 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
|
|
1917
2031
|
// 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
|
|
@@ -1921,7 +2035,42 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
1921
2035
|
const rel = path.relative(pluginDir, src);
|
|
1922
2036
|
return !String(rel).split(path.sep).some((seg) => seg === "node_modules" || seg === ".git" || seg === ".DS_Store");
|
|
1923
2037
|
}
|
|
1924
|
-
}
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
// 0) 清掉上次被强杀(或本次同 pid 重入)留下的临时目录
|
|
2041
|
+
for (const d of [stage, backup]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
2042
|
+
|
|
2043
|
+
// 1) 拷到 staging —— **绝不动 dest**
|
|
2044
|
+
try {
|
|
2045
|
+
fs.cpSync(pluginDir, stage, cpOpts);
|
|
2046
|
+
} catch (e) {
|
|
2047
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2048
|
+
throw new Error(`插件拷贝失败(已保留原目录不动):${e && e.message ? e.message : e}`);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// 2) 自检:必需文件一个都不能少(这一步就是 23:46 那次事故的闸门)
|
|
2052
|
+
const missing = missingPluginFiles(stage);
|
|
2053
|
+
if (missing.length > 0) {
|
|
2054
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2055
|
+
throw new Error(
|
|
2056
|
+
`插件拷贝不完整,缺少必需文件:${missing.join("、")}(已保留原目录不动,避免宿主读 overlay 失败起不来)`
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// 3) 原子切换
|
|
2061
|
+
try {
|
|
2062
|
+
if (fs.existsSync(dest)) fs.renameSync(dest, backup);
|
|
2063
|
+
try {
|
|
2064
|
+
fs.renameSync(stage, dest);
|
|
2065
|
+
} catch (e) {
|
|
2066
|
+
if (fs.existsSync(backup)) { try { fs.renameSync(backup, dest); } catch { /* 尽力回滚 */ } }
|
|
2067
|
+
throw e;
|
|
2068
|
+
}
|
|
2069
|
+
try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2070
|
+
} catch (e) {
|
|
2071
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2072
|
+
throw new Error(`插件切换失败(旧版仍在原位):${e && e.message ? e.message : e}`);
|
|
2073
|
+
}
|
|
1925
2074
|
return dest;
|
|
1926
2075
|
}
|
|
1927
2076
|
|