dsh-plugin-mobile-link 0.3.0 → 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.
- package/README.md +29 -3
- package/cordis.patch.yml +0 -1
- package/lib/cli.js +7 -3
- package/lib/dlp.js +30 -1
- package/lib/dsh-client.js +162 -61
- package/lib/files-out.js +425 -0
- package/lib/files.js +43 -14
- package/lib/handoff.js +53 -0
- package/lib/hello.js +8 -0
- package/lib/index.js +65 -11
- package/lib/link.js +102 -48
- package/lib/pairing.js +1 -1
- package/lib/router.js +288 -4
- package/lib/routes.js +1 -1
- package/lib/state.js +5 -14
- package/lib/status.js +101 -0
- package/package.json +1 -1
package/lib/files-out.js
ADDED
|
@@ -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,
|
|
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.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
|
72
|
+
writeSync(this.#handle(), buffer)
|
|
52
73
|
this.received += buffer.length
|
|
53
74
|
this.expectedSeq += 1
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
finish() {
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|