@limrun/api 0.38.0 → 0.39.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 (77) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/app-archive.d.mts +1 -1
  3. package/app-archive.d.mts.map +1 -1
  4. package/app-archive.d.ts +1 -1
  5. package/app-archive.d.ts.map +1 -1
  6. package/app-archive.js +22 -10
  7. package/app-archive.js.map +1 -1
  8. package/app-archive.mjs +22 -10
  9. package/app-archive.mjs.map +1 -1
  10. package/exec-client.d.mts +34 -0
  11. package/exec-client.d.mts.map +1 -1
  12. package/exec-client.d.ts +34 -0
  13. package/exec-client.d.ts.map +1 -1
  14. package/exec-client.js +25 -1
  15. package/exec-client.js.map +1 -1
  16. package/exec-client.mjs +25 -1
  17. package/exec-client.mjs.map +1 -1
  18. package/folder-sync-watcher.d.mts +1 -1
  19. package/folder-sync-watcher.d.mts.map +1 -1
  20. package/folder-sync-watcher.d.ts +1 -1
  21. package/folder-sync-watcher.d.ts.map +1 -1
  22. package/folder-sync-watcher.js +2 -9
  23. package/folder-sync-watcher.js.map +1 -1
  24. package/folder-sync-watcher.mjs +2 -9
  25. package/folder-sync-watcher.mjs.map +1 -1
  26. package/folder-sync.d.mts +4 -4
  27. package/folder-sync.d.mts.map +1 -1
  28. package/folder-sync.d.ts +4 -4
  29. package/folder-sync.d.ts.map +1 -1
  30. package/folder-sync.js +149 -45
  31. package/folder-sync.js.map +1 -1
  32. package/folder-sync.mjs +149 -45
  33. package/folder-sync.mjs.map +1 -1
  34. package/index.d.mts +1 -1
  35. package/index.d.mts.map +1 -1
  36. package/index.d.ts +1 -1
  37. package/index.d.ts.map +1 -1
  38. package/index.js.map +1 -1
  39. package/index.mjs.map +1 -1
  40. package/instance-client.d.mts +10 -0
  41. package/instance-client.d.mts.map +1 -1
  42. package/instance-client.d.ts +10 -0
  43. package/instance-client.d.ts.map +1 -1
  44. package/instance-client.js +124 -0
  45. package/instance-client.js.map +1 -1
  46. package/instance-client.mjs +123 -0
  47. package/instance-client.mjs.map +1 -1
  48. package/ios-client.d.mts +0 -2
  49. package/ios-client.d.mts.map +1 -1
  50. package/ios-client.d.ts +0 -2
  51. package/ios-client.d.ts.map +1 -1
  52. package/ios-client.js +2 -3
  53. package/ios-client.js.map +1 -1
  54. package/ios-client.mjs +2 -3
  55. package/ios-client.mjs.map +1 -1
  56. package/package.json +2 -2
  57. package/resources/xcode-instances-helpers.d.mts +10 -4
  58. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  59. package/resources/xcode-instances-helpers.d.ts +10 -4
  60. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  61. package/resources/xcode-instances-helpers.js +1 -1
  62. package/resources/xcode-instances-helpers.js.map +1 -1
  63. package/resources/xcode-instances-helpers.mjs +1 -1
  64. package/resources/xcode-instances-helpers.mjs.map +1 -1
  65. package/src/app-archive.ts +22 -10
  66. package/src/exec-client.ts +58 -1
  67. package/src/folder-sync-watcher.ts +2 -8
  68. package/src/folder-sync.ts +168 -61
  69. package/src/index.ts +2 -0
  70. package/src/instance-client.ts +159 -0
  71. package/src/ios-client.ts +2 -6
  72. package/src/resources/xcode-instances-helpers.ts +12 -5
  73. package/src/version.ts +1 -1
  74. package/version.d.mts +1 -1
  75. package/version.d.ts +1 -1
  76. package/version.js +1 -1
  77. package/version.mjs +1 -1
@@ -5,6 +5,7 @@ import crypto from 'crypto';
5
5
  import { watchFolderTree } from './folder-sync-watcher';
6
6
  import { type IgnoreFn } from './folder-sync-ignore';
7
7
  import { Readable } from 'stream';
8
+ import { once } from 'events';
8
9
  import * as zlib from 'zlib';
9
10
  import { nodeProxyTransport } from './internal/proxy-transport';
10
11
  import { directInstanceHttpError } from './internal/direct-instance-errors';
@@ -29,8 +30,6 @@ export type FolderSyncOptions = {
29
30
  launchMode: 'ForegroundIfRunning' | 'RelaunchIfRunning';
30
31
  /** If true, watch the folder and re-sync on any changes (debounced, single-flight). */
31
32
  watch: boolean;
32
- /** Max patch size (bytes) to send as delta before falling back to full upload. */
33
- maxPatchBytes: number;
34
33
  /** Controls logging verbosity */
35
34
  log: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
36
35
  /**
@@ -53,13 +52,15 @@ export type FolderSyncOptions = {
53
52
  * in every sync pass but are not watched directly.
54
53
  */
55
54
  additionalFiles?: AdditionalFileSyncEntry[];
55
+ /** Force transport content encoding. Android APK sync uses identity because APKs are already compressed. */
56
+ compression?: 'zstd' | 'gzip' | 'identity';
56
57
  };
57
58
 
58
59
  export type SyncFolderResult = {
59
60
  installedAppPath?: string;
60
61
  installedBundleId?: string;
61
62
  /** Present only when watch=true; call to stop watching. */
62
- stopWatching?: () => void;
63
+ stopWatching?: () => Promise<void>;
63
64
  };
64
65
 
65
66
  export type AdditionalFileSyncEntry = { localPath: string; remotePath: string };
@@ -264,6 +265,22 @@ async function sha256FileHex(filePath: string): Promise<string> {
264
265
 
265
266
  async function walkFiles(root: string, ignoreFn: IgnoreFn): Promise<FileEntry[]> {
266
267
  const rootResolved = path.resolve(root);
268
+ const rootStat = await fs.promises.stat(rootResolved);
269
+ if (rootStat.isFile()) {
270
+ const rel = path.basename(rootResolved);
271
+ if (ignoreFn(rel)) {
272
+ return [];
273
+ }
274
+ return [
275
+ {
276
+ path: rel,
277
+ size: rootStat.size,
278
+ sha256: await sha256FileHex(rootResolved),
279
+ absPath: rootResolved,
280
+ mode: rootStat.mode & 0o7777,
281
+ },
282
+ ];
283
+ }
267
284
 
268
285
  const out: FileEntry[] = [];
269
286
  const stack: string[] = [rootResolved];
@@ -325,37 +342,47 @@ async function collectAdditionalFiles(
325
342
  return out;
326
343
  }
327
344
 
328
- // xdelta3 encoder backed by a WASM build of the upstream xdelta3 library.
329
- // Produces VCDIFF-compatible patches identical to `xdelta3 -e -s basis target`,
330
- // so the server-side decoder continues to apply them without changes.
331
- type Xdelta3Wasm = {
332
- init: () => Promise<void>;
333
- xd3_encode_memory: (
334
- input: Uint8Array,
335
- source: Uint8Array,
336
- output_size_max: number,
337
- smatch_cfg: number,
338
- ) => { ret: number; str: string; output: Uint8Array };
339
- xd3_smatch_cfg: { DEFAULT: number };
340
- WASI_ERRNO: { ENOSPC: number };
345
+ type Xdelta3SourceReader = {
346
+ size: number;
347
+ read(offset: number, into: Uint8Array): number | Promise<number>;
341
348
  };
342
349
 
343
- let xdelta3WasmReady: Promise<Xdelta3Wasm> | null = null;
344
- async function loadXdelta3Wasm(): Promise<Xdelta3Wasm> {
345
- if (!xdelta3WasmReady) {
346
- xdelta3WasmReady = (async () => {
350
+ type Xdelta3StreamingModule = {
351
+ encode(target: AsyncIterable<Uint8Array>, source: Xdelta3SourceReader): AsyncIterable<Uint8Array>;
352
+ };
353
+
354
+ let xdelta3StreamingReady: Promise<Xdelta3StreamingModule> | null = null;
355
+ async function loadXdelta3Streaming(): Promise<Xdelta3StreamingModule> {
356
+ if (!xdelta3StreamingReady) {
357
+ xdelta3StreamingReady = (async () => {
347
358
  // Dynamic import so the WASM module is only loaded when sync is actually used.
348
359
  // Works in both CJS and ESM outputs emitted by tsc-multi.
349
- const mod = (await import('xdelta3-wasm')) as unknown as Xdelta3Wasm;
350
- await mod.init();
351
- return mod;
360
+ return (await import('@limrun/xdelta3-wasm')) as unknown as Xdelta3StreamingModule;
352
361
  })().catch((err) => {
353
362
  // Allow retry on a subsequent call if the first init failed.
354
- xdelta3WasmReady = null;
363
+ xdelta3StreamingReady = null;
355
364
  throw err;
356
365
  });
357
366
  }
358
- return await xdelta3WasmReady;
367
+ return await xdelta3StreamingReady;
368
+ }
369
+
370
+ async function* fileChunks(filePath: string): AsyncIterable<Uint8Array> {
371
+ for await (const chunk of fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 })) {
372
+ if (chunk instanceof Uint8Array) {
373
+ yield chunk;
374
+ } else {
375
+ yield Buffer.from(chunk);
376
+ }
377
+ }
378
+ }
379
+
380
+ const MIN_XDELTA_TARGET_BYTES = 64 * 1024;
381
+ const WATCH_DEBOUNCE_MS = 500;
382
+
383
+ function defaultMaxPatchBytes(targetSize: number): number {
384
+ // Use the patch only when it is strictly smaller than 90% of the target file.
385
+ return Math.max(0, Math.ceil(targetSize * 0.9) - 1);
359
386
  }
360
387
 
361
388
  /**
@@ -363,7 +390,7 @@ async function loadXdelta3Wasm(): Promise<Xdelta3Wasm> {
363
390
  * to `outPatch`. Returns the size of the resulting patch in bytes.
364
391
  *
365
392
  * If the encoder would produce a patch larger than `maxPatchBytes`, it short-
366
- * circuits with ENOSPC and this function returns -1 without writing a file, so
393
+ * circuits and this function returns -1 without writing a file, so
367
394
  * callers can fall back to a full upload cheaply.
368
395
  */
369
396
  export async function encodeXdelta3Patch(
@@ -372,22 +399,48 @@ export async function encodeXdelta3Patch(
372
399
  outPatch: string,
373
400
  maxPatchBytes: number,
374
401
  ): Promise<number> {
375
- const wasm = await loadXdelta3Wasm();
376
- const [basisBuf, targetBuf] = await Promise.all([
377
- fs.promises.readFile(basis),
378
- fs.promises.readFile(target),
379
- ]);
380
- const basisBytes = new Uint8Array(basisBuf.buffer, basisBuf.byteOffset, basisBuf.byteLength);
381
- const targetBytes = new Uint8Array(targetBuf.buffer, targetBuf.byteOffset, targetBuf.byteLength);
382
- const res = wasm.xd3_encode_memory(targetBytes, basisBytes, maxPatchBytes, wasm.xd3_smatch_cfg.DEFAULT);
383
- if (res.ret === wasm.WASI_ERRNO.ENOSPC) {
384
- return -1;
402
+ const xdelta3 = await loadXdelta3Streaming();
403
+ const basisHandle = await fs.promises.open(basis, 'r');
404
+ const basisSize = (await basisHandle.stat()).size;
405
+ const output = fs.createWriteStream(outPatch);
406
+ let bytesWritten = 0;
407
+ let exceededLimit = false;
408
+
409
+ const source: Xdelta3SourceReader = {
410
+ size: basisSize,
411
+ read: async (offset, into) => {
412
+ const { bytesRead } = await basisHandle.read(into, 0, into.byteLength, offset);
413
+ return bytesRead;
414
+ },
415
+ };
416
+
417
+ try {
418
+ for await (const chunk of xdelta3.encode(fileChunks(target), source)) {
419
+ bytesWritten += chunk.byteLength;
420
+ if (bytesWritten > maxPatchBytes) {
421
+ exceededLimit = true;
422
+ break;
423
+ }
424
+ if (!output.write(chunk)) {
425
+ await once(output, 'drain');
426
+ }
427
+ }
428
+
429
+ output.end();
430
+ await once(output, 'finish');
431
+ } catch (err) {
432
+ output.destroy();
433
+ await fs.promises.rm(outPatch, { force: true });
434
+ throw err;
435
+ } finally {
436
+ await basisHandle.close();
385
437
  }
386
- if (res.ret !== 0) {
387
- throw new Error(`xdelta3 encode failed: ${res.str} (code=${res.ret})`);
438
+
439
+ if (exceededLimit) {
440
+ await fs.promises.rm(outPatch, { force: true });
441
+ return -1;
388
442
  }
389
- await fs.promises.writeFile(outPatch, res.output);
390
- return res.output.byteLength;
443
+ return bytesWritten;
391
444
  }
392
445
 
393
446
  async function cachePut(cacheRoot: string, relPath: string, srcFile: string): Promise<void> {
@@ -416,36 +469,84 @@ export async function syncFolder(
416
469
  const first = await syncFolderOnce(localFolderPath, opts, 'startup');
417
470
  let inFlight = false;
418
471
  let queued = false;
472
+ let closed = false;
473
+ let debounceTimer: NodeJS.Timeout | undefined;
474
+ let activeRun: Promise<void> | undefined;
419
475
 
420
476
  const run = async (reason: string) => {
421
- if (inFlight) {
422
- queued = true;
423
- return;
424
- }
425
477
  inFlight = true;
426
478
  try {
427
479
  await syncFolderOnce(localFolderPath, opts, reason);
428
480
  } finally {
429
481
  inFlight = false;
430
- if (queued) {
482
+ if (queued && !closed) {
431
483
  queued = false;
432
- void run('queued-changes');
484
+ startRun('queued-changes');
433
485
  }
434
486
  }
435
487
  };
436
- const watcher = await watchFolderTree({
437
- rootPath: localFolderPath,
438
- log,
439
- ignoreFn: opts.ignoreFn,
440
- onChange: (reason) => {
441
- void run(reason);
442
- },
443
- });
488
+ const startRun = (reason: string) => {
489
+ if (closed) {
490
+ return;
491
+ }
492
+ if (inFlight) {
493
+ queued = true;
494
+ return;
495
+ }
496
+ const promise = run(reason).catch((err) => {
497
+ log('error', `syncFolder: watch sync failed: ${err instanceof Error ? err.message : String(err)}`);
498
+ });
499
+ activeRun = promise;
500
+ void promise.finally(() => {
501
+ if (activeRun === promise) {
502
+ activeRun = undefined;
503
+ }
504
+ });
505
+ };
506
+ const schedule = (reason: string) => {
507
+ if (closed) {
508
+ return;
509
+ }
510
+ if (debounceTimer) {
511
+ clearTimeout(debounceTimer);
512
+ }
513
+ debounceTimer = setTimeout(() => {
514
+ debounceTimer = undefined;
515
+ startRun(reason);
516
+ }, WATCH_DEBOUNCE_MS);
517
+ };
518
+ const st = await fs.promises.stat(localFolderPath);
519
+ let watcher: { close: () => void };
520
+ if (st.isFile()) {
521
+ const watchedFile = path.basename(localFolderPath);
522
+ const parent = path.dirname(localFolderPath);
523
+ const fsWatcher = fs.watch(parent, (_eventType, filename) => {
524
+ if (!filename || filename.toString() !== watchedFile) return;
525
+ schedule(`change:${watchedFile}`);
526
+ });
527
+ watcher = { close: () => fsWatcher.close() };
528
+ } else {
529
+ watcher = await watchFolderTree({
530
+ rootPath: localFolderPath,
531
+ log,
532
+ ignoreFn: opts.ignoreFn,
533
+ onChange: (reason) => {
534
+ schedule(reason);
535
+ },
536
+ });
537
+ }
444
538
 
445
539
  return {
446
540
  ...first,
447
- stopWatching: () => {
541
+ stopWatching: async () => {
542
+ closed = true;
543
+ queued = false;
544
+ if (debounceTimer) {
545
+ clearTimeout(debounceTimer);
546
+ debounceTimer = undefined;
547
+ }
448
548
  watcher.close();
549
+ await activeRun;
449
550
  },
450
551
  };
451
552
  }
@@ -459,7 +560,6 @@ async function syncFolderOnce(
459
560
  const totalStart = nowMs();
460
561
  const log = opts.log ?? noopLogger;
461
562
  const slog = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => log(level, `syncFolder: ${msg}`);
462
- const maxPatchBytes = opts.maxPatchBytes ?? 4 * 1024 * 1024;
463
563
 
464
564
  const files = await walkFiles(localFolderPath, opts.ignoreFn);
465
565
  const additionalFiles = await collectAdditionalFiles(opts.additionalFiles);
@@ -468,7 +568,8 @@ async function syncFolderOnce(
468
568
 
469
569
  const syncId = genId('sync');
470
570
  const rootName = path.basename(path.resolve(localFolderPath));
471
- const preferredCompression = (zlib as any).createZstdCompress ? 'zstd' : 'gzip';
571
+ const preferredCompression: 'zstd' | 'gzip' | 'identity' =
572
+ opts.compression ?? ((zlib as any).createZstdCompress ? 'zstd' : 'gzip');
472
573
 
473
574
  await fs.promises.mkdir(opts.basisCacheDir, { recursive: true });
474
575
 
@@ -496,12 +597,17 @@ async function syncFolderOnce(
496
597
 
497
598
  const encodedPayloads = await mapLimit(changed, encodeLimit, async (f): Promise<EncodedPayload> => {
498
599
  const basisPath = cacheGet(opts.basisCacheDir, f.path);
499
- if (fs.existsSync(basisPath)) {
600
+ if (f.size >= MIN_XDELTA_TARGET_BYTES && fs.existsSync(basisPath)) {
500
601
  const basisSha = await sha256FileHex(basisPath);
501
602
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'limulator-xdelta3-'));
502
603
  const patchPath = path.join(tmpDir, 'patch.xdelta3');
503
604
  const encodeStart = nowMs();
504
- const patchSize = await encodeXdelta3Patch(basisPath, f.absPath, patchPath, maxPatchBytes);
605
+ const patchSize = await encodeXdelta3Patch(
606
+ basisPath,
607
+ f.absPath,
608
+ patchPath,
609
+ defaultMaxPatchBytes(f.size),
610
+ );
505
611
  const encodeMs = nowMs() - encodeStart;
506
612
  deltaEncodeMsTotal += encodeMs;
507
613
  if (patchSize >= 0) {
@@ -556,7 +662,8 @@ async function syncFolderOnce(
556
662
  payloads: encodedPayloads.map((p) => p.payload),
557
663
  };
558
664
  const hasDelta = encodedPayloads.some((p) => p.payload.kind === 'delta');
559
- const compression: 'zstd' | 'gzip' | 'identity' = hasDelta ? 'identity' : preferredCompression;
665
+ const compression: 'zstd' | 'gzip' | 'identity' =
666
+ opts.compression ?? (hasDelta ? 'identity' : preferredCompression);
560
667
  slog(
561
668
  'debug',
562
669
  `sync started files=${allFiles.length}${reason ? ` reason=${reason}` : ''} compression=${compression}`,
@@ -609,7 +716,7 @@ async function syncFolderOnce(
609
716
  opts,
610
717
  retryMeta,
611
718
  retryPayloads.map((p) => ({ filePath: p.filePath })),
612
- preferredCompression,
719
+ opts.compression ?? preferredCompression,
613
720
  );
614
721
  httpSendMsTotal += nowMs() - retryStart;
615
722
  }
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@ export {
16
16
  type ExecOptions,
17
17
  type ExecResult,
18
18
  type ExecChildProcess,
19
+ type TestflightEvent,
20
+ type TestflightUploadConfig,
19
21
  } from './exec-client';
20
22
  export {
21
23
  type XcodeCreateClientParams,
@@ -1,10 +1,15 @@
1
1
  import { WebSocket, Data } from 'ws';
2
2
  import { exec } from 'node:child_process';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import crypto from 'crypto';
3
7
 
4
8
  import { downloadFileToLocalPath } from './internal/download-file';
5
9
  import { nodeProxyTransport } from './internal/proxy-transport';
6
10
  import { startTcpTunnel, isNonRetryableError } from './tunnel';
7
11
  import type { Tunnel } from './tunnel';
12
+ import { syncFolder, type FolderSyncOptions, type SyncFolderResult } from './folder-sync';
8
13
 
9
14
  const ANDROID_RECORDING_PATH = '/data/local/tmp/recordings/video_recording.mp4';
10
15
  const ANDROID_SIGNALING_PATH = '/ws';
@@ -32,12 +37,103 @@ function buildDownloadUrl(apiUrl: string): string {
32
37
  return `${apiUrl}/files?path=${encodeURIComponent(ANDROID_RECORDING_PATH)}`;
33
38
  }
34
39
 
40
+ function buildSyncStateUrl(apiUrl: string): string {
41
+ return `${apiUrl}/sync/state`;
42
+ }
43
+
44
+ function buildSyncSeedUrl(apiUrl: string, sha256: string): string {
45
+ return `${apiUrl}/sync/seeds/${encodeURIComponent(sha256)}`;
46
+ }
47
+
35
48
  function assertBandwidthKbps(field: keyof WifiBandwidthOptions, value: number): void {
36
49
  if (!Number.isInteger(value) || value < 0) {
37
50
  throw new Error(`${field} must be a non-negative integer Kbps value`);
38
51
  }
39
52
  }
40
53
 
54
+ async function sha256FileHex(filePath: string): Promise<string> {
55
+ return await new Promise((resolve, reject) => {
56
+ const hash = crypto.createHash('sha256');
57
+ const stream = fs.createReadStream(filePath);
58
+ stream.on('data', (chunk) => hash.update(chunk));
59
+ stream.on('error', reject);
60
+ stream.on('end', () => resolve(hash.digest('hex')));
61
+ });
62
+ }
63
+
64
+ async function assertApkFile(filePath: string): Promise<void> {
65
+ const st = await fs.promises.stat(filePath).catch(() => null);
66
+ if (!st?.isFile()) {
67
+ throw new Error(`APK file not found: ${filePath}`);
68
+ }
69
+ const fd = await fs.promises.open(filePath, 'r');
70
+ try {
71
+ const buf = Buffer.alloc(4);
72
+ const { bytesRead } = await fd.read(buf, 0, 4, 0);
73
+ if (bytesRead < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) {
74
+ throw new Error(`File is not an APK/ZIP archive: ${filePath}`);
75
+ }
76
+ } finally {
77
+ await fd.close();
78
+ }
79
+ }
80
+
81
+ async function fetchAndroidSyncState(apiUrl: string, token: string): Promise<AndroidSyncState> {
82
+ const response = await nodeProxyTransport.fetch(buildSyncStateUrl(apiUrl), {
83
+ method: 'GET',
84
+ headers: { Authorization: `Bearer ${token}` },
85
+ });
86
+ if (!response.ok) {
87
+ const text = await response.text();
88
+ throw new Error(`Failed to fetch Android sync state: ${response.status} ${text}`);
89
+ }
90
+ return (await response.json()) as AndroidSyncState;
91
+ }
92
+
93
+ async function bootstrapAndroidBasisCache(
94
+ apkPath: string,
95
+ basisCacheDir: string,
96
+ apiUrl: string,
97
+ token: string,
98
+ log: FolderSyncOptions['log'],
99
+ ): Promise<void> {
100
+ const remotePath = path.basename(apkPath);
101
+ const basisPath = path.join(basisCacheDir, remotePath);
102
+ if (fs.existsSync(basisPath)) {
103
+ return;
104
+ }
105
+ const state = await fetchAndroidSyncState(apiUrl, token).catch((err) => {
106
+ log('debug', `android sync state unavailable: ${err instanceof Error ? err.message : String(err)}`);
107
+ return undefined;
108
+ });
109
+ if (!state) {
110
+ return;
111
+ }
112
+ const localSha = await sha256FileHex(apkPath);
113
+ for (const root of state.roots ?? []) {
114
+ for (const file of root.files ?? []) {
115
+ if (file.path === remotePath && file.sha256?.toLowerCase() === localSha) {
116
+ await fs.promises.mkdir(path.dirname(basisPath), { recursive: true });
117
+ await fs.promises.copyFile(apkPath, basisPath);
118
+ log('debug', `seeded Android basis cache from matching server root: ${remotePath}`);
119
+ return;
120
+ }
121
+ }
122
+ }
123
+ const seeds = [...(state.seeds ?? [])]
124
+ .filter(
125
+ (seed): seed is { sha256: string; size?: number; name?: string; mtime?: number } =>
126
+ typeof seed.sha256 === 'string' && /^[0-9a-f]{64}$/i.test(seed.sha256),
127
+ )
128
+ .sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
129
+ const seed = seeds[0];
130
+ if (!seed) {
131
+ return;
132
+ }
133
+ await downloadFileToLocalPath(buildSyncSeedUrl(apiUrl, seed.sha256), token, basisPath);
134
+ log('debug', `seeded Android basis cache from instance seed: ${seed.sha256}`);
135
+ }
136
+
41
137
  /**
42
138
  * A client for interacting with a Limbar instance
43
139
  */
@@ -131,6 +227,19 @@ export type InstanceClient = {
131
227
  */
132
228
  sendAsset: (url: string, timeoutMs?: number) => Promise<void>;
133
229
 
230
+ /**
231
+ * Delta-sync a local APK to the instance and install it.
232
+ */
233
+ syncApp: (
234
+ apkPath: string,
235
+ opts?: {
236
+ install?: boolean;
237
+ watch?: boolean;
238
+ launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
239
+ basisCacheDir?: string;
240
+ },
241
+ ) => Promise<SyncFolderResult>;
242
+
134
243
  /**
135
244
  * Get current connection state
136
245
  */
@@ -258,6 +367,14 @@ type ScreenshotData = {
258
367
  dataUri: string;
259
368
  };
260
369
 
370
+ type AndroidSyncState = {
371
+ roots?: Array<{
372
+ rootName?: string;
373
+ files?: Array<{ path?: string; sha256?: string; size?: number; mode?: number }>;
374
+ }>;
375
+ seeds?: Array<{ sha256?: string; size?: number; name?: string; mtime?: number }>;
376
+ };
377
+
261
378
  export type ElementTreeData = {
262
379
  xml: string;
263
380
  nodes: AndroidElementNode[];
@@ -890,6 +1007,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
890
1007
  disconnect,
891
1008
  startAdbTunnel,
892
1009
  sendAsset,
1010
+ syncApp,
893
1011
  getConnectionState,
894
1012
  onConnectionStateChange,
895
1013
  });
@@ -1150,6 +1268,47 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1150
1268
  });
1151
1269
  };
1152
1270
 
1271
+ const syncApp: InstanceClient['syncApp'] = async (apkPath, syncOpts) => {
1272
+ const resolvedPath = path.resolve(apkPath);
1273
+ await assertApkFile(resolvedPath);
1274
+ const fileName = path.basename(resolvedPath);
1275
+ const hash = crypto.createHash('sha1').update(resolvedPath).digest('hex').slice(0, 8);
1276
+ const cacheKey = `limsync-cache-android-${fileName}-${hash}`;
1277
+ const basisCacheDir = syncOpts?.basisCacheDir ?? path.join(os.tmpdir(), cacheKey);
1278
+ const syncLog: FolderSyncOptions['log'] = (level, msg) => {
1279
+ switch (level) {
1280
+ case 'debug':
1281
+ logger.debug(msg);
1282
+ break;
1283
+ case 'info':
1284
+ logger.info(msg);
1285
+ break;
1286
+ case 'warn':
1287
+ logger.warn(msg);
1288
+ break;
1289
+ case 'error':
1290
+ logger.error(msg);
1291
+ break;
1292
+ default:
1293
+ logger.info(msg);
1294
+ }
1295
+ };
1296
+ await fs.promises.mkdir(basisCacheDir, { recursive: true });
1297
+ await bootstrapAndroidBasisCache(resolvedPath, basisCacheDir, options.apiUrl, options.token, syncLog);
1298
+ return await syncFolder(resolvedPath, {
1299
+ apiUrl: options.apiUrl,
1300
+ token: options.token,
1301
+ udid: cacheKey,
1302
+ basisCacheDir,
1303
+ install: syncOpts?.install ?? true,
1304
+ launchMode: syncOpts?.launchMode ?? 'ForegroundIfRunning',
1305
+ watch: syncOpts?.watch ?? false,
1306
+ ignoreFn: () => false,
1307
+ log: syncLog,
1308
+ compression: 'identity',
1309
+ });
1310
+ };
1311
+
1153
1312
  // Start the initial connection
1154
1313
  setupWebSocket();
1155
1314
  });
package/src/ios-client.ts CHANGED
@@ -641,7 +641,6 @@ export type InstanceClient = {
641
641
  * @param opts Optional sync options
642
642
  * @param opts.install If true, install the app after syncing. Defaults to true.
643
643
  * @param opts.basisCacheDir Directory for the client-side folder-sync cache.
644
- * @param opts.maxPatchBytes Max patch size (bytes) to send as delta before falling back to full upload. Defaults to 4MB.
645
644
  * @param opts.launchMode Launch mode after installation: "ForegroundIfRunning" (default): bring to foreground if already running, otherwise launch, "RelaunchIfRunning": kill and relaunch if already running
646
645
  * @param opts.watch If true, watch the folder and re-sync on any changes (debounced, single-flight).
647
646
  */
@@ -650,7 +649,6 @@ export type InstanceClient = {
650
649
  opts?: {
651
650
  install?: boolean;
652
651
  basisCacheDir?: string;
653
- maxPatchBytes?: number;
654
652
  launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
655
653
  watch?: boolean;
656
654
  },
@@ -1939,7 +1937,6 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1939
1937
  opts?: {
1940
1938
  install?: boolean;
1941
1939
  basisCacheDir?: string;
1942
- maxPatchBytes?: number;
1943
1940
  launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
1944
1941
  watch?: boolean;
1945
1942
  },
@@ -1987,7 +1984,6 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1987
1984
  basisCacheDir,
1988
1985
  log: syncLog,
1989
1986
  install: opts?.install ?? true,
1990
- maxPatchBytes: opts?.maxPatchBytes ?? 4 * 1024 * 1024,
1991
1987
  launchMode: opts?.launchMode ?? 'ForegroundIfRunning',
1992
1988
  watch: preparedApp.isArchive ? false : shouldWatch,
1993
1989
  };
@@ -2004,8 +2000,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
2004
2000
  });
2005
2001
  return {
2006
2002
  ...result,
2007
- stopWatching: () => {
2008
- archiveWatcher.close();
2003
+ stopWatching: async () => {
2004
+ await archiveWatcher.close();
2009
2005
  },
2010
2006
  };
2011
2007
  };
@@ -4,7 +4,9 @@ import crypto from 'crypto';
4
4
 
5
5
  import { XcodeInstances as GeneratedXcodeInstances, type XcodeInstance } from './xcode-instances';
6
6
  import { type IosInstance } from './ios-instances';
7
- import { exec, type ExecChildProcess, type ExecRequest } from '../exec-client';
7
+ import { exec, type ExecChildProcess, type ExecRequest, type TestflightUploadConfig } from '../exec-client';
8
+
9
+ export type { TestflightUploadConfig } from '../exec-client';
8
10
  import {
9
11
  syncFolder as syncFolderImpl,
10
12
  type AdditionalFileSyncEntry,
@@ -34,8 +36,6 @@ export type SyncOptions = {
34
36
  * Defaults to a temporary directory under the OS temp directory.
35
37
  */
36
38
  basisCacheDir?: string;
37
- /** Max patch size (bytes) to send as delta before falling back to full upload. */
38
- maxPatchBytes?: number;
39
39
  /** If true, install the app after syncing. Defaults to true. */
40
40
  install?: boolean;
41
41
  /**
@@ -53,7 +53,7 @@ export type SyncOptions = {
53
53
 
54
54
  export type SyncResult = {
55
55
  /** Present only when watch=true; call to stop watching */
56
- stopWatching?: () => void;
56
+ stopWatching?: () => Promise<void>;
57
57
  };
58
58
 
59
59
  export type XcodeProjectConfig = {
@@ -95,6 +95,13 @@ export type ReactNativeBuildConfig = {
95
95
  export type XcodeBuildOptions = {
96
96
  upload?: { assetName: string } | { signedUploadUrl: string };
97
97
  signing?: XcodeSigningConfig;
98
+ /**
99
+ * Upload the signed device IPA to TestFlight after the build. Requires
100
+ * signing and sdk=iphoneos. Check ExecResult.testflight on completion: it is
101
+ * absent when the instance's limbuild predates the feature (old servers
102
+ * silently ignore this option).
103
+ */
104
+ testflight?: TestflightUploadConfig;
98
105
  reactNative?: ReactNativeBuildConfig;
99
106
  buildSettings?: Record<string, string>;
100
107
  };
@@ -615,7 +622,6 @@ export class XcodeInstances extends GeneratedXcodeInstances {
615
622
  }),
616
623
  basisCacheDir,
617
624
  watch: opts?.watch ?? true,
618
- maxPatchBytes: opts?.maxPatchBytes ?? 4 * 1024 * 1024,
619
625
  launchMode: 'ForegroundIfRunning',
620
626
  log,
621
627
  ...(additionalFiles ? { additionalFiles } : {}),
@@ -640,6 +646,7 @@ export class XcodeInstances extends GeneratedXcodeInstances {
640
646
  ...(settings && { xcodebuild: settings }),
641
647
  ...(options?.reactNative && { reactNative: options.reactNative }),
642
648
  ...(options?.signing && { signing: options.signing }),
649
+ ...(options?.testflight && { testflight: options.testflight }),
643
650
  ...(options?.buildSettings && { buildSettings: options.buildSettings }),
644
651
  };
645
652
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.38.0'; // x-release-please-version
1
+ export const VERSION = '0.39.1'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.38.0";
1
+ export declare const VERSION = "0.39.1";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.38.0";
1
+ export declare const VERSION = "0.39.1";
2
2
  //# sourceMappingURL=version.d.ts.map