dsh-plugin-mobile-link 0.6.6 → 0.7.0

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 CHANGED
@@ -80,7 +80,7 @@ dsh plugin --profile web add ./plugins/mobile-link
80
80
  dsh-mobile-link enroll --invite <邀请码> --relay wss://<你的站点>/dsh-link
81
81
  ```
82
82
 
83
- …or register it on the relay host with `relay/admin.py agent-register
83
+ …or register it on the relay host with `dsh-relay admin agent-register
84
84
  --write-config ~/.dsh/mobile-link/agent.json`.
85
85
 
86
86
  2. Mint a pairing code, and show it as a QR the phone camera can read:
package/cordis.patch.yml CHANGED
@@ -32,6 +32,12 @@
32
32
  # (the per-attach `device.summary` is still written).
33
33
  # diagnostics false refuses the phone's phase uploads (`_link/diagnostics`) with
34
34
  # `diagnostics/disabled`; the phone ignores it and stays connected.
35
+ # logs false refuses the phone's runtime logs (`_link/logs`) with
36
+ # `logs/disabled`; the phone keeps them in its own ring and retries.
37
+ # logsFile log spool root (default ~/.dsh/mobile-link/logs). One directory
38
+ # per device, one JSONL per local day, pruned to the last 7 days
39
+ # in the write path itself — there is no timer or background job
40
+ # to keep alive. Read it with `node scripts/dev/ios-logs.mjs`.
35
41
  # dshUrl explicit local DSH base URL override (default: discovery)
36
42
  #
37
43
  # Address resolution order: this config block, then DSH_RELAY_URL (or
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `_link/dirListing`:`workspaceFiles/list` 的同形增强副本,本机补 `mtimeMs`。
3
+ *
4
+ * 为什么要它:宿主 `workspaceFiles/list` 的条目只有 `name/type/size?`(宿主类型见
5
+ * `@deepseek-ai/dsh-api-workspace-files` 的 `WorkspaceDirectoryEntry`),
6
+ * 没有任何时间字段;`workspaceFiles/stat` 的 `version` 是 opaque token(类型注释明确
7
+ * "never parsed"),推不出时间。用户要的是"哪个文件最近被动过",所以由电脑侧补。
8
+ *
9
+ * 三条纪律(与 `session-digest.js` 同款):
10
+ * 1. **清单的所有者仍是宿主**:连接器只往条目上加一个字段,不删、不改、不重排;
11
+ * 宿主报错时错误信封逐字透传。
12
+ * 2. **拿不到就原样返回**:root 缺失/相对/越界、单条目 stat 失败、超出总预算——
13
+ * 一律退回未增强的原始清单。装饰永不弄坏清单,目录本身一定能打开。
14
+ * 3. **不提速、不重试**:一个条目最多 stat 一次;没有缓存(mtime 会变,缓存就要失效
15
+ * 逻辑,而列目录本来就是低频动作)。
16
+ *
17
+ * 符号链接用 `fs.stat`(跟随链接)报**目标**的时间——宿主列的 `type`/`size` 本来就是
18
+ * 按目标解析的(类型注释:"A symlink reports the type of its destination"),
19
+ * `lstat` 报的链接自身时间只在重指时变化,回答不了"内容什么时候改的"。
20
+ */
21
+
22
+ import { stat } from 'node:fs/promises'
23
+ import { isAbsolute, resolve, sep } from 'node:path'
24
+
25
+ /** 方法名(设备发起;`_link/` 前缀 = 连接器自答,永不转发宿主)。 */
26
+ export const DIR_LISTING_METHOD = '_link/dirListing'
27
+
28
+ /** 宿主的清单方法:连接器只读它,不改它。 */
29
+ const HOST_LIST_METHOD = 'workspaceFiles/list'
30
+
31
+ /** 可调的上限。`statBudgetMs` 是全部条目补时间的总预算(见 §2.2 失败语义表)。 */
32
+ export const LIMITS = Object.freeze({
33
+ statBudgetMs: 2000,
34
+ })
35
+
36
+ export function isDirListingMethod(method) {
37
+ return method === DIR_LISTING_METHOD
38
+ }
39
+
40
+ /**
41
+ * 给一份宿主目录清单补上 `mtimeMs`。
42
+ *
43
+ * 认两种输入形状(与 `digestSessionList` 同款):宿主信封 `{ ok, value: { entries } }`
44
+ * 以及直接给的 `{ entries }`。**返回的始终是同形状的值**,调用方不必再猜信封在哪一层。
45
+ * 结构不是预期的样子就原样返回入参——装饰不许把"看不懂"变成"改掉"。
46
+ *
47
+ * @param {object} options
48
+ * @param {{ rpc: (method: string, args: object) => Promise<unknown> }} options.dsh 宿主 RPC
49
+ * @param {object} options.args 设备发来的参数(`workspaceFileScopeId` / `path` / `root`)
50
+ * @param {{ debug?: (message: string) => void, warn?: (message: string) => void }} [options.logger]
51
+ * @param {(path: string) => Promise<{ mtimeMs: number }>} [options.statImpl] 可注入,供单测
52
+ * @param {number} [options.budgetMs] 总预算,缺省 `LIMITS.statBudgetMs`
53
+ * @returns {Promise<unknown>} 增强后的同形值;降级时是原始值
54
+ */
55
+ export async function dirListing({ dsh, args, logger, statImpl = stat, budgetMs = LIMITS.statBudgetMs }) {
56
+ const request = args ?? {}
57
+ const result = await dsh.rpc(HOST_LIST_METHOD, {
58
+ workspaceFileScopeId: request.workspaceFileScopeId,
59
+ path: typeof request.path === 'string' ? request.path : '.',
60
+ })
61
+
62
+ const entries = listingEntries(result)
63
+ if (entries === null) {
64
+ logger?.debug?.('dir-listing: host answered a shape this connector does not decorate')
65
+ return result
66
+ }
67
+
68
+ const directory = resolveDirectory(request.root, entries.path)
69
+ if (directory === null) {
70
+ // root 缺失 / 非绝对 / 宿主回的 path 越出 root:不是错误,只是补不了时间。
71
+ logger?.debug?.(`dir-listing: cannot resolve the listed directory from root ${JSON.stringify(request.root)}`)
72
+ return result
73
+ }
74
+
75
+ const decorated = await withBudget(
76
+ Promise.all(entries.items.map((entry) => decorate(entry, directory, statImpl))),
77
+ budgetMs
78
+ )
79
+ if (decorated === null) {
80
+ logger?.warn?.(`dir-listing: stat budget of ${budgetMs}ms ran out, answering the plain listing`)
81
+ return result
82
+ }
83
+ return replaceEntries(result, decorated)
84
+ }
85
+
86
+ /**
87
+ * 取出清单本体(`{ entries: [...], path }`),形状不对时返回 null。
88
+ *
89
+ * 信封可能是 `result` 本身,也可能是 `result.value`——按"哪一层有 entries 数组"判定,
90
+ * 与 `digestSessionList` 同一读法。
91
+ */
92
+ function listingEntries(result) {
93
+ if (result === null || typeof result !== 'object') return null
94
+ if (result.ok !== true) return null
95
+ const container = Array.isArray(result.entries) ? result : result.value
96
+ if (container === null || typeof container !== 'object' || !Array.isArray(container.entries)) return null
97
+ return { path: container.path, items: container.entries }
98
+ }
99
+
100
+ /** 按原形状放回增强后的条目数组。 */
101
+ function replaceEntries(result, items) {
102
+ if (Array.isArray(result.entries)) return { ...result, entries: items }
103
+ return { ...result, value: { ...result.value, entries: items } }
104
+ }
105
+
106
+ /**
107
+ * 宿主回的 `path` 词法解析成绝对目录;解析不了(要求降级)时返回 null。
108
+ *
109
+ * - `path` 可以是相对的(`""` 表根、`"docs"`)也可以是绝对的(App/宿主都有先例)。
110
+ * - 结果必须位于 root 之内:`resolved === root || resolved.startsWith(root + sep)`。
111
+ * 只比 `startsWith(root)` 会把 `/a/b-sibling` 当成 `/a/b` 的子路径。
112
+ * - **不做 realpath 越界拦截**(设计 §2.2 决策 1):符号链接的目标可以合法地在工作区
113
+ * 之外,宿主今天已经按目标报 `type`/`size`,这里保持同一语义。
114
+ */
115
+ function resolveDirectory(root, echoedPath) {
116
+ if (typeof root !== 'string' || !isAbsolute(root)) return null
117
+ const base = resolve(root)
118
+ const echoed = typeof echoedPath === 'string' ? echoedPath : ''
119
+ const resolved = isAbsolute(echoed) ? resolve(echoed) : resolve(base, echoed)
120
+ if (resolved !== base && !resolved.startsWith(base + sep)) return null
121
+ return resolved
122
+ }
123
+
124
+ /**
125
+ * 一条条目:stat 成功且时间可用就加 `mtimeMs`,其余情况原样返回。
126
+ *
127
+ * 条目名逐段校验:含 `/`、`\` 或等于 `.` / `..` 的名字直接跳过(宿主契约说是 basename,
128
+ * 这里是纵深防御,防的是异常回包,不是防用户)。跳过的意思是"不给它补时间",
129
+ * **不是把条目从清单里删掉**——清单的所有者是宿主。
130
+ */
131
+ async function decorate(entry, directory, statImpl) {
132
+ if (entry === null || typeof entry !== 'object') return entry
133
+ if (typeof entry.name !== 'string' || !isPlainName(entry.name)) return entry
134
+ try {
135
+ const info = await statImpl(resolve(directory, entry.name))
136
+ const mtimeMs = info?.mtimeMs
137
+ if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return entry
138
+ const rounded = Math.round(mtimeMs)
139
+ if (!Number.isFinite(rounded)) return entry
140
+ return { ...entry, mtimeMs: rounded }
141
+ } catch {
142
+ // 断链、权限、竞态删除:这个条目没有时间,其余条目照旧。
143
+ return entry
144
+ }
145
+ }
146
+
147
+ function isPlainName(name) {
148
+ return name.length > 0 && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
149
+ }
150
+
151
+ /**
152
+ * 给整批 stat 一个总预算。超时返回 `null`(调用方据此降级),
153
+ * **不取消**后台的 stat——它们会自然收尾,取消反而要给每条挂一个 signal。
154
+ */
155
+ async function withBudget(work, budgetMs) {
156
+ if (!(budgetMs > 0)) return await work
157
+ let timer
158
+ const expiry = new Promise((resolve) => {
159
+ timer = setTimeout(() => resolve(null), budgetMs)
160
+ })
161
+ try {
162
+ const outcome = await Promise.race([work, expiry])
163
+ return outcome === null ? null : outcome
164
+ } finally {
165
+ clearTimeout(timer)
166
+ }
167
+ }
package/lib/dlp.js CHANGED
@@ -6,6 +6,8 @@
6
6
  * Wire contract: docs/RELAY-PROTOCOL.md §3.
7
7
  */
8
8
 
9
+ import { repairLoneSurrogatesWithCount } from './unicode.js'
10
+
9
11
  /** Frame ceiling aligned with DSH's image-attachment limit (spec §5). */
10
12
  export const MAX_FRAME_BYTES = 32 * 1024 * 1024
11
13
 
@@ -40,8 +42,23 @@ export const RELAY_TO_AGENT_CONTROL = new Set(['fsGetCancel'])
40
42
 
41
43
  const ID_FRAMES = new Set(['req', 'open', 'cancel', 'res', 'item', 'end', 'streamError', 'eventResult'])
42
44
 
45
+ /**
46
+ * 出站帧的唯一序列化出口。
47
+ *
48
+ * 为什么要过一遍代理项修复:宿主会给我们**截断过**的预览(`turnOutline` 的
49
+ * prompt/response 截到 50/120 个码元),截断点落在 emoji 中间时字符串里就有
50
+ * 落单代理项,`JSON.stringify` 会忠实写成 `"\ud83d…"`。中转能把它修回 U+FFFD,
51
+ * 但解码方(iOS `JSONDecoder`)遇到这种转义会整条拒绝,所以生产端先把帧修好。
52
+ */
43
53
  export function encodeFrame(frame) {
44
- return JSON.stringify(frame)
54
+ return encodeFrameChecked(frame).text
55
+ }
56
+
57
+ /** `encodeFrame` 的带诊断版本:`repaired` 是这条帧里修掉的落单代理项条数。 */
58
+ export function encodeFrameChecked(frame) {
59
+ const raw = JSON.stringify(frame)
60
+ const { text, repaired } = repairLoneSurrogatesWithCount(raw)
61
+ return { text, repaired }
45
62
  }
46
63
 
47
64
  /**
package/lib/enroll.js CHANGED
@@ -2,11 +2,11 @@
2
2
  * First-run enrollment: turn an invite code into a durable agent identity.
3
3
  *
4
4
  * Until now an identity could only be provisioned by whoever owns the relay
5
- * (`relay/admin.py agent-register --write-config`). That does not distribute:
5
+ * (`dsh-relay admin agent-register --write-config`). That does not distribute:
6
6
  * somebody else installs DSH plus this connector on their own computer and has
7
7
  * to be handed a credential by the operator, out of band.
8
8
  *
9
- * So the relay grew `POST /agents/enroll` (relay/api.py), which redeems a
9
+ * So the relay grew `POST /agents/enroll` (relay-go/internal/httpapi), which redeems a
10
10
  * one-time invite code for a fresh `agentId` / `agentSecret`. This module is the
11
11
  * connector half: one call, then the identity is written to the same
12
12
  * `~/.dsh/mobile-link/agent.json` (mode 0600) that `agent-register` writes, so
package/lib/files.js CHANGED
@@ -26,6 +26,24 @@ export function isFileMethod(method) {
26
26
  return method === FILE_BEGIN || method === FILE_CHUNK || method === FILE_END
27
27
  }
28
28
 
29
+ /**
30
+ * 多久没有新分片就认定这次上传已经没人要了。
31
+ *
32
+ * 这个时限不是"请求超时":正在慢慢传的上传每来一片就重新计时。它要解决的是
33
+ * 一种**线上没有信号**的中断——relay 的 `PUT /files/up` 被手机掐掉之后,两个
34
+ * 中转(Python 与 Go)都只在本地摘掉桥接,**不会给连接器发任何取消帧**,也不会
35
+ * 断开 agent 的 WebSocket。连接器因此永远等不到下一次 `fsPutChunk`/`fsPutEnd`,
36
+ * `.part` 的 fd 就一直开着(M8 文件联调实测)。
37
+ *
38
+ * 取值要能容下慢速链路:1 MiB 一片,即便链路慢到 100 KB/s 也约 10 秒一片;
39
+ * 10 分钟的空档只可能是手机侧被系统挂起或请求早已死掉。真被清扫时,这次上传
40
+ * 会以 `fsErr`/HTTP 错误明确失败,用户重传即可,不会静默写出半截文件。
41
+ */
42
+ export const DEFAULT_TRANSFER_IDLE_MS = 10 * 60 * 1000
43
+
44
+ /** 空闲清扫的周期:只决定"最晚多久之后"释放,不决定"多久算空闲"。 */
45
+ export const DEFAULT_SWEEP_MS = 30 * 1000
46
+
29
47
  /**
30
48
  * One in-flight transfer.
31
49
  *
@@ -40,7 +58,10 @@ export function isFileMethod(method) {
40
58
  * checks out, so a half-sent file is never mistaken for a complete one.
41
59
  */
42
60
  class Transfer {
43
- constructor({ sessionId, name, bytes }) {
61
+ /** 注入的时钟;FileInbox 把它传给每次 Transfer,保证"空闲多久"只有一份口径。 */
62
+ #now
63
+
64
+ constructor({ sessionId, name, bytes, now = Date.now }) {
44
65
  this.sessionId = sessionId
45
66
  this.name = basename(name || 'file')
46
67
  this.bytes = Number.isFinite(bytes) ? bytes : undefined
@@ -51,6 +72,12 @@ class Transfer {
51
72
  this.directory = join(dshHome(), 'inbox', sessionId)
52
73
  mkdirSync(this.directory, { recursive: true, mode: 0o700 })
53
74
  this.staging = join(this.directory, `.${this.name}.part`)
75
+ // 最后一次收到分片的时刻(空闲清扫用)。时钟由 FileInbox 注入,测试里可精确推进。
76
+ this.#now = now
77
+ this.lastActivityAt = now()
78
+ // 释放只能发生一次:fd 与 `.part` 是同一份资源的两个面,重复释放要么是空操作、
79
+ // 要么会删到别人刚建好的同路径文件(重传接管时正好是这种情况)。
80
+ this.released = false
54
81
  // Opened once and kept: reopening per chunk would be a syscall per chunk for
55
82
  // no benefit. `w` truncates, which is what a retried transfer wants.
56
83
  this.fd = undefined
@@ -72,6 +99,7 @@ class Transfer {
72
99
  writeSync(this.#handle(), buffer)
73
100
  this.received += buffer.length
74
101
  this.expectedSeq += 1
102
+ this.lastActivityAt = this.#now()
75
103
  }
76
104
 
77
105
  finish() {
@@ -95,7 +123,15 @@ class Transfer {
95
123
  }
96
124
  }
97
125
 
126
+ /**
127
+ * 放弃这次上传:关句柄、删暂存文件,幂等。
128
+ *
129
+ * **只碰 `.part`**:`finish()` 已把暂存改名成成品,这里再调一次也只是对一个
130
+ * 不存在的暂存路径做 no-op,成品不会被误删(`已完成的文件不会被清理路径误删` 用例守着)。
131
+ */
98
132
  dispose() {
133
+ if (this.released) return
134
+ this.released = true
99
135
  this.#close()
100
136
  rmSync(this.staging, { force: true })
101
137
  }
@@ -105,9 +141,56 @@ class Transfer {
105
141
  export class FileInbox {
106
142
  #transfers = new Map()
107
143
  #logger
144
+ #idleMs
145
+ #now
146
+ #sweeper
108
147
 
109
- constructor(logger) {
148
+ /**
149
+ * @param {object} [logger]
150
+ * @param {object} [options]
151
+ * @param {number} [options.idleMs] 多久没有新分片算"没人要了"
152
+ * @param {number} [options.sweepMs] 空闲清扫周期
153
+ * @param {() => number} [options.now] 注入时钟(测试用;生产就是 `Date.now`)
154
+ */
155
+ constructor(logger, {
156
+ idleMs = DEFAULT_TRANSFER_IDLE_MS,
157
+ sweepMs = DEFAULT_SWEEP_MS,
158
+ now = Date.now,
159
+ } = {}) {
110
160
  this.#logger = logger
161
+ this.#idleMs = idleMs
162
+ this.#now = now
163
+ // 定时器是**兜底信号**:relay 从不为上传发取消帧,也不在上传中断时断开
164
+ // agent 的 WebSocket,所以"再也不会来分片"只能由时间发现。`unref` 掉,
165
+ // 别让一个空的收件箱按住进程退出(与 link.js / jobs-roster.js 同一口径)。
166
+ this.#sweeper = setInterval(() => this.sweep(), Math.max(1, sweepMs))
167
+ this.#sweeper.unref?.()
168
+ }
169
+
170
+ /**
171
+ * 释放空闲超过 `idleMs` 的上传;返回本次释放的个数。
172
+ *
173
+ * 单独暴露是为了让测试给出确定时刻,而不是 sleep 等定时器;生产里由构造时
174
+ * 安好的 interval 调用。
175
+ */
176
+ sweep(now = this.#now()) {
177
+ let released = 0
178
+ for (const [transferId, transfer] of this.#transfers) {
179
+ const idle = now - transfer.lastActivityAt
180
+ if (idle < this.#idleMs) continue
181
+ this.#release(transferId, transfer)
182
+ this.#logger.warn?.(
183
+ `mobile-link: file transfer ${transferId} released after ${Math.round(idle / 1000)}s idle`,
184
+ )
185
+ released += 1
186
+ }
187
+ return released
188
+ }
189
+
190
+ /** 收掉一次在传上传;释放本身幂等,重复到达的取消/清扫都是空操作。 */
191
+ #release(transferId, transfer) {
192
+ transfer.dispose()
193
+ this.#transfers.delete(transferId)
111
194
  }
112
195
 
113
196
  /** Answers one reserved call. Throws `Error` for the router to report. */
@@ -118,12 +201,21 @@ export class FileInbox {
118
201
  if (method === FILE_BEGIN) {
119
202
  const sessionId = String(args.sessionId ?? '')
120
203
  if (!sessionId) throw new Error('sessionId is required')
121
- this.#transfers.get(transferId)?.dispose()
122
- this.#transfers.set(
123
- transferId,
124
- new Transfer({ sessionId, name: args.name, bytes: Number(args.bytes) })
204
+ const transfer = new Transfer({
205
+ sessionId, name: args.name, bytes: Number(args.bytes), now: this.#now,
206
+ })
207
+ // 一条 `.part` 同时只能有一个写者。两种"重来"都要在这里把旧的收掉:
208
+ // 同一个 bid 重来,以及手机换了新 bid 但落盘路径相同(中断后重传)。
209
+ // 不收的话旧 fd 会一直握着一个已被 unlink 的 inode——M8 联调实测的泄漏。
210
+ for (const [id, existing] of this.#transfers) {
211
+ if (id === transferId || existing.staging === transfer.staging) {
212
+ this.#release(id, existing)
213
+ }
214
+ }
215
+ this.#transfers.set(transferId, transfer)
216
+ this.#logger.debug?.(
217
+ `mobile-link: file transfer ${transferId} started for ${sessionId}`,
125
218
  )
126
- this.#logger.debug?.(`mobile-link: file transfer ${transferId} started for ${sessionId}`)
127
219
  return { accepted: true }
128
220
  }
129
221
 
@@ -131,7 +223,14 @@ export class FileInbox {
131
223
  if (!transfer) throw new Error(`unknown transfer ${transferId}`)
132
224
 
133
225
  if (method === FILE_CHUNK) {
134
- transfer.append(Number(args.seq ?? 0), String(args.data ?? ''))
226
+ try {
227
+ transfer.append(Number(args.seq ?? 0), String(args.data ?? ''))
228
+ } catch (error) {
229
+ // 这一片已经给这次上传判了死(调用方随后回 `fsErr`):句柄与 `.part`
230
+ // 一起收掉,别等空闲清扫——它可能永远等不到下一次触发。
231
+ this.#release(transferId, transfer)
232
+ throw error
233
+ }
135
234
  return { received: transfer.received }
136
235
  }
137
236
 
@@ -143,14 +242,21 @@ export class FileInbox {
143
242
  )
144
243
  return done
145
244
  } finally {
146
- transfer.dispose()
147
- this.#transfers.delete(transferId)
245
+ this.#release(transferId, transfer)
148
246
  }
149
247
  }
150
248
 
249
+ /** 放弃一次上传(relay 的 `fsPutCancel`)。未知 id 是空操作,与 `fsGetCancel` 同口径。 */
250
+ cancel(transferId) {
251
+ const id = String(transferId ?? '')
252
+ const transfer = this.#transfers.get(id)
253
+ if (transfer) this.#release(id, transfer)
254
+ }
255
+
151
256
  /** Drops every in-flight transfer, e.g. when the link goes down. */
152
257
  clear() {
153
- for (const transfer of this.#transfers.values()) transfer.dispose()
154
- this.#transfers.clear()
258
+ for (const [transferId, transfer] of this.#transfers) {
259
+ this.#release(transferId, transfer)
260
+ }
155
261
  }
156
262
  }
package/lib/git.js CHANGED
@@ -19,6 +19,7 @@
19
19
  import { execFile } from 'node:child_process'
20
20
  import { statSync } from 'node:fs'
21
21
  import { isAbsolute, resolve } from 'node:path'
22
+ import { truncateSafe } from './unicode.js'
22
23
 
23
24
  /** Reserved method names this module answers; never forwarded to the Host. */
24
25
  export const GIT_STATUS = '_link/gitStatus'
@@ -44,10 +45,24 @@ export const LIMITS = {
44
45
  readTimeoutMs: 15_000,
45
46
  }
46
47
 
47
- /** The environment every git call runs in. */
48
- function gitEnv() {
48
+ /** The environment every git call runs in.
49
+ *
50
+ * 导出给单测的 fixture 使用:测试自己也要起 git,两边必须是同一份环境口径,
51
+ * 否则 hook 里跑测试时 fixture 会写进外层仓库。
52
+ */
53
+ export function gitEnv() {
54
+ const env = { ...process.env }
55
+ // "看哪个仓库"只由 `-C <dir>` 决定。调用方环境里可能带着仓库定位变量(从
56
+ // .githooks 里启动 DSH、或在 git rebase 里启动时都会这样),它们会盖掉 `-C`,
57
+ // 让手机看到另一个仓库、甚至报 "nothing to commit"。这里清掉,归属唯一。
58
+ for (const key of [
59
+ 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX', 'GIT_NAMESPACE',
60
+ 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
61
+ ]) {
62
+ delete env[key]
63
+ }
49
64
  return {
50
- ...process.env,
65
+ ...env,
51
66
  // Never wait for a credential prompt or open an editor: a phone review
52
67
  // must not be able to wedge a process on the computer.
53
68
  GIT_TERMINAL_PROMPT: '0',
@@ -400,7 +415,7 @@ export class GitBridge {
400
415
  staged,
401
416
  untracked,
402
417
  binary,
403
- text: binary ? '' : text.slice(0, LIMITS.diffBytes),
418
+ text: binary ? '' : truncateSafe(text, LIMITS.diffBytes),
404
419
  truncated: !binary && buffer.length > LIMITS.diffBytes,
405
420
  }
406
421
  }
@@ -450,7 +465,7 @@ export class GitBridge {
450
465
  sha,
451
466
  path,
452
467
  binary,
453
- text: binary ? '' : text.slice(0, LIMITS.diffBytes),
468
+ text: binary ? '' : truncateSafe(text, LIMITS.diffBytes),
454
469
  truncated: !binary && buffer.length > LIMITS.diffBytes,
455
470
  }
456
471
  }
package/lib/hello.js CHANGED
@@ -98,6 +98,28 @@ export const SERVER_CAPABILITIES = [
98
98
  // search-progress: 扫描式搜索的每一页都带 `totalChunks` / `remainingChunks`,
99
99
  // 界面据此从第一页起就能显示「已扫 k/N 片」(026 之后的 App 才读)。
100
100
  'search-progress',
101
+ // history-compression: `_link/sessionWindow` 可按要求把回包压成
102
+ // `deflate-raw` 的 base64 信封(见 history/compress.js)。手机只在看到这条
103
+ // 能力时请求;旧客户端不传 encoding,回包与改动前逐字一致。
104
+ 'history-compression',
105
+ // history-fold-summary: `_link/sessionWindow` 接受 `fold: true`,把已完成轮次的
106
+ // 过程事件折成一条 `+link/fold` 摘要(见 history/fold.js);手机展开时用普通请求取回。
107
+ 'history-fold-summary',
108
+ // history-fold-summary-v2: `_link/sessionWindow` 另接受 `fold: "summary"`——概要先行
109
+ // 取数(只回讲话 + `+link/fold` v2 概要 + 正式回答,按整轮切页,见
110
+ // history/fold-summary.js 与 history/summary-window.js)。**独立能力位**:
111
+ // 不声明 `"summary"` 的老客户端拿到的回包与改动前逐字节相同。
112
+ 'history-fold-summary-v2',
113
+ 'history-coverage',
114
+ // jobs-roster: 连接器答 `_link/jobRoster`(逐活会话聚合宿主任务),并推
115
+ // `link/jobs` 帧(spec 018)。手机**只按这条能力**决定是否渲染任务面板、列表行
116
+ // 右侧计数与卡片新行:无此能力的旧连接器,手机整块不出现(界面与升级前逐屏一致)。
117
+ 'jobs-roster',
118
+ // dir-mtimes: 连接器答 `_link/dirListing`——宿主 `workspaceFiles/list` 的同形副本,
119
+ // 在电脑本机对每个条目补一个 `mtimeMs`(宿主类型里没有时间字段,`stat` 的 version
120
+ // 是 opaque token,推不出时间)。手机**只按这条能力**决定要不要走新方法:没有它的
121
+ // 旧连接器仍走 `workspaceFiles/list`,文件页与升级前逐屏一致(只是没有时间),不报错。
122
+ 'dir-mtimes',
101
123
  'device-subprotocol-auth', 'persistent-pair-code', 'account-state', 'account-state-push',
102
124
  ]
103
125
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `_link/sessionWindow` 的可选压缩(能力 `history-compression`)。
3
+ *
4
+ * 为什么在应用层压:中转的 WebSocket 关闭了 permessage-deflate(`compress=False`),
5
+ * iOS 的 `URLSessionWebSocketTask` 也不支持协商压缩。要在不动中转的前提下减少
6
+ * 公网字节,只能在连接器把 value 压成 DEFLATE、手机侧自己解(与 spec 021 的
7
+ * `_link/sessionDigest` 同一套信封与同一种编码)。
8
+ *
9
+ * 编码选 `deflate-raw`(RFC1951)而不是 gzip:Apple `Compression` 框架的
10
+ * `COMPRESSION_ZLIB` 直接解原始 DEFLATE,gzip 包装还得引 libz。
11
+ *
12
+ * 兼容:不传 `encoding` 或传了不认识的值时原样返回 JSON,老 App 行为不变;
13
+ * 新 App 只在 `_link/hello` 声明了 `history-compression` 时才请求压缩。
14
+ */
15
+
16
+ import { deflateRawSync } from 'node:zlib'
17
+
18
+ /// 与 `session-digest.js` 的 `DEFLATE_ENCODING` 同一词表(协议里只能有一个名字)。
19
+ export const HISTORY_COMPRESSION_ENCODING = 'deflate-raw'
20
+
21
+ /// 能力名:连接器在 `_link/hello` 里声明,手机据此决定要不要请求压缩。
22
+ export const HISTORY_COMPRESSION_CAPABILITY = 'history-compression'
23
+
24
+ /// 把一页的值包成 `{encoding, value(base64), rawBytes}` 信封。
25
+ ///
26
+ /// `rawBytes` 是解压后的字节数:`compression_decode_buffer` 必须先知道输出多大,
27
+ /// 让连接器一次给足,手机就不用"猜容量、失败再翻倍"。
28
+ export function compressHistoryValue(value) {
29
+ const raw = Buffer.from(JSON.stringify(value ?? null), 'utf8')
30
+ return {
31
+ encoding: HISTORY_COMPRESSION_ENCODING,
32
+ value: deflateRawSync(raw).toString('base64'),
33
+ rawBytes: raw.length,
34
+ }
35
+ }
36
+
37
+ /// 按调用方要求决定压不压。只有明确要 `deflate-raw` 才压。
38
+ export function maybeCompressHistoryValue(value, encoding) {
39
+ return encoding === HISTORY_COMPRESSION_ENCODING ? compressHistoryValue(value) : value
40
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 已知为空的推导:一次窗口回包里,哪些号确认没有内容。
3
+ *
4
+ * 只在"从最老一条返回到窗口上界"之间下结论:窗口下界到最老返回之间可能被
5
+ * 字节/条数截断,那一段没有被完整扫描,不能判空。上界取 `before - 1`。
6
+ */
7
+ export function emptySpans(records, lower, upper) {
8
+ const seqs = (records ?? [])
9
+ .map((record) => record?.event?.seq)
10
+ .filter((seq) => Number.isInteger(seq) && seq >= lower && seq <= upper)
11
+ .sort((a, b) => a - b)
12
+ if (seqs.length === 0) return []
13
+ const spans = []
14
+ let cursor = seqs[0]
15
+ for (const seq of seqs) {
16
+ if (seq > cursor) spans.push([cursor, seq - 1])
17
+ cursor = seq + 1
18
+ }
19
+ if (upper >= cursor) spans.push([cursor, upper])
20
+ return spans
21
+ }