@limrun/api 0.33.1 → 0.33.3

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.
@@ -0,0 +1,345 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { pipeline } from 'stream/promises';
6
+ import * as yauzl from 'yauzl';
7
+
8
+ type LogFn = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
9
+
10
+ export type PreparedAppBundle = {
11
+ appPath: string;
12
+ cacheIdentityPath: string;
13
+ isArchive: boolean;
14
+ archivePath?: string;
15
+ };
16
+
17
+ export type ArchiveWatcher = {
18
+ close: () => void;
19
+ };
20
+
21
+ const noopLogger: LogFn = () => {};
22
+
23
+ function sleep(ms: number): Promise<void> {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ function stableArchiveRoot(archivePath: string): string {
28
+ const resolved = path.resolve(archivePath);
29
+ const hash = crypto.createHash('sha1').update(resolved).digest('hex').slice(0, 12);
30
+ const base = path
31
+ .basename(resolved, path.extname(resolved))
32
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
33
+ .replace(/^-+|-+$/g, '');
34
+ const safeBase = base === '' ? 'app' : base;
35
+ return path.join(os.tmpdir(), `limrun-sync-app-${safeBase}-${hash}`);
36
+ }
37
+
38
+ function stableAppPath(archivePath: string): string {
39
+ return path.join(stableArchiveRoot(archivePath), 'Extracted.app');
40
+ }
41
+
42
+ function toZipPath(name: string): string {
43
+ return name.replace(/\\/g, '/');
44
+ }
45
+
46
+ function isUnsafeZipPath(name: string): boolean {
47
+ if (name.includes('\0')) return true;
48
+ if (path.posix.isAbsolute(name)) return true;
49
+ return name.split('/').some((part) => part === '..');
50
+ }
51
+
52
+ function entryMode(entry: yauzl.Entry): number {
53
+ return (entry.externalFileAttributes >>> 16) & 0xffff;
54
+ }
55
+
56
+ function isSymlinkEntry(entry: yauzl.Entry): boolean {
57
+ return (entryMode(entry) & 0o170000) === 0o120000;
58
+ }
59
+
60
+ function isDirectoryEntry(entry: yauzl.Entry): boolean {
61
+ return entry.fileName.endsWith('/');
62
+ }
63
+
64
+ function entryPerm(entry: yauzl.Entry, fallback: number): number {
65
+ return entryMode(entry) & 0o7777 || fallback;
66
+ }
67
+
68
+ async function openZip(archivePath: string): Promise<yauzl.ZipFile> {
69
+ try {
70
+ return await yauzl.openPromise(archivePath, {
71
+ autoClose: false,
72
+ lazyEntries: true,
73
+ strictFileNames: true,
74
+ validateEntrySizes: true,
75
+ });
76
+ } catch (err) {
77
+ const message = err instanceof Error ? `: ${err.message}` : '';
78
+ throw new Error(
79
+ `The path is not a valid app bundle directory or ZIP/IPA archive: ${archivePath}${message}`,
80
+ );
81
+ }
82
+ }
83
+
84
+ async function readEntries(zip: yauzl.ZipFile): Promise<yauzl.Entry[]> {
85
+ const entries: yauzl.Entry[] = [];
86
+ for await (const entry of zip.eachEntry()) {
87
+ entries.push(entry);
88
+ }
89
+ return entries;
90
+ }
91
+
92
+ async function ensureDirectoryNotSymlink(dir: string): Promise<void> {
93
+ const stat = await fs.promises.lstat(dir).catch((err: NodeJS.ErrnoException) => {
94
+ if (err.code === 'ENOENT') return null;
95
+ throw err;
96
+ });
97
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) {
98
+ await fs.promises.rm(dir, { recursive: true, force: true });
99
+ }
100
+ await fs.promises.mkdir(dir, { recursive: true });
101
+ }
102
+
103
+ function discoverPayloadApp(entries: yauzl.Entry[], archivePath: string): string {
104
+ const appNames = new Set<string>();
105
+ const appsWithInfoPlist = new Set<string>();
106
+
107
+ for (const entry of entries) {
108
+ const name = toZipPath(entry.fileName);
109
+ if (isUnsafeZipPath(name)) {
110
+ throw new Error(`ZIP entry has an unsafe path: ${entry.fileName}`);
111
+ }
112
+ if (isDirectoryEntry(entry)) {
113
+ continue;
114
+ }
115
+ const rest = name.startsWith('Payload/') ? name.slice('Payload/'.length) : '';
116
+ if (rest === '') {
117
+ continue;
118
+ }
119
+ const [appName, ...parts] = rest.split('/');
120
+ if (!appName?.endsWith('.app')) {
121
+ continue;
122
+ }
123
+ appNames.add(appName);
124
+ if (parts.join('/') === 'Info.plist') {
125
+ appsWithInfoPlist.add(appName);
126
+ }
127
+ }
128
+
129
+ if (appNames.size === 0) {
130
+ throw new Error(`ZIP/IPA archive contains no Payload/*.app bundle: ${archivePath}`);
131
+ }
132
+ if (appNames.size > 1) {
133
+ throw new Error(
134
+ `ZIP/IPA archive contains more than one Payload/*.app bundle (${[...appNames].join(
135
+ ', ',
136
+ )}); expected exactly one`,
137
+ );
138
+ }
139
+
140
+ const appName = [...appNames][0]!;
141
+ if (!appsWithInfoPlist.has(appName)) {
142
+ throw new Error(`ZIP/IPA archive app bundle is missing Info.plist: Payload/${appName}/Info.plist`);
143
+ }
144
+ return appName;
145
+ }
146
+
147
+ async function extractEntry(
148
+ zip: yauzl.ZipFile,
149
+ entry: yauzl.Entry,
150
+ target: string,
151
+ appRoot: string,
152
+ ): Promise<void> {
153
+ if (isDirectoryEntry(entry)) {
154
+ await fs.promises.mkdir(target, { recursive: true, mode: entryPerm(entry, 0o755) });
155
+ return;
156
+ }
157
+
158
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
159
+ if (isSymlinkEntry(entry)) {
160
+ const stream = await zip.openReadStreamPromise(entry);
161
+ const chunks: Buffer[] = [];
162
+ for await (const chunk of stream) {
163
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
164
+ }
165
+ const linkTarget = Buffer.concat(chunks).toString('utf8');
166
+ const resolved = path.resolve(path.dirname(target), linkTarget);
167
+ const appRootResolved = path.resolve(appRoot);
168
+ if (resolved !== appRootResolved && !resolved.startsWith(appRootResolved + path.sep)) {
169
+ throw new Error(`ZIP/IPA symlink escapes app bundle: ${entry.fileName} -> ${linkTarget}`);
170
+ }
171
+ await fs.promises.rm(target, { force: true });
172
+ await fs.promises.symlink(linkTarget, target);
173
+ return;
174
+ }
175
+
176
+ const stream = await zip.openReadStreamPromise(entry);
177
+ await pipeline(stream, fs.createWriteStream(target, { mode: entryPerm(entry, 0o644) }));
178
+ await fs.promises.chmod(target, entryPerm(entry, 0o644));
179
+ }
180
+
181
+ export async function extractAppArchiveToStablePath(archivePath: string): Promise<PreparedAppBundle> {
182
+ const resolvedArchivePath = path.resolve(archivePath);
183
+ const appPath = stableAppPath(resolvedArchivePath);
184
+ const stableRoot = path.dirname(appPath);
185
+
186
+ await ensureDirectoryNotSymlink(stableRoot);
187
+ const zip = await openZip(resolvedArchivePath);
188
+ try {
189
+ const entries = await readEntries(zip);
190
+ const appName = discoverPayloadApp(entries, resolvedArchivePath);
191
+ const payloadPrefix = `Payload/${appName}/`;
192
+ await fs.promises.rm(appPath, { recursive: true, force: true });
193
+ await ensureDirectoryNotSymlink(appPath);
194
+
195
+ for (const entry of entries) {
196
+ const name = toZipPath(entry.fileName);
197
+ if (name !== `Payload/${appName}` && !name.startsWith(payloadPrefix)) {
198
+ continue;
199
+ }
200
+ const rel = name === `Payload/${appName}` ? '' : name.slice(payloadPrefix.length);
201
+ if (rel === '') {
202
+ await fs.promises.mkdir(appPath, { recursive: true });
203
+ continue;
204
+ }
205
+ if (isUnsafeZipPath(rel)) {
206
+ throw new Error(`ZIP entry has an unsafe app-relative path: ${entry.fileName}`);
207
+ }
208
+ const target = path.join(appPath, rel.split('/').join(path.sep));
209
+ const stagingAppResolved = path.resolve(appPath);
210
+ const targetResolved = path.resolve(target);
211
+ if (
212
+ targetResolved !== stagingAppResolved &&
213
+ !targetResolved.startsWith(stagingAppResolved + path.sep)
214
+ ) {
215
+ throw new Error(`ZIP entry escapes app bundle: ${entry.fileName}`);
216
+ }
217
+ await extractEntry(zip, entry, target, appPath);
218
+ }
219
+
220
+ return {
221
+ appPath,
222
+ cacheIdentityPath: resolvedArchivePath,
223
+ isArchive: true,
224
+ archivePath: resolvedArchivePath,
225
+ };
226
+ } finally {
227
+ zip.close();
228
+ }
229
+ }
230
+
231
+ export async function prepareAppBundlePath(inputPath: string): Promise<PreparedAppBundle> {
232
+ const resolvedPath = path.resolve(inputPath);
233
+ const stat = await fs.promises.stat(resolvedPath);
234
+ if (stat.isDirectory()) {
235
+ return {
236
+ appPath: resolvedPath,
237
+ cacheIdentityPath: resolvedPath,
238
+ isArchive: false,
239
+ };
240
+ }
241
+ if (!stat.isFile()) {
242
+ throw new Error(`The path is neither an app bundle directory nor a ZIP/IPA archive: ${inputPath}`);
243
+ }
244
+ return await extractAppArchiveToStablePath(resolvedPath);
245
+ }
246
+
247
+ async function waitForStableArchiveFile(archivePath: string, shouldStop: () => boolean): Promise<boolean> {
248
+ const deadline = Date.now() + 10_000;
249
+ let previous: { size: number; mtimeMs: number } | undefined;
250
+
251
+ while (!shouldStop() && Date.now() < deadline) {
252
+ const stat = await fs.promises.stat(archivePath).catch((err: NodeJS.ErrnoException) => {
253
+ if (err.code === 'ENOENT') return null;
254
+ throw err;
255
+ });
256
+
257
+ if (stat?.isFile()) {
258
+ const current = { size: stat.size, mtimeMs: stat.mtimeMs };
259
+ if (previous && previous.size === current.size && previous.mtimeMs === current.mtimeMs) {
260
+ return true;
261
+ }
262
+ previous = current;
263
+ } else {
264
+ previous = undefined;
265
+ }
266
+
267
+ await sleep(100);
268
+ }
269
+
270
+ return false;
271
+ }
272
+
273
+ export function watchAppArchive(opts: {
274
+ archivePath: string;
275
+ log?: LogFn;
276
+ onExtracted?: () => void | Promise<void>;
277
+ }): ArchiveWatcher {
278
+ const archivePath = path.resolve(opts.archivePath);
279
+ const log = opts.log ?? noopLogger;
280
+ const parentDir = path.dirname(archivePath);
281
+ const archiveBase = path.basename(archivePath);
282
+ let timer: NodeJS.Timeout | undefined;
283
+ let inFlight = false;
284
+ let queued = false;
285
+ let closed = false;
286
+
287
+ const run = async () => {
288
+ if (closed) return;
289
+ if (inFlight) {
290
+ queued = true;
291
+ return;
292
+ }
293
+ inFlight = true;
294
+ try {
295
+ const ready = await waitForStableArchiveFile(archivePath, () => closed);
296
+ if (!ready) {
297
+ if (!closed) {
298
+ log('warn', `ZIP/IPA archive did not become readable after change: ${archivePath}`);
299
+ }
300
+ return;
301
+ }
302
+ await extractAppArchiveToStablePath(archivePath);
303
+ log('debug', `re-extracted ZIP/IPA archive: ${archivePath}`);
304
+ await opts.onExtracted?.();
305
+ } catch (err) {
306
+ log(
307
+ 'error',
308
+ `failed to re-extract ZIP/IPA archive ${archivePath}: ${err instanceof Error ? err.message : err}`,
309
+ );
310
+ } finally {
311
+ inFlight = false;
312
+ if (queued) {
313
+ queued = false;
314
+ void run();
315
+ }
316
+ }
317
+ };
318
+
319
+ const schedule = () => {
320
+ if (closed) return;
321
+ if (timer) clearTimeout(timer);
322
+ timer = setTimeout(() => {
323
+ timer = undefined;
324
+ void run();
325
+ }, 500);
326
+ };
327
+
328
+ const watcher = fs.watch(parentDir, (_eventType, filename) => {
329
+ if (filename?.toString() === archiveBase) {
330
+ schedule();
331
+ }
332
+ });
333
+ log('debug', `watchAppArchive: ${archivePath}`);
334
+
335
+ return {
336
+ close: () => {
337
+ closed = true;
338
+ if (timer) {
339
+ clearTimeout(timer);
340
+ timer = undefined;
341
+ }
342
+ watcher.close();
343
+ },
344
+ };
345
+ }
@@ -88,6 +88,12 @@ export type InstanceClient = {
88
88
  * Open a URL/deeplink on Android.
89
89
  */
90
90
  openUrl: (url: string) => Promise<OpenUrlResult>;
91
+ /**
92
+ * Play an on-device WAV/MP3 file as microphone input.
93
+ *
94
+ * The file must already exist on the Android instance, for example after pushing it with ADB.
95
+ */
96
+ playOnMicrophone: (path: string, options?: PlayOnMicrophoneOptions) => Promise<PlayOnMicrophoneResult>;
91
97
  /**
92
98
  * Set Android Wi-Fi bandwidth limits in Kbps. Omit a direction to leave it unchanged;
93
99
  * pass `0` to clear that direction's limit.
@@ -287,6 +293,17 @@ export type OpenUrlResult = {
287
293
  url: string;
288
294
  };
289
295
 
296
+ export type PlayOnMicrophoneOptions = {
297
+ once?: boolean;
298
+ };
299
+
300
+ export type PlayOnMicrophoneResult = {
301
+ /** Duration of the decoded audio in microseconds. */
302
+ duration: number;
303
+ once: boolean;
304
+ generation: number;
305
+ };
306
+
290
307
  export type WifiBandwidthOptions = {
291
308
  downKbps?: number;
292
309
  upKbps?: number;
@@ -381,6 +398,13 @@ type OpenUrlResultMessage = {
381
398
  error?: CommandError;
382
399
  };
383
400
 
401
+ type PlayOnMicrophoneResultMessage = {
402
+ type: 'playOnMicrophoneResult';
403
+ id: string;
404
+ payload?: PlayOnMicrophoneResult;
405
+ error?: CommandError;
406
+ };
407
+
384
408
  type SetWifiBandwidthResultMessage = {
385
409
  type: 'setWifiBandwidthResult';
386
410
  id: string;
@@ -412,6 +436,7 @@ type KnownCommandResultMessage =
412
436
  | ScrollScreenResultMessage
413
437
  | ScrollElementResultMessage
414
438
  | OpenUrlResultMessage
439
+ | PlayOnMicrophoneResultMessage
415
440
  | SetWifiBandwidthResultMessage
416
441
  | StartVideoRecordingResultMessage
417
442
  | StopVideoRecordingResultMessage;
@@ -433,6 +458,7 @@ type CommandRequestMap = {
433
458
  scrollScreen: { direction: ScrollDirection; amount?: number };
434
459
  scrollElement: AndroidElementTarget & { direction: ScrollDirection; amount?: number };
435
460
  openUrl: { url: string };
461
+ playOnMicrophone: { path: string; once?: boolean };
436
462
  setWifiBandwidth: WifiBandwidthOptions;
437
463
  startRecording: { quality?: RecordingQuality };
438
464
  stopRecording: { upload?: { presignedUrl: string } };
@@ -448,6 +474,7 @@ type CommandResultMap = {
448
474
  scrollScreen: ScrollResult;
449
475
  scrollElement: ScrollResult;
450
476
  openUrl: OpenUrlResult;
477
+ playOnMicrophone: PlayOnMicrophoneResult;
451
478
  setWifiBandwidth: EmptyCommandResult;
452
479
  startRecording: EmptyCommandResult;
453
480
  stopRecording: EmptyCommandResult;
@@ -614,6 +641,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
614
641
  case 'scrollScreenResult':
615
642
  case 'scrollElementResult':
616
643
  case 'openUrlResult':
644
+ case 'playOnMicrophoneResult':
617
645
  case 'setWifiBandwidthResult':
618
646
  case 'startRecordingResult':
619
647
  case 'stopRecordingResult':
@@ -854,6 +882,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
854
882
  scrollScreen,
855
883
  scrollElement,
856
884
  openUrl,
885
+ playOnMicrophone,
857
886
  setWifiBandwidth,
858
887
  startRecording,
859
888
  stopRecording,
@@ -947,6 +976,19 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
947
976
  };
948
977
  };
949
978
 
979
+ const playOnMicrophone = async (
980
+ inputPath: string,
981
+ microphoneOptions?: PlayOnMicrophoneOptions,
982
+ ): Promise<PlayOnMicrophoneResult> => {
983
+ if (!inputPath) {
984
+ throw new Error('path must be a non-empty string');
985
+ }
986
+ return sendRequest('playOnMicrophone', {
987
+ path: inputPath,
988
+ ...(microphoneOptions?.once === undefined ? {} : { once: microphoneOptions.once }),
989
+ });
990
+ };
991
+
950
992
  const setWifiBandwidth = async (bandwidthOptions: WifiBandwidthOptions): Promise<void> => {
951
993
  const request: CommandRequestMap['setWifiBandwidth'] = {};
952
994
  if (bandwidthOptions.downKbps !== undefined) {
package/src/ios-client.ts CHANGED
@@ -7,6 +7,7 @@ import { EventEmitter } from 'events';
7
7
  import { assertPort, isNonRetryableError, startReverseTcpTunnel, type ReverseTunnel } from './tunnel';
8
8
  import { type SyncFolderResult, type FolderSyncOptions, syncFolder } from './folder-sync';
9
9
  import { createIgnoreFn } from './folder-sync-ignore';
10
+ import { prepareAppBundlePath, watchAppArchive } from './app-archive';
10
11
  import { downloadFileToLocalPath } from './internal/download-file';
11
12
  import { nodeProxyTransport } from './internal/proxy-transport';
12
13
  import { startHttpProxy as startLocalHttpProxy } from './http-proxy';
@@ -1900,7 +1901,9 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1900
1901
  watch?: boolean;
1901
1902
  },
1902
1903
  ): Promise<SyncFolderResult> => {
1903
- const infoPlistPath = path.join(localAppBundlePath, 'Info.plist');
1904
+ const preparedApp = await prepareAppBundlePath(localAppBundlePath);
1905
+ const appBundlePath = preparedApp.appPath;
1906
+ const infoPlistPath = path.join(appBundlePath, 'Info.plist');
1904
1907
  const infoPlistStat = await fs.promises.stat(infoPlistPath).catch(() => null);
1905
1908
  if (!infoPlistStat?.isFile()) {
1906
1909
  throw new Error(`The folder is not a valid app bundle: missing Info.plist at ${infoPlistPath}`);
@@ -1908,11 +1911,12 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1908
1911
  if (!cachedDeviceInfo) {
1909
1912
  throw new Error('Device info not available yet; wait for client connection to be established.');
1910
1913
  }
1911
- const resolvedPath = path.resolve(localAppBundlePath);
1914
+ const resolvedPath = path.resolve(preparedApp.cacheIdentityPath);
1912
1915
  const folderName = path.basename(resolvedPath);
1913
1916
  const hash = crypto.createHash('sha1').update(resolvedPath).digest('hex').slice(0, 8);
1914
1917
  const cacheKey = `limsync-cache-${folderName}-${hash}`;
1915
1918
  const basisCacheDir = opts?.basisCacheDir ?? path.join(os.tmpdir(), cacheKey);
1919
+ const shouldWatch = opts?.watch ?? true;
1916
1920
  const syncLog: FolderSyncOptions['log'] = (level, msg) => {
1917
1921
  switch (level) {
1918
1922
  case 'debug':
@@ -1936,15 +1940,31 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1936
1940
  apiUrl: options.apiUrl,
1937
1941
  token: options.token,
1938
1942
  udid: cacheKey,
1939
- ignoreFn: await createIgnoreFn(localAppBundlePath, { basisCacheDir, log: syncLog }),
1943
+ ignoreFn: await createIgnoreFn(appBundlePath, { basisCacheDir, log: syncLog }),
1940
1944
  basisCacheDir,
1941
1945
  log: syncLog,
1942
1946
  install: opts?.install ?? true,
1943
1947
  maxPatchBytes: opts?.maxPatchBytes ?? 4 * 1024 * 1024,
1944
1948
  launchMode: opts?.launchMode ?? 'ForegroundIfRunning',
1945
- watch: opts?.watch ?? true,
1949
+ watch: preparedApp.isArchive ? false : shouldWatch,
1950
+ };
1951
+ const result = await syncFolder(appBundlePath, folderSyncOpts);
1952
+ if (!preparedApp.isArchive || !preparedApp.archivePath || !shouldWatch) {
1953
+ return result;
1954
+ }
1955
+ const archiveWatcher = watchAppArchive({
1956
+ archivePath: preparedApp.archivePath,
1957
+ log: syncLog,
1958
+ onExtracted: async () => {
1959
+ await syncFolder(appBundlePath, folderSyncOpts);
1960
+ },
1961
+ });
1962
+ return {
1963
+ ...result,
1964
+ stopWatching: () => {
1965
+ archiveWatcher.close();
1966
+ },
1946
1967
  };
1947
- return await syncFolder(localAppBundlePath, folderSyncOpts);
1948
1968
  };
1949
1969
 
1950
1970
  const lsof = (): Promise<LsofEntry[]> => {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.33.1'; // x-release-please-version
1
+ export const VERSION = '0.33.3'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.33.1";
1
+ export declare const VERSION = "0.33.3";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.33.1";
1
+ export declare const VERSION = "0.33.3";
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.33.1'; // x-release-please-version
4
+ exports.VERSION = '0.33.3'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.33.1'; // x-release-please-version
1
+ export const VERSION = '0.33.3'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map