runcloud 0.1.108 → 0.1.110

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/README.md CHANGED
@@ -64,12 +64,22 @@ runcloud ios create --model iphone --install ./MyApp.app # boot a simulator an
64
64
  runcloud ios list # list active sessions
65
65
  runcloud ios get <id> # inspect a session (viewer URL, status)
66
66
  runcloud ios open-url myapp://path --id <id> # open a URL or deep link
67
+ runcloud ios tap <id> 0.5 0.75 # normalized top-left display coordinates
68
+ runcloud ios type-text <id> "Hello from iOS!" # printable US ASCII, tab, and line feed
69
+ runcloud ios screenshot <id> --output ios.png # save a PNG without exposing the viewer URL
70
+ runcloud ios recording start <id> --json # start an idempotent MP4 screen recording
71
+ runcloud ios recording stop <id> <recording-id> --json # stop and finalize it
72
+ runcloud ios recording download <id> <recording-id> \
73
+ --output ios.mp4 --json # download through the authenticated API
67
74
  runcloud ios logs <id> --tail 200 # read logs from this lease
68
75
  runcloud ios logs <id> --follow # follow new log entries
69
76
  runcloud ios delete <id> # release the session
70
77
 
71
78
  # Android works the same under `runcloud android …`
72
79
  runcloud android create --model pixel
80
+ runcloud android swipe <id> 0.5 0.8 0.5 0.2 --duration 250
81
+ runcloud android press-key <id> enter
82
+ runcloud android screenshot <id> --output android.png --json
73
83
  ```
74
84
 
75
85
  Useful `create` flags: `--region`, `--display-name`, `--inactivity-timeout 3m`,
@@ -80,6 +90,29 @@ The same `logs` commands work for Android. Snapshots are limited to the active
80
90
  lease and accept 1 to 1,000 lines. In follow mode, `--json` emits one JSON object
81
91
  per line.
82
92
 
93
+ Both platforms also expose `gesture`, `press-button`, `rotate`, `reload`,
94
+ `scroll`, `toggle-software-keyboard`, `simulate-memory-warning`,
95
+ `rotate-digital-crown`, and `set-render-debug`. Run `runcloud ios --help` or
96
+ `runcloud android --help` for the full surface and per-command input choices.
97
+ Current mobile sessions return an unsupported-action error for Digital Crown
98
+ input; render-debug controls are iOS-only. Android Emulator sessions also
99
+ report `capsLock`, `numLock`, and `scrollLock` as unsupported keys.
100
+ Coordinates are inclusive normalized display coordinates: `(0, 0)` is the
101
+ top-left and `(1, 1)` is the bottom-right.
102
+
103
+ The `recording` group is shared by iOS and Android. It provides `start`,
104
+ `list`, `status` (`get` alias), `stop`, and `download`. Pass a stable
105
+ `--idempotency-key` to retry `start` without creating a second recording.
106
+ `download --output <path>` validates the MP4 and reports its byte size and
107
+ SHA-256 digest without printing a signed storage URL.
108
+
109
+ Interaction commands wait for a correlated simulator acknowledgement. They
110
+ accept `--timeout <milliseconds>` (15 seconds by default), `--request-id <id>`,
111
+ and `--json`. Successful JSON is the API's typed completion envelope. Failures
112
+ write a stable `{ ok: false, error: { code, message, retryable } }` envelope to
113
+ stderr and exit non-zero. Ctrl-C cancels only the in-flight command, leaves the
114
+ session active, and exits with code 130.
115
+
83
116
  ### Tunnel a local dev server into a simulator
84
117
 
85
118
  ```bash
package/dist/api.js CHANGED
@@ -3,28 +3,84 @@ export class ApiError extends Error {
3
3
  status;
4
4
  detail;
5
5
  path;
6
- constructor(status, detail, path) {
6
+ code;
7
+ retryable;
8
+ details;
9
+ requestId;
10
+ sessionId;
11
+ platform;
12
+ action;
13
+ interactionStatus;
14
+ acceptedAt;
15
+ completedAt;
16
+ durationMs;
17
+ constructor(status, detail, path, context = {}) {
7
18
  super(`API ${status}: ${detail}`);
8
19
  this.status = status;
9
20
  this.detail = detail;
10
21
  this.path = path;
22
+ this.name = 'ApiError';
23
+ Object.assign(this, context);
11
24
  }
12
25
  }
13
- function parsedErrorDetail(body) {
26
+ export class ApiTransportError extends Error {
27
+ path;
28
+ originalError;
29
+ constructor(path, originalError) {
30
+ super('Could not reach the run.cloud API');
31
+ this.path = path;
32
+ this.originalError = originalError;
33
+ this.name = 'ApiTransportError';
34
+ }
35
+ }
36
+ function parsedError(body) {
14
37
  try {
15
38
  const parsed = JSON.parse(body);
16
- const detail = parsed.detail ?? parsed.error ?? parsed.message;
17
- if (typeof detail === 'string')
18
- return detail;
19
- if (detail !== undefined)
20
- return JSON.stringify(detail);
39
+ const nested = parsed.error && typeof parsed.error === 'object'
40
+ ? parsed.error
41
+ : undefined;
42
+ const rawDetail = nested?.message ?? parsed.detail ?? parsed.error ?? parsed.message;
43
+ const detail = typeof rawDetail === 'string'
44
+ ? rawDetail
45
+ : rawDetail === undefined ? '' : JSON.stringify(rawDetail);
46
+ const retryable = nested?.retryable ?? parsed.retryable;
47
+ const interactionStatus = parsed.status === 'failed'
48
+ || parsed.status === 'cancelled'
49
+ || parsed.status === 'timed_out'
50
+ ? parsed.status
51
+ : undefined;
52
+ const platform = parsed.platform === 'ios' || parsed.platform === 'android'
53
+ ? parsed.platform
54
+ : undefined;
55
+ return {
56
+ detail,
57
+ ...(typeof (nested?.code ?? parsed.code) === 'string'
58
+ ? { code: (nested?.code ?? parsed.code) }
59
+ : {}),
60
+ ...(typeof retryable === 'boolean' ? { retryable } : {}),
61
+ ...((nested?.details ?? parsed.details) === undefined
62
+ ? {}
63
+ : { details: nested?.details ?? parsed.details }),
64
+ ...(typeof parsed.requestId === 'string' ? { requestId: parsed.requestId } : {}),
65
+ ...(typeof parsed.sessionId === 'string' ? { sessionId: parsed.sessionId } : {}),
66
+ ...(platform ? { platform } : {}),
67
+ ...(typeof parsed.action === 'string' ? { action: parsed.action } : {}),
68
+ ...(interactionStatus ? { interactionStatus } : {}),
69
+ ...(typeof parsed.acceptedAt === 'string' ? { acceptedAt: parsed.acceptedAt } : {}),
70
+ ...(typeof parsed.completedAt === 'string' ? { completedAt: parsed.completedAt } : {}),
71
+ ...(typeof parsed.durationMs === 'number' && Number.isFinite(parsed.durationMs)
72
+ ? { durationMs: parsed.durationMs }
73
+ : {}),
74
+ };
21
75
  }
22
76
  catch {
23
77
  }
24
78
  if (/<(?:!doctype|html)\b/i.test(body)) {
25
- return 'the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer command';
79
+ return {
80
+ detail: 'the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer command',
81
+ };
26
82
  }
27
- return body;
83
+ return { detail: body };
28
84
  }
29
85
  export class ApiClient {
30
86
  baseUrl;
@@ -33,18 +89,27 @@ export class ApiClient {
33
89
  this.baseUrl = baseUrl;
34
90
  this.token = token;
35
91
  }
36
- async request(method, path, body) {
37
- const res = await fetch(`${this.baseUrl}${path}`, {
38
- method,
39
- headers: {
40
- Authorization: `Bearer ${this.token}`,
41
- ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
42
- },
43
- body: body !== undefined ? JSON.stringify(body) : undefined,
44
- });
92
+ async request(method, path, body, options = {}) {
93
+ let res;
94
+ try {
95
+ res = await fetch(`${this.baseUrl}${path}`, {
96
+ method,
97
+ headers: {
98
+ Authorization: `Bearer ${this.token}`,
99
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
100
+ },
101
+ body: body !== undefined ? JSON.stringify(body) : undefined,
102
+ signal: options.signal,
103
+ });
104
+ }
105
+ catch (error) {
106
+ if (options.signal?.aborted)
107
+ throw options.signal.reason ?? error;
108
+ throw new ApiTransportError(path, error);
109
+ }
45
110
  if (!res.ok) {
46
- const detail = parsedErrorDetail(await res.text());
47
- throw new ApiError(res.status, detail, path);
111
+ const { detail, ...context } = parsedError(await res.text());
112
+ throw new ApiError(res.status, detail, path, context);
48
113
  }
49
114
  const contentType = res.headers.get('content-type') ?? '';
50
115
  return contentType.includes('application/json') ? res.json() : res;
@@ -56,8 +121,8 @@ export class ApiClient {
56
121
  const response = await this.request('GET', path);
57
122
  return response.text();
58
123
  }
59
- post(path, body) {
60
- return this.request('POST', path, body);
124
+ post(path, body, options = {}) {
125
+ return this.request('POST', path, body, options);
61
126
  }
62
127
  async postWithHeaders(path, body, headers) {
63
128
  const res = await fetch(`${this.baseUrl}${path}`, {
@@ -70,8 +135,8 @@ export class ApiClient {
70
135
  body: JSON.stringify(body),
71
136
  });
72
137
  if (!res.ok) {
73
- const detail = parsedErrorDetail(await res.text());
74
- throw new ApiError(res.status, detail, path);
138
+ const { detail, ...context } = parsedError(await res.text());
139
+ throw new ApiError(res.status, detail, path, context);
75
140
  }
76
141
  const contentType = res.headers.get('content-type') ?? '';
77
142
  return contentType.includes('application/json') ? res.json() : res;
@@ -95,8 +160,8 @@ export class ApiClient {
95
160
  body: body,
96
161
  });
97
162
  if (!res.ok) {
98
- const detail = parsedErrorDetail(await res.text());
99
- throw new ApiError(res.status, detail, path);
163
+ const { detail, ...context } = parsedError(await res.text());
164
+ throw new ApiError(res.status, detail, path, context);
100
165
  }
101
166
  const contentType_ = res.headers.get('content-type') ?? '';
102
167
  return contentType_.includes('application/json') ? res.json() : res;
@@ -110,20 +175,29 @@ export class ApiClient {
110
175
  body: form,
111
176
  });
112
177
  if (!res.ok) {
113
- const detail = parsedErrorDetail(await res.text());
114
- throw new ApiError(res.status, detail, path);
178
+ const { detail, ...context } = parsedError(await res.text());
179
+ throw new ApiError(res.status, detail, path, context);
115
180
  }
116
181
  const contentType = res.headers.get('content-type') ?? '';
117
182
  return contentType.includes('application/json') ? res.json() : res;
118
183
  }
119
- async getBinary(path) {
120
- const res = await fetch(`${this.baseUrl}${path}`, {
121
- method: 'GET',
122
- headers: { Authorization: `Bearer ${this.token}` },
123
- });
184
+ async getBinary(path, options = {}) {
185
+ let res;
186
+ try {
187
+ res = await fetch(`${this.baseUrl}${path}`, {
188
+ method: 'GET',
189
+ headers: { Authorization: `Bearer ${this.token}`, ...options.headers },
190
+ signal: options.signal,
191
+ });
192
+ }
193
+ catch (error) {
194
+ if (options.signal?.aborted)
195
+ throw options.signal.reason ?? error;
196
+ throw new ApiTransportError(path, error);
197
+ }
124
198
  if (!res.ok) {
125
- const detail = await res.text().catch(() => '');
126
- throw new ApiError(res.status, detail || res.statusText, path);
199
+ const { detail, ...context } = parsedError(await res.text().catch(() => ''));
200
+ throw new ApiError(res.status, detail || res.statusText, path, context);
127
201
  }
128
202
  return Buffer.from(await res.arrayBuffer());
129
203
  }
@@ -133,8 +207,8 @@ export class ApiClient {
133
207
  headers: { Authorization: `Bearer ${this.token}`, Accept: 'text/event-stream' },
134
208
  });
135
209
  if (!res.ok) {
136
- const detail = parsedErrorDetail(await res.text().catch(() => ''));
137
- throw new ApiError(res.status, detail || res.statusText, path);
210
+ const { detail, ...context } = parsedError(await res.text().catch(() => ''));
211
+ throw new ApiError(res.status, detail || res.statusText, path, context);
138
212
  }
139
213
  const decoder = new TextDecoder();
140
214
  for await (const chunk of res.body) {