dsh-plugin-mobile-link 0.3.1 → 0.4.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.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Files the phone pulls **from** this computer, over the relay's HTTP surface.
3
+ *
4
+ * The mirror of `files.js`. Same reason for existing: the phone cannot reach the
5
+ * computer (this connector dials out and listens on loopback only), so the relay
6
+ * terminates the HTTP request — but only this side can drive the Host's
7
+ * `workspaceFiles/readBytes`. The relay pumps `GET /files/down`, this module
8
+ * answers `fsGetBegin` with a run of `fsGetChunk` frames, and the relay writes
9
+ * each one straight into the response body. **Nothing is stored anywhere in
10
+ * between.**
11
+ *
12
+ * Why not just let the app use `workspaceFiles/readBytes` over WSS, as it does
13
+ * today? Because a socket dies when iOS suspends the app, and a large download
14
+ * the user started before pocketing the phone would die with it. A background
15
+ * `URLSessionDownloadTask` is the only kind of transfer the system finishes on
16
+ * the app's behalf — and that needs an HTTP endpoint.
17
+ *
18
+ * Two shapes worth stating, because both differ from `files.js`:
19
+ *
20
+ * * the reads are **pulled a window at a time** by this module rather than
21
+ * pushed at it, so a slow or vanished reader costs at most one window in
22
+ * flight; and
23
+ * * the replies carry **no `deviceId`** (like `fsPut*`): the relay correlates by
24
+ * `bid` alone, and adding an addressing field would break that.
25
+ */
26
+
27
+ /** Reserved frame types this module answers; see `docs/relay-contract.json`. */
28
+ export const FSGET_BEGIN = 'fsGetBegin'
29
+ export const FSGET_CHUNK = 'fsGetChunk'
30
+ export const FSGET_END = 'fsGetEnd'
31
+ /**
32
+ * The relay's "stop reading for this run" frame.
33
+ *
34
+ * The pull side can end without the connector ever noticing: the phone hangs up,
35
+ * or the daily allowance cuts the response off mid-body. The relay sees that
36
+ * immediately (`deliver` reports a full queue), this side does not — the reads
37
+ * are driven here, so nothing would stop a run whose reader has left until the
38
+ * whole file had been read and every window thrown away at the relay.
39
+ *
40
+ * Cancelling an unknown `bid` is a no-op, exactly like `fsGetCancel`'s sibling
41
+ * `deviceDetach`: the relay is free to send one per dead bridge without tracking
42
+ * which runs are live, and a duplicate cannot hurt a run that is already gone.
43
+ */
44
+ export const FSGET_CANCEL = 'fsGetCancel'
45
+ /**
46
+ * The connector's "I have the file, start reading" answer.
47
+ *
48
+ * Carries `size` and `version` when the Host could tell us (P-13b). The relay
49
+ * needs the size to answer `Content-Length` and a full
50
+ * `Content-Range: bytes N-M/TOTAL`, which is what makes an interrupted download
51
+ * resumable by the system rather than restarted: a background
52
+ * `URLSessionDownloadTask` only produces resume data when the response states
53
+ * the total. The version becomes the `ETag`, so a `If-Range` on the retry can
54
+ * tell "same file, keep going" from "the file changed under me, start over".
55
+ *
56
+ * Both fields are **optional on purpose**: a Host that cannot stat the file
57
+ * still gets its download, just without the resume guarantee. A missing field
58
+ * must degrade to the old behaviour, never to an error — that is what keeps
59
+ * this a compatible addition rather than a protocol break.
60
+ */
61
+ export const FSGET_ACK = 'fsGetAck'
62
+ export const FSERR = 'fsErr'
63
+
64
+ /**
65
+ * The window this module asks the Host for, and therefore the size of one
66
+ * `fsGetChunk` before base64.
67
+ *
68
+ * 2 MiB is the Host's own `maxBytes` for `workspaceFiles/readBytes`
69
+ * (`DSHClient.swift`), so asking for exactly that means no window is ever
70
+ * refused for being too large — and base64 makes the frame ~2.67 MB, well under
71
+ * the 32 MB frame ceiling (`lib/dlp.js`).
72
+ */
73
+ export const DEFAULT_WINDOW_BYTES = 2 * 1024 * 1024
74
+
75
+ /**
76
+ * The floor a window shrinks to when the Host says `workspace-file/too-large`.
77
+ *
78
+ * The Host's limit is a deployment value, not a constant this side can read, so
79
+ * it is discovered: refuse → halve, down to here. Same approach as the app's own
80
+ * downloader (`WorkspaceFileDownloader.swift`), and the same floor.
81
+ */
82
+ export const MIN_WINDOW_BYTES = 128 * 1024
83
+
84
+ /** How many refusals in a row before the fetch is abandoned rather than halved. */
85
+ const MAX_SHRINKS = 8
86
+
87
+ /**
88
+ * Host error codes that mean "asking again will answer the same thing".
89
+ *
90
+ * The app decides whether to retry from the code, so these must travel verbatim
91
+ * in the `fsErr` — a generic `file/rejected` would make a bad path look like a
92
+ * flaky network and send the user round the retry loop for nothing. The list is
93
+ * the connector's copy of the app's own `isRetryable` set
94
+ * (`WorkspaceFileDownloader.swift`); it only has to be *wider* than reality,
95
+ * never narrower, because a code that lands here wrongly turns a retryable
96
+ * failure into a permanent one.
97
+ */
98
+ export const NON_RETRYABLE_CODES = new Set([
99
+ 'workspace-file/not-found',
100
+ 'workspace-file/outside-workspace',
101
+ 'workspace-file/not-regular-file',
102
+ 'workspace-file/not-directory',
103
+ 'workspace-file/unknown-workspace',
104
+ 'gateway/lookup-not-found',
105
+ 'gateway/bad-request',
106
+ ])
107
+
108
+ /**
109
+ * Whether a failure should be reported as-is (the app must not retry it).
110
+ *
111
+ * Anything unrecognised is assumed retryable: the cost of one wasted retry is a
112
+ * second of the user's time, while wrongly declaring a transient failure
113
+ * permanent leaves them with a file they cannot fetch at all.
114
+ */
115
+ export function isPermanent(error) {
116
+ const code = error?.code
117
+ return typeof code === 'string' && NON_RETRYABLE_CODES.has(code)
118
+ }
119
+
120
+ /** Normalise a thrown Host failure into the `{code, message}` shape `fsErr` wants. */
121
+ function failureOf(error) {
122
+ const code = typeof error?.code === 'string' && error.code ? error.code : 'file/rejected'
123
+ const message = error instanceof Error ? error.message : String(error ?? 'download failed')
124
+ return { code, message }
125
+ }
126
+
127
+ /**
128
+ * Whether the gateway refused a call over its argument **shape**.
129
+ *
130
+ * The Host checks every Remote call against the method's descriptor and rejects
131
+ * anything that does not match it field for field — a renamed or relocated
132
+ * argument comes back as `gateway/arguments-invalid`, never as a silent default.
133
+ * That refusal is the only thing a connector can learn one Host generation's
134
+ * signature from another by, which is why the probe in `#callReadBytes` keys on
135
+ * it rather than on a version number.
136
+ */
137
+ function isShapeRefusal(result) {
138
+ return result?.ok === false && result?.error?.code === 'gateway/arguments-invalid'
139
+ }
140
+
141
+ /**
142
+ * One fetch, as this connector runs it.
143
+ *
144
+ * Not stateful beyond one run: it opens no file (the Host does the reading), so
145
+ * there is nothing to clean up but the cancellation flag.
146
+ */
147
+ export class FetchRun {
148
+ constructor({ bid, scopeId, path, offset = 0, windowBytes = DEFAULT_WINDOW_BYTES }) {
149
+ this.bid = bid
150
+ this.scopeId = scopeId
151
+ this.path = path
152
+ this.offset = Number.isFinite(offset) ? Math.max(0, offset) : 0
153
+ this.windowBytes = windowBytes
154
+ /** Set when the relay stopped reading (it hung up, or ran out of quota). */
155
+ this.cancelled = false
156
+ }
157
+ }
158
+
159
+ /** Owns the in-flight downloads for one connector. */
160
+ export class FileFetcher {
161
+ #runs = new Map()
162
+ #logger
163
+ #sends
164
+ #rpc
165
+ /**
166
+ * Which argument shape this Host's `workspaceFiles/readBytes` accepts.
167
+ *
168
+ * `null` until the first read has taught us which one. Per fetcher, so per
169
+ * connector process: the probe below costs one extra round trip, once.
170
+ * @type {'options'|'range'|null}
171
+ */
172
+ #byteWindowShape = null
173
+
174
+ /**
175
+ * @param {object} options
176
+ * @param {(frame: object) => boolean} options.send one frame to the relay
177
+ * @param {(method: string, args: object) => Promise<object>} options.rpc
178
+ * the ordinary Host call path (`this.dsh.rpc`). Injected rather than
179
+ * reached for, so `node --test` can run a whole download against a stub
180
+ * Host with no DSH process at all.
181
+ * @param {object} options.logger
182
+ */
183
+ constructor({ send, rpc, logger = {} } = {}) {
184
+ this.#sends = send ?? (() => false)
185
+ this.#rpc = rpc ?? (async () => ({ ok: false, error: { code: 'host/unavailable' } }))
186
+ this.#logger = logger
187
+ }
188
+
189
+ /**
190
+ * Start one download and run it to completion.
191
+ *
192
+ * Deliberately **not** awaited by the caller's frame handler: the whole point
193
+ * is that chunks go out while the relay reads, and a `handleRelayFrame` that
194
+ * awaited the file would block every other frame on the same socket.
195
+ * Resolves when the run finishes (or `false` if it never started).
196
+ */
197
+ start(frame) {
198
+ const bid = String(frame?.bid ?? '')
199
+ if (!bid) {
200
+ this.#logger.warn?.('mobile-link: fsGetBegin without a bid')
201
+ return false
202
+ }
203
+ const scopeId = String(frame?.scopeId ?? '')
204
+ const path = String(frame?.path ?? '')
205
+ if (!scopeId || !path) {
206
+ this.#sendError(bid, { code: 'file/rejected', message: 'scopeId and path are required' })
207
+ return false
208
+ }
209
+ // A retried `bid` replaces the previous run: the connector reuses the id
210
+ // across attempts of the same fetch, and two runs writing the same bid
211
+ // would interleave their chunks into one nonsense stream.
212
+ this.cancel(bid)
213
+ // **`replaces` is how the superseded run is named now that the relay mints
214
+ // its own ids.** Each HTTP request gets a fresh `bid`, so the run left over
215
+ // from an interrupted attempt is not reachable by this request's identifier —
216
+ // and on a resumed download it is very much still there: the system's resume
217
+ // data freezes the *original* request, so what arrives is the old identifier
218
+ // replayed. Without this the old run would keep reading until the new
219
+ // `fsGetBegin` reached it, and its windows are exactly the ones the relay
220
+ // cannot recall. Advisory by construction: a name we do not know stops
221
+ // nothing, which is what an older relay (which never sends it) relies on.
222
+ const replaced = String(frame?.replaces ?? '')
223
+ if (replaced && replaced !== bid) this.cancel(replaced)
224
+ const run = new FetchRun({
225
+ bid, scopeId, path,
226
+ offset: Number(frame?.offset ?? 0),
227
+ windowBytes: Number(frame?.window) > 0 ? Number(frame.window) : DEFAULT_WINDOW_BYTES,
228
+ })
229
+ this.#runs.set(bid, run)
230
+ // The stat has to happen **before** the ack: the relay builds the response
231
+ // (status, `Content-Length`, `Content-Range`, `ETag`) from this answer, and
232
+ // it can only prepare a resumable response once it is told what it is
233
+ // resuming. Statting after the ack would mean the headers were already sent.
234
+ this.#ackNow(run)
235
+ this.#drive(run).catch((error) => {
236
+ this.#logger.warn?.(`mobile-link: download ${bid} failed: ${error?.message ?? error}`)
237
+ })
238
+ return true
239
+ }
240
+
241
+ /**
242
+ * Answer the relay's `fsGetBegin`, with the file's size and version if the
243
+ * Host can supply them.
244
+ *
245
+ * Never fails the transfer: `stat` is an enhancement (it is what makes the
246
+ * download resumable), and a Host that refuses it — an older DSH, a file that
247
+ * vanished between the request and the stat — must still get its bytes. The
248
+ * ack goes out either way, just without the fields the relay would have used
249
+ * to build a resumable response.
250
+ */
251
+ async #ackNow(run) {
252
+ const extra = await this.#statFor(run)
253
+ // A run superseded while the stat was in flight must not ack: the relay has
254
+ // already opened a new bridge for that bid, and a late ack would be
255
+ // answering the request it just replaced.
256
+ if (run.cancelled) return
257
+ this.#sends({ t: FSGET_ACK, bid: run.bid, ...extra })
258
+ }
259
+
260
+ /**
261
+ * One `workspaceFiles/stat`, reduced to the two fields the relay uses.
262
+ *
263
+ * Returns `{}` rather than throwing: see `#ackNow`. A sized response is worth
264
+ * a round trip, but never worth the transfer.
265
+ */
266
+ async #statFor(run) {
267
+ try {
268
+ const result = await this.#rpc('workspaceFiles/stat', {
269
+ workspaceFileScopeId: run.scopeId,
270
+ path: run.path,
271
+ })
272
+ if (result && result.ok === false) return {}
273
+ const value = result?.value ?? result
274
+ const size = Number.isFinite(value?.bytes) ? Number(value.bytes) : undefined
275
+ const version = typeof value?.version === 'string' && value.version ? value.version : undefined
276
+ if (size === undefined && version === undefined) return {}
277
+ return {
278
+ ...size === undefined ? {} : { size },
279
+ ...version === undefined ? {} : { version },
280
+ }
281
+ } catch {
282
+ return {}
283
+ }
284
+ }
285
+
286
+ /** Stop one run. Safe for an unknown bid (a late cancel is not an error). */
287
+ cancel(bid) {
288
+ const run = this.#runs.get(bid)
289
+ if (run) run.cancelled = true
290
+ }
291
+
292
+ /** Stop every run, e.g. when the link goes down. */
293
+ clear() {
294
+ for (const run of this.#runs.values()) run.cancelled = true
295
+ this.#runs.clear()
296
+ }
297
+
298
+ async #drive(run) {
299
+ let offset = run.offset
300
+ let shrinks = 0
301
+ let window = run.windowBytes
302
+ try {
303
+ for (;;) {
304
+ if (run.cancelled) return
305
+ let answer
306
+ try {
307
+ answer = await this.#readWindow(run.scopeId, run.path, offset, window)
308
+ } catch (error) {
309
+ const failure = failureOf(error)
310
+ if (failure.code === 'workspace-file/too-large' && shrinks < MAX_SHRINKS
311
+ && window > MIN_WINDOW_BYTES) {
312
+ // The Host's cap is smaller than the window asked for. Halve and try
313
+ // again — the cap is a deployment value nothing here can read.
314
+ window = Math.max(MIN_WINDOW_BYTES, Math.floor(window / 2))
315
+ shrinks += 1
316
+ continue
317
+ }
318
+ if (!run.cancelled) this.#sendError(run.bid, failure)
319
+ return
320
+ }
321
+ // A window that came back after the run was superseded or dropped: the
322
+ // relay has already stopped listening for this bid, and appending more
323
+ // chunks would interleave into whatever run owns the bid now.
324
+ if (run.cancelled) return
325
+ // The bytes arrive in one of two shapes: base64 inside the JSON answer
326
+ // (a Host whose results are all JSON), or a `Uint8Array` (a Host that
327
+ // answers binary results as form parts — see `dsh-client.js`). The
328
+ // relay's frame carries base64 either way, so this is where the two
329
+ // converge.
330
+ const raw = answer?.data
331
+ const data = typeof raw === 'string'
332
+ ? raw
333
+ : raw instanceof Uint8Array ? Buffer.from(raw).toString('base64') : ''
334
+ const eof = answer?.eof === true
335
+ const piece = Buffer.from(data, 'base64')
336
+ if (piece.length > 0) {
337
+ this.#sends({ t: FSGET_CHUNK, bid: run.bid, data, eof })
338
+ offset += piece.length
339
+ } else if (!eof) {
340
+ // An empty window that is not the end means the Host has nothing more
341
+ // at this offset. Asking again would spin forever, so this is an error.
342
+ if (!run.cancelled) {
343
+ this.#sendError(run.bid, {
344
+ code: 'workspace-file/stalled',
345
+ message: `the Host returned an empty window at offset ${offset}`,
346
+ })
347
+ }
348
+ return
349
+ }
350
+ if (eof) {
351
+ // The run may have been superseded while that window was in flight —
352
+ // a stale `fsGetEnd` would tell the relay a download nobody wanted any
353
+ // more is complete, and a retried bid would answer its HTTP request
354
+ // twice.
355
+ if (run.cancelled) return
356
+ if (piece.length === 0) {
357
+ // Nothing at all was read and it is already the end: an empty file,
358
+ // or an offset past the end. Either way the stream is complete.
359
+ this.#sends({ t: FSGET_CHUNK, bid: run.bid, data: '', eof: true })
360
+ }
361
+ this.#sends({ t: FSGET_END, bid: run.bid })
362
+ return
363
+ }
364
+ }
365
+ } finally {
366
+ this.#runs.delete(run.bid)
367
+ }
368
+ }
369
+
370
+ /** One `workspaceFiles/readBytes`, unwrapped to `{data, eof}`. */
371
+ async #readWindow(scopeId, path, offset, length) {
372
+ const result = await this.#callReadBytes(scopeId, path, offset, length)
373
+ if (result && result.ok === false) {
374
+ // The DSH client returns failures as values (`{ok:false, error}`) rather
375
+ // than throwing; the code has to survive into the `fsErr`.
376
+ throw Object.assign(new Error(result.error?.message ?? 'read failed'),
377
+ { code: result.error?.code })
378
+ }
379
+ return result?.value ?? result
380
+ }
381
+
382
+ /**
383
+ * The read, in whichever argument shape this Host takes.
384
+ *
385
+ * Host 0.2.0-rc.2 moved the byte window from a top-level `range` into
386
+ * `options.range`, and the gateway validates field for field — so the two
387
+ * shapes cannot be sent at once, and a connector that speaks only one of them
388
+ * breaks the whole download path on the other Host generation. Which shape
389
+ * this Host wants is therefore **probed, not version-compared**, the same way
390
+ * `hello.js` treats capabilities: try the current shape, and if the descriptor
391
+ * refuses it, try the older one and remember the answer.
392
+ */
393
+ async #callReadBytes(scopeId, path, offset, length) {
394
+ const attempt = (shape) => this.#rpc('workspaceFiles/readBytes', {
395
+ workspaceFileScopeId: scopeId,
396
+ path,
397
+ ...shape === 'options'
398
+ ? { options: { range: { offset, length } } }
399
+ : { range: { offset, length } },
400
+ })
401
+
402
+ if (this.#byteWindowShape) return attempt(this.#byteWindowShape)
403
+
404
+ const current = await attempt('options')
405
+ if (!isShapeRefusal(current)) {
406
+ this.#byteWindowShape = 'options'
407
+ return current
408
+ }
409
+ // The older shape's own answer is the one that counts now: if that is refused
410
+ // too, the failure is the caller's to report, exactly as it stands.
411
+ const older = await attempt('range')
412
+ if (!isShapeRefusal(older)) this.#byteWindowShape = 'range'
413
+ return older
414
+ }
415
+
416
+ #sendError(bid, failure) {
417
+ this.#logger.warn?.(`mobile-link: download ${bid} failed: ${failure.code} ${failure.message}`)
418
+ this.#sends({ t: FSERR, bid, ...failure })
419
+ }
420
+
421
+ /** Whether a run is still wanted; a superseded one emits nothing more. */
422
+ static isLive(run) {
423
+ return run !== undefined && !run.cancelled
424
+ }
425
+ }
package/lib/files.js CHANGED
@@ -12,10 +12,11 @@
12
12
  * neither the relay nor the protocol needs to change.
13
13
  */
14
14
 
15
- import { mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs'
16
- import { homedir } from 'node:os'
15
+ import { mkdirSync, openSync, closeSync, writeSync, renameSync, rmSync, statSync } from 'node:fs'
17
16
  import { basename, join } from 'node:path'
18
17
 
18
+ import { dshHome } from './state.js'
19
+
19
20
  /** Reserved method names this module answers; never forwarded to the Host. */
20
21
  export const FILE_BEGIN = '_link/fileBegin'
21
22
  export const FILE_CHUNK = '_link/fileChunk'
@@ -26,21 +27,41 @@ export function isFileMethod(method) {
26
27
  }
27
28
 
28
29
  /**
29
- * One in-flight transfer. Chunks are appended in order and the file only
30
- * appears at its final path once the end frame arrives, so a half-sent file is
31
- * never mistaken for a complete one.
30
+ * One in-flight transfer.
31
+ *
32
+ * Chunks are **appended to a staging file as they arrive**, so the memory a
33
+ * transfer holds is one chunk, not the whole file. It used to accumulate every
34
+ * chunk in an array and `Buffer.concat` at the end, which meant a 300 MB upload
35
+ * needed 300 MB (plus the concatenation's copy) of RSS on this computer — fine
36
+ * for the few-MB screenshots this path was written for, not fine for the large
37
+ * files the background-URL-session path now sends through the same code.
38
+ *
39
+ * The final name only appears once the end frame has arrived and the byte count
40
+ * checks out, so a half-sent file is never mistaken for a complete one.
32
41
  */
33
42
  class Transfer {
34
43
  constructor({ sessionId, name, bytes }) {
35
44
  this.sessionId = sessionId
36
45
  this.name = basename(name || 'file')
37
46
  this.bytes = Number.isFinite(bytes) ? bytes : undefined
38
- this.parts = []
39
47
  this.received = 0
40
48
  this.expectedSeq = 0
41
- this.directory = join(homedir(), '.dsh', 'inbox', sessionId)
49
+ // 认 `DSH_HOME`(不是写死 `~/.dsh`):测试与验证都靠它隔离,
50
+ // 混用两种口径会让隔离失效(宪法第 III 条)。
51
+ this.directory = join(dshHome(), 'inbox', sessionId)
42
52
  mkdirSync(this.directory, { recursive: true, mode: 0o700 })
43
53
  this.staging = join(this.directory, `.${this.name}.part`)
54
+ // Opened once and kept: reopening per chunk would be a syscall per chunk for
55
+ // no benefit. `w` truncates, which is what a retried transfer wants.
56
+ this.fd = undefined
57
+ }
58
+
59
+ #handle() {
60
+ if (this.fd === undefined) {
61
+ rmSync(this.staging, { force: true })
62
+ this.fd = openSync(this.staging, 'w', 0o600)
63
+ }
64
+ return this.fd
44
65
  }
45
66
 
46
67
  append(seq, base64) {
@@ -48,26 +69,34 @@ class Transfer {
48
69
  throw new Error(`chunk ${seq} arrived out of order (expected ${this.expectedSeq})`)
49
70
  }
50
71
  const buffer = Buffer.from(base64, 'base64')
51
- this.parts.push(buffer)
72
+ writeSync(this.#handle(), buffer)
52
73
  this.received += buffer.length
53
74
  this.expectedSeq += 1
54
75
  }
55
76
 
56
77
  finish() {
57
- const body = Buffer.concat(this.parts)
58
- if (this.bytes !== undefined && body.length !== this.bytes) {
59
- throw new Error(`expected ${this.bytes} bytes but received ${body.length}`)
78
+ if (this.bytes !== undefined && this.received !== this.bytes) {
79
+ throw new Error(`expected ${this.bytes} bytes but received ${this.received}`)
60
80
  }
61
- writeFileSync(this.staging, body, { mode: 0o600 })
81
+ this.#close()
62
82
  // Renamed only now: the final name means "complete".
63
83
  const final = join(this.directory, this.name)
64
84
  rmSync(final, { force: true })
65
- writeFileSync(final, body, { mode: 0o600 })
66
- rmSync(this.staging, { force: true })
85
+ renameSync(this.staging, final)
67
86
  return { path: final, bytes: statSync(final).size }
68
87
  }
69
88
 
89
+ #close() {
90
+ if (this.fd === undefined) return
91
+ try {
92
+ closeSync(this.fd)
93
+ } finally {
94
+ this.fd = undefined
95
+ }
96
+ }
97
+
70
98
  dispose() {
99
+ this.#close()
71
100
  rmSync(this.staging, { force: true })
72
101
  }
73
102
  }
package/lib/handoff.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 连接器写给**进程外**消费者的交接文件。
3
+ *
4
+ * 为什么需要它:跑在 DSH 宿主进程之外的脚本(`scripts/dev/dsh-probe.mjs`、`verify-simulator.sh`、
5
+ * `test/tools/host.mjs` 等)与 iOS 的集成测试辅助都没有进程内上下文,拿不到注入的服务;
6
+ * 它们需要一个本机可读的"带凭据本机地址"。契约见
7
+ * `specs/001-connector-host-compat/contracts/out-of-process-handoff.md`。
8
+ *
9
+ * 安全取舍:该文件含可用凭据,因此权限固定 0600、只写本机、不进日志;
10
+ * 这是"让进程外工具可用"与"凭据不落盘"之间**有意识接受**的代价。
11
+ *
12
+ * 不变量:连接器**不读回**这个文件(避免自证循环);连接器停止时**不删除**它
13
+ * (宿主短暂重启期间,进程外工具仍要能读到最近一次的值)。
14
+ */
15
+
16
+ import { chmod, mkdir, rename, writeFile } from 'node:fs/promises'
17
+ import { dirname } from 'node:path'
18
+
19
+ import { defaultHandoffFile } from './state.js'
20
+
21
+ /**
22
+ * 原子写入交接文件。
23
+ *
24
+ * @param {object} input
25
+ * @param {string} input.url 带凭据的本机地址(`http://127.0.0.1:<port>/?token=...`)
26
+ * @param {number} input.port 本机 DSH 端口
27
+ * @param {string} input.source 该地址来自哪一级(通常是 `host-service`)
28
+ * @param {string} [input.path] 覆盖默认路径(测试用)
29
+ * @param {number} [input.pid] 写入进程 pid
30
+ * @param {string} [input.updatedAt] ISO-8601 时间戳
31
+ * @returns {Promise<string>} 写好的文件路径
32
+ */
33
+ export async function writeHandoff({ url, port, source, path = defaultHandoffFile(), pid = process.pid, updatedAt = new Date().toISOString() }) {
34
+ if (typeof url !== 'string' || url === '') throw new TypeError('handoff: url is required')
35
+ if (!Number.isFinite(Number(port)) || Number(port) <= 0) throw new TypeError('handoff: port is required')
36
+ const payload = { url, port: Number(port), pid, source: source ?? null, updatedAt }
37
+ await mkdir(dirname(path), { recursive: true })
38
+ const temporary = `${path}.${process.pid}.tmp`
39
+ await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
40
+ await rename(temporary, path)
41
+ await chmod(path, 0o600).catch(() => {})
42
+ return path
43
+ }
44
+
45
+ /** 由已认证的 endpoint 组出交接文件内容所需的字段。 */
46
+ export function handoffFromEndpoint(endpoint) {
47
+ if (!endpoint?.base || !endpoint?.token) return undefined
48
+ return {
49
+ url: `${endpoint.base}/?token=${encodeURIComponent(endpoint.token)}`,
50
+ port: endpoint.port,
51
+ source: endpoint.source,
52
+ }
53
+ }
package/lib/hello.js CHANGED
@@ -46,9 +46,17 @@ function readManifestVersion() {
46
46
  * - `self-enroll` this connector can register itself with an invite code
47
47
  * - `git` git status / diff / log / show can be read from the work
48
48
  * tree on this computer (see git.js)
49
+ * - `background-transfer`
50
+ * this connector speaks the `fsPut*` / `fsGet*` bridge frames,
51
+ * so the app may **send and fetch** large files over the
52
+ * relay's HTTPS surface with a background URL session instead
53
+ * of the WebSocket. An app that does not see this falls back to
54
+ * the WSS path — which is why adding the capability is the
55
+ * *only* switch the client needs.
49
56
  */
50
57
  export const SERVER_CAPABILITIES = [
51
58
  'file-transfer', 'events', 'session-streams', 'pair-code', 'qr-pairing', 'self-enroll', 'git',
59
+ 'background-transfer',
52
60
  ]
53
61
 
54
62
  export function isHelloMethod(method) {