dsh-plugin-mobile-link 0.2.0 → 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/README.md +7 -0
- package/lib/cli.js +23 -1
- package/lib/dlp.js +9 -0
- package/lib/git.js +506 -0
- package/lib/hello.js +30 -1
- package/lib/link.js +20 -1
- package/lib/qr.js +29 -0
- package/lib/router.js +169 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# dsh-plugin-mobile-link
|
|
2
2
|
|
|
3
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
4
|
+
[](https://github.com/Dominic789654/awesome-deepseek-harness)
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-plugin-mobile-link)
|
|
6
|
+
[](../../LICENSE)
|
|
7
|
+
|
|
8
|
+
> 面向:使用者与实现者 · 状态:stable · 最近核对:2026-09-20
|
|
9
|
+
|
|
3
10
|
**The computer half of [DSH Mobile](https://github.com/jayantTang/DSH_Mobile): it
|
|
4
11
|
dials a relay over WSS, so the native iOS client reaches this DSH instance even
|
|
5
12
|
though the computer has no public IP.**
|
package/lib/cli.js
CHANGED
|
@@ -26,6 +26,7 @@ import { rename, writeFile } from 'node:fs/promises'
|
|
|
26
26
|
import { enrollAgent, inviteFrom } from './enroll.js'
|
|
27
27
|
import { MobileLinkAgent } from './link.js'
|
|
28
28
|
import { mintPairCode } from './pairing.js'
|
|
29
|
+
import { qrTerminal } from './qr.js'
|
|
29
30
|
import { INVITE_ENV, defaultStatePath, resolveIdentity } from './state.js'
|
|
30
31
|
|
|
31
32
|
/** Atomic status snapshot so readers never observe a half-written file. */
|
|
@@ -89,7 +90,28 @@ async function main() {
|
|
|
89
90
|
relayUrl: settled.relayUrl,
|
|
90
91
|
stateFile: settled.stateFile,
|
|
91
92
|
}, null, 2)}\n`)
|
|
92
|
-
|
|
93
|
+
// 登记只是第一步:还有两步(重启、扫码)不写在输出里,新用户就会以为"登记完就好了"。
|
|
94
|
+
// 第一位试用用户正是这样——登记了电脑,却没有配对手机。
|
|
95
|
+
//
|
|
96
|
+
// 反正人已经坐在终端前、手机就在手边,索性把配对码也申请出来、画成终端二维码:
|
|
97
|
+
// 扫这一屏等于在 App 里填「中转服务器 + 配对码」,少一次开页面找端口的往返。
|
|
98
|
+
// 申请失败不算失败——登记本身已经成功,退回到"去二维码页取"的说法。
|
|
99
|
+
process.stdout.write('这台电脑已登记。接下来还有两步:\n'
|
|
100
|
+
+ ' 1. 重启 DSH,让连接器用上这个身份;\n'
|
|
101
|
+
+ ' 2. 手机 App 底部「扫码配对」扫下面的二维码(等同于点开二维码页取码)。\n')
|
|
102
|
+
try {
|
|
103
|
+
const identity = await resolveIdentity({ stateFile: settled.stateFile })
|
|
104
|
+
const minted = await mintPairCode({ identity, ttlMs: 10 * 60 * 1000 })
|
|
105
|
+
const minutes = Math.round((minted.expiresAt - Date.now()) / 60000)
|
|
106
|
+
process.stdout.write('\n' + qrTerminal(minted.qrPayload) + '\n')
|
|
107
|
+
process.stdout.write(`配对码 ${minted.code}(约 ${minutes} 分钟有效,一次性)\n`)
|
|
108
|
+
process.stdout.write('也可以在这台电脑上打开 http://127.0.0.1:<DSH 端口>/mobile-link/qr 取新码;\n'
|
|
109
|
+
+ '端口在 DSH 启动时打印,也在 ~/.dsh/desktop-shell/endpoint.json 里。\n')
|
|
110
|
+
} catch (error) {
|
|
111
|
+
process.stdout.write('(这次没能预先申请配对码:' + (error?.message ?? error) + ')\n'
|
|
112
|
+
+ '重启 DSH 后,在这台电脑的浏览器里打开 http://127.0.0.1:<DSH 端口>/mobile-link/qr,'
|
|
113
|
+
+ '用手机 App 的「扫码配对」扫它。\n')
|
|
114
|
+
}
|
|
93
115
|
return 0
|
|
94
116
|
}
|
|
95
117
|
|
package/lib/dlp.js
CHANGED
|
@@ -190,6 +190,15 @@ export class StreamTable {
|
|
|
190
190
|
return this.byDlp.get(StreamTable.key(deviceId, dlpId))
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/** Every open entry for one device, in insertion order. */
|
|
194
|
+
byDevice(deviceId) {
|
|
195
|
+
const entries = []
|
|
196
|
+
for (const entry of this.byMux.values()) {
|
|
197
|
+
if (entry.deviceId === deviceId) entries.push(entry)
|
|
198
|
+
}
|
|
199
|
+
return entries
|
|
200
|
+
}
|
|
201
|
+
|
|
193
202
|
entryOfMux(muxId) {
|
|
194
203
|
return this.byMux.get(muxId)
|
|
195
204
|
}
|
package/lib/git.js
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git status, diffs and history, answered on the computer itself.
|
|
3
|
+
*
|
|
4
|
+
* The Host has no git endpoint — nothing in its 47 RPC methods runs a command —
|
|
5
|
+
* but the connector is already a process on the machine that holds the work
|
|
6
|
+
* tree, and the `_link/file*` upload path proved the shape: a reserved method
|
|
7
|
+
* name answered here instead of being forwarded. So git runs here, in the
|
|
8
|
+
* session's own directory, and the phone gets what a person wants to check
|
|
9
|
+
* before approving an agent's work: which files changed, what changed in them,
|
|
10
|
+
* and what the recent commits were.
|
|
11
|
+
*
|
|
12
|
+
* **Read-only by construction.** The command whitelist below is the entire
|
|
13
|
+
* surface: status, diff, log, show, rev-parse, numstat. There is no way to
|
|
14
|
+
* commit, stage, checkout or clean through this path, and every command runs
|
|
15
|
+
* with `GIT_OPTIONAL_LOCKS=0` so merely looking at a repository can never leave
|
|
16
|
+
* an `index.lock` behind for the agent to trip over.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFile } from 'node:child_process'
|
|
20
|
+
import { statSync } from 'node:fs'
|
|
21
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
22
|
+
|
|
23
|
+
/** Reserved method names this module answers; never forwarded to the Host. */
|
|
24
|
+
export const GIT_STATUS = '_link/gitStatus'
|
|
25
|
+
export const GIT_DIFF = '_link/gitDiff'
|
|
26
|
+
export const GIT_LOG = '_link/gitLog'
|
|
27
|
+
export const GIT_SHOW = '_link/gitShow'
|
|
28
|
+
export const GIT_FILE = '_link/gitFile'
|
|
29
|
+
|
|
30
|
+
const METHODS = new Set([GIT_STATUS, GIT_DIFF, GIT_LOG, GIT_SHOW, GIT_FILE])
|
|
31
|
+
|
|
32
|
+
export function isGitMethod(method) {
|
|
33
|
+
return METHODS.has(method)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Output ceilings: a phone review, not a `git log | less`. */
|
|
37
|
+
export const LIMITS = {
|
|
38
|
+
statusBytes: 1024 * 1024,
|
|
39
|
+
diffBytes: 256 * 1024,
|
|
40
|
+
logBytes: 512 * 1024,
|
|
41
|
+
fileBytes: 8 * 1024 * 1024,
|
|
42
|
+
logPage: 20,
|
|
43
|
+
statusTimeoutMs: 15_000,
|
|
44
|
+
readTimeoutMs: 15_000,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The environment every git call runs in. */
|
|
48
|
+
function gitEnv() {
|
|
49
|
+
return {
|
|
50
|
+
...process.env,
|
|
51
|
+
// Never wait for a credential prompt or open an editor: a phone review
|
|
52
|
+
// must not be able to wedge a process on the computer.
|
|
53
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
54
|
+
GIT_PAGER: 'cat',
|
|
55
|
+
GIT_EDITOR: 'true',
|
|
56
|
+
// Looking at a repository must not take its locks — the agent may be
|
|
57
|
+
// working in it at the same moment.
|
|
58
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
59
|
+
// Stable field order and dates; the parsers below depend on it.
|
|
60
|
+
LC_ALL: 'C',
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A refusal the phone can branch on, rather than a string to match. */
|
|
65
|
+
export class GitError extends Error {
|
|
66
|
+
constructor(code, message, details = {}) {
|
|
67
|
+
super(message)
|
|
68
|
+
this.code = code
|
|
69
|
+
this.details = details
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Runs one git command in `cwd`.
|
|
75
|
+
*
|
|
76
|
+
* `okCodes` exists because `git diff` answers 1 when it found differences —
|
|
77
|
+
* which is the normal case here, not a failure.
|
|
78
|
+
*/
|
|
79
|
+
function runGit(cwd, argv, { timeoutMs, maxBytes, okCodes = [0] } = {}) {
|
|
80
|
+
return new Promise((resolvePromise, reject) => {
|
|
81
|
+
execFile(
|
|
82
|
+
'git',
|
|
83
|
+
['-C', cwd, '--no-pager', '-c', 'core.quotepath=false', ...argv],
|
|
84
|
+
{
|
|
85
|
+
timeout: timeoutMs ?? LIMITS.readTimeoutMs,
|
|
86
|
+
maxBuffer: (maxBytes ?? LIMITS.diffBytes) + 1024,
|
|
87
|
+
env: gitEnv(),
|
|
88
|
+
encoding: 'buffer',
|
|
89
|
+
},
|
|
90
|
+
(error, stdout, stderr) => {
|
|
91
|
+
const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? '')
|
|
92
|
+
if (error) {
|
|
93
|
+
// `execFile` reports a spawn failure (no git installed) as ENOENT.
|
|
94
|
+
if (error.code === 'ENOENT') {
|
|
95
|
+
reject(new GitError('git/unavailable', '这台电脑上没有找到 git'))
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (error.killed || error.signal) {
|
|
99
|
+
reject(new GitError('git/timed-out', 'git 命令超过时限,已中止'))
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
|
103
|
+
reject(new GitError('git/too-large', '命令输出超过上限,请缩小范围'))
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const code = typeof error.code === 'number' ? error.code : 1
|
|
107
|
+
if (okCodes.includes(code)) {
|
|
108
|
+
resolvePromise(out)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const text = (Buffer.isBuffer(stderr) ? stderr : Buffer.from(stderr ?? '')).toString('utf8')
|
|
112
|
+
reject(new GitError('git/failed', text.trim().split('\n').slice(-1)[0] || 'git 命令失败', {
|
|
113
|
+
exitCode: code,
|
|
114
|
+
}))
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
resolvePromise(out)
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Resolves a directory that is inside a work tree, and that tree's root. */
|
|
124
|
+
async function repository(cwd) {
|
|
125
|
+
const directory = String(cwd ?? '')
|
|
126
|
+
if (!directory || !isAbsolute(directory)) {
|
|
127
|
+
throw new GitError('git/bad-request', '需要一个绝对路径的工作目录')
|
|
128
|
+
}
|
|
129
|
+
let stats
|
|
130
|
+
try {
|
|
131
|
+
stats = statSync(resolve(directory))
|
|
132
|
+
} catch {
|
|
133
|
+
throw new GitError('git/not-found', `目录不在了:${directory}`)
|
|
134
|
+
}
|
|
135
|
+
if (!stats.isDirectory()) {
|
|
136
|
+
throw new GitError('git/bad-request', `不是一个目录:${directory}`)
|
|
137
|
+
}
|
|
138
|
+
let root
|
|
139
|
+
try {
|
|
140
|
+
root = (await runGit(directory, ['rev-parse', '--show-toplevel'], {
|
|
141
|
+
timeoutMs: LIMITS.statusTimeoutMs,
|
|
142
|
+
}))
|
|
143
|
+
.toString('utf8')
|
|
144
|
+
.trim()
|
|
145
|
+
} catch (error) {
|
|
146
|
+
// `rev-parse` exits 128 outside a work tree; that is a state to report, not
|
|
147
|
+
// a failure of the command.
|
|
148
|
+
if (error instanceof GitError && error.code === 'git/failed') {
|
|
149
|
+
throw new GitError('git/not-a-repo', '这个目录不在 git 仓库里', { path: directory })
|
|
150
|
+
}
|
|
151
|
+
throw error
|
|
152
|
+
}
|
|
153
|
+
if (!root) throw new GitError('git/not-a-repo', '这个目录不在 git 仓库里', { path: directory })
|
|
154
|
+
return { directory, root }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One repository-relative path, as the phone sends it. */
|
|
158
|
+
function safePath(value, { required = true } = {}) {
|
|
159
|
+
const path = String(value ?? '').trim()
|
|
160
|
+
if (!path) {
|
|
161
|
+
if (!required) return ''
|
|
162
|
+
throw new GitError('git/bad-request', '缺少文件路径')
|
|
163
|
+
}
|
|
164
|
+
if (isAbsolute(path) || path.split('/').includes('..')) {
|
|
165
|
+
throw new GitError('git/bad-request', `路径必须是仓库内的相对路径:${path}`)
|
|
166
|
+
}
|
|
167
|
+
return path
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Whether this revision exists at all (a fresh repository has no HEAD). */
|
|
171
|
+
async function headExists(root) {
|
|
172
|
+
try {
|
|
173
|
+
await runGit(root, ['rev-parse', '--verify', 'HEAD'])
|
|
174
|
+
return true
|
|
175
|
+
} catch {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── status ─────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `git status --porcelain=v2 -z --branch`.
|
|
184
|
+
*
|
|
185
|
+
* Version 2 with NUL separators, not the human format: it names the index and
|
|
186
|
+
* worktree sides separately (so "staged" and "not staged" are facts, not
|
|
187
|
+
* guesses), reports renames with both paths, and survives newlines in file
|
|
188
|
+
* names — which the line-based format does not.
|
|
189
|
+
*/
|
|
190
|
+
export function parseStatus(buffer) {
|
|
191
|
+
const records = buffer.toString('utf8').split('\0')
|
|
192
|
+
const files = []
|
|
193
|
+
let branch = { head: null, oid: null, upstream: null, ahead: 0, behind: 0, detached: false }
|
|
194
|
+
|
|
195
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
196
|
+
const record = records[index]
|
|
197
|
+
if (!record) continue
|
|
198
|
+
if (record.startsWith('# branch.oid ')) {
|
|
199
|
+
branch.oid = record.slice('# branch.oid '.length).trim()
|
|
200
|
+
if (branch.oid === '(initial)') branch.oid = null
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (record.startsWith('# branch.head ')) {
|
|
204
|
+
const head = record.slice('# branch.head '.length).trim()
|
|
205
|
+
branch.detached = head === '(detached)'
|
|
206
|
+
branch.head = branch.detached ? null : head
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (record.startsWith('# branch.upstream ')) {
|
|
210
|
+
branch.upstream = record.slice('# branch.upstream '.length).trim()
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (record.startsWith('# branch.ab ')) {
|
|
214
|
+
// `# branch.ab +2 -1`: the counters are the third and fourth fields.
|
|
215
|
+
const parts = record.split(/\s+/)
|
|
216
|
+
branch.ahead = Number(String(parts[2] ?? '').replace('+', '')) || 0
|
|
217
|
+
branch.behind = Number(String(parts[3] ?? '').replace('-', '')) || 0
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
if (record.startsWith('1 ')) {
|
|
221
|
+
const parts = record.split(' ')
|
|
222
|
+
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
|
|
223
|
+
files.push(entry(parts[1], parts.slice(8).join(' '), null))
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if (record.startsWith('2 ')) {
|
|
227
|
+
const parts = record.split(' ')
|
|
228
|
+
// 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>
|
|
229
|
+
const path = parts.slice(9).join(' ')
|
|
230
|
+
// The original path is the next NUL-separated field.
|
|
231
|
+
const original = records[index + 1] ?? ''
|
|
232
|
+
index += 1
|
|
233
|
+
files.push(entry(parts[1], path, original))
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
if (record.startsWith('u ')) {
|
|
237
|
+
const parts = record.split(' ')
|
|
238
|
+
// u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>
|
|
239
|
+
files.push({ ...entry(parts[1], parts.slice(10).join(' '), null), conflicted: true })
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
if (record.startsWith('? ')) {
|
|
243
|
+
files.push({ path: record.slice(2), originalPath: null, index: '.', worktree: '?', kind: 'untracked', staged: false, unstaged: true, conflicted: false })
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { branch, files }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function entry(xy, path, originalPath) {
|
|
251
|
+
const index = xy?.[0] ?? '.'
|
|
252
|
+
const worktree = xy?.[1] ?? '.'
|
|
253
|
+
return {
|
|
254
|
+
path,
|
|
255
|
+
originalPath: originalPath || null,
|
|
256
|
+
index,
|
|
257
|
+
worktree,
|
|
258
|
+
kind: kindOf(index, worktree),
|
|
259
|
+
staged: index !== '.' && index !== '?',
|
|
260
|
+
unstaged: worktree !== '.' && worktree !== '?',
|
|
261
|
+
conflicted: false,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function kindOf(index, worktree) {
|
|
266
|
+
const code = worktree !== '.' ? worktree : index
|
|
267
|
+
switch (code) {
|
|
268
|
+
case 'A': return 'added'
|
|
269
|
+
case 'D': return 'deleted'
|
|
270
|
+
case 'R': return 'renamed'
|
|
271
|
+
case 'C': return 'copied'
|
|
272
|
+
case 'T': return 'typechange'
|
|
273
|
+
case 'M': return 'modified'
|
|
274
|
+
default: return 'modified'
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── log ────────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
const LOG_FORMAT = '%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D'
|
|
281
|
+
|
|
282
|
+
/** `git log -z --format=…`: one NUL-separated record per commit. */
|
|
283
|
+
export function parseLog(buffer) {
|
|
284
|
+
return buffer
|
|
285
|
+
.toString('utf8')
|
|
286
|
+
.split('\0')
|
|
287
|
+
.map((record) => record.replace(/^\n+/, ''))
|
|
288
|
+
.filter((record) => record.trim())
|
|
289
|
+
.map((record) => {
|
|
290
|
+
const [sha, short, author, date, subject, refs] = record.split('\u001f')
|
|
291
|
+
return {
|
|
292
|
+
sha,
|
|
293
|
+
short,
|
|
294
|
+
author,
|
|
295
|
+
date,
|
|
296
|
+
subject,
|
|
297
|
+
refs: refs ? refs.split(', ').filter(Boolean) : [],
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── show ───────────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* `--numstat`: additions, deletions and path per file; `-` means binary.
|
|
306
|
+
*
|
|
307
|
+
* Called with `-z`, so records are NUL-separated and a path may contain
|
|
308
|
+
* anything — including a newline. The `\n` fallback keeps the parser usable on
|
|
309
|
+
* captured output that was not produced with `-z`.
|
|
310
|
+
*/
|
|
311
|
+
export function parseNumstat(buffer) {
|
|
312
|
+
const text = buffer.toString('utf8')
|
|
313
|
+
return text
|
|
314
|
+
.split(text.includes('\0') ? '\0' : '\n')
|
|
315
|
+
.filter((line) => line.trim())
|
|
316
|
+
.map((line) => {
|
|
317
|
+
const [added, deleted, ...rest] = line.split('\t')
|
|
318
|
+
let path = rest.join('\t')
|
|
319
|
+
// A rename shows as `old => new` (or `dir/{old => new}/file`).
|
|
320
|
+
let originalPath = null
|
|
321
|
+
const brace = /\{(.*?) => (.*?)\}/.exec(path)
|
|
322
|
+
if (brace) {
|
|
323
|
+
originalPath = path.replace(/\{(.*?) => (.*?)\}/, '$1')
|
|
324
|
+
path = path.replace(/\{(.*?) => (.*?)\}/, '$2')
|
|
325
|
+
} else if (path.includes(' => ')) {
|
|
326
|
+
const [from, to] = path.split(' => ')
|
|
327
|
+
originalPath = from
|
|
328
|
+
path = to
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
path,
|
|
332
|
+
originalPath,
|
|
333
|
+
additions: added === '-' ? null : Number(added),
|
|
334
|
+
deletions: deleted === '-' ? null : Number(deleted),
|
|
335
|
+
binary: added === '-' && deleted === '-',
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── the bridge ─────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
export class GitBridge {
|
|
343
|
+
#logger
|
|
344
|
+
|
|
345
|
+
constructor(logger) {
|
|
346
|
+
this.#logger = logger
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Answers one reserved call. Throws `GitError` for the router to report. */
|
|
350
|
+
async handle(method, args = {}) {
|
|
351
|
+
switch (method) {
|
|
352
|
+
case GIT_STATUS: return this.#status(args)
|
|
353
|
+
case GIT_DIFF: return this.#diff(args)
|
|
354
|
+
case GIT_LOG: return this.#log(args)
|
|
355
|
+
case GIT_SHOW: return this.#show(args)
|
|
356
|
+
case GIT_FILE: return this.#file(args)
|
|
357
|
+
default: throw new GitError('git/bad-request', `未知的 git 调用:${method}`)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async #status(args) {
|
|
362
|
+
const { directory, root } = await repository(args.cwd)
|
|
363
|
+
const buffer = await runGit(
|
|
364
|
+
directory,
|
|
365
|
+
['status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all'],
|
|
366
|
+
{ timeoutMs: LIMITS.statusTimeoutMs, maxBytes: LIMITS.statusBytes }
|
|
367
|
+
)
|
|
368
|
+
const parsed = parseStatus(buffer)
|
|
369
|
+
this.#logger?.debug?.(`mobile-link: git status ${root} — ${parsed.files.length} 处改动`)
|
|
370
|
+
return {
|
|
371
|
+
root,
|
|
372
|
+
insideWorkTree: true,
|
|
373
|
+
hasHead: await headExists(root),
|
|
374
|
+
branch: parsed.branch,
|
|
375
|
+
files: parsed.files,
|
|
376
|
+
truncated: buffer.length >= LIMITS.statusBytes,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async #diff(args) {
|
|
381
|
+
const { directory, root } = await repository(args.cwd)
|
|
382
|
+
const path = safePath(args.path)
|
|
383
|
+
const staged = args.staged === true
|
|
384
|
+
// An untracked file has no baseline to diff against: `--no-index` against
|
|
385
|
+
// /dev/null renders it as the pure addition it is. Exit code 1 is "there
|
|
386
|
+
// were differences", which is the normal answer here.
|
|
387
|
+
const untracked = await this.#isUntracked(directory, path)
|
|
388
|
+
const argv = untracked
|
|
389
|
+
? ['diff', '--no-color', '--no-index', `--unified=${clampContext(args.context)}`, '--', '/dev/null', path]
|
|
390
|
+
: ['diff', '--no-color', `--unified=${clampContext(args.context)}`, ...(staged ? ['--cached'] : []), '--', path]
|
|
391
|
+
const buffer = await runGit(directory, argv, {
|
|
392
|
+
maxBytes: LIMITS.diffBytes + 1024,
|
|
393
|
+
okCodes: [0, 1],
|
|
394
|
+
})
|
|
395
|
+
const text = buffer.toString('utf8')
|
|
396
|
+
const binary = /^Binary files .* differ$/m.test(text) || /^GIT binary patch$/m.test(text)
|
|
397
|
+
return {
|
|
398
|
+
root,
|
|
399
|
+
path,
|
|
400
|
+
staged,
|
|
401
|
+
untracked,
|
|
402
|
+
binary,
|
|
403
|
+
text: binary ? '' : text.slice(0, LIMITS.diffBytes),
|
|
404
|
+
truncated: !binary && buffer.length > LIMITS.diffBytes,
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async #isUntracked(directory, path) {
|
|
409
|
+
const buffer = await runGit(directory, ['status', '--porcelain=v1', '-z', '--', path], {
|
|
410
|
+
timeoutMs: LIMITS.statusTimeoutMs,
|
|
411
|
+
})
|
|
412
|
+
return buffer.toString('utf8').startsWith('?? ')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async #log(args) {
|
|
416
|
+
const { directory, root } = await repository(args.cwd)
|
|
417
|
+
if (!(await headExists(root))) {
|
|
418
|
+
throw new GitError('git/no-commits', '这个仓库还没有任何提交', { root })
|
|
419
|
+
}
|
|
420
|
+
const page = Math.min(Math.max(Number(args.limit) || LIMITS.logPage, 1), 50)
|
|
421
|
+
const skip = Math.max(Number(args.skip) || 0, 0)
|
|
422
|
+
const buffer = await runGit(
|
|
423
|
+
directory,
|
|
424
|
+
['log', '-z', `--max-count=${page + 1}`, `--skip=${skip}`, `--format=${LOG_FORMAT}`],
|
|
425
|
+
{ maxBytes: LIMITS.logBytes }
|
|
426
|
+
)
|
|
427
|
+
const all = parseLog(buffer)
|
|
428
|
+
const hasMore = all.length > page
|
|
429
|
+
return { root, commits: all.slice(0, page), hasMore, skip }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async #show(args) {
|
|
433
|
+
const { directory, root } = await repository(args.cwd)
|
|
434
|
+
const sha = String(args.sha ?? '').trim()
|
|
435
|
+
if (!/^[0-9a-fA-F]{4,40}$/.test(sha)) {
|
|
436
|
+
throw new GitError('git/bad-request', '需要一个提交号')
|
|
437
|
+
}
|
|
438
|
+
const path = safePath(args.path, { required: false })
|
|
439
|
+
|
|
440
|
+
if (path) {
|
|
441
|
+
const buffer = await runGit(
|
|
442
|
+
directory,
|
|
443
|
+
['show', '--no-color', `--unified=${clampContext(args.context)}`, '--format=', sha, '--', path],
|
|
444
|
+
{ maxBytes: LIMITS.diffBytes + 1024, okCodes: [0, 1] }
|
|
445
|
+
)
|
|
446
|
+
const text = buffer.toString('utf8')
|
|
447
|
+
const binary = /^Binary files .* differ$/m.test(text) || /^GIT binary patch$/m.test(text)
|
|
448
|
+
return {
|
|
449
|
+
root,
|
|
450
|
+
sha,
|
|
451
|
+
path,
|
|
452
|
+
binary,
|
|
453
|
+
text: binary ? '' : text.slice(0, LIMITS.diffBytes),
|
|
454
|
+
truncated: !binary && buffer.length > LIMITS.diffBytes,
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const meta = parseLog(
|
|
459
|
+
await runGit(directory, ['show', '-s', '-z', `--format=${LOG_FORMAT}`, sha], { okCodes: [0, 1] })
|
|
460
|
+
)[0] ?? { sha }
|
|
461
|
+
const files = parseNumstat(
|
|
462
|
+
await runGit(directory, ['show', '--numstat', '--format=', '-z', sha], {
|
|
463
|
+
maxBytes: LIMITS.statusBytes,
|
|
464
|
+
okCodes: [0, 1],
|
|
465
|
+
})
|
|
466
|
+
)
|
|
467
|
+
return { root, commit: meta, files }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async #file(args) {
|
|
471
|
+
const { directory, root } = await repository(args.cwd)
|
|
472
|
+
const sha = String(args.rev ?? args.sha ?? '').trim()
|
|
473
|
+
if (!sha || !/^[0-9a-zA-Z._/^~-]{1,80}$/.test(sha)) {
|
|
474
|
+
throw new GitError('git/bad-request', '需要一个版本号')
|
|
475
|
+
}
|
|
476
|
+
const path = safePath(args.path)
|
|
477
|
+
const size = Number(
|
|
478
|
+
(await runGit(directory, ['cat-file', '-s', `${sha}:${path}`], { okCodes: [0, 1] }))
|
|
479
|
+
.toString('utf8')
|
|
480
|
+
.trim()
|
|
481
|
+
)
|
|
482
|
+
if (Number.isFinite(size) && size > LIMITS.fileBytes) {
|
|
483
|
+
throw new GitError('git/too-large', `这个版本的文件有 ${size} 字节,超过手机上直接打开的上限`, {
|
|
484
|
+
bytes: size,
|
|
485
|
+
limit: LIMITS.fileBytes,
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
const buffer = await runGit(directory, ['show', `${sha}:${path}`], {
|
|
489
|
+
maxBytes: LIMITS.fileBytes + 1024,
|
|
490
|
+
okCodes: [0, 1],
|
|
491
|
+
})
|
|
492
|
+
return {
|
|
493
|
+
root,
|
|
494
|
+
rev: sha,
|
|
495
|
+
path,
|
|
496
|
+
bytes: buffer.length,
|
|
497
|
+
data: buffer.toString('base64'),
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function clampContext(value) {
|
|
503
|
+
const context = Number(value)
|
|
504
|
+
if (!Number.isFinite(context)) return 3
|
|
505
|
+
return Math.min(Math.max(Math.trunc(context), 0), 10)
|
|
506
|
+
}
|
package/lib/hello.js
CHANGED
|
@@ -44,15 +44,44 @@ function readManifestVersion() {
|
|
|
44
44
|
* - `qr-pairing` this connector can render the pairing QR itself
|
|
45
45
|
* (`GET /mobile-link/qr`), so the desktop needs no client
|
|
46
46
|
* - `self-enroll` this connector can register itself with an invite code
|
|
47
|
+
* - `git` git status / diff / log / show can be read from the work
|
|
48
|
+
* tree on this computer (see git.js)
|
|
47
49
|
*/
|
|
48
50
|
export const SERVER_CAPABILITIES = [
|
|
49
|
-
'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll',
|
|
51
|
+
'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll', 'git',
|
|
50
52
|
]
|
|
51
53
|
|
|
52
54
|
export function isHelloMethod(method) {
|
|
53
55
|
return method === HELLO_METHOD
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* What the phone says it is, out of the handshake arguments.
|
|
60
|
+
*
|
|
61
|
+
* The app sends this on every connection — the only place the *client's* build
|
|
62
|
+
* travels, since the relay's device row is otherwise written once at pairing and
|
|
63
|
+
* then never again. Older clients send `{}`, which yields nulls: reporting
|
|
64
|
+
* "unknown" beats reporting a version that may be days old.
|
|
65
|
+
*
|
|
66
|
+
* Exported for testing: what it returns ends up in a log line a person will read
|
|
67
|
+
* when they ask "did that phone update?".
|
|
68
|
+
*/
|
|
69
|
+
export function parseClientInfo(args) {
|
|
70
|
+
const text = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null)
|
|
71
|
+
const name = text(args?.clientName)
|
|
72
|
+
const version = text(args?.clientVersion)
|
|
73
|
+
const build = text(args?.clientBuild)
|
|
74
|
+
if (!name && !version && !build) return null
|
|
75
|
+
return {
|
|
76
|
+
name,
|
|
77
|
+
version,
|
|
78
|
+
build,
|
|
79
|
+
// `DSHMobile 1.0 (20260919.0005)`, skipping whatever is missing.
|
|
80
|
+
label: [name, version ? `${version}${build ? ` (${build})` : ''}` : build].filter(Boolean).join(' '),
|
|
81
|
+
at: Date.now(),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
/** The answer to {@link HELLO_METHOD}. */
|
|
57
86
|
export function helloPayload({ protocolVersion, agentId, name } = {}) {
|
|
58
87
|
return {
|
package/lib/link.js
CHANGED
|
@@ -10,7 +10,10 @@ import { DshClient } from './dsh-client.js'
|
|
|
10
10
|
import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
|
|
11
11
|
import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
|
|
12
12
|
import { mintPairCode } from './pairing.js'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_EVENTS_BACKLOG, DEFAULT_EVENTS_GRACE_MS,
|
|
15
|
+
EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
|
|
16
|
+
} from './router.js'
|
|
14
17
|
import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
|
|
15
18
|
import { MissingIdentity, resolveIdentity } from './state.js'
|
|
16
19
|
import { connect as wsConnect } from './ws.js'
|
|
@@ -28,12 +31,24 @@ function sleep(ms, signal) {
|
|
|
28
31
|
})
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
/** Read a positive number from the environment; anything else falls back. */
|
|
35
|
+
function numberFromEnv(raw, fallback) {
|
|
36
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return fallback
|
|
37
|
+
const value = Number(raw)
|
|
38
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
export class MobileLinkAgent extends EventEmitter {
|
|
32
42
|
constructor({
|
|
33
43
|
relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile,
|
|
34
44
|
logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
|
|
35
45
|
random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
|
|
36
46
|
inviteCode, fetchImpl = fetch, env = process.env,
|
|
47
|
+
// 「手机不在时替它留着 $events 流」的宽限期与缓冲上限(见 router.js 的说明);
|
|
48
|
+
// 环境变量给部署用,构造参数给测试用。
|
|
49
|
+
eventsGraceMs = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_GRACE_MS, DEFAULT_EVENTS_GRACE_MS),
|
|
50
|
+
eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
|
|
51
|
+
setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout,
|
|
37
52
|
} = {}) {
|
|
38
53
|
super()
|
|
39
54
|
this.config = { relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl, endpointFile, inviteCode }
|
|
@@ -56,6 +71,10 @@ export class MobileLinkAgent extends EventEmitter {
|
|
|
56
71
|
logger,
|
|
57
72
|
now,
|
|
58
73
|
protocolVersion: PROTOCOL_VERSION,
|
|
74
|
+
eventsGraceMs,
|
|
75
|
+
eventsBacklog,
|
|
76
|
+
setTimeoutFn,
|
|
77
|
+
clearTimeoutFn,
|
|
59
78
|
send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
|
|
60
79
|
onChange: () => this.emit('status'),
|
|
61
80
|
})
|
package/lib/qr.js
CHANGED
|
@@ -573,4 +573,33 @@ export function qrSvg(text, { scale = 8, quietZone = 4, dark = '#111111', light
|
|
|
573
573
|
+ '</svg>'
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
/**
|
|
577
|
+
* The same symbol for a terminal, two matrix rows per line of text.
|
|
578
|
+
*
|
|
579
|
+
* Why bother: `enroll` is the one moment a person is already sitting at the
|
|
580
|
+
* terminal with the phone in hand, and a code printed there can be scanned
|
|
581
|
+
* straight off the screen — no page to open, no port to look up. Half blocks
|
|
582
|
+
* ('▀' = top dark, '▄' = bottom dark) keep the symbol roughly square in a
|
|
583
|
+
* terminal cell, which is twice as tall as it is wide. The quiet zone is the
|
|
584
|
+
* spec's four modules, not the two this first shipped with: at two, Vision —
|
|
585
|
+
* the same decoder iOS uses — refused to find the symbol at all.
|
|
586
|
+
*/
|
|
587
|
+
export function qrTerminal(text, { quietZone = 4, margin = ' ' } = {}) {
|
|
588
|
+
const matrix = qrMatrix(text)
|
|
589
|
+
const size = matrix.length
|
|
590
|
+
const blank = (row) => row < 0 || row >= size
|
|
591
|
+
const dark = (row, column) => !blank(row) && column >= 0 && column < size && matrix[row][column]
|
|
592
|
+
const lines = []
|
|
593
|
+
for (let row = -quietZone; row < size + quietZone; row += 2) {
|
|
594
|
+
let line = margin
|
|
595
|
+
for (let column = -quietZone; column < size + quietZone; column += 1) {
|
|
596
|
+
const top = dark(row, column)
|
|
597
|
+
const bottom = dark(row + 1, column)
|
|
598
|
+
line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
|
|
599
|
+
}
|
|
600
|
+
lines.push(line)
|
|
601
|
+
}
|
|
602
|
+
return lines.join('\n')
|
|
603
|
+
}
|
|
604
|
+
|
|
576
605
|
export { TOTAL_CODEWORDS }
|
package/lib/router.js
CHANGED
|
@@ -13,11 +13,39 @@ import {
|
|
|
13
13
|
StreamTable, WaterfallDedupe, deviceFrameError, deviceIdOf, nonEmptyString, resultFrame,
|
|
14
14
|
} from './dlp.js'
|
|
15
15
|
import { FileInbox, isFileMethod } from './files.js'
|
|
16
|
-
import {
|
|
16
|
+
import { GitBridge, isGitMethod } from './git.js'
|
|
17
|
+
import { isHelloMethod, helloPayload, parseClientInfo } from './hello.js'
|
|
17
18
|
|
|
18
19
|
export const EVENTS_ENDPOINT = '$events'
|
|
19
20
|
export const EVENTS_RESULT = '$events/result'
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* 手机断了之后,这条 `$events` 流还替它留多久(毫秒)。
|
|
24
|
+
*
|
|
25
|
+
* 为什么需要:手机是唯一客户端时,它一断,DSH 那边就没人持有这条流了——
|
|
26
|
+
* 提问(waterfall)会随 Agent Context 释放被撤掉,用户回到手机再也看不到
|
|
27
|
+
* (根因见 maintainers/PENDING-WATERFALL-LOSS.md)。留着流就等于"代手机值班":
|
|
28
|
+
* 这期间来的提问先攒着,手机回来补发。
|
|
29
|
+
*
|
|
30
|
+
* 代价是这段时间里 DSH 认为"设备还在",会话的 Agent Context 不会因此释放;
|
|
31
|
+
* 所以必须有上限,不能无限留。
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_EVENTS_GRACE_MS = 15 * 60 * 1000
|
|
34
|
+
|
|
35
|
+
/** 离线期间最多替一台设备攒多少条 `$events` 项(只攒 waterfall/cancel)。 */
|
|
36
|
+
export const DEFAULT_EVENTS_BACKLOG = 50
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 手机不在时要攒下来的 `$events` 项:只有"待回答的提问"和"提问作废"两种。
|
|
40
|
+
*
|
|
41
|
+
* 别的一律不攒——emit 类的都是"某事刚发生"的通知,几分钟后补发只会让 App
|
|
42
|
+
* 对着旧事件发通知(例如一条早就结束的 turn)。
|
|
43
|
+
*/
|
|
44
|
+
export function isBufferedEvent(value) {
|
|
45
|
+
if (!value || typeof value !== 'object') return false
|
|
46
|
+
return value.type === 'waterfall' || value.type === 'cancel'
|
|
47
|
+
}
|
|
48
|
+
|
|
21
49
|
export function messageOf(error) {
|
|
22
50
|
return error instanceof Error ? error.message : String(error)
|
|
23
51
|
}
|
|
@@ -33,8 +61,16 @@ export class DeviceRouter {
|
|
|
33
61
|
* @param {(deviceId: string, frame: object) => boolean} options.send deliver one frame to one device
|
|
34
62
|
* @param {() => void} [options.onChange] called whenever the status snapshot changes
|
|
35
63
|
*/
|
|
36
|
-
constructor({
|
|
64
|
+
constructor({
|
|
65
|
+
dsh, send, logger = console, onChange = () => {}, now = Date.now, protocolVersion = 1,
|
|
66
|
+
eventsGraceMs = DEFAULT_EVENTS_GRACE_MS, eventsBacklog = DEFAULT_EVENTS_BACKLOG,
|
|
67
|
+
setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout,
|
|
68
|
+
} = {}) {
|
|
37
69
|
this.dsh = dsh
|
|
70
|
+
this.eventsGraceMs = eventsGraceMs
|
|
71
|
+
this.eventsBacklog = eventsBacklog
|
|
72
|
+
this.setTimeoutFn = setTimeoutFn
|
|
73
|
+
this.clearTimeoutFn = clearTimeoutFn
|
|
38
74
|
// Reported to the app so it can tell which wire revision it is talking to.
|
|
39
75
|
this.protocolVersion = protocolVersion
|
|
40
76
|
this.send = send
|
|
@@ -46,6 +82,7 @@ export class DeviceRouter {
|
|
|
46
82
|
this.dedupe = new WaterfallDedupe({ now })
|
|
47
83
|
this.lastError = undefined
|
|
48
84
|
this.fileInbox = new FileInbox(logger)
|
|
85
|
+
this.git = new GitBridge(logger)
|
|
49
86
|
}
|
|
50
87
|
|
|
51
88
|
#changed() {
|
|
@@ -98,7 +135,20 @@ export class DeviceRouter {
|
|
|
98
135
|
// ── device lifecycle ────────────────────────────────────────────────────
|
|
99
136
|
|
|
100
137
|
async attachDevice(deviceId, info = {}) {
|
|
101
|
-
if (!nonEmptyString(deviceId)
|
|
138
|
+
if (!nonEmptyString(deviceId)) return
|
|
139
|
+
const existing = this.devices.get(deviceId)
|
|
140
|
+
if (existing) {
|
|
141
|
+
// 回头客:宽限期内又连上了。流和攒下的提问都还在,交给 handleOpen 补发。
|
|
142
|
+
if (existing.offlineSince !== undefined) {
|
|
143
|
+
this.clearTimeoutFn(existing.offlineTimer)
|
|
144
|
+
existing.offlineTimer = undefined
|
|
145
|
+
existing.offlineSince = undefined
|
|
146
|
+
this.logger.info?.(
|
|
147
|
+
`mobile-link: device ${deviceId} reattached with ${existing.eventsPending.size} unanswered question(s)`)
|
|
148
|
+
this.#changed()
|
|
149
|
+
}
|
|
150
|
+
return
|
|
151
|
+
}
|
|
102
152
|
this.devices.set(deviceId, {
|
|
103
153
|
deviceId,
|
|
104
154
|
name: info?.name,
|
|
@@ -108,6 +158,13 @@ export class DeviceRouter {
|
|
|
108
158
|
readyValue: undefined,
|
|
109
159
|
eventsMuxId: undefined,
|
|
110
160
|
dlpIds: new Set(),
|
|
161
|
+
// 手机不在时的"值班"状态:offlineSince 有值 = 这条流在替它留着;
|
|
162
|
+
// eventsPending 是**还没被回答**的提问(按 eventId 存)。
|
|
163
|
+
// 注意它不是一个"送一次就清空"的队列:送过也要留着,否则 App 再重启一次
|
|
164
|
+
// (或崩一次)这条提问就永远丢了——2026-09-22 验收时正是这么发现的。
|
|
165
|
+
offlineSince: undefined,
|
|
166
|
+
offlineTimer: undefined,
|
|
167
|
+
eventsPending: new Map(),
|
|
111
168
|
})
|
|
112
169
|
this.logger.info?.(`mobile-link: device ${deviceId} attached (${info?.name ?? 'unknown'})`)
|
|
113
170
|
this.#changed()
|
|
@@ -117,9 +174,42 @@ export class DeviceRouter {
|
|
|
117
174
|
async detachDevice(deviceId, reason) {
|
|
118
175
|
const record = this.devices.get(deviceId)
|
|
119
176
|
if (!record) return
|
|
177
|
+
// 除了 `$events`,其它流(文件、会话流)立刻撤掉:它们没有"值班"的意义,
|
|
178
|
+
// 留着只会占资源。`$events` 留着,见 DEFAULT_EVENTS_GRACE_MS 的说明。
|
|
179
|
+
const keep = []
|
|
180
|
+
for (const entry of this.streams.byDevice(deviceId)) {
|
|
181
|
+
if (entry.endpoint === EVENTS_ENDPOINT) {
|
|
182
|
+
keep.push(entry)
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
this.streams.removeByMux(entry.muxId)
|
|
186
|
+
this.dsh.cancelStream(entry.muxId)
|
|
187
|
+
}
|
|
188
|
+
record.dlpIds.clear()
|
|
189
|
+
for (const entry of keep) record.dlpIds.add(entry.dlpId)
|
|
190
|
+
|
|
191
|
+
if (record.offlineSince === undefined) {
|
|
192
|
+
record.offlineSince = this.now()
|
|
193
|
+
record.offlineTimer = this.setTimeoutFn(() => {
|
|
194
|
+
void this.#expireOfflineDevice(deviceId)
|
|
195
|
+
}, this.eventsGraceMs)
|
|
196
|
+
// 定时器不该把进程钉住(CLI 与测试里尤其明显)。
|
|
197
|
+
record.offlineTimer?.unref?.()
|
|
198
|
+
}
|
|
199
|
+
this.logger.info?.(
|
|
200
|
+
`mobile-link: device ${deviceId} detached (${reason ?? 'closed'});`
|
|
201
|
+
+ `$events 流替它留 ${Math.round(this.eventsGraceMs / 1000)} 秒,期间来的提问会攒着`)
|
|
202
|
+
this.#changed()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 宽限期到点:真的把这台设备放下(撤流、清缓冲)。 */
|
|
206
|
+
async #expireOfflineDevice(deviceId) {
|
|
207
|
+
const record = this.devices.get(deviceId)
|
|
208
|
+
if (!record || record.offlineSince === undefined) return
|
|
120
209
|
for (const muxId of this.streams.removeDevice(deviceId)) this.dsh.cancelStream(muxId)
|
|
121
210
|
this.devices.delete(deviceId)
|
|
122
|
-
this.logger.info?.(
|
|
211
|
+
this.logger.info?.(
|
|
212
|
+
`mobile-link: device ${deviceId} 宽限期结束,已放下(丢掉 ${record.eventsPending.size} 条没人回答的提问)`)
|
|
123
213
|
this.#changed()
|
|
124
214
|
}
|
|
125
215
|
|
|
@@ -146,6 +236,8 @@ export class DeviceRouter {
|
|
|
146
236
|
teardownAll() {
|
|
147
237
|
const muxIds = []
|
|
148
238
|
for (const deviceId of [...this.devices.keys()]) {
|
|
239
|
+
const record = this.devices.get(deviceId)
|
|
240
|
+
this.clearTimeoutFn(record?.offlineTimer)
|
|
149
241
|
muxIds.push(...this.streams.removeDevice(deviceId))
|
|
150
242
|
this.devices.delete(deviceId)
|
|
151
243
|
}
|
|
@@ -162,6 +254,23 @@ export class DeviceRouter {
|
|
|
162
254
|
// What this connector is and can do. Answered here for the same reason as
|
|
163
255
|
// the file calls: the HTTP status route exists only on the direct path.
|
|
164
256
|
if (isHelloMethod(frame.method)) {
|
|
257
|
+
// Recorded before answering: this is the only frame in which the phone
|
|
258
|
+
// says which build it is, and the relay cannot see it from the pairing
|
|
259
|
+
// row it keeps.
|
|
260
|
+
const client = parseClientInfo(frame.args)
|
|
261
|
+
if (client) {
|
|
262
|
+
const record = this.devices.get(deviceId)
|
|
263
|
+
if (record) record.client = client
|
|
264
|
+
if (record?.client?.build !== client.build || record?.clientLogged !== client.build) {
|
|
265
|
+
if (record) record.clientLogged = client.build
|
|
266
|
+
// One line per build per connection: enough to answer "did they
|
|
267
|
+
// update?" from a log, without a line per reconnect.
|
|
268
|
+
this.logger?.info?.(
|
|
269
|
+
`mobile-link: device ${deviceId} is ${client.label}` +
|
|
270
|
+
`${record?.name ? ` (${record.name})` : ''}`
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
165
274
|
const value = helloPayload({
|
|
166
275
|
protocolVersion: this.protocolVersion,
|
|
167
276
|
agentId: this.identity?.agentId,
|
|
@@ -182,6 +291,24 @@ export class DeviceRouter {
|
|
|
182
291
|
return
|
|
183
292
|
}
|
|
184
293
|
|
|
294
|
+
// Git also runs here, on the machine that holds the work tree: the Host has
|
|
295
|
+
// no endpoint that runs a command, and the review a person wants — what
|
|
296
|
+
// changed, what the diff says, what the last commits were — is all local.
|
|
297
|
+
// Read-only; see git.js for the command whitelist.
|
|
298
|
+
if (isGitMethod(frame.method)) {
|
|
299
|
+
let result
|
|
300
|
+
try {
|
|
301
|
+
result = { ok: true, value: await this.git.handle(frame.method, frame.args ?? {}) }
|
|
302
|
+
} catch (error) {
|
|
303
|
+
result = {
|
|
304
|
+
ok: false,
|
|
305
|
+
error: errorObject(error?.code ?? 'git/failed', messageOf(error), error?.details ?? {}),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
this.send(deviceId, resultFrame(frame.id, result, deviceId))
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
185
312
|
let result
|
|
186
313
|
try {
|
|
187
314
|
result = await this.dsh.rpc(frame.method, frame.args ?? {})
|
|
@@ -216,6 +343,17 @@ export class DeviceRouter {
|
|
|
216
343
|
if (record.readyValue !== undefined) {
|
|
217
344
|
this.send(deviceId, { t: 'item', id: frame.id, value: record.readyValue })
|
|
218
345
|
}
|
|
346
|
+
// 还没被回答的提问在这里补发:ready 已经发过,所以 App 拿得到 clientId,
|
|
347
|
+
// 补发的提问是可以被回答的。**送出去不清空**——App 可能又崩/又重启,
|
|
348
|
+
// 只要还没人回答,下次连上还得再给一遍(App 侧按 eventId 去重)。
|
|
349
|
+
if (record.eventsPending.size > 0) {
|
|
350
|
+
const pending = [...record.eventsPending.values()]
|
|
351
|
+
this.logger.info?.(
|
|
352
|
+
`mobile-link: replaying ${pending.length} unanswered question(s) to ${deviceId}`)
|
|
353
|
+
for (const value of pending) {
|
|
354
|
+
this.send(deviceId, { t: 'item', id: frame.id, value })
|
|
355
|
+
}
|
|
356
|
+
}
|
|
219
357
|
return
|
|
220
358
|
}
|
|
221
359
|
const muxId = `s:${deviceId}:${frame.id}`
|
|
@@ -265,6 +403,8 @@ export class DeviceRouter {
|
|
|
265
403
|
})
|
|
266
404
|
return
|
|
267
405
|
}
|
|
406
|
+
// 答过了就不再是"待答":删掉它,免得下次重连又把已经回答过的问题推给 App。
|
|
407
|
+
record.eventsPending.delete(eventId)
|
|
268
408
|
// The clientId is always the one from *this device's* $events ready frame
|
|
269
409
|
// (spec §4.1.5); any clientId the device echoed is ignored on purpose.
|
|
270
410
|
if (!this.dedupe.claim(eventId)) return
|
|
@@ -302,6 +442,27 @@ export class DeviceRouter {
|
|
|
302
442
|
forwardEvent(deviceId, value) {
|
|
303
443
|
const record = this.devices.get(deviceId)
|
|
304
444
|
if (!record) return
|
|
445
|
+
// 只有"待回答的提问"值得留:别的都是"某事刚发生"的通知,补发只会让 App
|
|
446
|
+
// 对着几分钟前的事件发通知(例如一条早就结束的 turn)。
|
|
447
|
+
// 待答表:不管手机在不在都记下来,直到**有人回答**或 host 作废它。
|
|
448
|
+
// 只记在线时的转发是不够的——App 收下提问后崩一次/重启一次,那条提问就再也没人
|
|
449
|
+
// 记得了(host 认为已经投给这条流了,不会重发)。连接器记着,重连时再给一遍。
|
|
450
|
+
if (nonEmptyString(value?.eventId)) {
|
|
451
|
+
if (value.type === 'cancel') record.eventsPending.delete(value.eventId)
|
|
452
|
+
else if (value.type === 'waterfall') {
|
|
453
|
+
record.eventsPending.set(value.eventId, value)
|
|
454
|
+
if (record.eventsPending.size > this.eventsBacklog) {
|
|
455
|
+
// 超上限时丢最老的(Map 保持插入顺序)。
|
|
456
|
+
const oldest = record.eventsPending.keys().next().value
|
|
457
|
+
record.eventsPending.delete(oldest)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (record.offlineSince !== undefined && isBufferedEvent(value)) {
|
|
462
|
+
this.logger.debug?.(
|
|
463
|
+
`mobile-link: kept ${value.type} ${value.event ?? value.eventId ?? ''} for offline ${deviceId}`)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
305
466
|
if (value && value.type === 'ready' && nonEmptyString(value.clientId)) {
|
|
306
467
|
record.clientId = value.clientId
|
|
307
468
|
record.readyValue = value
|
|
@@ -371,7 +532,11 @@ export class DeviceRouter {
|
|
|
371
532
|
deviceId: record.deviceId,
|
|
372
533
|
name: record.name,
|
|
373
534
|
model: record.model,
|
|
535
|
+
// Which build that phone reported on its last handshake.
|
|
536
|
+
client: record.client ?? null,
|
|
374
537
|
connectedAt: record.connectedAt,
|
|
538
|
+
offlineSince: record.offlineSince ?? null,
|
|
539
|
+
unansweredEvents: record.eventsPending.size,
|
|
375
540
|
eventsReady: Boolean(record.clientId),
|
|
376
541
|
openStreams: record.dlpIds.size,
|
|
377
542
|
}))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-link",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|