dsh-mobile-plugin 0.3.1 → 0.4.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/config/agent-presets/standard/agent.cordis.yml +7 -5
- package/dsh-mobile-credentials/lib/credentials.js +8 -8
- package/host.js +108 -39
- package/lib/client.js +29 -82
- package/lib/desktop-view.cjs +5 -10
- package/lib/git/read.js +6 -8
- package/lib/host-compat.cjs +120 -0
- package/lib/host-guard.cjs +9 -39
- package/lib/host-patches.cjs +5 -16
- package/lib/http/routing.js +7 -27
- package/lib/impl.js +6 -8
- package/lib/mirror/pusher.cjs +33 -85
- package/lib/mirror-snapshot.cjs +6 -24
- package/lib/mobile/agent-presets.js +6 -26
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +10 -19
- package/lib/mobile/attachments.js +11 -36
- package/lib/mobile/channels.js +15 -41
- package/lib/mobile/credential-upgrade.js +7 -16
- package/lib/mobile/credentials.js +5 -21
- package/lib/mobile/directory-browse.js +8 -27
- package/lib/mobile/directory-listing.js +10 -23
- package/lib/mobile/drive-roots.js +59 -0
- package/lib/mobile/envelope.js +4 -15
- package/lib/mobile/events.js +35 -65
- package/lib/mobile/extensions.js +5 -8
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +23 -41
- package/lib/mobile/host-bridge/endpoints.js +4 -6
- package/lib/mobile/host-bridge/event-synthesis.js +21 -35
- package/lib/mobile/host-bridge/shims.js +12 -22
- package/lib/mobile/host-bridge.js +8 -20
- package/lib/mobile/host-settings.js +9 -19
- package/lib/mobile/inbox-clean.js +7 -24
- package/lib/mobile/local-images.js +11 -17
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-self.js +5 -22
- package/lib/mobile/plugin-update.js +8 -27
- package/lib/mobile/plugins.js +6 -24
- package/lib/mobile/questions.js +15 -40
- package/lib/mobile/references.js +11 -31
- package/lib/mobile/relay-control.js +6 -27
- package/lib/mobile/rpc.js +5 -13
- package/lib/mobile/schedule.js +8 -33
- package/lib/mobile/self-check.cjs +9 -36
- package/lib/mobile/session-cache.js +13 -19
- package/lib/mobile/session-file.js +8 -21
- package/lib/mobile/session-flags.js +7 -13
- package/lib/mobile/session-mirror.js +8 -15
- package/lib/mobile/session-models.js +3 -3
- package/lib/mobile/session-schedule-mark.js +8 -14
- package/lib/mobile/session-send.js +13 -21
- package/lib/mobile/session-snapshot.js +4 -6
- package/lib/mobile/sessions.js +7 -23
- package/lib/mobile/stats-collect.cjs +10 -24
- package/lib/mobile/stats-ledger.cjs +13 -20
- package/lib/mobile/stream.js +12 -23
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +8 -17
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +6 -13
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/routes/control-plane.js +28 -45
- package/lib/routes/mobile-api.js +19 -32
- package/lib/session-cleanup.cjs +16 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +1 -1
- package/lib/shared/e2ee.js +8 -14
- package/lib/shared/fresh-request.js +6 -22
- package/lib/shared/http-util.js +3 -4
- package/lib/shared/list-revision.js +5 -14
- package/lib/shared/session-signal.js +7 -16
- package/lib/shared/sse-write.js +14 -20
- package/lib/shared/workspace-guard.js +7 -15
- package/lib/shell/device-ota.js +4 -6
- package/lib/shell/manager.js +12 -27
- package/lib/shell/ota-package.js +7 -12
- package/lib/shell/runtime.js +8 -16
- package/lib/shell/self-check-after-swap.js +9 -23
- package/lib/shell/updater.js +13 -26
- package/lib/sidecar-facade-worker.cjs +20 -33
- package/lib/sidecar-facade.cjs +7 -7
- package/lib/sidecar-lifecycle.cjs +7 -9
- package/lib/update-install.cjs +14 -59
- package/lib/update-registry.cjs +6 -18
- package/package.json +8 -5
|
@@ -166,9 +166,10 @@
|
|
|
166
166
|
# the `user-questions/request` hook (0.2.x shape) and answers it — only the
|
|
167
167
|
# tool row was missing.
|
|
168
168
|
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
169
|
+
# 这里的 `name` 是**占位**:无头宿主启动时会把同代那一份的入口绝对路径写进
|
|
170
|
+
# 一份副本(`host.js` 的 syncPresetDir),挂载的是运行时自己的那一代。这个包的
|
|
171
|
+
# peerDependencies 逐版本紧咬,对不上时 `dsh-app-boot` 会**整行禁用**(不是降级),
|
|
172
|
+
# 而钉死哪一代都不可能覆盖每台机器上装的 DSH —— 判据交给运行期,别在这里写死。
|
|
172
173
|
- id: tool-ask-user
|
|
173
174
|
name: '@deepseek-ai/dsh-tool-ask-user'
|
|
174
175
|
# `present` 是模型**显式声明交付文件**的那一行。桌面 `standard` preset 里一直有它
|
|
@@ -180,7 +181,8 @@
|
|
|
180
181
|
# `deliverables/presented` 事件追加进会话日志。卡片该不该出现,由**模型叫不叫这个工具**
|
|
181
182
|
# 决定,不由「有没有文件被改」决定 —— 这是桌面的口径。
|
|
182
183
|
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
184
|
+
# 版本同样由运行期决定(见上面 tool-ask-user 那段):0.2.1 的 `present` 要
|
|
185
|
+
# `dsh-agent` 恰好是同一代,对不上时 `dsh-app-boot` 会整行禁用,模型只会说
|
|
186
|
+
# 「没有这个工具」。`name` 是占位,宿主启动时换成本次运行时的入口。
|
|
185
187
|
- id: tool-present
|
|
186
188
|
name: '@deepseek-ai/dsh-tool-present'
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 每设备凭据库:签发、校验、吊销手机端的持久化凭据。
|
|
5
5
|
*
|
|
6
|
-
* **凭据本身就是 Bearer
|
|
6
|
+
* **凭据本身就是 Bearer**。配对签发一枚
|
|
7
7
|
* `<instanceId>.<exp>.<random>`,同一枚同时充当 `Authorization: Bearer`、`dsh_lan` cookie
|
|
8
8
|
* 与 `X-Dsh-Device-Credential`。三段形状只为兼容老 App「实例号缺失时从第一段补」那条逻辑;
|
|
9
9
|
* 服务端**只按整串的 sha256 查库**,不看前缀、不解析三段、也不信里面的 `exp`
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 验签必须持有密钥本身,所以它按 base64url 原文落盘 —— 「整库只有哈希」这句话在它身上不成立。
|
|
14
14
|
* `hostToken` 只为旧的 HMAC 记录保留:新记录一律留空,代理直接转发请求里出示的那一枚。
|
|
15
15
|
*
|
|
16
|
-
*
|
|
16
|
+
* 写入只有一个方向:宿主主进程是唯一写方;旁路子进程拿的是只读副本,靠文件
|
|
17
17
|
* mtime/size 变了就重载来跟住变化,签发与吊销走 IPC 交给主进程(`lib/impl.js`)。
|
|
18
18
|
*/
|
|
19
19
|
const crypto = require('node:crypto');
|
|
@@ -24,7 +24,7 @@ const os = require('node:os');
|
|
|
24
24
|
const FILE = process.env.DEVICE_CREDENTIALS_FILE || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'relay-device-credentials.json');
|
|
25
25
|
/** 默认有效期一年。`issue(..., ttlMs)` 可覆盖;`ttlMs <= 0` 表示不过期。 */
|
|
26
26
|
const DEFAULT_TTL_MS = 365 * 24 * 3600 * 1000;
|
|
27
|
-
/**
|
|
27
|
+
/** 已吊销或过期超过这么久的记录,下次写入时整条删掉:别让每条请求扫一整年的账。 */
|
|
28
28
|
const PRUNE_GRACE_MS = 30 * 24 * 3600 * 1000;
|
|
29
29
|
/** 剩余有效期超过这个比例就不续期:滑动续期只在过掉一半之后发生。 */
|
|
30
30
|
const RENEW_BELOW_RATIO = 0.5;
|
|
@@ -142,7 +142,7 @@ function deviceMeta(device) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
|
-
*
|
|
145
|
+
* 已吊销、或过期超过宽限期的整条删掉。
|
|
146
146
|
*
|
|
147
147
|
* 旧库只追加、吊销只打标记,今天不疼是因为「查不到就回退验 HMAC」把问题掩盖成了可用;
|
|
148
148
|
* 查库优先之后,每条请求都要扫整张表,一年下来几千条就是每条请求扫几千次。
|
|
@@ -257,7 +257,7 @@ function findByKeyId(instanceId, keyId) {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
*
|
|
260
|
+
* 记一次「这台设备刚连上过」。
|
|
261
261
|
*
|
|
262
262
|
* 内存里每次都更新(面板 3 秒轮询要的是当下的值),落盘按全局 1 分钟节流:每条请求都写一次
|
|
263
263
|
* 盘,是拿磁盘换一个只用来显示「几分钟前」的字段。`issue` / `revoke` 立刻写,不受它影响。
|
|
@@ -269,7 +269,7 @@ function touch(record, info = {}) {
|
|
|
269
269
|
record.lastSeenAt = now;
|
|
270
270
|
const channel = typeof info.channel === 'string' ? info.channel : '';
|
|
271
271
|
if (channel) record.lastChannel = channel.slice(0, FIELD_MAX);
|
|
272
|
-
//
|
|
272
|
+
// 身份那几格:名字 / 平台 / 系统版本 / App 版本由**每一次请求**带上来,
|
|
273
273
|
// 顺手刷新。它们以前只在配对那一刻写一次,于是「手机升到新版了、面板还报配对时的旧版本」,
|
|
274
274
|
// 而旧会话升级写进来的记录干脆什么身份都没有(面板只能画「未知设备」)。
|
|
275
275
|
// **只写这次真带了值的格子**:老版本 App 不带头,绝不能把已经有的名字冲成空。
|
|
@@ -290,7 +290,7 @@ function touch(record, info = {}) {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
|
-
*
|
|
293
|
+
* 滑动续期:剩余不到一半就把 `expiresAt` 后推一个新 TTL。
|
|
294
294
|
*
|
|
295
295
|
* 不需要新协议 —— 服务端本来就以记录为准、不验 token 里的 `exp`,所以续期就是改这条记录。
|
|
296
296
|
* **不能改 token 本身**:库按整串哈希查,改一个字节手机上那枚就对不上了。
|
|
@@ -362,7 +362,7 @@ function listActive() {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
/**
|
|
365
|
-
*
|
|
365
|
+
* 面板要的那份设备清单。
|
|
366
366
|
*
|
|
367
367
|
* 按 `deviceId` 聚成一行:同一台手机重配几次不该在列表里重复出现。名字与平台取最新一条
|
|
368
368
|
* **有值的**记录(老记录这些字段是空的,空的不能覆盖有值的),绑定时间取最早,最近连接取
|
package/host.js
CHANGED
|
@@ -2,33 +2,35 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Headless Cordis Host for the DSH mobile app.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* it boots the same upstream Cordis Host, composes the shipped DSH Web
|
|
8
|
-
* bundle, and adds one plugin — the mobile API at `/api/mobile/v1`.
|
|
5
|
+
* Boots the upstream Cordis Host, composes the shipped DSH Web bundle, and adds
|
|
6
|
+
* one plugin — the mobile API at `/api/mobile/v1`.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* package's `cordis.patch.yml`.
|
|
8
|
+
* `@deepseek-ai/dsh-app-boot#boot` mounts a `cordis.yml` entry list, so the root
|
|
9
|
+
* list stays empty and every plugin comes from a patch layer: the profile's
|
|
10
|
+
* bundle layers (dsh-base then dsh-web-app), the profile's own
|
|
11
|
+
* `cordis.patch.yml`, and finally this package's `cordis.patch.yml`.
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
|
-
import { existsSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { createRequire } from 'node:module'
|
|
18
16
|
import { dirname, join } from 'node:path'
|
|
19
17
|
import { fileURLToPath } from 'node:url'
|
|
20
18
|
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
|
|
21
19
|
import { installProcessGuard } from './lib/host-guard.cjs'
|
|
20
|
+
import { DIRECTORY_PICKER_ROW, TOOL_ROWS, pinInsertedRows, syncPresetDir } from './lib/host-compat.cjs'
|
|
22
21
|
import { dropBareNameSelfMounts } from './lib/host-patches.cjs'
|
|
23
22
|
import {
|
|
24
23
|
assertEntriesActivated,
|
|
25
24
|
boot,
|
|
25
|
+
evaluatePluginCompatibility,
|
|
26
|
+
getDshRuntimeVersion,
|
|
26
27
|
initProfile,
|
|
27
28
|
installFailLoud,
|
|
28
29
|
loadLayeredEnv,
|
|
29
30
|
loadOverlayPatches,
|
|
30
31
|
loadProfile,
|
|
31
32
|
PROFILE_TEMPLATES,
|
|
33
|
+
readProfileCompatibility,
|
|
32
34
|
resolveProfileDir,
|
|
33
35
|
} from '@deepseek-ai/dsh-app-boot'
|
|
34
36
|
|
|
@@ -62,24 +64,19 @@ try {
|
|
|
62
64
|
/**
|
|
63
65
|
* 本插件是**装在这个 profile 里**的,还是「源码树直接跑」的?
|
|
64
66
|
*
|
|
65
|
-
*
|
|
67
|
+
* 两种现场挂的不是同一份入口:
|
|
68
|
+
* - **装在 profile 里**(桌面端)→ 挂 `<profile>/node_modules/dsh-mobile-plugin/lib/index.js`,
|
|
69
|
+
* 即**稳定外壳**:查更新、装新版、就地换代都在它手里,面板上才有「检查 / 立即更新」。
|
|
70
|
+
* - **源码树直接跑**(无头部署、开发时 `node host.js`)→ 没有任何人把它放进 `node_modules`,
|
|
71
|
+
* 于是挂随包发布的 `lib/impl.js`。代价是**没有外壳**,那台只能同步源码 + 重启宿主。
|
|
66
72
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* `lib/impl.js`(真正的实现)。代价是**没有外壳** → 那台的面板里更新不了,只能同步
|
|
73
|
-
* 源码 + 重启宿主。
|
|
73
|
+
* **两处都走绝对路径而不是包名**:`cordis.patch.yml` 里那行按包名挂载是给桌面端用的,
|
|
74
|
+
* 无头宿主这边解析不到 —— loader 从**它自己所在的位置**解析包名,而它在宿主自己的依赖树里,
|
|
75
|
+
* 看不见 profile 里装的那份。loader 对解析失败不是留一条警告,是**整棵插件树加载失败**,
|
|
76
|
+
* 所以那行在这里一律摘掉,由这里按绝对路径插一行。装没装 profile 决定的是**挂哪一份**,
|
|
77
|
+
* 不是挂不挂。
|
|
74
78
|
*
|
|
75
|
-
*
|
|
76
|
-
* 无头宿主这边解析不到 —— loader 是从**它自己所在的位置**解析包名的(实测报错原文:
|
|
77
|
-
* `Cannot find package 'dsh-mobile-plugin' imported from <…>/@deepseek-ai/cordis-plugin-loader/lib/index.js`),
|
|
78
|
-
* 而它在宿主自己的依赖树里,profile 里装的那份它根本看不见。而 loader 对解析失败不是留一条
|
|
79
|
-
* 警告,是**整棵插件树加载失败**(服务器那台当年就是这么完全起不来的)。所以那行在无头宿主
|
|
80
|
-
* 里一律摘掉,由这里按绝对路径插一行 —— 装没装 profile 决定的是**挂哪一份**,不是挂不挂。
|
|
81
|
-
*
|
|
82
|
-
* 判据取「profile 里有没有这个包」,不看环境变量、不看目录名:装没装是事实,改不了口径。
|
|
79
|
+
* 判据取「profile 里有没有这个包」,不看环境变量、不看目录名:装没装是事实。
|
|
83
80
|
*/
|
|
84
81
|
const installedDir = join(profileDir, 'node_modules', BIN_NAME)
|
|
85
82
|
const selfInstalled = existsSync(join(installedDir, 'package.json'))
|
|
@@ -90,6 +87,82 @@ try {
|
|
|
90
87
|
const rootConfig = join(profileDir, 'cordis.root.yml')
|
|
91
88
|
writeFileSync(rootConfig, '[]\n')
|
|
92
89
|
|
|
90
|
+
/**
|
|
91
|
+
* 官方工具行跟着**运行时**换代,本包不再自带一份钉死的副本。
|
|
92
|
+
*
|
|
93
|
+
* 这三个包(提问工具 / 交付文件工具 / 目录浏览后端)互相之间用精确版本咬死,
|
|
94
|
+
* 而装载前的预检对不上的处理不是降级、是**整行禁用** —— 现象是手机上
|
|
95
|
+
* 「模型说没有这个工具」「添加工作区列不出目录」,桌面上却一切正常。钉在哪一代
|
|
96
|
+
* 都不对:官方每抬一次代,钉死的副本就失效一次,而一台机器上装的 DSH 是哪一代
|
|
97
|
+
* 我们发版时并不知道。
|
|
98
|
+
*
|
|
99
|
+
* 判据直接用 `dsh-app-boot` 自己导出的 `evaluatePluginCompatibility`,与装载前那次
|
|
100
|
+
* 预检**同源**:自己写一套 semver 只会让两边分叉,这里放过的行照样被预检禁掉。
|
|
101
|
+
* 先问运行时那棵树,再问本包自带的那份,都不行就返回 null —— 那一行保持原样,
|
|
102
|
+
* 交给官方预检去禁用并打印原因,等于退回旧行为,不会更差。
|
|
103
|
+
*/
|
|
104
|
+
let runtimeRequire = null
|
|
105
|
+
try {
|
|
106
|
+
runtimeRequire = createRequire(new URL(import.meta.resolve('@deepseek-ai/dsh-app-boot')))
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// 定位不到不是致命:下面每一行都会退回本包自带的那份,最差回归旧行为。
|
|
109
|
+
process.stderr.write(`${BIN_NAME}: 定位不到运行时那棵依赖树(${error?.message || error}),这一轮只按本包自带的解析\n`)
|
|
110
|
+
}
|
|
111
|
+
const bundledRequire = createRequire(import.meta.url)
|
|
112
|
+
const runtimeVersion = getDshRuntimeVersion()
|
|
113
|
+
let exemptions = {}
|
|
114
|
+
try {
|
|
115
|
+
exemptions = readProfileCompatibility(profileDir).exemptions
|
|
116
|
+
} catch (error) {
|
|
117
|
+
process.stderr.write(`${BIN_NAME}: 读不了 profile 的兼容性豁免文件,这一轮按无豁免处理:${error?.message || error}\n`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resolveGeneration = (scope, require, name) => {
|
|
121
|
+
if (!require) return null
|
|
122
|
+
try {
|
|
123
|
+
const manifestPath = require.resolve(`${name}/package.json`)
|
|
124
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
125
|
+
const issue = evaluatePluginCompatibility(manifest, exemptions, runtimeVersion)
|
|
126
|
+
if (issue !== undefined && !issue.exempted) return null
|
|
127
|
+
return { scope, name, version: manifest.version, entry: require.resolve(name) }
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pinnedRows = new Map()
|
|
134
|
+
for (const { id, name } of [...TOOL_ROWS, DIRECTORY_PICKER_ROW]) {
|
|
135
|
+
const hit = resolveGeneration('运行时', runtimeRequire, name)
|
|
136
|
+
?? resolveGeneration('本包自带', bundledRequire, name)
|
|
137
|
+
if (hit) pinnedRows.set(id, hit)
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
hit
|
|
140
|
+
? `${BIN_NAME}: ${name} → 用${hit.scope}的 ${hit.version}\n`
|
|
141
|
+
: `${BIN_NAME}: ${name} 没解析到与 dsh ${runtimeVersion} 同代的一份,这一行维持原样(装载预检会说明它为什么被禁用)\n`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 预设里的两行默认按裸名写:Loader 从它自己的位置解析,命中哪一份不由我们决定。
|
|
146
|
+
// 解析到了同代的那份就改写一份副本给它,路径写死成那一份的入口。
|
|
147
|
+
const presetRoots = {}
|
|
148
|
+
for (const { id } of TOOL_ROWS) {
|
|
149
|
+
const hit = pinnedRows.get(id)
|
|
150
|
+
if (hit) presetRoots[id] = hit.entry
|
|
151
|
+
}
|
|
152
|
+
const presetDir = TOOL_ROWS.some(({ id }) => presetRoots[id] !== undefined)
|
|
153
|
+
? syncPresetDir(
|
|
154
|
+
join(PACKAGE_DIR, 'config', 'agent-presets'),
|
|
155
|
+
join(profileDir, '.mobile-agent-presets'),
|
|
156
|
+
presetRoots,
|
|
157
|
+
)
|
|
158
|
+
: join(PACKAGE_DIR, 'config', 'agent-presets')
|
|
159
|
+
|
|
160
|
+
// patch 层里的 insert 行按包名写(`cordis.mobile-host.yml` 的目录浏览器),
|
|
161
|
+
// 同样换成解析到的那份入口。只改 insert:patch 里其它形态的 name 是断言,
|
|
162
|
+
// 改它会让整条 patch 被静默跳过。
|
|
163
|
+
const entryByName = {}
|
|
164
|
+
for (const hit of pinnedRows.values()) entryByName[hit.name] = hit.entry
|
|
165
|
+
|
|
93
166
|
const patches = [
|
|
94
167
|
...profile.layers.flatMap(layer => layer.patches),
|
|
95
168
|
...profile.patches,
|
|
@@ -123,16 +196,18 @@ try {
|
|
|
123
196
|
id: 'agent-presets',
|
|
124
197
|
config: {
|
|
125
198
|
default: 'standard',
|
|
126
|
-
roots: [{ path:
|
|
199
|
+
roots: [{ path: presetDir, trust: 'system' }],
|
|
127
200
|
},
|
|
128
201
|
},
|
|
129
202
|
]
|
|
130
203
|
|
|
204
|
+
const pinnedPatches = pinInsertedRows(patches, entryByName)
|
|
205
|
+
|
|
131
206
|
process.stderr.write(
|
|
132
207
|
`${BIN_NAME}: 挂载方式 = ${selfInstalled ? `profile 里装的那份(${installedDir},带稳定外壳,面板里能自更新)` : '源码树直挂(无外壳,更新靠同步源码 + 重启宿主)'}\n`,
|
|
133
208
|
)
|
|
134
209
|
|
|
135
|
-
const ctx = await boot(BIN_NAME, rootConfig,
|
|
210
|
+
const ctx = await boot(BIN_NAME, rootConfig, pinnedPatches, (hostCtx) => {
|
|
136
211
|
// A headless mobile host has no command line: nothing passes flags and
|
|
137
212
|
// nobody is watching a help screen. The web startup plugin still waits on
|
|
138
213
|
// `cmdlineArgs` before it will provide `webStartup`, so the service must
|
|
@@ -147,17 +222,11 @@ try {
|
|
|
147
222
|
await assertEntriesActivated(ctx, BIN_NAME)
|
|
148
223
|
// 装载期结束,撤掉 installFailLoud 的兜底。
|
|
149
224
|
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// 紧接着一行 `fatal load failure` 就把 PID 换掉了。
|
|
156
|
-
//
|
|
157
|
-
// 这里在 assertEntriesActivated 通过之后才撤:那一刻树已经确认全部激活
|
|
158
|
-
//(激活失败的 entry 会在它内部 throw,进上面的 catch 走 boot failed),
|
|
159
|
-
// 此后再出现的 reject 就不再是「装载失败」而是运行期故障,
|
|
160
|
-
// 交给进程级错误边界记账 + 熔断。
|
|
225
|
+
// 它注册在 process 上之后**一辈子不摘**,而宿主是长驻进程 —— 运行期任意一次游离
|
|
226
|
+
// reject 都会走到它那里把进程带走,而它本该只管「装载失败」。所以这里在
|
|
227
|
+
// assertEntriesActivated 通过之后才撤:那一刻树已确认全部激活(激活失败的 entry 会在
|
|
228
|
+
// 它内部 throw,进上面的 catch 走 boot failed),此后的 reject 是运行期故障,交给
|
|
229
|
+
// 进程级错误边界记账 + 熔断。
|
|
161
230
|
releaseFailLoud()
|
|
162
231
|
process.stderr.write(`${BIN_NAME}: mobile host ready (profile ${PROFILE_NAME})\n`)
|
|
163
232
|
} catch (error) {
|
package/lib/client.js
CHANGED
|
@@ -1,40 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-mobile-plugin 的**客户端半边**(在 DSH 桌面 Web 页面里运行)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 四件事:桌面侧边栏的展开态与「读过了」报给宿主;手机端的已读回执应用回桌面绿点;
|
|
5
|
+
* 提供「设置 → 移动端」那颗由本机打开的网页管理开关。方向是单向的(桌面 → 宿主 → 手机),
|
|
6
|
+
* 手机上的折叠只存在 App 本地,不写回。
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 的 createWorkspaceViewStore 持久化到 dsh-client-store 的 localStorage),
|
|
9
|
-
* 宿主进程、中转和手机都读不到。移动端要与桌面端对齐展开态,就只能由页面里的
|
|
10
|
-
* 这一半把状态读出来交给宿主。
|
|
11
|
-
*
|
|
12
|
-
* 数据方向是单向的:桌面 → 宿主 → 手机。手机上手动折叠/展开只存在 App 本地
|
|
13
|
-
* (AsyncStorage),不会写回这份 localStorage。
|
|
14
|
-
*
|
|
15
|
-
* 2. **设置 →「移动端」**(设置菜单里的顶层一项,不再挂在内置插件页签下)。保留
|
|
16
|
-
* **网页版入口**,并只加一颗由可信本机 DSH 设置页操作的公网访问开关;网页管理默认关闭,
|
|
17
|
-
* 每次开启固定 2 小时,到期不因访问续时。管理界面本体仍在宿主吐出的 HTML 里。
|
|
18
|
-
*
|
|
19
|
-
* 为什么这里只加开关:这半边不可热替换,管理界面继续留在网页版;本机开关只控制
|
|
20
|
-
* 网页管理路由,不改手机 App API 或隧道状态。插件更新仍交给 DSH 插件市场。
|
|
21
|
-
*
|
|
22
|
-
* 3. **桌面端已读 → 宿主**(`startDesktopReadSync`)。桌面侧栏的「已完成待读」绿点
|
|
23
|
-
* 只活在渲染进程内存里(`UiSession.completionUnread`),宿主和手机都读不到。
|
|
24
|
-
* 这一半盯着 `uiSession.sessionStatus`,把「刚从未读集合掉出去」的会话报给
|
|
25
|
-
* `/api/relay/desktop-read`,宿主据此清掉手机端那份完成提醒。
|
|
26
|
-
*
|
|
27
|
-
* 4. **手机端已读 → 桌面端**(`startReadReceiptSync`)。反方向:宿主把手机端读过
|
|
28
|
-
* 哪些会话记成 `sessionId → readAt` 台账,这一半拉下来,对水位前进过的会话做一次
|
|
29
|
-
* 瞬时 `sessions.retain(id, { source: 'mainView' })` + `release()`,借桌面自己的
|
|
30
|
-
* `reconcileStatus()` 把绿点摘掉。见 lib/read-receipt.cjs 的文件头。
|
|
31
|
-
*
|
|
32
|
-
* 写成惰性工厂(`window.__ModuleLoader__.load`)是 DSH 客户端插件的约定:
|
|
33
|
-
* 运行 bundle 只注册工厂,副作用(这里的定时器、页签注册)在物化时才发生。
|
|
34
|
-
* 工厂收到的 `require` 是 DSH 的模块表 require,`react` 是平台 seed word。
|
|
35
|
-
*
|
|
36
|
-
* 本文件**不经任何构建**(package.json 的 files 直接收录 lib 下的 .js),所以不能
|
|
37
|
-
* 写 JSX:统一用 `React.createElement` 的别名 `h`。
|
|
8
|
+
* 本文件不经任何构建(package.json 直接收录 lib 下的 .js),所以不能写 JSX,统一用
|
|
9
|
+
* `React.createElement` 的别名 `h`;客户端插件按约定写成惰性工厂,定时器与页签在物化时才起。
|
|
38
10
|
*/
|
|
39
11
|
|
|
40
12
|
window.__ModuleLoader__.load({
|
|
@@ -195,18 +167,11 @@ window.__ModuleLoader__.load({
|
|
|
195
167
|
}
|
|
196
168
|
|
|
197
169
|
/**
|
|
198
|
-
* 控制面调用。`path`
|
|
199
|
-
*
|
|
200
|
-
* `/api/relay/xxx` 或 `api/relay/xxx`——几种都接受,最终一律折成文档相对地址。
|
|
170
|
+
* 控制面调用。`path` 既接受 `/xxx` 这样的短路径,也接受已经带 `/api/relay` 的全路径,
|
|
171
|
+
* 几种都折成文档相对地址。
|
|
201
172
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* (`READ_RECEIPT_ENDPOINT` / `DESKTOP_READ_ENDPOINT`),于是真正发出去的是
|
|
205
|
-
* `/api/relay/api/relay/read-receipt` —— 一个不存在的地址。它返回 401/404,被
|
|
206
|
-
* 两条同步各自的 `catch` 静静吞掉,所以「手机上读过桌面不灭灯」「桌面上读过手机
|
|
207
|
-
* 不灭灯」从来就没通过,而且**一条日志都不留**(宿主侧活体台账里那两个计数恒为 0)。
|
|
208
|
-
* 改断言而不是改实现是不行的:守卫测试里的假 fetch 按 `endsWith(endpoint)` 匹配,
|
|
209
|
-
* 而 `/api/relay/api/relay/read-receipt` 也满足这个后缀 —— 测试因此一直是绿的。
|
|
173
|
+
* 必须两种都认:绿点那两条同步传进来的本来就是全路径,无条件拼前缀会发到
|
|
174
|
+
* `/api/relay/api/relay/...` 这个不存在的地址,而调用方的 catch 会把它静静吞掉。
|
|
210
175
|
*/
|
|
211
176
|
function controlUrl(path) {
|
|
212
177
|
const raw = String(path);
|
|
@@ -241,15 +206,14 @@ window.__ModuleLoader__.load({
|
|
|
241
206
|
});
|
|
242
207
|
|
|
243
208
|
/* ── 三:侧边栏未读绿点的双向同步 ───────────────────────────────────────
|
|
244
|
-
*
|
|
209
|
+
* 两端的绿点是两份互不相干的状态:
|
|
245
210
|
* - 手机端 = 宿主按设备维度维护的完成提醒(lib/mobile/completion.js),
|
|
246
211
|
* 由 `POST /api/mobile/v1/session/read` 清掉;
|
|
247
212
|
* - 桌面端 = 渲染进程内存里的 `UiSession.completionUnread`,既不落盘也不过服务端。
|
|
248
|
-
*
|
|
213
|
+
* 两边各缺一块,所以两个方向都要这一半补。
|
|
249
214
|
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* 所以每个 tick 现取一次,而不是启动时取一次。
|
|
215
|
+
* 两条同步都依赖桌面端的服务(uiSession / sessions),拿不到就整条不启动:注册了
|
|
216
|
+
* 也只是空转的定时器。服务可能比我们晚就位,所以每个 tick 现取一次。
|
|
253
217
|
*/
|
|
254
218
|
/** 轮询间隔:与展开态同步同频。绿点晚 2s 灭掉无感,但没必要更勤。 */
|
|
255
219
|
const READ_SYNC_INTERVAL_MS = 2000;
|
|
@@ -286,15 +250,11 @@ window.__ModuleLoader__.load({
|
|
|
286
250
|
/**
|
|
287
251
|
* 方向一:桌面端读过了 → 告诉宿主,手机端侧栏灭灯。
|
|
288
252
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
* - 此刻**正开在主视图里**(`retainedBy.mainView > 0`:用户盯着它读完,它压根不进未读集合);
|
|
292
|
-
* - 本次打开页面之后**它变过、而且现在不是未读**(补掉漏采的那一瞬间)。
|
|
253
|
+
* 三种情形算「桌面读过了」,每种只报一次:看见它从未读里掉出去、此刻正开在主视图里
|
|
254
|
+
* (`retainedBy.mainView > 0`)、或本次页面里它变过且现在不是未读。
|
|
293
255
|
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* 未读全灭掉。所以「页面打开之后变过」这条要求 `updatedAt > 页面打开时刻`,
|
|
297
|
-
* 没拿到会话清单时宁可不报。
|
|
256
|
+
* 首帧一条都不报:页面刚加载时 `sessionStatus` 里全是 `completionUnread: false`,
|
|
257
|
+
* 照单全收会把手机上真未读的全灭掉。所以要求 `updatedAt > 页面打开时刻`。
|
|
298
258
|
*/
|
|
299
259
|
function startDesktopReadSync(ctx) {
|
|
300
260
|
withClientServices(ctx, ['uiSession'], 'uiSession', 'mobile-plugin: 桌面端已读上报', (scope, isDisposed) => {
|
|
@@ -381,15 +341,9 @@ window.__ModuleLoader__.load({
|
|
|
381
341
|
* 拿到桌面客户端的两个服务:`uiSession`(未读绿点的真源 + 每会话运行态)与
|
|
382
342
|
* `sessions`(会话清单 + `retain`)。
|
|
383
343
|
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
* effect 是**无条件 2s 轮询**、`ctx.get` 之后才 fetch,所以只可能是 tick 在服务这一
|
|
388
|
-
* 道 guard 上就返回了。同一个桌面里能正常工作的客户端插件(内置的 conversation /
|
|
389
|
-
* approval、第三方 `dsh-opencode-go`)一律走 `inject`:DSH 自己的插件把服务写进
|
|
390
|
-
* `inject` 数组,opencode-go 用 `ctx.inject([...], child => …)`。`ctx.get` 是「绕过
|
|
391
|
-
* 注入直接取」,在客户端运行时里取不到这两个服务。拿不到就一个定时器都不起(与旧
|
|
392
|
-
* 行为一致),由 lib/client.js 的自检上报把「到底看见了什么」说给宿主听。
|
|
344
|
+
* 必须用 `ctx.inject([...], child => …)`,不能用 `ctx.get(name)`:`ctx.get` 是绕过注入
|
|
345
|
+
* 直接取,在客户端运行时里取不到这两个服务,两条绿点同步会一条都不跑。拿不到就一个
|
|
346
|
+
* 定时器都不起,由自检上报把「到底看见了什么」说给宿主听。
|
|
393
347
|
*/
|
|
394
348
|
/**
|
|
395
349
|
* 客户端半边的自检状态:只给自检上报用(谁注入了、谁没注入)。
|
|
@@ -437,16 +391,12 @@ window.__ModuleLoader__.load({
|
|
|
437
391
|
* 方向二:手机上读过了 → 在桌面页面里就地清掉绿点。
|
|
438
392
|
*
|
|
439
393
|
* 拉宿主那份 `sessionId → readAt` 台账,对水位前进过的会话做一次瞬时
|
|
440
|
-
* `sessions.retain(id, { source: 'mainView' })` + 同 tick `release()
|
|
441
|
-
* `reconcileStatus()`
|
|
442
|
-
*
|
|
443
|
-
* 发生;release 把引用数降回 0 也不会把绿点加回来(只有 `observeRunning(false)`
|
|
444
|
-
* 且非 mainView 才会加)。整个过程同一个 tick 内完成,React 还没重渲染,看不到抖动。
|
|
394
|
+
* `sessions.retain(id, { source: 'mainView' })` + 同 tick `release()`:`retain` 会同步
|
|
395
|
+
* 抬 `retainedBy.mainView`,桌面自己的 `reconcileStatus()` 因此摘掉绿点,而 release
|
|
396
|
+
* 把引用数降回 0 也不会把它加回来。同一个 tick 内完成,React 还没重渲染,看不到抖动。
|
|
445
397
|
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
* 早已被新一轮完成盖过的旧回执重新应用,把本该亮的绿点灭掉。会话的最后活动时间
|
|
449
|
-
* 晚于已读水位,就说明这一轮是读完之后才跑完的,必须跳过。
|
|
398
|
+
* 还要跟 `updatedAt` 比一次:台账不记「读完之后又跑完了一轮」,重新打开页面时会把
|
|
399
|
+
* 旧回执重新应用,灭掉本该亮的绿点。最后活动时间晚于已读水位就跳过。
|
|
450
400
|
*/
|
|
451
401
|
function startReadReceiptSync(ctx) {
|
|
452
402
|
withClientServices(ctx, ['sessions'], 'sessions', 'mobile-plugin: 已读回执应用', (scope, isDisposed) => {
|
|
@@ -682,14 +632,11 @@ window.__ModuleLoader__.load({
|
|
|
682
632
|
/**
|
|
683
633
|
* 客户端半边的自检上报:每 10s 把「这一半到底看见了什么」报给宿主。
|
|
684
634
|
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
* 「服务拿到了但清理失败」两种可能耗掉了一整轮排查 —— 这条通路把答案直接摆出来:
|
|
688
|
-
* 两条同步各自的 `ctx.inject` 到底有没有回调(`uiSession` / `sessions` 两格)、
|
|
689
|
-
* `ctx.get` 能不能看见那两个服务、最后一次错在哪。
|
|
635
|
+
* 宿主能数出「那半边没来敲门」,数不出「为什么没来」。这条通路直接摆出:两条同步各自的
|
|
636
|
+
* `ctx.inject` 有没有回调、`ctx.get` 能不能看见那两个服务、最后一次错在哪。
|
|
690
637
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
638
|
+
* 刻意不依赖任何客户端服务:探测全包在 try/catch 里,它自己挂在服务上就永远报不出
|
|
639
|
+
* 「服务拿不到」这件事。
|
|
693
640
|
*/
|
|
694
641
|
function startClientHalfReport(ctx) {
|
|
695
642
|
const probe = (name) => {
|
package/lib/desktop-view.cjs
CHANGED
|
@@ -3,17 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 桌面端工作区视图快照(侧边栏展开/折叠)的宿主侧存储。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 宿主进程、中转和手机都读不到它。插件因此带了一个客户端半边(lib/client.js):
|
|
10
|
-
* 桌面页面里每 2 秒读一次这份状态,变了就 POST `/api/relay/desktop-view`,
|
|
11
|
-
* 由本模块落到 `$DSH_HOME/desktop-view.json`,再由 `/api/mobile/v1/desktop-view`
|
|
12
|
-
* 下发给 App。
|
|
6
|
+
* 桌面端只把展开态记在浏览器 localStorage 里,宿主、中转和手机都读不到;插件因此带了
|
|
7
|
+
* 客户端半边(lib/client.js):桌面页面定期读出来 POST `/api/relay/desktop-view`,由本模块
|
|
8
|
+
* 落到 `$DSH_HOME/desktop-view.json`,再由 `/api/mobile/v1/desktop-view` 下发给 App。
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* 还没有记录」时会自动展开它并落盘,所以「当前会话所在工作区」通常也在里面。
|
|
10
|
+
* 语义与桌面端一致:`groupExpansion` 以 workspaceId 为键(未分组固定为空串),只有显式
|
|
11
|
+
* true 表示展开,缺省即折叠;当前会话所属分组桌面端会自动展开,所以通常也在里面。
|
|
17
12
|
*/
|
|
18
13
|
|
|
19
14
|
const fs = require('node:fs');
|
package/lib/git/read.js
CHANGED
|
@@ -383,12 +383,11 @@ function githubTargetOf(remote) {
|
|
|
383
383
|
/**
|
|
384
384
|
* GitHub 元信息(星标 / Fork / 关注)。
|
|
385
385
|
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
386
|
+
* 只取这三个数字,不取星标曲线:曲线要把 stargazers 一页页翻完,几千星的仓库等于几十次
|
|
387
|
+
* 请求,撞上匿名限流就整页变慢。有 `GITHUB_TOKEN` / `GH_TOKEN` 时带上,私有仓库也能读。
|
|
388
388
|
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* 私有仓库靠这一个头就能读,不必在 App 里输任何东西。
|
|
389
|
+
* 「读不到」和「没有 GitHub 远端」要分开:私有仓库的匿名请求回 404,是两件事,前端那句
|
|
390
|
+
* 话必须说对。
|
|
392
391
|
*/
|
|
393
392
|
async function githubOf(root) {
|
|
394
393
|
const remotes = await remotesOf(root);
|
|
@@ -564,9 +563,8 @@ async function activityOf(root) {
|
|
|
564
563
|
/**
|
|
565
564
|
* 仓库概览:远端、语言占比、代码量、近 12 周活动,外加(能取到时)GitHub 的三个数字。
|
|
566
565
|
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
* 不给。它不该把整页拖垮。
|
|
566
|
+
* 这张卡最贵,所以结果按仓库缓存一分钟(`fresh` 跳过),每项都自己兜底:语言统计有读取
|
|
567
|
+
* 上限,历史统计失败退化成 0,GitHub 取不到就整块不给。它不该把整页拖垮。
|
|
570
568
|
*/
|
|
571
569
|
async function overviewOf(root, options = {}) {
|
|
572
570
|
const cached = overviewCache.get(root);
|