dsh-mobile-plugin 0.4.0 → 0.4.4
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 +71 -74
- package/dsh-mobile-credentials/lib/credentials.js +47 -24
- package/host.js +22 -36
- 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 +13 -25
- 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 -12
- package/lib/index.js +1 -1
- 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/allowed-zone.js +100 -0
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +13 -21
- package/lib/mobile/attachments.js +16 -37
- 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/file-read.js +168 -0
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +85 -134
- 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 +48 -23
- package/lib/mobile/model-vendors.js +105 -0
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-market.js +475 -0
- package/lib/mobile/plugin-self.js +6 -23
- 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 +136 -33
- 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-cwd.js +28 -0
- package/lib/mobile/session-file.js +9 -33
- 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 +154 -37
- package/lib/mobile/stats-ledger.cjs +258 -75
- package/lib/mobile/stats-scheduler.cjs +41 -0
- package/lib/mobile/stream.js +39 -130
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +75 -21
- package/lib/mobile/vendor-logos.cjs +30 -0
- package/lib/mobile/wire-cut.js +212 -0
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/apply-ledger.cjs +52 -0
- package/lib/relay/core.js +14 -3
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/migrate.js +90 -0
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +9 -12
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/relay/verify.js +52 -0
- package/lib/routes/control-plane.js +39 -47
- package/lib/routes/mobile-api.js +47 -34
- package/lib/session-cleanup.cjs +26 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/device-headers.js +12 -4
- 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 +15 -28
- package/lib/sidecar-facade-worker.cjs +75 -41
- package/lib/sidecar-facade.cjs +18 -8
- package/lib/sidecar-lifecycle.cjs +21 -10
- package/lib/update-install.cjs +24 -62
- package/lib/update-registry.cjs +6 -18
- package/package.json +1 -1
- package/lib/auto-archive.cjs +0 -98
package/lib/mobile/envelope.js
CHANGED
|
@@ -3,22 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 宿主 apiProxy 的回执信封。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 失败 `{ result: { ok: false, error: { code, message, details? } } }`
|
|
9
|
-
* 异常(只有 envelope.js 之外的调用方会碰到)`{ result: { ok: true, value } }` 的缺位形态
|
|
6
|
+
* 所有 `apiProxy.*` 调用都回同一个形状:成功 `{ result: { ok: true, value } }`,失败
|
|
7
|
+
* `{ result: { ok: false, error: { code, message, details? } } }`。
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* plugins.js / references.js 在此之上又叠了各自的字段校验与错误码
|
|
14
|
-
* (那两个是有意的领域解码,不算重复);auto-archive.cjs 则干脆绕过信封直接读
|
|
15
|
-
* `response?.value?.items`——它读的那一层压根不是信封,是 bug。
|
|
16
|
-
*
|
|
17
|
-
* 把唯一实现收到这里,是因为 5 个模块为了拿这 5 行去 require 一个 1400 行、
|
|
18
|
-
* 带 9 个模块级 WeakMap 的 sessions.js——那才是 sessions.js 拆不动的真正原因:
|
|
19
|
-
* 它同时扮演了会话领域、缓存中心、RPC 封装和公共工具库四个角色。
|
|
20
|
-
*
|
|
21
|
-
* 依赖方向:envelope.js 是叶子模块,不 require 任何东西。
|
|
9
|
+
* 拆信封只有这一份实现:此前几个模块各写一遍,还要为了这 5 行去 require 一个 1400 行的
|
|
10
|
+
* sessions.js。envelope.js 是叶子模块,不 require 任何东西。
|
|
22
11
|
*/
|
|
23
12
|
|
|
24
13
|
/**
|
package/lib/mobile/events.js
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 设备级事件流(GET /api/mobile/v1/events/stream):手机上侧栏那条 SSE。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 插件自己补了 device/list-changed(来自 lib/shared/list-signal.js,清单变了)、
|
|
9
|
-
* device/session-activity(来自 lib/shared/session-signal.js,某个会话的正文水位涨了)、
|
|
10
|
-
* device/desktop-view-changed(来自 lib/shared/desktop-view-signal.js,桌面侧栏折叠态
|
|
11
|
-
* 变了)与 device/heartbeat(心跳)四类自产帧,App 侧靠它们判断「这条流还活着」
|
|
12
|
-
* 「该重拉列表」「该给某个会话补正文」「该重取折叠态」并据此决定要不要把兜底轮询降频。
|
|
4
|
+
* 它只是「有东西变了」的信号,绝不是数据源,所以每条宿主帧都削到只剩「列表需要重拉」的
|
|
5
|
+
* 部分。0.2.0 兼容桥只订阅 workspace/follow + session/control,会话状态变化会被丢掉;插件
|
|
6
|
+
* 因此自产四类帧:device/list-changed、device/session-activity、device/desktop-view-changed
|
|
7
|
+
* 与 device/heartbeat,App 靠它们判断流还活着并决定是否降频兜底轮询。
|
|
13
8
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* 各一帧 device/session-activity(见 replayKnownSignals)。补的是断流窗口,不是新变更:
|
|
17
|
-
* 只读信号总线的快照,不 bump、不前移基准,App 侧对同 revision / 同水位幂等。
|
|
9
|
+
* 这条流没有 Last-Event-ID 续传,所以连上时立刻补一帧当前 revision 与每个已跟踪会话的水位;
|
|
10
|
+
* 补的是断流窗口,不是新变更。
|
|
18
11
|
*/
|
|
19
12
|
'use strict';
|
|
20
13
|
|
|
@@ -30,11 +23,8 @@ const DEDUP_WINDOW = 2048;
|
|
|
30
23
|
const HEARTBEAT_MS = 15_000;
|
|
31
24
|
|
|
32
25
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* 而宿主日志里查不到这条流是正常收尾、被客户端断开,还是循环抛错。会话级流
|
|
36
|
-
* (mobile/stream.js)早就有 logStream,这里是它的设备级对偶——任何一条异常路径都必须
|
|
37
|
-
* 留下可 grep 的行。
|
|
26
|
+
* 诊断日志。失败路径一条都不许静默:手机把兜底轮询降成 60s,宿主日志里却查不到这条流是
|
|
27
|
+
* 正常收尾、被客户端断开,还是循环抛错。会话级流早有 logStream,这里是它的设备级对偶。
|
|
38
28
|
*/
|
|
39
29
|
function logDeviceStream(message, error) {
|
|
40
30
|
const detail = error instanceof Error ? (error.stack || error.message) : (error == null ? '' : String(error));
|
|
@@ -46,11 +36,9 @@ function logDeviceStream(message, error) {
|
|
|
46
36
|
/**
|
|
47
37
|
* 写一条 SSE 记录(策略声明,实现见 `lib/shared/sse-write.js`)。
|
|
48
38
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* 这比把帧堆在写缓冲里好(2026-10-17 现场:466MB 的 SSE 帧挂在 socket 写队列上,宿主撞
|
|
53
|
-
* 4GB 上限被杀)。不补填充:这里的帧少、单帧也小,不需要撑 RN 的缓冲阈值。
|
|
39
|
+
* 这条流的软水位一律 shed,而 shed 不能当成功:没有 Last-Event-ID 续传,丢了帧又继续读,
|
|
40
|
+
* App 就会停在缺少那一次变更的状态。所以调用方看到 `shed` 必须立即断流,让客户端重连后
|
|
41
|
+
* 走 `replayKnownSignals()` 全量补水位 —— 这比把帧堆在写缓冲里好。
|
|
54
42
|
*
|
|
55
43
|
* 返回值与会话流一致:`ok:false` 该收尾,`shed:true` 这条被丢掉(调用方必须断流),
|
|
56
44
|
* `needDrain:true` 写进去了但底下已经写不动。
|
|
@@ -60,7 +48,7 @@ function writeSse(res, payload, frame, sealFrame) {
|
|
|
60
48
|
frame,
|
|
61
49
|
namespace: 'device',
|
|
62
50
|
// 协议 v2 的会话上这条流也逐帧封装:`App` 判定「这条会话是加密的」之后,收到一帧
|
|
63
|
-
// 明文就当畸形帧计数,连着十帧就把整条流判死、退回 15
|
|
51
|
+
// 明文就当畸形帧计数,连着十帧就把整条流判死、退回 15 秒轮询。
|
|
64
52
|
sealFrame: typeof sealFrame === 'function' ? sealFrame : null,
|
|
65
53
|
shedWhenBuffered: () => true,
|
|
66
54
|
});
|
|
@@ -87,14 +75,10 @@ async function* mergeHostAndMux(ctx, signal) {
|
|
|
87
75
|
}
|
|
88
76
|
|
|
89
77
|
/**
|
|
90
|
-
* Trim a device-wide stream payload to the metadata the mobile session-list
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* whenever a session is actively generating. The device-wide stream is only
|
|
95
|
-
* used by the phone to refresh the session list (turn/start/end/title), so it
|
|
96
|
-
* needs the event identity and inner type, not the content. Other frame types
|
|
97
|
-
* (summary/queue/projection/question/approval/host) are small and kept intact.
|
|
78
|
+
* Trim a device-wide stream payload to the metadata the mobile session-list consumes.
|
|
79
|
+
* `session/event` frames carry the full message content; forwarding them to every paired
|
|
80
|
+
* phone floods the relay whenever a session is generating. Only the event identity and
|
|
81
|
+
* inner type are needed. Other frame types are small and kept intact.
|
|
98
82
|
*/
|
|
99
83
|
function lightPayload(payload) {
|
|
100
84
|
if (!payload || typeof payload !== 'object' || payload.type !== 'session/event') return payload;
|
|
@@ -174,15 +158,13 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
174
158
|
return !result.needDrain;
|
|
175
159
|
};
|
|
176
160
|
/**
|
|
177
|
-
* 自产的「清单变了」帧:镜像推送调度器每 500ms
|
|
161
|
+
* 自产的「清单变了」帧:镜像推送调度器每 500ms 比一次清单内容哈希,变了就推给手机。
|
|
178
162
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* 手机于是「连着却收不到任何会话状态变化」,兜底轮询又被降成 60s。这里补上真源。
|
|
163
|
+
* 设备级流此前只有宿主事件这一个来源,而 0.2.0 兼容桥的订阅里没有 session/projection,
|
|
164
|
+
* 会话状态变化会被丢掉,手机于是「连着却收不到任何变化」。
|
|
182
165
|
*
|
|
183
|
-
* 走直订而不是塞进 mergeHostAndMux
|
|
184
|
-
*
|
|
185
|
-
* 收尾时必须注销,否则它永远以为有人听。
|
|
166
|
+
* 走直订而不是塞进 mergeHostAndMux:那是个无限流,混进去之后「宿主事件流结束」就不再等于
|
|
167
|
+
* 「这条 SSE 该收尾」。watch() 让推送调度器知道有人在听,收尾时必须注销。
|
|
186
168
|
*/
|
|
187
169
|
const releaseSignal = sharedListSignal.watch();
|
|
188
170
|
const unsubscribeSignal = sharedListSignal.onBump((event) => {
|
|
@@ -192,17 +174,12 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
192
174
|
/**
|
|
193
175
|
* 自产的「某个会话的正文水位涨了」帧,与「清单变了」是两条独立的通知。
|
|
194
176
|
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* 用户点进去才拉 —— 而这正好是「点开对话要等一会儿才看到内容」的来源。
|
|
177
|
+
* 清单信号只说列表内容变了;而正文在会话缓存里,没有这条帧时只有打开着那个会话的手机才
|
|
178
|
+
* 会实时收到正文,没打开的会话要等点进去才拉 —— 这正是「点开对话要等一会儿才看到内容」
|
|
179
|
+
* 的来源。
|
|
199
180
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* 它说的是「本机正文到哪了」,不是「中转镜像里有到哪了」。手机收到帧后立刻带 afterSeq
|
|
203
|
-
* 去 /session/messages 增量拉:走中转的手机读到的是刚推上去的镜像,走局域网直连的手机
|
|
204
|
-
* 读到的是 sidecar 本地那份快照(/tunnel/send 先落盘再尝试发),两条路都拿得到。
|
|
205
|
-
* 同样必须直订 + 收尾注销,理由与上面那条一致。
|
|
181
|
+
* 水位由镜像调度器算出后广播,说的是「本机正文到哪了」,不是「中转镜像里有到哪了」,与
|
|
182
|
+
* 镜像是否真的推成功解耦。同样必须直订 + 收尾注销。
|
|
206
183
|
*/
|
|
207
184
|
const unsubscribeActivity = sharedSessionSignal.onBump((event) => {
|
|
208
185
|
if (ac.signal.aborted) return;
|
|
@@ -211,14 +188,12 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
211
188
|
/**
|
|
212
189
|
* 自产的「桌面侧栏折叠态变了」帧。
|
|
213
190
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* 早就知道它哪一刻变了(desktop-view.cjs 的 update())。这里补上那个出口。
|
|
191
|
+
* groupExpansion 只活在浏览器 localStorage,插件靠客户端半边轮询上报落在
|
|
192
|
+
* lib/desktop-view.cjs;手机要拿它只能问 GET /desktop-view。过去 App 按「跟列表刷新走 +
|
|
193
|
+
* 5 分钟最短间隔」去猜它变没变,而插件早就知道它哪一刻变了。
|
|
218
194
|
*
|
|
219
|
-
* 只发 updatedAt
|
|
220
|
-
*
|
|
221
|
-
* 同样必须直订 + 收尾注销,理由与上面两条一致。
|
|
195
|
+
* 只发 updatedAt、不发内容:手机据此重拉那一发,不必让信号流承担数据同步。
|
|
196
|
+
* 同样必须直订 + 收尾注销。
|
|
222
197
|
*/
|
|
223
198
|
const unsubscribeDesktopView = sharedDesktopViewSignal.onBump((event) => {
|
|
224
199
|
if (ac.signal.aborted) return;
|
|
@@ -227,16 +202,11 @@ async function handleDeviceStream(ctx, req, res) {
|
|
|
227
202
|
/**
|
|
228
203
|
* 连上就先补一帧「当前状态」。
|
|
229
204
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* 不补的后果:手机重连后 state 是空的,而侧栏兜底轮询已经因为「流活着」被降到 60s ——
|
|
233
|
-
* 中间这几分钟的变化要等到下一次真实变更才可见。
|
|
205
|
+
* 这条流没有 Last-Event-ID 续传,断流窗口里发生的清单变更与正文水位必须在重连后补上,
|
|
206
|
+
* 否则手机 state 是空的,而兜底轮询已经因为「流活着」被降到 60s。
|
|
234
207
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* (app 的 handleDeviceSignal 按 revision 与 seq 判重),重连补发不会引发重复刷新。
|
|
238
|
-
* 帧形状与实时帧完全一致(同为 device/event 信封包 device/list-changed 或
|
|
239
|
-
* device/session-activity),App 不需要任何改动。
|
|
208
|
+
* 只读两条信号总线已经广播过的最近值,既不 bump 也不前移基准,所以不会制造新变更;
|
|
209
|
+
* App 对同 revision / 同水位幂等,重连补发不会引发重复刷新。
|
|
240
210
|
*/
|
|
241
211
|
const replayKnownSignals = () => {
|
|
242
212
|
const list = sharedListSignal.snapshot();
|
package/lib/mobile/extensions.js
CHANGED
|
@@ -3,15 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 扩展卡的宿主侧:卡状态账本、扩展动作登记表,以及给模型用的 `extension_present` 工具。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 卡会一直停在声明那一刻的样子(卡的状态得由宿主认账)。账本与会话
|
|
9
|
-
* 日志无关,清理会话不连带它,与统计账本同口径。
|
|
6
|
+
* 卡状态要落在宿主家里:扩展页回写标题与说明之后,手机重进会话、刷新列表都会从历史重新
|
|
7
|
+
* 拼卡,没有这份账本,卡就停在声明那一刻的样子。账本与会话日志无关,清理会话不连带它。
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 这一档还没有对任何扩展放开,所以任何动作名都必须被拒。
|
|
9
|
+
* 动作名是一张硬编码白名单:扩展能让宿主做的事必须由宿主预先枚举,不能在消息里现拼一个
|
|
10
|
+
* 动作名。这条边界与移动 API 的能力面相同——不暴露 shell、系统设置、任意文件读写。
|
|
11
|
+
* 登记表现在是空的,所以任何动作名都必须被拒。
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
14
|
const fs = require('node:fs');
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 按路径读文件(POST /api/mobile/v1/file/read):右栏文件浏览器「点开一个文件」。
|
|
5
|
+
*
|
|
6
|
+
* 与 `/session/file` 的分工:那条服务的是**交付**(卡片、正文里的文件链接),放行面是
|
|
7
|
+
* 「本会话工作区 ∪ 本轮 present 声明过的路径」;右栏能一路翻到别的已登记工作区,手里的
|
|
8
|
+
* 就是一串路径,所以这条按 `allowed-zone.js` 判:宿主已登记的工作区 ∪ 当前会话的项目目录。
|
|
9
|
+
*
|
|
10
|
+
* 三条边界:
|
|
11
|
+
* 1. `path` 必须是绝对路径 —— 这条路由没有可信的解析基准,相对路径直接 400,不去猜谁当基准。
|
|
12
|
+
* 判定用的是 resolve 之后的落点,`..` 爬出允许区一律不认(403 `outside-allowed`)。
|
|
13
|
+
* 2. 认出允许区之后的失败分开报:盘上没有 404 `missing`、不是普通文件 400 `not-a-file`、
|
|
14
|
+
* 超过 512MB 413 `too-large`。码与文案跟 `/session/file` 同一套,App 那边一套分支就够。
|
|
15
|
+
* 3. 字节流式回写,不在插件进程里缓冲。协议 v2 的分块信封**直接调 `/session/file` 那个写入
|
|
16
|
+
* 函数**:两边抄一份形状必然漂,而漂了的症状是「手机上永远下载中」,没有报错。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const { stat } = require('node:fs/promises');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { pipeline } = require('node:stream/promises');
|
|
23
|
+
|
|
24
|
+
const { sendJson, readJsonBody } = require('../shared/http-util');
|
|
25
|
+
const { allowedRoots, inAllowedZone } = require('./allowed-zone.js');
|
|
26
|
+
const { writeEncryptedFile, SESSION_FILE_MAX_BYTES } = require('./session-file.js');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 扩展名 → 内容类型。与 `session-file.js` 那张表逐条相同:两条路给同一个文件必须给出同一个
|
|
30
|
+
* `content-type`,否则 App 那边「按类型决定怎么预览」的分支会看端点选,等于多一种行为。
|
|
31
|
+
*/
|
|
32
|
+
const MEDIA_TYPES = Object.freeze({
|
|
33
|
+
mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
|
|
34
|
+
mp3: 'audio/mpeg', m4a: 'audio/mp4', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac',
|
|
35
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
36
|
+
webp: 'image/webp', heic: 'image/heic', bmp: 'image/bmp', svg: 'image/svg+xml',
|
|
37
|
+
pdf: 'application/pdf',
|
|
38
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
39
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
40
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
41
|
+
zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar',
|
|
42
|
+
json: 'application/json', csv: 'text/csv', html: 'text/html', htm: 'text/html',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const TEXT_EXTENSIONS = new Set([
|
|
46
|
+
'txt', 'md', 'markdown', 'log', 'json', 'csv', 'tsv', 'html', 'htm', 'css',
|
|
47
|
+
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt',
|
|
48
|
+
'swift', 'c', 'h', 'cpp', 'sh', 'bash', 'zsh', 'yml', 'yaml', 'toml', 'ini',
|
|
49
|
+
'conf', 'sql', 'xml', 'svg', 'patch', 'diff',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** 取扩展名(小写、不含点);没有扩展名回空串。 */
|
|
53
|
+
function extensionOf(filePath) {
|
|
54
|
+
const dot = filePath.lastIndexOf('.');
|
|
55
|
+
if (dot <= 0 || dot === filePath.length - 1) return '';
|
|
56
|
+
return filePath.slice(dot + 1).toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 回给响应的内容类型:认得出的按表,认不出的按扩展名落文本或 octet-stream。 */
|
|
60
|
+
function mediaTypeOf(filePath) {
|
|
61
|
+
const ext = extensionOf(filePath);
|
|
62
|
+
if (MEDIA_TYPES[ext]) return MEDIA_TYPES[ext];
|
|
63
|
+
if (TEXT_EXTENSIONS.has(ext)) return 'text/plain; charset=utf-8';
|
|
64
|
+
return 'application/octet-stream';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `content-disposition` 的文件名:头里只能放 ASCII,中文名按 RFC 5987 另给一份
|
|
69
|
+
* `filename*`。App 不读它(文件名它自己有),留着是给别人抓包时认人用的。
|
|
70
|
+
*/
|
|
71
|
+
function dispositionOf(name) {
|
|
72
|
+
const ascii = name.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
|
73
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* POST { path(绝对), sessionId? } → 该文件的原始字节。
|
|
78
|
+
*
|
|
79
|
+
* @param {object} ctx 插件上下文
|
|
80
|
+
* @param {object} req node:http IncomingMessage
|
|
81
|
+
* @param {object} res node:http ServerResponse
|
|
82
|
+
*/
|
|
83
|
+
async function handleFileRead(ctx, req, res) {
|
|
84
|
+
const body = await readJsonBody(req);
|
|
85
|
+
const declared = body && typeof body.path === 'string' ? body.path.trim() : '';
|
|
86
|
+
const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
87
|
+
if (!declared) {
|
|
88
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { path }(绝对路径)' } });
|
|
89
|
+
}
|
|
90
|
+
if (!path.isAbsolute(declared)) {
|
|
91
|
+
return sendJson(res, 400, {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: { code: 'bad-request', message: 'path 必须是绝对路径' },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const target = path.resolve(declared);
|
|
97
|
+
|
|
98
|
+
let roots;
|
|
99
|
+
try {
|
|
100
|
+
roots = await allowedRoots(ctx, sessionId);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// 宿主那句原话回给手机:这条路上「没取到」与「你没有这个目录」必须分得开。
|
|
103
|
+
return sendJson(res, 502, {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: {
|
|
106
|
+
code: (error && error.code) || 'workspace-list-failed',
|
|
107
|
+
message: String((error && error.message) || error),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!inAllowedZone(roots, target)) {
|
|
112
|
+
return sendJson(res, 403, {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: { code: 'outside-allowed', message: '这个文件不在能看的范围里(只放行工作区目录和这条会话的项目目录)' },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let info;
|
|
119
|
+
try {
|
|
120
|
+
info = await stat(target);
|
|
121
|
+
} catch {
|
|
122
|
+
return sendJson(res, 404, { ok: false, error: { code: 'missing', message: '文件已经不在这台电脑上了' } });
|
|
123
|
+
}
|
|
124
|
+
if (!info.isFile()) {
|
|
125
|
+
return sendJson(res, 400, { ok: false, error: { code: 'not-a-file', message: '这条路径不是一个文件' } });
|
|
126
|
+
}
|
|
127
|
+
if (info.size > SESSION_FILE_MAX_BYTES) {
|
|
128
|
+
return sendJson(res, 413, {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: { code: 'too-large', message: '这个文件太大,手机这边不支持整份取回' },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const header = {
|
|
135
|
+
'content-type': mediaTypeOf(target),
|
|
136
|
+
'content-disposition': dispositionOf(path.basename(target)),
|
|
137
|
+
'cache-control': 'no-store',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// 协议 v2:与 `/session/file` 逐字节同形(`x-dsh-content-type` 带明文类型、chunked,
|
|
141
|
+
// 真实类型单独给一份让 App 解密后决定怎么预览)。
|
|
142
|
+
const opened = req && req.__dshE2ee;
|
|
143
|
+
if (opened && opened.keys && opened.req) {
|
|
144
|
+
res.writeHead(200, {
|
|
145
|
+
...header,
|
|
146
|
+
'content-type': 'application/x-dsh-e2ee-chunks',
|
|
147
|
+
'transfer-encoding': 'chunked',
|
|
148
|
+
'x-dsh-proto': '2',
|
|
149
|
+
'x-dsh-content-type': header['content-type'],
|
|
150
|
+
});
|
|
151
|
+
await writeEncryptedFile(req, res, target, opened);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
res.writeHead(200, { ...header, 'content-length': String(info.size) });
|
|
156
|
+
if (info.size === 0) return res.end();
|
|
157
|
+
|
|
158
|
+
const stream = fs.createReadStream(target);
|
|
159
|
+
res.on('close', () => stream.destroy());
|
|
160
|
+
try {
|
|
161
|
+
await pipeline(stream, res);
|
|
162
|
+
} catch {
|
|
163
|
+
// 头已经发出去了,改不了状态码,只能断开让对端按截断处理。
|
|
164
|
+
try { res.destroy(); } catch { /* 连接已关闭 */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { handleFileRead, mediaTypeOf };
|
package/lib/mobile/git-repos.js
CHANGED
|
@@ -3,23 +3,14 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 项目目录的 Git 仓库识别:回传「这里有没有仓库、仓库根在哪、当前在哪个分支」。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 是空页 —— 字段缺失一律按「不是仓库」处理。
|
|
6
|
+
* 手机看不到电脑上的目录,侧栏 Git 入口的可见性只能靠宿主回传的标记;判据不许 App 猜,
|
|
7
|
+
* 字段缺失一律按「不是仓库」处理。
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* 只读文件、不起 git 进程:这段代码跟着工作区清单一轮轮跑,起进程太贵,而且宿主未必装了
|
|
10
|
+
* git。三种都算有仓库:项目根自己就是仓库、项目落在某个仓库的子目录里(往上找最近的根)、
|
|
11
|
+
* 项目下面并列着若干仓库。往上走到主目录为止,往下只扫两层、条数封顶。
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
* 1. 项目根自己就是仓库根;
|
|
16
|
-
* 2. 项目目录落在某个仓库的子目录里 —— 往上找最近的那个根,操作按它执行;
|
|
17
|
-
* 3. 项目下面并列着若干个仓库(第 1、2 层,含 `.git` 是文件的子模块与 worktree)。
|
|
18
|
-
* 往上走到主目录为止不再往上:主目录本身是个 dotfiles 仓库时,再往上是把每个项目都
|
|
19
|
-
* 判成仓库,那不是这些项目的意思。往下只扫两层、条数封顶,扫满置 `truncated`。
|
|
20
|
-
*
|
|
21
|
-
* 结果按目录缓存一会儿(GIT_CACHE_MS):下拉刷新带 `fresh` 跳过缓存,见
|
|
22
|
-
* lib/mobile/workspaces.js 的 handleWorkspaces。
|
|
13
|
+
* 结果按目录缓存一会儿,下拉刷新带 `fresh` 跳过。
|
|
23
14
|
*/
|
|
24
15
|
|
|
25
16
|
const { readdir, readFile, stat } = require('node:fs/promises');
|
package/lib/mobile/git.js
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 移动端 Git
|
|
4
|
+
* 移动端 Git 管理器:`/api/mobile/v1/git/*` 的 handler。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 「仓库摘要 / 变更清单 / 单个文件的 diff / 历史 / 单次提交 / 分支 / 概览」加上
|
|
9
|
-
* 「暂存 / 取消暂存 / 提交 / 切分支 / 新建分支 / 推送到上游」这些动作,参数只有仓库、
|
|
10
|
-
* 相对路径、分支名、提交信息四类值(push 连分支名都不收:只推当前分支到它自己的上游)。
|
|
6
|
+
* 能力面刻意收成固定命名的动词,不接受「传一条 git 命令」:这条 API 的边界是不暴露 shell,
|
|
7
|
+
* 透传等于把 shell 交出去。参数只有仓库、相对路径、分支名、提交信息四类值。
|
|
11
8
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* 归属校验做两遍:workspaceId 必须是本设备已注册的工作区;仓库根必须是 git-repos.js 给这个
|
|
10
|
+
* 项目识别出来的根之一,于是随意传路径、`..`、仓库外目录都进不来。写操作按仓库串行,避免
|
|
11
|
+
* 并发写撞 `.git/index.lock`,写完顺手回一份新的变更清单。
|
|
15
12
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* 明确不做:合并 / 变基 / 拣选 / 冲突解决 / 强制推送 / 删除远端分支 / 改配置与凭据 /
|
|
20
|
-
* 子模块管理 / fetch、pull。**push 只做最窄的一档**(2026-10-22):推当前分支到它已有的上游,
|
|
21
|
-
* 不接受 `--force`、不收分支名参数 —— 远端分叉、没有上游一律拒掉并叫用户回电脑处理,
|
|
22
|
-
* 因为「拉下来再合」这件事本来就留在电脑上。丢弃改动也没做 —— 那是不可恢复的删工作,
|
|
23
|
-
* 接口留白比给一个能被误按的按钮安全。
|
|
13
|
+
* 明确不做合并 / 变基 / 拣选 / 冲突解决 / 强制推送 / 删除远端分支 / 改配置与凭据 / 子模块 /
|
|
14
|
+
* fetch、pull;push 只推当前分支到已有上游,远端分叉或没有上游一律拒掉。丢弃改动也没做,
|
|
15
|
+
* 接口留白比一个能被误按的按钮安全。
|
|
24
16
|
*/
|
|
25
17
|
|
|
26
18
|
const { isAbsolute, resolve } = require('node:path');
|
|
@@ -434,8 +426,8 @@ function cleanCommitMessage(raw) {
|
|
|
434
426
|
* `llm` 服务(跟渠道包、agent loop 同一条流式路径),让它写一行 Conventional Commits 标题填进
|
|
435
427
|
* 提交框 —— 只是**填框**,不提交、不推送。跟 VSCode 那颗一致:没暂存也要能写。
|
|
436
428
|
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
429
|
+
* 边界:模型由手机指定(选哪个是用户的事,宿主不猜);工作区干净就直接拒,让模型凭空写一条
|
|
430
|
+
* 比报错更糟。这里只读 diff,不写盘。
|
|
439
431
|
*/
|
|
440
432
|
async function handleGitCommitMessage(ctx, req, res) {
|
|
441
433
|
const body = await bodyJson(req);
|