@limrun/api 0.38.0 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/app-archive.d.mts +1 -1
- package/app-archive.d.mts.map +1 -1
- package/app-archive.d.ts +1 -1
- package/app-archive.d.ts.map +1 -1
- package/app-archive.js +22 -10
- package/app-archive.js.map +1 -1
- package/app-archive.mjs +22 -10
- package/app-archive.mjs.map +1 -1
- package/exec-client.d.mts +34 -0
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +34 -0
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js +25 -1
- package/exec-client.js.map +1 -1
- package/exec-client.mjs +25 -1
- package/exec-client.mjs.map +1 -1
- package/folder-sync-watcher.d.mts +1 -1
- package/folder-sync-watcher.d.mts.map +1 -1
- package/folder-sync-watcher.d.ts +1 -1
- package/folder-sync-watcher.d.ts.map +1 -1
- package/folder-sync-watcher.js +2 -9
- package/folder-sync-watcher.js.map +1 -1
- package/folder-sync-watcher.mjs +2 -9
- package/folder-sync-watcher.mjs.map +1 -1
- package/folder-sync.d.mts +4 -4
- package/folder-sync.d.mts.map +1 -1
- package/folder-sync.d.ts +4 -4
- package/folder-sync.d.ts.map +1 -1
- package/folder-sync.js +149 -45
- package/folder-sync.js.map +1 -1
- package/folder-sync.mjs +149 -45
- package/folder-sync.mjs.map +1 -1
- package/index.d.mts +1 -1
- package/index.d.mts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js.map +1 -1
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +10 -0
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +10 -0
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +124 -0
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +123 -0
- package/instance-client.mjs.map +1 -1
- package/ios-client.d.mts +0 -2
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +0 -2
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +2 -3
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +2 -3
- package/ios-client.mjs.map +1 -1
- package/package.json +2 -2
- package/resources/xcode-instances-helpers.d.mts +10 -4
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +10 -4
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js +1 -1
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs +1 -1
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/app-archive.ts +22 -10
- package/src/exec-client.ts +58 -1
- package/src/folder-sync-watcher.ts +2 -8
- package/src/folder-sync.ts +168 -61
- package/src/index.ts +2 -0
- package/src/instance-client.ts +159 -0
- package/src/ios-client.ts +2 -6
- package/src/resources/xcode-instances-helpers.ts +12 -5
- 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
|
@@ -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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
+
xdelta3StreamingReady = null;
|
|
355
364
|
throw err;
|
|
356
365
|
});
|
|
357
366
|
}
|
|
358
|
-
return await
|
|
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
|
|
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
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
387
|
-
|
|
438
|
+
|
|
439
|
+
if (exceededLimit) {
|
|
440
|
+
await fs.promises.rm(outPatch, { force: true });
|
|
441
|
+
return -1;
|
|
388
442
|
}
|
|
389
|
-
|
|
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
|
-
|
|
484
|
+
startRun('queued-changes');
|
|
433
485
|
}
|
|
434
486
|
}
|
|
435
487
|
};
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
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(
|
|
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' =
|
|
665
|
+
const compression: 'zstd' | 'gzip' | 'identity' =
|
|
666
|
+
opts.compression ?? (hasDelta ? 'identity' : preferredCompression);
|
|
560
667
|
slog(
|
|
561
668
|
'debug',
|
|
562
669
|
`sync started files=${allFiles.length}${reason ? ` reason=${reason}` : ''} compression=${compression}`,
|
|
@@ -609,7 +716,7 @@ async function syncFolderOnce(
|
|
|
609
716
|
opts,
|
|
610
717
|
retryMeta,
|
|
611
718
|
retryPayloads.map((p) => ({ filePath: p.filePath })),
|
|
612
|
-
preferredCompression,
|
|
719
|
+
opts.compression ?? preferredCompression,
|
|
613
720
|
);
|
|
614
721
|
httpSendMsTotal += nowMs() - retryStart;
|
|
615
722
|
}
|
package/src/index.ts
CHANGED
package/src/instance-client.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { WebSocket, Data } from 'ws';
|
|
2
2
|
import { exec } from 'node:child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import crypto from 'crypto';
|
|
3
7
|
|
|
4
8
|
import { downloadFileToLocalPath } from './internal/download-file';
|
|
5
9
|
import { nodeProxyTransport } from './internal/proxy-transport';
|
|
6
10
|
import { startTcpTunnel, isNonRetryableError } from './tunnel';
|
|
7
11
|
import type { Tunnel } from './tunnel';
|
|
12
|
+
import { syncFolder, type FolderSyncOptions, type SyncFolderResult } from './folder-sync';
|
|
8
13
|
|
|
9
14
|
const ANDROID_RECORDING_PATH = '/data/local/tmp/recordings/video_recording.mp4';
|
|
10
15
|
const ANDROID_SIGNALING_PATH = '/ws';
|
|
@@ -32,12 +37,103 @@ function buildDownloadUrl(apiUrl: string): string {
|
|
|
32
37
|
return `${apiUrl}/files?path=${encodeURIComponent(ANDROID_RECORDING_PATH)}`;
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
function buildSyncStateUrl(apiUrl: string): string {
|
|
41
|
+
return `${apiUrl}/sync/state`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildSyncSeedUrl(apiUrl: string, sha256: string): string {
|
|
45
|
+
return `${apiUrl}/sync/seeds/${encodeURIComponent(sha256)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
function assertBandwidthKbps(field: keyof WifiBandwidthOptions, value: number): void {
|
|
36
49
|
if (!Number.isInteger(value) || value < 0) {
|
|
37
50
|
throw new Error(`${field} must be a non-negative integer Kbps value`);
|
|
38
51
|
}
|
|
39
52
|
}
|
|
40
53
|
|
|
54
|
+
async function sha256FileHex(filePath: string): Promise<string> {
|
|
55
|
+
return await new Promise((resolve, reject) => {
|
|
56
|
+
const hash = crypto.createHash('sha256');
|
|
57
|
+
const stream = fs.createReadStream(filePath);
|
|
58
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
59
|
+
stream.on('error', reject);
|
|
60
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function assertApkFile(filePath: string): Promise<void> {
|
|
65
|
+
const st = await fs.promises.stat(filePath).catch(() => null);
|
|
66
|
+
if (!st?.isFile()) {
|
|
67
|
+
throw new Error(`APK file not found: ${filePath}`);
|
|
68
|
+
}
|
|
69
|
+
const fd = await fs.promises.open(filePath, 'r');
|
|
70
|
+
try {
|
|
71
|
+
const buf = Buffer.alloc(4);
|
|
72
|
+
const { bytesRead } = await fd.read(buf, 0, 4, 0);
|
|
73
|
+
if (bytesRead < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) {
|
|
74
|
+
throw new Error(`File is not an APK/ZIP archive: ${filePath}`);
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
await fd.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function fetchAndroidSyncState(apiUrl: string, token: string): Promise<AndroidSyncState> {
|
|
82
|
+
const response = await nodeProxyTransport.fetch(buildSyncStateUrl(apiUrl), {
|
|
83
|
+
method: 'GET',
|
|
84
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
const text = await response.text();
|
|
88
|
+
throw new Error(`Failed to fetch Android sync state: ${response.status} ${text}`);
|
|
89
|
+
}
|
|
90
|
+
return (await response.json()) as AndroidSyncState;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function bootstrapAndroidBasisCache(
|
|
94
|
+
apkPath: string,
|
|
95
|
+
basisCacheDir: string,
|
|
96
|
+
apiUrl: string,
|
|
97
|
+
token: string,
|
|
98
|
+
log: FolderSyncOptions['log'],
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const remotePath = path.basename(apkPath);
|
|
101
|
+
const basisPath = path.join(basisCacheDir, remotePath);
|
|
102
|
+
if (fs.existsSync(basisPath)) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const state = await fetchAndroidSyncState(apiUrl, token).catch((err) => {
|
|
106
|
+
log('debug', `android sync state unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
107
|
+
return undefined;
|
|
108
|
+
});
|
|
109
|
+
if (!state) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const localSha = await sha256FileHex(apkPath);
|
|
113
|
+
for (const root of state.roots ?? []) {
|
|
114
|
+
for (const file of root.files ?? []) {
|
|
115
|
+
if (file.path === remotePath && file.sha256?.toLowerCase() === localSha) {
|
|
116
|
+
await fs.promises.mkdir(path.dirname(basisPath), { recursive: true });
|
|
117
|
+
await fs.promises.copyFile(apkPath, basisPath);
|
|
118
|
+
log('debug', `seeded Android basis cache from matching server root: ${remotePath}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const seeds = [...(state.seeds ?? [])]
|
|
124
|
+
.filter(
|
|
125
|
+
(seed): seed is { sha256: string; size?: number; name?: string; mtime?: number } =>
|
|
126
|
+
typeof seed.sha256 === 'string' && /^[0-9a-f]{64}$/i.test(seed.sha256),
|
|
127
|
+
)
|
|
128
|
+
.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
|
|
129
|
+
const seed = seeds[0];
|
|
130
|
+
if (!seed) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await downloadFileToLocalPath(buildSyncSeedUrl(apiUrl, seed.sha256), token, basisPath);
|
|
134
|
+
log('debug', `seeded Android basis cache from instance seed: ${seed.sha256}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
41
137
|
/**
|
|
42
138
|
* A client for interacting with a Limbar instance
|
|
43
139
|
*/
|
|
@@ -131,6 +227,19 @@ export type InstanceClient = {
|
|
|
131
227
|
*/
|
|
132
228
|
sendAsset: (url: string, timeoutMs?: number) => Promise<void>;
|
|
133
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Delta-sync a local APK to the instance and install it.
|
|
232
|
+
*/
|
|
233
|
+
syncApp: (
|
|
234
|
+
apkPath: string,
|
|
235
|
+
opts?: {
|
|
236
|
+
install?: boolean;
|
|
237
|
+
watch?: boolean;
|
|
238
|
+
launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
|
|
239
|
+
basisCacheDir?: string;
|
|
240
|
+
},
|
|
241
|
+
) => Promise<SyncFolderResult>;
|
|
242
|
+
|
|
134
243
|
/**
|
|
135
244
|
* Get current connection state
|
|
136
245
|
*/
|
|
@@ -258,6 +367,14 @@ type ScreenshotData = {
|
|
|
258
367
|
dataUri: string;
|
|
259
368
|
};
|
|
260
369
|
|
|
370
|
+
type AndroidSyncState = {
|
|
371
|
+
roots?: Array<{
|
|
372
|
+
rootName?: string;
|
|
373
|
+
files?: Array<{ path?: string; sha256?: string; size?: number; mode?: number }>;
|
|
374
|
+
}>;
|
|
375
|
+
seeds?: Array<{ sha256?: string; size?: number; name?: string; mtime?: number }>;
|
|
376
|
+
};
|
|
377
|
+
|
|
261
378
|
export type ElementTreeData = {
|
|
262
379
|
xml: string;
|
|
263
380
|
nodes: AndroidElementNode[];
|
|
@@ -890,6 +1007,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
890
1007
|
disconnect,
|
|
891
1008
|
startAdbTunnel,
|
|
892
1009
|
sendAsset,
|
|
1010
|
+
syncApp,
|
|
893
1011
|
getConnectionState,
|
|
894
1012
|
onConnectionStateChange,
|
|
895
1013
|
});
|
|
@@ -1150,6 +1268,47 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1150
1268
|
});
|
|
1151
1269
|
};
|
|
1152
1270
|
|
|
1271
|
+
const syncApp: InstanceClient['syncApp'] = async (apkPath, syncOpts) => {
|
|
1272
|
+
const resolvedPath = path.resolve(apkPath);
|
|
1273
|
+
await assertApkFile(resolvedPath);
|
|
1274
|
+
const fileName = path.basename(resolvedPath);
|
|
1275
|
+
const hash = crypto.createHash('sha1').update(resolvedPath).digest('hex').slice(0, 8);
|
|
1276
|
+
const cacheKey = `limsync-cache-android-${fileName}-${hash}`;
|
|
1277
|
+
const basisCacheDir = syncOpts?.basisCacheDir ?? path.join(os.tmpdir(), cacheKey);
|
|
1278
|
+
const syncLog: FolderSyncOptions['log'] = (level, msg) => {
|
|
1279
|
+
switch (level) {
|
|
1280
|
+
case 'debug':
|
|
1281
|
+
logger.debug(msg);
|
|
1282
|
+
break;
|
|
1283
|
+
case 'info':
|
|
1284
|
+
logger.info(msg);
|
|
1285
|
+
break;
|
|
1286
|
+
case 'warn':
|
|
1287
|
+
logger.warn(msg);
|
|
1288
|
+
break;
|
|
1289
|
+
case 'error':
|
|
1290
|
+
logger.error(msg);
|
|
1291
|
+
break;
|
|
1292
|
+
default:
|
|
1293
|
+
logger.info(msg);
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
await fs.promises.mkdir(basisCacheDir, { recursive: true });
|
|
1297
|
+
await bootstrapAndroidBasisCache(resolvedPath, basisCacheDir, options.apiUrl, options.token, syncLog);
|
|
1298
|
+
return await syncFolder(resolvedPath, {
|
|
1299
|
+
apiUrl: options.apiUrl,
|
|
1300
|
+
token: options.token,
|
|
1301
|
+
udid: cacheKey,
|
|
1302
|
+
basisCacheDir,
|
|
1303
|
+
install: syncOpts?.install ?? true,
|
|
1304
|
+
launchMode: syncOpts?.launchMode ?? 'ForegroundIfRunning',
|
|
1305
|
+
watch: syncOpts?.watch ?? false,
|
|
1306
|
+
ignoreFn: () => false,
|
|
1307
|
+
log: syncLog,
|
|
1308
|
+
compression: 'identity',
|
|
1309
|
+
});
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1153
1312
|
// Start the initial connection
|
|
1154
1313
|
setupWebSocket();
|
|
1155
1314
|
});
|
package/src/ios-client.ts
CHANGED
|
@@ -641,7 +641,6 @@ export type InstanceClient = {
|
|
|
641
641
|
* @param opts Optional sync options
|
|
642
642
|
* @param opts.install If true, install the app after syncing. Defaults to true.
|
|
643
643
|
* @param opts.basisCacheDir Directory for the client-side folder-sync cache.
|
|
644
|
-
* @param opts.maxPatchBytes Max patch size (bytes) to send as delta before falling back to full upload. Defaults to 4MB.
|
|
645
644
|
* @param opts.launchMode Launch mode after installation: "ForegroundIfRunning" (default): bring to foreground if already running, otherwise launch, "RelaunchIfRunning": kill and relaunch if already running
|
|
646
645
|
* @param opts.watch If true, watch the folder and re-sync on any changes (debounced, single-flight).
|
|
647
646
|
*/
|
|
@@ -650,7 +649,6 @@ export type InstanceClient = {
|
|
|
650
649
|
opts?: {
|
|
651
650
|
install?: boolean;
|
|
652
651
|
basisCacheDir?: string;
|
|
653
|
-
maxPatchBytes?: number;
|
|
654
652
|
launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
|
|
655
653
|
watch?: boolean;
|
|
656
654
|
},
|
|
@@ -1939,7 +1937,6 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1939
1937
|
opts?: {
|
|
1940
1938
|
install?: boolean;
|
|
1941
1939
|
basisCacheDir?: string;
|
|
1942
|
-
maxPatchBytes?: number;
|
|
1943
1940
|
launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
|
|
1944
1941
|
watch?: boolean;
|
|
1945
1942
|
},
|
|
@@ -1987,7 +1984,6 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1987
1984
|
basisCacheDir,
|
|
1988
1985
|
log: syncLog,
|
|
1989
1986
|
install: opts?.install ?? true,
|
|
1990
|
-
maxPatchBytes: opts?.maxPatchBytes ?? 4 * 1024 * 1024,
|
|
1991
1987
|
launchMode: opts?.launchMode ?? 'ForegroundIfRunning',
|
|
1992
1988
|
watch: preparedApp.isArchive ? false : shouldWatch,
|
|
1993
1989
|
};
|
|
@@ -2004,8 +2000,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
2004
2000
|
});
|
|
2005
2001
|
return {
|
|
2006
2002
|
...result,
|
|
2007
|
-
stopWatching: () => {
|
|
2008
|
-
archiveWatcher.close();
|
|
2003
|
+
stopWatching: async () => {
|
|
2004
|
+
await archiveWatcher.close();
|
|
2009
2005
|
},
|
|
2010
2006
|
};
|
|
2011
2007
|
};
|
|
@@ -4,7 +4,9 @@ import crypto from 'crypto';
|
|
|
4
4
|
|
|
5
5
|
import { XcodeInstances as GeneratedXcodeInstances, type XcodeInstance } from './xcode-instances';
|
|
6
6
|
import { type IosInstance } from './ios-instances';
|
|
7
|
-
import { exec, type ExecChildProcess, type ExecRequest } from '../exec-client';
|
|
7
|
+
import { exec, type ExecChildProcess, type ExecRequest, type TestflightUploadConfig } from '../exec-client';
|
|
8
|
+
|
|
9
|
+
export type { TestflightUploadConfig } from '../exec-client';
|
|
8
10
|
import {
|
|
9
11
|
syncFolder as syncFolderImpl,
|
|
10
12
|
type AdditionalFileSyncEntry,
|
|
@@ -34,8 +36,6 @@ export type SyncOptions = {
|
|
|
34
36
|
* Defaults to a temporary directory under the OS temp directory.
|
|
35
37
|
*/
|
|
36
38
|
basisCacheDir?: string;
|
|
37
|
-
/** Max patch size (bytes) to send as delta before falling back to full upload. */
|
|
38
|
-
maxPatchBytes?: number;
|
|
39
39
|
/** If true, install the app after syncing. Defaults to true. */
|
|
40
40
|
install?: boolean;
|
|
41
41
|
/**
|
|
@@ -53,7 +53,7 @@ export type SyncOptions = {
|
|
|
53
53
|
|
|
54
54
|
export type SyncResult = {
|
|
55
55
|
/** Present only when watch=true; call to stop watching */
|
|
56
|
-
stopWatching?: () => void
|
|
56
|
+
stopWatching?: () => Promise<void>;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
59
|
export type XcodeProjectConfig = {
|
|
@@ -95,6 +95,13 @@ export type ReactNativeBuildConfig = {
|
|
|
95
95
|
export type XcodeBuildOptions = {
|
|
96
96
|
upload?: { assetName: string } | { signedUploadUrl: string };
|
|
97
97
|
signing?: XcodeSigningConfig;
|
|
98
|
+
/**
|
|
99
|
+
* Upload the signed device IPA to TestFlight after the build. Requires
|
|
100
|
+
* signing and sdk=iphoneos. Check ExecResult.testflight on completion: it is
|
|
101
|
+
* absent when the instance's limbuild predates the feature (old servers
|
|
102
|
+
* silently ignore this option).
|
|
103
|
+
*/
|
|
104
|
+
testflight?: TestflightUploadConfig;
|
|
98
105
|
reactNative?: ReactNativeBuildConfig;
|
|
99
106
|
buildSettings?: Record<string, string>;
|
|
100
107
|
};
|
|
@@ -615,7 +622,6 @@ export class XcodeInstances extends GeneratedXcodeInstances {
|
|
|
615
622
|
}),
|
|
616
623
|
basisCacheDir,
|
|
617
624
|
watch: opts?.watch ?? true,
|
|
618
|
-
maxPatchBytes: opts?.maxPatchBytes ?? 4 * 1024 * 1024,
|
|
619
625
|
launchMode: 'ForegroundIfRunning',
|
|
620
626
|
log,
|
|
621
627
|
...(additionalFiles ? { additionalFiles } : {}),
|
|
@@ -640,6 +646,7 @@ export class XcodeInstances extends GeneratedXcodeInstances {
|
|
|
640
646
|
...(settings && { xcodebuild: settings }),
|
|
641
647
|
...(options?.reactNative && { reactNative: options.reactNative }),
|
|
642
648
|
...(options?.signing && { signing: options.signing }),
|
|
649
|
+
...(options?.testflight && { testflight: options.testflight }),
|
|
643
650
|
...(options?.buildSettings && { buildSettings: options.buildSettings }),
|
|
644
651
|
};
|
|
645
652
|
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.39.1'; // x-release-please-version
|
package/version.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.39.1";
|
|
2
2
|
//# sourceMappingURL=version.d.mts.map
|
package/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.39.1";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|