@mrrisega/dsh-remote 0.7.0-beta.1 → 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 +128 -12
- package/clients/dsh-remote/dsh-events.mjs +66 -8
- 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/dsh-events.test.mjs +88 -0
- package/clients/dsh-remote/test/harness-cookie-authority.test.mjs +121 -0
- package/clients/dsh-remote/test/harness-cookie-selfheal.test.mjs +15 -7
- 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 +88 -13
- 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 +160 -19
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +128 -12
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +66 -8
- 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 +88 -13
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +8 -2
- package/packages/dsh-remote-web/test/desktop-compat.test.mjs +39 -6
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +178 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +38 -0
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +13 -1
|
@@ -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";
|
|
@@ -740,6 +740,34 @@ function applySaaSMode(cfg) {
|
|
|
740
740
|
* 老版本(无该鉴权)下 connection 服务没有 authenticatedUrl → 静默跳过,行为不变。
|
|
741
741
|
*/
|
|
742
742
|
const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
743
|
+
/**
|
|
744
|
+
* Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
|
|
745
|
+
*
|
|
746
|
+
* 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
|
|
747
|
+
* 随机端口)是**两个宿主进程**,各自的插件都往里写"自己 authority 的 Cookie" → 互相覆盖。
|
|
748
|
+
* 桥撞 401 后重读磁盘,拿到的是另一个宿主刚写的 Cookie → 重试 → 还是 401 → 再读 → 又被
|
|
749
|
+
* 覆盖回去……**乒乓,永不自愈**,手机端持续白页(现象与"自愈坏了"一模一样,极易误判)。
|
|
750
|
+
* 【根治】每个宿主只写自己 authority 的文件:`.harness-cookie.<authority>.json`
|
|
751
|
+
* (`:` → `_`,如 `.harness-cookie.127.0.0.1_19387.json`),互不覆盖。旧版共享文件继续**写**
|
|
752
|
+
* 一份镜像(旧桥还能用),但新桥只认当前上游 authority 的那一份,foreign 一律跳过。
|
|
753
|
+
* 命名规则与桥半 `clients/dsh-remote/dsh-bridge.mjs` 同名,漂移由
|
|
754
|
+
* `clients/dsh-remote/test/harness-cookie-authority.test.mjs` 的跨文件不变量锁住。
|
|
755
|
+
*/
|
|
756
|
+
function authoritySlug(authority) {
|
|
757
|
+
return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
758
|
+
}
|
|
759
|
+
/** 本宿主专属 Cookie 文件名(与桥半同规则)。 */
|
|
760
|
+
function harnessCookieFileFor(authority) {
|
|
761
|
+
return `${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`;
|
|
762
|
+
}
|
|
763
|
+
/** 本宿主专属「作废」标记名(与桥半同规则)。 */
|
|
764
|
+
function harnessCookieRevokedFileFor(authority) {
|
|
765
|
+
return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
|
|
766
|
+
}
|
|
767
|
+
/** 由本进程 webServer 端口推出 authority(与 bridge 的 Host 口径一致)。 */
|
|
768
|
+
function harnessAuthorityOfPort(port) {
|
|
769
|
+
return port ? `127.0.0.1:${port}` : "";
|
|
770
|
+
}
|
|
743
771
|
/**
|
|
744
772
|
* 手机端 401「dsh web authentication required; reopen the URL printed by dsh web」的根因就在这里:
|
|
745
773
|
* 该 Cookie 由本插件在进程内经 ?token= 换取后交给 bridge 代持,**dsh web 每次重启都会换签名密钥**,
|
|
@@ -786,22 +814,47 @@ async function mintHarnessCookie(ctx, relayDir) {
|
|
|
786
814
|
if (UNINSTALLED_DIRS.has(relayDir)) return false;
|
|
787
815
|
const out = { authority: `127.0.0.1:${port}`, cookie, mintedAt: Date.now() };
|
|
788
816
|
mkdirSync(dirname(configPathOf(relayDir)), { recursive: true });
|
|
789
|
-
|
|
790
|
-
|
|
817
|
+
// ① 本宿主专属文件(权威,两个宿主互不覆盖 —— 事故根治点)
|
|
818
|
+
const ownPath = join(relayDir, harnessCookieFileFor(out.authority));
|
|
819
|
+
writeFileSync(ownPath, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
820
|
+
hardenFile(ownPath); // 会话 Cookie ≈ dsh web 的完整访问权,同样要收紧
|
|
821
|
+
// ② 旧版共享文件镜像:只为还跑着旧桥(<0.7.0-beta.2)的机器保留 —— 新桥优先读 ①,
|
|
822
|
+
// 且对 authority 不符的镜像**直接跳过**,所以这里写镜像不会再引发乒乓。
|
|
823
|
+
const legacyPath = join(relayDir, HARNESS_COOKIE_FILE);
|
|
824
|
+
writeFileSync(legacyPath, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
825
|
+
hardenFile(legacyPath);
|
|
791
826
|
return true;
|
|
792
827
|
} catch {
|
|
793
828
|
return false;
|
|
794
829
|
}
|
|
795
830
|
}
|
|
796
831
|
|
|
797
|
-
/**
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
832
|
+
/**
|
|
833
|
+
* 读取当前 authority 可用的 Cookie 文件状态:{cookie, authority, mintedAt} 或 null。
|
|
834
|
+
* ① 本宿主专属文件优先;
|
|
835
|
+
* ② 旧版共享文件仅在 authority 对得上、或旧插件没写 authority 字段(兼容)时采用;
|
|
836
|
+
* ③ 声明了别的 authority → 明确不用(那属于另一个宿主,用了必然 401)。
|
|
837
|
+
* @param {string} relayDir 配置目录
|
|
838
|
+
* @param {string} [authority] 本进程 authority(`127.0.0.1:<port>`);缺省只做兼容读取
|
|
839
|
+
*/
|
|
840
|
+
function readHarnessCookie(relayDir, authority) {
|
|
841
|
+
const read = (file) => {
|
|
842
|
+
try {
|
|
843
|
+
const j = JSON.parse(readFileSync(join(relayDir, file), "utf8"));
|
|
844
|
+
return j && typeof j.cookie === "string" && j.cookie ? j : null;
|
|
845
|
+
} catch {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
if (authority) {
|
|
850
|
+
const own = read(harnessCookieFileFor(authority));
|
|
851
|
+
if (own) return own;
|
|
804
852
|
}
|
|
853
|
+
const legacy = read(HARNESS_COOKIE_FILE);
|
|
854
|
+
if (!legacy) return null;
|
|
855
|
+
const claimed = typeof legacy.authority === "string" ? legacy.authority : "";
|
|
856
|
+
if (claimed === "" || !authority || claimed === authority) return legacy;
|
|
857
|
+
return null; // foreign authority:另一个宿主的 Cookie,本宿主用不了
|
|
805
858
|
}
|
|
806
859
|
|
|
807
860
|
let harnessMintInflight = false;
|
|
@@ -819,9 +872,22 @@ let harnessMintLastAt = 0;
|
|
|
819
872
|
*/
|
|
820
873
|
async function ensureHarnessCookie(ctx, relayDir, opts) {
|
|
821
874
|
const force = !!(opts && opts.force);
|
|
822
|
-
const
|
|
875
|
+
const authority = harnessAuthorityOfPort(ctx.webServer?.port);
|
|
876
|
+
const ownRevokedPath = authority ? join(relayDir, harnessCookieRevokedFileFor(authority)) : "";
|
|
877
|
+
const legacyRevokedPath = join(relayDir, HARNESS_COOKIE_REVOKED_FILE);
|
|
878
|
+
const cur = readHarnessCookie(relayDir, authority);
|
|
879
|
+
// 作废标记两个来源:
|
|
880
|
+
// ① 本宿主专属标记(新桥 0.7.0-beta.2+ 写)—— 精确,不会被人家的 401 催着重换;
|
|
881
|
+
// ② 旧版共享标记(旧桥写)—— 仅当它**晚于**我们手上这份 Cookie 时才认:说明是它被拒了。
|
|
882
|
+
// 否则(比如另一个宿主刚被拒、标记比我们的 Cookie 还旧)不理会,避免两个宿主互相催。
|
|
883
|
+
let revoked = ownRevokedPath !== "" && existsSync(ownRevokedPath);
|
|
884
|
+
if (!revoked && existsSync(legacyRevokedPath)) {
|
|
885
|
+
try {
|
|
886
|
+
const revokedAt = Number(readFileSync(legacyRevokedPath, "utf8").trim()) || 0;
|
|
887
|
+
revoked = !cur || revokedAt > Number(cur.mintedAt || 0);
|
|
888
|
+
} catch { /* 标记读不到就当没有 */ }
|
|
889
|
+
}
|
|
823
890
|
if (!force && !revoked) {
|
|
824
|
-
const cur = readHarnessCookie(relayDir);
|
|
825
891
|
if (cur && Date.now() - Number(cur.mintedAt || 0) < HARNESS_COOKIE_STALE_MS) return true;
|
|
826
892
|
}
|
|
827
893
|
if (harnessMintInflight) return false; // 去重:同一时刻只换一次
|
|
@@ -831,7 +897,11 @@ async function ensureHarnessCookie(ctx, relayDir, opts) {
|
|
|
831
897
|
try {
|
|
832
898
|
const ok = await mintHarnessCookie(ctx, relayDir);
|
|
833
899
|
if (ok || revoked) {
|
|
834
|
-
|
|
900
|
+
// 清标记:① 本宿主专属标记(精确);② 旧版共享标记(旧桥写的,清了不影响新桥)。
|
|
901
|
+
// 代价:多宿主机器上可能清掉"另一个宿主"的旧标记 —— 那只让对方的**快速**自愈退化成
|
|
902
|
+
// 30 分钟周期刷新,不会造成错误状态,故不做更细的归属判定(复杂度不值)。
|
|
903
|
+
if (ownRevokedPath) { try { rmSync(ownRevokedPath, { force: true }); } catch { /* ignore */ } }
|
|
904
|
+
try { rmSync(legacyRevokedPath, { force: true }); } catch { /* ignore */ }
|
|
835
905
|
}
|
|
836
906
|
if (!ok && revoked) console.warn("[dsh-remote-web] 浏览器会话 Cookie 已被 dsh web 作废,重取失败,下次查状态再试");
|
|
837
907
|
return ok;
|
|
@@ -1005,6 +1075,7 @@ Environment=PATH=${SERVICE_PATH}
|
|
|
1005
1075
|
${SERVICE_NODE_ENV_UNIT}Environment=DSH_BRIDGE_UPSTREAM=${upstreamUrl()}
|
|
1006
1076
|
Environment=DSH_BRIDGE_INSTALL_SOURCE=${installSourceOf(relayDir)}
|
|
1007
1077
|
Environment=DSH_BRIDGE_INSTALL_VERSION=${PLUGIN_VERSION}
|
|
1078
|
+
Environment=DSH_BRIDGE_HARNESS_VERSION=${harnessVersion()}
|
|
1008
1079
|
|
|
1009
1080
|
[Install]
|
|
1010
1081
|
WantedBy=default.target
|
|
@@ -1487,7 +1558,8 @@ function ensureRuntime(relayDir) {
|
|
|
1487
1558
|
windowsHide: true,
|
|
1488
1559
|
// DSH_BRIDGE_INSTALL_SOURCE:本机运行环境是插件自愈补的 → 安装器原样透传到 plist/bridge env,
|
|
1489
1560
|
// 于是设备行上的 install_source 如实记成 plugin_market(而不是安装器默认的 npx)。
|
|
1490
|
-
|
|
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() }),
|
|
1491
1563
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
|
|
1492
1564
|
onError: (e) => failSpawn(e, npx.kind),
|
|
1493
1565
|
});
|
|
@@ -1583,7 +1655,7 @@ function writeAutostartFile(relayDir) {
|
|
|
1583
1655
|
<key>LimitLoadToSessionType</key><array><string>Aqua</string><string>Background</string></array>
|
|
1584
1656
|
<key>StandardOutPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1585
1657
|
<key>StandardErrorPath</key><string>${join(relayDir, ".dsh-bridge.log")}</string>
|
|
1586
|
-
<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>
|
|
1587
1659
|
</dict></plist>`;
|
|
1588
1660
|
mkdirSync(dirname(plistPath), { recursive: true });
|
|
1589
1661
|
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
@@ -1693,6 +1765,8 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
|
|
|
1693
1765
|
env: spawnEnv({
|
|
1694
1766
|
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
1695
1767
|
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
1768
|
+
// 宿主版本:bridge 自己推不出来(它是另一个进程),只能由插件在宿主进程内问出来再注入。
|
|
1769
|
+
DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
|
|
1696
1770
|
// 上游端口**必须**透传:watcher 用它判断「dsh web 在线吗」,bridge 用它做转发目标。
|
|
1697
1771
|
// 不传的话两边都回落到写死的 3080 —— dsh web 不在默认端口时 bridge 永远不启动(0.6.12 修)。
|
|
1698
1772
|
DSH_BRIDGE_UPSTREAM: upstreamUrl(),
|
|
@@ -3042,6 +3116,12 @@ async function proxyFeedback(relayDir, req, res, pathname) {
|
|
|
3042
3116
|
const headers = {
|
|
3043
3117
|
"x-dsh-device": cfg.device_id || "",
|
|
3044
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,
|
|
3045
3125
|
};
|
|
3046
3126
|
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
3047
3127
|
const auth = req.headers.authorization;
|
|
@@ -3457,7 +3537,14 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
|
|
|
3457
3537
|
windowsHide: true, // Windows:不弹控制台窗口(POSIX 上该选项被忽略)
|
|
3458
3538
|
// PATH 补 node 目录:App 最小 PATH 下也能跑 npx;同时保持本机既有的安装来源口径
|
|
3459
3539
|
// (在线更新会重跑安装器并重写 plist,若不带来源就会把 plugin_market 误记成 npx)。
|
|
3460
|
-
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
|
+
}),
|
|
3461
3548
|
stdio: ["ignore", openSync(log, "a"), openSync(log, "a")],
|
|
3462
3549
|
onError,
|
|
3463
3550
|
});
|
|
@@ -3537,6 +3624,7 @@ async function runOnlineUpdate(relayDir) {
|
|
|
3537
3624
|
env: spawnEnv({
|
|
3538
3625
|
DSH_BRIDGE_INSTALL_SOURCE: installSourceOf(relayDir),
|
|
3539
3626
|
DSH_BRIDGE_INSTALL_VERSION: PLUGIN_VERSION,
|
|
3627
|
+
DSH_BRIDGE_HARNESS_VERSION: harnessVersion(),
|
|
3540
3628
|
npm_config_registry: "https://registry.npmjs.org",
|
|
3541
3629
|
}),
|
|
3542
3630
|
stdio: ["ignore", out, out],
|
|
@@ -4543,6 +4631,8 @@ function buildConnectDiagnostics(relayDir, conn, log) {
|
|
|
4543
4631
|
const rows = [
|
|
4544
4632
|
"dsh-remote 连接诊断",
|
|
4545
4633
|
`插件版本: ${PLUGIN_VERSION}`,
|
|
4634
|
+
// 宿主版本:生产反馈里最缺的就是这个数 —— 只报插件版本时,「是不是 DSH 升级导致的兼容故障」答不出来
|
|
4635
|
+
`宿主版本: ${harnessVersion() || "未知"}`,
|
|
4546
4636
|
`配置目录: ${relayDir}`,
|
|
4547
4637
|
`连接阶段: ${conn.phase}(${conn.text})`,
|
|
4548
4638
|
`设备 ID: ${conn.deviceId || "(未生成)"}`,
|
|
@@ -4608,7 +4698,7 @@ async function buildDiagReport(relayDir, opts = {}) {
|
|
|
4608
4698
|
const conn = await composeConnect(relayDir, { manual });
|
|
4609
4699
|
const cfg = loadConfig(relayDir);
|
|
4610
4700
|
const head = redactForDiagnostics([
|
|
4611
|
-
`【插件】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]}`,
|
|
4612
4702
|
`【阶段】${conn.phase}(${conn.text})`,
|
|
4613
4703
|
`【设备】ID=${conn.deviceId || "(未生成)"} 运行环境=${conn.runtimeReady ? "就绪" : `缺失${conn.runtimeMissing && conn.runtimeMissing.length ? `(缺 ${conn.runtimeMissing.slice(0, 3).join("、")})` : ""}`}`,
|
|
4614
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} 次` : ""}`,
|
|
@@ -4644,6 +4734,10 @@ async function submitFeedback(relayDir, payload) {
|
|
|
4644
4734
|
"content-type": "application/json",
|
|
4645
4735
|
"x-dsh-device": cfg.device_id || "",
|
|
4646
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,
|
|
4647
4741
|
};
|
|
4648
4742
|
if (cfg.phone) headers["x-dsh-phone"] = String(cfg.phone);
|
|
4649
4743
|
const t = await feedbackAuthToken(relayDir).catch(() => "");
|
|
@@ -5110,9 +5204,51 @@ function harnessVersionFromEntry(entry) {
|
|
|
5110
5204
|
}
|
|
5111
5205
|
}
|
|
5112
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
|
+
|
|
5113
5249
|
/** 本进程运行所在的宿主版本(未知 = "")。每次调用现读:事件量很小,且绝不该缓存出过期结论。 */
|
|
5114
5250
|
function harnessVersion() {
|
|
5115
|
-
return
|
|
5251
|
+
return harnessVersionFromArgv(process.argv);
|
|
5116
5252
|
}
|
|
5117
5253
|
|
|
5118
5254
|
/**
|
|
@@ -5486,6 +5622,8 @@ export const __telemetryInternals = {
|
|
|
5486
5622
|
// 必须能被用例逐条锁住(含未知/非法值的降级行为)。
|
|
5487
5623
|
harnessVersion: () => harnessVersion(),
|
|
5488
5624
|
harnessVersionFromEntry: (entry) => harnessVersionFromEntry(entry),
|
|
5625
|
+
harnessVersionFromRoot: (root) => harnessVersionFromRoot(root),
|
|
5626
|
+
harnessVersionFromArgv: (argv) => harnessVersionFromArgv(argv),
|
|
5489
5627
|
harnessToken: (v) => telemetryToken(v),
|
|
5490
5628
|
updateTag: UPDATE_TAG,
|
|
5491
5629
|
eventNames: [...TELEMETRY_EVENT_NAMES],
|
|
@@ -5512,7 +5650,7 @@ export const __telemetryInternals = {
|
|
|
5512
5650
|
* 安装信息上报(插件侧通道):登录后把本机安装信息发给企业端,供「已登录用户升级插件后刷新版本号」
|
|
5513
5651
|
* 之类的口径统计。无账号凭据不上报;失败静默(绝不阻塞面板、不弹错误)。
|
|
5514
5652
|
* 契约:POST <api_url>/api/install-report,头 `authorization: Bearer <device-login JWT>`,
|
|
5515
|
-
* 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};
|
|
5516
5654
|
* 404/400 一律忽略(老服务端没有该接口时不能报错)。
|
|
5517
5655
|
*/
|
|
5518
5656
|
const INSTALL_REPORT_FILE = ".dsh-install-report.json";
|
|
@@ -5547,6 +5685,9 @@ async function reportInstallOnce(relayDir, cfgOverride, opts = {}) {
|
|
|
5547
5685
|
...(cfg.device_id ? { device_id: String(cfg.device_id) } : {}),
|
|
5548
5686
|
install_source: source,
|
|
5549
5687
|
install_version: version,
|
|
5688
|
+
// 宿主(DeepSeek Harness)版本:与 install_version(插件版本)是两个数,
|
|
5689
|
+
// 只报插件版本时「是不是 DSH 升级导致的故障」答不出来(2026-10 生产反馈的普遍缺口)。
|
|
5690
|
+
harness_version: harnessVersion(),
|
|
5550
5691
|
host_os: process.platform,
|
|
5551
5692
|
host_arch: process.arch,
|
|
5552
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
|
|
|
@@ -320,21 +337,100 @@ const HARNESS_COOKIE_FILE = ".harness-cookie.json";
|
|
|
320
337
|
const HARNESS_UNAUTHORIZED_TEXT = "dsh web authentication required";
|
|
321
338
|
/** 撞到 401 时写的「作废」标记:插件在面板轮询时看到它就会立刻重换 Cookie(见 node 半 ensureHarnessCookie)。 */
|
|
322
339
|
const HARNESS_COOKIE_REVOKED_FILE = ".harness-cookie-revoked";
|
|
323
|
-
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Cookie 按 **authority 分文件**(0.7.0-beta.2 起,2026-10-09 事故根治)。
|
|
343
|
+
*
|
|
344
|
+
* 【事故】`~/.dsh-remote/` 只有一份 `.harness-cookie.json`,而 Web 端(3080)与桌面端(每次启动
|
|
345
|
+
* 随机端口)是**两个宿主、两个进程**,各自的插件都会往里写"自己 authority 的 Cookie" →
|
|
346
|
+
* 互相覆盖。桥撞 401 后重读磁盘,拿到的是**另一个宿主刚写的** Cookie(与失败的不是同一个)
|
|
347
|
+
* → 重试 → 还是 401 → 再读 → 又被覆盖回去……**乒乓,永不自愈**。手机端持续白页。
|
|
348
|
+
* 【根治】每个宿主只写自己 authority 的文件:`.harness-cookie.<authority>.json`
|
|
349
|
+
* (`:` 换成 `_`,如 `.harness-cookie.127.0.0.1_19387.json`),互不覆盖;桥只认
|
|
350
|
+
* **当前上游 authority** 的那一份。旧版共享文件继续兼容读取,但**foreign authority 一律不用**
|
|
351
|
+
* —— 那正是乒乓的来源。命名规则与插件半 `packages/dsh-remote-web/lib/index.js` 同名,
|
|
352
|
+
* 漂移由 `test/harness-cookie-authority.test.mjs` 的跨文件不变量锁住。
|
|
353
|
+
*/
|
|
354
|
+
function authoritySlug(authority) {
|
|
355
|
+
return String(authority).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
356
|
+
}
|
|
357
|
+
/** 本宿主专属 Cookie 文件名(与插件半同规则)。 */
|
|
358
|
+
function harnessCookieFileFor(authority) {
|
|
359
|
+
return `${HARNESS_COOKIE_FILE.slice(0, -".json".length)}.${authoritySlug(authority)}.json`;
|
|
360
|
+
}
|
|
361
|
+
/** 本宿主专属「作废」标记名(与插件半同规则)。 */
|
|
362
|
+
function harnessCookieRevokedFileFor(authority) {
|
|
363
|
+
return `${HARNESS_COOKIE_REVOKED_FILE}.${authoritySlug(authority)}`;
|
|
364
|
+
}
|
|
365
|
+
/** 当前上游的 authority(`127.0.0.1:<port>`);取不到返回空串。 */
|
|
366
|
+
function upstreamAuthorityOf(upstream) {
|
|
324
367
|
try {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
368
|
+
return new URL(String(upstream || UPSTREAM)).host || "";
|
|
369
|
+
} catch {
|
|
370
|
+
return "";
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** 已就"哪个 foreign authority 在抢"告警过的集合(每对只吵一次,不刷屏)。 */
|
|
374
|
+
const foreignCookieWarned = new Set();
|
|
375
|
+
function warnForeignCookieOnce(claimed, authority) {
|
|
376
|
+
const key = `${claimed}->${authority}`;
|
|
377
|
+
if (foreignCookieWarned.has(key)) return;
|
|
378
|
+
foreignCookieWarned.add(key);
|
|
379
|
+
console.warn(
|
|
380
|
+
`[bridge] 检测到另一宿主的会话 Cookie(${claimed}),而当前上游是 ${authority} —— ` +
|
|
381
|
+
`一台机器只能服务一个宿主,已跳过该 Cookie(不再乒乓重试)。` +
|
|
382
|
+
`若手机端仍 401,请确认 .dsh-upstream 指向正在运行的那个宿主,并让该宿主的插件重发 Cookie。`
|
|
383
|
+
);
|
|
328
384
|
}
|
|
385
|
+
function readCookieDoc(dir, file) {
|
|
386
|
+
try {
|
|
387
|
+
const j = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
|
388
|
+
return j && typeof j.cookie === "string" && j.cookie ? j : null;
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* 当前上游该用的浏览器会话 Cookie(空串 = 没有可用的)。
|
|
395
|
+
*
|
|
396
|
+
* 取值顺序(2026-10-09 起):
|
|
397
|
+
* ① 本宿主专属文件 `.harness-cookie.<authority>.json` —— 新插件写的,权威;
|
|
398
|
+
* ② 旧版共享 `.harness-cookie.json` —— 仅当它声明的 `authority` 与当前上游一致,
|
|
399
|
+
* 或旧插件根本没写 authority 字段(向后兼容)时才用;
|
|
400
|
+
* ③ 声明了**别的** authority → 明确不用,并告警一次(这就是乒乓的源头)。
|
|
401
|
+
*/
|
|
329
402
|
function harnessCookieOf() {
|
|
403
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
404
|
+
const authority = upstreamAuthorityOf();
|
|
405
|
+
if (authority) {
|
|
406
|
+
const own = readCookieDoc(dir, harnessCookieFileFor(authority));
|
|
407
|
+
if (own) return own.cookie;
|
|
408
|
+
}
|
|
409
|
+
const legacy = readCookieDoc(dir, HARNESS_COOKIE_FILE);
|
|
410
|
+
if (!legacy) return "";
|
|
411
|
+
const claimed = typeof legacy.authority === "string" ? legacy.authority : "";
|
|
412
|
+
if (claimed === "" || authority === "" || claimed === authority) return legacy.cookie;
|
|
413
|
+
warnForeignCookieOnce(claimed, authority);
|
|
414
|
+
return "";
|
|
415
|
+
}
|
|
416
|
+
/** 本宿主 Cookie 是否处于「已作废、等插件重换」状态(诊断用,不参与取值)。 */
|
|
417
|
+
function harnessCookieRevokedFor(authority) {
|
|
330
418
|
try {
|
|
331
|
-
|
|
332
|
-
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
333
|
-
return j && typeof j.cookie === "string" && j.cookie ? j.cookie : "";
|
|
419
|
+
return fs.existsSync(path.join(path.dirname(CONFIG_PATH), harnessCookieRevokedFileFor(authority)));
|
|
334
420
|
} catch {
|
|
335
|
-
return
|
|
421
|
+
return false;
|
|
336
422
|
}
|
|
337
423
|
}
|
|
424
|
+
function markHarnessCookieRevoked() {
|
|
425
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
426
|
+
const authority = upstreamAuthorityOf();
|
|
427
|
+
// 只写**本宿主**的标记:两个宿主各写各的,插件侧才不会被人家的 401 反复催着重换。
|
|
428
|
+
// (旧版共享标记 `.harness-cookie-revoked` 不再写:它是"两个写者抢一份文件"的同款隐患。)
|
|
429
|
+
if (!authority) return;
|
|
430
|
+
try {
|
|
431
|
+
fs.writeFileSync(path.join(dir, harnessCookieRevokedFileFor(authority)), String(Date.now()), { mode: 0o600 });
|
|
432
|
+
} catch { /* 忽略:标记只为加速自愈 */ }
|
|
433
|
+
}
|
|
338
434
|
|
|
339
435
|
/**
|
|
340
436
|
* 解析稳定 deviceId:优先级 env DSH_BRIDGE_DEVICE_ID > argv[2] > 配置 device_id > 生成。
|
|
@@ -1320,6 +1416,9 @@ async function registerDeviceInAccount(token) {
|
|
|
1320
1416
|
// install_source: npx(安装器注入) | plugin_market(插件拉起/自愈,默认) | unknown(白名单外)
|
|
1321
1417
|
install_source: INSTALL_SOURCE,
|
|
1322
1418
|
install_version: INSTALL_VERSION,
|
|
1419
|
+
// v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
|
|
1420
|
+
// 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
|
|
1421
|
+
harness_version: HARNESS_VERSION,
|
|
1323
1422
|
host_os: process.platform,
|
|
1324
1423
|
host_arch: process.arch
|
|
1325
1424
|
})
|
|
@@ -1568,6 +1667,18 @@ function wechatAppUrl() {
|
|
|
1568
1667
|
}
|
|
1569
1668
|
|
|
1570
1669
|
let wechatRuntime = null;
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* 微信事件通道要连的上游(**每次重连都会调一次**)。
|
|
1673
|
+
*
|
|
1674
|
+
* 行为:先顺手触发一次上游重发现(内部有 15s 冷却 + 单飞,反复调用不会打爆 lsof/探测),
|
|
1675
|
+
* 再返回**当下**的 UPSTREAM。这样宿主换端口时通道能自己跟上,而不必重启 bridge。
|
|
1676
|
+
*/
|
|
1677
|
+
function currentUpstreamForChannel() {
|
|
1678
|
+
void refreshUpstream("微信事件通道重连");
|
|
1679
|
+
return UPSTREAM;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1571
1682
|
function startWeChat() {
|
|
1572
1683
|
if (WECHAT_DISABLED) {
|
|
1573
1684
|
console.log("[bridge] 微信通道已禁用(DSH_WECHAT=0)");
|
|
@@ -1578,10 +1689,15 @@ function startWeChat() {
|
|
|
1578
1689
|
const cfg = loadLocalConfig();
|
|
1579
1690
|
wechatRuntime = createWeChatRuntime({
|
|
1580
1691
|
relayDir,
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
|
|
1692
|
+
// 传**取值器**而不是当次的值(0.7.0-beta.2 修复)。
|
|
1693
|
+
//
|
|
1694
|
+
// 【2026-10-09 真机事故】这里以前传的是 `UPSTREAM` 这个**当次快照**:bridge 起来时宿主还在
|
|
1695
|
+
// 3080,随后宿主换了端口(Desktop 每次启动随机),隧道转发侧因为每次读最新 UPSTREAM 而
|
|
1696
|
+
// 一切正常,**唯独微信事件通道抱着 3080 死循环重连** —— 现象极具迷惑性:手机能开页面、
|
|
1697
|
+
// bridge-status 显示 online,但任务跑完**一条推送都没有**,日志里只有刷屏的
|
|
1698
|
+
// `connect ECONNREFUSED 127.0.0.1:3080`。
|
|
1699
|
+
// 现在取值器每次重连都会重新求值,并在打到死端口时主动触发一次上游重发现。
|
|
1700
|
+
upstream: () => currentUpstreamForChannel(),
|
|
1585
1701
|
cookieOf: harnessCookieOf,
|
|
1586
1702
|
secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
|
|
1587
1703
|
// 免费/付费分层:档位来自账号的生效套餐,App 链接是免费用户越界时的转化入口。
|