rdsh-gateway 0.8.6 → 0.10.0

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/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@ export { ipInCidrs, parseCidr, ipToInt } from "./cidr.ts";
21
21
  export { loadTls } from "./tls.ts";
22
22
  export type { TlsMaterial } from "./tls.ts";
23
23
  export { loginPageHtml } from "./login-page.ts";
24
- export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
24
+ export { installService, uninstallService, serviceStatus, startService, stopService, restartService, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
25
25
  export type { ServiceSpec } from "./service.ts";
26
26
  export declare const NAME = "rdsh-gateway";
27
27
  export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.ts";
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { hashPassword, verifyPassword, UserManager } from "./auth.js";
14
14
  export { ipInCidrs, parseCidr, ipToInt } from "./cidr.js";
15
15
  export { loadTls } from "./tls.js";
16
16
  export { loginPageHtml } from "./login-page.js";
17
- export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.js";
17
+ export { installService, uninstallService, serviceStatus, startService, stopService, restartService, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.js";
18
18
  export const NAME = "rdsh-gateway";
19
19
  export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.js";
20
20
  export { readPersistedToken, clearPersistedToken } from "./token-store.js";
package/dist/join.d.ts CHANGED
@@ -105,6 +105,8 @@ export declare function classifyUpstreamFailure(code: string | undefined, respon
105
105
  };
106
106
  /** JS 响应判定(content-type 含 javascript)。 */
107
107
  export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
108
+ /** HTML 响应判定(content-type 含 text/html)。 */
109
+ export declare function isHtmlContentType(headers: IncomingHttpHeaders): boolean;
108
110
  /**
109
111
  * 最小 patch:把 DSH 客户端 bundle 里的前端 isLoopback 判定替换为 true
110
112
  * (持久设置/API key 输入只对 loopback 开放;E2EE 流上信任基础等同 loopback)。
package/dist/join.js CHANGED
@@ -22,6 +22,7 @@ import { acquireJoinLock, releaseJoinLock } from "./lock.js";
22
22
  import { responderHandshake, Aead } from "./e2ee.js";
23
23
  import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
24
24
  import { GATE_COOKIE, signGateCookie, verifyGateCookie, verifyGateCode } from "./access-gate.js";
25
+ import { RDSH_WEBVIEW_API, injectHtmlScript } from "./rdsh-webview-api.js";
25
26
  /** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
26
27
  function isCertError(err) {
27
28
  const code = err?.code ?? "";
@@ -157,6 +158,12 @@ export function isJsContentType(headers) {
157
158
  const s = Array.isArray(ct) ? ct.join(";") : (ct ?? "");
158
159
  return /javascript/i.test(s);
159
160
  }
161
+ /** HTML 响应判定(content-type 含 text/html)。 */
162
+ export function isHtmlContentType(headers) {
163
+ const ct = headers["content-type"];
164
+ const s = Array.isArray(ct) ? ct.join(";") : (ct ?? "");
165
+ return /text\/html/i.test(s);
166
+ }
160
167
  /**
161
168
  * 最小 patch:把 DSH 客户端 bundle 里的前端 isLoopback 判定替换为 true
162
169
  * (持久设置/API key 输入只对 loopback 开放;E2EE 流上信任基础等同 loopback)。
@@ -301,6 +308,16 @@ export function startJoin(opts) {
301
308
  const gatedHttp = new Map();
302
309
  /** 已报告过"loopback 补丁未命中"的路径(每个路径只报一次,避免日志刷屏)。 */
303
310
  const patchMissReported = new Set();
311
+ /** 已报告过"loopback 补丁命中"的路径(与 miss 对称:只看得到 miss 会让人误判"补丁从未生效")。 */
312
+ const patchHitReported = new Set();
313
+ /**
314
+ * 已确认**不含**补丁目标串的 URL(键为完整 path,含 query)——命中过一次 miss 后直接走流式,
315
+ * 不再每次页面加载都缓冲 + 解压 + 全量扫描(DSH 前端壳资源 2 个就 ~1.3 MB)。
316
+ * 键含 query 且不截断:combo 路由用 query 选内容,资源 URL 由内容决定(rev/hash),内容一变 URL 就变。
317
+ */
318
+ const patchSkipped = new Set();
319
+ /** rdsh WebView API 是否已注入过(每个进程只打一次日志,避免页面加载刷屏)。 */
320
+ let adapterInjectionLogged = false;
304
321
  /** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
305
322
  function gateCookie(headers) {
306
323
  const ck = headers["cookie"];
@@ -474,7 +491,35 @@ export function startJoin(opts) {
474
491
  responded = true;
475
492
  const status = upRes.statusCode ?? 502;
476
493
  const baseHeaders = normalizeRespHeaders(upRes.headers);
477
- const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers);
494
+ // 已确认该 URL 不含目标串(DSH 前端壳资源就属此类)→ 直接流式,省掉白解压与全量扫描
495
+ const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers) && !patchSkipped.has(path);
496
+ const wantsInject = dio?.htmlInject !== undefined && isHtmlContentType(upRes.headers) && upRes.headers["content-encoding"] === undefined;
497
+ // HTML + 注入:缓冲后注入(htmlInject 会改 body 长度,须先处理再发 OPEN 并重算 content-length)
498
+ if (wantsInject) {
499
+ const htmlChunks = [];
500
+ upRes.on("data", (chunk) => htmlChunks.push(chunk));
501
+ upRes.on("end", () => {
502
+ const html = injectHtmlScript(Buffer.concat(htmlChunks).toString("utf8"), dio.htmlInject);
503
+ const outHeaders = {
504
+ ...baseHeaders,
505
+ "content-length": String(Buffer.byteLength(html)),
506
+ };
507
+ delete outHeaders["transfer-encoding"];
508
+ if (!adapterInjectionLogged) {
509
+ adapterInjectionLogged = true;
510
+ console.log("[rdsh] rdsh webview api injected (window.__rdshWebViewApi v1)");
511
+ }
512
+ send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
513
+ sendChunkedBody(send, streamId, Buffer.from(html));
514
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
515
+ httpStreams.delete(streamId);
516
+ });
517
+ upRes.on("error", () => {
518
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
519
+ httpStreams.delete(streamId);
520
+ });
521
+ return;
522
+ }
478
523
  // 非 JS / 未开补丁:保持原流式路径(OPEN 立即发,body 边到边发)
479
524
  if (!wantsPatch) {
480
525
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: baseHeaders })));
@@ -503,8 +548,11 @@ export function startJoin(opts) {
503
548
  let body = raw;
504
549
  let outHeaders = baseHeaders;
505
550
  let hit = false;
551
+ /** 解码成功、且明文里确实**没有**目标串 → 该 URL 可以永久短路(换编码回来也一样没有)。 */
552
+ let targetAbsent = false;
506
553
  if (decoded !== null) {
507
554
  const patched = patchLoopbackJs(decoded);
555
+ targetAbsent = patched === null;
508
556
  const recoded = patched === null ? null : encodeBody(patched, encoding);
509
557
  if (recoded !== null) {
510
558
  body = recoded;
@@ -516,17 +564,26 @@ export function startJoin(opts) {
516
564
  outHeaders["cache-control"] = "public, max-age=300";
517
565
  }
518
566
  }
519
- // 未命中必须留痕(每个路径一次):fail-open 是刻意设计,但"补丁从未生效"不能静默
520
- //(2026-09-14 gzip 事故正是因为没有这条日志而排查了很久)
521
- if (!hit) {
522
- const key = (path.split("?")[0] ?? path).slice(0, 120);
523
- if (!patchMissReported.has(key)) {
524
- patchMissReported.add(key);
525
- console.log(`[patch] miss: ${key} (${raw.length}B, content-encoding=${encoding === "" ? "identity" : encoding})`);
567
+ // 命中与未命中都必须留痕(每个路径一次):fail-open 是刻意设计,但"补丁是否生效"不能靠猜。
568
+ // 两次误判都源于只看得到一半日志:2026-09-14 gzip 事故(只见 miss)、2026-09-17
569
+ // 「补丁在 0.1.5-rc.2 上永不命中」(把前端壳资源的 miss 当成了判据所在的 bundle)。
570
+ const logKey = (path.split("?")[0] ?? path).slice(0, 120);
571
+ if (hit) {
572
+ if (!patchHitReported.has(logKey) || process.env.RDSH_DEBUG_PATCH === "1") {
573
+ patchHitReported.add(logKey);
574
+ console.log(`[patch] hit: ${logKey} (${raw.length}B → ${body.length}B, ${encoding === "" ? "identity" : encoding})`);
526
575
  }
527
576
  }
528
- else if (process.env.RDSH_DEBUG_PATCH === "1") {
529
- console.log(`[patch] hit: ${path.slice(0, 120)} (${raw.length}B → ${body.length}B, ${encoding === "" ? "identity" : encoding})`);
577
+ else {
578
+ const firstMiss = !patchMissReported.has(logKey);
579
+ patchMissReported.add(logKey);
580
+ // 只有"解码后确实没有目标串"才永久短路;编码不支持(decoded === null)时并没看过明文,
581
+ // 保持每次尝试(同一 URL 换个编码回来仍能被补丁);重压失败同理不算。
582
+ if (targetAbsent)
583
+ patchSkipped.add(path);
584
+ if (firstMiss) {
585
+ console.log(`[patch] miss: ${logKey} (${raw.length}B, content-encoding=${encoding === "" ? "identity" : encoding}) — fail-open${targetAbsent ? ";此 URL 后续不再尝试补丁" : ""}`);
586
+ }
530
587
  }
531
588
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
532
589
  sendChunkedBody(send, streamId, body);
@@ -615,7 +672,7 @@ export function startJoin(opts) {
615
672
  }
616
673
  return { handleFrame, cleanup };
617
674
  }
618
- const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback, gate: true });
675
+ const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback, gate: true, htmlInject: RDSH_WEBVIEW_API });
619
676
  // host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
620
677
  let hostE2eeKeypair;
621
678
  try {
package/dist/proxy.js CHANGED
@@ -7,6 +7,20 @@
7
7
  import { request } from "node:http";
8
8
  import { WebSocket, WebSocketServer } from "ws";
9
9
  import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
10
+ /**
11
+ * 已知**不含**补丁目标串的 URL(按 patcher 函数身份隔离——目标串由 patcher 决定,只看 URL 会误判)。
12
+ * 目的与隧道路径(`join.ts` 的 `patchSkipped`)一致:DSH 前端壳资源每次页面加载都被缓冲 + 解压 + 全量扫描,
13
+ * 而这些字节永远不会命中;记住一次即可一直走流式。
14
+ */
15
+ const jsPatchMissedUrls = new WeakMap();
16
+ function missedUrlsFor(patcher) {
17
+ const existing = jsPatchMissedUrls.get(patcher);
18
+ if (existing !== undefined)
19
+ return existing;
20
+ const created = new Set();
21
+ jsPatchMissedUrls.set(patcher, created);
22
+ return created;
23
+ }
10
24
  /**
11
25
  * 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
12
26
  * - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
@@ -81,7 +95,12 @@ export function forwardHttp(req, res, target, opts) {
81
95
  });
82
96
  return;
83
97
  }
84
- const canPatchJs = opts?.jsPatch !== undefined && typeof contentType === "string" && /javascript/i.test(contentType);
98
+ const patchUrl = req.url ?? "";
99
+ const patchMissed = opts?.jsPatch === undefined ? undefined : missedUrlsFor(opts.jsPatch);
100
+ const canPatchJs = opts?.jsPatch !== undefined &&
101
+ typeof contentType === "string" &&
102
+ /javascript/i.test(contentType) &&
103
+ patchMissed?.has(patchUrl) !== true;
85
104
  if (canPatchJs) {
86
105
  // JS bundle 需缓冲后才能替换目标串;先按 content-encoding 解码(dsh 会 gzip JS),
87
106
  // 补丁后按原编码重压(fail-open:未命中/不支持编码 → 原字节原头透传)
@@ -95,6 +114,9 @@ export function forwardHttp(req, res, target, opts) {
95
114
  const patched = decoded === null ? null : opts.jsPatch(decoded);
96
115
  const recoded = patched === null ? null : encodeBody(patched, encoding);
97
116
  const outBody = recoded ?? raw;
117
+ // 只有"解码后确实没有目标串"才记住该 URL(编码不支持时没看过明文,保持每次尝试)
118
+ if (patched === null && decoded !== null)
119
+ patchMissed?.add(patchUrl);
98
120
  const outHeaders = { ...upstreamRes.headers };
99
121
  if (recoded !== null) {
100
122
  outHeaders["content-length"] = String(outBody.length);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * rdsh-webview-api.ts — 智能体 API 契约适配脚本(注入 DSH 首页,feature 22)。
3
+ *
4
+ * 挂出稳定契约 `window.__rdshWebViewApi`(version 1),供 WebView 客户端(如 garsync App)优先消费,
5
+ * 以把「DSH 版本适配」从 App(发版慢)挪到 gateway(随 npm 包快速发布)。
6
+ *
7
+ * 契约正文与语义以 garsync `doc/feature/108_agent_api_contract/req.md` R7 为准;
8
+ * 本脚本是 garsync `lib/rdsh/rdsh_web_bridge.dart` 的 `_bridgeJs` 的**精简等价物**:
9
+ * - 保留:isDshPage / readReply / fillAndSend 三方法及其 DOM 细节(Lexical 写入管线、
10
+ * 键盘守卫、最后一轮答案提取、UTF-8 安全 base64);
11
+ * - 去掉:`report()` 通道与 105 诊断探针(那是 App 侧独有的 debug 机制)。
12
+ *
13
+ * 安全约定:幂等(已注入即返回)、防御式(任何 DOM 缺失/异常返回安全默认值、绝不抛错)。
14
+ */
15
+ export declare const RDSH_WEBVIEW_API = "(function () {\n if (window.__rdshWebViewApi) return;\n function findComposer() {\n var el = document.querySelector('[data-lexical-editor=\"true\"]');\n if (el) return { el: el, kind: 'lexical' };\n el = document.querySelector('[contenteditable=\"true\"]');\n if (el) return { el: el, kind: 'contenteditable' };\n el = document.querySelector('textarea');\n if (el) return { el: el, kind: 'textarea' };\n return null;\n }\n function selectAll(el) {\n var tag = el.tagName;\n if (tag === 'TEXTAREA' || tag === 'INPUT') { el.select(); return; }\n var range = document.createRange();\n range.selectNodeContents(el);\n var sel = window.getSelection();\n sel.removeAllRanges();\n sel.addRange(range);\n }\n function insertText(el, text) {\n el.focus();\n try { selectAll(el); } catch (e) {}\n // 1) execCommand \u8D70\u6D4F\u89C8\u5668\u7F16\u8F91\u7BA1\u7EBF\uFF0C\u8BA9 Lexical \u6536\u5230 input \u4E8B\u4EF6\u3002\n try { if (document.execCommand('insertText', false, text)) return true; } catch (e) {}\n // 2) \u663E\u5F0F beforeinput\u3002\n try {\n el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: text, bubbles: true, cancelable: true }));\n return true;\n } catch (e) {}\n // 3) \u515C\u5E95\uFF1A\u76F4\u63A5\u5199 DOM + \u89E6\u53D1 input\u3002\n try {\n var tag = el.tagName;\n if (tag === 'TEXTAREA' || tag === 'INPUT') {\n var proto = tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, text);\n } else {\n el.textContent = text;\n }\n el.dispatchEvent(new Event('input', { bubbles: true }));\n return true;\n } catch (e) {}\n return false;\n }\n function pressEnter(el) {\n var opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true, isComposing: false };\n try {\n el.dispatchEvent(new KeyboardEvent('keydown', opts));\n el.dispatchEvent(new KeyboardEvent('keypress', opts));\n el.dispatchEvent(new KeyboardEvent('keyup', opts));\n } catch (e) {}\n }\n // \u952E\u76D8\u5B88\u536B\uFF1A\u8BED\u97F3\u5199\u5165\u540E\u5173\u8F6F\u952E\u76D8\uFF1B\u7528\u6237\u771F\u5B9E\u89E6\u6478\u9875\u9762\u5373\u89E3\u9664\u3002\n var keyboardGuard = false;\n function dismissKeyboard() {\n try {\n var active = document.activeElement;\n if (active && active !== document.body && typeof active.blur === 'function') active.blur();\n var selection = window.getSelection();\n if (selection && selection.removeAllRanges) selection.removeAllRanges();\n } catch (e) {}\n }\n function onUserTouch() { if (keyboardGuard) keyboardGuard = false; }\n document.addEventListener('focusin', function () { if (!keyboardGuard) return; dismissKeyboard(); }, true);\n document.addEventListener('pointerdown', onUserTouch, true);\n document.addEventListener('touchstart', onUserTouch, true);\n\n // \u5355\u4E2A flow \u8282\u70B9\u7684\u53EF\u89C1\u6587\u672C\uFF1BproseOnly \u989D\u5916\u4E22\u5F03\u6574\u5757\u4EE3\u7801\u4E0E\u8868\u683C\uFF08105 \u53EA\u8BFB\u7B54\u6848\u6B63\u6587\uFF09\u3002\n function answerTextOf(el, proseOnly) {\n var clone = el.cloneNode(true);\n var selector = '[data-variant=\"think\"],[data-variant=\"others\"]';\n if (proseOnly) selector += ',pre,table';\n var drop = clone.querySelectorAll(selector);\n for (var i = 0; i < drop.length; i++) { if (drop[i].parentNode) drop[i].parentNode.removeChild(drop[i]); }\n return (clone.innerText || clone.textContent || '').trim();\n }\n // \u6700\u540E\u4E00\u8F6E\u7684\u7B54\u6848\u90E8\u5206\uFF08\u6392\u9664 reasoning\uFF0C\u53D6\u6700\u5927 turn\uFF0C\u9010 part \u62FC\u63A5\uFF09\u3002\n function latestAnswer() {\n var steps = Array.prototype.slice.call(document.querySelectorAll('[data-chat-flow-kind=\"assistant-step\"]'));\n var answers = steps.filter(function (el) { return el.getAttribute('data-chat-group-part') !== 'reasoning'; });\n var maxTurn = -1;\n for (var i = 0; i < answers.length; i++) {\n var turn = parseInt(answers[i].getAttribute('data-chat-turn'), 10);\n if (!isNaN(turn) && turn > maxTurn) maxTurn = turn;\n }\n var parts = answers.filter(function (el) { return parseInt(el.getAttribute('data-chat-turn'), 10) === maxTurn; });\n var texts = [];\n for (var j = 0; j < parts.length; j++) {\n var text = answerTextOf(parts[j], true);\n if (text.length) texts.push(text);\n }\n return texts.join('\\n\\n');\n }\n\n window.__rdshWebViewApi = {\n version: 1,\n // 104\uFF1A\u5199\u5165 composer \u5E76\u63D0\u4EA4\u3002\u8FD4\u56DE\u88F8\u72B6\u6001 token\u3002\n fillAndSend: function (text) {\n var found = findComposer();\n if (!found) return 'no-composer';\n keyboardGuard = false;\n if (!insertText(found.el, text)) return 'fill-failed';\n keyboardGuard = true;\n dismissKeyboard();\n setTimeout(function () { pressEnter(found.el); dismissKeyboard(); }, 80);\n return 'ok';\n },\n // 105\uFF1A\u5F85\u6717\u8BFB\u5185\u5BB9 + \u72B6\u6001\u3002\u8FD4\u56DE UTF-8 \u5B89\u5168\u7684 base64 JSON {dsh, streaming, text}\u3002\n readReply: function () {\n var out = { dsh: false, streaming: 0, text: '' };\n try {\n out.streaming = document.querySelectorAll('[data-streaming]').length;\n if (document.querySelectorAll('[data-chat-flow-kind]').length > 0) {\n out.dsh = true;\n out.text = latestAnswer();\n } else {\n out.text = document.body ? document.body.innerText : '';\n }\n } catch (e) {}\n return window.btoa(unescape(encodeURIComponent(JSON.stringify(out))));\n },\n // 107\uFF1A\u662F\u5426 DSH \u4F1A\u8BDD\u9875\uFF08\u8F7B\u91CF\u5224\u5B9A\uFF0C\u53EF\u8F6E\u8BE2\uFF09\u3002\u8FD4\u56DE\u88F8 '1' / '0'\u3002\n isDshPage: function () {\n try {\n return document.querySelectorAll('[data-chat-flow-kind]').length > 0 ? '1' : '0';\n } catch (e) { return '0'; }\n }\n };\n})();";
16
+ /** 把 `<script>${script}</script>` 插到 `</head>` 前(无则前置);纯函数,可单测。 */
17
+ export declare function injectHtmlScript(html: string, script: string): string;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * rdsh-webview-api.ts — 智能体 API 契约适配脚本(注入 DSH 首页,feature 22)。
3
+ *
4
+ * 挂出稳定契约 `window.__rdshWebViewApi`(version 1),供 WebView 客户端(如 garsync App)优先消费,
5
+ * 以把「DSH 版本适配」从 App(发版慢)挪到 gateway(随 npm 包快速发布)。
6
+ *
7
+ * 契约正文与语义以 garsync `doc/feature/108_agent_api_contract/req.md` R7 为准;
8
+ * 本脚本是 garsync `lib/rdsh/rdsh_web_bridge.dart` 的 `_bridgeJs` 的**精简等价物**:
9
+ * - 保留:isDshPage / readReply / fillAndSend 三方法及其 DOM 细节(Lexical 写入管线、
10
+ * 键盘守卫、最后一轮答案提取、UTF-8 安全 base64);
11
+ * - 去掉:`report()` 通道与 105 诊断探针(那是 App 侧独有的 debug 机制)。
12
+ *
13
+ * 安全约定:幂等(已注入即返回)、防御式(任何 DOM 缺失/异常返回安全默认值、绝不抛错)。
14
+ */
15
+ export const RDSH_WEBVIEW_API = `(function () {
16
+ if (window.__rdshWebViewApi) return;
17
+ function findComposer() {
18
+ var el = document.querySelector('[data-lexical-editor="true"]');
19
+ if (el) return { el: el, kind: 'lexical' };
20
+ el = document.querySelector('[contenteditable="true"]');
21
+ if (el) return { el: el, kind: 'contenteditable' };
22
+ el = document.querySelector('textarea');
23
+ if (el) return { el: el, kind: 'textarea' };
24
+ return null;
25
+ }
26
+ function selectAll(el) {
27
+ var tag = el.tagName;
28
+ if (tag === 'TEXTAREA' || tag === 'INPUT') { el.select(); return; }
29
+ var range = document.createRange();
30
+ range.selectNodeContents(el);
31
+ var sel = window.getSelection();
32
+ sel.removeAllRanges();
33
+ sel.addRange(range);
34
+ }
35
+ function insertText(el, text) {
36
+ el.focus();
37
+ try { selectAll(el); } catch (e) {}
38
+ // 1) execCommand 走浏览器编辑管线,让 Lexical 收到 input 事件。
39
+ try { if (document.execCommand('insertText', false, text)) return true; } catch (e) {}
40
+ // 2) 显式 beforeinput。
41
+ try {
42
+ el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: text, bubbles: true, cancelable: true }));
43
+ return true;
44
+ } catch (e) {}
45
+ // 3) 兜底:直接写 DOM + 触发 input。
46
+ try {
47
+ var tag = el.tagName;
48
+ if (tag === 'TEXTAREA' || tag === 'INPUT') {
49
+ var proto = tag === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
50
+ Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, text);
51
+ } else {
52
+ el.textContent = text;
53
+ }
54
+ el.dispatchEvent(new Event('input', { bubbles: true }));
55
+ return true;
56
+ } catch (e) {}
57
+ return false;
58
+ }
59
+ function pressEnter(el) {
60
+ var opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true, isComposing: false };
61
+ try {
62
+ el.dispatchEvent(new KeyboardEvent('keydown', opts));
63
+ el.dispatchEvent(new KeyboardEvent('keypress', opts));
64
+ el.dispatchEvent(new KeyboardEvent('keyup', opts));
65
+ } catch (e) {}
66
+ }
67
+ // 键盘守卫:语音写入后关软键盘;用户真实触摸页面即解除。
68
+ var keyboardGuard = false;
69
+ function dismissKeyboard() {
70
+ try {
71
+ var active = document.activeElement;
72
+ if (active && active !== document.body && typeof active.blur === 'function') active.blur();
73
+ var selection = window.getSelection();
74
+ if (selection && selection.removeAllRanges) selection.removeAllRanges();
75
+ } catch (e) {}
76
+ }
77
+ function onUserTouch() { if (keyboardGuard) keyboardGuard = false; }
78
+ document.addEventListener('focusin', function () { if (!keyboardGuard) return; dismissKeyboard(); }, true);
79
+ document.addEventListener('pointerdown', onUserTouch, true);
80
+ document.addEventListener('touchstart', onUserTouch, true);
81
+
82
+ // 单个 flow 节点的可见文本;proseOnly 额外丢弃整块代码与表格(105 只读答案正文)。
83
+ function answerTextOf(el, proseOnly) {
84
+ var clone = el.cloneNode(true);
85
+ var selector = '[data-variant="think"],[data-variant="others"]';
86
+ if (proseOnly) selector += ',pre,table';
87
+ var drop = clone.querySelectorAll(selector);
88
+ for (var i = 0; i < drop.length; i++) { if (drop[i].parentNode) drop[i].parentNode.removeChild(drop[i]); }
89
+ return (clone.innerText || clone.textContent || '').trim();
90
+ }
91
+ // 最后一轮的答案部分(排除 reasoning,取最大 turn,逐 part 拼接)。
92
+ function latestAnswer() {
93
+ var steps = Array.prototype.slice.call(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]'));
94
+ var answers = steps.filter(function (el) { return el.getAttribute('data-chat-group-part') !== 'reasoning'; });
95
+ var maxTurn = -1;
96
+ for (var i = 0; i < answers.length; i++) {
97
+ var turn = parseInt(answers[i].getAttribute('data-chat-turn'), 10);
98
+ if (!isNaN(turn) && turn > maxTurn) maxTurn = turn;
99
+ }
100
+ var parts = answers.filter(function (el) { return parseInt(el.getAttribute('data-chat-turn'), 10) === maxTurn; });
101
+ var texts = [];
102
+ for (var j = 0; j < parts.length; j++) {
103
+ var text = answerTextOf(parts[j], true);
104
+ if (text.length) texts.push(text);
105
+ }
106
+ return texts.join('\\n\\n');
107
+ }
108
+
109
+ window.__rdshWebViewApi = {
110
+ version: 1,
111
+ // 104:写入 composer 并提交。返回裸状态 token。
112
+ fillAndSend: function (text) {
113
+ var found = findComposer();
114
+ if (!found) return 'no-composer';
115
+ keyboardGuard = false;
116
+ if (!insertText(found.el, text)) return 'fill-failed';
117
+ keyboardGuard = true;
118
+ dismissKeyboard();
119
+ setTimeout(function () { pressEnter(found.el); dismissKeyboard(); }, 80);
120
+ return 'ok';
121
+ },
122
+ // 105:待朗读内容 + 状态。返回 UTF-8 安全的 base64 JSON {dsh, streaming, text}。
123
+ readReply: function () {
124
+ var out = { dsh: false, streaming: 0, text: '' };
125
+ try {
126
+ out.streaming = document.querySelectorAll('[data-streaming]').length;
127
+ if (document.querySelectorAll('[data-chat-flow-kind]').length > 0) {
128
+ out.dsh = true;
129
+ out.text = latestAnswer();
130
+ } else {
131
+ out.text = document.body ? document.body.innerText : '';
132
+ }
133
+ } catch (e) {}
134
+ return window.btoa(unescape(encodeURIComponent(JSON.stringify(out))));
135
+ },
136
+ // 107:是否 DSH 会话页(轻量判定,可轮询)。返回裸 '1' / '0'。
137
+ isDshPage: function () {
138
+ try {
139
+ return document.querySelectorAll('[data-chat-flow-kind]').length > 0 ? '1' : '0';
140
+ } catch (e) { return '0'; }
141
+ }
142
+ };
143
+ })();`;
144
+ /** 把 `<script>${script}</script>` 插到 `</head>` 前(无则前置);纯函数,可单测。 */
145
+ export function injectHtmlScript(html, script) {
146
+ const tag = `<script>${script}</script>`;
147
+ if (html.includes("</head>"))
148
+ return html.replace("</head>", `${tag}</head>`);
149
+ return `${tag}${html}`;
150
+ }
151
+ //# sourceMappingURL=rdsh-webview-api.js.map
package/dist/server.js CHANGED
@@ -22,8 +22,11 @@ import { patchLoopbackJs } from "./join.js";
22
22
  import { pairPageHtml } from "./pair-page.js";
23
23
  import { loginPageHtml } from "./login-page.js";
24
24
  import { SECURE_CONTEXT_POLYFILL } from "./secure-context-polyfill.js";
25
+ import { RDSH_WEBVIEW_API } from "./rdsh-webview-api.js";
25
26
  import { loadConfig } from "./config.js";
26
27
  import { ipInCidrs } from "./cidr.js";
28
+ /** 注入 DSH 首页的脚本:非 secure context polyfill + rdsh WebView API 契约。两处 forwardHttp 共用,避免漂移。 */
29
+ const HTML_INJECT = SECURE_CONTEXT_POLYFILL + RDSH_WEBVIEW_API;
27
30
  const MAX_LOGIN_FAILS = 5;
28
31
  const LOGIN_LOCK_MS = 10 * 60 * 1000;
29
32
  export async function startGateway(opts) {
@@ -159,7 +162,7 @@ async function handleHttp(req, res, ctx, loginLimiter) {
159
162
  }
160
163
  if (ctx.authMode === "none") {
161
164
  forwardHttp(req, res, ctx.target, {
162
- htmlInject: SECURE_CONTEXT_POLYFILL,
165
+ htmlInject: HTML_INJECT,
163
166
  authCookie: ctx.dshAuthCookieHeader,
164
167
  jsPatch: ctx.trustPairedAsLoopback ? patchLoopbackJs : undefined,
165
168
  });
@@ -171,7 +174,7 @@ async function handleHttp(req, res, ctx, loginLimiter) {
171
174
  return;
172
175
  }
173
176
  forwardHttp(req, res, ctx.target, {
174
- htmlInject: SECURE_CONTEXT_POLYFILL,
177
+ htmlInject: HTML_INJECT,
175
178
  authCookie: ctx.dshAuthCookieHeader,
176
179
  // 会话分支同样要打 loopback 补丁:LAN 没有 E2EE shim,设置/API key 唯一依赖这个补丁;
177
180
  // 旧实现只在 authMode==="none" 分支传了 jsPatch ⇒ 配对/登录模式下设置页打不开(2026-09-14 复审发现)
package/dist/service.d.ts CHANGED
@@ -30,3 +30,9 @@ export declare function installService(spec: ServiceSpec): Promise<string>;
30
30
  export declare function serviceStatus(name?: string): Promise<string>;
31
31
  /** 停止并移除服务。 */
32
32
  export declare function uninstallService(name?: string): Promise<string>;
33
+ /** 启动服务(运行时控制,不改变开机自启配置)。 */
34
+ export declare function startService(name?: string): Promise<string>;
35
+ /** 停止服务(运行时控制,不改变开机自启配置)。 */
36
+ export declare function stopService(name?: string): Promise<string>;
37
+ /** 重启服务(运行时控制,不改变开机自启配置)。 */
38
+ export declare function restartService(name?: string): Promise<string>;
package/dist/service.js CHANGED
@@ -158,4 +158,33 @@ export async function uninstallService(name = SERVICE_NAME) {
158
158
  await rm(launchdPlistPath(name), { force: true });
159
159
  return `removed ${launchdPlistPath(name)}`;
160
160
  }
161
+ /** 启动服务(运行时控制,不改变开机自启配置)。 */
162
+ export async function startService(name = SERVICE_NAME) {
163
+ if (isLinux()) {
164
+ await run("systemctl", ["--user", "start", name]);
165
+ return `started ${name}`;
166
+ }
167
+ // macOS:kickstart -k 确保在跑(已运行则先停再启)。
168
+ await run("launchctl", ["kickstart", "-k", `com.${name}`]);
169
+ return `started com.${name}`;
170
+ }
171
+ /** 停止服务(运行时控制,不改变开机自启配置)。 */
172
+ export async function stopService(name = SERVICE_NAME) {
173
+ if (isLinux()) {
174
+ await run("systemctl", ["--user", "stop", name]);
175
+ return `stopped ${name}`;
176
+ }
177
+ // macOS:stop 仅停当前实例;KeepAlive(仅失败退出重启)可能立即拉起 —— 与 systemd stop 语义不同。
178
+ await run("launchctl", ["stop", `com.${name}`]).catch(() => undefined);
179
+ return `stopped com.${name}`;
180
+ }
181
+ /** 重启服务(运行时控制,不改变开机自启配置)。 */
182
+ export async function restartService(name = SERVICE_NAME) {
183
+ if (isLinux()) {
184
+ await run("systemctl", ["--user", "restart", name]);
185
+ return `restarted ${name}`;
186
+ }
187
+ await run("launchctl", ["kickstart", "-k", `com.${name}`]);
188
+ return `restarted com.${name}`;
189
+ }
161
190
  //# sourceMappingURL=service.js.map
@@ -80,9 +80,12 @@ export declare function compareDshVersions(a: string, b: string): number;
80
80
  * 2026-09-11:MAX 扩到 `0.1.5-rc.2` —— 真机实测通过(spawn + ready 行 token + 会话 cookie 换发 +
81
81
  * `/api` 转发 + HTML 注入 + WS `/api/remote.mux` 桥接 + join 的 `patchLoopbackJs` 命中),
82
82
  * 见 doc/review/20260911-dsh-0.1.5-rc.2-plugin-compat.md §5。
83
+ * 2026-09-24:MAX 扩到 `0.1.7-rc.1` —— 同一份 G1–G7 清单重跑通过。0.1.7 只改前端 dist 形态
84
+ * (`<base href="./">` + 相对 `plugins/…`),认证/RPC 未变;网关是同源透明代理,相对引用由浏览器
85
+ * 解析成绝对路径后原样转发,故不受影响,见 doc/review/20260924-dsh-0.1.7-rc.1-compat.md。
83
86
  */
84
87
  export declare const DSH_COMPAT_MIN = "0.1.1-rc.2";
85
- export declare const DSH_COMPAT_MAX = "0.1.5-rc.2";
88
+ export declare const DSH_COMPAT_MAX = "0.1.7-rc.1";
86
89
  /**
87
90
  * 版本落在实测窗口外时的提示文案(零文档导向:直接给动作指令,用户不查表)。
88
91
  * - 比 max 新 → 升级 remote-dsh 或暂用旧 dsh;
package/dist/spawn-dsh.js CHANGED
@@ -287,9 +287,12 @@ export function compareDshVersions(a, b) {
287
287
  * 2026-09-11:MAX 扩到 `0.1.5-rc.2` —— 真机实测通过(spawn + ready 行 token + 会话 cookie 换发 +
288
288
  * `/api` 转发 + HTML 注入 + WS `/api/remote.mux` 桥接 + join 的 `patchLoopbackJs` 命中),
289
289
  * 见 doc/review/20260911-dsh-0.1.5-rc.2-plugin-compat.md §5。
290
+ * 2026-09-24:MAX 扩到 `0.1.7-rc.1` —— 同一份 G1–G7 清单重跑通过。0.1.7 只改前端 dist 形态
291
+ * (`<base href="./">` + 相对 `plugins/…`),认证/RPC 未变;网关是同源透明代理,相对引用由浏览器
292
+ * 解析成绝对路径后原样转发,故不受影响,见 doc/review/20260924-dsh-0.1.7-rc.1-compat.md。
290
293
  */
291
294
  export const DSH_COMPAT_MIN = "0.1.1-rc.2";
292
- export const DSH_COMPAT_MAX = "0.1.5-rc.2";
295
+ export const DSH_COMPAT_MAX = "0.1.7-rc.1";
293
296
  /**
294
297
  * 版本落在实测窗口外时的提示文案(零文档导向:直接给动作指令,用户不查表)。
295
298
  * - 比 max 新 → 升级 remote-dsh 或暂用旧 dsh;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdsh-gateway",
3
- "version": "0.8.6",
3
+ "version": "0.10.0",
4
4
  "description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
5
5
  "license": "MIT",
6
6
  "type": "module",