@limrun/api 0.39.8 → 0.40.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/folder-sync-ignore.d.mts +15 -14
  3. package/folder-sync-ignore.d.mts.map +1 -1
  4. package/folder-sync-ignore.d.ts +15 -14
  5. package/folder-sync-ignore.d.ts.map +1 -1
  6. package/folder-sync-ignore.js +23 -21
  7. package/folder-sync-ignore.js.map +1 -1
  8. package/folder-sync-ignore.mjs +23 -21
  9. package/folder-sync-ignore.mjs.map +1 -1
  10. package/instance-client.d.mts +3 -3
  11. package/instance-client.d.ts +3 -3
  12. package/internal/android-basis-cache.d.mts +0 -1
  13. package/internal/android-basis-cache.d.mts.map +1 -1
  14. package/internal/android-basis-cache.d.ts +0 -1
  15. package/internal/android-basis-cache.d.ts.map +1 -1
  16. package/internal/android-basis-cache.js +29 -9
  17. package/internal/android-basis-cache.js.map +1 -1
  18. package/internal/android-basis-cache.mjs +29 -9
  19. package/internal/android-basis-cache.mjs.map +1 -1
  20. package/internal/seed-reconstruct.d.mts +39 -0
  21. package/internal/seed-reconstruct.d.mts.map +1 -0
  22. package/internal/seed-reconstruct.d.ts +39 -0
  23. package/internal/seed-reconstruct.d.ts.map +1 -0
  24. package/internal/seed-reconstruct.js +413 -0
  25. package/internal/seed-reconstruct.js.map +1 -0
  26. package/internal/seed-reconstruct.mjs +405 -0
  27. package/internal/seed-reconstruct.mjs.map +1 -0
  28. package/package.json +1 -1
  29. package/resources/ios-instances.d.mts +6 -0
  30. package/resources/ios-instances.d.mts.map +1 -1
  31. package/resources/ios-instances.d.ts +6 -0
  32. package/resources/ios-instances.d.ts.map +1 -1
  33. package/src/folder-sync-ignore.ts +30 -28
  34. package/src/instance-client.ts +3 -3
  35. package/src/internal/android-basis-cache.ts +38 -16
  36. package/src/internal/seed-reconstruct.ts +533 -0
  37. package/src/resources/ios-instances.ts +7 -0
  38. package/src/version.ts +1 -1
  39. package/version.d.mts +1 -1
  40. package/version.d.ts +1 -1
  41. package/version.js +1 -1
  42. package/version.mjs +1 -1
@@ -4,13 +4,14 @@ import path from 'path';
4
4
 
5
5
  import { downloadFileToLocalPath } from './download-file';
6
6
  import { nodeProxyTransport } from './proxy-transport';
7
+ import { reconstructSeedFromLocalFiles } from './seed-reconstruct';
7
8
 
8
9
  export type AndroidSyncState = {
9
10
  roots?: Array<{
10
11
  rootName?: string;
11
12
  files?: Array<{ path?: string; sha256?: string; size?: number; mode?: number }>;
12
13
  }>;
13
- seeds?: Array<{ sha256?: string; size?: number; name?: string; mtime?: number }>;
14
+ seeds?: Array<{ sha256?: string; size?: number; mtime?: number }>;
14
15
  };
15
16
 
16
17
  type SyncLog = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
@@ -85,7 +86,7 @@ export async function bootstrapAndroidBasisCache(
85
86
  }
86
87
  const seeds = [...(state.seeds ?? [])]
87
88
  .filter(
88
- (seed): seed is { sha256: string; size?: number; name?: string; mtime?: number } =>
89
+ (seed): seed is { sha256: string; size?: number; mtime?: number } =>
89
90
  typeof seed.sha256 === 'string' && /^[0-9a-f]{64}$/i.test(seed.sha256),
90
91
  )
91
92
  .sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
@@ -93,16 +94,16 @@ export async function bootstrapAndroidBasisCache(
93
94
  serverShas.add(seed.sha256.toLowerCase());
94
95
  }
95
96
 
97
+ let staleBasisPath: string | undefined;
96
98
  if (fs.existsSync(basisPath)) {
97
99
  const basisSha = (await sha256FileHex(basisPath)).toLowerCase();
98
100
  if (serverShas.has(basisSha)) {
99
101
  return;
100
102
  }
101
- // A delta against a basis this instance doesn't have only earns a needFull
102
- // round trip and then a full upload (e.g. a warm cache pointed at a fresh
103
- // instance), so drop the stale basis and re-seed below.
103
+ // Keep the stale basis temporarily: even though the instance cannot use it
104
+ // directly, many of its blocks may be reusable while reconstructing a seed.
104
105
  log('debug', `local basis ${basisSha} unknown to instance; re-seeding basis cache`);
105
- await fs.promises.rm(basisPath, { force: true });
106
+ staleBasisPath = basisPath;
106
107
  }
107
108
 
108
109
  const localSha = (await sha256FileHex(apkPath)).toLowerCase();
@@ -116,16 +117,37 @@ export async function bootstrapAndroidBasisCache(
116
117
  }
117
118
  const seed = seeds[0];
118
119
  if (!seed) {
120
+ if (staleBasisPath) {
121
+ await fs.promises.rm(staleBasisPath, { force: true });
122
+ }
119
123
  return;
120
124
  }
121
- // Announce the download before any bytes flow so consumers can react to the
122
- // expected size even during connection setup / time-to-first-byte.
123
- onBasisDownloadProgress?.(0, seed.size ?? 0);
124
- await downloadFileToLocalPath(
125
- buildSyncSeedUrl(apiUrl, seed.sha256),
126
- token,
127
- basisPath,
128
- onBasisDownloadProgress,
129
- );
130
- log('debug', `seeded Android basis cache from instance seed: ${seed.sha256}`);
125
+ const seedUrl = buildSyncSeedUrl(apiUrl, seed.sha256);
126
+ try {
127
+ const result = await reconstructSeedFromLocalFiles({
128
+ signatureUrl: `${seedUrl}.sig`,
129
+ seedUrl,
130
+ token,
131
+ sourcePaths: staleBasisPath ? [apkPath, staleBasisPath] : [apkPath],
132
+ outputPath: basisPath,
133
+ expectedSha256: seed.sha256,
134
+ ...(onBasisDownloadProgress ? { onProgress: onBasisDownloadProgress } : {}),
135
+ });
136
+ log(
137
+ 'debug',
138
+ `reconstructed Android basis seed ${seed.sha256}: matched=${result.matchedBlocks} downloaded=${result.downloadedBytes}`,
139
+ );
140
+ } catch (err) {
141
+ log(
142
+ 'debug',
143
+ `basis seed reconstruction unavailable; downloading full seed: ${
144
+ err instanceof Error ? err.message : String(err)
145
+ }`,
146
+ );
147
+ // A failed reconstruction never replaces basisPath, so it is safe to
148
+ // truncate the stale basis in place for the full-download fallback.
149
+ onBasisDownloadProgress?.(0, seed.size ?? 0);
150
+ await downloadFileToLocalPath(seedUrl, token, basisPath, onBasisDownloadProgress);
151
+ log('debug', `seeded Android basis cache from full instance seed: ${seed.sha256}`);
152
+ }
131
153
  }
@@ -0,0 +1,533 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { Readable } from 'stream';
5
+
6
+ import { nodeProxyTransport } from './proxy-transport';
7
+
8
+ const SIGNATURE_VERSION = 1;
9
+ const STRONG_HASH_HEX_LENGTH = 32;
10
+ const SCAN_CHUNK_SIZE = 1024 * 1024;
11
+ const MAX_ALIGNED_MISS_BYTES = 32 * 1024 * 1024;
12
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
13
+ const STRONG_HASH_PATTERN = /^[0-9a-f]{32}$/i;
14
+
15
+ export type SeedSignatureBlock = {
16
+ w: number;
17
+ s: string;
18
+ };
19
+
20
+ export type SeedSignature = {
21
+ version: number;
22
+ blockSize: number;
23
+ fileSize: number;
24
+ sha256: string;
25
+ blocks: SeedSignatureBlock[];
26
+ };
27
+
28
+ export type SeedBlockMatch = {
29
+ sourcePath: string;
30
+ sourceOffset: number;
31
+ };
32
+
33
+ export type SeedByteRange = {
34
+ start: number;
35
+ end: number;
36
+ };
37
+
38
+ type BlockSpan = {
39
+ offset: number;
40
+ length: number;
41
+ };
42
+
43
+ type SourceFile = {
44
+ path: string;
45
+ size: number;
46
+ };
47
+
48
+ type ReconstructSeedOptions = {
49
+ signatureUrl: string;
50
+ seedUrl: string;
51
+ token: string;
52
+ sourcePaths: string[];
53
+ outputPath: string;
54
+ expectedSha256: string;
55
+ onProgress?: (downloadedBytes: number, totalBytes: number) => void;
56
+ };
57
+
58
+ function strongHash(buffer: Uint8Array): string {
59
+ return crypto.createHash('sha256').update(buffer).digest('hex').slice(0, STRONG_HASH_HEX_LENGTH);
60
+ }
61
+
62
+ function blockSpan(signature: SeedSignature, index: number): BlockSpan {
63
+ const offset = index * signature.blockSize;
64
+ return {
65
+ offset,
66
+ length: Math.min(signature.blockSize, signature.fileSize - offset),
67
+ };
68
+ }
69
+
70
+ async function sha256FileHex(filePath: string): Promise<string> {
71
+ return await new Promise((resolve, reject) => {
72
+ const hash = crypto.createHash('sha256');
73
+ const stream = fs.createReadStream(filePath);
74
+ stream.on('data', (chunk) => hash.update(chunk));
75
+ stream.on('error', reject);
76
+ stream.on('end', () => resolve(hash.digest('hex')));
77
+ });
78
+ }
79
+
80
+ export function weakRollingChecksum(buffer: Uint8Array): number {
81
+ let a = 0;
82
+ let b = 0;
83
+ for (let i = 0; i < buffer.byteLength; i++) {
84
+ const value = buffer[i]!;
85
+ a = (a + value) & 0xffff;
86
+ b = (b + (buffer.byteLength - i) * value) & 0xffff;
87
+ }
88
+ return b * 0x10000 + a;
89
+ }
90
+
91
+ function validateSignature(value: unknown, expectedSha256: string): SeedSignature {
92
+ if (!value || typeof value !== 'object') {
93
+ throw new Error('Invalid seed signature: expected an object');
94
+ }
95
+ const candidate = value as Partial<SeedSignature>;
96
+ if (candidate.version !== SIGNATURE_VERSION) {
97
+ throw new Error(`Unsupported seed signature version: ${String(candidate.version)}`);
98
+ }
99
+ if (!Number.isSafeInteger(candidate.blockSize) || candidate.blockSize! <= 0) {
100
+ throw new Error('Invalid seed signature blockSize');
101
+ }
102
+ if (!Number.isSafeInteger(candidate.fileSize) || candidate.fileSize! < 0) {
103
+ throw new Error('Invalid seed signature fileSize');
104
+ }
105
+ if (
106
+ typeof candidate.sha256 !== 'string' ||
107
+ !SHA256_PATTERN.test(candidate.sha256) ||
108
+ candidate.sha256.toLowerCase() !== expectedSha256.toLowerCase()
109
+ ) {
110
+ throw new Error('Seed signature sha256 does not match the requested seed');
111
+ }
112
+ if (!Array.isArray(candidate.blocks)) {
113
+ throw new Error('Invalid seed signature blocks');
114
+ }
115
+ const expectedBlocks = Math.ceil(candidate.fileSize! / candidate.blockSize!);
116
+ if (candidate.blocks.length !== expectedBlocks) {
117
+ throw new Error(
118
+ `Invalid seed signature block count: got ${candidate.blocks.length}, expected ${expectedBlocks}`,
119
+ );
120
+ }
121
+ const blocks = candidate.blocks.map((block, index) => {
122
+ if (
123
+ !block ||
124
+ !Number.isInteger(block.w) ||
125
+ block.w < 0 ||
126
+ block.w > 0xffffffff ||
127
+ typeof block.s !== 'string' ||
128
+ !STRONG_HASH_PATTERN.test(block.s)
129
+ ) {
130
+ throw new Error(`Invalid seed signature block at index ${index}`);
131
+ }
132
+ return { w: block.w, s: block.s.toLowerCase() };
133
+ });
134
+ return {
135
+ version: candidate.version,
136
+ blockSize: candidate.blockSize!,
137
+ fileSize: candidate.fileSize!,
138
+ sha256: candidate.sha256.toLowerCase(),
139
+ blocks,
140
+ };
141
+ }
142
+
143
+ export async function fetchSeedSignature(
144
+ signatureUrl: string,
145
+ token: string,
146
+ expectedSha256: string,
147
+ ): Promise<SeedSignature> {
148
+ const response = await nodeProxyTransport.fetch(signatureUrl, {
149
+ method: 'GET',
150
+ headers: { Authorization: `Bearer ${token}` },
151
+ });
152
+ if (!response.ok) {
153
+ const body = await response.text();
154
+ throw new Error(`Seed signature download failed: ${response.status} ${body}`);
155
+ }
156
+ return validateSignature(await response.json(), expectedSha256);
157
+ }
158
+
159
+ async function readExactly(
160
+ handle: fs.promises.FileHandle,
161
+ buffer: Buffer,
162
+ length: number,
163
+ position: number,
164
+ ): Promise<void> {
165
+ let offset = 0;
166
+ while (offset < length) {
167
+ const { bytesRead } = await handle.read(buffer, offset, length - offset, position + offset);
168
+ if (bytesRead <= 0) {
169
+ throw new Error(`Unexpected EOF at offset ${position + offset}`);
170
+ }
171
+ offset += bytesRead;
172
+ }
173
+ }
174
+
175
+ async function scanSourceForFullBlocks(
176
+ source: SourceFile,
177
+ candidatesByWeak: Map<number, number[]>,
178
+ signature: SeedSignature,
179
+ matches: Map<number, SeedBlockMatch>,
180
+ unmatched: Set<number>,
181
+ ): Promise<void> {
182
+ const blockLength = signature.blockSize;
183
+ const handle = await fs.promises.open(source.path, 'r');
184
+ try {
185
+ if (source.size < blockLength || unmatched.size === 0) {
186
+ return;
187
+ }
188
+
189
+ const ring = Buffer.allocUnsafe(blockLength);
190
+ await readExactly(handle, ring, blockLength, 0);
191
+ let a = 0;
192
+ let b = 0;
193
+ for (let i = 0; i < blockLength; i++) {
194
+ const value = ring[i]!;
195
+ a = (a + value) & 0xffff;
196
+ b = (b + (blockLength - i) * value) & 0xffff;
197
+ }
198
+
199
+ let ringPosition = 0;
200
+ let sourceOffset = 0;
201
+ let candidateBuffer: Buffer | undefined;
202
+ const confirmCandidate = async (candidateIndexes: number[]) => {
203
+ candidateBuffer ??= Buffer.allocUnsafe(blockLength);
204
+ await readExactly(handle, candidateBuffer, blockLength, sourceOffset);
205
+ const strong = strongHash(candidateBuffer);
206
+ for (const index of candidateIndexes) {
207
+ if (unmatched.has(index) && signature.blocks[index]!.s === strong) {
208
+ matches.set(index, { sourcePath: source.path, sourceOffset });
209
+ unmatched.delete(index);
210
+ }
211
+ }
212
+ };
213
+
214
+ let candidateIndexes = candidatesByWeak.get(b * 0x10000 + a);
215
+ if (candidateIndexes?.some((index) => unmatched.has(index))) {
216
+ await confirmCandidate(candidateIndexes);
217
+ }
218
+ const incoming = Buffer.allocUnsafe(SCAN_CHUNK_SIZE);
219
+ let readPosition = blockLength;
220
+ while (readPosition < source.size && unmatched.size > 0) {
221
+ const requested = Math.min(incoming.byteLength, source.size - readPosition);
222
+ const { bytesRead } = await handle.read(incoming, 0, requested, readPosition);
223
+ if (bytesRead <= 0) {
224
+ break;
225
+ }
226
+ for (let i = 0; i < bytesRead && unmatched.size > 0; i++) {
227
+ const outgoingValue = ring[ringPosition]!;
228
+ const incomingValue = incoming[i]!;
229
+ ring[ringPosition] = incomingValue;
230
+ ringPosition++;
231
+ if (ringPosition === blockLength) {
232
+ ringPosition = 0;
233
+ }
234
+ a = (a - outgoingValue + incomingValue) & 0xffff;
235
+ b = (b - ((blockLength * outgoingValue) & 0xffff) + a) & 0xffff;
236
+ sourceOffset++;
237
+ candidateIndexes = candidatesByWeak.get(b * 0x10000 + a);
238
+ if (candidateIndexes?.some((index) => unmatched.has(index))) {
239
+ await confirmCandidate(candidateIndexes);
240
+ }
241
+ }
242
+ readPosition += bytesRead;
243
+ }
244
+ } finally {
245
+ await handle.close();
246
+ }
247
+ }
248
+
249
+ async function existingSourceFiles(sourcePaths: string[]): Promise<SourceFile[]> {
250
+ const sources: SourceFile[] = [];
251
+ for (const sourcePath of sourcePaths) {
252
+ const stat = await fs.promises.stat(sourcePath).catch(() => undefined);
253
+ if (stat?.isFile()) {
254
+ sources.push({ path: sourcePath, size: stat.size });
255
+ }
256
+ }
257
+ return sources;
258
+ }
259
+
260
+ async function matchAlignedBlocks(
261
+ sources: SourceFile[],
262
+ signature: SeedSignature,
263
+ matches: Map<number, SeedBlockMatch>,
264
+ ): Promise<void> {
265
+ for (const source of sources) {
266
+ const handle = await fs.promises.open(source.path, 'r');
267
+ const block = Buffer.allocUnsafe(signature.blockSize);
268
+ try {
269
+ for (let index = 0; index < signature.blocks.length; index++) {
270
+ if (matches.has(index)) {
271
+ continue;
272
+ }
273
+ const span = blockSpan(signature, index);
274
+ if (span.offset + span.length > source.size) {
275
+ continue;
276
+ }
277
+ await readExactly(handle, block, span.length, span.offset);
278
+ if (strongHash(block.subarray(0, span.length)) === signature.blocks[index]!.s) {
279
+ matches.set(index, { sourcePath: source.path, sourceOffset: span.offset });
280
+ }
281
+ }
282
+ } finally {
283
+ await handle.close();
284
+ }
285
+ }
286
+ }
287
+
288
+ async function matchTrailingPartialBlock(
289
+ sources: SourceFile[],
290
+ signature: SeedSignature,
291
+ matches: Map<number, SeedBlockMatch>,
292
+ ): Promise<void> {
293
+ if (signature.fileSize === 0 || signature.fileSize % signature.blockSize === 0) {
294
+ return;
295
+ }
296
+ const index = signature.blocks.length - 1;
297
+ if (matches.has(index)) {
298
+ return;
299
+ }
300
+ const span = blockSpan(signature, index);
301
+ const expectedStrongHash = signature.blocks[index]!.s;
302
+ const block = Buffer.allocUnsafe(span.length);
303
+ for (const source of sources) {
304
+ if (source.size < span.length) {
305
+ continue;
306
+ }
307
+ const sourceOffset = source.size - span.length;
308
+ if (sourceOffset === span.offset) {
309
+ continue; // The aligned pass already checked this offset.
310
+ }
311
+ const handle = await fs.promises.open(source.path, 'r');
312
+ try {
313
+ await readExactly(handle, block, span.length, sourceOffset);
314
+ if (strongHash(block) === expectedStrongHash) {
315
+ matches.set(index, { sourcePath: source.path, sourceOffset });
316
+ return;
317
+ }
318
+ } finally {
319
+ await handle.close();
320
+ }
321
+ }
322
+ }
323
+
324
+ function shouldSkipRollingScan(
325
+ signature: SeedSignature,
326
+ matches: ReadonlyMap<number, SeedBlockMatch>,
327
+ ): boolean {
328
+ let missingBytes = 0;
329
+ for (let index = 0; index < signature.blocks.length; index++) {
330
+ if (!matches.has(index)) {
331
+ missingBytes += blockSpan(signature, index).length;
332
+ }
333
+ }
334
+ const matchRatio = signature.blocks.length === 0 ? 1 : matches.size / signature.blocks.length;
335
+ return matchRatio >= 0.8 && missingBytes <= MAX_ALIGNED_MISS_BYTES;
336
+ }
337
+
338
+ async function matchShiftedFullBlocks(
339
+ sources: SourceFile[],
340
+ signature: SeedSignature,
341
+ matches: Map<number, SeedBlockMatch>,
342
+ ): Promise<void> {
343
+ const fullBlockCount = Math.floor(signature.fileSize / signature.blockSize);
344
+ const unmatched = new Set<number>();
345
+ const candidatesByWeak = new Map<number, number[]>();
346
+ for (let index = 0; index < fullBlockCount; index++) {
347
+ if (matches.has(index)) {
348
+ continue;
349
+ }
350
+ unmatched.add(index);
351
+ const weak = signature.blocks[index]!.w;
352
+ const candidates = candidatesByWeak.get(weak) ?? [];
353
+ candidates.push(index);
354
+ candidatesByWeak.set(weak, candidates);
355
+ }
356
+ for (const source of sources) {
357
+ if (unmatched.size === 0) {
358
+ return;
359
+ }
360
+ await scanSourceForFullBlocks(source, candidatesByWeak, signature, matches, unmatched);
361
+ }
362
+ }
363
+
364
+ export async function findSeedBlockMatches(
365
+ sourcePaths: string[],
366
+ signature: SeedSignature,
367
+ ): Promise<Map<number, SeedBlockMatch>> {
368
+ const sources = await existingSourceFiles(sourcePaths);
369
+ const matches = new Map<number, SeedBlockMatch>();
370
+ await matchAlignedBlocks(sources, signature, matches);
371
+ await matchTrailingPartialBlock(sources, signature, matches);
372
+ if (!shouldSkipRollingScan(signature, matches)) {
373
+ await matchShiftedFullBlocks(sources, signature, matches);
374
+ }
375
+ return matches;
376
+ }
377
+
378
+ export function missingSeedRanges(
379
+ signature: SeedSignature,
380
+ matches: ReadonlyMap<number, SeedBlockMatch>,
381
+ ): SeedByteRange[] {
382
+ const ranges: SeedByteRange[] = [];
383
+ let startBlock: number | undefined;
384
+ for (let index = 0; index <= signature.blocks.length; index++) {
385
+ const missing = index < signature.blocks.length && !matches.has(index);
386
+ if (missing && startBlock === undefined) {
387
+ startBlock = index;
388
+ } else if (!missing && startBlock !== undefined) {
389
+ const first = blockSpan(signature, startBlock);
390
+ const last = blockSpan(signature, index - 1);
391
+ ranges.push({
392
+ start: first.offset,
393
+ end: last.offset + last.length - 1,
394
+ });
395
+ startBlock = undefined;
396
+ }
397
+ }
398
+ return ranges;
399
+ }
400
+
401
+ async function copyMatchedBlocks(
402
+ output: fs.promises.FileHandle,
403
+ signature: SeedSignature,
404
+ matches: ReadonlyMap<number, SeedBlockMatch>,
405
+ ): Promise<void> {
406
+ const sourceHandles = new Map<string, fs.promises.FileHandle>();
407
+ try {
408
+ for (const [index, match] of matches) {
409
+ let source = sourceHandles.get(match.sourcePath);
410
+ if (!source) {
411
+ source = await fs.promises.open(match.sourcePath, 'r');
412
+ sourceHandles.set(match.sourcePath, source);
413
+ }
414
+ const span = blockSpan(signature, index);
415
+ const block = Buffer.allocUnsafe(span.length);
416
+ await readExactly(source, block, span.length, match.sourceOffset);
417
+ await writeExactly(output, block, span.offset);
418
+ }
419
+ } finally {
420
+ await Promise.all([...sourceHandles.values()].map((handle) => handle.close()));
421
+ }
422
+ }
423
+
424
+ async function writeExactly(handle: fs.promises.FileHandle, buffer: Buffer, position: number): Promise<void> {
425
+ let offset = 0;
426
+ while (offset < buffer.byteLength) {
427
+ const { bytesWritten } = await handle.write(
428
+ buffer,
429
+ offset,
430
+ buffer.byteLength - offset,
431
+ position + offset,
432
+ );
433
+ if (bytesWritten <= 0) {
434
+ throw new Error(`Unable to write reconstructed seed at offset ${position + offset}`);
435
+ }
436
+ offset += bytesWritten;
437
+ }
438
+ }
439
+
440
+ async function downloadRange(
441
+ seedUrl: string,
442
+ token: string,
443
+ range: SeedByteRange,
444
+ fileSize: number,
445
+ output: fs.promises.FileHandle,
446
+ reportBytes: (bytes: number) => void,
447
+ ): Promise<void> {
448
+ const response = await nodeProxyTransport.fetch(seedUrl, {
449
+ method: 'GET',
450
+ headers: {
451
+ Authorization: `Bearer ${token}`,
452
+ Range: `bytes=${range.start}-${range.end}`,
453
+ },
454
+ });
455
+ if (response.status !== 206 || !response.body) {
456
+ const body = await response.text();
457
+ throw new Error(`Seed range download failed: ${response.status} ${body}`);
458
+ }
459
+ const expectedContentRange = `bytes ${range.start}-${range.end}/${fileSize}`;
460
+ if (response.headers.get('content-range') !== expectedContentRange) {
461
+ throw new Error(
462
+ `Invalid Content-Range for seed: ${response.headers.get('content-range') ?? '<missing>'}`,
463
+ );
464
+ }
465
+
466
+ const expectedLength = range.end - range.start + 1;
467
+ let received = 0;
468
+ const source = Readable.fromWeb(response.body as any);
469
+ for await (const value of source) {
470
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value as Uint8Array);
471
+ if (received + chunk.byteLength > expectedLength) {
472
+ throw new Error('Seed range response exceeded the requested length');
473
+ }
474
+ await writeExactly(output, chunk, range.start + received);
475
+ received += chunk.byteLength;
476
+ reportBytes(chunk.byteLength);
477
+ }
478
+ if (received !== expectedLength) {
479
+ throw new Error(`Seed range response was truncated: got ${received}, expected ${expectedLength}`);
480
+ }
481
+ }
482
+
483
+ export async function reconstructSeedFromLocalFiles(options: ReconstructSeedOptions): Promise<{
484
+ downloadedBytes: number;
485
+ totalDownloadBytes: number;
486
+ matchedBlocks: number;
487
+ }> {
488
+ const expectedSha256 = options.expectedSha256.toLowerCase();
489
+ const signature = await fetchSeedSignature(options.signatureUrl, options.token, expectedSha256);
490
+ const uniqueSources = [...new Set(options.sourcePaths.map((sourcePath) => path.resolve(sourcePath)))];
491
+ const matches = await findSeedBlockMatches(uniqueSources, signature);
492
+ const ranges = missingSeedRanges(signature, matches);
493
+ const totalDownloadBytes = ranges.reduce((total, range) => total + range.end - range.start + 1, 0);
494
+ let downloadedBytes = 0;
495
+ options.onProgress?.(0, totalDownloadBytes);
496
+
497
+ await fs.promises.mkdir(path.dirname(options.outputPath), { recursive: true });
498
+ const temporaryPath = `${options.outputPath}.reconstruct-${process.pid}-${crypto
499
+ .randomBytes(6)
500
+ .toString('hex')}`;
501
+ const output = await fs.promises.open(temporaryPath, 'wx');
502
+ let outputClosed = false;
503
+ let installed = false;
504
+ try {
505
+ await output.truncate(signature.fileSize);
506
+ await copyMatchedBlocks(output, signature, matches);
507
+ for (const range of ranges) {
508
+ await downloadRange(options.seedUrl, options.token, range, signature.fileSize, output, (bytes) => {
509
+ downloadedBytes += bytes;
510
+ options.onProgress?.(downloadedBytes, totalDownloadBytes);
511
+ });
512
+ }
513
+ await output.close();
514
+ outputClosed = true;
515
+
516
+ const actualSha256 = await sha256FileHex(temporaryPath);
517
+ if (actualSha256 !== expectedSha256) {
518
+ throw new Error(`Reconstructed seed SHA-256 mismatch: got ${actualSha256}, expected ${expectedSha256}`);
519
+ }
520
+ await fs.promises.rm(options.outputPath, { force: true });
521
+ await fs.promises.rename(temporaryPath, options.outputPath);
522
+ installed = true;
523
+ } finally {
524
+ if (!outputClosed) {
525
+ await output.close().catch(() => undefined);
526
+ }
527
+ if (!installed) {
528
+ await fs.promises.rm(temporaryPath, { force: true });
529
+ }
530
+ }
531
+
532
+ return { downloadedBytes, totalDownloadBytes, matchedBlocks: matches.size };
533
+ }
@@ -170,6 +170,13 @@ export namespace IosInstanceCreateParams {
170
170
  export interface Spec {
171
171
  clues?: Array<Spec.Clue>;
172
172
 
173
+ /**
174
+ * Keeps this app in the foreground after it is first observed there. This does not
175
+ * launch the app when the simulator starts. Once armed, closing or backgrounding
176
+ * the app causes it to be brought back to the foreground.
177
+ */
178
+ forceBundleId?: string;
179
+
173
180
  /**
174
181
  * After how many minutes should the instance be terminated. Example values 1m,
175
182
  * 10m, 3h. Default is "0" which means no hard timeout.
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.39.8'; // x-release-please-version
1
+ export const VERSION = '0.40.1'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.39.8";
1
+ export declare const VERSION = "0.40.1";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.39.8";
1
+ export declare const VERSION = "0.40.1";
2
2
  //# sourceMappingURL=version.d.ts.map
package/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '0.39.8'; // x-release-please-version
4
+ exports.VERSION = '0.40.1'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.39.8'; // x-release-please-version
1
+ export const VERSION = '0.40.1'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map