@limrun/api 0.23.2 → 0.24.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.
Files changed (148) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/client.d.mts +4 -0
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +4 -0
  5. package/client.d.ts.map +1 -1
  6. package/client.js +3 -0
  7. package/client.js.map +1 -1
  8. package/client.mjs +3 -0
  9. package/client.mjs.map +1 -1
  10. package/exec-client.d.mts +3 -2
  11. package/exec-client.d.mts.map +1 -1
  12. package/exec-client.d.ts +3 -2
  13. package/exec-client.d.ts.map +1 -1
  14. package/exec-client.js +10 -3
  15. package/exec-client.js.map +1 -1
  16. package/exec-client.mjs +10 -3
  17. package/exec-client.mjs.map +1 -1
  18. package/folder-sync.d.mts.map +1 -1
  19. package/folder-sync.d.ts.map +1 -1
  20. package/folder-sync.js +5 -2
  21. package/folder-sync.js.map +1 -1
  22. package/folder-sync.mjs +5 -2
  23. package/folder-sync.mjs.map +1 -1
  24. package/index.d.mts +1 -1
  25. package/index.d.mts.map +1 -1
  26. package/index.d.ts +1 -1
  27. package/index.d.ts.map +1 -1
  28. package/index.js +1 -3
  29. package/index.js.map +1 -1
  30. package/index.mjs +0 -1
  31. package/index.mjs.map +1 -1
  32. package/instance-client.d.mts +31 -4
  33. package/instance-client.d.mts.map +1 -1
  34. package/instance-client.d.ts +31 -4
  35. package/instance-client.d.ts.map +1 -1
  36. package/instance-client.js +61 -2
  37. package/instance-client.js.map +1 -1
  38. package/instance-client.mjs +60 -2
  39. package/instance-client.mjs.map +1 -1
  40. package/internal/download-file.d.mts +2 -0
  41. package/internal/download-file.d.mts.map +1 -0
  42. package/internal/download-file.d.ts +2 -0
  43. package/internal/download-file.d.ts.map +1 -0
  44. package/internal/download-file.js +35 -0
  45. package/internal/download-file.js.map +1 -0
  46. package/internal/download-file.mjs +31 -0
  47. package/internal/download-file.mjs.map +1 -0
  48. package/internal/proxy-transport.d.mts +14 -0
  49. package/internal/proxy-transport.d.mts.map +1 -0
  50. package/internal/proxy-transport.d.ts +14 -0
  51. package/internal/proxy-transport.d.ts.map +1 -0
  52. package/internal/proxy-transport.js +59 -0
  53. package/internal/proxy-transport.js.map +1 -0
  54. package/internal/proxy-transport.mjs +56 -0
  55. package/internal/proxy-transport.mjs.map +1 -0
  56. package/internal/shims.d.mts.map +1 -1
  57. package/internal/shims.d.ts.map +1 -1
  58. package/internal/shims.js +2 -1
  59. package/internal/shims.js.map +1 -1
  60. package/internal/shims.mjs +2 -1
  61. package/internal/shims.mjs.map +1 -1
  62. package/internal/tslib.js +4 -4
  63. package/internal/utils/env.js +2 -2
  64. package/internal/utils/env.js.map +1 -1
  65. package/internal/utils/env.mjs +2 -2
  66. package/internal/utils/env.mjs.map +1 -1
  67. package/ios-client.d.mts +13 -1
  68. package/ios-client.d.mts.map +1 -1
  69. package/ios-client.d.ts +13 -1
  70. package/ios-client.d.ts.map +1 -1
  71. package/ios-client.js +44 -65
  72. package/ios-client.js.map +1 -1
  73. package/ios-client.mjs +42 -63
  74. package/ios-client.mjs.map +1 -1
  75. package/package.json +4 -11
  76. package/resources/android-instances.d.mts +5 -4
  77. package/resources/android-instances.d.mts.map +1 -1
  78. package/resources/android-instances.d.ts +5 -4
  79. package/resources/android-instances.d.ts.map +1 -1
  80. package/resources/assets-helpers.d.mts.map +1 -1
  81. package/resources/assets-helpers.d.ts.map +1 -1
  82. package/resources/assets-helpers.js +2 -1
  83. package/resources/assets-helpers.js.map +1 -1
  84. package/resources/assets-helpers.mjs +2 -1
  85. package/resources/assets-helpers.mjs.map +1 -1
  86. package/resources/index.d.mts +2 -0
  87. package/resources/index.d.mts.map +1 -1
  88. package/resources/index.d.ts +2 -0
  89. package/resources/index.d.ts.map +1 -1
  90. package/resources/index.js +3 -1
  91. package/resources/index.js.map +1 -1
  92. package/resources/index.mjs +1 -0
  93. package/resources/index.mjs.map +1 -1
  94. package/resources/ios-instances.d.mts +4 -4
  95. package/resources/ios-instances.d.ts +4 -4
  96. package/resources/xcode-instances-helpers.d.mts +76 -0
  97. package/resources/xcode-instances-helpers.d.mts.map +1 -0
  98. package/resources/xcode-instances-helpers.d.ts +76 -0
  99. package/resources/xcode-instances-helpers.d.ts.map +1 -0
  100. package/resources/xcode-instances-helpers.js +150 -0
  101. package/resources/xcode-instances-helpers.js.map +1 -0
  102. package/resources/xcode-instances-helpers.mjs +145 -0
  103. package/resources/xcode-instances-helpers.mjs.map +1 -0
  104. package/resources/xcode-instances.d.mts +122 -0
  105. package/resources/xcode-instances.d.mts.map +1 -0
  106. package/resources/xcode-instances.d.ts +122 -0
  107. package/resources/xcode-instances.d.ts.map +1 -0
  108. package/resources/xcode-instances.js +40 -0
  109. package/resources/xcode-instances.js.map +1 -0
  110. package/resources/xcode-instances.mjs +36 -0
  111. package/resources/xcode-instances.mjs.map +1 -0
  112. package/src/client.ts +27 -0
  113. package/src/exec-client.ts +12 -5
  114. package/src/folder-sync.ts +15 -12
  115. package/src/index.ts +6 -9
  116. package/src/instance-client.ts +107 -8
  117. package/src/internal/download-file.ts +33 -0
  118. package/src/internal/proxy-transport.ts +69 -0
  119. package/src/internal/shims.ts +2 -1
  120. package/src/internal/utils/env.ts +2 -2
  121. package/src/ios-client.ts +45 -67
  122. package/src/resources/android-instances.ts +6 -4
  123. package/src/resources/assets-helpers.ts +2 -1
  124. package/src/resources/index.ts +13 -0
  125. package/src/resources/ios-instances.ts +4 -4
  126. package/src/resources/xcode-instances-helpers.ts +228 -0
  127. package/src/resources/xcode-instances.ts +177 -0
  128. package/src/tunnel.ts +5 -0
  129. package/src/version.ts +1 -1
  130. package/tunnel.d.mts.map +1 -1
  131. package/tunnel.d.ts.map +1 -1
  132. package/tunnel.js +5 -0
  133. package/tunnel.js.map +1 -1
  134. package/tunnel.mjs +5 -0
  135. package/tunnel.mjs.map +1 -1
  136. package/version.d.mts +1 -1
  137. package/version.d.ts +1 -1
  138. package/version.js +1 -1
  139. package/version.mjs +1 -1
  140. package/sandbox-client.d.mts +0 -129
  141. package/sandbox-client.d.mts.map +0 -1
  142. package/sandbox-client.d.ts +0 -129
  143. package/sandbox-client.d.ts.map +0 -1
  144. package/sandbox-client.js +0 -158
  145. package/sandbox-client.js.map +0 -1
  146. package/sandbox-client.mjs +0 -154
  147. package/sandbox-client.mjs.map +0 -1
  148. package/src/sandbox-client.ts +0 -277
@@ -1,9 +1,14 @@
1
1
  import { WebSocket, Data } from 'ws';
2
2
  import { exec } from 'node:child_process';
3
3
 
4
+ import { downloadFileToLocalPath } from './internal/download-file';
5
+ import { nodeProxyTransport } from './internal/proxy-transport';
4
6
  import { startTcpTunnel, isNonRetryableError } from './tunnel';
5
7
  import type { Tunnel } from './tunnel';
6
8
 
9
+ const ANDROID_RECORDING_PATH = '/data/local/tmp/recordings/video_recording.mp4';
10
+ const ANDROID_SIGNALING_PATH = '/ws';
11
+
7
12
  /**
8
13
  * Connection state of the instance client
9
14
  */
@@ -14,6 +19,19 @@ export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'rec
14
19
  */
15
20
  export type ConnectionStateCallback = (state: ConnectionState) => void;
16
21
 
22
+ function deriveEndpointWebSocketUrl(apiUrl: string): string {
23
+ const parsed = new URL(apiUrl);
24
+ parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
25
+ parsed.search = '';
26
+ parsed.hash = '';
27
+ parsed.pathname = `${parsed.pathname.replace(/\/$/, '')}${ANDROID_SIGNALING_PATH}`;
28
+ return parsed.toString().replace(/\/$/, '');
29
+ }
30
+
31
+ function buildDownloadUrl(apiUrl: string): string {
32
+ return `${apiUrl}/files?path=${encodeURIComponent(ANDROID_RECORDING_PATH)}`;
33
+ }
34
+
17
35
  /**
18
36
  * A client for interacting with a Limbar instance
19
37
  */
@@ -64,6 +82,19 @@ export type InstanceClient = {
64
82
  * Open a URL/deeplink on Android.
65
83
  */
66
84
  openUrl: (url: string) => Promise<OpenUrlResult>;
85
+ /**
86
+ * Start recording device video. Use stopRecording() to finish the recording.
87
+ * When provided, `quality` must be one of `5`, `6`, `7`, `8`, `9`, or `10`.
88
+ * The server default is `5`.
89
+ */
90
+ startRecording: (options?: { quality?: RecordingQuality }) => Promise<void>;
91
+ /**
92
+ * Stop the active server-side recording.
93
+ * If `saveTo.presignedUrl` is provided, the server uploads the completed file there before resolving.
94
+ * If `saveTo.localPath` is provided, the client downloads the completed file to that path.
95
+ * Returns a download URL for the completed recording.
96
+ */
97
+ stopRecording: (saveTo: { presignedUrl?: string; localPath?: string }) => Promise<string>;
67
98
  /**
68
99
  * Disconnect from the Limbar instance
69
100
  */
@@ -99,6 +130,14 @@ export type InstanceClient = {
99
130
  export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
100
131
 
101
132
  export type ScrollDirection = 'up' | 'down' | 'left' | 'right';
133
+ export enum RecordingQuality {
134
+ Q5 = 5,
135
+ Q6 = 6,
136
+ Q7 = 7,
137
+ Q8 = 8,
138
+ Q9 = 9,
139
+ Q10 = 10,
140
+ }
102
141
 
103
142
  export type AndroidSelector = {
104
143
  resourceId?: string;
@@ -151,13 +190,14 @@ export type AndroidElementNode = {
151
190
  */
152
191
  export type InstanceClientOptions = {
153
192
  /**
154
- * The URL of the ADB WebSocket endpoint.
193
+ * HTTP base URL for the Android daemon. WebSocket control is derived from it
194
+ * using the `/ws` path, and recording downloads use the same base URL.
155
195
  */
156
- adbUrl: string;
196
+ apiUrl: string;
157
197
  /**
158
- * The URL of the main endpoint WebSocket.
198
+ * The URL of the ADB WebSocket endpoint.
159
199
  */
160
- endpointUrl: string;
200
+ adbUrl?: string;
161
201
  /**
162
202
  * The token to use for the WebSocket connections.
163
203
  */
@@ -234,6 +274,8 @@ export type OpenUrlResult = {
234
274
  url: string;
235
275
  };
236
276
 
277
+ type EmptyCommandResult = Record<string, never>;
278
+
237
279
  type ScreenshotErrorResponse = {
238
280
  type: 'screenshotError';
239
281
  message: string;
@@ -321,6 +363,20 @@ type OpenUrlResultMessage = {
321
363
  error?: CommandError;
322
364
  };
323
365
 
366
+ type StartVideoRecordingResultMessage = {
367
+ type: 'startRecordingResult';
368
+ id: string;
369
+ payload?: EmptyCommandResult;
370
+ error?: CommandError;
371
+ };
372
+
373
+ type StopVideoRecordingResultMessage = {
374
+ type: 'stopRecordingResult';
375
+ id: string;
376
+ payload?: EmptyCommandResult;
377
+ error?: CommandError;
378
+ };
379
+
324
380
  type KnownCommandResultMessage =
325
381
  | ScreenshotResultMessage
326
382
  | GetElementTreeResultMessage
@@ -330,7 +386,9 @@ type KnownCommandResultMessage =
330
386
  | PressKeyResultMessage
331
387
  | ScrollScreenResultMessage
332
388
  | ScrollElementResultMessage
333
- | OpenUrlResultMessage;
389
+ | OpenUrlResultMessage
390
+ | StartVideoRecordingResultMessage
391
+ | StopVideoRecordingResultMessage;
334
392
 
335
393
  type ServerMessage =
336
394
  | ScreenshotResponse
@@ -349,6 +407,8 @@ type CommandRequestMap = {
349
407
  scrollScreen: { direction: ScrollDirection; amount?: number };
350
408
  scrollElement: AndroidElementTarget & { direction: ScrollDirection; amount?: number };
351
409
  openUrl: { url: string };
410
+ startRecording: { quality?: RecordingQuality };
411
+ stopRecording: { upload?: { presignedUrl: string } };
352
412
  };
353
413
 
354
414
  type CommandResultMap = {
@@ -361,6 +421,8 @@ type CommandResultMap = {
361
421
  scrollScreen: ScrollResult;
362
422
  scrollElement: ScrollResult;
363
423
  openUrl: OpenUrlResult;
424
+ startRecording: EmptyCommandResult;
425
+ stopRecording: EmptyCommandResult;
364
426
  };
365
427
 
366
428
  type PendingRequest<T> = {
@@ -375,7 +437,9 @@ type PendingRequest<T> = {
375
437
  * @returns An InstanceClient for controlling the instance
376
438
  */
377
439
  export async function createInstanceClient(options: InstanceClientOptions): Promise<InstanceClient> {
378
- const serverAddress = `${options.endpointUrl}?token=${options.token}`;
440
+ const endpointWebSocketUrl = deriveEndpointWebSocketUrl(options.apiUrl);
441
+ const serverAddress = `${endpointWebSocketUrl}?token=${options.token}`;
442
+ const recordingApiUrl = options.apiUrl;
379
443
  const logLevel = options.logLevel ?? 'info';
380
444
  const maxReconnectAttempts = options.maxReconnectAttempts ?? 6;
381
445
  const reconnectDelay = options.reconnectDelay ?? 1000;
@@ -387,7 +451,6 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
387
451
  let reconnectTimeout: NodeJS.Timeout | undefined;
388
452
  let intentionalDisconnect = false;
389
453
  let lastError: string | undefined;
390
-
391
454
  const pendingRequests: Map<string, PendingRequest<unknown>> = new Map();
392
455
  const pendingAssetRequestsByUrl: Map<string, Array<PendingRequest<void>>> = new Map();
393
456
 
@@ -521,6 +584,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
521
584
  case 'scrollScreenResult':
522
585
  case 'scrollElementResult':
523
586
  case 'openUrlResult':
587
+ case 'startRecordingResult':
588
+ case 'stopRecordingResult':
524
589
  return 'id' in message && typeof message.id === 'string';
525
590
  default:
526
591
  return false;
@@ -597,7 +662,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
597
662
  cleanup();
598
663
  updateConnectionState('connecting');
599
664
 
600
- ws = new WebSocket(serverAddress);
665
+ const proxyAgent = nodeProxyTransport.getWebSocketAgent(serverAddress);
666
+ ws = new WebSocket(serverAddress, proxyAgent ? { agent: proxyAgent } : {});
601
667
 
602
668
  ws.on('message', (data: Data) => {
603
669
  let message: ServerMessage;
@@ -757,6 +823,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
757
823
  scrollScreen,
758
824
  scrollElement,
759
825
  openUrl,
826
+ startRecording,
827
+ stopRecording,
760
828
  disconnect,
761
829
  startAdbTunnel,
762
830
  sendAsset,
@@ -846,6 +914,34 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
846
914
  };
847
915
  };
848
916
 
917
+ const startRecording = async (recordingOptions?: { quality?: RecordingQuality }): Promise<void> => {
918
+ const request: CommandRequestMap['startRecording'] = {};
919
+ if (recordingOptions?.quality !== undefined) {
920
+ if (
921
+ !Number.isInteger(recordingOptions.quality) ||
922
+ recordingOptions.quality < 5 ||
923
+ recordingOptions.quality > 10
924
+ ) {
925
+ throw new Error('quality must be one of: 5, 6, 7, 8, 9, 10');
926
+ }
927
+ request.quality = recordingOptions.quality;
928
+ }
929
+ await sendRequest('startRecording', request);
930
+ };
931
+
932
+ const stopRecording = async (saveTo: { presignedUrl?: string; localPath?: string }): Promise<string> => {
933
+ const request: CommandRequestMap['stopRecording'] = {};
934
+ if (saveTo.presignedUrl) {
935
+ request.upload = { presignedUrl: saveTo.presignedUrl };
936
+ }
937
+ await sendRequest('stopRecording', request);
938
+ const downloadUrl = buildDownloadUrl(recordingApiUrl);
939
+ if (saveTo.localPath) {
940
+ await downloadFileToLocalPath(downloadUrl, options.token, saveTo.localPath);
941
+ }
942
+ return downloadUrl;
943
+ };
944
+
849
945
  const disconnect = (): void => {
850
946
  intentionalDisconnect = true;
851
947
  cleanup();
@@ -870,6 +966,9 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
870
966
  * client to it.
871
967
  */
872
968
  const startAdbTunnel = async (): Promise<Tunnel> => {
969
+ if (!options.adbUrl) {
970
+ throw new Error('adbUrl is required to start an ADB tunnel.');
971
+ }
873
972
  const tunnel = await startTcpTunnel(options.adbUrl, options.token, '127.0.0.1', 0, {
874
973
  maxReconnectAttempts,
875
974
  reconnectDelay,
@@ -0,0 +1,33 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { Readable } from 'stream';
4
+ import { pipeline } from 'stream/promises';
5
+
6
+ import { nodeProxyTransport } from './proxy-transport';
7
+
8
+ export async function downloadFileToLocalPath(url: string, token: string, localPath: string): Promise<void> {
9
+ const maxRetries = 3;
10
+
11
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
12
+ const response = await nodeProxyTransport.fetch(url, {
13
+ method: 'GET',
14
+ headers: {
15
+ Authorization: `Bearer ${token}`,
16
+ },
17
+ });
18
+ if (!response.ok) {
19
+ const errorBody = await response.text();
20
+ const isRetriable = response.status >= 500 && response.status < 600;
21
+ if (isRetriable && attempt < maxRetries) {
22
+ continue;
23
+ }
24
+ throw new Error(`Download failed: ${response.status} ${errorBody}`);
25
+ }
26
+ if (!response.body) {
27
+ throw new Error('Download failed: response body is missing');
28
+ }
29
+ await fs.promises.mkdir(path.dirname(localPath), { recursive: true });
30
+ await pipeline(Readable.fromWeb(response.body as any), fs.createWriteStream(localPath));
31
+ return;
32
+ }
33
+ }
@@ -0,0 +1,69 @@
1
+ import type { Agent as HttpAgent } from 'http';
2
+
3
+ import { HttpsProxyAgent } from 'https-proxy-agent';
4
+ import { getProxyForUrl } from 'proxy-from-env';
5
+ import { EnvHttpProxyAgent } from 'undici';
6
+
7
+ import type { Fetch } from './builtin-types';
8
+
9
+ class NodeProxyTransport {
10
+ private envHttpProxyAgent: EnvHttpProxyAgent | undefined;
11
+ private websocketAgents = new Map<string, HttpAgent>();
12
+
13
+ fetch: Fetch = async (input, init) => {
14
+ if (!this.hasProxyEnv()) {
15
+ return fetch(input, init);
16
+ }
17
+
18
+ return (fetch as any)(input, {
19
+ ...(init ?? {}),
20
+ dispatcher: this.getEnvHttpProxyAgent(),
21
+ });
22
+ };
23
+
24
+ getWebSocketAgent(url: string): HttpAgent | undefined {
25
+ if (!this.hasProxyEnv()) {
26
+ return undefined;
27
+ }
28
+
29
+ const proxyUrl = getProxyForUrl(this.getWebSocketProxyLookupUrl(url));
30
+ if (!proxyUrl) {
31
+ return undefined;
32
+ }
33
+
34
+ let agent = this.websocketAgents.get(proxyUrl);
35
+ if (!agent) {
36
+ const createdAgent = new HttpsProxyAgent(proxyUrl);
37
+ this.websocketAgents.set(proxyUrl, createdAgent);
38
+ agent = createdAgent;
39
+ }
40
+
41
+ return agent;
42
+ }
43
+
44
+ private getEnvHttpProxyAgent(): EnvHttpProxyAgent {
45
+ this.envHttpProxyAgent ??= new EnvHttpProxyAgent();
46
+ return this.envHttpProxyAgent;
47
+ }
48
+
49
+ private hasProxyEnv(): boolean {
50
+ if (typeof process === 'undefined' || !process.versions?.node) {
51
+ return false;
52
+ }
53
+
54
+ const env = process.env;
55
+ return !!(env['http_proxy'] || env['HTTP_PROXY'] || env['https_proxy'] || env['HTTPS_PROXY']);
56
+ }
57
+
58
+ private getWebSocketProxyLookupUrl(url: string): string {
59
+ const lookupUrl = new URL(url);
60
+ if (lookupUrl.protocol === 'ws:') {
61
+ lookupUrl.protocol = 'http:';
62
+ } else if (lookupUrl.protocol === 'wss:') {
63
+ lookupUrl.protocol = 'https:';
64
+ }
65
+ return lookupUrl.toString();
66
+ }
67
+ }
68
+
69
+ export const nodeProxyTransport = new NodeProxyTransport();
@@ -8,11 +8,12 @@
8
8
  */
9
9
 
10
10
  import type { Fetch } from './builtin-types';
11
+ import { nodeProxyTransport } from './proxy-transport';
11
12
  import type { ReadableStream } from './shim-types';
12
13
 
13
14
  export function getDefaultFetch(): Fetch {
14
15
  if (typeof fetch !== 'undefined') {
15
- return fetch as any;
16
+ return nodeProxyTransport.fetch;
16
17
  }
17
18
 
18
19
  throw new Error(
@@ -9,10 +9,10 @@
9
9
  */
10
10
  export const readEnv = (env: string): string | undefined => {
11
11
  if (typeof (globalThis as any).process !== 'undefined') {
12
- return (globalThis as any).process.env?.[env]?.trim() ?? undefined;
12
+ return (globalThis as any).process.env?.[env]?.trim() || undefined;
13
13
  }
14
14
  if (typeof (globalThis as any).Deno !== 'undefined') {
15
- return (globalThis as any).Deno.env?.get?.(env)?.trim();
15
+ return (globalThis as any).Deno.env?.get?.(env)?.trim() || undefined;
16
16
  }
17
17
  return undefined;
18
18
  };
package/src/ios-client.ts CHANGED
@@ -1,14 +1,14 @@
1
- import { WebSocket, Data } from 'ws';
2
- import fs from 'fs';
3
1
  import os from 'os';
4
- import path from 'path';
5
2
  import crypto from 'crypto';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { WebSocket, Data } from 'ws';
6
6
  import { EventEmitter } from 'events';
7
- import { Readable } from 'stream';
8
- import { pipeline } from 'stream/promises';
9
7
  import { isNonRetryableError } from './tunnel';
10
8
  import { type SyncFolderResult, type FolderSyncOptions, syncFolder } from './folder-sync';
11
9
  import { createIgnoreFn } from './folder-sync-ignore';
10
+ import { downloadFileToLocalPath } from './internal/download-file';
11
+ import { nodeProxyTransport } from './internal/proxy-transport';
12
12
 
13
13
  /**
14
14
  * Connection state of the instance client
@@ -20,44 +20,10 @@ export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'rec
20
20
  */
21
21
  export type ConnectionStateCallback = (state: ConnectionState) => void;
22
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(/\..+/, '');
23
+ const ACTIVE_RECORDING_FILENAME = 'recording.mp4';
27
24
 
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)}`;
25
+ function buildDownloadUrl(apiUrl: string): string {
26
+ return `${apiUrl}/files?name=${encodeURIComponent(ACTIVE_RECORDING_FILENAME)}`;
61
27
  }
62
28
 
63
29
  /**
@@ -343,8 +309,10 @@ export type InstanceClient = {
343
309
 
344
310
  /**
345
311
  * Start recording simulator video. Use stopRecording() to stop the recording.
312
+ * When provided, `quality` must be one of `5`, `6`, `7`, `8`, `9`, or `10`.
313
+ * The server default is `5`.
346
314
  */
347
- startRecording: () => Promise<void>;
315
+ startRecording: (options?: { quality?: RecordingQuality }) => Promise<void>;
348
316
 
349
317
  /**
350
318
  * Stop the active recording for this client instance.
@@ -512,6 +480,15 @@ export type InstanceClient = {
512
480
  */
513
481
  export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
514
482
 
483
+ export enum RecordingQuality {
484
+ Q5 = 5,
485
+ Q6 = 6,
486
+ Q7 = 7,
487
+ Q8 = 8,
488
+ Q9 = 9,
489
+ Q10 = 10,
490
+ }
491
+
515
492
  /**
516
493
  * Configuration options for creating an iOS client
517
494
  */
@@ -849,7 +826,8 @@ export class LogStream extends EventEmitter {
849
826
 
850
827
  /** @internal - Establish the dedicated WebSocket connection */
851
828
  private _connect(): void {
852
- this.ws = new WebSocket(this.wsUrl);
829
+ const proxyAgent = nodeProxyTransport.getWebSocketAgent(this.wsUrl);
830
+ this.ws = new WebSocket(this.wsUrl, proxyAgent ? { agent: proxyAgent } : {});
853
831
 
854
832
  this.ws.on('open', () => {
855
833
  if (this.stopped) {
@@ -920,7 +898,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
920
898
  let reconnectTimeout: NodeJS.Timeout | undefined;
921
899
  let intentionalDisconnect = false;
922
900
  let lastError: string | undefined;
923
- let activeRecordingFilename: string | undefined;
901
+ let hasActiveRecording = false;
924
902
 
925
903
  // Centralized pending requests map - handles all request/response patterns
926
904
  const pendingRequests: Map<string, PendingRequest<any>> = new Map();
@@ -1100,9 +1078,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1100
1078
  bundleId: msg.bundleId || '',
1101
1079
  }),
1102
1080
  startVideoRecordingResult: () => undefined,
1103
- stopVideoRecordingResult: (msg) => ({
1104
- filename: msg.filename || '',
1105
- }),
1081
+ stopVideoRecordingResult: () => undefined,
1106
1082
  setOrientationResult: () => undefined,
1107
1083
  scrollResult: () => undefined,
1108
1084
  xcrunResult: (msg): CommandResult => ({
@@ -1121,7 +1097,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1121
1097
  cleanup();
1122
1098
  updateConnectionState('connecting');
1123
1099
 
1124
- ws = new WebSocket(endpointWebSocketUrl);
1100
+ const proxyAgent = nodeProxyTransport.getWebSocketAgent(endpointWebSocketUrl);
1101
+ ws = new WebSocket(endpointWebSocketUrl, proxyAgent ? { agent: proxyAgent } : {});
1125
1102
 
1126
1103
  ws.on('message', (data: Data) => {
1127
1104
  let message: ServerResponse;
@@ -1418,41 +1395,42 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1418
1395
  });
1419
1396
  };
1420
1397
 
1421
- const startRecording = async (): Promise<void> => {
1422
- if (activeRecordingFilename) {
1423
- throw new Error(`A recording is already active for this client: ${activeRecordingFilename}`);
1398
+ const startRecording = async (opts?: { quality?: RecordingQuality }): Promise<void> => {
1399
+ if (hasActiveRecording) {
1400
+ throw new Error('A recording is already active for this client');
1424
1401
  }
1425
- const finalFilename = generateRecordingFilename();
1426
- activeRecordingFilename = finalFilename;
1402
+ hasActiveRecording = true;
1427
1403
  try {
1428
- await sendRequest<void>('startVideoRecording', { filename: finalFilename });
1429
- } catch (error) {
1430
- if (activeRecordingFilename === finalFilename) {
1431
- activeRecordingFilename = undefined;
1404
+ const request: { quality?: RecordingQuality } = {};
1405
+ if (opts?.quality !== undefined) {
1406
+ if (!Number.isInteger(opts.quality) || opts.quality < 5 || opts.quality > 10) {
1407
+ throw new Error('quality must be one of: 5, 6, 7, 8, 9, 10');
1408
+ }
1409
+ request.quality = opts.quality;
1432
1410
  }
1411
+ await sendRequest<void>('startVideoRecording', request);
1412
+ } catch (error) {
1413
+ hasActiveRecording = false;
1433
1414
  throw error;
1434
1415
  }
1435
1416
  };
1436
1417
 
1437
1418
  const stopRecording = async (saveTo: { presignedUrl?: string; localPath?: string }): Promise<string> => {
1438
- const filename = activeRecordingFilename;
1439
- if (!filename) {
1419
+ if (!hasActiveRecording) {
1440
1420
  throw new Error('No active recording for this client. Call startRecording() first.');
1441
1421
  }
1442
- const result = await sendRequest<{ filename: string }>('stopVideoRecording', {
1443
- filename,
1422
+ await sendRequest<void>('stopVideoRecording', {
1444
1423
  upload: saveTo.presignedUrl ? { presignedUrl: saveTo.presignedUrl } : undefined,
1445
1424
  });
1446
- const finalFilename = result.filename || filename;
1447
- const downloadUrl = buildDownloadUrl(options.apiUrl, finalFilename);
1425
+ const downloadUrl = buildDownloadUrl(options.apiUrl);
1448
1426
  if (saveTo.localPath) {
1449
1427
  try {
1450
1428
  await downloadFileToLocalPath(downloadUrl, options.token, saveTo.localPath);
1451
1429
  } finally {
1452
- activeRecordingFilename = undefined;
1430
+ hasActiveRecording = false;
1453
1431
  }
1454
1432
  } else {
1455
- activeRecordingFilename = undefined;
1433
+ hasActiveRecording = false;
1456
1434
  }
1457
1435
  return downloadUrl;
1458
1436
  };
@@ -1594,7 +1572,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1594
1572
  try {
1595
1573
  // Node's fetch (undici) supports streaming request bodies but TS DOM types may not include
1596
1574
  // `duplex` and may not accept Node ReadStreams as BodyInit in some configs.
1597
- const response = await fetch(uploadUrl, {
1575
+ const response = await nodeProxyTransport.fetch(uploadUrl, {
1598
1576
  method: 'PUT',
1599
1577
  headers: {
1600
1578
  'Content-Type': 'application/octet-stream',
@@ -72,8 +72,8 @@ export namespace AndroidInstance {
72
72
  export interface Spec {
73
73
  /**
74
74
  * After how many minutes of inactivity should the instance be terminated. Example
75
- * values 1m, 10m, 3h. Default is 3m. Providing "0" disables inactivity checks
76
- * altogether.
75
+ * values 1m, 10m, 3h. Default is 3m. Providing "0" uses the organization's default
76
+ * inactivity timeout.
77
77
  */
78
78
  inactivityTimeout: string;
79
79
 
@@ -97,6 +97,8 @@ export namespace AndroidInstance {
97
97
 
98
98
  adbWebSocketUrl?: string;
99
99
 
100
+ apiUrl?: string;
101
+
100
102
  endpointWebSocketUrl?: string;
101
103
 
102
104
  errorMessage?: string;
@@ -162,8 +164,8 @@ export namespace AndroidInstanceCreateParams {
162
164
 
163
165
  /**
164
166
  * After how many minutes of inactivity should the instance be terminated. Example
165
- * values 1m, 10m, 3h. Default is 3m. Providing "0" disables inactivity checks
166
- * altogether.
167
+ * values 1m, 10m, 3h. Default is 3m. Providing "0" uses the organization's default
168
+ * inactivity timeout.
167
169
  */
168
170
  inactivityTimeout?: string;
169
171
 
@@ -3,6 +3,7 @@ import { createHash } from 'crypto';
3
3
  import { promises as fs } from 'fs';
4
4
 
5
5
  import { RequestOptions } from '../internal/request-options';
6
+ import { nodeProxyTransport } from '../internal/proxy-transport';
6
7
  import { Assets as GeneratedAssets } from './assets';
7
8
 
8
9
  export interface AssetGetOrUploadParams {
@@ -45,7 +46,7 @@ export class Assets extends GeneratedAssets {
45
46
  md5: creationResponse.md5,
46
47
  };
47
48
  }
48
- const uploadResponse = await fetch(creationResponse.signedUploadUrl, {
49
+ const uploadResponse = await nodeProxyTransport.fetch(creationResponse.signedUploadUrl, {
49
50
  headers: {
50
51
  'Content-Length': data.length.toString(),
51
52
  'Content-Type': 'application/octet-stream',
@@ -22,5 +22,18 @@ export {
22
22
  type IosInstanceListParams,
23
23
  type IosInstancesItems,
24
24
  } from './ios-instances';
25
+ export {
26
+ type XcodeInstance,
27
+ type XcodeInstanceCreateParams,
28
+ type XcodeInstanceListParams,
29
+ type XcodeInstancesItems,
30
+ } from './xcode-instances';
25
31
 
26
32
  export { Assets, AssetGetOrUploadParams, AssetGetOrUploadResponse } from './assets-helpers';
33
+ export {
34
+ XcodeInstances,
35
+ type XcodeCreateClientParams,
36
+ type XcodeClient,
37
+ type XcodeProjectConfig,
38
+ type XcodeBuildOptions,
39
+ } from './xcode-instances-helpers';
@@ -72,8 +72,8 @@ export namespace IosInstance {
72
72
  export interface Spec {
73
73
  /**
74
74
  * After how many minutes of inactivity should the instance be terminated. Example
75
- * values 1m, 10m, 3h. Default is 3m. Providing "0" disables inactivity checks
76
- * altogether.
75
+ * values 1m, 10m, 3h. Default is 3m. Providing "0" uses the organization's default
76
+ * inactivity timeout.
77
77
  */
78
78
  inactivityTimeout: string;
79
79
 
@@ -162,8 +162,8 @@ export namespace IosInstanceCreateParams {
162
162
 
163
163
  /**
164
164
  * After how many minutes of inactivity should the instance be terminated. Example
165
- * values 1m, 10m, 3h. Default is 3m. Providing "0" disables inactivity checks
166
- * altogether.
165
+ * values 1m, 10m, 3h. Default is 3m. Providing "0" uses the organization's default
166
+ * inactivity timeout.
167
167
  */
168
168
  inactivityTimeout?: string;
169
169