@limrun/api 0.22.2 → 0.23.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 (61) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/exec-client.d.mts +5 -1
  3. package/exec-client.d.mts.map +1 -1
  4. package/exec-client.d.ts +5 -1
  5. package/exec-client.d.ts.map +1 -1
  6. package/exec-client.js +14 -7
  7. package/exec-client.js.map +1 -1
  8. package/exec-client.mjs +14 -7
  9. package/exec-client.mjs.map +1 -1
  10. package/folder-sync-ignore.d.mts +7 -0
  11. package/folder-sync-ignore.d.mts.map +1 -0
  12. package/folder-sync-ignore.d.ts +7 -0
  13. package/folder-sync-ignore.d.ts.map +1 -0
  14. package/folder-sync-ignore.js +56 -0
  15. package/folder-sync-ignore.js.map +1 -0
  16. package/folder-sync-ignore.mjs +52 -0
  17. package/folder-sync-ignore.mjs.map +1 -0
  18. package/folder-sync-watcher.d.mts +2 -0
  19. package/folder-sync-watcher.d.mts.map +1 -1
  20. package/folder-sync-watcher.d.ts +2 -0
  21. package/folder-sync-watcher.d.ts.map +1 -1
  22. package/folder-sync-watcher.js +10 -62
  23. package/folder-sync-watcher.js.map +1 -1
  24. package/folder-sync-watcher.mjs +10 -62
  25. package/folder-sync-watcher.mjs.map +1 -1
  26. package/folder-sync.d.mts +16 -16
  27. package/folder-sync.d.mts.map +1 -1
  28. package/folder-sync.d.ts +16 -16
  29. package/folder-sync.d.ts.map +1 -1
  30. package/folder-sync.js +22 -65
  31. package/folder-sync.js.map +1 -1
  32. package/folder-sync.mjs +21 -64
  33. package/folder-sync.mjs.map +1 -1
  34. package/ios-client.d.mts +24 -0
  35. package/ios-client.d.mts.map +1 -1
  36. package/ios-client.d.ts +24 -0
  37. package/ios-client.d.ts.map +1 -1
  38. package/ios-client.js +108 -8
  39. package/ios-client.js.map +1 -1
  40. package/ios-client.mjs +109 -9
  41. package/ios-client.mjs.map +1 -1
  42. package/package.json +11 -1
  43. package/sandbox-client.d.mts +20 -15
  44. package/sandbox-client.d.mts.map +1 -1
  45. package/sandbox-client.d.ts +20 -15
  46. package/sandbox-client.d.ts.map +1 -1
  47. package/sandbox-client.js +49 -40
  48. package/sandbox-client.js.map +1 -1
  49. package/sandbox-client.mjs +48 -40
  50. package/sandbox-client.mjs.map +1 -1
  51. package/src/exec-client.ts +14 -7
  52. package/src/folder-sync-ignore.ts +65 -0
  53. package/src/folder-sync-watcher.ts +11 -66
  54. package/src/folder-sync.ts +39 -89
  55. package/src/ios-client.ts +138 -9
  56. package/src/sandbox-client.ts +72 -62
  57. package/src/version.ts +1 -1
  58. package/version.d.mts +1 -1
  59. package/version.d.ts +1 -1
  60. package/version.js +1 -1
  61. package/version.mjs +1 -1
@@ -4,9 +4,9 @@ import os from 'os';
4
4
  import crypto from 'crypto';
5
5
  import { spawn } from 'child_process';
6
6
  import { watchFolderTree } from './folder-sync-watcher';
7
+ import { type IgnoreFn } from './folder-sync-ignore';
7
8
  import { Readable } from 'stream';
8
9
  import * as zlib from 'zlib';
9
- import ignore, { type Ignore } from 'ignore';
10
10
 
11
11
  // =============================================================================
12
12
  // Folder Sync (HTTP batch)
@@ -21,32 +21,32 @@ export type FolderSyncOptions = {
21
21
  * Used to store the last-synced “basis” copies of files (and related sync metadata) so we can compute xdelta patches
22
22
  * on subsequent syncs without re-downloading server state.
23
23
  *
24
- * Can be absolute or relative to process.cwd(). Defaults to `.limsync-cache/`.
24
+ * Defaults to a temporary directory under the OS temp directory.
25
25
  */
26
- basisCacheDir?: string;
27
- install?: boolean;
28
- launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
26
+ basisCacheDir: string;
27
+ install: boolean;
28
+ launchMode: 'ForegroundIfRunning' | 'RelaunchIfRunning';
29
29
  /** If true, watch the folder and re-sync on any changes (debounced, single-flight). */
30
- watch?: boolean;
30
+ watch: boolean;
31
31
  /** Max patch size (bytes) to send as delta before falling back to full upload. */
32
- maxPatchBytes?: number;
32
+ maxPatchBytes: number;
33
33
  /** Controls logging verbosity */
34
- log?: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
34
+ log: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
35
35
  /**
36
- * Optional filter function to include/exclude files and directories.
36
+ * Predicate for ignoring files and directories during sync.
37
37
  * Called with the relative path from localFolderPath (using forward slashes).
38
38
  * For directories, the path ends with '/'.
39
- * Return true to include, false to exclude.
39
+ * Return true to ignore, false to keep.
40
40
  *
41
41
  * @example
42
- * // Exclude build folder
43
- * filter: (path) => !path.startsWith('build/')
42
+ * // Ignore build folder
43
+ * ignoreFn: (path) => path.startsWith('build/')
44
44
  *
45
45
  * @example
46
- * // Only include source files
47
- * filter: (path) => path.startsWith('src/') || path.endsWith('.json')
46
+ * // Ignore anything outside src/ and JSON files
47
+ * ignoreFn: (path) => !(path.startsWith('src/') || path.endsWith('.json'))
48
48
  */
49
- filter?: (relativePath: string) => boolean;
49
+ ignoreFn: IgnoreFn;
50
50
  };
51
51
 
52
52
  export type SyncFolderResult = {
@@ -246,40 +246,30 @@ async function sha256FileHex(filePath: string): Promise<string> {
246
246
  });
247
247
  }
248
248
 
249
- async function walkFiles(root: string, filter?: (relativePath: string) => boolean): Promise<FileEntry[]> {
249
+ async function walkFiles(root: string, ignoreFn: IgnoreFn): Promise<FileEntry[]> {
250
250
  const rootResolved = path.resolve(root);
251
251
 
252
- // Load .gitignore if it exists
253
- const ig = await loadGitignore(rootResolved);
254
-
255
252
  const out: FileEntry[] = [];
256
253
  const stack: string[] = [rootResolved];
257
254
  while (stack.length) {
258
255
  const dir = stack.pop()!;
259
256
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });
260
257
  for (const ent of entries) {
261
- // Always skip .git folder and .DS_Store
262
- if (ent.name === '.DS_Store' || ent.name === '.git') continue;
263
-
264
258
  const abs = path.join(dir, ent.name);
265
259
  const rel = path.relative(rootResolved, abs).split(path.sep).join('/');
266
260
 
267
- // Check if ignored by .gitignore
268
- if (ig.ignores(rel)) continue;
269
-
270
261
  if (ent.isDirectory()) {
271
262
  // For directories, check with trailing slash
272
263
  const relDir = rel + '/';
273
- if (ig.ignores(relDir)) continue;
274
- // Check custom filter (directories have trailing slash)
275
- if (filter && !filter(relDir)) continue;
264
+ // Check custom ignores (directories have trailing slash)
265
+ if (ignoreFn(relDir)) continue;
276
266
  stack.push(abs);
277
267
  continue;
278
268
  }
279
269
  if (!ent.isFile()) continue;
280
270
 
281
- // Check custom filter for files
282
- if (filter && !filter(rel)) continue;
271
+ // Check custom ignores for files
272
+ if (ignoreFn(rel)) continue;
283
273
 
284
274
  const st = await fs.promises.stat(abs);
285
275
  const sha256 = await sha256FileHex(abs);
@@ -292,22 +282,6 @@ async function walkFiles(root: string, filter?: (relativePath: string) => boolea
292
282
  return out;
293
283
  }
294
284
 
295
- /**
296
- * Load and parse .gitignore file if it exists.
297
- * Returns an Ignore instance that can be used to test paths.
298
- */
299
- async function loadGitignore(rootDir: string): Promise<Ignore> {
300
- const ig = ignore();
301
- const gitignorePath = path.join(rootDir, '.gitignore');
302
- try {
303
- const content = await fs.promises.readFile(gitignorePath, 'utf-8');
304
- ig.add(content);
305
- } catch {
306
- // No .gitignore file, return empty ignore instance
307
- }
308
- return ig;
309
- }
310
-
311
285
  let xdelta3Ready: Promise<void> | null = null;
312
286
  async function ensureXdelta3(): Promise<void> {
313
287
  if (!xdelta3Ready) {
@@ -338,19 +312,6 @@ async function runXdelta3Encode(basis: string, target: string, outPatch: string)
338
312
  });
339
313
  }
340
314
 
341
- function localBasisCacheRoot(opts: FolderSyncOptions, localFolderPath: string): string {
342
- const hostKey = opts.apiUrl.replace(/[:/]+/g, '_');
343
- const resolved = path.resolve(localFolderPath);
344
- const base = path.basename(resolved);
345
- const hash = crypto.createHash('sha1').update(resolved).digest('hex').slice(0, 8);
346
- const rootOverride =
347
- opts.basisCacheDir ?
348
- path.resolve(process.cwd(), opts.basisCacheDir)
349
- : path.join(process.cwd(), '.limsync-cache');
350
- // Include folder identity to avoid collisions between different roots.
351
- return path.join(rootOverride, 'folder-sync', hostKey, opts.udid, `${base}-${hash}`);
352
- }
353
-
354
315
  async function cachePut(cacheRoot: string, relPath: string, srcFile: string): Promise<void> {
355
316
  const dst = path.join(cacheRoot, relPath.split('/').join(path.sep));
356
317
  await fs.promises.mkdir(path.dirname(dst), { recursive: true });
@@ -361,9 +322,14 @@ function cacheGet(cacheRoot: string, relPath: string): string {
361
322
  return path.join(cacheRoot, relPath.split('/').join(path.sep));
362
323
  }
363
324
 
364
- export type SyncAppResult = SyncFolderResult;
365
-
366
- export async function syncApp(localFolderPath: string, opts: FolderSyncOptions): Promise<SyncFolderResult> {
325
+ export async function syncFolder(
326
+ localFolderPath: string,
327
+ opts: FolderSyncOptions,
328
+ ): Promise<SyncFolderResult> {
329
+ const log = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => {
330
+ (opts.log ?? noopLogger)(level, `syncFolder: ${msg}`);
331
+ };
332
+ log('debug', `setup ${localFolderPath} watch=${opts.watch} basisCacheDir=${opts.basisCacheDir}`);
367
333
  if (!opts.watch) {
368
334
  const result = await syncFolderOnce(localFolderPath, opts);
369
335
  return result;
@@ -389,13 +355,10 @@ export async function syncApp(localFolderPath: string, opts: FolderSyncOptions):
389
355
  }
390
356
  }
391
357
  };
392
-
393
- const watcherLog = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => {
394
- (opts.log ?? noopLogger)(level, `syncApp: ${msg}`);
395
- };
396
358
  const watcher = await watchFolderTree({
397
359
  rootPath: localFolderPath,
398
- log: watcherLog,
360
+ log,
361
+ ignoreFn: opts.ignoreFn,
399
362
  onChange: (reason) => {
400
363
  void run(reason);
401
364
  },
@@ -417,24 +380,18 @@ async function syncFolderOnce(
417
380
  ): Promise<SyncFolderResult> {
418
381
  const totalStart = nowMs();
419
382
  const log = opts.log ?? noopLogger;
420
- const slog = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => log(level, `syncApp: ${msg}`);
383
+ const slog = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => log(level, `syncFolder: ${msg}`);
421
384
  const maxPatchBytes = opts.maxPatchBytes ?? 4 * 1024 * 1024;
422
-
423
- const tEnsureStart = nowMs();
424
385
  await ensureXdelta3();
425
- const tEnsureMs = nowMs() - tEnsureStart;
426
386
 
427
- const tWalkStart = nowMs();
428
- const files = await walkFiles(localFolderPath, opts.filter);
429
- const tWalkMs = nowMs() - tWalkStart;
387
+ const files = await walkFiles(localFolderPath, opts.ignoreFn);
430
388
  const fileMap = new Map(files.map((f) => [f.path, f]));
431
389
 
432
390
  const syncId = genId('sync');
433
391
  const rootName = path.basename(path.resolve(localFolderPath));
434
392
  const preferredCompression = (zlib as any).createZstdCompress ? 'zstd' : 'gzip';
435
393
 
436
- const cacheRoot = localBasisCacheRoot(opts, localFolderPath);
437
- await fs.promises.mkdir(cacheRoot, { recursive: true });
394
+ await fs.promises.mkdir(opts.basisCacheDir, { recursive: true });
438
395
 
439
396
  // Track how many bytes we actually transmit to the server (single HTTP request).
440
397
  let bytesSentFull = 0;
@@ -447,7 +404,7 @@ async function syncFolderOnce(
447
404
  const encodeLimit = concurrencyLimit();
448
405
  const changed: FileEntry[] = [];
449
406
  for (const f of files) {
450
- const basisPath = cacheGet(cacheRoot, f.path);
407
+ const basisPath = cacheGet(opts.basisCacheDir, f.path);
451
408
  if (!fs.existsSync(basisPath)) {
452
409
  changed.push(f);
453
410
  continue;
@@ -459,7 +416,7 @@ async function syncFolderOnce(
459
416
  }
460
417
 
461
418
  const encodedPayloads = await mapLimit(changed, encodeLimit, async (f): Promise<EncodedPayload> => {
462
- const basisPath = cacheGet(cacheRoot, f.path);
419
+ const basisPath = cacheGet(opts.basisCacheDir, f.path);
463
420
  if (fs.existsSync(basisPath)) {
464
421
  const basisSha = await sha256FileHex(basisPath);
465
422
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'limulator-xdelta3-'));
@@ -505,7 +462,7 @@ async function syncFolderOnce(
505
462
  const meta: FolderSyncHttpMeta = {
506
463
  id: syncId,
507
464
  rootName,
508
- install: opts.install ?? true,
465
+ install: opts.install,
509
466
  ...(opts.launchMode ? { launchMode: opts.launchMode } : {}),
510
467
  files: files.map((f) => ({ path: f.path, size: f.size, sha256: f.sha256.toLowerCase(), mode: f.mode })),
511
468
  payloads: encodedPayloads.map((p) => p.payload),
@@ -513,7 +470,7 @@ async function syncFolderOnce(
513
470
  const hasDelta = encodedPayloads.some((p) => p.payload.kind === 'delta');
514
471
  const compression: 'zstd' | 'gzip' | 'identity' = hasDelta ? 'identity' : preferredCompression;
515
472
  slog(
516
- 'info',
473
+ 'debug',
517
474
  `sync started files=${files.length}${reason ? ` reason=${reason}` : ''} compression=${compression}`,
518
475
  );
519
476
 
@@ -590,18 +547,11 @@ async function syncFolderOnce(
590
547
  const tookMs = nowMs() - totalStart;
591
548
  const totalBytes = bytesSentFull + bytesSentDelta;
592
549
  slog(
593
- 'info',
550
+ 'debug',
594
551
  `sync finished files=${files.length} sent=${fmtBytes(totalBytes)} syncWork=${fmtMs(
595
552
  syncWorkMs,
596
553
  )} total=${fmtMs(tookMs)}`,
597
554
  );
598
- slog('debug', `sync bytes full=${fmtBytes(bytesSentFull)} delta=${fmtBytes(bytesSentDelta)}`);
599
- slog(
600
- 'debug',
601
- `timing ensureXdelta3=${fmtMs(tEnsureMs)} walk=${fmtMs(tWalkMs)} httpSend=${fmtMs(
602
- httpSendMsTotal,
603
- )} deltaEncode=${fmtMs(deltaEncodeMsTotal)}`,
604
- );
605
555
  const out: SyncFolderResult = {};
606
556
  if (resp.installedAppPath) {
607
557
  out.installedAppPath = resp.installedAppPath;
@@ -611,7 +561,7 @@ async function syncFolderOnce(
611
561
  }
612
562
  // Update local cache optimistically: after a successful sync, cache reflects current local tree.
613
563
  for (const f of files) {
614
- await cachePut(cacheRoot, f.path, f.absPath);
564
+ await cachePut(opts.basisCacheDir, f.path, f.absPath);
615
565
  }
616
566
  return out;
617
567
  }
package/src/ios-client.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { WebSocket, Data } from 'ws';
2
2
  import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import crypto from 'crypto';
3
6
  import { EventEmitter } from 'events';
7
+ import { Readable } from 'stream';
8
+ import { pipeline } from 'stream/promises';
4
9
  import { isNonRetryableError } from './tunnel';
5
- import { syncApp as syncAppImpl, type SyncFolderResult, type FolderSyncOptions } from './folder-sync';
10
+ import { type SyncFolderResult, type FolderSyncOptions, syncFolder } from './folder-sync';
11
+ import { createIgnoreFn } from './folder-sync-ignore';
6
12
 
7
13
  /**
8
14
  * Connection state of the instance client
@@ -14,6 +20,46 @@ export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'rec
14
20
  */
15
21
  export type ConnectionStateCallback = (state: ConnectionState) => void;
16
22
 
23
+ function generateRecordingFilename(): string {
24
+ const rand = Math.random().toString(36).slice(2, 5).padEnd(3, '0');
25
+ const now = new Date();
26
+ const formattedDate = now.toISOString().replace(/[-:]/g, '_').replace('T', '_').replace(/\..+/, '');
27
+
28
+ // Example: 20240602_17_45_30 for June 2, 2024 17:45:30 UTC
29
+ return `ios_video_${formattedDate}_${rand}.mp4`;
30
+ }
31
+
32
+ async function downloadFileToLocalPath(url: string, token: string, localPath: string): Promise<void> {
33
+ const maxRetries = 3;
34
+
35
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
36
+ const response = await fetch(url, {
37
+ method: 'GET',
38
+ headers: {
39
+ Authorization: `Bearer ${token}`,
40
+ },
41
+ });
42
+ if (!response.ok) {
43
+ const errorBody = await response.text();
44
+ const isRetriable = response.status >= 500 && response.status < 600;
45
+ if (isRetriable && attempt < maxRetries) {
46
+ continue;
47
+ }
48
+ throw new Error(`Download failed: ${response.status} ${errorBody}`);
49
+ }
50
+ if (!response.body) {
51
+ throw new Error('Download failed: response body is missing');
52
+ }
53
+ await fs.promises.mkdir(path.dirname(localPath), { recursive: true });
54
+ await pipeline(Readable.fromWeb(response.body as any), fs.createWriteStream(localPath));
55
+ return;
56
+ }
57
+ }
58
+
59
+ function buildDownloadUrl(apiUrl: string, filename: string): string {
60
+ return `${apiUrl}/files?name=${encodeURIComponent(filename)}`;
61
+ }
62
+
17
63
  /**
18
64
  * Events emitted by a simctl execution
19
65
  */
@@ -295,13 +341,36 @@ export type InstanceClient = {
295
341
  options?: { coordinate?: [number, number]; momentum?: number },
296
342
  ) => Promise<void>;
297
343
 
344
+ /**
345
+ * Start recording simulator video. Use stopRecording() to stop the recording.
346
+ */
347
+ startRecording: () => Promise<void>;
348
+
349
+ /**
350
+ * Stop the active recording for this client instance.
351
+ * If `saveTo.presignedUrl` is provided, the server uploads the completed file there before resolving.
352
+ * If `saveTo.localPath` is provided, the client downloads the completed file to that path.
353
+ * If both are provided, both are performed.
354
+ * Returns a download URL for the completed recording that can be used to download using the token.
355
+ * Note that the download URL is only valid while the instance is running.
356
+ */
357
+ stopRecording: (saveTo: { presignedUrl?: string; localPath?: string }) => Promise<string>;
358
+
298
359
  /**
299
360
  * Sync an iOS app bundle folder to the server and (optionally) install/launch it.
361
+ * @param localAppBundlePath The path to the local app bundle folder
362
+ * @param opts Optional sync options
363
+ * @param opts.install If true, install the app after syncing. Defaults to true.
364
+ * @param opts.basisCacheDir Directory for the client-side folder-sync cache.
365
+ * @param opts.maxPatchBytes Max patch size (bytes) to send as delta before falling back to full upload. Defaults to 4MB.
366
+ * @param opts.launchMode Launch mode after installation: "ForegroundIfRunning" (default): bring to foreground if already running, otherwise launch, "RelaunchIfRunning": kill and relaunch if already running
367
+ * @param opts.watch If true, watch the folder and re-sync on any changes (debounced, single-flight).
300
368
  */
301
369
  syncApp: (
302
370
  localAppBundlePath: string,
303
371
  opts?: {
304
372
  install?: boolean;
373
+ basisCacheDir?: string;
305
374
  maxPatchBytes?: number;
306
375
  launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
307
376
  watch?: boolean;
@@ -513,6 +582,7 @@ type ServerResponse = {
513
582
  elementType?: string;
514
583
  apps?: string;
515
584
  url?: string;
585
+ filename?: string;
516
586
  bundleId?: string;
517
587
  files?: LsofEntry[];
518
588
  // Device info fields
@@ -850,6 +920,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
850
920
  let reconnectTimeout: NodeJS.Timeout | undefined;
851
921
  let intentionalDisconnect = false;
852
922
  let lastError: string | undefined;
923
+ let activeRecordingFilename: string | undefined;
853
924
 
854
925
  // Centralized pending requests map - handles all request/response patterns
855
926
  const pendingRequests: Map<string, PendingRequest<any>> = new Map();
@@ -1028,6 +1099,10 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1028
1099
  url: msg.url || '',
1029
1100
  bundleId: msg.bundleId || '',
1030
1101
  }),
1102
+ startVideoRecordingResult: () => undefined,
1103
+ stopVideoRecordingResult: (msg) => ({
1104
+ filename: msg.filename || '',
1105
+ }),
1031
1106
  setOrientationResult: () => undefined,
1032
1107
  scrollResult: () => undefined,
1033
1108
  xcrunResult: (msg): CommandResult => ({
@@ -1061,7 +1136,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1061
1136
  if (message.type === 'simctlStream') {
1062
1137
  const execution = simctlExecutions.get(message.id);
1063
1138
  if (!execution) {
1064
- logger.warn(`Received simctl stream for unknown execution: ${message.id}`);
1139
+ logger.debug(`Received simctl stream for unknown execution: ${message.id}`);
1065
1140
  return;
1066
1141
  }
1067
1142
 
@@ -1200,6 +1275,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1200
1275
  installApp,
1201
1276
  setOrientation,
1202
1277
  scroll,
1278
+ startRecording,
1279
+ stopRecording,
1203
1280
  syncApp,
1204
1281
  disconnect,
1205
1282
  getConnectionState,
@@ -1341,22 +1418,74 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1341
1418
  });
1342
1419
  };
1343
1420
 
1421
+ const startRecording = async (): Promise<void> => {
1422
+ if (activeRecordingFilename) {
1423
+ throw new Error(`A recording is already active for this client: ${activeRecordingFilename}`);
1424
+ }
1425
+ const finalFilename = generateRecordingFilename();
1426
+ activeRecordingFilename = finalFilename;
1427
+ try {
1428
+ await sendRequest<void>('startVideoRecording', { filename: finalFilename });
1429
+ } catch (error) {
1430
+ if (activeRecordingFilename === finalFilename) {
1431
+ activeRecordingFilename = undefined;
1432
+ }
1433
+ throw error;
1434
+ }
1435
+ };
1436
+
1437
+ const stopRecording = async (saveTo: { presignedUrl?: string; localPath?: string }): Promise<string> => {
1438
+ const filename = activeRecordingFilename;
1439
+ if (!filename) {
1440
+ throw new Error('No active recording for this client. Call startRecording() first.');
1441
+ }
1442
+ const result = await sendRequest<{ filename: string }>('stopVideoRecording', {
1443
+ filename,
1444
+ upload: saveTo.presignedUrl ? { presignedUrl: saveTo.presignedUrl } : undefined,
1445
+ });
1446
+ const finalFilename = result.filename || filename;
1447
+ const downloadUrl = buildDownloadUrl(options.apiUrl, finalFilename);
1448
+ if (saveTo.localPath) {
1449
+ try {
1450
+ await downloadFileToLocalPath(downloadUrl, options.token, saveTo.localPath);
1451
+ } finally {
1452
+ activeRecordingFilename = undefined;
1453
+ }
1454
+ } else {
1455
+ activeRecordingFilename = undefined;
1456
+ }
1457
+ return downloadUrl;
1458
+ };
1459
+
1344
1460
  const syncApp = async (
1345
1461
  localAppBundlePath: string,
1346
1462
  opts?: {
1347
1463
  install?: boolean;
1464
+ basisCacheDir?: string;
1348
1465
  maxPatchBytes?: number;
1349
1466
  launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
1350
1467
  watch?: boolean;
1351
1468
  },
1352
1469
  ): Promise<SyncFolderResult> => {
1470
+ const infoPlistPath = path.join(localAppBundlePath, 'Info.plist');
1471
+ const infoPlistStat = await fs.promises.stat(infoPlistPath).catch(() => null);
1472
+ if (!infoPlistStat?.isFile()) {
1473
+ throw new Error(`The folder is not a valid app bundle: missing Info.plist at ${infoPlistPath}`);
1474
+ }
1353
1475
  if (!cachedDeviceInfo) {
1354
1476
  throw new Error('Device info not available yet; wait for client connection to be established.');
1355
1477
  }
1356
- const appSyncOpts: FolderSyncOptions = {
1478
+ const resolvedPath = path.resolve(localAppBundlePath);
1479
+ const folderName = path.basename(resolvedPath);
1480
+ const hash = crypto.createHash('sha1').update(resolvedPath).digest('hex').slice(0, 8);
1481
+ const cacheKey = `limsync-cache-${folderName}-${hash}`;
1482
+ const basisCacheDir = opts?.basisCacheDir ?? path.join(os.tmpdir(), cacheKey);
1483
+ const folderSyncOpts: FolderSyncOptions = {
1357
1484
  apiUrl: options.apiUrl,
1358
1485
  token: options.token,
1359
- udid: cachedDeviceInfo.udid,
1486
+ udid: cacheKey,
1487
+ ignoreFn: await createIgnoreFn(localAppBundlePath, { basisCacheDir }),
1488
+ basisCacheDir,
1360
1489
  log: (level, msg) => {
1361
1490
  switch (level) {
1362
1491
  case 'debug':
@@ -1376,12 +1505,12 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1376
1505
  break;
1377
1506
  }
1378
1507
  },
1379
- ...(opts?.install !== undefined ? { install: opts.install } : {}),
1380
- ...(opts?.maxPatchBytes !== undefined ? { maxPatchBytes: opts.maxPatchBytes } : {}),
1381
- ...(opts?.launchMode !== undefined ? { launchMode: opts.launchMode } : {}),
1382
- ...(opts?.watch !== undefined ? { watch: opts.watch } : {}),
1508
+ install: opts?.install ?? true,
1509
+ maxPatchBytes: opts?.maxPatchBytes ?? 4 * 1024 * 1024,
1510
+ launchMode: opts?.launchMode ?? 'ForegroundIfRunning',
1511
+ watch: opts?.watch ?? true,
1383
1512
  };
1384
- return await syncAppImpl(localAppBundlePath, appSyncOpts);
1513
+ return await syncFolder(localAppBundlePath, folderSyncOpts);
1385
1514
  };
1386
1515
 
1387
1516
  const lsof = (): Promise<LsofEntry[]> => {