dsh-mobile-plugin 0.2.0 → 0.2.2
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 +1 -1
- package/lib/impl.js +9 -4
- package/lib/index.js +2 -1
- package/lib/mobile/credential-upgrade.js +11 -3
- package/lib/mobile/directory-listing.js +144 -0
- package/lib/mobile/history-derive.js +52 -13
- package/lib/mobile/host-bridge/event-synthesis.js +21 -9
- package/lib/mobile/monitor.js +140 -25
- package/lib/mobile/relay-control.js +13 -0
- package/lib/mobile/session-cache.js +41 -1
- package/lib/mobile/session-file.js +57 -32
- package/lib/mobile/session-last-turn.js +22 -2
- package/lib/mobile/session-send.js +10 -32
- package/lib/mobile/session-snapshot.js +5 -6
- package/lib/mobile/stream.js +281 -15
- package/lib/mobile/subagents.js +11 -2
- package/lib/mobile/uploads.js +12 -2
- package/lib/panel/index.html +1 -1
- package/lib/read-receipt.cjs +27 -7
- package/lib/relay/core.js +3 -0
- package/lib/relay/lan-network.cjs +47 -0
- package/lib/routes/control-plane.js +4 -0
- package/lib/routes/mobile-api.js +11 -3
- package/lib/shell/updater.js +8 -1
- package/lib/update-install.cjs +13 -5
- package/package.json +1 -1
package/lib/read-receipt.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* 「已读回执」台账:哪个会话在什么时候被读过,桌面端据此清掉自己的未读绿点。
|
|
5
5
|
*
|
|
6
6
|
* 背景:两端侧边栏的绿点来自**两份互不相干**的状态。
|
|
7
7
|
* - 手机端绿点 = 宿主按设备维度维护的完成提醒(lib/mobile/completion.js),
|
|
@@ -10,11 +10,16 @@
|
|
|
10
10
|
* (@deepseek-ai/dsh-client-ui-session),既不落盘也不过服务端。
|
|
11
11
|
* 所以手机上点开一个会话,桌面那份完全不知情,绿点照旧。
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
13
|
+
* 这里补的就是这条缺失的通道:只要有人在某一端读过,宿主就往本模块记一条
|
|
14
|
+
* `sessionId → readAt` 水位;插件的客户端半边(lib/client.js 的 startReadReceiptSync)
|
|
15
|
+
* 每 2 秒拉一次 `GET /api/relay/read-receipt`,对水位前进过的会话做一次瞬时
|
|
16
|
+
* `sessions.retain(id, { source: 'mainView' })` + `release()`,借桌面自己的
|
|
17
|
+
* `reconcileStatus()` 把 `completionUnread` 摘掉。
|
|
18
|
+
*
|
|
19
|
+
* 这本台账还有第二个读者:宿主自己。手机侧栏的绿点由 App 拿「上一轮什么时候结束」和
|
|
20
|
+
* 本机已读水位算出来,那一份水位(lib/mobile/session-last-turn.js 的事实地表)是进程内的,
|
|
21
|
+
* 重启就空 —— 所以 `readAtOf` 让它在重启后从这盘上补回来。两头都写同一本账,
|
|
22
|
+
* 「读过」这件事才不会随进程消失。
|
|
18
23
|
*
|
|
19
24
|
* 为什么存 readAt 而不是一个「已读」布尔:冲突策略是 last-write-wins。
|
|
20
25
|
* 桌面点开 → 客户端半边把该会话 POST 回 `/api/relay/desktop-read` → 宿主清掉
|
|
@@ -97,6 +102,21 @@ function persist(readAt) {
|
|
|
97
102
|
loaded = true;
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
/**
|
|
106
|
+
* 这条会话最近一次被读的时刻;台账里没有就是 0。
|
|
107
|
+
*
|
|
108
|
+
* 宿主自己也读这份台账:`lib/mobile/session-last-turn.js` 在进程重启后靠它把设备无关的
|
|
109
|
+
* 已读水位补回内存(那张表按 ctx 隔离、进程一换就空)。所以这本台账记的必须是「谁读过
|
|
110
|
+
* 都算」—— 手机点开与桌面点开两条路都要走到 `recordRead`。
|
|
111
|
+
*/
|
|
112
|
+
function readAtOf(sessionId) {
|
|
113
|
+
const id = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
114
|
+
if (!id) return 0;
|
|
115
|
+
const current = load();
|
|
116
|
+
const value = current ? current.readAt[id] : undefined;
|
|
117
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
100
120
|
/**
|
|
101
121
|
* 记一条已读:`sessionId` 在 `now` 被读过。返回 `{ sessionId, readAt, changed }`。
|
|
102
122
|
*
|
|
@@ -121,4 +141,4 @@ function recordRead(sessionId, now = Date.now()) {
|
|
|
121
141
|
return { sessionId: id, readAt: at, changed: true };
|
|
122
142
|
}
|
|
123
143
|
|
|
124
|
-
module.exports = { stateFile, snapshot, recordRead };
|
|
144
|
+
module.exports = { stateFile, snapshot, recordRead, readAtOf };
|
package/lib/relay/core.js
CHANGED
|
@@ -44,6 +44,9 @@ function defaultState() {
|
|
|
44
44
|
// 手机优先用哪条地址(空 = 不指定)。它是 `RemoteControlSettingsSchema` 里的一项,
|
|
45
45
|
// 写它不碰 sidecar(那份配置是 `impl.js` 的 `buildConfig` 逐字段拼的,不带它)。
|
|
46
46
|
preferredLanAddress: '',
|
|
47
|
+
// 用户手填的直连地址(空 = 没填)。与上面那条同一条口径:也是 schema 里的一项、
|
|
48
|
+
// 也不进 sidecar 配置 —— 它只影响「报给手机的候选」与二维码,不影响隧道怎么连。
|
|
49
|
+
manualLanUrl: '',
|
|
47
50
|
// 首次接入引导走完没有。**刻意不进 RemoteControlSettingsSchema**:它不是一项
|
|
48
51
|
// 「远程控制设置」,改它不该触发 sidecar 重配(那条链上每加一个字段都要同步改
|
|
49
52
|
// schema、worker 的 allowed 数组与 buildConfig,漏一处就抛 unknown field)。
|
|
@@ -344,6 +344,39 @@ function advertisedCandidates(port, interfaces = os.networkInterfaces()) {
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* 用户**自己填的**直连地址(设置项 `manualLanUrl`,通常是内网穿透域名)→ 候选。
|
|
349
|
+
*
|
|
350
|
+
* 为什么需要它:网卡扫描只能产出这台机器身上的 IP,域名一条都扫不出来。手机在「网络」页
|
|
351
|
+
* 手填的地址从前只活在手机自己那份会话里 —— 重启、换网、重新配对之后全没了,而且只要同网
|
|
352
|
+
* 还有一条扫出来的 IP 更快,手填的那条还会被顶掉。放进宿主配置之后,候选的唯一真源就回到
|
|
353
|
+
* 宿主:手机每次核对连接信息都重新拿到它,二维码里也带着它。
|
|
354
|
+
*
|
|
355
|
+
* 只挡明显不能用的:空、非 http(s)、解析不出主机。不校验端口、不要求是 IP —— 穿透域名后面
|
|
356
|
+
* 接的是什么由反代决定,宿主无从判断也没必要判断。
|
|
357
|
+
*/
|
|
358
|
+
function manualCandidate(value) {
|
|
359
|
+
const url = typeof value === 'string' ? value.trim().replace(/\/+$/, '') : ''
|
|
360
|
+
if (!url || !/^https?:\/\/[^\s/]+/i.test(url)) return null
|
|
361
|
+
let host = ''
|
|
362
|
+
try { host = new URL(url).hostname } catch { return null }
|
|
363
|
+
if (!host) return null
|
|
364
|
+
// `address` 与网卡候选同形(面板按裸地址显示、`orderCandidates` 按地址匹配)。
|
|
365
|
+
return { url, address: host, interface: 'manual', family: 'IPv4' }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 候选 = 用户填的那条(有的话)在前 + 其余照旧。按 url 去重,**不删**任何一条 NIC 候选:
|
|
370
|
+
* 穿透域名不通时手机还得能顺着网卡地址摸回这台机器。值不合法时原样返回 NIC 候选,
|
|
371
|
+
* 不让一条坏配置把整份候选清空(那正是彻底失联的形状)。
|
|
372
|
+
*/
|
|
373
|
+
function withManualCandidate(list, value) {
|
|
374
|
+
const manual = manualCandidate(value)
|
|
375
|
+
const rest = Array.isArray(list) ? list : []
|
|
376
|
+
if (!manual) return rest
|
|
377
|
+
return [manual, ...rest.filter(item => item && item.url !== manual.url)]
|
|
378
|
+
}
|
|
379
|
+
|
|
347
380
|
/**
|
|
348
381
|
* 把用户选中的那条排到最前面(面板上的「手机优先用这条」,存在设置项 `preferredLanAddress`)。
|
|
349
382
|
*
|
|
@@ -371,6 +404,17 @@ function orderCandidates(list, preferred) {
|
|
|
371
404
|
return ordered
|
|
372
405
|
}
|
|
373
406
|
|
|
407
|
+
/**
|
|
408
|
+
* 报给手机的直连候选 —— 二维码、配对响应与 `/connection-info` 三处**只许走这一份**。
|
|
409
|
+
*
|
|
410
|
+
* 两步顺序不能反:先让「手机优先用这条」调网卡候选的顺序,再把用户手填的地址盖到最前
|
|
411
|
+
* (那条是用户在手机上手打的,`preferredLanAddress` 只是面板上对网卡地址的选择,压不过它)。
|
|
412
|
+
* 三处各写一遍的结果就是「画面上说这条、手机去试另一条」。
|
|
413
|
+
*/
|
|
414
|
+
function phoneCandidates(list, options = {}) {
|
|
415
|
+
return withManualCandidate(orderCandidates(list, options.preferred || ''), options.manual || '')
|
|
416
|
+
}
|
|
417
|
+
|
|
374
418
|
module.exports = {
|
|
375
419
|
ipv4Number,
|
|
376
420
|
ipv6Number,
|
|
@@ -391,5 +435,8 @@ module.exports = {
|
|
|
391
435
|
ipv6Urls,
|
|
392
436
|
ipv6Candidates,
|
|
393
437
|
advertisedCandidates,
|
|
438
|
+
manualCandidate,
|
|
439
|
+
withManualCandidate,
|
|
394
440
|
orderCandidates,
|
|
441
|
+
phoneCandidates,
|
|
395
442
|
}
|
|
@@ -665,6 +665,10 @@ function registerControlPlaneRoutes(deps) {
|
|
|
665
665
|
if (typeof body?.preferredLanAddress === 'string' && body.preferredLanAddress.length <= 64) {
|
|
666
666
|
patch.preferredLanAddress = body.preferredLanAddress.trim()
|
|
667
667
|
}
|
|
668
|
+
// 用户手填的直连地址(内网穿透域名 / 公网 IP)。空串合法 = 清掉,理由同上。
|
|
669
|
+
if (typeof body?.manualLanUrl === 'string' && body.manualLanUrl.length <= 200) {
|
|
670
|
+
patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '')
|
|
671
|
+
}
|
|
668
672
|
await settings.update(patch)
|
|
669
673
|
await syncSidecar().catch(() => undefined)
|
|
670
674
|
sendJson(res, 200, status())
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
const { sendJson } = require('../http/routing.js');
|
|
23
|
-
const { orderCandidates } = require('../relay/lan-network.cjs');
|
|
23
|
+
const { orderCandidates, withManualCandidate } = require('../relay/lan-network.cjs');
|
|
24
24
|
|
|
25
25
|
const sessions = require('../mobile/sessions.js');
|
|
26
26
|
const sessionModelOptions = require('../mobile/session-model-options.js');
|
|
@@ -32,6 +32,7 @@ const events = require('../mobile/events.js');
|
|
|
32
32
|
const questions = require('../mobile/questions.js');
|
|
33
33
|
const approvals = require('../mobile/approvals.js');
|
|
34
34
|
const workspaces = require('../mobile/workspaces.js');
|
|
35
|
+
const directoryListing = require('../mobile/directory-listing.js');
|
|
35
36
|
const archiveClean = require('../mobile/archive-clean.js');
|
|
36
37
|
const sessionLog = require('../mobile/session-log.js');
|
|
37
38
|
const hostSettings = require('../mobile/host-settings.js');
|
|
@@ -183,7 +184,8 @@ function registerMobileRoutes(deps) {
|
|
|
183
184
|
// 旧会话升级成 T1(§3.3):新 App 每次启动幂等调一次。判据、密钥归属与「为什么密钥
|
|
184
185
|
// 只能明文上行一次」都在 mobile/credential-upgrade.js 的文件头里。
|
|
185
186
|
route('/api/mobile/v1/device-credential/upgrade', async (_ctx, req, res) => {
|
|
186
|
-
|
|
187
|
+
// 必须 await:这条路要现读请求体(明文老会话的字节还挂在 socket 上,见 handler 文件头)。
|
|
188
|
+
await handleCredentialUpgrade(req, res, { instanceId: String(state.instanceId || ''), store: credentialsEntry() })
|
|
187
189
|
})
|
|
188
190
|
|
|
189
191
|
route('/api/mobile/v1/connection-info', async (_ctx, _req, res) => {
|
|
@@ -192,8 +194,10 @@ function registerMobileRoutes(deps) {
|
|
|
192
194
|
const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort
|
|
193
195
|
// 用户在面板上选过的「手机优先用这条」(设置项 `preferredLanAddress`)在这里同样兑现:
|
|
194
196
|
// 手机是按顺序挨个试的,所以只挪顺序、不删候选。选的那条不在了就退回宿主排序。
|
|
197
|
+
// 手填的直连地址再盖到最前面:那是用户明确指定的那条,`preferredLanAddress` 只调网卡候选
|
|
198
|
+
// 的顺序,不该把它压到后面。
|
|
195
199
|
const candidates = state.lanEnabled
|
|
196
|
-
? orderCandidates(safeLanCandidates(port), state.preferredLanAddress)
|
|
200
|
+
? withManualCandidate(orderCandidates(safeLanCandidates(port), state.preferredLanAddress), state.manualLanUrl)
|
|
197
201
|
: []
|
|
198
202
|
// IPv6 直连候选:单独一份、**不参与**上面的排序 —— 它和中转谁更快只有手机实测得出,
|
|
199
203
|
// 宿主无从判断对端走哪条路由。与局域网那份同一个开关管(那条监听挂在同一个代理上),
|
|
@@ -274,6 +278,10 @@ function registerMobileRoutes(deps) {
|
|
|
274
278
|
// 「宿主最近收到的快照」;从未收到过时返回 view: null,App 按桌面的折叠缺省处理。
|
|
275
279
|
route('/api/mobile/v1/desktop-view', async (_ctx, _req, res) => sendJson(res, 200, { ok: true, view: desktopView.snapshot() }))
|
|
276
280
|
route('/api/mobile/v1/directory/list', workspaces.handleListDirectory)
|
|
281
|
+
// 右栏文件浏览器要的那一份:文件与文件夹都列、带类型/大小/修改时间,见
|
|
282
|
+
// mobile/directory-listing.js。与上面那条**并存**:那条是「挑工作区目录」用的,只列
|
|
283
|
+
// 能进去的目录,行为不变。
|
|
284
|
+
route('/api/mobile/v1/directory/listing', directoryListing.handleDirectoryListing)
|
|
277
285
|
route('/api/mobile/v1/directory/create', workspaces.handleCreateDirectory)
|
|
278
286
|
route('/api/mobile/v1/workspace/create', workspaces.handleCreateWorkspace)
|
|
279
287
|
route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
|
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'),
|
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.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|