@mrrisega/dsh-remote 0.7.0-beta.2 → 0.7.0-beta.3
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/clients/dsh-remote/dsh-bridge.mjs +20 -0
- package/clients/dsh-remote/mobile-adapter.mjs +19 -7
- package/clients/dsh-remote/test/device-registration-report.test.mjs +133 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +41 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +87 -1
- package/clients/dsh-remote/wechat-runtime.mjs +75 -11
- package/dsh-setup.mjs +8 -2
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +67 -8
- package/packages/dsh-remote-web/lib/index.js +78 -7
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +20 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +19 -7
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +75 -11
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +8 -2
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +178 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +38 -0
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
// ① 官方「设置」→「远程访问」栏目(settings.section,位于「Agent 预设」下方,官方扩展点);
|
|
10
10
|
// ② 左侧官方「设置」按钮旁注入独立「📱 远程访问」快捷按钮(id=dru-nav-remote,非 clone,
|
|
11
11
|
// 插在其前方、同布局不覆盖官方按钮;点击直达该栏目,首次带引导红点)
|
|
12
|
+
// ⚠️ 只认**语义锚点**(`button[aria-haspopup="dialog"]`,且必须在设置弹层之外):
|
|
13
|
+
// 0.2 桌面端的设置面板本身就是 role="dialog" 的弹层,它的栏目也叫「通用设置」,
|
|
14
|
+
// 旧代码用「文本包含『设置』」去找锚点 → 按钮被塞进设置面板栏目列表最上面(业主反馈的
|
|
15
|
+
// 「植入错了地方」)。现在找不到锚点就**不注入**,绝不将就一个错误的落点。
|
|
12
16
|
// - 栏目内联渲染配置面板(浅色高对比 UI,遵循主流登录体验)
|
|
13
17
|
// · 登录态:已登录显示账号 + 退出登录;未登录显示 登录/注册 tabs
|
|
14
18
|
// · 🔒 修改密码(账号卡):已登录账号经 图形验证码+短信验证码 重置(/dsh-remote/password/reset 代理);
|
|
@@ -792,35 +796,87 @@ window.__ModuleLoader__.load({
|
|
|
792
796
|
return !!el.closest('[role="navigation"], nav, [class*="sidebar" i], [class*="sidenav" i], [class*="appnav" i], [class*="navRail" i]');
|
|
793
797
|
} catch (e) { return false; }
|
|
794
798
|
}
|
|
795
|
-
|
|
799
|
+
/**
|
|
800
|
+
* 是否位于「设置」弹层内部。
|
|
801
|
+
*
|
|
802
|
+
* 🔴 这一条是 2026-10-09 桌面端事故的根治点。官方设置面板本身就是一个
|
|
803
|
+
* `role="dialog" aria-modal="true"` 的弹层(0.2 实测,见 ui-settings-general 的 SettingsRoot),
|
|
804
|
+
* 它的左侧栏目同样长得像「导航」(`nav` + `navCell`),于是会被 navCellLike 命中。
|
|
805
|
+
* 旧代码用「文本包含『设置』」去找侧栏那颗齿轮按钮 —— 而弹层里的第一项叫
|
|
806
|
+
* **「通用设置」**,`indexOf("设置") !== -1` 直接命中,注入按钮就被塞进了
|
|
807
|
+
* 设置面板的栏目列表最上面(用户反馈的「植入错了地方」)。
|
|
808
|
+
*/
|
|
809
|
+
function navInDialog(el) {
|
|
810
|
+
try { return !!el.closest('[role="dialog"], [aria-modal="true"]'); } catch (e) { return false; }
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* 官方「设置」触发器(侧栏那颗齿轮按钮)—— **只认语义锚点**。
|
|
814
|
+
*
|
|
815
|
+
* 两个版本(Web 0.1.x / 桌面端 0.2)的触发器都是同一个形状(各自实测源码一致):
|
|
816
|
+
* `<button aria-label="设置" aria-haspopup="dialog" aria-expanded=…>`,外层 `…triggerRow`。
|
|
817
|
+
* `aria-haspopup="dialog"` 是**语言无关**的语义标记,比匹配文案稳(换语言/换文案都不失效)。
|
|
818
|
+
* 找不到就**不注入** —— 宁可不显示快捷入口,也绝不把按钮塞到别人家的面板里。
|
|
819
|
+
*/
|
|
820
|
+
function pickSettingsTrigger() {
|
|
821
|
+
try {
|
|
822
|
+
var cands = document.querySelectorAll('button[aria-haspopup="dialog"], [aria-haspopup="dialog"]');
|
|
823
|
+
for (var i = 0; i < cands.length; i++) {
|
|
824
|
+
var el = cands[i];
|
|
825
|
+
if (!el || el.id === NAV_ENTRY_ID) continue; // 排除注入按钮自身
|
|
826
|
+
if (navInDialog(el)) continue; // 弹层里的(含面板内的关闭/栏目)一律不要
|
|
827
|
+
if (!navCellLike(el)) continue;
|
|
828
|
+
if (NAV_BAD_RE.test(String(el.className || ""))) continue;
|
|
829
|
+
return el;
|
|
830
|
+
}
|
|
831
|
+
} catch (e) { /* 忽略 */ }
|
|
832
|
+
// 兜底:严格等值匹配(**不做子串匹配**,否则又会命中「通用设置」);且必须在弹层外
|
|
833
|
+
return pickNavByToken("设置", { exactOnly: true, insideDialog: false });
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* 按文案/aria-label 找导航项。
|
|
837
|
+
* @param {string} token 目标文案(如「远程访问」「设置」)
|
|
838
|
+
* @param {{exactOnly?:boolean, insideDialog?:boolean}} [opts]
|
|
839
|
+
* exactOnly=true 只接受完全相等;insideDialog 限定是否必须在设置弹层内。
|
|
840
|
+
* 无论哪种模式:**精确相等优先于子串**(老代码只要子串命中就返回,误伤面很大)。
|
|
841
|
+
*/
|
|
842
|
+
function pickNavByToken(token, opts) {
|
|
843
|
+
var exactOnly = !!(opts && opts.exactOnly);
|
|
844
|
+
var insideDialog = opts ? opts.insideDialog : undefined;
|
|
796
845
|
try {
|
|
797
846
|
var els = document.querySelectorAll("button, [role='tab'], [role='menuitem'], [role='button'], a, [aria-label]");
|
|
847
|
+
var loose = null;
|
|
798
848
|
for (var i = 0; i < els.length; i++) {
|
|
799
849
|
var el = els[i];
|
|
800
850
|
if (!el || el.id === NAV_ENTRY_ID) continue; // 排除注入按钮自身(防递归)
|
|
801
851
|
if (!navCellLike(el)) continue;
|
|
802
852
|
if (NAV_BAD_RE.test(String(el.className || ""))) continue;
|
|
853
|
+
var inDlg = navInDialog(el);
|
|
854
|
+
if (insideDialog === true && !inDlg) continue;
|
|
855
|
+
if (insideDialog === false && inDlg) continue;
|
|
803
856
|
var t = navCleanText(el);
|
|
804
|
-
if (t && !/\r|\n/.test(t) && t.length <= NAV_TEXT_MAX &&
|
|
805
|
-
(t === token || t.indexOf(token) === 0 || t.indexOf(token) !== -1)) return el;
|
|
806
857
|
var at = navAttrOf(el);
|
|
807
|
-
|
|
858
|
+
var textOK = !!(t && !/\r|\n/.test(t) && t.length <= NAV_TEXT_MAX);
|
|
859
|
+
if ((textOK && t === token) || (at && at === token)) return el; // 精确命中:立即返回
|
|
860
|
+
if (!loose && ((textOK && t.indexOf(token) !== -1) || (at && at.indexOf(token) === 0))) loose = el;
|
|
808
861
|
}
|
|
862
|
+
return exactOnly ? null : loose;
|
|
809
863
|
} catch (e) { /* 忽略 */ }
|
|
810
864
|
return null;
|
|
811
865
|
}
|
|
812
866
|
/** 打开「远程访问」设置栏目:栏目已渲染直接点;否则先点官方「设置」再 ≤3s 轮询补点。 */
|
|
813
867
|
function openRemoteSettings() {
|
|
814
868
|
try {
|
|
815
|
-
|
|
869
|
+
// 栏目项在设置弹层内 → 这里**允许**弹层内匹配(insideDialog: true);
|
|
870
|
+
// 精确相等优先,避免命中「通用设置」这类含「设置」的邻居。
|
|
871
|
+
var cell = pickNavByToken("远程访问", { insideDialog: true });
|
|
816
872
|
if (cell) { cell.click(); return; }
|
|
817
|
-
var st =
|
|
873
|
+
var st = pickSettingsTrigger(); // 侧栏那颗齿轮(语义锚点,绝不落进弹层)
|
|
818
874
|
if (!st) return;
|
|
819
875
|
st.click();
|
|
820
876
|
var tries = 0;
|
|
821
877
|
var iv = setInterval(function () {
|
|
822
878
|
tries++;
|
|
823
|
-
var c2 = pickNavByToken("远程访问");
|
|
879
|
+
var c2 = pickNavByToken("远程访问", { insideDialog: true });
|
|
824
880
|
if (c2) { clearInterval(iv); c2.click(); }
|
|
825
881
|
else if (tries >= NAV_POLL_MAX) clearInterval(iv);
|
|
826
882
|
}, 150);
|
|
@@ -830,6 +886,9 @@ window.__ModuleLoader__.load({
|
|
|
830
886
|
function mountNavEntry(host) {
|
|
831
887
|
try {
|
|
832
888
|
if (document.getElementById(NAV_ENTRY_ID)) return;
|
|
889
|
+
// 最后一道防线:无论调用方怎么找的,**绝不**把入口塞进设置弹层内部
|
|
890
|
+
// (2026-10-09 桌面端事故:按钮出现在设置面板栏目列表最上面)。
|
|
891
|
+
if (navInDialog(host)) return;
|
|
833
892
|
var wrap = host && host.parentNode;
|
|
834
893
|
if (!wrap || !wrap.insertBefore || !wrap.contains) return;
|
|
835
894
|
var btn = document.createElement("button");
|
|
@@ -871,7 +930,7 @@ window.__ModuleLoader__.load({
|
|
|
871
930
|
var iv = setInterval(function () {
|
|
872
931
|
try {
|
|
873
932
|
if (document.getElementById(NAV_ENTRY_ID)) return; // 已注入
|
|
874
|
-
var host =
|
|
933
|
+
var host = pickSettingsTrigger(); // 只认 aria-haspopup="dialog" 的官方「设置」触发器
|
|
875
934
|
if (host) mountNavEntry(host);
|
|
876
935
|
} catch (e) { /* 忽略 */ }
|
|
877
936
|
}, 2000);
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
//
|
|
22
22
|
// 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
|
|
23
23
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, copyFileSync, cpSync, constants as fsConstants } from "node:fs";
|
|
24
|
-
import { join, dirname, sep, delimiter } from "node:path";
|
|
24
|
+
import { join, dirname, sep, delimiter, isAbsolute } from "node:path";
|
|
25
25
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import { randomUUID } from "node:crypto";
|
|
27
27
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -1075,6 +1075,7 @@ Environment=PATH=${SERVICE_PATH}
|
|
|
1075
1075
|
${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
|
|
1076
1076
|
Environment=DSH_BRIDGE_INSTALL_SOURCE=${installSourceOf(relayDir)}
|
|
1077
1077
|
Environment=DSH_BRIDGE_INSTALL_VERSION=${PLUGIN_VERSION}
|
|
1078
|
+
Environment=DSH_BRIDGE_HARNESS_VERSION=${harnessVersion()}
|
|
1078
1079
|
|
|
1079
1080
|
[Install]
|
|
1080
1081
|
WantedBy=default.target
|
|
@@ -1557,7 +1558,8 @@ function ensureRuntime(relayDir) {
|
|
|
1557
1558
|
windowsHide: true,
|
|
1558
1559
|
// DSH_BRIDGE_INSTALL_SOURCE:本机运行环境是插件自愈补的 → 安装器原样透传到 plist/bridge env,
|
|
1559
1560
|
// 于是设备行上的 install_source 如实记成 plugin_market(而不是安装器默认的 npx)。
|
|
1560
|
-
|
|
1561
|
+
// DSH_BRIDGE_HARNESS_VERSION 同理:安装器会把它写进 plist;不给的话,桥重启后就丢了宿主版本。
|
|
1562
|
+
env: spawnEnv({ npm_config_registry: "https://registry.npmjs.org", DSH_BRIDGE_INSTALL_SOURCE: "plugin_market", DSH_BRIDGE_HARNESS_VERSION: harnessVersion() }),
|
|
1561
1563
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
|
|
1562
1564
|
onError: (e) => failSpawn(e, npx.kind),
|
|
1563
1565
|
});
|
|
@@ -1653,7 +1655,7 @@ function writeAutostartFile(relayDir) {
|
|
|
1653
1655
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
1654
1656
|
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1655
1657
|
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1656
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string></dict>
|
|
1658
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${installSourceOf(relayDir)}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${PLUGIN_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${harnessVersion()}</string></dict>
|
|
1657
1659
|
</dict></plist>`;
|
|
1658
1660
|
mkdirSync(dirname(plistPath), { recursive: true });
|
|
1659
1661
|
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
@@ -1763,6 +1765,8 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
|
|
|
1763
1765
|
env: spawnEnv({
|
|
1764
1766
|
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
1765
1767
|
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
1768
|
+
// 宿主版本:bridge 自己推不出来(它是另一个进程),只能由插件在宿主进程内问出来再注入。
|
|
1769
|
+
DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
|
|
1766
1770
|
// 上游端口**必须**透传:watcher 用它判断「dsh web 在线吗」,bridge 用它做转发目标。
|
|
1767
1771
|
// 不传的话两边都回落到写死的 3080 —— dsh web 不在默认端口时 bridge 永远不启动(0.6.12 修)。
|
|
1768
1772
|
DSH_BRIDGE_UPSTREAM: upstreamUrl(),
|
|
@@ -3112,6 +3116,12 @@ async function proxyFeedback(relayDir, req, res, pathname) {
|
|
|
3112
3116
|
const headers = {
|
|
3113
3117
|
"x-dsh-device": cfg.device_id || "",
|
|
3114
3118
|
"x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
|
|
3119
|
+
// 真实 UA 必须**转发**:以前不转发,上游 fetch 就用 undici 默认 UA(`node`),
|
|
3120
|
+
// 后台「机型」列对每条真实反馈都显示同一个没用值(生产实测 fb_* 全是 user_agent=node),
|
|
3121
|
+
// 于是「Windows 还是 Linux 出的问题」只能靠翻安装日志考古。同时补一个结构化平台字段
|
|
3122
|
+
// (x-dsh-platform),比让后台解析 UA 可靠得多。
|
|
3123
|
+
"user-agent": String(req.headers["user-agent"] || `dsh-remote-web/${PLUGIN_VERSION}`),
|
|
3124
|
+
"x-dsh-platform": process.platform,
|
|
3115
3125
|
};
|
|
3116
3126
|
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
3117
3127
|
const auth = req.headers.authorization;
|
|
@@ -3527,7 +3537,14 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
|
|
|
3527
3537
|
windowsHide: true, // Windows:不弹控制台窗口(POSIX 上该选项被忽略)
|
|
3528
3538
|
// PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源口径
|
|
3529
3539
|
// (在线更新会重跑安装器并重写 plist,若不带来源就会把 plugin_market 误记成 npx)。
|
|
3530
|
-
env: spawnEnv({
|
|
3540
|
+
env: spawnEnv({
|
|
3541
|
+
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
3542
|
+
// 安装器会把这些环境变量原样写进 plist/systemd 单元,所以宿主版本也要一起给它 ——
|
|
3543
|
+
// 否则安装器重启桥之后,新写的自启动项里就没有 harness 版本了。
|
|
3544
|
+
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
3545
|
+
DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
|
|
3546
|
+
...(extraEnv || {}),
|
|
3547
|
+
}),
|
|
3531
3548
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
|
|
3532
3549
|
onError,
|
|
3533
3550
|
});
|
|
@@ -3607,6 +3624,7 @@ async function runOnlineUpdate(relayDir) {
|
|
|
3607
3624
|
env: spawnEnv({
|
|
3608
3625
|
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
3609
3626
|
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
3627
|
+
DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
|
|
3610
3628
|
npm_config_registry: "https://registry.npmjs.org",
|
|
3611
3629
|
}),
|
|
3612
3630
|
stdio: ["ignore", out, out],
|
|
@@ -4613,6 +4631,8 @@ function buildConnectDiagnostics(relayDir, conn, log) {
|
|
|
4613
4631
|
const rows = [
|
|
4614
4632
|
"dsh-remote 连接诊断",
|
|
4615
4633
|
`插件版本: ${PLUGIN_VERSION}`,
|
|
4634
|
+
// 宿主版本:生产反馈里最缺的就是这个数 —— 只报插件版本时,「是不是 DSH 升级导致的兼容故障」答不出来
|
|
4635
|
+
`宿主版本: ${harnessVersion() || "未知"}`,
|
|
4616
4636
|
`配置目录: ${relayDir}`,
|
|
4617
4637
|
`连接阶段: ${conn.phase}(${conn.text})`,
|
|
4618
4638
|
`设备 ID: ${conn.deviceId || "(未生成)"}`,
|
|
@@ -4678,7 +4698,7 @@ async function buildDiagReport(relayDir, opts = {}) {
|
|
|
4678
4698
|
const conn = await composeConnect(relayDir, { manual });
|
|
4679
4699
|
const cfg = loadConfig(relayDir);
|
|
4680
4700
|
const head = redactForDiagnostics([
|
|
4681
|
-
`【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
|
|
4701
|
+
`【插件】v${PLUGIN_VERSION} 运行时 ${runtimeVersionOf(relayDir) || "未知"} 宿主 ${harnessVersion() || "未知"} ${osPlatform()}/${process.arch} node ${String(process.versions.node).split(".")[0]}`,
|
|
4682
4702
|
`【阶段】${conn.phase}(${conn.text})`,
|
|
4683
4703
|
`【设备】ID=${conn.deviceId || "(未生成)"} 运行环境=${conn.runtimeReady ? "就绪" : `缺失${conn.runtimeMissing && conn.runtimeMissing.length ? `(缺 ${conn.runtimeMissing.slice(0, 3).join("、")})` : ""}`}`,
|
|
4684
4704
|
`【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} 次` : ""}`,
|
|
@@ -4714,6 +4734,10 @@ async function submitFeedback(relayDir, payload) {
|
|
|
4714
4734
|
"content-type": "application/json",
|
|
4715
4735
|
"x-dsh-device": cfg.device_id || "",
|
|
4716
4736
|
"x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
|
|
4737
|
+
// 与 proxyFeedback 同一口径:结构化平台字段 + 真实 UA(此处没有浏览器请求可转发,
|
|
4738
|
+
// 就如实写本机平台,绝不冒充浏览器 UA)。
|
|
4739
|
+
"user-agent": `dsh-remote-web/${PLUGIN_VERSION} (${process.platform}; ${process.arch})`,
|
|
4740
|
+
"x-dsh-platform": process.platform,
|
|
4717
4741
|
};
|
|
4718
4742
|
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
4719
4743
|
const t = await feedbackAuthToken(relayDir).catch(() => "");
|
|
@@ -5180,9 +5204,51 @@ function harnessVersionFromEntry(entry) {
|
|
|
5180
5204
|
}
|
|
5181
5205
|
}
|
|
5182
5206
|
|
|
5207
|
+
/**
|
|
5208
|
+
* 由**宿主根目录**解析版本:`<root>/node_modules/@deepseek-ai/dsh/package.json`。
|
|
5209
|
+
*
|
|
5210
|
+
* 【为什么还要这一条(2026-10-09 真机)】桌面端(DeepSeek Harness 0.2,Electron)的宿主进程 argv 是:
|
|
5211
|
+
* [0] …/MacOS/DeepSeek Harness
|
|
5212
|
+
* [1] --expose-internals ← **是开关,不是路径**
|
|
5213
|
+
* [2] …/app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/index.js
|
|
5214
|
+
* [3] …/app.asar/dsh ← **宿主根目录**
|
|
5215
|
+
* 于是「取 argv[1] 的上一级 package.json」在桌面端**必然拿不到**(argv[1] 是 `--expose-internals`),
|
|
5216
|
+
* 这就是桌面用户全部上报 harness 版本为空的原因。好在 argv[3] 就是宿主根,其
|
|
5217
|
+
* `node_modules/@deepseek-ai/dsh/package.json` 正是宿主版本(实测 0.2.0-rc.2)。
|
|
5218
|
+
* 桌面端是 Electron,`readFileSync` 能透明读 asar 内的路径,所以这条在真实宿主里可读。
|
|
5219
|
+
*/
|
|
5220
|
+
function harnessVersionFromRoot(root) {
|
|
5221
|
+
try {
|
|
5222
|
+
const manifest = JSON.parse(readFileSync(join(String(root), "node_modules", HARNESS_PACKAGE_NAME, "package.json"), "utf8"));
|
|
5223
|
+
if (!manifest || manifest.name !== HARNESS_PACKAGE_NAME) return "";
|
|
5224
|
+
return telemetryToken(manifest.version);
|
|
5225
|
+
} catch {
|
|
5226
|
+
return ""; // 不是宿主根 / 读不到 → 交给下一条候选,绝不抛
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
|
|
5230
|
+
/**
|
|
5231
|
+
* 从整条 argv 里找出宿主版本(**顺序试探,先命中先返回**)。三条来源都不联网、不起子进程:
|
|
5232
|
+
* ① 目录形态:某参数本身就是宿主根(桌面端 argv[3]);
|
|
5233
|
+
* ② 文件形态:某参数是 DSH 主模块(Web 版 argv[1]),取它上一级的 package.json;
|
|
5234
|
+
* 开关(`-` 开头)与非绝对路径一律跳过 —— 绝不把 `--expose-internals` 当路径去 realpath。
|
|
5235
|
+
*/
|
|
5236
|
+
function harnessVersionFromArgv(argv) {
|
|
5237
|
+
const list = Array.isArray(argv) ? argv : [];
|
|
5238
|
+
for (const raw of list) {
|
|
5239
|
+
const p = String(raw ?? "");
|
|
5240
|
+
if (p === "" || p.startsWith("-") || !isAbsolute(p)) continue;
|
|
5241
|
+
const viaRoot = harnessVersionFromRoot(p);
|
|
5242
|
+
if (viaRoot) return viaRoot;
|
|
5243
|
+
const viaEntry = harnessVersionFromEntry(p);
|
|
5244
|
+
if (viaEntry) return viaEntry;
|
|
5245
|
+
}
|
|
5246
|
+
return "";
|
|
5247
|
+
}
|
|
5248
|
+
|
|
5183
5249
|
/** 本进程运行所在的宿主版本(未知 = "")。每次调用现读:事件量很小,且绝不该缓存出过期结论。 */
|
|
5184
5250
|
function harnessVersion() {
|
|
5185
|
-
return
|
|
5251
|
+
return harnessVersionFromArgv(process.argv);
|
|
5186
5252
|
}
|
|
5187
5253
|
|
|
5188
5254
|
/**
|
|
@@ -5556,6 +5622,8 @@ export const __telemetryInternals = {
|
|
|
5556
5622
|
// 必须能被用例逐条锁住(含未知/非法值的降级行为)。
|
|
5557
5623
|
harnessVersion: () => harnessVersion(),
|
|
5558
5624
|
harnessVersionFromEntry: (entry) => harnessVersionFromEntry(entry),
|
|
5625
|
+
harnessVersionFromRoot: (root) => harnessVersionFromRoot(root),
|
|
5626
|
+
harnessVersionFromArgv: (argv) => harnessVersionFromArgv(argv),
|
|
5559
5627
|
harnessToken: (v) => telemetryToken(v),
|
|
5560
5628
|
updateTag: UPDATE_TAG,
|
|
5561
5629
|
eventNames: [...TELEMETRY_EVENT_NAMES],
|
|
@@ -5582,7 +5650,7 @@ export const __telemetryInternals = {
|
|
|
5582
5650
|
* 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
|
|
5583
5651
|
* 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
|
|
5584
5652
|
* 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
|
|
5585
|
-
* body { device_id?, install_source, install_version, host_os, host_arch },成功 200 {ok:true};
|
|
5653
|
+
* body { device_id?, install_source, install_version, harness_version, host_os, host_arch },成功 200 {ok:true};
|
|
5586
5654
|
* 404/400 一律忽略(老服务端没有该接口时不能报错)。
|
|
5587
5655
|
*/
|
|
5588
5656
|
const INSTALL_REPORT_FILE = ".dsh-install-report.json";
|
|
@@ -5617,6 +5685,9 @@ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
|
|
|
5617
5685
|
...(cfg.device_id ? { device_id: String(cfg.device_id) } : {}),
|
|
5618
5686
|
install_source: source,
|
|
5619
5687
|
install_version: version,
|
|
5688
|
+
// 宿主(DeepSeek Harness)版本:与 install_version(插件版本)是两个数,
|
|
5689
|
+
// 只报插件版本时「是不是 DSH 升级导致的故障」答不出来(2026-10 生产反馈的普遍缺口)。
|
|
5690
|
+
harness_version: harnessVersion(),
|
|
5620
5691
|
host_os: process.platform,
|
|
5621
5692
|
host_arch: process.arch,
|
|
5622
5693
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.0-beta.
|
|
3
|
+
"version": "0.7.0-beta.3",
|
|
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",
|
|
@@ -254,8 +254,25 @@ function resolveInstallVersion() {
|
|
|
254
254
|
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version || "";
|
|
255
255
|
} catch { return ""; }
|
|
256
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* 宿主(DeepSeek Harness)版本:**由插件注入**,bridge 自己算不出来。
|
|
259
|
+
*
|
|
260
|
+
* 为什么必须是注入:宿主版本要从"宿主进程的 argv / 安装目录"推出来,而 bridge 是插件
|
|
261
|
+
* **另外拉起的独立进程**(脱离宿主,见 dsh-setup.mjs 的守护),它的 argv 与宿主毫无关系。
|
|
262
|
+
* 插件跑在宿主进程内,能拿到真实版本(见 packages/dsh-remote-web/lib/index.js 的
|
|
263
|
+
* harnessVersion()),所以由它经 DSH_BRIDGE_HARNESS_VERSION 传下来。
|
|
264
|
+
*
|
|
265
|
+
* 与 INSTALL_VERSION 的区别(曾经把这两个数混为一谈,导致版本问题答不出来):
|
|
266
|
+
* install_version = **本插件**的版本(用户装的 dsh-remote-web 是哪个版本)
|
|
267
|
+
* harness_version = **宿主**的版本(用户跑的 DeepSeek Harness 是哪个版本)
|
|
268
|
+
* 只知其一都定位不了"是不是宿主升级导致的兼容性故障"。
|
|
269
|
+
*/
|
|
270
|
+
function resolveHarnessVersion() {
|
|
271
|
+
return String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
|
|
272
|
+
}
|
|
257
273
|
const INSTALL_SOURCE = resolveInstallSource();
|
|
258
274
|
const INSTALL_VERSION = resolveInstallVersion();
|
|
275
|
+
const HARNESS_VERSION = resolveHarnessVersion();
|
|
259
276
|
|
|
260
277
|
// ---------- 稳定设备身份(.dsh-config.json) ----------
|
|
261
278
|
|
|
@@ -1399,6 +1416,9 @@ async function registerDeviceInAccount(token) {
|
|
|
1399
1416
|
// install_source: npx(安装器注入) | plugin_market(插件拉起/自愈,默认) | unknown(白名单外)
|
|
1400
1417
|
install_source: INSTALL_SOURCE,
|
|
1401
1418
|
install_version: INSTALL_VERSION,
|
|
1419
|
+
// v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
|
|
1420
|
+
// 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
|
|
1421
|
+
harness_version: HARNESS_VERSION,
|
|
1402
1422
|
host_os: process.platform,
|
|
1403
1423
|
host_arch: process.arch
|
|
1404
1424
|
})
|
|
@@ -1244,7 +1244,12 @@ const SCRIPT = `(() => {
|
|
|
1244
1244
|
旧的 120/300 分档是照"网页首屏"的体感定的,会把大量正常可用的移动网络误报成"较慢",
|
|
1245
1245
|
反而让用户以为哪里坏了。 */
|
|
1246
1246
|
const RTT_KEEP = 8, RTT_MAX_MS = 20000;
|
|
1247
|
-
|
|
1247
|
+
/* 🔴 样本数组挂在 window 上,**不在 buildRemoteMenu 的闭包里**(2026-10-09 加固)。
|
|
1248
|
+
为什么:fetch 包装只装一次(window.__dshMaSessionWatch 一次性闸门),而菜单/悬浮按钮
|
|
1249
|
+
在官方 SPA 重渲染时可能被**重建**。若样本数组长在 buildRemoteMenu 里,包装就会一直喂给
|
|
1250
|
+
"上一代"渲染器(那个节点已经不在文档里),新界面读自己的空数组 → **永远停在「测速中…」**。
|
|
1251
|
+
挂在 window 上则任何一代渲染器都读同一份样本。 */
|
|
1252
|
+
const rttSamples = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
|
|
1248
1253
|
const rttEl = fab.querySelector('[data-role="rtt"]');
|
|
1249
1254
|
const netEl = menu.querySelector('[data-role="net"]');
|
|
1250
1255
|
const rttValue = () => {
|
|
@@ -1262,11 +1267,13 @@ const SCRIPT = `(() => {
|
|
|
1262
1267
|
try {
|
|
1263
1268
|
const ms = rttValue();
|
|
1264
1269
|
const g = ms === null ? "" : rttGrade(ms);
|
|
1270
|
+
// 0ms 是句谎话(不足 1ms 的真实往返会被四舍五入成 0)→ 展示层夹到 ≥1ms。
|
|
1271
|
+
const shown = ms === null ? null : Math.max(1, Math.round(ms));
|
|
1265
1272
|
if (rttEl) {
|
|
1266
|
-
if (
|
|
1273
|
+
if (shown === null) { rttEl.hidden = true; rttEl.textContent = ""; rttEl.removeAttribute("data-rtt"); }
|
|
1267
1274
|
else {
|
|
1268
1275
|
rttEl.hidden = false;
|
|
1269
|
-
rttEl.textContent =
|
|
1276
|
+
rttEl.textContent = shown + "ms";
|
|
1270
1277
|
rttEl.setAttribute("data-rtt", g);
|
|
1271
1278
|
}
|
|
1272
1279
|
}
|
|
@@ -1274,17 +1281,22 @@ const SCRIPT = `(() => {
|
|
|
1274
1281
|
netEl.setAttribute("data-rtt", g || "");
|
|
1275
1282
|
const ico = netEl.querySelector(".ico");
|
|
1276
1283
|
const txt = netEl.querySelector(".txt");
|
|
1277
|
-
if (ico) ico.textContent =
|
|
1278
|
-
if (txt) txt.textContent =
|
|
1284
|
+
if (ico) ico.textContent = shown === null ? "📶" : (g === "bad" ? "📵" : "📶");
|
|
1285
|
+
if (txt) txt.textContent = shown === null ? "测速中…(等一次网络请求后显示)" : (shown + " ms · " + rttLabel(g));
|
|
1279
1286
|
}
|
|
1280
|
-
try { fab.title =
|
|
1287
|
+
try { fab.title = shown === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + shown + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
|
|
1281
1288
|
} catch (e) { /* 延迟显示失败绝不影响主功能 */ }
|
|
1282
1289
|
};
|
|
1283
1290
|
/** 记一个成功样本(由 fetch 包装在**响应到达**时调用;失败/超时不调用)。 */
|
|
1284
1291
|
const noteRtt = (ms) => {
|
|
1285
1292
|
if (typeof ms !== "number" || !isFinite(ms)) return;
|
|
1286
1293
|
const r = Math.round(ms);
|
|
1287
|
-
if (r < 1 ||
|
|
1294
|
+
/* 🔴 旧写法是 if (r < 1 || ...) return; ,注释说"亚毫秒=同帧缓存命中"。这个假设是**错的**:
|
|
1295
|
+
本机/回环访问(桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms,
|
|
1296
|
+
于是**每一个**样本都被丢掉,界面永远停在「测速中…(等一次网络请求后显示)」——
|
|
1297
|
+
业主 2026-10-09 实测反馈的正是这个现象。只拒绝负数与超时;0 → 展示层夹到 1ms。
|
|
1298
|
+
(面板侧 chip 从一开始就是 v <= 0 + Math.max(1, round),两边口径由此对齐。) */
|
|
1299
|
+
if (r < 0 || r > RTT_MAX_MS) return;
|
|
1288
1300
|
rttSamples.push(r);
|
|
1289
1301
|
while (rttSamples.length > RTT_KEEP) rttSamples.shift();
|
|
1290
1302
|
renderRtt();
|
|
@@ -199,10 +199,22 @@ const SPECIAL_EVENT_KINDS = Object.freeze([
|
|
|
199
199
|
const NOTICE_FORMATTER_KINDS = Object.freeze(["quota", "membership"]);
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
|
-
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive
|
|
203
|
-
*
|
|
202
|
+
* 主动推送失败后的**待补发队列**上限(见 `#pushProactive`):只留**最近几条**。
|
|
203
|
+
*
|
|
204
|
+
* 2026-10-09 业主口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
|
|
205
|
+
* 从 20 收到 5 的理由:一轮里真正还值得看的就最近那么两三条;攒到 20 条的结果是
|
|
206
|
+
* 平台一恢复就**一次连打十几条** —— 实测正是这种突发批量触发 `ret=-2 prepare failed`
|
|
207
|
+
* 被限流,于是补发又失败、队列再涨,越积越多永远还不上(现场观察到的就是这个循环)。
|
|
208
|
+
*/
|
|
209
|
+
const MAX_OUTBOX = 5;
|
|
210
|
+
/**
|
|
211
|
+
* 待补发条目的**保质期**:超过这个时长还没送达就丢弃(先丢最旧的)。
|
|
212
|
+
*
|
|
213
|
+
* 为什么必须有(同一条业主口径):一条"任务跑完了"的通知迟到几小时就**毫无价值** ——
|
|
214
|
+
* 补发出去只会让用户以为刚刚才发生;而队列的价值在于"网络抖动期间的短暂失败",
|
|
215
|
+
* 那是分钟级的。2 小时足够覆盖抖动与短时离线,又不会把半天前的旧事翻出来刷屏。
|
|
204
216
|
*/
|
|
205
|
-
const
|
|
217
|
+
const OUTBOX_TTL_MS = 2 * 60 * 60 * 1000;
|
|
206
218
|
/** 补发时的正文前缀(迟到必须如实说,不能让用户以为刚刚才发生)。 */
|
|
207
219
|
const OUTBOX_MARK = "(补发)";
|
|
208
220
|
|
|
@@ -337,6 +349,12 @@ function sendJson(res, status, body) {
|
|
|
337
349
|
}
|
|
338
350
|
|
|
339
351
|
export class WeChatRuntime {
|
|
352
|
+
/**
|
|
353
|
+
* 「预期内的订阅拒绝」已报过的 (code|sessionId) 集合 —— 见 fault 处理器。
|
|
354
|
+
* 只是防刷屏:每生成一个**子代理**会话都会被订阅层拒一次,不记集合就是每个子代理刷一行 warn。
|
|
355
|
+
*/
|
|
356
|
+
#expectedFaultSeen = new Set();
|
|
357
|
+
|
|
340
358
|
/**
|
|
341
359
|
* @param {{
|
|
342
360
|
* relayDir: string,
|
|
@@ -508,7 +526,9 @@ export class WeChatRuntime {
|
|
|
508
526
|
followed_sessions: this.subscriber && typeof this.subscriber.sessions === "function"
|
|
509
527
|
? this.subscriber.sessions()
|
|
510
528
|
: [],
|
|
511
|
-
|
|
529
|
+
// 报**有效**条数(按保质期过滤后):否则进程重启后,面板会一直显示那几条早已过期、
|
|
530
|
+
// 永远不会被补发的条目,看起来像"队列卡住了"。真正的清理由 #trimOutbox 落盘。
|
|
531
|
+
pending_outbox: this.#pruneOutbox(state.pending_outbox).kept.length,
|
|
512
532
|
last_fault: state.last_fault || null,
|
|
513
533
|
last_completed_session_id: state.last_completed_session_id || "",
|
|
514
534
|
last_completed_at: Number(state.last_completed_at || 0),
|
|
@@ -1547,14 +1567,44 @@ export class WeChatRuntime {
|
|
|
1547
1567
|
return list;
|
|
1548
1568
|
}
|
|
1549
1569
|
|
|
1570
|
+
/**
|
|
1571
|
+
* 丢掉**过期**条目(返回保留的与丢掉的数量)。
|
|
1572
|
+
* `at` 缺失/非法 = 更早的落盘格式(那时还没有 at 字段)→ 一律视为过期:
|
|
1573
|
+
* 留着只会把"最近几条"挤掉,而它们必然比 TTL 更老。
|
|
1574
|
+
*/
|
|
1575
|
+
#pruneOutbox(list) {
|
|
1576
|
+
const now = this.clock();
|
|
1577
|
+
const kept = [];
|
|
1578
|
+
let dropped = 0;
|
|
1579
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
1580
|
+
const at = Number(item && item.at);
|
|
1581
|
+
if (!Number.isFinite(at) || at <= 0 || now - at > OUTBOX_TTL_MS) { dropped += 1; continue; }
|
|
1582
|
+
kept.push(item);
|
|
1583
|
+
}
|
|
1584
|
+
return { kept, dropped };
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** 丢弃过期的、并按上限只留最近几条;丢了就**如实记一行日志**(绝不静默丢)。 */
|
|
1588
|
+
#trimOutbox(list) {
|
|
1589
|
+
const { kept, dropped } = this.#pruneOutbox(list);
|
|
1590
|
+
let overflow = 0;
|
|
1591
|
+
while (kept.length > MAX_OUTBOX) { kept.shift(); overflow += 1; }
|
|
1592
|
+
const lost = dropped + overflow;
|
|
1593
|
+
if (lost > 0) {
|
|
1594
|
+
const mins = Math.round(OUTBOX_TTL_MS / 60000);
|
|
1595
|
+
const why = [dropped ? `${dropped} 条超 ${mins} 分钟未送达` : "", overflow ? `${overflow} 条超出最近 ${MAX_OUTBOX} 条上限` : ""]
|
|
1596
|
+
.filter(Boolean).join("、");
|
|
1597
|
+
this.logger.warn(`[wechat] 待补发队列丢弃 ${lost} 条(${why}),保留最近 ${kept.length} 条`);
|
|
1598
|
+
}
|
|
1599
|
+
return kept;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1550
1602
|
#enqueueOutbox(text, kind = "") {
|
|
1551
1603
|
const body = String(text || "");
|
|
1552
1604
|
if (!body) return 0;
|
|
1553
1605
|
const list = this.#outbox();
|
|
1554
1606
|
list.push({ text: body, kind: String(kind || ""), at: this.clock() });
|
|
1555
|
-
|
|
1556
|
-
while (list.length > MAX_OUTBOX) list.shift();
|
|
1557
|
-
return this.#writeOutbox(list).length;
|
|
1607
|
+
return this.#writeOutbox(this.#trimOutbox(list)).length;
|
|
1558
1608
|
}
|
|
1559
1609
|
|
|
1560
1610
|
/**
|
|
@@ -1562,7 +1612,11 @@ export class WeChatRuntime {
|
|
|
1562
1612
|
* @returns {Promise<number>} 这次补发成功的条数
|
|
1563
1613
|
*/
|
|
1564
1614
|
async #flushOutbox(to) {
|
|
1565
|
-
|
|
1615
|
+
// 补发前先按保质期清一遍:**过期的不发**(迟到几小时的"任务跑完了"只会误导用户),
|
|
1616
|
+
// 也避免一次性连打十几条把平台打限流(那正是队列越积越多的成因)。
|
|
1617
|
+
const raw = this.#outbox();
|
|
1618
|
+
const list = this.#trimOutbox(raw); // 只在真丢了东西时才落盘(避免每轮都写状态文件)
|
|
1619
|
+
if (list.length !== raw.length) this.#writeOutbox(list);
|
|
1566
1620
|
if (!list.length || !to) return 0;
|
|
1567
1621
|
let sent = 0;
|
|
1568
1622
|
const rest = [...list];
|
|
@@ -1645,10 +1699,20 @@ export class WeChatRuntime {
|
|
|
1645
1699
|
const code = String((info && info.code) || "unknown");
|
|
1646
1700
|
const message = String((info && info.message) || "").slice(0, 160);
|
|
1647
1701
|
const sessionId = String((info && info.sessionId) || "");
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1702
|
+
// 预期内的拒绝(子代理会话必然被拒 `session/agent-busy` / 会话刚被删 `session/not-found`)
|
|
1703
|
+
// **不是故障**:每生成一个子代理就会来一条,写成 warn 会把真正的故障淹掉
|
|
1704
|
+
// (2026-10-09 现场:日志里成片 `订阅异常 session/agent-busy … require their durable parent address`)。
|
|
1705
|
+
// 但也不能完全静默(2026-09-25 的教训:fault 以前没人接,全丢)→ 降为 info,且同一对只报一次。
|
|
1706
|
+
if (info && info.expected === true) {
|
|
1707
|
+
const key = `${code}|${sessionId}`;
|
|
1708
|
+
if (this.#expectedFaultSeen.size < 200 && !this.#expectedFaultSeen.has(key)) {
|
|
1709
|
+
this.#expectedFaultSeen.add(key);
|
|
1710
|
+
this.logger.info(`[wechat/events] 订阅跳过(预期,不重试) ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
|
|
1711
|
+
}
|
|
1712
|
+
return;
|
|
1651
1713
|
}
|
|
1714
|
+
this.logger.warn(`[wechat/events] 订阅异常 ${code}${sessionId ? ` session=${sessionId}` : ""}: ${message}`);
|
|
1715
|
+
this.channel.writeState({ last_fault: { code, message, session_id: sessionId, at: this.clock() } });
|
|
1652
1716
|
});
|
|
1653
1717
|
}
|
|
1654
1718
|
|
|
@@ -362,9 +362,13 @@ const SERVICE_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/
|
|
|
362
362
|
* 插件市场那条路径由插件半自愈补装:插件 spawn 本安装器时显式传入
|
|
363
363
|
* DSH_BRIDGE_INSTALL_SOURCE=plugin_market,这里原样透传(外部注入 > 硬编码默认)。
|
|
364
364
|
* install_version:优先取外部注入(插件/在线更新器),否则取本包 package.json 版本。
|
|
365
|
+
* harness_version:**只能由外部注入** —— 宿主(DeepSeek Harness)版本要从宿主进程的 argv /
|
|
366
|
+
* 安装目录推出来,而安装器是独立进程,推不出来。插件会在宿主进程内问出来再传进来;
|
|
367
|
+
* 用户直接跑 `npx @mrrisega/dsh-remote` 时没有这个值 → 留空(= 未上报,绝不猜一个错的)。
|
|
365
368
|
*/
|
|
366
369
|
const INSTALL_SOURCE = String(process.env.DSH_BRIDGE_INSTALL_SOURCE || "").trim() || "npx";
|
|
367
370
|
const INSTALL_VERSION = String(process.env.DSH_BRIDGE_INSTALL_VERSION || "").trim() || pkgVersion();
|
|
371
|
+
const HARNESS_VERSION = String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
|
|
368
372
|
|
|
369
373
|
/**
|
|
370
374
|
* 把文件权限收紧到「只有本人可读写」。
|
|
@@ -708,7 +712,7 @@ function writeAutostartFile() {
|
|
|
708
712
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
709
713
|
<key>StandardOutPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
710
714
|
<key>StandardErrorPath</key><string>${path.join(CONFIG_DIR, ".dsh-bridge.log")}</string>
|
|
711
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string></dict>
|
|
715
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${SERVICE_PATH}</string>${SERVICE_NODE_ENV_PLIST}<key>DSH_BRIDGE_INSTALL_SOURCE</key><string>${INSTALL_SOURCE}</string><key>DSH_BRIDGE_INSTALL_VERSION</key><string>${INSTALL_VERSION}</string><key>DSH_BRIDGE_HARNESS_VERSION</key><string>${HARNESS_VERSION}</string></dict>
|
|
712
716
|
</dict></plist>`;
|
|
713
717
|
// ~/Library/LaunchAgents 在全新账户/精简系统上可能不存在,必须先建目录,
|
|
714
718
|
// 否则 writeFileSync 抛 ENOENT 直接把安装流程打断(用户反馈里也踩过类似的路径问题)。
|
|
@@ -724,7 +728,7 @@ function writeAutostartFile() {
|
|
|
724
728
|
if (process.platform === "linux") {
|
|
725
729
|
const dir = path.join(os.homedir(), ".config/systemd/user");
|
|
726
730
|
fs.mkdirSync(dir, { recursive: true });
|
|
727
|
-
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
731
|
+
const unit = `[Unit]\nDescription=dsh-remote bridge (auto-starts with dsh web)\n\n[Service]\nExecStart=${runCmd}\nRestart=on-failure\nRestartSec=5\nEnvironment=PATH=${SERVICE_PATH}\n${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_INSTALL_SOURCE=${INSTALL_SOURCE}\nEnvironment=DSH_BRIDGE_INSTALL_VERSION=${INSTALL_VERSION}\nEnvironment=DSH_BRIDGE_HARNESS_VERSION=${HARNESS_VERSION}\n\n[Install]\nWantedBy=default.target\n`;
|
|
728
732
|
const unitPath = autostartFilePath();
|
|
729
733
|
fs.writeFileSync(unitPath, unit);
|
|
730
734
|
return unitPath; // 路径在最终汇总里统一展示
|
|
@@ -1505,6 +1509,8 @@ async function runBridge() {
|
|
|
1505
1509
|
// 安装口径透传给 bridge(bridge 把它随设备登记请求一起上报给账号 API)
|
|
1506
1510
|
DSH_BRIDGE_INSTALL_SOURCE: INSTALL_SOURCE,
|
|
1507
1511
|
DSH_BRIDGE_INSTALL_VERSION: INSTALL_VERSION,
|
|
1512
|
+
// 宿主版本:外部注入(插件在宿主进程内问出来的),安装器只是搬运工;没有就是空串
|
|
1513
|
+
DSH_BRIDGE_HARNESS_VERSION: HARNESS_VERSION,
|
|
1508
1514
|
...(saas ? { DSH_BRIDGE_PHONE: (cfg.phone || cfg.email || ""), DSH_BRIDGE_PASSWORD: cfg.password, DSH_BRIDGE_API: apiUrl, DSH_BRIDGE_SECRET: cfg.bridge_secret || "" } : {}),
|
|
1509
1515
|
...(local ? { DSH_BRIDGE_LOCAL_KEY: cfg.local_key } : {})
|
|
1510
1516
|
};
|