dsh-mobile-plugin 0.1.100 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +88 -9
- package/lib/index.js +3 -2
- 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 +137 -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 +209 -11
- package/lib/mobile/host-bridge/event-synthesis.js +61 -10
- 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 +547 -0
- package/lib/mobile/session-cache.js +60 -2
- package/lib/mobile/session-file.js +133 -34
- 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 +30 -27
- package/lib/mobile/session-snapshot.js +12 -12
- 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 +382 -31
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +91 -5
- package/lib/mobile/workspaces.js +39 -8
- package/lib/panel/index.html +11 -11
- package/lib/relay/core.js +28 -2
- package/lib/relay/lan-network.cjs +47 -0
- 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 +139 -9
- package/lib/routes/mobile-api.js +92 -9
- 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 +44 -3
- package/lib/sidecar-facade-worker.cjs +70 -6
- package/lib/update-install.cjs +13 -5
- package/package.json +3 -2
package/lib/shell/updater.js
CHANGED
|
@@ -227,16 +227,20 @@ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'rolled-back'])
|
|
|
227
227
|
* 装完了但盘上版本没变(市场报成功、文件没落),这里如实返回 `installed: false`,不让调用方
|
|
228
228
|
* 以为换过代码 —— 「状态说成功、代码没换」是这个仓库吃过最贵的一次亏。
|
|
229
229
|
*/
|
|
230
|
-
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, onEvent = () => {}, packageManager = () => null, agents = () => null, beforeInstall = null, now = Date.now } = {}) {
|
|
230
|
+
export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false, auto = false, onEvent = () => {}, packageManager = () => null, agents = () => null, beforeInstall = null, now = Date.now } = {}) {
|
|
231
231
|
const before = installedVersion()
|
|
232
232
|
// 装归实现自己(2026-10-11 起):盘上那一代里有 `update-install.cjs` 就走它。判定(有没有
|
|
233
233
|
// 新版、`link:` 来源不碰、有 agent 在跑就先别换文件)也一并交给它,别在这里抄第二份 ——
|
|
234
234
|
// 外壳的自动更新与面板那颗按钮必须是同一套判定,否则会出现「手动说有新版、自动说没有」。
|
|
235
|
+
//
|
|
236
|
+
// `auto` 也透下去:自动那一轮跳过「有对话在跑就先别装」那道闸(2026-10-25 用户拍板的取舍,
|
|
237
|
+
// 见 `update-install.cjs` 文件头),但没有新版时它照样什么都不做。
|
|
235
238
|
const selfInstall = await installedInstaller()
|
|
236
239
|
if (selfInstall) {
|
|
237
240
|
const outcome = await selfInstall({
|
|
238
241
|
profileNodeModules: profileNodeModules(),
|
|
239
242
|
force,
|
|
243
|
+
auto,
|
|
240
244
|
onEvent,
|
|
241
245
|
// 宿主发布的包管理器(优先用它装)与 agents 服务(那道闸的判据)都从外壳这一层透下去:
|
|
242
246
|
// 实现在代里,它没有宿主 ctx,只有外壳有。
|
|
@@ -262,6 +266,8 @@ export async function installIfNeeded(base, { name = PACKAGE_NAME, force = false
|
|
|
262
266
|
}
|
|
263
267
|
}
|
|
264
268
|
const check = await checkUpdate(base, name, { force })
|
|
269
|
+
// 兜底这条路(盘上那一代还没有自装模块)**不认 `auto`**:市场接口只有 `force` 这一档,
|
|
270
|
+
// 而把 `force` 借给自动会连「有没有新版」一起跳过。宁可让它暂时还按老规矩在对话结束后装。
|
|
265
271
|
if (check.source !== null && !updatableSource(check.source)) {
|
|
266
272
|
// 本地 link / file: 装的:市场不会碰它(见 updatableSource)。如实返回,别去试。
|
|
267
273
|
return {
|
|
@@ -344,6 +350,7 @@ async function runUpdate({ runtime, base, hostService = () => null, updateState,
|
|
|
344
350
|
try {
|
|
345
351
|
outcome = await installIfNeeded(base, {
|
|
346
352
|
force,
|
|
353
|
+
auto,
|
|
347
354
|
beforeInstall,
|
|
348
355
|
packageManager: () => hostService('profileContext')?.packageManager ?? null,
|
|
349
356
|
agents: () => hostService('agents'),
|
|
@@ -375,18 +382,37 @@ async function runUpdate({ runtime, base, hostService = () => null, updateState,
|
|
|
375
382
|
let swapped = false
|
|
376
383
|
let swapFailure = null
|
|
377
384
|
let deferred = false
|
|
385
|
+
/**
|
|
386
|
+
* 换代之后自检没过、外壳自己退回去了。
|
|
387
|
+
*
|
|
388
|
+
* 判据与动作都在 `runtime.js`(它才知道新版到底装起来了没有),这里只把那条**已经发生
|
|
389
|
+
* 过的事实**抄进这一轮的账。手机端不按回滚 —— 装坏的后果正是 App 连不上,那时候它按不了
|
|
390
|
+
* 任何东西 —— 它只用这条记录在**下一次连上之后**如实显示「上次装失败了,已退回 X」。
|
|
391
|
+
*/
|
|
392
|
+
let rollback = null
|
|
378
393
|
if (outcome.installed === true || newerOnDisk) {
|
|
379
394
|
const attempt = await swapTo(runtime, disk ?? runningBefore, { auto, log })
|
|
380
395
|
swapped = attempt.swapped
|
|
381
396
|
swapFailure = attempt.failure
|
|
382
397
|
deferred = attempt.deferred === true
|
|
398
|
+
rollback = attempt.rollback ?? null
|
|
383
399
|
}
|
|
400
|
+
const rolledBack = Boolean(rollback && rollback.ok)
|
|
384
401
|
const lastApply = {
|
|
385
402
|
...outcome,
|
|
386
403
|
installed: outcome.installed === true,
|
|
387
404
|
swapped,
|
|
388
|
-
state:
|
|
389
|
-
|
|
405
|
+
state: rolledBack
|
|
406
|
+
? 'rolled-back'
|
|
407
|
+
: swapFailure ? 'installed-not-swapped' : (outcome.state ?? (outcome.installed || outcome.skipped ? 'succeeded' : 'failed')),
|
|
408
|
+
// 退回去了的话,真正失败的原因是自检那句话,而不是「换代码抛错」那个笼统说法。
|
|
409
|
+
failure: rolledBack
|
|
410
|
+
? { code: 'self_check_failed', message: rollback.reason }
|
|
411
|
+
: (swapFailure ?? outcome.failure ?? null),
|
|
412
|
+
/** 装上了、但新版没通过自检,于是外壳自己退回了上一代。 */
|
|
413
|
+
rolledBack,
|
|
414
|
+
/** 退回之后跑着的版本(退成功才有)。 */
|
|
415
|
+
runningAfterRollback: rolledBack ? (rollback.toVersion ?? null) : null,
|
|
390
416
|
// 换代由外壳就地做,市场那套「装完要重启」在本项目一律不照做。只为如实上报。
|
|
391
417
|
restartRequired: false,
|
|
392
418
|
refreshRequired: false,
|
|
@@ -422,14 +448,29 @@ async function swapTo(runtime, target, { auto = false, log = () => {} } = {}) {
|
|
|
422
448
|
}
|
|
423
449
|
}
|
|
424
450
|
swapAttempts.set(key, Math.max(tried, 1))
|
|
451
|
+
let rollback = null
|
|
425
452
|
try {
|
|
426
453
|
const status = await runtime.reload()
|
|
427
454
|
swapAttempts.delete(key)
|
|
455
|
+
// 外壳把「装起来了但自检没过、于是自己退回去」记在 `lastRollback` 上,而不是抛错
|
|
456
|
+
// (它退成功了,进程还活着)。这里把它捞出来,交给这一轮的账 —— 手机端要读的就是它。
|
|
457
|
+
rollback = status?.lastRollback ?? null
|
|
458
|
+
if (rollback && rollback.ok) {
|
|
459
|
+
log('warn', 'dsh-mobile-plugin: 新版自检没过,已退回 %s:%s', rollback.toVersion, rollback.reason)
|
|
460
|
+
return {
|
|
461
|
+
swapped: false,
|
|
462
|
+
rollback,
|
|
463
|
+
failure: { code: 'self_check_failed', message: rollback.reason },
|
|
464
|
+
}
|
|
465
|
+
}
|
|
428
466
|
log('info', 'dsh-mobile-plugin: 已就地换到 %s(无需重启 DSH)', status?.version ?? key)
|
|
429
467
|
return { swapped: true, status }
|
|
430
468
|
} catch (error) {
|
|
469
|
+
// 新版装载直接抛错:runtime 已经按老规矩回滚过一代了,那条回滚也记在 `lastRollback`。
|
|
470
|
+
try { rollback = runtime.status()?.lastRollback ?? null } catch { rollback = null }
|
|
431
471
|
return {
|
|
432
472
|
swapped: false,
|
|
473
|
+
rollback,
|
|
433
474
|
failure: { code: 'swap_failed', message: `新版已经装到盘上(${key}),但换代码没成:${String(error?.message || error)}` },
|
|
434
475
|
}
|
|
435
476
|
}
|
|
@@ -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/lib/update-install.cjs
CHANGED
|
@@ -43,7 +43,14 @@
|
|
|
43
43
|
*
|
|
44
44
|
* 装是**就地替换插件文件**,正在干活的对话可能读到半个包。市场那条路有一道这样的闸
|
|
45
45
|
* (`AGENTS_RUNNING`),这里也补上了 —— 判到有人跑就**不动盘**,如实报成「可再试」。
|
|
46
|
-
*
|
|
46
|
+
*
|
|
47
|
+
* 两档能跳过它:`force`(面板上的「强制更新」,用户明说「我知道,装吧」)与 `auto`(宿主
|
|
48
|
+
* 每 10 分钟那一轮自动更新)。后者是有意的取舍:手机那边几乎一直有对话在跑,按着这道闸等,
|
|
49
|
+
* 自动更新实际上永远轮不到 —— 而它的定义就是「没人看着也会发生」。
|
|
50
|
+
*
|
|
51
|
+
* `auto` **只**跳过这道闸:照样要有新版才装(不跳过下面那条判断),也不一开始就带发布时长
|
|
52
|
+
* 开关(撞上再重试一次就够了)。把 `force` 那一档借给自动会在每 10 分钟把同一个版本重装
|
|
53
|
+
* 一遍,那不是自动更新。
|
|
47
54
|
*
|
|
48
55
|
* 顺序上**先答「有没有新版」**:没东西可装时拿一句「有对话在跑」挡在前面是误导,用户会以为
|
|
49
56
|
* 是那个原因,而真正的答案是「已经是最新」。
|
|
@@ -459,13 +466,14 @@ async function installLatest({
|
|
|
459
466
|
* 把它搬到插件自己这条路上来。判到有人跑就**不动盘**,如实报成「可再试」,下个 tick 再来。
|
|
460
467
|
*
|
|
461
468
|
* `force` 是**用户明说「我知道,装吧」**的那一挡(面板上那颗「强制更新」):它同时跳过
|
|
462
|
-
*
|
|
469
|
+
* 这道闸和「新发布先晾一会儿」的门槛,而且不管有没有新版都装一遍。`auto`(宿主那一轮自动
|
|
470
|
+
* 更新)只跳过这道闸,其余照旧 —— 见文件头。
|
|
463
471
|
*/
|
|
464
472
|
async function installLatestIfNeeded(options = {}) {
|
|
465
|
-
const { profileNodeModules, force = false, name = DEFAULT_NAME, agents = null } = options;
|
|
473
|
+
const { profileNodeModules, force = false, auto = false, name = DEFAULT_NAME, agents = null } = options;
|
|
466
474
|
const beforeVersion = installedVersion(profileNodeModules, name);
|
|
467
475
|
// 来源那道门排在最前:非注册表来源连「线上最新是几」都不用问 —— 答案再新也不该装,
|
|
468
|
-
//
|
|
476
|
+
// 而且这一条同时管着手动与自动两条路(`force` / `auto` 都过不去)。
|
|
469
477
|
const source = declaredSource(path.dirname(profileNodeModules), name);
|
|
470
478
|
const gate = installAllowedFor(source);
|
|
471
479
|
if (!gate.ok) {
|
|
@@ -497,7 +505,7 @@ async function installLatestIfNeeded(options = {}) {
|
|
|
497
505
|
check,
|
|
498
506
|
};
|
|
499
507
|
}
|
|
500
|
-
if (!force) {
|
|
508
|
+
if (!force && !auto) {
|
|
501
509
|
const running = runningAgentIds(agents);
|
|
502
510
|
if (running.length > 0) {
|
|
503
511
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|