@mrrisega/dsh-remote 0.6.9 → 0.6.10-beta.10
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/README.md +2 -1
- package/clients/dsh-remote/dsh-bridge.mjs +270 -9
- package/clients/dsh-remote/dsh-events.mjs +1981 -0
- package/clients/dsh-remote/src/lifecycle.mjs +42 -0
- package/clients/dsh-remote/test/dsh-events.test.mjs +1998 -0
- package/clients/dsh-remote/test/lifecycle.test.mjs +116 -1
- package/clients/dsh-remote/test/machine-fingerprint.test.mjs +148 -0
- package/clients/dsh-remote/test/wechat-channel.test.mjs +2112 -0
- package/clients/dsh-remote/test/wechat-e2e.test.mjs +481 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +1613 -0
- package/clients/dsh-remote/test/wechat-tier-source.test.mjs +287 -0
- package/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/clients/dsh-remote/wechat-runtime.mjs +1910 -0
- package/docs/telemetry.md +31 -2
- package/dsh-setup.mjs +192 -43
- package/package.json +6 -5
- package/packages/dsh-remote-web/lib/client.js +877 -65
- package/packages/dsh-remote-web/lib/index.js +511 -14
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/activation-single-point.test.mjs +104 -0
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +59 -0
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +25 -12
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +19 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +17 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +190 -3
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +5 -0
- package/packages/dsh-remote-web/test/wechat-bind-telemetry.test.mjs +357 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +1002 -0
- package/packages/dsh-remote-web/test/wechat-proxy.test.mjs +369 -0
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +117 -0
package/README.md
CHANGED
|
@@ -185,7 +185,8 @@ npx @mrrisega/dsh-remote setup --server wss://<你的域名>:端口 --key <访
|
|
|
185
185
|
生产诊断发现「注册了但设备一直没连上」的用户没有任何账号数据、无法归因,因此插件内置了一条
|
|
186
186
|
**匿名装机统计**通道,只上报**装机与连接是否成功**:
|
|
187
187
|
|
|
188
|
-
- **采**:事件名(安装开始/失败原因/运行环境就绪/bridge
|
|
188
|
+
- **采**:事件名(安装开始/失败原因/运行环境就绪/bridge 是否注册成功/首次远程打通/面板打开/重启/更新、
|
|
189
|
+
**微信机器人通道的绑定/解绑**)、
|
|
189
190
|
失败码(白名单,如 `npm_unreachable`、`npm_eacces`、`runtime_install_timeout`)、插件版本号、
|
|
190
191
|
系统平台(`darwin`/`linux`/`win32`)、架构(`arm64`/`x64`)、node 主版本号,
|
|
191
192
|
以及一个**本机随机 ID**(`crypto.randomUUID()`,非硬件派生、重装即变、不可跨机器关联);
|
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
* bridge 转发时:Host 由 fetch/ws 自动取上游 authority(127.0.0.1:3080,满足 loopback 围栏),
|
|
24
24
|
* 显式剥离 Origin、Sec-Fetch-*、Cookie、Referer 等浏览器标记,保证通过 dsh web 的信任围栏。
|
|
25
25
|
*
|
|
26
|
-
* ── WebSocket 透传(覆盖 /api/
|
|
26
|
+
* ── WebSocket 透传(覆盖 /api/remote.mux|host 下行流) ──
|
|
27
|
+
* ⚠️ 修正(2026-09-20):此处原写 /api/events.mux —— 该路径在本版 DSH 里**不存在**
|
|
28
|
+
* (全包 grep 零命中),真路径是 /api/remote.mux(@deepseek-ai/dsh-api-gateway 的
|
|
29
|
+
* REMOTE_STREAM_MUX_PATH)。线上未出问题是因为本文件对路径**透明透传**、由浏览器决定
|
|
30
|
+
* 请求哪个路径;但错的注释会把后来者带沟里(微信通道订阅事件流时会照着它写)。
|
|
27
31
|
* → { "id", "type":"ws-open", "path", "headers":{...} } ← { "id","type":"ws-open","ok":true|false,"code"?,"reason"? }
|
|
28
32
|
* → { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
|
|
29
33
|
* ← { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
|
|
@@ -86,6 +90,10 @@ import {
|
|
|
86
90
|
parseWsE2eeParams,
|
|
87
91
|
writeE2eeStateFile
|
|
88
92
|
} from "./e2ee-client.mjs";
|
|
93
|
+
// 微信机器人通道(编排层):绑定控制面 + 出站通知路由 + 入站长轮询。
|
|
94
|
+
// ⚠️ 微信流量只走「腾讯 ilink ↔ 本机」,**不经我们的 relay**;DSH 事件走本机回环。
|
|
95
|
+
// 总开关 DSH_WECHAT=0(默认开启);未绑定时只起控制面,不起长轮询。
|
|
96
|
+
import { createWeChatRuntime } from "./wechat-runtime.mjs";
|
|
89
97
|
|
|
90
98
|
// ---------- 强制直连:清除代理环境变量 ----------
|
|
91
99
|
// 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
|
|
@@ -107,6 +115,8 @@ const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
|
|
|
107
115
|
const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
|
|
108
116
|
const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || 15_000);
|
|
109
117
|
const UPSTREAM = process.env.DSH_BRIDGE_UPSTREAM || "http://127.0.0.1:3080";
|
|
118
|
+
// 微信机器人通道总开关:DSH_WECHAT=0 关闭(默认开启)。控制面只 bind 回环,且必须带 bridge_secret。
|
|
119
|
+
const WECHAT_DISABLED = String(process.env.DSH_WECHAT || "") === "0";
|
|
110
120
|
// 默认云端服务地址(dsh-remote setup 会显式传入;自建模式无需账号 API)
|
|
111
121
|
const API_BASE = (process.env.DSH_BRIDGE_API || "https://n.risegao.cn:13443/relay-api").replace(/\/+$/, "");
|
|
112
122
|
const EMAIL = process.env.DSH_BRIDGE_EMAIL || "";
|
|
@@ -117,15 +127,25 @@ const TOKEN = process.env.DSH_BRIDGE_TOKEN || "";
|
|
|
117
127
|
|
|
118
128
|
// ---------- 本机稳定指纹(同机重装识别;供服务端自动顶替旧设备) ----------
|
|
119
129
|
|
|
120
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* 读取与安装无关的机器级唯一值:macOS IOPlatformUUID / Linux machine-id / Windows MachineGuid。
|
|
132
|
+
* 用途只有一个:同机卸载重装后被服务端认成同一台设备(自动顶替旧设备记录)。
|
|
133
|
+
* 拿到的值**只在本机哈希**(见 machineFingerprint),原文绝不上报。
|
|
134
|
+
*/
|
|
121
135
|
function machineUniqueId() {
|
|
122
136
|
if (process.env.DSH_BRIDGE_MACHINE_FP) return String(process.env.DSH_BRIDGE_MACHINE_FP).slice(0, 64);
|
|
123
137
|
if (process.platform === "darwin") {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
// ⚠️ 必须用**绝对路径**:由 launchd 拉起时 PATH 来自 plist(实测 /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin),
|
|
139
|
+
// **不含 /usr/sbin** —— 而 ioreg 恰好就在那里。用裸名会得到
|
|
140
|
+
// "/bin/sh: ioreg: command not found"(真机日志实测),于是这里静默返回 "",
|
|
141
|
+
// 指纹退化成 hostname 哈希:用户改一次主机名就被服务端当成新设备 —— 与 Windows 那条是同一个 bug。
|
|
142
|
+
for (const ioreg of ["/usr/sbin/ioreg", "ioreg"]) {
|
|
143
|
+
try {
|
|
144
|
+
const out = execSync(`${ioreg} -rd1 -c IOPlatformExpertDevice`, { encoding: "utf8", timeout: 5000 });
|
|
145
|
+
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
|
|
146
|
+
if (m && m[1]) return m[1];
|
|
147
|
+
} catch { /* 换下一个候选 */ }
|
|
148
|
+
}
|
|
129
149
|
} else if (process.platform === "linux") {
|
|
130
150
|
for (const f of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
131
151
|
try {
|
|
@@ -133,8 +153,32 @@ function machineUniqueId() {
|
|
|
133
153
|
if (s) return s;
|
|
134
154
|
} catch { /* 继续 */ }
|
|
135
155
|
}
|
|
156
|
+
} else if (process.platform === "win32") {
|
|
157
|
+
// Windows 既没有 IOPlatformUUID 也没有 machine-id,取注册表 MachineGuid
|
|
158
|
+
//(Windows 安装时生成、之后稳定)—— 语义与 Linux 的 machine-id 一致:都是"每次安装一个",
|
|
159
|
+
// 所以"重装 dsh-remote 后仍认成同一台设备"这个唯一用途完全成立。
|
|
160
|
+
//
|
|
161
|
+
// 为什么不用 PowerShell/WMI 的 Win32_ComputerSystemProduct.UUID:
|
|
162
|
+
// · reg.exe 恒定存在于 System32,启动约 20ms;PowerShell 启动要几百毫秒;
|
|
163
|
+
// · PowerShell 在受限语言模式 / AppLocker 的企业机器上可能被策略直接禁掉,
|
|
164
|
+
// 而拿不到指纹时只能退回 hostname(用户一改主机名就被当成新设备)—— 正是要修的问题;
|
|
165
|
+
// · 走 spawnSync 传数组参数,不经过 cmd.exe,因而没有引号/空格/中文路径的转义坑。
|
|
166
|
+
// 同样不赌 PATH:优先 System32 下的绝对路径,取不到再退回裸名(macOS 那条就是这么栽的)。
|
|
167
|
+
const regCandidates = [];
|
|
168
|
+
if (process.env.SystemRoot) regCandidates.push(`${process.env.SystemRoot}\\System32\\reg.exe`);
|
|
169
|
+
regCandidates.push("reg");
|
|
170
|
+
for (const reg of regCandidates) {
|
|
171
|
+
try {
|
|
172
|
+
const r = spawnSync(reg, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
|
|
173
|
+
{ encoding: "utf8", timeout: 5000, windowsHide: true });
|
|
174
|
+
const m = r && r.status === 0
|
|
175
|
+
? /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{16,})/.exec(String(r.stdout || ""))
|
|
176
|
+
: null;
|
|
177
|
+
if (m && m[1]) return m[1];
|
|
178
|
+
} catch { /* 换下一个候选 */ }
|
|
179
|
+
}
|
|
136
180
|
}
|
|
137
|
-
return ""; // 读不到(
|
|
181
|
+
return ""; // 读不到(如容器/受限环境) → 回退宿主名指纹
|
|
138
182
|
}
|
|
139
183
|
/** 稳定指纹:机器唯一值哈希;同机卸载重装后不变。 */
|
|
140
184
|
function machineFingerprint() {
|
|
@@ -216,7 +260,15 @@ function hardenFile(file) {
|
|
|
216
260
|
try { fs.chmodSync(file, 0o600); } catch { /* POSIX 上失败不致命 */ }
|
|
217
261
|
if (process.platform !== "win32") return;
|
|
218
262
|
const who = [process.env.USERDOMAIN, process.env.USERNAME].filter(Boolean).join("\\");
|
|
219
|
-
|
|
263
|
+
// 两个环境变量都取不到时**绝不能静默返回**:那会让人以为文件已加固,实际仍是继承 ACL。
|
|
264
|
+
// 正常 Windows 会话不会走到这里(USERNAME 必然存在),但受限令牌/服务账户下有可能。
|
|
265
|
+
if (!who) {
|
|
266
|
+
if (!hardenWarned) {
|
|
267
|
+
hardenWarned = true;
|
|
268
|
+
console.warn(`⚠️ 无法收紧文件权限(USERDOMAIN/USERNAME 均未设置):${file}`);
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
220
272
|
let r;
|
|
221
273
|
try {
|
|
222
274
|
r = spawnSync("icacls", [file, "/inheritance:r", "/grant:r", `${who}:F`],
|
|
@@ -1158,17 +1210,205 @@ async function initE2ee(token) {
|
|
|
1158
1210
|
}
|
|
1159
1211
|
}
|
|
1160
1212
|
|
|
1213
|
+
/**
|
|
1214
|
+
* 启动微信通道(控制面 + 已绑定时的事件通知/入站长轮询)。
|
|
1215
|
+
*
|
|
1216
|
+
* ★ 为什么**不 await**、且放在 connectTunnel 之后:
|
|
1217
|
+
* 微信侧是"锦上添花"的能力(发通知)。它必须**永远不能**拖慢或拖挂远程访问本身 ——
|
|
1218
|
+
* 所以先让隧道连上,再 fire-and-forget 起微信;任何异常只记日志,不冒泡、不 exit。
|
|
1219
|
+
* 与 e2ee/mobile-adapter 的定位一致:主链路优先。
|
|
1220
|
+
*/
|
|
1221
|
+
/** 给没自带前缀的微信日志补上 `[wechat] `;已带的原样返回(避免双重前缀)。 */
|
|
1222
|
+
function tagWeChat(m) {
|
|
1223
|
+
const s = String(m);
|
|
1224
|
+
return s.startsWith("[") ? s : `[wechat] ${s}`;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* 微信通道的档位来源(免费/付费分层)。
|
|
1229
|
+
*
|
|
1230
|
+
* 取值 = 账号的**生效套餐**,服务端只有三种:free / pro / pro_max
|
|
1231
|
+
* (见企业端 auth.js 的 PLAN_PRIORITY;试用期服务端已折算成 pro,这里不必自己判断)。
|
|
1232
|
+
*
|
|
1233
|
+
* ★ 返回**两种契约**(见 docs/entitlements-contract.md §5/§6.1):
|
|
1234
|
+
* · 服务端 `/api/me` 带了 `entitlements`(后台可配置的权益包)→ 返回**对象**
|
|
1235
|
+
* `{plan, caps, limits, rev}`,runtime 以它为准判定能力 —— 这样「后台改配置 = 用户侧实时生效」
|
|
1236
|
+
* 才成立(无需发版)。caps/limits **原样透传**:权限判定以服务端为唯一真相源,
|
|
1237
|
+
* 客户端任何"二次加工/本地过滤"都会让展示与判定分叉,也会让后台配置失效(红线 §8.1/§8.2)。
|
|
1238
|
+
* · 服务端**没带** `entitlements`(灰度期的旧服务端)→ 保持今天的行为、返回**字符串**档位:
|
|
1239
|
+
* runtime 有上次的包就继续用它,一份包都没有才按内置表解析。新旧服务端都要能跑,
|
|
1240
|
+
* 所以这里不能只认新契约。
|
|
1241
|
+
*
|
|
1242
|
+
* ⚠️ 取不到时返回**空串**,而不是 "free":空串让 runtime 沿用上次成功取到的档位。
|
|
1243
|
+
* 一次网络抖动就把付过钱的用户降级成免费、还提示他去升级,比多给几分钟权限糟糕得多。
|
|
1244
|
+
* ⚠️ 自建部署直接给最高档:用户跑的是自己的服务器,不存在"会员"这回事,
|
|
1245
|
+
* 不能拿 SaaS 的分层去卡自建用户。
|
|
1246
|
+
*/
|
|
1247
|
+
/**
|
|
1248
|
+
* 档位查询专用的 token 缓存(理由见 resolveWechatTier 里的注释)。
|
|
1249
|
+
* TTL 取 5 分钟:远小于 JWT 有效期,又足以把「每 10 分钟一次定时校准 + 被拒即重查」
|
|
1250
|
+
* 全部挡在网络之外 —— 不缓存的话,免费用户连着发几条消息就会触发一串 device-login。
|
|
1251
|
+
*/
|
|
1252
|
+
const WECHAT_TIER_TOKEN_TTL_MS = 5 * 60_000;
|
|
1253
|
+
let WECHAT_TIER_TOKEN = { value: "", at: 0 };
|
|
1254
|
+
/**
|
|
1255
|
+
* 最近一次 `/api/me` 的账号快照(查档位时顺手记下来)。
|
|
1256
|
+
*
|
|
1257
|
+
* 为什么需要:微信通道要做"会员临近到期"提醒,而它**不该再单独打一次** `/api/me` ——
|
|
1258
|
+
* 档位每 10 分钟本来就会查一次,顺手记住即可。查失败时**保持上次的值**(与档位同一策略:
|
|
1259
|
+
* 一次网络抖动不该让提醒凭空消失或变成乱猜)。
|
|
1260
|
+
*/
|
|
1261
|
+
let WECHAT_ACCOUNT_SNAPSHOT = null;
|
|
1262
|
+
/** 供微信通道取账号快照(到期提醒用)。没查过就返回 null → 上层什么都不发。 */
|
|
1263
|
+
async function wechatAccountInfo() {
|
|
1264
|
+
return WECHAT_ACCOUNT_SNAPSHOT;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
async function resolveWechatTier() {
|
|
1268
|
+
if (process.env.DSH_BRIDGE_LOCAL_KEY) return "pro_max";
|
|
1269
|
+
try {
|
|
1270
|
+
// ⚠️ `resolveToken()` **不带缓存**:账号模式下每调一次就重新 device-login 一次
|
|
1271
|
+
// (见它的第一行,只有 DSH_BRIDGE_TOKEN / 自建模式才短路)。
|
|
1272
|
+
// 而档位会被反复查询:每 10 分钟一次定时校准 + 免费用户**每次被拒**都重查(最快 5 秒一次)。
|
|
1273
|
+
// 拿它直接查 = 用户连着发几条消息就把自己打到登录限流里 —— 而 /api/device-login
|
|
1274
|
+
// 是**按出口 IP 限流**的(15 分钟 5 次),连累的不只是他自己。
|
|
1275
|
+
// 所以这里自己缓存 token。
|
|
1276
|
+
const now = Date.now();
|
|
1277
|
+
if (!WECHAT_TIER_TOKEN.value || now - WECHAT_TIER_TOKEN.at > WECHAT_TIER_TOKEN_TTL_MS) {
|
|
1278
|
+
const t = await resolveToken();
|
|
1279
|
+
if (!t) return "";
|
|
1280
|
+
WECHAT_TIER_TOKEN = { value: t, at: now };
|
|
1281
|
+
}
|
|
1282
|
+
const r = await fetch(`${API_BASE}/api/me`, {
|
|
1283
|
+
headers: { authorization: `Bearer ${WECHAT_TIER_TOKEN.value}` },
|
|
1284
|
+
signal: AbortSignal.timeout(6000)
|
|
1285
|
+
});
|
|
1286
|
+
// token 过期/被吊销 → 丢掉缓存,下一次查询重新登录(而不是一直拿坏 token 打)
|
|
1287
|
+
if (r.status === 401 || r.status === 403) {
|
|
1288
|
+
WECHAT_TIER_TOKEN = { value: "", at: 0 };
|
|
1289
|
+
return "";
|
|
1290
|
+
}
|
|
1291
|
+
if (!r.ok) return "";
|
|
1292
|
+
const j = await r.json().catch(() => null);
|
|
1293
|
+
const u = (j && j.user) || null;
|
|
1294
|
+
// 顺手记账号快照(到期提醒用)。⚠️ 只在**真拿到了 user** 时覆盖 —— 否则保持上次。
|
|
1295
|
+
if (u) {
|
|
1296
|
+
WECHAT_ACCOUNT_SNAPSHOT = {
|
|
1297
|
+
plan: String(u.plan || ""),
|
|
1298
|
+
plan_ends_at: u.plan_ends_at ?? null,
|
|
1299
|
+
trial_expires_at: u.trial_expires_at ?? null
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
// ★ 服务端权益包(契约 §5):有它就返回对象,runtime 以服务端 caps/limits 为准。
|
|
1303
|
+
//
|
|
1304
|
+
// 🔴 判据是「`caps` 是**数组**」,**空数组也算给了**(2026-09-23 修):
|
|
1305
|
+
// 后台「明确清空」某档(契约 §4.1 的 `EMPTY_CAPS_VALUE`,下发即 `caps: []`)表达的是
|
|
1306
|
+
// **这个档位什么能力都没有**。如果这里把空数组当成"没给",runtime 就会沿用上次那份包
|
|
1307
|
+
// (一份都没有时回退**内置表**)—— 于是"我清空了 pro"变成"pro 拿回内置的一整套付费能力",
|
|
1308
|
+
// 比后台意图**更多**权限。那是 fail-open,方向正好反了(契约 §8 红线 2)。
|
|
1309
|
+
// 所以:数组(含空) = 权威;不是数组/字段缺失 = 这次没拿到 → 退回字符串契约沿用上次。
|
|
1310
|
+
const ent = j && j.entitlements;
|
|
1311
|
+
if (ent && typeof ent === "object" && Array.isArray(ent.caps)) {
|
|
1312
|
+
return {
|
|
1313
|
+
// plan 也原样取服务端的值;只有权益包里缺 plan 时才退回 user.plan(同一份真相的旧字段)。
|
|
1314
|
+
plan: String(ent.plan || (u && u.plan) || ""),
|
|
1315
|
+
caps: ent.caps,
|
|
1316
|
+
// ⚠️ limits 缺失时必须给 null,**不能补 `{}`**:runtime 里 `{}` 是"有效的一张限制表",
|
|
1317
|
+
// 于是 messages_per_month 读成 0 = 不限 → 免费用户当场变成无限额度(等于放权)。
|
|
1318
|
+
// null 才会让 runtime 沿用上次的值 / 回退内置表 —— 缺字段绝不等于放开(契约 §8.2 fail-closed)。
|
|
1319
|
+
limits: ent.limits && typeof ent.limits === "object" ? ent.limits : null,
|
|
1320
|
+
rev: String(ent.rev || "")
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
// 没有权益包(旧服务端 / 灰度期)→ 保持旧契约:只给档位字符串。
|
|
1324
|
+
// runtime 收到字符串后:有上次的包就继续用它,一份包都没有才按内置表解析(冷启动兜底)。
|
|
1325
|
+
return String((u && u.plan) || "");
|
|
1326
|
+
} catch {
|
|
1327
|
+
return "";
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* App 入口(免费用户越界时给的转化链接)。
|
|
1333
|
+
* 由账号 API 地址推导:去掉 `/relay-api` 再加 `/app/` —— 与插件半的 DEFAULT_APP_URL 同一口径
|
|
1334
|
+
* (https://host:port/relay-api → https://host:port/app/)。
|
|
1335
|
+
*/
|
|
1336
|
+
function wechatAppUrl() {
|
|
1337
|
+
return `${API_BASE.replace(/\/relay-api\/?$/, "")}/app/`;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
let wechatRuntime = null;
|
|
1341
|
+
function startWeChat() {
|
|
1342
|
+
if (WECHAT_DISABLED) {
|
|
1343
|
+
console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
const relayDir = path.dirname(CONFIG_PATH);
|
|
1348
|
+
const cfg = loadLocalConfig();
|
|
1349
|
+
wechatRuntime = createWeChatRuntime({
|
|
1350
|
+
relayDir,
|
|
1351
|
+
upstream: UPSTREAM,
|
|
1352
|
+
cookieOf: harnessCookieOf,
|
|
1353
|
+
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1354
|
+
// 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
|
|
1355
|
+
tierProvider: resolveWechatTier,
|
|
1356
|
+
// 到期提醒的数据来源(复用上面那次 /api/me,不额外打网络)
|
|
1357
|
+
accountInfo: wechatAccountInfo,
|
|
1358
|
+
appUrl: wechatAppUrl(),
|
|
1359
|
+
// 模块自己的消息已带 `[wechat]` / `[wechat/events]` 前缀,这里**不能再加一次**
|
|
1360
|
+
// (真机日志里出现过 `[wechat] [wechat] 控制面已就绪`)。没前缀的兜底补一个。
|
|
1361
|
+
logger: {
|
|
1362
|
+
info: (m) => console.log(tagWeChat(m)),
|
|
1363
|
+
warn: (m) => console.warn(tagWeChat(m)),
|
|
1364
|
+
error: (m) => console.warn(tagWeChat(m)),
|
|
1365
|
+
debug: () => {},
|
|
1366
|
+
// WeChatChannel 构造时会给 token 登记脱敏;这里保持同样契约(日志不落 token)
|
|
1367
|
+
addSecret: () => {}
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
wechatRuntime.start().catch((e) => {
|
|
1371
|
+
console.warn(`[bridge] 微信通道启动失败(不影响远程访问): ${redactText(e)}`);
|
|
1372
|
+
});
|
|
1373
|
+
} catch (e) {
|
|
1374
|
+
console.warn(`[bridge] 微信通道初始化失败(不影响远程访问): ${redactText(e)}`);
|
|
1375
|
+
wechatRuntime = null;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** 优雅退出微信通道(notifystop + 关控制面)。失败只记日志。 */
|
|
1380
|
+
async function stopWeChat() {
|
|
1381
|
+
const rt = wechatRuntime;
|
|
1382
|
+
wechatRuntime = null;
|
|
1383
|
+
if (!rt) return;
|
|
1384
|
+
try {
|
|
1385
|
+
await rt.stop();
|
|
1386
|
+
} catch (e) {
|
|
1387
|
+
console.warn(`[bridge] 微信通道停止失败(忽略): ${redactText(e)}`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function redactText(e) {
|
|
1392
|
+
return String(e && e.message ? e.message : e).slice(0, 200);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1161
1395
|
async function runTunnel() {
|
|
1162
1396
|
const token = await resolveToken();
|
|
1163
1397
|
if (!token) {
|
|
1164
1398
|
console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
|
|
1165
1399
|
process.exit(1);
|
|
1166
1400
|
}
|
|
1401
|
+
// 把刚拿到的 JWT 喂给档位查询的缓存:否则 startWeChat 里的档位校准会**再登录一次**
|
|
1402
|
+
// (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
|
|
1403
|
+
// 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
|
|
1404
|
+
WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
|
|
1167
1405
|
// 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)
|
|
1168
1406
|
persistBridgeState({ device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
|
|
1169
1407
|
await initE2ee(token);
|
|
1170
1408
|
await registerDeviceInAccount(token);
|
|
1171
1409
|
connectTunnel(token);
|
|
1410
|
+
// 隧道已发起后再起微信:主链路优先,微信永不阻塞远程访问
|
|
1411
|
+
startWeChat();
|
|
1172
1412
|
setTimeout(() => {
|
|
1173
1413
|
console.log(`[bridge] 隧道模式运行中(上游 ${UPSTREAM},Ctrl-C 退出)`);
|
|
1174
1414
|
}, 1000);
|
|
@@ -1180,6 +1420,27 @@ async function main() {
|
|
|
1180
1420
|
console.error("[bridge] 缺少 DSH_BRIDGE_TUNNEL_URL:隧道模式是唯一模式(请设 relay-router 地址)");
|
|
1181
1421
|
process.exit(1);
|
|
1182
1422
|
}
|
|
1423
|
+
// ⚠️ 本文件此前**一个 process.on 都没有** —— 被 launchd/systemd/schtasks 杀掉时,
|
|
1424
|
+
// 微信长轮询不会收到信号,腾讯侧会一直以为通道在线(notifystop 永远不发)。
|
|
1425
|
+
// 只注册在 main() 里(被测试 import 时不污染测试进程)。
|
|
1426
|
+
let shuttingDown = false;
|
|
1427
|
+
const shutdown = async (sig) => {
|
|
1428
|
+
if (shuttingDown) return;
|
|
1429
|
+
shuttingDown = true;
|
|
1430
|
+
console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
|
|
1431
|
+
await stopWeChat();
|
|
1432
|
+
process.exit(0);
|
|
1433
|
+
};
|
|
1434
|
+
process.on("SIGTERM", () => { shutdown("SIGTERM"); });
|
|
1435
|
+
process.on("SIGINT", () => { shutdown("SIGINT"); });
|
|
1436
|
+
// 【Windows 的关键一条】Windows 上 process.kill() 是**无条件 TerminateProcess**:
|
|
1437
|
+
// 上面的 SIGTERM/SIGINT 处理函数在 Windows 上**永远不会被执行**,
|
|
1438
|
+
// 于是 notifystop(微信通道下线通知)永远发不出去,腾讯侧一直以为通道还在线。
|
|
1439
|
+
// watcher 通过 IPC 发来的 shutdown 消息是 Windows 上唯一能真正跑到的优雅退出路径
|
|
1440
|
+
//(POSIX 上也一并走这条,比信号更可靠)。见 src/lifecycle.mjs 的 stopChildGracefully。
|
|
1441
|
+
process.on("message", (m) => {
|
|
1442
|
+
if (m && m.type === "shutdown") shutdown("IPC shutdown");
|
|
1443
|
+
});
|
|
1183
1444
|
return runTunnel();
|
|
1184
1445
|
}
|
|
1185
1446
|
|