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 +1 -1
- package/dist/index.js +1 -1
- package/dist/join.d.ts +2 -0
- package/dist/join.js +68 -11
- package/dist/proxy.js +23 -1
- package/dist/rdsh-webview-api.d.ts +17 -0
- package/dist/rdsh-webview-api.js +151 -0
- package/dist/server.js +5 -2
- package/dist/service.d.ts +6 -0
- package/dist/service.js +29 -0
- package/dist/spawn-dsh.d.ts +4 -1
- package/dist/spawn-dsh.js +4 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
529
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
package/dist/spawn-dsh.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
295
|
+
export const DSH_COMPAT_MAX = "0.1.7-rc.1";
|
|
293
296
|
/**
|
|
294
297
|
* 版本落在实测窗口外时的提示文案(零文档导向:直接给动作指令,用户不查表)。
|
|
295
298
|
* - 比 max 新 → 升级 remote-dsh 或暂用旧 dsh;
|