@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.
- package/CHANGELOG.md +8 -0
- package/exec-client.d.mts +5 -1
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +5 -1
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js +14 -7
- package/exec-client.js.map +1 -1
- package/exec-client.mjs +14 -7
- package/exec-client.mjs.map +1 -1
- package/folder-sync-ignore.d.mts +7 -0
- package/folder-sync-ignore.d.mts.map +1 -0
- package/folder-sync-ignore.d.ts +7 -0
- package/folder-sync-ignore.d.ts.map +1 -0
- package/folder-sync-ignore.js +56 -0
- package/folder-sync-ignore.js.map +1 -0
- package/folder-sync-ignore.mjs +52 -0
- package/folder-sync-ignore.mjs.map +1 -0
- package/folder-sync-watcher.d.mts +2 -0
- package/folder-sync-watcher.d.mts.map +1 -1
- package/folder-sync-watcher.d.ts +2 -0
- package/folder-sync-watcher.d.ts.map +1 -1
- package/folder-sync-watcher.js +10 -62
- package/folder-sync-watcher.js.map +1 -1
- package/folder-sync-watcher.mjs +10 -62
- package/folder-sync-watcher.mjs.map +1 -1
- package/folder-sync.d.mts +16 -16
- package/folder-sync.d.mts.map +1 -1
- package/folder-sync.d.ts +16 -16
- package/folder-sync.d.ts.map +1 -1
- package/folder-sync.js +22 -65
- package/folder-sync.js.map +1 -1
- package/folder-sync.mjs +21 -64
- package/folder-sync.mjs.map +1 -1
- package/ios-client.d.mts +24 -0
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +24 -0
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +108 -8
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +109 -9
- package/ios-client.mjs.map +1 -1
- package/package.json +11 -1
- package/sandbox-client.d.mts +20 -15
- package/sandbox-client.d.mts.map +1 -1
- package/sandbox-client.d.ts +20 -15
- package/sandbox-client.d.ts.map +1 -1
- package/sandbox-client.js +49 -40
- package/sandbox-client.js.map +1 -1
- package/sandbox-client.mjs +48 -40
- package/sandbox-client.mjs.map +1 -1
- package/src/exec-client.ts +14 -7
- package/src/folder-sync-ignore.ts +65 -0
- package/src/folder-sync-watcher.ts +11 -66
- package/src/folder-sync.ts +39 -89
- package/src/ios-client.ts +138 -9
- package/src/sandbox-client.ts +72 -62
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
package/src/folder-sync.ts
CHANGED
|
@@ -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
|
-
*
|
|
24
|
+
* Defaults to a temporary directory under the OS temp directory.
|
|
25
25
|
*/
|
|
26
|
-
basisCacheDir
|
|
27
|
-
install
|
|
28
|
-
launchMode
|
|
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
|
|
30
|
+
watch: boolean;
|
|
31
31
|
/** Max patch size (bytes) to send as delta before falling back to full upload. */
|
|
32
|
-
maxPatchBytes
|
|
32
|
+
maxPatchBytes: number;
|
|
33
33
|
/** Controls logging verbosity */
|
|
34
|
-
log
|
|
34
|
+
log: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
|
|
35
35
|
/**
|
|
36
|
-
*
|
|
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
|
|
39
|
+
* Return true to ignore, false to keep.
|
|
40
40
|
*
|
|
41
41
|
* @example
|
|
42
|
-
* //
|
|
43
|
-
*
|
|
42
|
+
* // Ignore build folder
|
|
43
|
+
* ignoreFn: (path) => path.startsWith('build/')
|
|
44
44
|
*
|
|
45
45
|
* @example
|
|
46
|
-
* //
|
|
47
|
-
*
|
|
46
|
+
* // Ignore anything outside src/ and JSON files
|
|
47
|
+
* ignoreFn: (path) => !(path.startsWith('src/') || path.endsWith('.json'))
|
|
48
48
|
*/
|
|
49
|
-
|
|
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,
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
282
|
-
if (
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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, `
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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(
|
|
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 {
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
|
1513
|
+
return await syncFolder(localAppBundlePath, folderSyncOpts);
|
|
1385
1514
|
};
|
|
1386
1515
|
|
|
1387
1516
|
const lsof = (): Promise<LsofEntry[]> => {
|