@limrun/api 0.37.2 → 0.39.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +29 -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/folder-sync-watcher.d.mts +1 -1
  11. package/folder-sync-watcher.d.mts.map +1 -1
  12. package/folder-sync-watcher.d.ts +1 -1
  13. package/folder-sync-watcher.d.ts.map +1 -1
  14. package/folder-sync-watcher.js +2 -9
  15. package/folder-sync-watcher.js.map +1 -1
  16. package/folder-sync-watcher.mjs +2 -9
  17. package/folder-sync-watcher.mjs.map +1 -1
  18. package/folder-sync.d.mts +4 -4
  19. package/folder-sync.d.mts.map +1 -1
  20. package/folder-sync.d.ts +4 -4
  21. package/folder-sync.d.ts.map +1 -1
  22. package/folder-sync.js +149 -45
  23. package/folder-sync.js.map +1 -1
  24. package/folder-sync.mjs +149 -45
  25. package/folder-sync.mjs.map +1 -1
  26. package/instance-client.d.mts +10 -0
  27. package/instance-client.d.mts.map +1 -1
  28. package/instance-client.d.ts +10 -0
  29. package/instance-client.d.ts.map +1 -1
  30. package/instance-client.js +124 -0
  31. package/instance-client.js.map +1 -1
  32. package/instance-client.mjs +123 -0
  33. package/instance-client.mjs.map +1 -1
  34. package/ios-client.d.mts +0 -2
  35. package/ios-client.d.mts.map +1 -1
  36. package/ios-client.d.ts +0 -2
  37. package/ios-client.d.ts.map +1 -1
  38. package/ios-client.js +2 -3
  39. package/ios-client.js.map +1 -1
  40. package/ios-client.mjs +2 -3
  41. package/ios-client.mjs.map +1 -1
  42. package/package.json +2 -2
  43. package/rbe-workspace.d.mts.map +1 -1
  44. package/rbe-workspace.d.ts.map +1 -1
  45. package/rbe-workspace.js +0 -3
  46. package/rbe-workspace.js.map +1 -1
  47. package/rbe-workspace.mjs +0 -3
  48. package/rbe-workspace.mjs.map +1 -1
  49. package/resources/android-instances.d.mts +13 -0
  50. package/resources/android-instances.d.mts.map +1 -1
  51. package/resources/android-instances.d.ts +13 -0
  52. package/resources/android-instances.d.ts.map +1 -1
  53. package/resources/assets.d.mts +14 -9
  54. package/resources/assets.d.mts.map +1 -1
  55. package/resources/assets.d.ts +14 -9
  56. package/resources/assets.d.ts.map +1 -1
  57. package/resources/ios-instances.d.mts +15 -2
  58. package/resources/ios-instances.d.mts.map +1 -1
  59. package/resources/ios-instances.d.ts +15 -2
  60. package/resources/ios-instances.d.ts.map +1 -1
  61. package/resources/xcode-instances-helpers.d.mts +1 -3
  62. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  63. package/resources/xcode-instances-helpers.d.ts +1 -3
  64. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  65. package/resources/xcode-instances-helpers.js +0 -1
  66. package/resources/xcode-instances-helpers.js.map +1 -1
  67. package/resources/xcode-instances-helpers.mjs +0 -1
  68. package/resources/xcode-instances-helpers.mjs.map +1 -1
  69. package/resources/xcode-instances.d.mts +13 -0
  70. package/resources/xcode-instances.d.mts.map +1 -1
  71. package/resources/xcode-instances.d.ts +13 -0
  72. package/resources/xcode-instances.d.ts.map +1 -1
  73. package/src/app-archive.ts +22 -10
  74. package/src/folder-sync-watcher.ts +2 -8
  75. package/src/folder-sync.ts +168 -61
  76. package/src/instance-client.ts +159 -0
  77. package/src/ios-client.ts +2 -6
  78. package/src/rbe-workspace.ts +0 -3
  79. package/src/resources/android-instances.ts +14 -0
  80. package/src/resources/assets.ts +18 -12
  81. package/src/resources/ios-instances.ts +16 -2
  82. package/src/resources/xcode-instances-helpers.ts +1 -4
  83. package/src/resources/xcode-instances.ts +14 -0
  84. package/src/version.ts +1 -1
  85. package/version.d.mts +1 -1
  86. package/version.d.ts +1 -1
  87. package/version.js +1 -1
  88. package/version.mjs +1 -1
@@ -15,7 +15,7 @@ export type PreparedAppBundle = {
15
15
  };
16
16
 
17
17
  export type ArchiveWatcher = {
18
- close: () => void;
18
+ close: () => Promise<void>;
19
19
  };
20
20
 
21
21
  const noopLogger: LogFn = () => {};
@@ -283,13 +283,9 @@ export function watchAppArchive(opts: {
283
283
  let inFlight = false;
284
284
  let queued = false;
285
285
  let closed = false;
286
+ let activeRun: Promise<void> | undefined;
286
287
 
287
288
  const run = async () => {
288
- if (closed) return;
289
- if (inFlight) {
290
- queued = true;
291
- return;
292
- }
293
289
  inFlight = true;
294
290
  try {
295
291
  const ready = await waitForStableArchiveFile(archivePath, () => closed);
@@ -309,19 +305,33 @@ export function watchAppArchive(opts: {
309
305
  );
310
306
  } finally {
311
307
  inFlight = false;
312
- if (queued) {
308
+ if (queued && !closed) {
313
309
  queued = false;
314
- void run();
310
+ startRun();
315
311
  }
316
312
  }
317
313
  };
314
+ const startRun = () => {
315
+ if (closed) return;
316
+ if (inFlight) {
317
+ queued = true;
318
+ return;
319
+ }
320
+ const promise = run();
321
+ activeRun = promise;
322
+ void promise.finally(() => {
323
+ if (activeRun === promise) {
324
+ activeRun = undefined;
325
+ }
326
+ });
327
+ };
318
328
 
319
329
  const schedule = () => {
320
330
  if (closed) return;
321
331
  if (timer) clearTimeout(timer);
322
332
  timer = setTimeout(() => {
323
333
  timer = undefined;
324
- void run();
334
+ startRun();
325
335
  }, 500);
326
336
  };
327
337
 
@@ -333,13 +343,15 @@ export function watchAppArchive(opts: {
333
343
  log('debug', `watchAppArchive: ${archivePath}`);
334
344
 
335
345
  return {
336
- close: () => {
346
+ close: async () => {
337
347
  closed = true;
348
+ queued = false;
338
349
  if (timer) {
339
350
  clearTimeout(timer);
340
351
  timer = undefined;
341
352
  }
342
353
  watcher.close();
354
+ await activeRun;
343
355
  },
344
356
  };
345
357
  }
@@ -15,29 +15,23 @@ const noopLogger = (_level: 'debug' | 'info' | 'warn' | 'error', _msg: string) =
15
15
 
16
16
  /**
17
17
  * Watch a folder tree for changes. Uses recursive watch when supported (macOS),
18
- * otherwise falls back to watching each directory. Debounced.
18
+ * otherwise falls back to watching each directory.
19
19
  *
20
20
  * Returns a handle that can be closed to stop watching.
21
21
  */
22
22
  export async function watchFolderTree(opts: FolderSyncWatcherOptions): Promise<WatcherHandle> {
23
23
  const log = opts.log ?? noopLogger;
24
- const debounceMs = 500;
25
24
  const rootPath = opts.rootPath;
26
25
  if (!fs.existsSync(rootPath)) {
27
26
  throw new Error(`watchFolderTree root does not exist: ${rootPath}`);
28
27
  }
29
- let timer: NodeJS.Timeout | undefined;
30
- const schedule = (reason: string) => {
31
- if (timer) clearTimeout(timer);
32
- timer = setTimeout(() => opts.onChange(reason), debounceMs);
33
- };
34
28
  const watcher = fs.watch(rootPath, { recursive: true }, (_eventType, filename) => {
35
29
  if (!filename) return;
36
30
  const relativePath = filename.split(path.sep).join('/');
37
31
  if (opts.ignoreFn(relativePath)) {
38
32
  return;
39
33
  }
40
- schedule(relativePath ? `change:${relativePath}` : 'change');
34
+ opts.onChange(relativePath ? `change:${relativePath}` : 'change');
41
35
  });
42
36
  log('debug', `watchFolderTree(recursive): ${rootPath}`);
43
37
  return { close: () => watcher.close() };
@@ -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
  }
@@ -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
  });