@limrun/api 0.22.2 → 0.23.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 (59) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/exec-client.d.mts.map +1 -1
  3. package/exec-client.d.ts.map +1 -1
  4. package/exec-client.js +3 -5
  5. package/exec-client.js.map +1 -1
  6. package/exec-client.mjs +3 -5
  7. package/exec-client.mjs.map +1 -1
  8. package/folder-sync-ignore.d.mts +7 -0
  9. package/folder-sync-ignore.d.mts.map +1 -0
  10. package/folder-sync-ignore.d.ts +7 -0
  11. package/folder-sync-ignore.d.ts.map +1 -0
  12. package/folder-sync-ignore.js +56 -0
  13. package/folder-sync-ignore.js.map +1 -0
  14. package/folder-sync-ignore.mjs +52 -0
  15. package/folder-sync-ignore.mjs.map +1 -0
  16. package/folder-sync-watcher.d.mts +2 -0
  17. package/folder-sync-watcher.d.mts.map +1 -1
  18. package/folder-sync-watcher.d.ts +2 -0
  19. package/folder-sync-watcher.d.ts.map +1 -1
  20. package/folder-sync-watcher.js +10 -62
  21. package/folder-sync-watcher.js.map +1 -1
  22. package/folder-sync-watcher.mjs +10 -62
  23. package/folder-sync-watcher.mjs.map +1 -1
  24. package/folder-sync.d.mts +16 -16
  25. package/folder-sync.d.mts.map +1 -1
  26. package/folder-sync.d.ts +16 -16
  27. package/folder-sync.d.ts.map +1 -1
  28. package/folder-sync.js +22 -65
  29. package/folder-sync.js.map +1 -1
  30. package/folder-sync.mjs +21 -64
  31. package/folder-sync.mjs.map +1 -1
  32. package/ios-client.d.mts +24 -0
  33. package/ios-client.d.mts.map +1 -1
  34. package/ios-client.d.ts +24 -0
  35. package/ios-client.d.ts.map +1 -1
  36. package/ios-client.js +108 -8
  37. package/ios-client.js.map +1 -1
  38. package/ios-client.mjs +109 -9
  39. package/ios-client.mjs.map +1 -1
  40. package/package.json +11 -1
  41. package/sandbox-client.d.mts +20 -15
  42. package/sandbox-client.d.mts.map +1 -1
  43. package/sandbox-client.d.ts +20 -15
  44. package/sandbox-client.d.ts.map +1 -1
  45. package/sandbox-client.js +49 -40
  46. package/sandbox-client.js.map +1 -1
  47. package/sandbox-client.mjs +48 -40
  48. package/sandbox-client.mjs.map +1 -1
  49. package/src/exec-client.ts +3 -5
  50. package/src/folder-sync-ignore.ts +65 -0
  51. package/src/folder-sync-watcher.ts +11 -66
  52. package/src/folder-sync.ts +39 -89
  53. package/src/ios-client.ts +138 -9
  54. package/src/sandbox-client.ts +72 -62
  55. package/src/version.ts +1 -1
  56. package/version.d.mts +1 -1
  57. package/version.d.ts +1 -1
  58. package/version.js +1 -1
  59. package/version.mjs +1 -1
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[]> => {
@@ -1,5 +1,9 @@
1
- import { syncApp as syncFolderImpl, type FolderSyncOptions } from './folder-sync';
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import { syncFolder as syncFolderImpl, type FolderSyncOptions } from './folder-sync';
2
4
  import { exec, ExecChildProcess } from './exec-client';
5
+ import { createIgnoreFn } from './folder-sync-ignore';
6
+ import crypto from 'crypto';
3
7
 
4
8
  export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
5
9
 
@@ -27,32 +31,37 @@ export type SimulatorConfig = {
27
31
  */
28
32
  export type SyncOptions = {
29
33
  /**
30
- * Cache scoping key for delta basis caching. Defaults to 'xcode-sandbox'.
31
- * This is not sent to the server.
34
+ * If true, watch the folder and re-sync on any changes. Defaults to true.
32
35
  */
33
- cacheKey?: string;
34
- basisCacheDir?: string;
35
- maxPatchBytes?: number;
36
+ watch?: boolean;
36
37
  /**
37
- * If true, watch the folder and re-sync on any changes.
38
+ * Directory for the client-side folder-sync cache.
39
+ * Used to store the last-synced “basis” copies of files (and related sync metadata) so we can compute xdelta patches
40
+ * on subsequent syncs without re-downloading server state.
41
+ *
42
+ * Defaults to a temporary directory under the OS temp directory.
38
43
  */
39
- watch?: boolean;
40
- log?: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
44
+ basisCacheDir?: string;
45
+ /** Max patch size (bytes) to send as delta before falling back to full upload. */
46
+ maxPatchBytes?: number;
47
+ /** If true, install the app after syncing. Defaults to true. */
48
+ install?: boolean;
41
49
  /**
42
- * Optional filter function to include/exclude files and directories.
50
+ * Optional predicate for ignoring files and directories during sync.
51
+ * Applied in addition to built-in sync and Xcode-specific ignore rules.
43
52
  * Called with the relative path from the sync root (using forward slashes).
44
53
  * For directories, the path ends with '/'.
45
- * Return true to include, false to exclude.
54
+ * Return true to ignore, false to keep.
46
55
  *
47
56
  * @example
48
- * // Exclude build folder
49
- * filter: (path) => !path.startsWith('build/')
57
+ * // Ignore build folder
58
+ * ignore: (path) => path.startsWith('build/')
50
59
  *
51
60
  * @example
52
- * // Only include source files
53
- * filter: (path) => path.startsWith('src/') || path.endsWith('.json')
61
+ * // Ignore anything outside src/ and JSON files
62
+ * ignore: (path) => !(path.startsWith('src/') || path.endsWith('.json'))
54
63
  */
55
- filter?: (relativePath: string) => boolean;
64
+ ignore?: (relativePath: string) => boolean;
56
65
  };
57
66
 
58
67
  /**
@@ -150,7 +159,7 @@ export async function createXCodeSandboxClient(
150
159
  },
151
160
  };
152
161
 
153
- const logFn = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => {
162
+ const log = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => {
154
163
  switch (level) {
155
164
  case 'debug':
156
165
  logger.debug(msg);
@@ -196,54 +205,55 @@ export async function createXCodeSandboxClient(
196
205
 
197
206
  return {
198
207
  async sync(localCodePath: string, opts?: SyncOptions): Promise<SyncResult> {
208
+ // Use folder name and hash of absolute path to scope basisCacheDir uniquely for each sync root
209
+ const resolvedPath = path.resolve(localCodePath);
210
+ const folderName = path.basename(resolvedPath);
211
+ const hash = crypto.createHash('sha1').update(resolvedPath).digest('hex').slice(0, 8);
212
+ const cacheKey = `limsync-cache-${folderName}-${hash}`;
213
+ const basisCacheDir = opts?.basisCacheDir ?? path.join(os.tmpdir(), cacheKey);
199
214
  const codeSyncOpts: FolderSyncOptions = {
200
215
  apiUrl: options.apiUrl,
201
216
  token: options.token,
202
- udid: opts?.cacheKey ?? 'xcode-sandbox',
203
- install: false,
204
- filter: (relativePath: string) => {
205
- if (
206
- relativePath.startsWith('build/') ||
207
- relativePath.startsWith('.build/') ||
208
- relativePath.startsWith('DerivedData/') ||
209
- relativePath.startsWith('Index.noindex/') ||
210
- relativePath.startsWith('ModuleCache.noindex/') ||
211
- relativePath.startsWith('.index-build/')
212
- ) {
217
+ udid: cacheKey,
218
+ install: opts?.install ?? true,
219
+ ignoreFn: await createIgnoreFn(localCodePath, {
220
+ basisCacheDir,
221
+ additional: (relativePath: string) => {
222
+ if (
223
+ relativePath.startsWith('build/') ||
224
+ relativePath.startsWith('.build/') ||
225
+ relativePath.startsWith('DerivedData/') ||
226
+ relativePath.startsWith('Index.noindex/') ||
227
+ relativePath.startsWith('ModuleCache.noindex/') ||
228
+ relativePath.startsWith('.index-build/')
229
+ ) {
230
+ return true;
231
+ }
232
+ if (
233
+ relativePath.startsWith('.swiftpm/') ||
234
+ relativePath.startsWith('Pods/') ||
235
+ relativePath.startsWith('Carthage/Build/')
236
+ ) {
237
+ return true;
238
+ }
239
+ if (relativePath.includes('/xcuserdata/')) {
240
+ return true;
241
+ }
242
+ if (relativePath.includes('.dSYM/')) {
243
+ return true;
244
+ }
245
+ // User-provided ignores
246
+ if (opts?.ignore?.(relativePath)) {
247
+ return true;
248
+ }
213
249
  return false;
214
- }
215
- if (
216
- relativePath.startsWith('.swiftpm/') ||
217
- relativePath.startsWith('Pods/') ||
218
- relativePath.startsWith('Carthage/Build/')
219
- ) {
220
- return false;
221
- }
222
- if (
223
- relativePath.startsWith('.git/') ||
224
- relativePath.startsWith('.limsync-cache/') ||
225
- relativePath === '.DS_Store' ||
226
- relativePath.endsWith('/.DS_Store')
227
- ) {
228
- return false;
229
- }
230
- if (relativePath.includes('/xcuserdata/')) {
231
- return false;
232
- }
233
- if (relativePath.includes('.dSYM/')) {
234
- return false;
235
- }
236
-
237
- // User-provided filter
238
- if (opts?.filter && !opts.filter(relativePath)) {
239
- return false;
240
- }
241
- return true;
242
- },
243
- ...(opts?.basisCacheDir ? { basisCacheDir: opts.basisCacheDir } : {}),
244
- ...(opts?.maxPatchBytes !== undefined ? { maxPatchBytes: opts.maxPatchBytes } : {}),
245
- ...(opts?.watch !== undefined ? { watch: opts.watch } : {}),
246
- log: opts?.log ?? logFn,
250
+ },
251
+ }),
252
+ basisCacheDir,
253
+ watch: opts?.watch ?? true,
254
+ maxPatchBytes: opts?.maxPatchBytes ?? 4 * 1024 * 1024,
255
+ launchMode: 'ForegroundIfRunning',
256
+ log,
247
257
  };
248
258
 
249
259
  const result = await syncFolderImpl(localCodePath, codeSyncOpts);
@@ -259,7 +269,7 @@ export async function createXCodeSandboxClient(
259
269
  {
260
270
  apiUrl: options.apiUrl,
261
271
  token: options.token,
262
- log: logFn,
272
+ log,
263
273
  },
264
274
  );
265
275
  },
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.22.2'; // x-release-please-version
1
+ export const VERSION = '0.23.0'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.22.2";
1
+ export declare const VERSION = "0.23.0";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.22.2";
1
+ export declare const VERSION = "0.23.0";
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.22.2'; // x-release-please-version
4
+ exports.VERSION = '0.23.0'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.22.2'; // x-release-please-version
1
+ export const VERSION = '0.23.0'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map