dsh-mobile-plugin 0.1.99 → 0.2.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/README.md +14 -5
- package/dsh-mobile-credentials/lib/credentials.js +405 -84
- package/lib/client.js +31 -8
- package/lib/http/routing.js +165 -9
- package/lib/impl.js +79 -5
- package/lib/index.js +1 -1
- package/lib/mirror/pusher.cjs +35 -4
- package/lib/mobile/approvals.js +62 -15
- package/lib/mobile/auth.js +46 -26
- package/lib/mobile/credential-upgrade.js +129 -0
- package/lib/mobile/entry-window.js +32 -0
- package/lib/mobile/events.js +14 -4
- package/lib/mobile/extensions.js +279 -0
- package/lib/mobile/history-derive.js +159 -0
- package/lib/mobile/host-bridge/event-synthesis.js +40 -1
- package/lib/mobile/pairing.js +150 -64
- package/lib/mobile/plugin-self.js +187 -0
- package/lib/mobile/push-notify-route.js +37 -0
- package/lib/mobile/push-notify.js +84 -0
- package/lib/mobile/questions.js +75 -10
- package/lib/mobile/relay-control.js +534 -0
- package/lib/mobile/session-cache.js +19 -1
- package/lib/mobile/session-file.js +78 -4
- package/lib/mobile/session-list-shortcut.js +38 -0
- package/lib/mobile/session-model-options.js +3 -1
- package/lib/mobile/session-models.js +15 -3
- package/lib/mobile/session-send.js +21 -3
- package/lib/mobile/session-snapshot.js +12 -11
- package/lib/mobile/sessions.js +20 -0
- package/lib/mobile/stats-collect.cjs +55 -12
- package/lib/mobile/stats-ledger.cjs +140 -21
- package/lib/mobile/stream-registry.js +73 -0
- package/lib/mobile/stream.js +103 -18
- package/lib/mobile/uploads.js +79 -3
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +25 -2
- package/lib/relay/lan-proxy.js +283 -122
- package/lib/relay/protocol.js +4 -2
- package/lib/relay/tunnel.js +20 -14
- package/lib/routes/control-plane.js +135 -9
- package/lib/routes/mobile-api.js +87 -7
- package/lib/shared/e2ee-pair.js +91 -0
- package/lib/shared/e2ee-plain.js +79 -0
- package/lib/shared/e2ee-request.js +331 -0
- package/lib/shared/e2ee.js +173 -0
- package/lib/shared/http-util.js +27 -4
- package/lib/shared/list-signal.js +16 -0
- package/lib/shared/pair-throttle.js +150 -0
- package/lib/shared/sse-write.js +16 -5
- package/lib/shared/via-header.js +65 -0
- package/lib/shell/runtime.js +147 -42
- package/lib/shell/self-check-after-swap.js +93 -0
- package/lib/shell/updater.js +36 -2
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/package.json +3 -2
package/lib/client.js
CHANGED
|
@@ -85,7 +85,13 @@ window.__ModuleLoader__.load({
|
|
|
85
85
|
const PUSH_INTERVAL_MS = 2000;
|
|
86
86
|
/** 与宿主 lib/desktop-view.cjs 的上限保持一致,避免无谓的超大请求。 */
|
|
87
87
|
const MAX_KEYS = 512;
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* 浏览器侧的应用路由一律写成**文档相对**形式(没有前导斜杠),与 DSH 自己的客户端
|
|
90
|
+
* 一致:宿主给 index 注入了 `<base href="./">`,页面挂在哪个前缀下,请求就跟着哪个
|
|
91
|
+
* 前缀走。写成 `/api/...` 这种站点根绝对地址,DSH 被反向代理挂到子路径(例如
|
|
92
|
+
* `/dsh/`)时会直接打到站点根、拿回 404 —— 设置页看到的正是「读取宿主状态失败: HTTP 404」。
|
|
93
|
+
*/
|
|
94
|
+
const DESKTOP_VIEW_ENDPOINT = 'api/relay/desktop-view';
|
|
89
95
|
|
|
90
96
|
/** 读桌面端最新的视图状态;读不到返回 null(首次使用、隐私模式、键名变更等)。 */
|
|
91
97
|
function readDesktopView() {
|
|
@@ -167,8 +173,12 @@ window.__ModuleLoader__.load({
|
|
|
167
173
|
/* ── 二:控制面调用 ─────────────────────────────────────────────────────
|
|
168
174
|
* 全部走同源 fetch,天然满足宿主的 `localOnly` 守卫(拒绝 cross-site、要求
|
|
169
175
|
* Origin 与 Host 同源)——设置页与宿主同源,正是它被设计成的样子。
|
|
176
|
+
*
|
|
177
|
+
* 地址一律是**文档相对**的:宿主给 index 注入了 `<base href="./">`,把它挂在
|
|
178
|
+
* 子路径下(反向代理 `/dsh/` 之类)时请求才会跟着前缀走。DSH 自己的客户端也是
|
|
179
|
+
* 这么写的(`PATH.slice(1)`)。站点根绝对地址在那种部署下会打到根路径并 404。
|
|
170
180
|
*/
|
|
171
|
-
const RELAY = '
|
|
181
|
+
const RELAY = 'api/relay';
|
|
172
182
|
|
|
173
183
|
/** 错误正文有三种形状:字符串、{code,message}、{ok:false,error}。这里拍平成一句话。 */
|
|
174
184
|
function errorText(body, status) {
|
|
@@ -185,22 +195,27 @@ window.__ModuleLoader__.load({
|
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
/**
|
|
188
|
-
* 控制面调用。`path` 既可以是 `/xxx`
|
|
198
|
+
* 控制面调用。`path` 既可以是 `/xxx` / `xxx` 这样的**短路径**(推荐,与
|
|
189
199
|
* `postJson('/desktop-read')` 的写法一致),也可以直接甩一个完整的
|
|
190
|
-
* `/api/relay/xxx
|
|
200
|
+
* `/api/relay/xxx` 或 `api/relay/xxx`——几种都接受,最终一律折成文档相对地址。
|
|
191
201
|
*
|
|
192
|
-
*
|
|
202
|
+
* **为什么必须几种都接受(2026-10-10 真机故障)**:这里以前是无条件
|
|
193
203
|
* `${RELAY}${path}`,而绿点那两条同步传进来的偏偏是**已经带 `/api/relay` 的全路径**
|
|
194
204
|
* (`READ_RECEIPT_ENDPOINT` / `DESKTOP_READ_ENDPOINT`),于是真正发出去的是
|
|
195
205
|
* `/api/relay/api/relay/read-receipt` —— 一个不存在的地址。它返回 401/404,被
|
|
196
206
|
* 两条同步各自的 `catch` 静静吞掉,所以「手机上读过桌面不灭灯」「桌面上读过手机
|
|
197
207
|
* 不灭灯」从来就没通过,而且**一条日志都不留**(宿主侧活体台账里那两个计数恒为 0)。
|
|
198
|
-
* 同一文件里的展开态同步之所以一直正常,只是因为它用的是裸 `fetch` + 全路径。
|
|
199
208
|
* 改断言而不是改实现是不行的:守卫测试里的假 fetch 按 `endsWith(endpoint)` 匹配,
|
|
200
209
|
* 而 `/api/relay/api/relay/read-receipt` 也满足这个后缀 —— 测试因此一直是绿的。
|
|
201
210
|
*/
|
|
211
|
+
function controlUrl(path) {
|
|
212
|
+
const raw = String(path);
|
|
213
|
+
if (raw.startsWith('/api/') || raw.startsWith('api/')) return raw.replace(/^\//, '');
|
|
214
|
+
return `${RELAY}${raw.startsWith('/') ? raw : `/${raw}`}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
202
217
|
async function call(path, init) {
|
|
203
|
-
const url =
|
|
218
|
+
const url = controlUrl(path);
|
|
204
219
|
let response;
|
|
205
220
|
try {
|
|
206
221
|
response = await fetch(url, {
|
|
@@ -503,6 +518,7 @@ window.__ModuleLoader__.load({
|
|
|
503
518
|
panelPending: '正在读取页面地址…',
|
|
504
519
|
panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
|
|
505
520
|
loadFailed: '读取宿主状态失败',
|
|
521
|
+
loadMissing: '插件宿主侧没有加载:把 DSH 完整退出再打开一次;还不行就在插件市场重装一遍插件。',
|
|
506
522
|
webAccessOn: '公网网页管理已开放,到期时间',
|
|
507
523
|
webAccessOff: '公网网页管理已关闭',
|
|
508
524
|
webAccessOpen: '开放网页管理 2 小时',
|
|
@@ -520,6 +536,7 @@ window.__ModuleLoader__.load({
|
|
|
520
536
|
panelPending: 'Reading the page address…',
|
|
521
537
|
panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
|
|
522
538
|
loadFailed: 'Failed to read host status',
|
|
539
|
+
loadMissing: 'The plugin host half is not loaded: quit and reopen DSH; if that does not help, reinstall the plugin from the market.',
|
|
523
540
|
webAccessOn: 'Public web management is open until',
|
|
524
541
|
webAccessOff: 'Public web management is closed',
|
|
525
542
|
webAccessOpen: 'Open web management for 2 hours',
|
|
@@ -544,6 +561,8 @@ window.__ModuleLoader__.load({
|
|
|
544
561
|
const [webAccess, setWebAccess] = useState(null);
|
|
545
562
|
const [webAccessError, setWebAccessError] = useState(null);
|
|
546
563
|
const [webAccessBusy, setWebAccessBusy] = useState(false);
|
|
564
|
+
/** 最近一次失败的 HTTP 状态码(0 = 网络异常 / 还没失败过)。404 单独说。 */
|
|
565
|
+
const [errorStatus, setErrorStatus] = useState(0);
|
|
547
566
|
|
|
548
567
|
useEffect(() => {
|
|
549
568
|
let alive = true;
|
|
@@ -557,6 +576,7 @@ window.__ModuleLoader__.load({
|
|
|
557
576
|
const next = await call('/status');
|
|
558
577
|
if (!alive) return;
|
|
559
578
|
setError(null);
|
|
579
|
+
setErrorStatus(0);
|
|
560
580
|
setPanelUrl(typeof next.panelUrl === 'string' ? next.panelUrl : '');
|
|
561
581
|
setWebAccess(next.webAccess && typeof next.webAccess.enabled === 'boolean' ? next.webAccess : null);
|
|
562
582
|
if (retryTimer !== null) {
|
|
@@ -572,6 +592,7 @@ window.__ModuleLoader__.load({
|
|
|
572
592
|
} catch (problem) {
|
|
573
593
|
if (!alive) return;
|
|
574
594
|
setError(String((problem && problem.message) || problem));
|
|
595
|
+
setErrorStatus(Number(problem && problem.status) || 0);
|
|
575
596
|
setWebAccess(null);
|
|
576
597
|
if (refreshTimer !== null) {
|
|
577
598
|
clearTimer(refreshTimer);
|
|
@@ -618,7 +639,9 @@ window.__ModuleLoader__.load({
|
|
|
618
639
|
|
|
619
640
|
return h('div', { className: 'dshm_root' }, [
|
|
620
641
|
h('h2', { className: 'dshm_heading' }, t('heading')),
|
|
621
|
-
error
|
|
642
|
+
error
|
|
643
|
+
? h('p', { className: 'dshm_error' }, errorStatus === 404 ? t('loadMissing') : `${t('loadFailed')}: ${error}`)
|
|
644
|
+
: null,
|
|
622
645
|
|
|
623
646
|
// 用 `<a target="_blank">` 而不是 iframe:DSH 桌面端会把这个链接交给系统浏览器
|
|
624
647
|
// 打开(网页版有自己的地址与刷新节奏,嵌在设置弹窗里反而不好用)。
|
package/lib/http/routing.js
CHANGED
|
@@ -14,12 +14,45 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const net = require('node:net');
|
|
17
|
+
const { isProto2, openRequest, sealResponse, readRawBody } = require('../shared/e2ee-request');
|
|
17
18
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 「这个请求是谁转发的」。
|
|
22
|
+
*
|
|
23
|
+
* 中转与局域网代理都跑在这台机器上,它们转给本地上游的请求 socket 对端就是回环地址,
|
|
24
|
+
* `host` 还会被补成 `127.0.0.1:<上游端口>` —— 于是**任何看来源地址或 Host 的判据都对它们
|
|
25
|
+
* 失效**,经中转打进来的控制面请求长得和本机 DSH 设置页一模一样(实测:不带实例头、不带
|
|
26
|
+
* cookie 的 `POST /api/relay/config` 照样执行,能改中转地址、改注册密钥、`revoke-all`)。
|
|
27
|
+
*
|
|
28
|
+
* 所以判据换成「谁转发的」这个事实:两个代理在转发前**先删掉客户端自带的同名头、再写入
|
|
29
|
+
* 自己的值**(`relay` / `lan`),控制面见到它就按远程处理。客户端自己塞一个也没用 ——
|
|
30
|
+
* 代理会先把它删掉。
|
|
31
|
+
*/
|
|
32
|
+
const VIA_HEADER = 'x-dsh-via';
|
|
33
|
+
|
|
34
|
+
/** 请求是否由本机的中转或局域网代理转发而来(值即转发方)。 */
|
|
35
|
+
const proxiedRequest = (req) => {
|
|
36
|
+
const headers = req?.headers;
|
|
37
|
+
if (!headers || typeof headers !== 'object') return false;
|
|
38
|
+
// 按小写比对而不是直接取键:Node 会把入站头名压成小写,但假 req、反代注入、
|
|
39
|
+
// 以及 `WebSocket` 升级那条路的 headers 对象不保证如此,靠大小写绕过太便宜。
|
|
40
|
+
for (const [name, raw] of Object.entries(headers)) {
|
|
41
|
+
if (name.toLowerCase() !== VIA_HEADER) continue;
|
|
42
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
43
|
+
if (typeof value === 'string' && value.trim() !== '') return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
};
|
|
47
|
+
|
|
19
48
|
// Relay pairing cookies and instance headers are routing credentials only;
|
|
20
49
|
// never allow them to reach the local control plane.
|
|
50
|
+
//
|
|
51
|
+
// `x-dsh-via` 也在这里:它是「转发来的」这个事实本身。手机走 `/api/mobile/v1/**` 时通常
|
|
52
|
+
// 既不待实例头也不带 cookie,只靠这两条挡不住转发来的控制面请求。
|
|
21
53
|
const remoteRelayRequest = (req) => Boolean(
|
|
22
|
-
(
|
|
54
|
+
proxiedRequest(req)
|
|
55
|
+
|| (typeof req.headers['x-dsh-instance'] === 'string' && req.headers['x-dsh-instance'])
|
|
23
56
|
|| /(?:^|;\s*)dsh_(?:pair|lan)=/.test(String(req.headers.cookie || '')),
|
|
24
57
|
)
|
|
25
58
|
|
|
@@ -57,6 +90,8 @@ const isLoopbackAddress = (address) => {
|
|
|
57
90
|
const trustedLocalUi = (req) => {
|
|
58
91
|
const socket = req?.socket || req?.connection
|
|
59
92
|
if (!isLoopbackAddress(socket?.remoteAddress)) return false
|
|
93
|
+
// 转发来的一律不算本机界面:隧道与局域网代理都是从回环连出去的,光看 socket 分不出来。
|
|
94
|
+
if (proxiedRequest(req)) return false
|
|
60
95
|
const headers = req?.headers && typeof req.headers === 'object' ? req.headers : {}
|
|
61
96
|
for (const [name, value] of Object.entries(headers)) {
|
|
62
97
|
if (/^(?:forwarded$|x-forwarded-|x-real-ip$|via$|true-client-ip$|cf-connecting-ip$)/i.test(name)
|
|
@@ -101,19 +136,140 @@ const readJson = (req) => readJsonBody(req, 256 * 1024).then(body => body || {})
|
|
|
101
136
|
* 否则随便谁打一下也能把面板骗成「连上了」。
|
|
102
137
|
* @returns {(handler: (ctx: object, req: object, res: object) => Promise<unknown>) => (req: object, res: object) => Promise<unknown>}
|
|
103
138
|
*/
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
139
|
+
/**
|
|
140
|
+
* 移动端路由守卫:token + 协议 v2 信封 + 把 handler 抛出的异常统一收成 500 信封。
|
|
141
|
+
*
|
|
142
|
+
* 与 localOnly 的不对称是刻意的:移动端请求本来就要从公网进来,跨站不是它的风险;
|
|
143
|
+
* 它的风险是「没带 token 也能读别人的会话」,所以只校验 token。
|
|
144
|
+
*
|
|
145
|
+
* **信封的顺序在这里定死**(§8.3(11)):鉴权(token)→ 验签 → 重放 → 解封请求体 →
|
|
146
|
+
* 业务 handler → 封响应。鉴权放在最前,是因为「这台设备是谁」决定了拿哪把密钥验签;
|
|
147
|
+
* 而鉴权自身失败时回的是明文 401(`e2ee-unknown-device` 之外的一切凭据问题),
|
|
148
|
+
* 因为 App 必须先在解不开信封的情况下读懂它。
|
|
149
|
+
*
|
|
150
|
+
* @param {object} ctx 插件 ctx,透传给 handler
|
|
151
|
+
* @param {(req: object) => boolean} authorized 移动 token 校验,由调用方注入
|
|
152
|
+
* @param {() => void} [note] 通过 token 校验之后叫一声:宿主用它记「手机来过」的时刻,
|
|
153
|
+
* 引导最后一页据此说「手机已经连上了」。**只在校验通过之后叫**,401 那条不算——
|
|
154
|
+
* 否则随便谁打一下也能把面板骗成「连上了」。
|
|
155
|
+
* @param {(req: object) => void} [onAuthorized] 带请求对象的记账(要读凭据记录时用)。
|
|
156
|
+
* @param {object} [envelope] 协议 v2 的信封钩子:`{ keyFor, readBody }`。不给就是纯明文。
|
|
157
|
+
* @returns {(handler: (ctx: object, req: object, res: object) => Promise<unknown>) => (req: object, res: object) => Promise<unknown>}
|
|
158
|
+
*/
|
|
159
|
+
const createGuarded = (ctx, authorized, note = null, extra = {}) => {
|
|
160
|
+
const onAuthorized = typeof extra.onAuthorized === 'function' ? extra.onAuthorized : null
|
|
161
|
+
const envelope = extra.envelope && typeof extra.envelope === 'object' ? extra.envelope : null
|
|
162
|
+
return (handler) => async (req, res) => {
|
|
163
|
+
// 鉴权失败要**说清是哪一种**(§3.8):被吊销 / 已过期 / 只是没有 token。
|
|
164
|
+
//
|
|
165
|
+
// 前两种必须让手机知道「我被踢了」并停止轮询 —— 只回一句「缺少或无效的移动 token」,
|
|
166
|
+
// 用户看到的是「请求失败」,而它其实已经不能再做任何事了。这三条响应都是明文:
|
|
167
|
+
// 客户端要在解不开信封的情况下读懂它们。
|
|
168
|
+
// `authorized` 的两种返回值都认:布尔(老调用方/测试)与带 reason 的对象(宿主用,
|
|
169
|
+
// 这样才能区分「被踢了」与「没带 token」)。
|
|
170
|
+
const outcome = authorized(req)
|
|
171
|
+
const passed = outcome === true || (outcome && typeof outcome === 'object' && outcome.ok === true)
|
|
172
|
+
if (!passed) {
|
|
173
|
+
const reason = outcome && typeof outcome === 'object' ? outcome.reason : 'unauthorized'
|
|
174
|
+
if (reason === 'revoked') return sendJson(res, 401, { ok: false, error: { code: 'device-revoked', message: '这台设备已被解除绑定,请重新扫码' } })
|
|
175
|
+
if (reason === 'expired') return sendJson(res, 401, { ok: false, error: { code: 'device-expired', message: '这台设备的授权已过期,请重新扫码' } })
|
|
176
|
+
return sendJson(res, 401, { ok: false, error: { code: 'unauthorized', message: '缺少或无效的移动 token' } })
|
|
177
|
+
}
|
|
178
|
+
// 鉴权结果里的凭据记录挂到请求上:流那条路要按设备登记(吊销时主动断流,§3.3),
|
|
179
|
+
// 而设备 id 只在这条记录里。挂在 req 上而不是再查一次库,是因为这一条请求上已经查过了。
|
|
180
|
+
if (outcome && typeof outcome === 'object' && outcome.record) req.__dshRecord = outcome.record
|
|
181
|
+
if (note) {
|
|
182
|
+
// 记账本身绝不能影响请求结果:它坏了也只是面板少一个确认。
|
|
183
|
+
try { note() } catch { /* 忽略 */ }
|
|
184
|
+
}
|
|
185
|
+
if (onAuthorized) {
|
|
186
|
+
try { onAuthorized(req) } catch { /* 同上 */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// **被动降级要被发现,不能悄悄变明文**(§8.3(7) 的 `e2ee-required`)。
|
|
190
|
+
//
|
|
191
|
+
// 判据:这枚凭据的记录里有 `deviceKey`(说明它当初是按协议 v2 配的),而这条请求没带
|
|
192
|
+
// `X-Dsh-Proto` —— 中转把那个头删掉时就是这个形状。不判的话宿主会按老协议解析一个
|
|
193
|
+
// 信封 JSON,得到一个「请求体不是合法 JSON」的 400,看起来像 App 的 bug。
|
|
194
|
+
// 老 App 的凭据里没有 `deviceKey`,不受这一条影响。
|
|
195
|
+
const authRecord = req.__dshRecord
|
|
196
|
+
if (envelope && authRecord && authRecord.deviceKey && !isProto2(req)) {
|
|
197
|
+
return sendJson(res, 400, { ok: false, error: { code: 'e2ee-required', message: '这条会话是加密的,请用信封重发' } })
|
|
198
|
+
}
|
|
199
|
+
// 协议 v2:只有带 `X-Dsh-Proto: 2` 的请求才走信封。老客户端没有这个头,一字不改地照旧。
|
|
200
|
+
let sealed = null
|
|
201
|
+
if (envelope && isProto2(req)) {
|
|
202
|
+
let rawBody
|
|
203
|
+
try {
|
|
204
|
+
rawBody = await envelope.readBody(req)
|
|
205
|
+
} catch {
|
|
206
|
+
return sendJson(res, 413, { ok: false, error: { code: 'e2ee-bad-envelope', message: '请求体过大' } })
|
|
207
|
+
}
|
|
208
|
+
const opened = openRequest(req, { keyFor: envelope.keyFor }, rawBody)
|
|
209
|
+
if (!opened.ok) {
|
|
210
|
+
// 错误响应永远明文(§8.3(11)):App 要先读懂它才谈得上重发或校准时钟。
|
|
211
|
+
if (opened.extra) sendJson(res, opened.status, { ok: false, error: { code: opened.code, ...opened.extra } })
|
|
212
|
+
else sendJson(res, opened.status, { ok: false, error: { code: opened.code } })
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
sealed = opened
|
|
216
|
+
// 业务 handler 拿到的是明文 JSON:域代码一行业务逻辑都不用知道信封存在。
|
|
217
|
+
if (opened.body !== undefined) req.body = opened.body
|
|
218
|
+
// SSE 那条路要逐帧封装(帧没有请求头可用),所以把密钥与这次请求的 AAD 材料
|
|
219
|
+
// 挂在 req 上,由开流那一处取。**只在内存里、只活这一条请求**。
|
|
220
|
+
req.__dshE2ee = { keys: opened.keys, req: opened.req }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
if (!sealed) {
|
|
225
|
+
await handler(ctx, req, res)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
// 封响应:业务代码照常 `sendJson(res, status, payload)`,这里把**写出去的那一份**包成
|
|
229
|
+
// 信封。劫的是 `res.end` 而不是 `res.json` —— 宿主侧写响应一律走 `sendJson`,
|
|
230
|
+
// 而它内部是 `writeHead` + `end`,没有 `res.json` 这一层。
|
|
231
|
+
const originalEnd = res.end.bind(res)
|
|
232
|
+
const originalWriteHead = res.writeHead.bind(res)
|
|
233
|
+
let chosenStatus = 200
|
|
234
|
+
res.writeHead = (status, ...rest) => {
|
|
235
|
+
chosenStatus = Number(status) || 200
|
|
236
|
+
return originalWriteHead(status, ...rest)
|
|
237
|
+
}
|
|
238
|
+
res.end = (chunk, ...rest) => {
|
|
239
|
+
res.end = originalEnd
|
|
240
|
+
res.writeHead = originalWriteHead
|
|
241
|
+
let payload = chunk
|
|
242
|
+
if (typeof chunk === 'string' || Buffer.isBuffer(chunk)) {
|
|
243
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : chunk
|
|
244
|
+
let parsed
|
|
245
|
+
try {
|
|
246
|
+
parsed = JSON.parse(text)
|
|
247
|
+
} catch {
|
|
248
|
+
parsed = null
|
|
249
|
+
}
|
|
250
|
+
if (parsed !== null) {
|
|
251
|
+
const sealedPayload = sealResponse(req, { statusCode: chosenStatus }, sealed.keys, sealed.req, parsed)
|
|
252
|
+
// **封完直接发,不再 gzip**:`content-encoding` 只能在头出去之前声明,而这个
|
|
253
|
+
// 钩子跑在 `sendJson` 的 `writeHead` 之后,`setHeader` 会被静默忽略 —— 结果
|
|
254
|
+
// 是「线上是 gzip、头却说 identity」,客户端解不开,报「请求失败 (HTTP 200)」
|
|
255
|
+
// (2026-10-24 现场)。信封是 base64 密文,外层压缩本来也只省几个百分点;
|
|
256
|
+
// 要省带宽靠载荷裁剪,不靠这一层。
|
|
257
|
+
payload = JSON.stringify(sealedPayload)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return originalEnd(payload, ...rest)
|
|
261
|
+
}
|
|
262
|
+
await handler(ctx, req, res)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
sendJson(res, 500, { ok: false, error: { code: 'internal', message: String(error?.message || error) } })
|
|
265
|
+
}
|
|
112
266
|
}
|
|
113
267
|
}
|
|
114
268
|
|
|
115
269
|
module.exports = {
|
|
116
270
|
remoteRelayRequest,
|
|
271
|
+
proxiedRequest,
|
|
272
|
+
VIA_HEADER,
|
|
117
273
|
crossSiteRequest,
|
|
118
274
|
isLoopbackAddress,
|
|
119
275
|
trustedLocalUi,
|
package/lib/impl.js
CHANGED
|
@@ -20,7 +20,10 @@ const { localOnly, trustedLocalUi, readJson, createGuarded } = require('./http/r
|
|
|
20
20
|
const { registerMobileRoutes } = require('./routes/mobile-api.js')
|
|
21
21
|
const { registerControlPlaneRoutes } = require('./routes/control-plane.js')
|
|
22
22
|
const { hostMetadata, createStatusView } = require('./relay/status.js')
|
|
23
|
-
const { authorized } = require('./mobile/auth.js')
|
|
23
|
+
const { authorized, authOutcome, presentedCredential } = require('./mobile/auth.js')
|
|
24
|
+
const { readRawBody } = require('./shared/e2ee-request')
|
|
25
|
+
const streamRegistry = require('./mobile/stream-registry.js')
|
|
26
|
+
const { viaChannel } = require('./shared/via-header')
|
|
24
27
|
const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
|
|
25
28
|
const { createMirrorPusher } = require('./mirror/pusher.cjs')
|
|
26
29
|
const { randomBytes } = require('node:crypto')
|
|
@@ -38,6 +41,8 @@ const { installQuestionBridge } = require('./mobile/questions.js')
|
|
|
38
41
|
const activity = require('./mobile/activity.cjs')
|
|
39
42
|
const { installAutoArchive } = require('./auto-archive.cjs')
|
|
40
43
|
const { installSessionCleanup } = require('./session-cleanup.cjs')
|
|
44
|
+
// 扩展卡的宿主侧:`extension_present` 工具与卡状态账本(见 mobile/extensions.js)。
|
|
45
|
+
const { installExtensions } = require('./mobile/extensions.js')
|
|
41
46
|
const { activeGuard } = require('./host-guard.cjs')
|
|
42
47
|
// 桌面端侧栏展开态的快照存储。桌面把这份状态只记在浏览器 localStorage 里,宿主必须
|
|
43
48
|
// 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
|
|
@@ -136,7 +141,12 @@ function createConfigStore(state) {
|
|
|
136
141
|
}
|
|
137
142
|
return {
|
|
138
143
|
get: () => configOf(state),
|
|
139
|
-
|
|
144
|
+
/**
|
|
145
|
+
* 写配置。`notify: false` 只落盘、不通知订阅者 —— 手机端改中转地址时要用它:
|
|
146
|
+
* 订阅者一跑就会重配隧道,而那一发响应还没吐完(详见 mobile/relay-control.js)。
|
|
147
|
+
*/
|
|
148
|
+
notify() { notify() },
|
|
149
|
+
async update(patch, options = {}) {
|
|
140
150
|
const next = configOf(state)
|
|
141
151
|
for (const [key, value] of Object.entries(patch || {})) {
|
|
142
152
|
if (value === undefined || !(key in next)) continue
|
|
@@ -150,7 +160,7 @@ function createConfigStore(state) {
|
|
|
150
160
|
}
|
|
151
161
|
Object.assign(state, parsed)
|
|
152
162
|
core.saveState(state)
|
|
153
|
-
notify()
|
|
163
|
+
if (options.notify !== false) notify()
|
|
154
164
|
return configOf(state)
|
|
155
165
|
},
|
|
156
166
|
watch(listener) {
|
|
@@ -172,6 +182,15 @@ function pairingPayload(state, lanPort) {
|
|
|
172
182
|
name: state.name || os.hostname(),
|
|
173
183
|
instanceId: state.instanceId,
|
|
174
184
|
code: state.pairingCode,
|
|
185
|
+
// 协议版本与一次性票据(§8.3(1))。
|
|
186
|
+
//
|
|
187
|
+
// `proto: 2` 由**宿主**写、也由宿主生成,中转没有任何机会改写它 —— 所以「新 App 被
|
|
188
|
+
// 中转骗回明文」这个问题从一开始就不存在。`ticket` 是 32 字节随机数,手机拿它派生
|
|
189
|
+
// 配对密钥;二维码是屏幕对摄像头,这条通道完全绕开中转。
|
|
190
|
+
//
|
|
191
|
+
// 老 App 只取自己认识的那几个字段,多出来的两个会被忽略 —— 一张二维码同时服务新旧两种。
|
|
192
|
+
proto: 2,
|
|
193
|
+
ticket: state.pairingTicket || '',
|
|
175
194
|
relayUrl,
|
|
176
195
|
lanUrls: urls,
|
|
177
196
|
// 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
|
|
@@ -290,7 +309,12 @@ function apply(ctx) {
|
|
|
290
309
|
// 显式传入能让「re-fork 前清的是不是正在跑的那份缓存」不依赖巧合。
|
|
291
310
|
pluginLibDir: path.dirname(fileURLToPath(import.meta.url)),
|
|
292
311
|
// 下发给 worker 的配置:这里的字段集就是 sidecar 的启动契约,改名要同步 worker。
|
|
293
|
-
|
|
312
|
+
//
|
|
313
|
+
// `pairingTicket` 与 `pairingCode` 都必须在这里:局域网代理的配对要拿票据派生配对
|
|
314
|
+
// 密钥(协议 v2 的请求在旁路里解封)。漏了票据时旁路读到的是空串,任何一次扫码配对
|
|
315
|
+
// 都回「配对码错误或桌面端已拒绝」,而主进程那条 `/pair` 一切正常 —— 症状是「同一个
|
|
316
|
+
// 二维码,走中转能配、走局域网不能」。
|
|
317
|
+
buildConfig: (lanPort) => ({ enabled: state.enabled, relayUrl: state.relayUrl, instanceId: state.instanceId, pairingCode: state.pairingCode, pairingTicket: state.pairingTicket || '', name: state.name, upstreamPort: ctx.webServer.port, lanPort, lanEnabled: state.lanEnabled, lanSecret: state.lanSecret, registerSecret: state.registerSecret }),
|
|
294
318
|
})
|
|
295
319
|
const { sidecarToken, syncSidecar, restartSidecar, tunnelConnected } = sidecarLifecycle
|
|
296
320
|
|
|
@@ -314,6 +338,13 @@ function apply(ctx) {
|
|
|
314
338
|
}
|
|
315
339
|
return undefined
|
|
316
340
|
}, `${name}: session cleanup tool`)
|
|
341
|
+
ctx.effect(() => {
|
|
342
|
+
// 卡状态账本与三条路由不靠这个工具,所以缺 tools 注入时只少一个工具,别把整块装配丢掉。
|
|
343
|
+
try { installExtensions(ctx) } catch (error) {
|
|
344
|
+
if (!String(error).includes('without inject')) throw error
|
|
345
|
+
}
|
|
346
|
+
return undefined
|
|
347
|
+
}, `${name}: extension tool`)
|
|
317
348
|
|
|
318
349
|
const status = createStatusView({
|
|
319
350
|
state,
|
|
@@ -370,10 +401,33 @@ function apply(ctx) {
|
|
|
370
401
|
getMirrorPush: mirror.getMirrorPush,
|
|
371
402
|
getMirrorPushFailures: mirror.getPushFailures,
|
|
372
403
|
getLanProxyStats: () => sidecarLifecycle.getSnapshot()?.streams?.lan ?? null,
|
|
404
|
+
// 吊销时收掉这台设备已经开着的流(§3.3):token 换了只保证「下一次请求」被拒,
|
|
405
|
+
// 一条已经开着的 SSE 不会自己停,被踢的手机还能继续收消息直到它自己重连。
|
|
406
|
+
dropDeviceStreams: (deviceId) => streamRegistry.drop(deviceId),
|
|
373
407
|
})
|
|
374
408
|
|
|
375
409
|
|
|
376
|
-
|
|
410
|
+
/**
|
|
411
|
+
* 协议 v2 信封的宿主侧钩子(§8.3(4))。
|
|
412
|
+
*
|
|
413
|
+
* `keyFor` 按 **keyId** 查密钥,不按 deviceId:后者是手机自填的,克隆/迁移后会撞,
|
|
414
|
+
* 撞了就变成「两台手机能用同一把密钥验签」。凭据库是唯一的密钥来源,而它只在宿主
|
|
415
|
+
* 主进程里可写。
|
|
416
|
+
*/
|
|
417
|
+
const envelope = {
|
|
418
|
+
keyFor: (keyId) => credentials.findByKeyId(state.instanceId, keyId),
|
|
419
|
+
readBody: (req) => readRawBody(req),
|
|
420
|
+
}
|
|
421
|
+
const guarded = createGuarded(ctx, authOutcome, activity.note, {
|
|
422
|
+
// 鉴权通过后立刻记「最近连接 + 走的是哪条路」:两条代理都注入了 `x-dsh-via`。
|
|
423
|
+
onAuthorized: (req) => {
|
|
424
|
+
const presented = presentedCredential(req)
|
|
425
|
+
if (!presented) return
|
|
426
|
+
const record = credentials.lookup(presented, state.instanceId)
|
|
427
|
+
if (record) credentials.touch(record, { channel: viaChannel(req) })
|
|
428
|
+
},
|
|
429
|
+
envelope,
|
|
430
|
+
})
|
|
377
431
|
|
|
378
432
|
// 内置浏览器预览的宿主端:手机按端口在它自己的环回上监听,把内置浏览器写下的原始
|
|
379
433
|
// 字节通过 WebSocket 送到这里,这里与 `127.0.0.1:<port>` 对搬。
|
|
@@ -423,6 +477,26 @@ function apply(ctx) {
|
|
|
423
477
|
// 收不收新设备('deny' 时一律拒)。两者本文件都已有,透传给移动端路由。
|
|
424
478
|
core,
|
|
425
479
|
pairingApproval,
|
|
480
|
+
// 配对成功之后换码 + 换票据(§5.1):状态变更在 core 里,这里只补「让旁路重读」。
|
|
481
|
+
rotatePairing: () => { core.rotatePairing(state); applyServices() },
|
|
482
|
+
// 「这台设备的中转与连接配置」那十条(见 lib/mobile/relay-control.js)。
|
|
483
|
+
// 它们与控制面 `/api/relay/*` 写的是**同一份** relay.json(同一个 `settings` store),
|
|
484
|
+
// 只是入口不同:控制面只认本机,移动端认已配对 token。这里的每一项都是本文件已有的
|
|
485
|
+
// 取值器/实例,没有一行实现搬进装配层。
|
|
486
|
+
relayControl: {
|
|
487
|
+
state,
|
|
488
|
+
settings,
|
|
489
|
+
syncSidecar,
|
|
490
|
+
status,
|
|
491
|
+
resolveLanPort,
|
|
492
|
+
lanPortInUse: advertisedLanPort,
|
|
493
|
+
getLanPortAdjusted: () => lanPortAdjusted,
|
|
494
|
+
restartSidecar,
|
|
495
|
+
// **取值器**:sidecar 会被自重启整体替换,存实例就是存一份指向已 stop 的 facade。
|
|
496
|
+
getSidecar: sidecarLifecycle.getSidecar,
|
|
497
|
+
credentials,
|
|
498
|
+
activeGuard,
|
|
499
|
+
},
|
|
426
500
|
})
|
|
427
501
|
ctx.effect(() => {
|
|
428
502
|
const stopWatching = settings.watch(next => {
|
package/lib/index.js
CHANGED
|
@@ -154,7 +154,7 @@ export function apply(ctx) {
|
|
|
154
154
|
})
|
|
155
155
|
|
|
156
156
|
runtime
|
|
157
|
-
.start(ctx)
|
|
157
|
+
.start(ctx, { log })
|
|
158
158
|
.then(() => {
|
|
159
159
|
log('info', 'dsh-mobile-plugin: 已装载 %s(来源 %s)', runtime.status().version, runtime.status().source)
|
|
160
160
|
// 每 10 分钟让已落盘的正式版接管 OTA,并补上「盘上比运行中新」的那一次换代;
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -35,6 +35,7 @@ const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
|
35
35
|
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
36
36
|
const { dropSessionListCache } = require('../mobile/session-cache.js');
|
|
37
37
|
const { observeCompletionEvent } = require('../mobile/completion.js');
|
|
38
|
+
const { lastTurnOf } = require('../mobile/session-last-turn.js');
|
|
38
39
|
|
|
39
40
|
/** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
|
|
40
41
|
const HIGH_PRIORITY_WINDOW_MS = 5 * 60 * 1000;
|
|
@@ -83,6 +84,11 @@ const MAX_CONSECUTIVE_EVENT_BUILDS = 3;
|
|
|
83
84
|
|
|
84
85
|
function createMirrorPusher(deps) {
|
|
85
86
|
const { ctx, tunnelConnected, getSidecar, listSignal = sharedListSignal, sessionSignal = sharedSessionSignal } = deps;
|
|
87
|
+
// iOS 推送出口(`mobile/push-notify.js` 的 `notifyTurnEnded`)。不传就完全不推——
|
|
88
|
+
// 装配层漏接线时不该在这里崩。标题从镜像清单现查(noteMirrorList 维护缓存)。
|
|
89
|
+
const notifyTurnEnded = typeof deps.notifyTurnEnded === 'function' ? deps.notifyTurnEnded : null;
|
|
90
|
+
const titleCache = new Map();
|
|
91
|
+
const sessionTitleOf = (sessionId) => titleCache.get(sessionId) || '';
|
|
86
92
|
/**
|
|
87
93
|
* 「手机最近来过没有」的取值器(`mobile/activity.cjs` 的 `snapshot` 形态)。
|
|
88
94
|
*
|
|
@@ -179,6 +185,17 @@ function createMirrorPusher(deps) {
|
|
|
179
185
|
// 也是同一个值,语义上就是「这份列表换了」。
|
|
180
186
|
let lastSignalRevision = '';
|
|
181
187
|
const sessionsRevisionOf = (body) => (body && typeof body.revision === 'string' ? body.revision : '');
|
|
188
|
+
/**
|
|
189
|
+
* 工作区清单的内容指纹也进信号总线。
|
|
190
|
+
*
|
|
191
|
+
* 不这么做,「`/workspaces` 的构建前短路」就没有可信的变更判据 —— 手机只能每 15 秒
|
|
192
|
+
* 让宿主把整份工作区(含归档会话 id 列表,几十 KB)重建一遍。这里只报指纹(幂等),
|
|
193
|
+
* 推送照旧按原来的指纹判重。
|
|
194
|
+
*/
|
|
195
|
+
const observeWorkspaces = (body) => {
|
|
196
|
+
if (body && typeof body.revision === 'string' && body.revision) listSignal.bumpWorkspaces(body.revision);
|
|
197
|
+
return body;
|
|
198
|
+
};
|
|
182
199
|
const announceSessionsRevision = (revision) => {
|
|
183
200
|
if (!revision || revision === lastSignalRevision) return false;
|
|
184
201
|
// 只在真正广播时才前移基准:构建失败、守卫拦下、推送失败这些路径都不该消耗掉
|
|
@@ -331,6 +348,13 @@ function createMirrorPusher(deps) {
|
|
|
331
348
|
mirrorListActiveSessions = items.filter(isHighPriority).length;
|
|
332
349
|
mirrorListSubagents = items.filter((s) => s && s.origin === 'subagent').length;
|
|
333
350
|
mirrorListAt = Date.now();
|
|
351
|
+
// 标题缓存:推送帧的标题从这里现查(列表本来就带 title,白拿)。
|
|
352
|
+
titleCache.clear();
|
|
353
|
+
for (const item of items) {
|
|
354
|
+
if (item && typeof item.sessionId === 'string' && typeof item.title === 'string' && item.title.trim()) {
|
|
355
|
+
titleCache.set(item.sessionId, item.title.trim());
|
|
356
|
+
}
|
|
357
|
+
}
|
|
334
358
|
};
|
|
335
359
|
|
|
336
360
|
// Every session snapshot fans out to three Host RPCs. Bound the number of sessions
|
|
@@ -508,7 +532,7 @@ function createMirrorPusher(deps) {
|
|
|
508
532
|
);
|
|
509
533
|
const [sessionsBody, workspacesBody, data] = await Promise.all([
|
|
510
534
|
sessionsPromise,
|
|
511
|
-
workspaces.buildWorkspacesBody(ctx),
|
|
535
|
+
workspaces.buildWorkspacesBody(ctx).then(observeWorkspaces),
|
|
512
536
|
// event slot covers the full push; call the unlocked builder to avoid nesting the semaphore.
|
|
513
537
|
buildSessionDataUnlocked(sessionId, summaryPromise, { reuseCache: false }),
|
|
514
538
|
]);
|
|
@@ -568,7 +592,7 @@ function createMirrorPusher(deps) {
|
|
|
568
592
|
try {
|
|
569
593
|
const [sessionsBody, workspacesBody] = await Promise.all([
|
|
570
594
|
sessions.buildSessionsResponse(ctx, 'legacy'),
|
|
571
|
-
workspaces.buildWorkspacesBody(ctx),
|
|
595
|
+
workspaces.buildWorkspacesBody(ctx).then(observeWorkspaces),
|
|
572
596
|
]);
|
|
573
597
|
const wsBlocked = guardWorkspaces(workspacesBody) || guardSessions(sessionsBody)
|
|
574
598
|
if (!(sessionsBody?.status === 200) || wsBlocked) {
|
|
@@ -663,7 +687,7 @@ function createMirrorPusher(deps) {
|
|
|
663
687
|
// 空清单(dsh-mobile-server/lib/mirror-state.js 的 installMirror,谓词与
|
|
664
688
|
// dsh-mobile-server/server.js:110 一致)。这里推的就是 buildSessionsResponse 的
|
|
665
689
|
// 原样 body,天然带这个字段。
|
|
666
|
-
const workspacesBodyEmpty = await workspaces.buildWorkspacesBody(ctx);
|
|
690
|
+
const workspacesBodyEmpty = await workspaces.buildWorkspacesBody(ctx).then(observeWorkspaces);
|
|
667
691
|
const wsBlockedEmpty = guardWorkspaces(workspacesBodyEmpty);
|
|
668
692
|
if (wsBlockedEmpty) {
|
|
669
693
|
// 工作区那份快照不可用:别拿一份「没取到」的工作区去覆盖镜像,只广播清单。
|
|
@@ -684,7 +708,7 @@ function createMirrorPusher(deps) {
|
|
|
684
708
|
// 门(见 BG_COLD_ROTATE_MIN_INTERVAL_MS):清单与工作区都还停在中转镜像里那一版、
|
|
685
709
|
// 又不到冷会话轮转时间时,这一轮到此为止 —— 省掉统计/模型投影与一个冷会话的 200 条
|
|
686
710
|
// 正文(那才是这个 tick 的大头)。工作区体提前到这里构建,正是为了拿到它的 revision。
|
|
687
|
-
const workspacesBody = await workspaces.buildWorkspacesBody(ctx);
|
|
711
|
+
const workspacesBody = await workspaces.buildWorkspacesBody(ctx).then(observeWorkspaces);
|
|
688
712
|
const workspacesRevision = workspacesBody && typeof workspacesBody.revision === 'string' ? workspacesBody.revision : '';
|
|
689
713
|
const now = Date.now();
|
|
690
714
|
// 清掉已经不在清单里的会话(归档/删除),免得这张表无限长。
|
|
@@ -800,6 +824,13 @@ function createMirrorPusher(deps) {
|
|
|
800
824
|
// sidecar,形成推送风暴(配合 pushSessionImmediately 的单飞才有稳定上界)。
|
|
801
825
|
observeCompletionEvent(ctx, sessionId, p?.event);
|
|
802
826
|
if (p?.event?.type === 'assistant/chunk') continue;
|
|
827
|
+
// iOS 推送:turn/end 是「可能刚收尾」的信号,把事实(lastTurnOf 现读)交给
|
|
828
|
+
// 通知器判断——只有 completed / failed 且这一轮之前没推过才会真发一帧。
|
|
829
|
+
// 标题从镜像快照现查:中转与苹果都只认纯文本,没查到就用会话 id 前缀。
|
|
830
|
+
if (p?.event?.type === 'turn/end' && notifyTurnEnded) {
|
|
831
|
+
const fact = lastTurnOf(ctx, sessionId);
|
|
832
|
+
if (fact) notifyTurnEnded(sessionId, fact, sessionTitleOf(sessionId));
|
|
833
|
+
}
|
|
803
834
|
void pushSessionImmediately(sessionId);
|
|
804
835
|
}
|
|
805
836
|
} catch { /* stream ended */ }
|