dsh-remote-web 0.7.1-beta.1 → 0.7.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.1-beta.1",
3
+ "version": "0.7.2-beta.1",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -76,6 +76,7 @@ import { promisify } from "node:util";
76
76
  import { gzip as gzipCb } from "node:zlib";
77
77
  // 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
78
78
  import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
79
+ import { maybeInjectMirrorDiag } from "./mirror-diag.mjs";
79
80
  import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./upstream-discovery.mjs";
80
81
  // 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
81
82
  import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
@@ -862,6 +863,15 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
862
863
  buf = m.buf;
863
864
  armMirrorLoadWatch(path);
864
865
  }
866
+ // 镜像页加载故障采集器(2026-10-17,**与 e2ee 无关**):出问题的用户往往没开 E2EE,
867
+ // 所以无条件注入。只在失败时上报(见 mirror-diag-script.js),零 DOM 改动。
868
+ {
869
+ const d = maybeInjectMirrorDiag({ buf, contentType });
870
+ if (d.injected) {
871
+ console.log(`[bridge] mirror-diag 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(d.buf.length / 1024).toFixed(0)}KB`);
872
+ buf = d.buf;
873
+ }
874
+ }
865
875
  // E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
866
876
  if (e2ee.enabled) {
867
877
  const s = maybeInjectE2eeShim({ buf, contentType });
@@ -1235,6 +1245,12 @@ export async function handleWsOpen(dchOrSend, frame) {
1235
1245
  console.log(`[bridge] ws-error ${path} (id=${id}): ${e.message || ""}`);
1236
1246
  if (!session.opened) {
1237
1247
  wsSessions.delete(id);
1248
+ // ★ 上游可能已经换端口(桌面端 App 重启会换本地端口,生产日志里抓到过
1249
+ // `upstream_ws_open_failed ... ECONNREFUSED 127.0.0.1:19387`)。
1250
+ // HTTP 失败路径早就会触发重新发现,**WS 失败路径之前漏了** —— 而镜像页常常
1251
+ // 先开 WS(控制通道/数据通道),于是端口变了以后一直 502、页面白屏,直到用户
1252
+ // 手动重开。这里补上:本次失败照旧回 502,但立即触发重新发现,下一次重试就命中新端口。
1253
+ void refreshUpstream("ws-open 上游连接失败");
1238
1254
  send({ id, type: "ws-open", ok: false, code: 502, reason: String(e.message || "ws error") });
1239
1255
  }
1240
1256
  });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * dsh-mirror-diag — 镜像页「加载故障」采集器(由 bridge 注入到 text/html 的 </head> 前)。
3
+ *
4
+ * 由来(2026-10-17):用户反馈「装了 UI 插件后打不开界面 / Failed to load plugins」,
5
+ * 但本地与账号环境都复现不出来。生产日志里只看到 `/plugins/??…` 的 499 中断,
6
+ * 拿不到浏览器侧的真实失败项(哪个 URL、多少毫秒、什么错误)。这个采集器就是
7
+ * **让下一次故障自己带证据回来**。
8
+ *
9
+ * 设计约束(都是硬要求):
10
+ * 1. **绝不打扰用户**:不改 DOM、不加 UI、不阻塞渲染;任何异常都在内部吞掉。
11
+ * 2. **只在出错时上报**:没有失败资源/脚本错误 → 一个字节都不发(零流量)。
12
+ * 3. **不泄密**:只上报**路径**(剥掉 query/hash)、状态码、耗时、字节数、错误消息前 500 字;
13
+ * 不上报页面内容、请求体、cookie、token、用户信息。
14
+ * 4. **可关**:`?mirror_diag=0` 或 bridge 环境变量 `DSH_MIRROR_DIAG=0` → 完全不注入。
15
+ * 5. **慢请求也记**:`/plugins/`、`/assets/` 加载超过 8s 记一条(这正是"插件聚合包被
16
+ * 限速拖死 → 浏览器 499"的现场)。
17
+ *
18
+ * 上报地址:`/relay-api/api/mirror-diag`(企业端,需登录态;token 从 localStorage.dsh_token 取)。
19
+ */
20
+ (function () {
21
+ "use strict";
22
+ var W = window, D = document;
23
+ var VERSION = 1;
24
+ var SLOW_MS = 8000;
25
+ var MAX_ITEMS = 20;
26
+ var FLUSH_DELAY = 2500;
27
+ var ENDPOINT = "/relay-api/api/mirror-diag";
28
+ var items = [];
29
+ var bootFailed = [];
30
+ var sent = false;
31
+ var timer = null;
32
+
33
+ function now() { return Date.now(); }
34
+ function clip(s, n) { s = String(s == null ? "" : s); return s.length > n ? s.slice(0, n) : s; }
35
+ /** 只保留同源路径(剥 query/hash),跨源整条丢弃 —— 避免把第三方 URL 带出去。 */
36
+ function pathOf(u) {
37
+ try {
38
+ var x = new URL(String(u || ""), W.location.href);
39
+ if (x.origin !== W.location.origin) return null;
40
+ return clip(x.pathname, 300);
41
+ } catch (e) { return null; }
42
+ }
43
+ function add(item) {
44
+ if (!item || items.length >= MAX_ITEMS || sent) return;
45
+ items.push(item);
46
+ if (timer === null) timer = setTimeout(flush, FLUSH_DELAY);
47
+ }
48
+ function interesting(p) {
49
+ return !!p && (p.indexOf("/plugins/") === 0 || p.indexOf("/assets/") === 0
50
+ || p.indexOf("/remote/") === 0 || p.indexOf("/api/") === 0);
51
+ }
52
+
53
+ // ① 资源加载失败(script/link/img/fetch 的 error 事件在捕获阶段拿到 target)
54
+ W.addEventListener("error", function (e) {
55
+ try {
56
+ var t = e && e.target;
57
+ if (t && t !== W && (t.src || t.href)) {
58
+ var p = pathOf(t.src || t.href);
59
+ if (interesting(p)) add({ t: "res", url: p, tag: String(t.tagName || "").toLowerCase() });
60
+ return;
61
+ }
62
+ if (e && e.message) add({ t: "err", msg: clip(e.message, 500), src: pathOf(e.filename) });
63
+ } catch (x) { /* 采集器自身绝不抛 */ }
64
+ }, true);
65
+
66
+ // ② 未处理异常 / Promise 拒绝
67
+ W.addEventListener("unhandledrejection", function (e) {
68
+ try {
69
+ var r = e && e.reason;
70
+ var msg = r && (r.message || r.stack) ? (r.message || "") + " " + (r.stack || "") : String(r);
71
+ add({ t: "rej", msg: clip(msg, 500) });
72
+ } catch (x) {}
73
+ });
74
+
75
+ // ③ 慢资源(插件聚合包/前端主干)—— 用 Resource Timing,不额外发请求
76
+ try {
77
+ if (W.PerformanceObserver) {
78
+ var po = new W.PerformanceObserver(function (list) {
79
+ try {
80
+ list.getEntries().forEach(function (en) {
81
+ var p = pathOf(en.name);
82
+ if (!interesting(p)) return;
83
+ if (en.duration >= SLOW_MS) {
84
+ add({ t: "slow", url: p, ms: Math.round(en.duration), bytes: en.transferSize || 0 });
85
+ } else if (en.transferSize === 0 && en.duration > 0 && en.responseEnd > 0) {
86
+ // transferSize 为 0 且非缓存命中 → 常见于"请求被中断/取消"
87
+ add({ t: "zero", url: p, ms: Math.round(en.duration) });
88
+ }
89
+ });
90
+ } catch (x) {}
91
+ });
92
+ po.observe({ type: "resource", buffered: true });
93
+ }
94
+ } catch (x) {}
95
+
96
+ // ④ 首页启动失败文案(`Failed to load plugins` / `did not activate`)—— 抓具体插件名
97
+ function scanBootFailure() {
98
+ try {
99
+ var txt = (D.body && D.body.innerText) || "";
100
+ if (txt.indexOf("Failed to load plugins") < 0 && txt.indexOf("did not activate") < 0) return;
101
+ var lines = txt.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
102
+ for (var i = 0; i < lines.length; i++) {
103
+ if (/import failed|did not activate|pending \(waiting|: [a-z]+ failed/i.test(lines[i])) {
104
+ if (bootFailed.length < 10) bootFailed.push(clip(lines[i], 200));
105
+ }
106
+ }
107
+ if (bootFailed.length === 0) bootFailed.push("Failed to load plugins(无明细文本)");
108
+ if (timer === null) timer = setTimeout(flush, 300);
109
+ } catch (x) {}
110
+ }
111
+ try {
112
+ var mo = new W.MutationObserver(scanBootFailure);
113
+ if (D.documentElement) mo.observe(D.documentElement, { childList: true, subtree: true, characterData: true });
114
+ setTimeout(scanBootFailure, 6000);
115
+ setTimeout(scanBootFailure, 15000);
116
+ } catch (x) {}
117
+
118
+ function token() {
119
+ try {
120
+ var t = W.localStorage && W.localStorage.getItem("dsh_token");
121
+ if (t) return t;
122
+ var m = D.cookie.match(/(?:^|;\s*)dsh_token=([^;]+)/);
123
+ return m ? decodeURIComponent(m[1]) : "";
124
+ } catch (e) { return ""; }
125
+ }
126
+
127
+ function flush() {
128
+ timer = null;
129
+ if (sent || (items.length === 0 && bootFailed.length === 0)) return;
130
+ sent = true;
131
+ var tk = token();
132
+ var payload = {
133
+ v: VERSION,
134
+ kind: bootFailed.length ? "boot" : "runtime",
135
+ page: clip(W.location.pathname, 200),
136
+ ua: clip(W.navigator && W.navigator.userAgent, 300),
137
+ items: items.slice(0, MAX_ITEMS),
138
+ boot_failed: bootFailed.slice(0, 10),
139
+ at: now()
140
+ };
141
+ try {
142
+ fetch(ENDPOINT, {
143
+ method: "POST",
144
+ credentials: "same-origin",
145
+ headers: { "content-type": "application/json", ...(tk ? { authorization: "Bearer " + tk } : {}) },
146
+ body: JSON.stringify(payload),
147
+ keepalive: true
148
+ }).catch(function () { /* 上报失败静默:绝不影响页面 */ });
149
+ } catch (e) { /* ignore */ }
150
+ }
151
+
152
+ // 页面卸载前兜底发一次(keepalive 允许在卸载后继续)
153
+ W.addEventListener("pagehide", function () { try { if (!sent) flush(); } catch (x) {} });
154
+ })();
@@ -0,0 +1,106 @@
1
+ /**
2
+ * mirror-diag.mjs — bridge 侧注入的「镜像页加载故障采集器」。
3
+ *
4
+ * 注入目标:经隧道回传的**官方 dsh web 文档**(与 mobile-adapter / e2ee-shim 同一注入管线、
5
+ * 同一宿主特征门),在 `</head>` 前插入 `<script id="dsh-mirror-diag-js">`。
6
+ *
7
+ * 为什么需要它(2026-10-17):用户反馈「装了 UI 插件后打不开界面 / Failed to load plugins」,
8
+ * 本地复现不出来。生产日志只能看到 `/plugins/??…` 出现 499(客户端中断),拿不到浏览器侧
9
+ * 到底哪个资源失败、耗时多少、什么错误。采集器让下一次故障**自己带证据回来**。
10
+ *
11
+ * 与 e2ee 的关系:**刻意不依赖** e2ee.enabled —— 出问题的用户往往没开 E2EE,
12
+ * 只有无条件注入才能覆盖他们。脚本本身零 DOM 改动、只在失败时上报(见 mirror-diag-script.js)。
13
+ *
14
+ * 开关:
15
+ * - `DSH_MIRROR_DIAG=0` → 完全不注入(逃生口);
16
+ * - `?mirror_diag=0` → 页面侧由脚本自身判定(见 mirror-diag-script.js 的注释)。
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
23
+ export const DIAG_ID = "dsh-mirror-diag";
24
+ export const DIAG_SCRIPT_PATH = path.join(THIS_DIR, "mirror-diag-script.js");
25
+
26
+ /** 与 mobile-adapter / e2ee-shim 同源的「官方 dsh web 文档」特征门。 */
27
+ const HOST_FEATURE_RE =
28
+ /(__ModuleLoader__|@deepseek-ai\/dsh-client-modules|@deepseek-ai\/dsh-client-connection|<title>\s*DeepSeek Harness)/i;
29
+
30
+ /** 采集器源码:只在首次注入时读盘并缓存(读失败 → 永不注入,绝不影响主链路)。 */
31
+ let scriptCache = null;
32
+ function diagScriptText() {
33
+ if (scriptCache !== null) return scriptCache;
34
+ try {
35
+ scriptCache = fs.readFileSync(DIAG_SCRIPT_PATH, "utf8");
36
+ } catch {
37
+ scriptCache = "";
38
+ }
39
+ return scriptCache;
40
+ }
41
+
42
+ /** `DSH_MIRROR_DIAG=0` 关闭注入(默认开启);测试里可用同样的环境变量。 */
43
+ export function mirrorDiagEnabled() {
44
+ const v = process.env.DSH_MIRROR_DIAG;
45
+ return !(v === "0" || v === "false" || v === "off");
46
+ }
47
+
48
+ /** 注入门:text/html + 宿主特征 + 未注入过。 */
49
+ export function shouldInjectMirrorDiag({ contentType, html }) {
50
+ const ct = String(contentType || "").toLowerCase();
51
+ if (!ct.includes("text/html")) return false;
52
+ const text = String(html || "");
53
+ if (text.length === 0) return false;
54
+ if (!text.toLowerCase().includes("</head>")) return false;
55
+ if (text.includes(`data-${DIAG_ID}`)) return false; // 幂等:重复注入直接跳过
56
+ return HOST_FEATURE_RE.test(text);
57
+ }
58
+
59
+ /**
60
+ * 纯函数:把采集器注入到上游 HTML 的 `</head>` 之前。
61
+ * @param {string} html
62
+ * @returns {{html: string, injected: boolean}}
63
+ */
64
+ export function injectMirrorDiag(html) {
65
+ const src = String(html);
66
+ if (src.includes(`data-${DIAG_ID}`)) return { html: src, injected: false };
67
+ // ★ 注入点必须是 <head> **开头**,不能是 </head> 前:
68
+ // 官方 dsh web 的插件/主干 <script> 就在 <head> 里,注入到 </head> 前会**晚于**
69
+ // 那些脚本执行 —— 启动期错误(本次要抓的正是这类)就漏了。
70
+ // 用字符扫描而不是正则,避免 HTML 里的属性引号/大小写差异把注入点算错。
71
+ const low = src.toLowerCase();
72
+ const headIdx = low.indexOf("<head");
73
+ let pos = -1;
74
+ if (headIdx !== -1) {
75
+ const gt = low.indexOf(">", headIdx);
76
+ pos = gt === -1 ? headIdx : gt + 1;
77
+ } else {
78
+ pos = low.indexOf("</head>"); // 兜底:没有 <head> 时退化为 </head> 前
79
+ }
80
+ if (pos === -1) return { html: src, injected: false };
81
+ const script = diagScriptText();
82
+ if (!script) return { html: src, injected: false };
83
+ // 注入体不能含 </script>(会把文档提前截断)—— 采集器源码里本来就没有,这里再守一道。
84
+ if (/<\/script/i.test(script)) {
85
+ console.warn(`[${DIAG_ID}] 采集器源码含 </script>,拒绝注入`);
86
+ return { html: src, injected: false };
87
+ }
88
+ const block = `<script id="${DIAG_ID}-js" data-${DIAG_ID}>${script}</script>`;
89
+ return { html: src.slice(0, pos) + block + src.slice(pos), injected: true };
90
+ }
91
+
92
+ /**
93
+ * @param {{buf: Buffer, contentType: string}} input
94
+ * @returns {{buf: Buffer, injected: boolean}}
95
+ */
96
+ export function maybeInjectMirrorDiag({ buf, contentType }) {
97
+ if (!mirrorDiagEnabled() || !Buffer.isBuffer(buf) || buf.length === 0) {
98
+ return { buf, injected: false };
99
+ }
100
+ const text = buf.toString("utf8");
101
+ if (!shouldInjectMirrorDiag({ contentType, html: text })) return { buf, injected: false };
102
+ const out = injectMirrorDiag(text);
103
+ return { buf: Buffer.from(out.html, "utf8"), injected: out.injected };
104
+ }
105
+
106
+ export default { injectMirrorDiag, maybeInjectMirrorDiag, shouldInjectMirrorDiag, mirrorDiagEnabled };