dsh-mobile-plugin 0.1.100 → 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
|
@@ -26,6 +26,11 @@ const { lanUrls } = require('./relay/lan-network.cjs')
|
|
|
26
26
|
const core = require('./relay/core.js')
|
|
27
27
|
const { createReconnectScheduler } = require('./reconnect-scheduler.cjs')
|
|
28
28
|
const { isMirrorSnapshotFresh } = require('./mirror-snapshot.cjs')
|
|
29
|
+
// 凭据库在这个进程里**只读**(§3.7):写盘只有宿主主进程一家。签发与吊销都经 IPC 交给它,
|
|
30
|
+
// 本地这份按文件指纹重载 —— 两个进程各持一份内存副本、各自整份覆写,后写的赢,于是
|
|
31
|
+
// 「经中转配上的手机回局域网 401」「吊销被回滚」这两条必然出现。
|
|
32
|
+
const credentials = require('./shared/credentials-entry.js').credentialsEntry()
|
|
33
|
+
if (typeof credentials.setReadOnly === 'function') credentials.setReadOnly(true)
|
|
29
34
|
|
|
30
35
|
const parentPort = Number(process.env.DSH_SIDECAR_PARENT_PORT)
|
|
31
36
|
const token = process.env.DSH_SIDECAR_TOKEN
|
|
@@ -104,6 +109,28 @@ class BodyTooLarge extends Error {}
|
|
|
104
109
|
* 留 64KB 余量给帧头。推得再大也只是这一次 413,隧道安然无恙。
|
|
105
110
|
*/
|
|
106
111
|
const TUNNEL_SEND_MAX_BODY = 8 * 1024 * 1024 - 64 * 1024
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 推给中转的那一份镜像:只留中转真的会读的字段。
|
|
115
|
+
*
|
|
116
|
+
* 桌面推上来的整份载荷里有四块:`sessions`、`workspaces`、`messages`、`stats`/`models`。
|
|
117
|
+
* 中转(`server/relay/rust/src/mirror.rs` 的 `remember`)只落**前两块**,另外三块
|
|
118
|
+
* 解析完就丢——它只解决「宿主不在线时手机还看得见清单」,会话正文有自己那条
|
|
119
|
+
* `/session/messages` 通道。而正文快照恰好是最大的一块:实测一个 200 条消息的会话
|
|
120
|
+
* 快照约 58KB,而它在每个会话事件上都会被重推一次。
|
|
121
|
+
*
|
|
122
|
+
* 所以**上隧道的那一份裁掉三块,本地快照(localSnapshot)照旧留全份**——局域网直连的
|
|
123
|
+
* 手机走 `snapshotRead` 读的就是本地那份,裁了它等于把读取隔离一起砍掉。
|
|
124
|
+
*
|
|
125
|
+
* 裁字段而不是改推送策略:宿主那侧「推什么、什么时候推」一个字都不用改,两侧版本
|
|
126
|
+
* 不必同时上线(老 worker + 新宿主也照旧工作,只是白推;新 worker + 老宿主也照旧)。
|
|
127
|
+
*/
|
|
128
|
+
function frameForTunnel(body) {
|
|
129
|
+
if (!body || typeof body !== 'object') return { kind: 'raw', data: body }
|
|
130
|
+
if (body.kind !== 'sync') return body
|
|
131
|
+
const { messages, stats, models, ...rest } = body
|
|
132
|
+
return rest
|
|
133
|
+
}
|
|
107
134
|
// /configure 只送 relay.json 那几十个字段,给 64KB 绰绰有余;放宽只会让误用更难发现。
|
|
108
135
|
const CONFIGURE_MAX_BODY = 64 * 1024
|
|
109
136
|
function readBody(req, limit) {
|
|
@@ -146,6 +173,12 @@ function upstream() { return `http://127.0.0.1:${config.upstreamPort}` }
|
|
|
146
173
|
// 读取隔离:把移动端高频读请求(sessions/workspaces/messages/stats)从本地快照直接回,
|
|
147
174
|
// 不再打扰主进程。快照缺失或路径不在清单内时返回 null → 调用方回源主进程。
|
|
148
175
|
function snapshotRead(info) {
|
|
176
|
+
// **协议 v2 的会话不走这条捷径**:这里直接回序列化好的 JSON,绕开了主进程的守卫 ——
|
|
177
|
+
// 请求信封没验、响应也没封。表现是「加密会话的高频读(列表 / 工作区 / 消息 / 统计)
|
|
178
|
+
// 全程明文过中转」,而且静默(App 收到明文只是降级,不报错)。回 null 让调用方回源
|
|
179
|
+
// 主进程,那条路有 `rev` 条件刷新,稳态只回几十字节,比这里回 38KB 还省。
|
|
180
|
+
const proto = info && info.headers ? String(info.headers['x-dsh-proto'] || info.headers['X-Dsh-Proto'] || '') : ''
|
|
181
|
+
if (proto.trim() === '2') return null
|
|
149
182
|
// 镜像只在中转连着时才会有新版本推来(原因见 mirror-snapshot.cjs 的说明):冻结的快照必须
|
|
150
183
|
// 回源主进程,否则中转一断、局域网仍健康时,新建的会话/工作区在 App 里永远不出现。
|
|
151
184
|
if (!isMirrorSnapshotFresh(localSnapshot)) return null
|
|
@@ -160,6 +193,16 @@ function snapshotRead(info) {
|
|
|
160
193
|
: null
|
|
161
194
|
if (!body || typeof body !== 'object' || body.ok !== true) return null
|
|
162
195
|
if (pathname === '/api/mobile/v1/session/messages') {
|
|
196
|
+
// **按内容裁的入口窗口(`thin=1`)不走这条捷径**:镜像里存的是一份完整窗口
|
|
197
|
+
// (最多 200 条消息),对着一轮跑过上百步的会话,它照样可能整条挤掉用户那句话 ——
|
|
198
|
+
// 那正是「进对话只剩 AI 的半轮」这个缺陷本身。裁剪是主进程按轮算的,这里回 null 让它
|
|
199
|
+
// 回源;入口那一发进对话只发一次,不是轮询。
|
|
200
|
+
if (url.searchParams.get('thin') === '1') return null
|
|
201
|
+
// 带 beforeSeq 的是**翻页**(往上翻历史 / 补取某一轮的过程明细):镜像只有尾部一份窗口,
|
|
202
|
+
// 它答不了「比这条更早」——早年这里直接回整份,调用方按 seq 自己过滤,看着能用,
|
|
203
|
+
// 但只要一轮比镜像窗口还长,补取就会在原地转三圈什么也拿不到。回源主进程。
|
|
204
|
+
const beforeSeqRaw = url.searchParams.get('beforeSeq')
|
|
205
|
+
if (beforeSeqRaw !== null && beforeSeqRaw !== '') return null
|
|
163
206
|
const afterSeqRaw = url.searchParams.get('afterSeq')
|
|
164
207
|
if (afterSeqRaw !== null && afterSeqRaw !== '' && /^\d+$/.test(afterSeqRaw)) {
|
|
165
208
|
const after = Number(afterSeqRaw)
|
|
@@ -186,7 +229,9 @@ function tunnelSignature(c) {
|
|
|
186
229
|
}
|
|
187
230
|
/** 「这份配置里哪几项决定局域网代理要重建」。 */
|
|
188
231
|
function lanSignature(c) {
|
|
189
|
-
|
|
232
|
+
// **配对码与票据不在其中**:它们由 `currentPairing` 现读,改它们不该重建监听 ——
|
|
233
|
+
// 配对成功就要换一次(§5.1),跟着重建会把刚握上的连接掐掉。
|
|
234
|
+
return JSON.stringify([Boolean(c.enabled), Boolean(c.lanEnabled), c.lanPort || 0, c.upstreamPort || 0, c.instanceId || '', c.lanSecret || ''])
|
|
190
235
|
}
|
|
191
236
|
/** 「这份配置里哪几项决定网络监视器要不要重排」。 */
|
|
192
237
|
function monitorSignature(c) {
|
|
@@ -237,7 +282,7 @@ function startTunnel() {
|
|
|
237
282
|
stopTunnel(); tunnelState = 'connecting'
|
|
238
283
|
publishState()
|
|
239
284
|
const current = new RelayTunnel({
|
|
240
|
-
relayUrl: core.tunnelWsUrl(config.relayUrl), instanceId: config.instanceId,
|
|
285
|
+
relayUrl: core.tunnelWsUrl(config.relayUrl), instanceId: config.instanceId,
|
|
241
286
|
name: config.name, upstream: upstream(),
|
|
242
287
|
registerSecret: config.registerSecret,
|
|
243
288
|
lanUrls: currentLanUrls(),
|
|
@@ -275,10 +320,22 @@ function startLan() {
|
|
|
275
320
|
stopLan(); lanState = 'connecting'
|
|
276
321
|
publishState()
|
|
277
322
|
const current = createLanProxy({
|
|
278
|
-
lanPort: config.lanPort,
|
|
323
|
+
lanPort: config.lanPort, instanceId: config.instanceId, lanSecret: config.lanSecret,
|
|
324
|
+
// 配对码与票据走取值器:配对成功之后宿主会换掉它们(§5.1),而重建局域网代理会把
|
|
325
|
+
// 刚握上的连接全掐掉 —— 换值不该动监听。
|
|
326
|
+
currentPairing: () => ({ code: config.pairingCode, ticket: config.pairingTicket || '' }),
|
|
279
327
|
upstreamPort: config.upstreamPort,
|
|
280
328
|
readSnapshot: snapshotRead,
|
|
281
329
|
onLog: (...args) => process.stderr.write(`[dsh-ai-hub sidecar] ${args.join(' ')}\n`),
|
|
330
|
+
// 配对成功之后换码 + 换票据:走宿主的控制面(那是唯一能写状态文件的地方)。
|
|
331
|
+
onPaired: () => {
|
|
332
|
+
void parentJson('/api/relay/internal/rotate-pairing', {}).catch(() => undefined)
|
|
333
|
+
},
|
|
334
|
+
// 签发交给主进程(唯一写方):旁路自己写盘会和主进程那份互相覆盖。
|
|
335
|
+
issueCredential: async (device, extra) => {
|
|
336
|
+
const result = await parentJson('/api/relay/internal/issue-credential', { device: device || {}, extra: extra || {} })
|
|
337
|
+
return result.data || result
|
|
338
|
+
},
|
|
282
339
|
onApprove: async (device) => {
|
|
283
340
|
try {
|
|
284
341
|
const result = await parentJson('/api/relay/internal/approval', { device: device || {} })
|
|
@@ -306,10 +363,11 @@ function startLan() {
|
|
|
306
363
|
}
|
|
307
364
|
async function configure(next) {
|
|
308
365
|
if (!next || typeof next !== 'object' || Array.isArray(next)) throw new Error('invalid configuration')
|
|
309
|
-
const allowed = ['enabled', 'relayUrl', 'instanceId', 'pairingCode', 'name', 'upstreamPort', 'lanPort', 'lanEnabled', 'lanSecret', 'registerSecret']
|
|
366
|
+
const allowed = ['enabled', 'relayUrl', 'instanceId', 'pairingCode', 'pairingTicket', 'name', 'upstreamPort', 'lanPort', 'lanEnabled', 'lanSecret', 'registerSecret']
|
|
310
367
|
if (Object.keys(next).some(key => !allowed.includes(key))) throw new Error('unknown configuration field')
|
|
311
368
|
if (typeof next.instanceId !== 'string' || next.instanceId.length > 128) throw new Error('invalid instanceId')
|
|
312
369
|
if (typeof next.pairingCode !== 'string' || !/^\d{6}$/.test(next.pairingCode)) throw new Error('invalid pairingCode')
|
|
370
|
+
if (next.pairingTicket !== undefined && (typeof next.pairingTicket !== 'string' || !/^[A-Za-z0-9_-]{0,64}$/.test(next.pairingTicket))) throw new Error('invalid pairingTicket')
|
|
313
371
|
if (typeof next.upstreamPort !== 'number' || !Number.isInteger(next.upstreamPort) || next.upstreamPort < 1 || next.upstreamPort > 65535) throw new Error('invalid upstreamPort')
|
|
314
372
|
if (next.lanPort !== undefined && (!Number.isInteger(next.lanPort) || next.lanPort < 1024 || next.lanPort > 65535)) throw new Error('invalid lanPort')
|
|
315
373
|
if (next.registerSecret !== undefined && (typeof next.registerSecret !== 'string' || next.registerSecret.length > 512)) throw new Error('invalid registerSecret')
|
|
@@ -333,7 +391,8 @@ async function configure(next) {
|
|
|
333
391
|
}
|
|
334
392
|
if (!tunnelNeedsRebuild && tunnel) {
|
|
335
393
|
const patch = {}
|
|
336
|
-
|
|
394
|
+
// **配对码不再进注册帧**(§5.2):中转从来没读过它(`ws.rs` 原文「不保存也不校验」),
|
|
395
|
+
// 而带上它就等于把「谁能配这台机器」的一半凭证交给链路上的第三方。
|
|
337
396
|
if (previous.name !== config.name) patch.name = config.name
|
|
338
397
|
if (lanNeedsRebuild) patch.lanUrls = currentLanUrls()
|
|
339
398
|
if (Object.keys(patch).length) tunnel.updateRegistration(patch)
|
|
@@ -365,7 +424,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
365
424
|
// 桌面→relay 主动推送(移动端镜像缓存):复用隧道立即 sendJson;同时在本地保留快照供读取隔离。
|
|
366
425
|
if (body && typeof body === 'object' && body.kind === 'sync') localSnapshot = body;
|
|
367
426
|
if (tunnel && tunnel.connected) {
|
|
368
|
-
|
|
427
|
+
// kind === 'push' 是 iOS 推送帧(pushRegister / pushSend),帧类型由 pushFrame
|
|
428
|
+
// 决定,不再一律塞进 sync——中转按帧类型分发,塞错类型等于发到黑洞。
|
|
429
|
+
const frameType = body && typeof body === 'object' && body.kind === 'push'
|
|
430
|
+
? (body.pushFrame === 'register' ? T.pushRegister : T.pushSend)
|
|
431
|
+
: T.sync;
|
|
432
|
+
tunnel.sendJson(frameType, 0, frameForTunnel(body));
|
|
369
433
|
return send(res, 202, { ok: true });
|
|
370
434
|
}
|
|
371
435
|
return send(res, 503, { ok: false, error: 'tunnel not connected' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"start": "node host.js",
|
|
45
|
-
"test": "node --test test/*.test.cjs && node test/lan-direct-e2e.cjs && node test/sidecar-facade.cjs"
|
|
45
|
+
"test": "node --test test/*.test.cjs && node test/lan-direct-e2e.cjs && node test/sidecar-facade.cjs",
|
|
46
|
+
"release:check": "node scripts/preflight-release.mjs"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
49
|
"@deepseek-ai/dsh-tool-ask-user": "0.2.1-rc.2",
|