@limrun/api 0.16.1 → 0.17.0-rc.2
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 +0 -17
- package/client.d.mts +1 -1
- package/client.d.mts.map +1 -1
- package/client.d.ts +1 -1
- package/client.d.ts.map +1 -1
- package/index.d.mts +1 -0
- package/index.d.mts.map +1 -1
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +2 -1
- package/index.js.map +1 -1
- package/index.mjs +1 -0
- package/index.mjs.map +1 -1
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +8 -24
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +9 -25
- package/instance-client.mjs.map +1 -1
- package/internal/tslib.js +4 -0
- package/ios-client.d.mts +207 -0
- package/ios-client.d.mts.map +1 -0
- package/ios-client.d.ts +207 -0
- package/ios-client.d.ts.map +1 -0
- package/ios-client.js +521 -0
- package/ios-client.js.map +1 -0
- package/ios-client.mjs +515 -0
- package/ios-client.mjs.map +1 -0
- package/package.json +11 -1
- package/src/client.ts +1 -1
- package/src/index.ts +1 -0
- package/src/instance-client.ts +9 -30
- package/src/ios-client.ts +788 -0
- package/src/tunnel.ts +6 -48
- package/src/version.ts +1 -1
- package/tunnel.d.mts +0 -1
- package/tunnel.d.mts.map +1 -1
- package/tunnel.d.ts +0 -1
- package/tunnel.d.ts.map +1 -1
- package/tunnel.js +6 -44
- package/tunnel.js.map +1 -1
- package/tunnel.mjs +6 -42
- package/tunnel.mjs.map +1 -1
- package/version.d.mts +1 -1
- package/version.d.mts.map +1 -1
- package/version.d.ts +1 -1
- package/version.d.ts.map +1 -1
- package/version.js +1 -1
- package/version.js.map +1 -1
- package/version.mjs +1 -1
- package/version.mjs.map +1 -1
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
import { WebSocket, Data } from 'ws';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Connection state of the instance client
|
|
7
|
+
*/
|
|
8
|
+
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Callback function for connection state changes
|
|
12
|
+
*/
|
|
13
|
+
export type ConnectionStateCallback = (state: ConnectionState) => void;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Events emitted by a simctl execution
|
|
17
|
+
*/
|
|
18
|
+
export interface SimctlExecutionEvents {
|
|
19
|
+
stdout: (data: Buffer) => void;
|
|
20
|
+
stderr: (data: Buffer) => void;
|
|
21
|
+
'line-stdout': (line: string) => void;
|
|
22
|
+
'line-stderr': (line: string) => void;
|
|
23
|
+
exit: (code: number) => void;
|
|
24
|
+
error: (error: Error) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A client for interacting with a Limrun iOS instance
|
|
29
|
+
*/
|
|
30
|
+
export type InstanceClient = {
|
|
31
|
+
/**
|
|
32
|
+
* Take a screenshot of the current screen
|
|
33
|
+
* @returns A promise that resolves to the screenshot data
|
|
34
|
+
*/
|
|
35
|
+
screenshot: () => Promise<ScreenshotData>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Disconnect from the Limrun instance
|
|
39
|
+
*/
|
|
40
|
+
disconnect: () => void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get current connection state
|
|
44
|
+
*/
|
|
45
|
+
getConnectionState: () => ConnectionState;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register callback for connection state changes
|
|
49
|
+
* @returns A function to unregister the callback
|
|
50
|
+
*/
|
|
51
|
+
onConnectionStateChange: (callback: ConnectionStateCallback) => () => void;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run `simctl` command targeting the instance with given arguments.
|
|
55
|
+
* Returns an EventEmitter that streams stdout, stderr, and exit events.
|
|
56
|
+
*
|
|
57
|
+
* @param args Arguments to pass to simctl
|
|
58
|
+
* @returns A SimctlExecution handle for listening to command output
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const execution = client.simctl(['boot', 'device-id']);
|
|
63
|
+
*
|
|
64
|
+
* // Listen to raw data
|
|
65
|
+
* execution.on('stdout', (data) => {
|
|
66
|
+
* console.log('stdout:', data.toString());
|
|
67
|
+
* });
|
|
68
|
+
*
|
|
69
|
+
* // Or listen line-by-line
|
|
70
|
+
* execution.on('line-stdout', (line) => {
|
|
71
|
+
* console.log('Line:', line);
|
|
72
|
+
* });
|
|
73
|
+
*
|
|
74
|
+
* execution.on('line-stderr', (line) => {
|
|
75
|
+
* console.error('Error:', line);
|
|
76
|
+
* });
|
|
77
|
+
*
|
|
78
|
+
* execution.on('exit', (code) => {
|
|
79
|
+
* console.log('Process exited with code:', code);
|
|
80
|
+
* });
|
|
81
|
+
*
|
|
82
|
+
* // Or wait for completion
|
|
83
|
+
* const result = await execution.wait();
|
|
84
|
+
* console.log('Exit code:', result.code);
|
|
85
|
+
* console.log('Full stdout:', result.stdout.toString());
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
simctl: (args: string[]) => SimctlExecution;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Copy a file to the sandbox of the simulator. Returns the path of the file that can be used in simctl commands.
|
|
92
|
+
* @param name The name of the file in the sandbox of the simulator.
|
|
93
|
+
* @param path The path of the file to copy to the sandbox of the simulator.
|
|
94
|
+
* @returns A promise that resolves to the path of the file that can be used in simctl commands.
|
|
95
|
+
*/
|
|
96
|
+
cp: (name: string, path: string) => Promise<string>;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Controls the verbosity of logging in the client
|
|
101
|
+
*/
|
|
102
|
+
export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Configuration options for creating an iOS client
|
|
106
|
+
*/
|
|
107
|
+
export type InstanceClientOptions = {
|
|
108
|
+
/**
|
|
109
|
+
* The API URL for the instance.
|
|
110
|
+
*/
|
|
111
|
+
apiUrl: string;
|
|
112
|
+
/**
|
|
113
|
+
* The token to use for authentication.
|
|
114
|
+
*/
|
|
115
|
+
token: string;
|
|
116
|
+
/**
|
|
117
|
+
* Controls logging verbosity
|
|
118
|
+
* @default 'info'
|
|
119
|
+
*/
|
|
120
|
+
logLevel?: LogLevel;
|
|
121
|
+
/**
|
|
122
|
+
* Maximum number of reconnection attempts
|
|
123
|
+
* @default 6
|
|
124
|
+
*/
|
|
125
|
+
maxReconnectAttempts?: number;
|
|
126
|
+
/**
|
|
127
|
+
* Initial reconnection delay in milliseconds
|
|
128
|
+
* @default 1000
|
|
129
|
+
*/
|
|
130
|
+
reconnectDelay?: number;
|
|
131
|
+
/**
|
|
132
|
+
* Maximum reconnection delay in milliseconds
|
|
133
|
+
* @default 30000
|
|
134
|
+
*/
|
|
135
|
+
maxReconnectDelay?: number;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
type ScreenshotRequest = {
|
|
139
|
+
type: 'screenshot';
|
|
140
|
+
id: string;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
type ScreenshotResponse = {
|
|
144
|
+
type: 'screenshot';
|
|
145
|
+
dataUri: string;
|
|
146
|
+
id: string;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
type ScreenshotData = {
|
|
150
|
+
dataUri: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
type ScreenshotErrorResponse = {
|
|
154
|
+
type: 'screenshotError';
|
|
155
|
+
message: string;
|
|
156
|
+
id: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
type SimctlRequest = {
|
|
160
|
+
type: 'simctl';
|
|
161
|
+
id: string;
|
|
162
|
+
args: string[];
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
type SimctlStreamResponse = {
|
|
166
|
+
type: 'simctlStream';
|
|
167
|
+
id: string;
|
|
168
|
+
stdout?: string; // base64 encoded
|
|
169
|
+
stderr?: string; // base64 encoded
|
|
170
|
+
exitCode?: number;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
type SimctlErrorResponse = {
|
|
174
|
+
type: 'simctlError';
|
|
175
|
+
id: string;
|
|
176
|
+
message: string;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
type ServerMessage =
|
|
180
|
+
| ScreenshotResponse
|
|
181
|
+
| ScreenshotErrorResponse
|
|
182
|
+
| SimctlStreamResponse
|
|
183
|
+
| SimctlErrorResponse
|
|
184
|
+
| { type: string; [key: string]: unknown };
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Handle for a running simctl command execution.
|
|
188
|
+
*
|
|
189
|
+
* This class extends EventEmitter and provides streaming access to command output.
|
|
190
|
+
* Methods starting with underscore (_) are internal and should not be called directly.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```typescript
|
|
194
|
+
* const execution = client.simctl(['boot', 'device-id']);
|
|
195
|
+
*
|
|
196
|
+
* // Listen to raw output
|
|
197
|
+
* execution.on('stdout', (data) => console.log(data.toString()));
|
|
198
|
+
*
|
|
199
|
+
* // Or listen line-by-line (more convenient for most use cases)
|
|
200
|
+
* execution.on('line-stdout', (line) => console.log('Output:', line));
|
|
201
|
+
* execution.on('line-stderr', (line) => console.error('Error:', line));
|
|
202
|
+
*
|
|
203
|
+
* execution.on('exit', (code) => console.log('Exit code:', code));
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
export class SimctlExecution extends EventEmitter {
|
|
207
|
+
private stdoutChunks: Buffer[] = [];
|
|
208
|
+
private stderrChunks: Buffer[] = [];
|
|
209
|
+
private stdoutLineBuffer = '';
|
|
210
|
+
private stderrLineBuffer = '';
|
|
211
|
+
private exitCodeValue: number | null = null;
|
|
212
|
+
private completed = false;
|
|
213
|
+
private waitPromise: Promise<{ code: number; stdout: Buffer; stderr: Buffer }> | null = null;
|
|
214
|
+
private stopCallback: (() => void) | null = null;
|
|
215
|
+
|
|
216
|
+
public get isRunning(): boolean {
|
|
217
|
+
return !this.completed;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
constructor(stopCallback: () => void) {
|
|
221
|
+
super();
|
|
222
|
+
this.stopCallback = stopCallback;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Register an event listener for stdout, stderr, line-stdout, line-stderr, exit, or error events.
|
|
227
|
+
* @param event The event name
|
|
228
|
+
* @param listener The callback function for this event
|
|
229
|
+
*/
|
|
230
|
+
override on<E extends keyof SimctlExecutionEvents>(event: E, listener: SimctlExecutionEvents[E]): this {
|
|
231
|
+
return super.on(event, listener as any);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Register a one-time event listener that will be removed after firing once.
|
|
236
|
+
* @param event The event name
|
|
237
|
+
* @param listener The callback function for this event
|
|
238
|
+
*/
|
|
239
|
+
override once<E extends keyof SimctlExecutionEvents>(event: E, listener: SimctlExecutionEvents[E]): this {
|
|
240
|
+
return super.once(event, listener as any);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Remove an event listener.
|
|
245
|
+
* @param event The event name
|
|
246
|
+
* @param listener The callback function to remove
|
|
247
|
+
*/
|
|
248
|
+
override off<E extends keyof SimctlExecutionEvents>(event: E, listener: SimctlExecutionEvents[E]): this {
|
|
249
|
+
return super.off(event, listener as any);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Wait for the command to complete and get the full result.
|
|
254
|
+
* This accumulates all stdout/stderr chunks in memory.
|
|
255
|
+
* @returns Promise that resolves with exit code and complete output
|
|
256
|
+
*/
|
|
257
|
+
wait(): Promise<{ code: number; stdout: Buffer; stderr: Buffer }> {
|
|
258
|
+
if (this.waitPromise) {
|
|
259
|
+
return this.waitPromise;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.waitPromise = new Promise((resolve, reject) => {
|
|
263
|
+
if (this.completed) {
|
|
264
|
+
resolve({
|
|
265
|
+
code: this.exitCodeValue!,
|
|
266
|
+
stdout: Buffer.concat(this.stdoutChunks),
|
|
267
|
+
stderr: Buffer.concat(this.stderrChunks),
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
this.once('exit', (code) => {
|
|
273
|
+
resolve({
|
|
274
|
+
code,
|
|
275
|
+
stdout: Buffer.concat(this.stdoutChunks),
|
|
276
|
+
stderr: Buffer.concat(this.stderrChunks),
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
this.once('error', (error) => {
|
|
281
|
+
reject(error);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
return this.waitPromise;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Stop the running simctl command (if supported by server).
|
|
290
|
+
* This cleans up the execution tracking.
|
|
291
|
+
*/
|
|
292
|
+
stop(): void {
|
|
293
|
+
if (this.stopCallback) {
|
|
294
|
+
this.stopCallback();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** @internal - Handle stdout data from server */
|
|
299
|
+
_handleStdout(data: Buffer): void {
|
|
300
|
+
this.stdoutChunks.push(data);
|
|
301
|
+
this.emit('stdout', data);
|
|
302
|
+
|
|
303
|
+
// Process line-by-line
|
|
304
|
+
this.stdoutLineBuffer += data.toString('utf-8');
|
|
305
|
+
const lines = this.stdoutLineBuffer.split('\n');
|
|
306
|
+
// Keep the last incomplete line in the buffer
|
|
307
|
+
this.stdoutLineBuffer = lines.pop() || '';
|
|
308
|
+
|
|
309
|
+
// Emit complete lines
|
|
310
|
+
for (const line of lines) {
|
|
311
|
+
this.emit('line-stdout', line);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** @internal - Handle stderr data from server */
|
|
316
|
+
_handleStderr(data: Buffer): void {
|
|
317
|
+
this.stderrChunks.push(data);
|
|
318
|
+
this.emit('stderr', data);
|
|
319
|
+
|
|
320
|
+
// Process line-by-line
|
|
321
|
+
this.stderrLineBuffer += data.toString('utf-8');
|
|
322
|
+
const lines = this.stderrLineBuffer.split('\n');
|
|
323
|
+
// Keep the last incomplete line in the buffer
|
|
324
|
+
this.stderrLineBuffer = lines.pop() || '';
|
|
325
|
+
|
|
326
|
+
// Emit complete lines
|
|
327
|
+
for (const line of lines) {
|
|
328
|
+
this.emit('line-stderr', line);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** @internal - Handle exit code from server */
|
|
333
|
+
_handleExit(code: number): void {
|
|
334
|
+
// Emit any remaining partial lines before exit
|
|
335
|
+
if (this.stdoutLineBuffer) {
|
|
336
|
+
this.emit('line-stdout', this.stdoutLineBuffer);
|
|
337
|
+
this.stdoutLineBuffer = '';
|
|
338
|
+
}
|
|
339
|
+
if (this.stderrLineBuffer) {
|
|
340
|
+
this.emit('line-stderr', this.stderrLineBuffer);
|
|
341
|
+
this.stderrLineBuffer = '';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
this.exitCodeValue = code;
|
|
345
|
+
this.completed = true;
|
|
346
|
+
this.emit('exit', code);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @internal - Handle errors from server or connection */
|
|
350
|
+
_handleError(error: Error): void {
|
|
351
|
+
this.completed = true;
|
|
352
|
+
this.emit('error', error);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Creates a client for interacting with a Limrun iOS instance
|
|
358
|
+
* @param options Configuration options including webrtcUrl, token and log level
|
|
359
|
+
* @returns An InstanceClient for controlling the instance
|
|
360
|
+
*/
|
|
361
|
+
export async function createInstanceClient(options: InstanceClientOptions): Promise<InstanceClient> {
|
|
362
|
+
const endpointWebSocketUrl = `${options.apiUrl}/signaling?token=${options.token}`;
|
|
363
|
+
const logLevel = options.logLevel ?? 'info';
|
|
364
|
+
const maxReconnectAttempts = options.maxReconnectAttempts ?? 6;
|
|
365
|
+
const reconnectDelay = options.reconnectDelay ?? 1000;
|
|
366
|
+
const maxReconnectDelay = options.maxReconnectDelay ?? 30000;
|
|
367
|
+
|
|
368
|
+
let ws: WebSocket | undefined = undefined;
|
|
369
|
+
let connectionState: ConnectionState = 'connecting';
|
|
370
|
+
let reconnectAttempts = 0;
|
|
371
|
+
let reconnectTimeout: NodeJS.Timeout | undefined;
|
|
372
|
+
let intentionalDisconnect = false;
|
|
373
|
+
|
|
374
|
+
const screenshotRequests: Map<
|
|
375
|
+
string,
|
|
376
|
+
{
|
|
377
|
+
resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => void;
|
|
378
|
+
rejecter: (reason?: any) => void;
|
|
379
|
+
}
|
|
380
|
+
> = new Map();
|
|
381
|
+
|
|
382
|
+
const simctlExecutions: Map<string, SimctlExecution> = new Map();
|
|
383
|
+
|
|
384
|
+
const stateChangeCallbacks: Set<ConnectionStateCallback> = new Set();
|
|
385
|
+
|
|
386
|
+
// Logger functions
|
|
387
|
+
const logger = {
|
|
388
|
+
debug: (...args: any[]) => {
|
|
389
|
+
if (logLevel === 'debug') console.log(...args);
|
|
390
|
+
},
|
|
391
|
+
info: (...args: any[]) => {
|
|
392
|
+
if (logLevel === 'info' || logLevel === 'debug') console.log(...args);
|
|
393
|
+
},
|
|
394
|
+
warn: (...args: any[]) => {
|
|
395
|
+
if (logLevel === 'warn' || logLevel === 'info' || logLevel === 'debug') console.warn(...args);
|
|
396
|
+
},
|
|
397
|
+
error: (...args: any[]) => {
|
|
398
|
+
if (logLevel !== 'none') console.error(...args);
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const updateConnectionState = (newState: ConnectionState): void => {
|
|
403
|
+
if (connectionState !== newState) {
|
|
404
|
+
connectionState = newState;
|
|
405
|
+
logger.debug(`Connection state changed to: ${newState}`);
|
|
406
|
+
stateChangeCallbacks.forEach((callback) => {
|
|
407
|
+
try {
|
|
408
|
+
callback(newState);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
logger.error('Error in connection state callback:', err);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const failPendingRequests = (reason: string): void => {
|
|
417
|
+
screenshotRequests.forEach((request) => request.rejecter(new Error(reason)));
|
|
418
|
+
screenshotRequests.clear();
|
|
419
|
+
|
|
420
|
+
simctlExecutions.forEach((execution) => execution._handleError(new Error(reason)));
|
|
421
|
+
simctlExecutions.clear();
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const cleanup = (): void => {
|
|
425
|
+
if (reconnectTimeout) {
|
|
426
|
+
clearTimeout(reconnectTimeout);
|
|
427
|
+
reconnectTimeout = undefined;
|
|
428
|
+
}
|
|
429
|
+
if (pingInterval) {
|
|
430
|
+
clearInterval(pingInterval);
|
|
431
|
+
pingInterval = undefined;
|
|
432
|
+
}
|
|
433
|
+
if (ws) {
|
|
434
|
+
ws.removeAllListeners();
|
|
435
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
436
|
+
ws.close();
|
|
437
|
+
}
|
|
438
|
+
ws = undefined;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
let pingInterval: NodeJS.Timeout | undefined;
|
|
443
|
+
|
|
444
|
+
return new Promise<InstanceClient>((resolveConnection, rejectConnection) => {
|
|
445
|
+
let hasResolved = false;
|
|
446
|
+
|
|
447
|
+
// Reconnection logic with exponential backoff
|
|
448
|
+
const scheduleReconnect = (): void => {
|
|
449
|
+
if (intentionalDisconnect) {
|
|
450
|
+
logger.debug('Skipping reconnection (intentional disconnect)');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
455
|
+
logger.error(`Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`);
|
|
456
|
+
updateConnectionState('disconnected');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const currentDelay = Math.min(reconnectDelay * Math.pow(2, reconnectAttempts), maxReconnectDelay);
|
|
461
|
+
|
|
462
|
+
reconnectAttempts++;
|
|
463
|
+
logger.debug(`Scheduling reconnection attempt ${reconnectAttempts} in ${currentDelay}ms...`);
|
|
464
|
+
updateConnectionState('reconnecting');
|
|
465
|
+
|
|
466
|
+
reconnectTimeout = setTimeout(() => {
|
|
467
|
+
logger.debug(`Attempting to reconnect (attempt ${reconnectAttempts})...`);
|
|
468
|
+
setupWebSocket();
|
|
469
|
+
}, currentDelay);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const setupWebSocket = (): void => {
|
|
473
|
+
cleanup();
|
|
474
|
+
updateConnectionState('connecting');
|
|
475
|
+
|
|
476
|
+
ws = new WebSocket(endpointWebSocketUrl);
|
|
477
|
+
|
|
478
|
+
ws.on('message', (data: Data) => {
|
|
479
|
+
let message: ServerMessage;
|
|
480
|
+
try {
|
|
481
|
+
message = JSON.parse(data.toString());
|
|
482
|
+
} catch (e) {
|
|
483
|
+
logger.error({ data, error: e }, 'Failed to parse JSON message');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
switch (message.type) {
|
|
488
|
+
case 'screenshot': {
|
|
489
|
+
if (!('dataUri' in message) || typeof message.dataUri !== 'string' || !('id' in message)) {
|
|
490
|
+
logger.warn('Received invalid screenshot message:', message);
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const screenshotMessage = message as ScreenshotResponse;
|
|
495
|
+
const request = screenshotRequests.get(screenshotMessage.id);
|
|
496
|
+
|
|
497
|
+
if (!request) {
|
|
498
|
+
logger.warn(
|
|
499
|
+
`Received screenshot data for unknown or already handled session: ${screenshotMessage.id}`,
|
|
500
|
+
);
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
logger.debug(`Received screenshot data URI for session ${screenshotMessage.id}.`);
|
|
505
|
+
request.resolver({ dataUri: screenshotMessage.dataUri });
|
|
506
|
+
screenshotRequests.delete(screenshotMessage.id);
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
case 'screenshotError': {
|
|
510
|
+
if (!('message' in message) || !('id' in message)) {
|
|
511
|
+
logger.warn('Received invalid screenshot error message:', message);
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const errorMessage = message as ScreenshotErrorResponse;
|
|
516
|
+
const request = screenshotRequests.get(errorMessage.id);
|
|
517
|
+
|
|
518
|
+
if (!request) {
|
|
519
|
+
logger.warn(
|
|
520
|
+
`Received screenshot error for unknown or already handled session: ${errorMessage.id}`,
|
|
521
|
+
);
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
logger.error(
|
|
526
|
+
`Server reported an error capturing screenshot for session ${errorMessage.id}:`,
|
|
527
|
+
errorMessage.message,
|
|
528
|
+
);
|
|
529
|
+
request.rejecter(new Error(errorMessage.message));
|
|
530
|
+
screenshotRequests.delete(errorMessage.id);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
case 'simctlStream': {
|
|
534
|
+
if (!('id' in message)) {
|
|
535
|
+
logger.warn('Received invalid simctl stream message:', message);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const streamMessage = message as SimctlStreamResponse;
|
|
540
|
+
const execution = simctlExecutions.get(streamMessage.id);
|
|
541
|
+
|
|
542
|
+
if (!execution) {
|
|
543
|
+
logger.warn(
|
|
544
|
+
`Received simctl stream for unknown or already completed execution: ${streamMessage.id}`,
|
|
545
|
+
);
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Handle stdout if present
|
|
550
|
+
if (streamMessage.stdout) {
|
|
551
|
+
try {
|
|
552
|
+
const stdoutBuffer = Buffer.from(streamMessage.stdout, 'base64');
|
|
553
|
+
execution._handleStdout(stdoutBuffer);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
logger.error('Failed to decode stdout data:', err);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Handle stderr if present
|
|
560
|
+
if (streamMessage.stderr) {
|
|
561
|
+
try {
|
|
562
|
+
const stderrBuffer = Buffer.from(streamMessage.stderr, 'base64');
|
|
563
|
+
execution._handleStderr(stderrBuffer);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
logger.error('Failed to decode stderr data:', err);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Handle exit code if present (final message)
|
|
570
|
+
if (streamMessage.exitCode !== undefined) {
|
|
571
|
+
logger.debug(
|
|
572
|
+
`Simctl execution ${streamMessage.id} completed with exit code ${streamMessage.exitCode}`,
|
|
573
|
+
);
|
|
574
|
+
execution._handleExit(streamMessage.exitCode);
|
|
575
|
+
simctlExecutions.delete(streamMessage.id);
|
|
576
|
+
}
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
case 'simctlError': {
|
|
580
|
+
if (!('message' in message) || !('id' in message)) {
|
|
581
|
+
logger.warn('Received invalid simctl error message:', message);
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const errorMessage = message as SimctlErrorResponse;
|
|
586
|
+
const execution = simctlExecutions.get(errorMessage.id);
|
|
587
|
+
|
|
588
|
+
if (!execution) {
|
|
589
|
+
logger.warn(
|
|
590
|
+
`Received simctl error for unknown or already handled execution: ${errorMessage.id}`,
|
|
591
|
+
);
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
logger.error(
|
|
596
|
+
`Server reported an error for simctl execution ${errorMessage.id}:`,
|
|
597
|
+
errorMessage.message,
|
|
598
|
+
);
|
|
599
|
+
execution._handleError(new Error(errorMessage.message));
|
|
600
|
+
simctlExecutions.delete(errorMessage.id);
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
default:
|
|
604
|
+
logger.warn('Received unexpected message type:', message);
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
ws.on('error', (err: Error) => {
|
|
610
|
+
logger.error('WebSocket error:', err.message);
|
|
611
|
+
if (!hasResolved && (ws?.readyState === WebSocket.CONNECTING || ws?.readyState === WebSocket.OPEN)) {
|
|
612
|
+
rejectConnection(err);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
ws.on('close', () => {
|
|
617
|
+
if (pingInterval) {
|
|
618
|
+
clearInterval(pingInterval);
|
|
619
|
+
pingInterval = undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const shouldReconnect = !intentionalDisconnect && connectionState !== 'disconnected';
|
|
623
|
+
updateConnectionState('disconnected');
|
|
624
|
+
|
|
625
|
+
logger.debug('Disconnected from server.');
|
|
626
|
+
|
|
627
|
+
failPendingRequests('Connection closed');
|
|
628
|
+
|
|
629
|
+
if (shouldReconnect) {
|
|
630
|
+
scheduleReconnect();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
ws.on('open', () => {
|
|
635
|
+
logger.debug(`Connected to ${endpointWebSocketUrl}`);
|
|
636
|
+
reconnectAttempts = 0;
|
|
637
|
+
updateConnectionState('connected');
|
|
638
|
+
|
|
639
|
+
pingInterval = setInterval(() => {
|
|
640
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
641
|
+
(ws as any).ping();
|
|
642
|
+
}
|
|
643
|
+
}, 30_000);
|
|
644
|
+
|
|
645
|
+
if (!hasResolved) {
|
|
646
|
+
hasResolved = true;
|
|
647
|
+
resolveConnection({
|
|
648
|
+
screenshot,
|
|
649
|
+
disconnect,
|
|
650
|
+
getConnectionState,
|
|
651
|
+
onConnectionStateChange,
|
|
652
|
+
simctl,
|
|
653
|
+
cp,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const screenshot = async (): Promise<ScreenshotData> => {
|
|
660
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
661
|
+
return Promise.reject(new Error('WebSocket is not connected or connection is not open.'));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const id = 'ts-client-' + Date.now();
|
|
665
|
+
const screenshotRequest: ScreenshotRequest = {
|
|
666
|
+
type: 'screenshot',
|
|
667
|
+
id,
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
return new Promise<ScreenshotData>((resolve, reject) => {
|
|
671
|
+
logger.debug('Sending screenshot request:', screenshotRequest);
|
|
672
|
+
ws!.send(JSON.stringify(screenshotRequest), (err?: Error) => {
|
|
673
|
+
if (err) {
|
|
674
|
+
logger.error('Failed to send screenshot request:', err);
|
|
675
|
+
reject(err);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const timeout = setTimeout(() => {
|
|
680
|
+
if (screenshotRequests.has(id)) {
|
|
681
|
+
logger.error(`Screenshot request timed out for session ${id}`);
|
|
682
|
+
screenshotRequests.get(id)?.rejecter(new Error('Screenshot request timed out'));
|
|
683
|
+
screenshotRequests.delete(id);
|
|
684
|
+
}
|
|
685
|
+
}, 30000);
|
|
686
|
+
screenshotRequests.set(id, {
|
|
687
|
+
resolver: (value: ScreenshotData | PromiseLike<ScreenshotData>) => {
|
|
688
|
+
clearTimeout(timeout);
|
|
689
|
+
resolve(value);
|
|
690
|
+
screenshotRequests.delete(id);
|
|
691
|
+
},
|
|
692
|
+
rejecter: (reason?: any) => {
|
|
693
|
+
clearTimeout(timeout);
|
|
694
|
+
reject(reason);
|
|
695
|
+
screenshotRequests.delete(id);
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const simctl = (args: string[]): SimctlExecution => {
|
|
702
|
+
const id = 'ts-simctl-' + Date.now() + '-' + Math.random().toString(36).substring(7);
|
|
703
|
+
|
|
704
|
+
const cancelCallback = () => {
|
|
705
|
+
// Clean up execution tracking
|
|
706
|
+
simctlExecutions.delete(id);
|
|
707
|
+
logger.debug(`Simctl execution ${id} cancelled`);
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const execution = new SimctlExecution(cancelCallback);
|
|
711
|
+
simctlExecutions.set(id, execution);
|
|
712
|
+
|
|
713
|
+
// Send request asynchronously
|
|
714
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
715
|
+
// Defer error to next tick to allow caller to attach listeners
|
|
716
|
+
process.nextTick(() => {
|
|
717
|
+
execution._handleError(new Error('WebSocket is not connected or connection is not open.'));
|
|
718
|
+
simctlExecutions.delete(id);
|
|
719
|
+
});
|
|
720
|
+
return execution;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const simctlRequest: SimctlRequest = {
|
|
724
|
+
type: 'simctl',
|
|
725
|
+
id,
|
|
726
|
+
args,
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
logger.debug('Sending simctl request:', simctlRequest);
|
|
730
|
+
ws.send(JSON.stringify(simctlRequest), (err?: Error) => {
|
|
731
|
+
if (err) {
|
|
732
|
+
logger.error('Failed to send simctl request:', err);
|
|
733
|
+
execution._handleError(err);
|
|
734
|
+
simctlExecutions.delete(id);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
return execution;
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const cp = async (name: string, filePath: string): Promise<string> => {
|
|
742
|
+
const fileStream = fs.createReadStream(filePath);
|
|
743
|
+
const uploadUrl = `${options.apiUrl}/files?name=${encodeURIComponent(name)}`;
|
|
744
|
+
try {
|
|
745
|
+
const response = await fetch(uploadUrl, {
|
|
746
|
+
method: 'PUT',
|
|
747
|
+
headers: {
|
|
748
|
+
'Content-Type': 'application/octet-stream',
|
|
749
|
+
'Content-Length': fs.statSync(filePath).size.toString(),
|
|
750
|
+
Authorization: `Bearer ${options.token}`,
|
|
751
|
+
},
|
|
752
|
+
body: fileStream,
|
|
753
|
+
duplex: 'half',
|
|
754
|
+
});
|
|
755
|
+
if (!response.ok) {
|
|
756
|
+
const errorBody = await response.text();
|
|
757
|
+
logger.debug(`Upload failed: ${response.status} ${errorBody}`);
|
|
758
|
+
throw new Error(`Upload failed: ${response.status} ${errorBody}`);
|
|
759
|
+
}
|
|
760
|
+
const result = (await response.json()) as { path: string };
|
|
761
|
+
return result.path;
|
|
762
|
+
} catch (err) {
|
|
763
|
+
logger.debug(`Failed to upload file ${filePath}:`, err);
|
|
764
|
+
throw err;
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const disconnect = (): void => {
|
|
769
|
+
intentionalDisconnect = true;
|
|
770
|
+
cleanup();
|
|
771
|
+
updateConnectionState('disconnected');
|
|
772
|
+
failPendingRequests('Intentional disconnect');
|
|
773
|
+
logger.debug('Intentionally disconnected from WebSocket.');
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const getConnectionState = (): ConnectionState => {
|
|
777
|
+
return connectionState;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const onConnectionStateChange = (callback: ConnectionStateCallback): (() => void) => {
|
|
781
|
+
stateChangeCallbacks.add(callback);
|
|
782
|
+
return () => {
|
|
783
|
+
stateChangeCallbacks.delete(callback);
|
|
784
|
+
};
|
|
785
|
+
};
|
|
786
|
+
setupWebSocket();
|
|
787
|
+
});
|
|
788
|
+
}
|