dsh-mobile-plugin 0.2.1 → 0.3.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/dsh-mobile-credentials/lib/credentials.js +14 -1
- package/lib/impl.js +2 -2
- package/lib/mobile/credential-upgrade.js +24 -0
- package/lib/mobile/directory-listing.js +144 -0
- package/lib/mobile/monitor.js +140 -25
- package/lib/mobile/session-last-turn.js +22 -2
- package/lib/mobile/session-send.js +1 -8
- package/lib/panel/index.html +11 -11
- package/lib/read-receipt.cjs +27 -7
- package/lib/routes/mobile-api.js +5 -0
- package/lib/shared/device-headers.js +64 -0
- 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/routes/mobile-api.js
CHANGED
|
@@ -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');
|
|
@@ -277,6 +278,10 @@ function registerMobileRoutes(deps) {
|
|
|
277
278
|
// 「宿主最近收到的快照」;从未收到过时返回 view: null,App 按桌面的折叠缺省处理。
|
|
278
279
|
route('/api/mobile/v1/desktop-view', async (_ctx, _req, res) => sendJson(res, 200, { ok: true, view: desktopView.snapshot() }))
|
|
279
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)
|
|
280
285
|
route('/api/mobile/v1/directory/create', workspaces.handleCreateDirectory)
|
|
281
286
|
route('/api/mobile/v1/workspace/create', workspaces.handleCreateWorkspace)
|
|
282
287
|
route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 手机每次请求顺带上来的身份:设备名、平台、系统版本、App 版本。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么要有这几个头
|
|
7
|
+
*
|
|
8
|
+
* 这几格以前**只在配对那一刻写一次**(见 `dsh-mobile-credentials/lib/credentials.js` 的
|
|
9
|
+
* `deviceMeta`),之后谁都不碰。后果是三件事一起出现(2026-10-26 用户报的就是它们):
|
|
10
|
+
*
|
|
11
|
+
* 1. **App 升级之后面板还报配对时的旧版本** —— 那一格永远不变;
|
|
12
|
+
* 2. **旧会话升级进来的记录没有名字** —— 面板只能画「未知设备」;
|
|
13
|
+
* 3. 面板把「加密」读成「已是最新」,其实那只说明这枚凭据带设备密钥。
|
|
14
|
+
*
|
|
15
|
+
* 身份属于「每一次请求都顺手知道」的信息,不该是一张一次性的照片。现在手机在统一的鉴权头
|
|
16
|
+
* 里带上这四样,宿主在鉴权通过那一刻(`lib/impl.js` 的 `onAuthorized`)把它们写回凭据记录。
|
|
17
|
+
*
|
|
18
|
+
* ## 口径
|
|
19
|
+
*
|
|
20
|
+
* - **缺一个就少写一个**:老版本 App 根本不带这些头,那时必须保持记录里原来的值,
|
|
21
|
+
* 绝不能把已经有的名字冲成空串(那会把一个好好的设备变成「未知设备」)。
|
|
22
|
+
* - 头的值是手机自报的,长度交给凭据库的 `truncate` 截断,这里不重复做校验。
|
|
23
|
+
* - 名字是**用户自己起的**,不参与任何判断,只用于显示。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 手机自报身份用的四个头(App 侧在 `app/src/services/viewmodels/authHeaders.ts` 里写)。 */
|
|
27
|
+
const DEVICE_NAME_HEADER = 'x-dsh-device-name';
|
|
28
|
+
const PLATFORM_HEADER = 'x-dsh-platform';
|
|
29
|
+
const OS_VERSION_HEADER = 'x-dsh-os-version';
|
|
30
|
+
const APP_VERSION_HEADER = 'x-dsh-app-version';
|
|
31
|
+
|
|
32
|
+
/** 一个头拿成字符串(重复头会是数组,只认第一个)。 */
|
|
33
|
+
function headerText(headers, name) {
|
|
34
|
+
const value = headers && headers[name];
|
|
35
|
+
if (typeof value === 'string') return value.trim();
|
|
36
|
+
if (Array.isArray(value) && typeof value[0] === 'string') return value[0].trim();
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 这次请求带上来的身份。**没带的格子不出现在返回对象里**(不是空串)——
|
|
42
|
+
* 凭据库那边按「有哪几格就写哪几格」合并,空串会变成「把名字清掉」。
|
|
43
|
+
*/
|
|
44
|
+
function deviceMetaFromHeaders(req) {
|
|
45
|
+
const headers = req?.headers || {};
|
|
46
|
+
const meta = {};
|
|
47
|
+
const name = headerText(headers, DEVICE_NAME_HEADER);
|
|
48
|
+
if (name) meta.name = name;
|
|
49
|
+
const platform = headerText(headers, PLATFORM_HEADER);
|
|
50
|
+
if (platform) meta.platform = platform;
|
|
51
|
+
const osVersion = headerText(headers, OS_VERSION_HEADER);
|
|
52
|
+
if (osVersion) meta.osVersion = osVersion;
|
|
53
|
+
const appVersion = headerText(headers, APP_VERSION_HEADER);
|
|
54
|
+
if (appVersion) meta.appVersion = appVersion;
|
|
55
|
+
return meta;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
APP_VERSION_HEADER,
|
|
60
|
+
DEVICE_NAME_HEADER,
|
|
61
|
+
OS_VERSION_HEADER,
|
|
62
|
+
PLATFORM_HEADER,
|
|
63
|
+
deviceMetaFromHeaders,
|
|
64
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|