@limrun/api 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/app-archive.d.mts +1 -1
  3. package/app-archive.d.mts.map +1 -1
  4. package/app-archive.d.ts +1 -1
  5. package/app-archive.d.ts.map +1 -1
  6. package/app-archive.js +22 -10
  7. package/app-archive.js.map +1 -1
  8. package/app-archive.mjs +22 -10
  9. package/app-archive.mjs.map +1 -1
  10. package/folder-sync-watcher.d.mts +1 -1
  11. package/folder-sync-watcher.d.mts.map +1 -1
  12. package/folder-sync-watcher.d.ts +1 -1
  13. package/folder-sync-watcher.d.ts.map +1 -1
  14. package/folder-sync-watcher.js +2 -9
  15. package/folder-sync-watcher.js.map +1 -1
  16. package/folder-sync-watcher.mjs +2 -9
  17. package/folder-sync-watcher.mjs.map +1 -1
  18. package/folder-sync.d.mts +4 -4
  19. package/folder-sync.d.mts.map +1 -1
  20. package/folder-sync.d.ts +4 -4
  21. package/folder-sync.d.ts.map +1 -1
  22. package/folder-sync.js +149 -45
  23. package/folder-sync.js.map +1 -1
  24. package/folder-sync.mjs +149 -45
  25. package/folder-sync.mjs.map +1 -1
  26. package/instance-client.d.mts +10 -0
  27. package/instance-client.d.mts.map +1 -1
  28. package/instance-client.d.ts +10 -0
  29. package/instance-client.d.ts.map +1 -1
  30. package/instance-client.js +124 -0
  31. package/instance-client.js.map +1 -1
  32. package/instance-client.mjs +123 -0
  33. package/instance-client.mjs.map +1 -1
  34. package/ios-client.d.mts +0 -2
  35. package/ios-client.d.mts.map +1 -1
  36. package/ios-client.d.ts +0 -2
  37. package/ios-client.d.ts.map +1 -1
  38. package/ios-client.js +2 -3
  39. package/ios-client.js.map +1 -1
  40. package/ios-client.mjs +2 -3
  41. package/ios-client.mjs.map +1 -1
  42. package/package.json +2 -2
  43. package/resources/xcode-instances-helpers.d.mts +1 -3
  44. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  45. package/resources/xcode-instances-helpers.d.ts +1 -3
  46. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  47. package/resources/xcode-instances-helpers.js +0 -1
  48. package/resources/xcode-instances-helpers.js.map +1 -1
  49. package/resources/xcode-instances-helpers.mjs +0 -1
  50. package/resources/xcode-instances-helpers.mjs.map +1 -1
  51. package/src/app-archive.ts +22 -10
  52. package/src/folder-sync-watcher.ts +2 -8
  53. package/src/folder-sync.ts +168 -61
  54. package/src/instance-client.ts +159 -0
  55. package/src/ios-client.ts +2 -6
  56. package/src/resources/xcode-instances-helpers.ts +1 -4
  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
@@ -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
  };
@@ -34,8 +34,6 @@ export type SyncOptions = {
34
34
  * Defaults to a temporary directory under the OS temp directory.
35
35
  */
36
36
  basisCacheDir?: string;
37
- /** Max patch size (bytes) to send as delta before falling back to full upload. */
38
- maxPatchBytes?: number;
39
37
  /** If true, install the app after syncing. Defaults to true. */
40
38
  install?: boolean;
41
39
  /**
@@ -53,7 +51,7 @@ export type SyncOptions = {
53
51
 
54
52
  export type SyncResult = {
55
53
  /** Present only when watch=true; call to stop watching */
56
- stopWatching?: () => void;
54
+ stopWatching?: () => Promise<void>;
57
55
  };
58
56
 
59
57
  export type XcodeProjectConfig = {
@@ -615,7 +613,6 @@ export class XcodeInstances extends GeneratedXcodeInstances {
615
613
  }),
616
614
  basisCacheDir,
617
615
  watch: opts?.watch ?? true,
618
- maxPatchBytes: opts?.maxPatchBytes ?? 4 * 1024 * 1024,
619
616
  launchMode: 'ForegroundIfRunning',
620
617
  log,
621
618
  ...(additionalFiles ? { additionalFiles } : {}),
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.38.0'; // x-release-please-version
1
+ export const VERSION = '0.39.0'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.38.0";
1
+ export declare const VERSION = "0.39.0";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.38.0";
1
+ export declare const VERSION = "0.39.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.38.0'; // x-release-please-version
4
+ exports.VERSION = '0.39.0'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.38.0'; // x-release-please-version
1
+ export const VERSION = '0.39.0'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map