runcloud 0.1.108 → 0.1.109

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,18 @@ 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
67
70
  runcloud ios logs <id> --tail 200 # read logs from this lease
68
71
  runcloud ios logs <id> --follow # follow new log entries
69
72
  runcloud ios delete <id> # release the session
70
73
 
71
74
  # Android works the same under `runcloud android …`
72
75
  runcloud android create --model pixel
76
+ runcloud android swipe <id> 0.5 0.8 0.5 0.2 --duration 250
77
+ runcloud android press-key <id> enter
78
+ runcloud android screenshot <id> --output android.png --json
73
79
  ```
74
80
 
75
81
  Useful `create` flags: `--region`, `--display-name`, `--inactivity-timeout 3m`,
@@ -80,6 +86,23 @@ The same `logs` commands work for Android. Snapshots are limited to the active
80
86
  lease and accept 1 to 1,000 lines. In follow mode, `--json` emits one JSON object
81
87
  per line.
82
88
 
89
+ Both platforms also expose `gesture`, `press-button`, `rotate`, `reload`,
90
+ `scroll`, `toggle-software-keyboard`, `simulate-memory-warning`,
91
+ `rotate-digital-crown`, and `set-render-debug`. Run `runcloud ios --help` or
92
+ `runcloud android --help` for the full surface and per-command input choices.
93
+ Current mobile sessions return an unsupported-action error for Digital Crown
94
+ input; render-debug controls are iOS-only. Android Emulator sessions also
95
+ report `capsLock`, `numLock`, and `scrollLock` as unsupported keys.
96
+ Coordinates are inclusive normalized display coordinates: `(0, 0)` is the
97
+ top-left and `(1, 1)` is the bottom-right.
98
+
99
+ Interaction commands wait for a correlated simulator acknowledgement. They
100
+ accept `--timeout <milliseconds>` (15 seconds by default), `--request-id <id>`,
101
+ and `--json`. Successful JSON is the API's typed completion envelope. Failures
102
+ write a stable `{ ok: false, error: { code, message, retryable } }` envelope to
103
+ stderr and exit non-zero. Ctrl-C cancels only the in-flight command, leaves the
104
+ session active, and exits with code 130.
105
+
83
106
  ### Tunnel a local dev server into a simulator
84
107
 
85
108
  ```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) {
@@ -3,9 +3,91 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFil
3
3
  import { basename, dirname, join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { createHash } from 'node:crypto';
7
- import { ApiClient, friendlyApiError } from '../api.js';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { ApiClient, ApiError, ApiTransportError, friendlyApiError } from '../api.js';
8
8
  import { requireCredentials } from '../config.js';
9
+ export const SIMULATOR_KEYS = [
10
+ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
11
+ 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
12
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
13
+ 'enter', 'escape', 'backspace', 'tab', 'space', 'minus', 'equal',
14
+ 'bracketLeft', 'bracketRight', 'backslash', 'semicolon', 'quote', 'backquote',
15
+ 'comma', 'period', 'slash', 'capsLock',
16
+ 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12',
17
+ 'printScreen', 'scrollLock', 'pause', 'insert', 'home', 'pageUp', 'delete', 'end',
18
+ 'pageDown', 'arrowRight', 'arrowLeft', 'arrowDown', 'arrowUp', 'numLock',
19
+ 'numpadDivide', 'numpadMultiply', 'numpadSubtract', 'numpadAdd', 'numpadEnter',
20
+ 'numpad0', 'numpad1', 'numpad2', 'numpad3', 'numpad4', 'numpad5', 'numpad6',
21
+ 'numpad7', 'numpad8', 'numpad9', 'numpadDecimal',
22
+ ];
23
+ export const SIMULATOR_BUTTONS = [
24
+ 'home', 'back', 'appSwitcher', 'recents', 'power', 'volumeUp', 'volumeDown',
25
+ 'menu', 'sideButton', 'actionButton', 'digitalCrown',
26
+ ];
27
+ export const SIMULATOR_RENDER_DEBUG_OPTIONS = [
28
+ 'colorBlendedLayers',
29
+ 'colorCopiedImages',
30
+ 'colorMisalignedImages',
31
+ 'colorOffscreenRendered',
32
+ 'slowAnimations',
33
+ ];
34
+ export const SIMULATOR_INTERACTION_ERROR_CODES = [
35
+ 'invalid_interaction',
36
+ 'active_session_not_found',
37
+ 'simulator_capacity_unavailable',
38
+ 'unsupported_action',
39
+ 'duplicate_request',
40
+ 'interaction_cancelled',
41
+ 'interaction_timeout',
42
+ 'interaction_transport_error',
43
+ 'interaction_invalid_response',
44
+ 'interaction_failed',
45
+ ];
46
+ export const SIMULATOR_SCREENSHOT_ERROR_CODES = [
47
+ 'invalid_screenshot_request',
48
+ 'active_session_not_found',
49
+ 'simulator_capacity_unavailable',
50
+ 'screenshot_cancelled',
51
+ 'screenshot_timeout',
52
+ 'screenshot_transport_error',
53
+ 'screenshot_invalid_response',
54
+ 'screenshot_failed',
55
+ ];
56
+ const simulatorInteractionErrorCodeSet = new Set(SIMULATOR_INTERACTION_ERROR_CODES);
57
+ const simulatorScreenshotErrorCodeSet = new Set(SIMULATOR_SCREENSHOT_ERROR_CODES);
58
+ function normalizedInteractionErrorCode(value) {
59
+ if (typeof value === 'string' && simulatorInteractionErrorCodeSet.has(value)) {
60
+ return value;
61
+ }
62
+ switch (value) {
63
+ case 'timeout':
64
+ case 'timed_out':
65
+ case 'interaction_transport_timeout':
66
+ return 'interaction_timeout';
67
+ case 'cancelled':
68
+ return 'interaction_cancelled';
69
+ case 'invalid_request':
70
+ return 'invalid_interaction';
71
+ case 'simulator_session_ended':
72
+ return 'active_session_not_found';
73
+ case 'unsupported_on_platform':
74
+ return 'unsupported_action';
75
+ case 'invalid_host_response':
76
+ case 'invalid_interaction_result':
77
+ case 'interaction_platform_mismatch':
78
+ return 'interaction_invalid_response';
79
+ default:
80
+ return 'interaction_failed';
81
+ }
82
+ }
83
+ function normalizedScreenshotErrorCode(value) {
84
+ return typeof value === 'string' && simulatorScreenshotErrorCodeSet.has(value)
85
+ ? value
86
+ : 'screenshot_failed';
87
+ }
88
+ const DEFAULT_INTERACTION_TIMEOUT_MS = 15_000;
89
+ const INTERACTION_RESPONSE_MARGIN_MS = 250;
90
+ const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
9
91
  export const RUN_CLOUD_SKILL_NAMES = [
10
92
  'run-cloud',
11
93
  'run-cloud-ios-simulator',
@@ -109,6 +191,300 @@ function parseTags(labels) {
109
191
  }
110
192
  return out;
111
193
  }
194
+ function integerInRange(raw, name, min, max, fallback) {
195
+ if (raw === undefined && fallback !== undefined)
196
+ return fallback;
197
+ const value = Number(raw);
198
+ if (!Number.isInteger(value) || value < min || value > max) {
199
+ throw new Error(`${name} must be an integer from ${min} to ${max}`);
200
+ }
201
+ return value;
202
+ }
203
+ function finiteInRange(raw, name, min, max) {
204
+ const value = Number(raw);
205
+ if (!Number.isFinite(value) || value < min || value > max) {
206
+ throw new Error(`${name} must be a number from ${min} to ${max}`);
207
+ }
208
+ return value;
209
+ }
210
+ function normalizedPoint(x, y, name) {
211
+ return {
212
+ x: finiteInRange(x, `${name} x`, 0, 1),
213
+ y: finiteInRange(y, `${name} y`, 0, 1),
214
+ };
215
+ }
216
+ function simulatorKey(raw) {
217
+ if (SIMULATOR_KEYS.includes(raw))
218
+ return raw;
219
+ throw new Error(`Unknown simulator key: ${raw}. Run with --help to see supported semantic keys.`);
220
+ }
221
+ function simulatorButton(raw) {
222
+ if (SIMULATOR_BUTTONS.includes(raw))
223
+ return raw;
224
+ throw new Error(`Unknown simulator button: ${raw}. Choose one of: ${SIMULATOR_BUTTONS.join(', ')}.`);
225
+ }
226
+ function simulatorOrientation(raw) {
227
+ if (raw === 'portrait' || raw === 'portrait_upside_down' || raw === 'landscape_left' || raw === 'landscape_right') {
228
+ return raw;
229
+ }
230
+ throw new Error(`Unknown orientation: ${raw}. Choose portrait, portrait_upside_down, landscape_left, or landscape_right.`);
231
+ }
232
+ function renderDebugOption(raw) {
233
+ if (SIMULATOR_RENDER_DEBUG_OPTIONS.includes(raw)) {
234
+ return raw;
235
+ }
236
+ throw new Error(`Unknown render debug option: ${raw}. Choose one of: ${SIMULATOR_RENDER_DEBUG_OPTIONS.join(', ')}.`);
237
+ }
238
+ function booleanArgument(raw, name) {
239
+ if (raw === 'true')
240
+ return true;
241
+ if (raw === 'false')
242
+ return false;
243
+ throw new Error(`${name} must be true or false`);
244
+ }
245
+ function isUsAsciiText(text) {
246
+ for (let index = 0; index < text.length; index += 1) {
247
+ const code = text.charCodeAt(index);
248
+ if (code !== 9 && code !== 10 && (code < 32 || code > 126))
249
+ return false;
250
+ }
251
+ return true;
252
+ }
253
+ function assertInteraction(interaction, timeoutMs) {
254
+ switch (interaction.action) {
255
+ case 'tap':
256
+ return;
257
+ case 'swipe':
258
+ if (interaction.durationMs !== undefined) {
259
+ integerInRange(String(interaction.durationMs), '--duration', 50, 30_000);
260
+ }
261
+ return;
262
+ case 'gesture': {
263
+ if (interaction.steps.length < 2 || interaction.steps.length > 1_000) {
264
+ throw new Error('--steps must contain from 2 to 1000 gesture steps');
265
+ }
266
+ let pointCount;
267
+ let totalDelayMs = 0;
268
+ for (const [index, step] of interaction.steps.entries()) {
269
+ if ((index === 0) !== (step.phase === 'begin')) {
270
+ throw new Error('Gesture steps must start with begin and contain no later begin phase');
271
+ }
272
+ if ((index === interaction.steps.length - 1) !== (step.phase === 'end')) {
273
+ throw new Error('Gesture steps must end with end and contain no earlier end phase');
274
+ }
275
+ if (step.points.length !== 1 && step.points.length !== 2) {
276
+ throw new Error('Each gesture step must contain one or two points');
277
+ }
278
+ if (pointCount === undefined)
279
+ pointCount = step.points.length;
280
+ else if (pointCount !== step.points.length) {
281
+ throw new Error('Every gesture step must use the same number of points');
282
+ }
283
+ for (const point of step.points) {
284
+ finiteInRange(String(point.x), 'gesture x', 0, 1);
285
+ finiteInRange(String(point.y), 'gesture y', 0, 1);
286
+ }
287
+ if (step.delayMs !== undefined) {
288
+ if (index === interaction.steps.length - 1 && step.delayMs !== 0) {
289
+ throw new Error('Final gesture end step delayMs must be 0 because there is no next step');
290
+ }
291
+ totalDelayMs += integerInRange(String(step.delayMs), 'gesture delayMs', 0, 5_000);
292
+ }
293
+ }
294
+ if (totalDelayMs > timeoutMs)
295
+ throw new Error('Gesture delays cannot exceed --timeout');
296
+ return;
297
+ }
298
+ case 'typeText':
299
+ if (interaction.text.length < 1 || interaction.text.length > 10_000) {
300
+ throw new Error('text must contain from 1 to 10000 characters');
301
+ }
302
+ if (!isUsAsciiText(interaction.text)) {
303
+ throw new Error('text must contain only US ASCII characters, tabs, and line feeds');
304
+ }
305
+ return;
306
+ case 'pressKey':
307
+ case 'pressButton':
308
+ if (interaction.durationMs !== undefined) {
309
+ integerInRange(String(interaction.durationMs), '--duration', 20, 30_000);
310
+ }
311
+ return;
312
+ case 'scroll':
313
+ if (interaction.deltaX === 0 && interaction.deltaY === 0) {
314
+ throw new Error('At least one scroll delta must be nonzero');
315
+ }
316
+ return;
317
+ case 'rotateDigitalCrown':
318
+ if (interaction.delta === 0)
319
+ throw new Error('digital crown delta must be nonzero');
320
+ return;
321
+ default:
322
+ return;
323
+ }
324
+ }
325
+ export function parseSimulatorGestureSteps(raw, timeoutMs = DEFAULT_INTERACTION_TIMEOUT_MS) {
326
+ let value;
327
+ try {
328
+ value = JSON.parse(raw);
329
+ }
330
+ catch {
331
+ throw new Error('--steps must be a valid JSON array');
332
+ }
333
+ if (!Array.isArray(value))
334
+ throw new Error('--steps must be a JSON array');
335
+ const steps = value.map((item, index) => {
336
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
337
+ throw new Error(`Gesture step ${index} must be an object`);
338
+ }
339
+ const record = item;
340
+ if (record.phase !== 'begin' && record.phase !== 'move' && record.phase !== 'end') {
341
+ throw new Error(`Gesture step ${index} phase must be begin, move, or end`);
342
+ }
343
+ if (!Array.isArray(record.points) || (record.points.length !== 1 && record.points.length !== 2)) {
344
+ throw new Error(`Gesture step ${index} must contain one or two points`);
345
+ }
346
+ const points = record.points.map((point, pointIndex) => {
347
+ if (!point || typeof point !== 'object' || Array.isArray(point)) {
348
+ throw new Error(`Gesture step ${index} point ${pointIndex} must contain x and y`);
349
+ }
350
+ const candidate = point;
351
+ return normalizedPoint(String(candidate.x), String(candidate.y), `gesture step ${index} point ${pointIndex}`);
352
+ });
353
+ const tuple = points.length === 1
354
+ ? [points[0]]
355
+ : [points[0], points[1]];
356
+ const delayMs = record.delayMs === undefined
357
+ ? undefined
358
+ : integerInRange(String(record.delayMs), `gesture step ${index} delayMs`, 0, 5_000);
359
+ return {
360
+ phase: record.phase,
361
+ points: tuple,
362
+ ...(delayMs === undefined ? {} : { delayMs }),
363
+ };
364
+ });
365
+ assertInteraction({ action: 'gesture', steps }, timeoutMs);
366
+ return steps;
367
+ }
368
+ function interactionTimeout(opts) {
369
+ return integerInRange(opts.timeout, '--timeout', 100, 60_000, DEFAULT_INTERACTION_TIMEOUT_MS);
370
+ }
371
+ function interactionRequestId(opts) {
372
+ const requestId = opts.requestId?.trim() || `cli_${randomUUID()}`;
373
+ if (!REQUEST_ID_PATTERN.test(requestId)) {
374
+ throw new Error('--request-id must match /^[A-Za-z0-9._:-]{1,128}$/');
375
+ }
376
+ return requestId;
377
+ }
378
+ class CliInteractionError extends Error {
379
+ code;
380
+ requestId;
381
+ actionName;
382
+ sessionId;
383
+ platform;
384
+ constructor(message, code, requestId, actionName, sessionId, platform) {
385
+ super(message);
386
+ this.code = code;
387
+ this.requestId = requestId;
388
+ this.actionName = actionName;
389
+ this.sessionId = sessionId;
390
+ this.platform = platform;
391
+ this.name = 'CliInteractionError';
392
+ }
393
+ }
394
+ async function withInteractionCancellation(timeoutMs, requestId, actionName, sessionId, platform, operation, run) {
395
+ const controller = new AbortController();
396
+ const onSigint = () => controller.abort(new CliInteractionError(`Simulator ${operation === 'screenshot' ? 'screenshot' : 'interaction'} cancelled by SIGINT`, operation === 'screenshot' ? 'screenshot_cancelled' : 'interaction_cancelled', requestId, actionName, sessionId, platform));
397
+ process.once('SIGINT', onSigint);
398
+ const timer = setTimeout(() => controller.abort(new CliInteractionError(`Simulator ${actionName} did not complete within ${timeoutMs} ms`, operation === 'screenshot' ? 'screenshot_timeout' : 'interaction_timeout', requestId, actionName, sessionId, platform)), timeoutMs + INTERACTION_RESPONSE_MARGIN_MS);
399
+ try {
400
+ return await run(controller.signal);
401
+ }
402
+ catch (error) {
403
+ if (controller.signal.aborted && controller.signal.reason instanceof CliInteractionError) {
404
+ throw controller.signal.reason;
405
+ }
406
+ throw error;
407
+ }
408
+ finally {
409
+ clearTimeout(timer);
410
+ process.removeListener('SIGINT', onSigint);
411
+ }
412
+ }
413
+ function addInteractionOptions(command) {
414
+ return command
415
+ .option('--timeout <milliseconds>', 'acknowledgement timeout in milliseconds (100-60000)', String(DEFAULT_INTERACTION_TIMEOUT_MS))
416
+ .option('--request-id <id>', 'caller-supplied correlation id')
417
+ .option('--json', 'output a stable JSON result or error envelope', false);
418
+ }
419
+ async function runSimulatorInteraction(platform, id, interaction, opts) {
420
+ const timeoutMs = interactionTimeout(opts);
421
+ const requestId = interactionRequestId(opts);
422
+ assertInteraction(interaction, timeoutMs);
423
+ let result;
424
+ try {
425
+ result = await withInteractionCancellation(timeoutMs, requestId, interaction.action, id, platform, 'interaction', async (signal) => await client().post(`/run-cloud/${platform}/${encodeURIComponent(id)}/interactions`, { ...interaction, requestId, timeoutMs }, { signal }));
426
+ }
427
+ catch (error) {
428
+ if (error instanceof ApiError) {
429
+ Object.assign(error, {
430
+ code: normalizedInteractionErrorCode(error.code),
431
+ requestId: error.requestId ?? requestId,
432
+ sessionId: error.sessionId ?? id,
433
+ platform: error.platform ?? platform,
434
+ action: error.action ?? interaction.action,
435
+ });
436
+ }
437
+ if (error instanceof ApiTransportError) {
438
+ throw new CliInteractionError(`Simulator ${interaction.action} could not reach the run.cloud API`, 'interaction_transport_error', requestId, interaction.action, id, platform);
439
+ }
440
+ throw error;
441
+ }
442
+ print(result, opts);
443
+ }
444
+ async function captureSimulatorScreenshot(platform, id, opts) {
445
+ const timeoutMs = interactionTimeout(opts);
446
+ const requestId = interactionRequestId(opts);
447
+ let bytes;
448
+ try {
449
+ bytes = await withInteractionCancellation(timeoutMs, requestId, 'screenshot', id, platform, 'screenshot', async (signal) => await client().getBinary(`/run-cloud/${platform}/${encodeURIComponent(id)}/screenshot`, { signal, headers: { 'X-Run-Cloud-Request-ID': requestId } }));
450
+ }
451
+ catch (error) {
452
+ if (error instanceof ApiError) {
453
+ Object.assign(error, {
454
+ code: normalizedScreenshotErrorCode(error.code),
455
+ requestId: error.requestId ?? requestId,
456
+ sessionId: error.sessionId ?? id,
457
+ platform: error.platform ?? platform,
458
+ action: error.action ?? 'screenshot',
459
+ });
460
+ }
461
+ if (error instanceof ApiTransportError) {
462
+ throw new CliInteractionError('Simulator screenshot could not reach the run.cloud API', 'screenshot_transport_error', requestId, 'screenshot', id, platform);
463
+ }
464
+ throw error;
465
+ }
466
+ if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
467
+ throw new CliInteractionError('Simulator screenshot response was not a valid PNG image', 'screenshot_invalid_response', requestId, 'screenshot', id, platform);
468
+ }
469
+ const output = resolve(opts.output);
470
+ mkdirSync(dirname(output), { recursive: true });
471
+ writeFileSync(output, bytes);
472
+ const result = {
473
+ ok: true,
474
+ requestId,
475
+ sessionId: id,
476
+ platform,
477
+ action: 'screenshot',
478
+ status: 'completed',
479
+ output,
480
+ byteSize: bytes.byteLength,
481
+ sha256: createHash('sha256').update(bytes).digest('hex'),
482
+ };
483
+ if (opts.json)
484
+ console.log(JSON.stringify(result, null, 2));
485
+ else
486
+ console.log(`Saved ${platform} simulator screenshot to ${output} (${bytes.byteLength} bytes)`);
487
+ }
112
488
  function fileBlob(path) {
113
489
  const resolved = resolve(path);
114
490
  if (!existsSync(resolved))
@@ -319,7 +695,9 @@ async function createSimulatorSession(platform, opts) {
319
695
  function registerSimulatorCommands(program, platform) {
320
696
  const label = platform === 'android' ? 'Android emulator' : 'iOS simulator';
321
697
  const article = platform === 'android' ? 'an' : 'an';
322
- const simulator = program.command(platform).description(`Create, list, inspect, and delete ${label} sessions`);
698
+ const simulator = program
699
+ .command(platform)
700
+ .description(`Create, inspect, interact with, capture, and release ${label} sessions`);
323
701
  simulator
324
702
  .command('create')
325
703
  .description(`Create a remote ${label} session`)
@@ -372,6 +750,138 @@ function registerSimulatorCommands(program, platform) {
372
750
  .action((url, opts) => action(async () => {
373
751
  print(await client().post(`/run-cloud/${platform}/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
374
752
  }));
753
+ addInteractionOptions(simulator
754
+ .command('tap')
755
+ .description(`Tap normalized display coordinates in ${article} ${label} session`)
756
+ .argument('<id>', 'session id')
757
+ .argument('<x>', 'normalized x coordinate from 0 (left) to 1 (right)')
758
+ .argument('<y>', 'normalized y coordinate from 0 (top) to 1 (bottom)')).action((id, x, y, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'tap', ...normalizedPoint(x, y, 'tap') }, opts), opts));
759
+ addInteractionOptions(simulator
760
+ .command('swipe')
761
+ .description(`Swipe between normalized display coordinates in ${article} ${label} session`)
762
+ .argument('<id>', 'session id')
763
+ .argument('<from-x>', 'start x from 0 to 1')
764
+ .argument('<from-y>', 'start y from 0 to 1')
765
+ .argument('<to-x>', 'end x from 0 to 1')
766
+ .argument('<to-y>', 'end y from 0 to 1')
767
+ .option('--duration <milliseconds>', 'swipe duration in milliseconds (50-30000)')).action((id, fromX, fromY, toX, toY, opts) => action(() => runSimulatorInteraction(platform, id, {
768
+ action: 'swipe',
769
+ from: normalizedPoint(fromX, fromY, 'swipe start'),
770
+ to: normalizedPoint(toX, toY, 'swipe end'),
771
+ ...(opts.duration === undefined
772
+ ? {}
773
+ : { durationMs: integerInRange(opts.duration, '--duration', 50, 30_000) }),
774
+ }, opts), opts));
775
+ addInteractionOptions(simulator
776
+ .command('gesture')
777
+ .description(`Run an ordered one- or two-finger gesture in ${article} ${label} session`)
778
+ .argument('<id>', 'session id')
779
+ .requiredOption('--steps <json>', 'JSON steps: [{"phase":"begin|move|end","points":[{"x":0.5,"y":0.5}],"delayMs":0}]')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, {
780
+ action: 'gesture',
781
+ steps: parseSimulatorGestureSteps(opts.steps, interactionTimeout(opts)),
782
+ }, opts), opts));
783
+ addInteractionOptions(simulator
784
+ .command('type-text')
785
+ .description(`Type US ASCII text into ${article} ${label} session`)
786
+ .argument('<id>', 'session id')
787
+ .argument('<text>', '1-10000 US ASCII characters; quote text containing spaces')).action((id, text, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'typeText', text }, opts), opts));
788
+ addInteractionOptions(simulator
789
+ .command('press-key')
790
+ .description(`Press a semantic keyboard key in ${article} ${label} session`)
791
+ .argument('<id>', 'session id')
792
+ .argument('<key>', 'semantic key, for example a, enter, arrowUp, or f1')
793
+ .option('--duration <milliseconds>', 'key hold duration in milliseconds (20-30000)')
794
+ .option('--shift', 'hold the left Shift modifier', false)
795
+ .option('--control', 'hold the left Control modifier', false)
796
+ .option('--alt', 'hold the left Alt modifier', false)
797
+ .option('--meta', 'hold the left Meta/Command modifier', false)
798
+ .addHelpText('after', `\nSupported keys:\n ${SIMULATOR_KEYS.join(', ')}\n`)).action((id, rawKey, opts) => action(() => {
799
+ const modifiers = ['shift', 'control', 'alt', 'meta']
800
+ .filter((modifier) => opts[modifier]);
801
+ return runSimulatorInteraction(platform, id, {
802
+ action: 'pressKey',
803
+ key: simulatorKey(rawKey),
804
+ ...(modifiers.length ? { modifiers } : {}),
805
+ ...(opts.duration === undefined
806
+ ? {}
807
+ : { durationMs: integerInRange(opts.duration, '--duration', 20, 30_000) }),
808
+ }, opts);
809
+ }, opts));
810
+ addInteractionOptions(simulator
811
+ .command('press-button')
812
+ .description(`Press a simulator hardware or system button in ${article} ${label} session`)
813
+ .argument('<id>', 'session id')
814
+ .argument('<button>', `one of: ${SIMULATOR_BUTTONS.join(', ')}`)
815
+ .option('--duration <milliseconds>', 'button hold duration in milliseconds (20-30000)')).action((id, rawButton, opts) => action(() => runSimulatorInteraction(platform, id, {
816
+ action: 'pressButton',
817
+ button: simulatorButton(rawButton),
818
+ ...(opts.duration === undefined
819
+ ? {}
820
+ : { durationMs: integerInRange(opts.duration, '--duration', 20, 30_000) }),
821
+ }, opts), opts));
822
+ addInteractionOptions(simulator
823
+ .command('rotate')
824
+ .description(`Set the absolute display orientation for ${article} ${label} session`)
825
+ .argument('<id>', 'session id')
826
+ .argument('<orientation>', 'portrait, portrait_upside_down, landscape_left, or landscape_right')).action((id, rawOrientation, opts) => action(() => runSimulatorInteraction(platform, id, {
827
+ action: 'rotate',
828
+ orientation: simulatorOrientation(rawOrientation),
829
+ }, opts), opts));
830
+ addInteractionOptions(simulator
831
+ .command('reload')
832
+ .description(`Reload the foreground app in ${article} ${label} session`)
833
+ .argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'reload' }, opts), opts));
834
+ addInteractionOptions(simulator
835
+ .command('scroll')
836
+ .description(`Send normalized scroll deltas to ${article} ${label} session`)
837
+ .argument('<id>', 'session id')
838
+ .argument('<delta-x>', 'horizontal delta from -1 to 1')
839
+ .argument('<delta-y>', 'vertical delta from -1 to 1')
840
+ .option('--x <coordinate>', 'normalized anchor x; requires --y')
841
+ .option('--y <coordinate>', 'normalized anchor y; requires --x')).action((id, deltaX, deltaY, opts) => action(() => {
842
+ if ((opts.x === undefined) !== (opts.y === undefined))
843
+ throw new Error('--x and --y must be provided together');
844
+ const anchor = opts.x !== undefined && opts.y !== undefined
845
+ ? normalizedPoint(opts.x, opts.y, 'scroll anchor')
846
+ : undefined;
847
+ return runSimulatorInteraction(platform, id, {
848
+ action: 'scroll',
849
+ deltaX: finiteInRange(deltaX, 'delta-x', -1, 1),
850
+ deltaY: finiteInRange(deltaY, 'delta-y', -1, 1),
851
+ ...(anchor ?? {}),
852
+ }, opts);
853
+ }, opts));
854
+ addInteractionOptions(simulator
855
+ .command('toggle-software-keyboard')
856
+ .description(`Toggle the software keyboard in ${article} ${label} session`)
857
+ .argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'toggleSoftwareKeyboard' }, opts), opts));
858
+ addInteractionOptions(simulator
859
+ .command('simulate-memory-warning')
860
+ .description(`Send a memory-warning event to ${article} ${label} session`)
861
+ .argument('<id>', 'session id')).action((id, opts) => action(() => runSimulatorInteraction(platform, id, { action: 'simulateMemoryWarning' }, opts), opts));
862
+ addInteractionOptions(simulator
863
+ .command('rotate-digital-crown')
864
+ .description(`Request Digital Crown input for ${article} ${label} session (unsupported on current mobile sessions)`)
865
+ .argument('<id>', 'session id')
866
+ .argument('<delta>', 'nonzero crown delta from -10000 to 10000')).action((id, delta, opts) => action(() => runSimulatorInteraction(platform, id, {
867
+ action: 'rotateDigitalCrown',
868
+ delta: finiteInRange(delta, 'delta', -10_000, 10_000),
869
+ }, opts), opts));
870
+ addInteractionOptions(simulator
871
+ .command('set-render-debug')
872
+ .description(`Enable or disable a simulator render diagnostic in ${article} ${label} session`)
873
+ .argument('<id>', 'session id')
874
+ .argument('<option>', `one of: ${SIMULATOR_RENDER_DEBUG_OPTIONS.join(', ')}`)
875
+ .argument('<enabled>', 'true or false')).action((id, rawOption, rawEnabled, opts) => action(() => runSimulatorInteraction(platform, id, {
876
+ action: 'setRenderDebug',
877
+ option: renderDebugOption(rawOption),
878
+ enabled: booleanArgument(rawEnabled, 'enabled'),
879
+ }, opts), opts));
880
+ addInteractionOptions(simulator
881
+ .command('screenshot')
882
+ .description(`Capture the current display from ${article} ${label} session`)
883
+ .argument('<id>', 'session id')
884
+ .requiredOption('-o, --output <path>', 'PNG output path')).action((id, opts) => action(() => captureSimulatorScreenshot(platform, id, opts), opts));
375
885
  simulator
376
886
  .command('logs')
377
887
  .description(`Read or follow logs from ${article} active ${label} session`)
@@ -414,16 +924,89 @@ function registerSimulatorCommands(program, platform) {
414
924
  }));
415
925
  return simulator;
416
926
  }
417
- async function action(fn) {
927
+ function jsonErrorEnvelope(error) {
928
+ if (error instanceof ApiError) {
929
+ return {
930
+ ok: false,
931
+ ...(error.requestId ? { requestId: error.requestId } : {}),
932
+ ...(error.sessionId ? { sessionId: error.sessionId } : {}),
933
+ ...(error.platform ? { platform: error.platform } : {}),
934
+ ...(error.action ? { action: error.action } : {}),
935
+ ...(error.interactionStatus ? { status: error.interactionStatus } : {}),
936
+ ...(error.acceptedAt ? { acceptedAt: error.acceptedAt } : {}),
937
+ ...(error.completedAt ? { completedAt: error.completedAt } : {}),
938
+ ...(error.durationMs === undefined ? {} : { durationMs: error.durationMs }),
939
+ error: {
940
+ code: error.code ?? `http_${error.status}`,
941
+ message: error.detail || friendlyApiError(error),
942
+ retryable: error.retryable ?? (error.status === 408 || error.status === 429 || error.status >= 500),
943
+ httpStatus: error.status,
944
+ ...(error.details === undefined ? {} : { details: error.details }),
945
+ },
946
+ };
947
+ }
948
+ if (error instanceof CliInteractionError) {
949
+ const status = error.code.endsWith('_timeout')
950
+ ? 'timed_out'
951
+ : error.code.endsWith('_cancelled')
952
+ ? 'cancelled'
953
+ : 'failed';
954
+ return {
955
+ ok: false,
956
+ requestId: error.requestId,
957
+ sessionId: error.sessionId,
958
+ platform: error.platform,
959
+ action: error.actionName,
960
+ status,
961
+ error: {
962
+ code: error.code,
963
+ message: error.message,
964
+ retryable: !error.code.endsWith('_cancelled'),
965
+ },
966
+ };
967
+ }
968
+ return {
969
+ ok: false,
970
+ error: {
971
+ code: 'invalid_request',
972
+ message: error instanceof Error ? error.message : String(error),
973
+ retryable: false,
974
+ },
975
+ };
976
+ }
977
+ async function action(fn, opts = {}) {
418
978
  try {
419
979
  await fn();
420
980
  }
421
981
  catch (err) {
422
- console.error(friendlyApiError(err));
423
- process.exitCode = 1;
982
+ console.error(opts.json ? JSON.stringify(jsonErrorEnvelope(err), null, 2) : friendlyApiError(err));
983
+ process.exitCode = err instanceof CliInteractionError && err.code.endsWith('_cancelled') ? 130 : 1;
424
984
  }
425
985
  }
986
+ function configureMachineReadableParseErrors(program) {
987
+ const output = program.configureOutput();
988
+ program.configureOutput({
989
+ ...output,
990
+ outputError: (message, write) => {
991
+ const rawArgs = program.rawArgs ?? [];
992
+ const argumentsSeen = [...process.argv, ...rawArgs, ...program.args];
993
+ if (!argumentsSeen.includes('--json')) {
994
+ (output.outputError ?? ((value, destination) => destination(value)))(message, write);
995
+ return;
996
+ }
997
+ write(`${JSON.stringify({
998
+ ok: false,
999
+ error: {
1000
+ code: 'invalid_request',
1001
+ message: message.replace(/^error:\s*/i, '').trim(),
1002
+ retryable: false,
1003
+ },
1004
+ })}\n`);
1005
+ },
1006
+ });
1007
+ }
426
1008
  export function registerRunCloud(program) {
1009
+ configureMachineReadableParseErrors(program);
427
1010
  const sample = program
428
1011
  .command('sample')
429
1012
  .alias('samples')
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.1.108';
1
+ export const CLI_VERSION = '0.1.109';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.1.108",
3
+ "version": "0.1.109",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
Binary file
@@ -1,44 +1,32 @@
1
1
  ---
2
2
  name: run-cloud-ios-simulator
3
- description: Operate run.cloud iOS simulator and Android emulator sessions with the CLI or TypeScript SDK. Use for creating, installing, inspecting, reading device logs, embedding, smoke-testing, connecting local Metro, capturing iOS screenshots, injecting iOS media, or releasing remote mobile sessions.
3
+ description: Operate run.cloud iOS simulator and Android emulator sessions with the CLI or TypeScript SDK. Use for creating, installing, inspecting, reading logs, controlling, embedding, smoke-testing, connecting local Metro, taking screenshots, injecting iOS media, or releasing remote mobile sessions.
4
4
  ---
5
5
 
6
6
  # Operate run.cloud Mobile Sessions
7
7
 
8
- Use run.cloud through the `runcloud` CLI for terminal workflows and
9
- `@run-cloud/sdk` for application, CI, or agent code.
8
+ Use the `runcloud` CLI for terminal workflows, `@run-cloud/sdk` for TypeScript,
9
+ and `@run-cloud/ui` for React embeds.
10
10
 
11
11
  ## Authenticate
12
12
 
13
13
  - Install the CLI with `npm install -g runcloud`.
14
- - Use `runcloud login` for an interactive browser handoff. Use
15
- `runcloud login --manual` when a local callback cannot open.
14
+ - Use `runcloud login`; add `--manual` when a local callback cannot open.
16
15
  - In CI, set `RUN_CLOUD_API_KEY`. `RUN_CLOUD_API_TOKEN` is an equivalent alias.
17
- - Set `RUN_CLOUD_API_URL` only to override the production default
18
- `https://api.run.cloud`.
19
- - Never print, commit, or place credentials in a skill file.
20
- - Treat signed simulator URLs and tunnel URLs as bearer secrets.
21
- - Require Node.js 20 or newer for the CLI and TypeScript SDK.
16
+ - Set `RUN_CLOUD_API_URL` only to override `https://api.run.cloud`.
17
+ - Require Node.js 20 or newer.
18
+ - Never print, commit, or place credentials in a skill file. Treat signed session
19
+ and tunnel URLs as bearer secrets.
22
20
 
23
- Inspect account and organization credit before starting metered work:
21
+ Check access and credit before starting metered work:
24
22
 
25
23
  ```bash
26
24
  runcloud account --json
27
25
  ```
28
26
 
29
- Sessions require product access, organization credit below its ceiling, and
30
- available fleet capacity. A create request may queue while capacity is full.
27
+ The SDK equivalents are `cloud.account()` and `cloud.usage({ orgId? })`.
31
28
 
32
- ## Choose the Interface
33
-
34
- - Prefer CLI commands with `--json` for shell automation.
35
- - Prefer `@run-cloud/sdk` for TypeScript applications and CI.
36
- - Inspect `runcloud <command> --help` or installed SDK types before using a
37
- method not documented here.
38
-
39
- ## Use the CLI
40
-
41
- Create an iOS session, capture its ID, open a deep link, and release it:
29
+ ## Create and Release Sessions
42
30
 
43
31
  ```bash
44
32
  SESSION_ID=$(runcloud ios create \
@@ -48,79 +36,51 @@ SESSION_ID=$(runcloud ios create \
48
36
  --json | jq -r '.id')
49
37
 
50
38
  trap 'runcloud ios delete "$SESSION_ID" >/dev/null 2>&1 || true' EXIT
51
-
52
39
  runcloud ios get "$SESSION_ID" --json
53
- runcloud ios open-url myapp://settings --id "$SESSION_ID" --json
54
40
  ```
55
41
 
56
- Use the corresponding `runcloud android` commands for Android artifacts.
57
-
58
- The shared mobile lifecycle is:
59
-
60
- - `runcloud ios|android create`
61
- - `runcloud ios|android list [--all]`
62
- - `runcloud ios|android get <id>`
63
- - `runcloud ios|android open-url <url> --id <id>`
64
- - `runcloud ios|android logs <id> [--tail N|--follow]`
65
- - `runcloud ios|android delete <id>`
66
-
67
- Create accepts `--model`, `--region`, `--display-name`, repeatable `--label`,
68
- repeatable `--install`, repeatable `--install-asset`,
69
- `--inactivity-timeout`, `--hard-timeout`, `--codec`, `--rm`, and `--json`.
70
-
71
- Use assets and samples when no local artifact is ready:
72
-
73
- ```bash
74
- runcloud sample download ios
75
- runcloud ios create --install ./run-cloud-sample-ios.app.tar.gz --json
76
-
77
- runcloud asset push ./build/MyApp.tar.gz --name my-app --json
78
- runcloud ios create --install-asset my-app --json
79
- runcloud asset list --json
80
- runcloud asset pull <asset-id> --output ./MyApp.tar.gz
81
- runcloud asset delete <asset-id> --json
82
- ```
42
+ Replace `ios` with `android` for an Android artifact. The shared lifecycle is
43
+ `create`, `list`, `get`, `open-url`, `logs`, and `delete`. Use `--json` whenever
44
+ another program consumes output, and inspect `runcloud ios|android --help` for
45
+ create and log options.
83
46
 
84
47
  iOS needs an Apple Silicon simulator-compatible `.app`, `.zip`, `.tar.gz`, or
85
- `.ipa` artifact. A device-signed App Store IPA is not a substitute. Android
86
- needs an emulator-compatible artifact such as an APK.
48
+ `.ipa`; a device-signed App Store IPA is not a substitute. Android needs an
49
+ emulator-compatible APK.
87
50
 
88
- ## Diagnose App Failures
51
+ ## Control a Session
89
52
 
90
- Read up to 1,000 retained entries from the current lease:
53
+ Both platform groups expose acknowledged controls:
91
54
 
92
55
  ```bash
93
- runcloud ios logs "$SESSION_ID" --tail 1000
94
- runcloud android logs "$SESSION_ID" --tail 1000
56
+ runcloud ios tap "$SESSION_ID" 0.5 0.3 --json
57
+ runcloud ios swipe "$SESSION_ID" 0.5 0.8 0.5 0.2 --duration 300 --json
58
+ runcloud ios type-text "$SESSION_ID" 'hello' --json
59
+ runcloud ios press-key "$SESSION_ID" enter --json
60
+ runcloud ios press-button "$SESSION_ID" home --json
61
+ runcloud ios screenshot "$SESSION_ID" --output ios.png --json
95
62
  ```
96
63
 
97
- Use `--follow` while reproducing an issue, and add `--json` when another tool
98
- will consume the entries. `--tail` and `--follow` are mutually exclusive.
99
- Before releasing a failed session, always capture a bounded snapshot and keep
100
- the relevant entries with the test evidence. A follow stream contains only new
101
- entries and is not a substitute for the retained snapshot.
102
-
103
- ## Connect Local Development
104
-
105
- Connect a local Metro or mock server to an active iOS session:
64
+ The full control set is `tap`, `swipe`, `gesture`, `type-text`, `press-key`,
65
+ `press-button`, `rotate`, `reload`, `scroll`, `toggle-software-keyboard`,
66
+ `simulate-memory-warning`, `rotate-digital-crown`, `set-render-debug`, and
67
+ `screenshot`. Every interaction accepts `--request-id`, `--timeout`, and
68
+ `--json`; use `runcloud <platform> <control> --help` for its typed arguments.
106
69
 
107
- ```bash
108
- runcloud ios tunnel "$SESSION_ID" \
109
- --local-port 8081 \
110
- --service metro \
111
- --json
112
-
113
- runcloud ios tunnel-status --json
114
- ```
70
+ Coordinates use the current display orientation: `(0, 0)` is top-left and
71
+ `(1, 1)` is bottom-right. Gesture steps use one or two points with `begin`,
72
+ `move`, and `end` phases. Each `delayMs` is the pause before the next step, so
73
+ the final `end` step must use `0`. Key names are semantic US-keyboard names;
74
+ modifiers are `shift`, `control`, `alt`, and `meta`. Current mobile sessions do
75
+ not support Digital Crown input, and Android does not support iOS render-debug
76
+ controls or the `capsLock`, `numLock`, and `scrollLock` keys. Handle structured
77
+ `unsupported_action` errors instead of retrying them.
115
78
 
116
- Use the run.cloud sidecar flow. Do not install or expose an unauthenticated
117
- third-party tunnel. If the sidecar is unavailable, report that requirement
118
- instead of guessing a public URL.
79
+ An acknowledgement means input dispatch completed. Confirm visible app effects
80
+ with a screenshot or the signed viewer when the outcome matters.
119
81
 
120
82
  ## Use the TypeScript SDK
121
83
 
122
- Install the SDK:
123
-
124
84
  ```bash
125
85
  npm install @run-cloud/sdk
126
86
  ```
@@ -132,84 +92,96 @@ import { writeFile } from "node:fs/promises";
132
92
  import { Client } from "@run-cloud/sdk";
133
93
 
134
94
  const cloud = new Client();
135
- const session = await cloud.ios.create({
136
- displayName: "Agent smoke",
137
- tags: { owner: "agent" },
95
+ const session = await cloud.android.create({
138
96
  inactivityTimeout: "60s",
139
97
  hardTimeout: "10m",
140
- codec: "auto",
98
+ tags: { owner: "agent" },
141
99
  });
142
100
 
143
101
  try {
144
- await cloud.ios.openUrl(session.id, "https://run.cloud");
145
- const screenshot = await cloud.ios.screenshot(session.id);
146
- await writeFile("run-cloud.png", screenshot);
102
+ await cloud.android.tap(session.id, { x: 0.5, y: 0.3 });
103
+ await cloud.android.typeText(session.id, "hello");
104
+ await cloud.android.pressKey(session.id, "enter");
105
+ const screenshot = await cloud.android.screenshot(session.id);
106
+ await writeFile("android.png", screenshot);
147
107
  } finally {
148
- await cloud.ios.delete(session.id);
108
+ await cloud.android.delete(session.id);
149
109
  }
150
110
  ```
151
111
 
152
- The mobile SDK surface is:
112
+ `cloud.ios`, `cloud.android`, and the platform-selectable `cloud.simulators`
113
+ expose `interact` plus convenience methods matching every CLI control above.
114
+ Interaction options accept `requestId`, `timeoutMs`, and `signal`; results are
115
+ typed acknowledgements. Use `RunCloudError` fields such as `code`, `retryable`,
116
+ `requestId`, and `action` when reporting API failures.
153
117
 
154
- - `cloud.account()` and `cloud.usage({ orgId? })`
155
- - `cloud.ios`: `create`, `list`, `get`, `openUrl`, `logs`, `followLogs`, `screenshot`,
156
- `uploadVideo`, `uploadMicrophoneAudio`, `delete`
157
- - `cloud.android`: `create`, `list`, `get`, `openUrl`, `logs`, `followLogs`, `delete`
158
- - `cloud.simulators`: runtime-platform `create`, `list`, `get`, `openUrl`,
159
- `delete`
160
- - `cloud.assets`: `upload`, `list`, `delete`
118
+ The lifecycle surface also includes `create`, `list`, `get`, `openUrl`, `logs`,
119
+ `followLogs`, and `delete`. Both platforms expose `screenshot`; iOS additionally
120
+ supports `uploadVideo` and `uploadMicrophoneAudio`. Inspect the installed types
121
+ for complete create, asset, log, and media options.
161
122
 
162
- Create options include `model`, `region`, `displayName`, `tags`,
163
- `installAssets`, `inactivityTimeout`, `hardTimeout`, and `codec`.
123
+ In compact form, `cloud.ios`: `create`, `list`, `get`, `openUrl`, `logs`, `followLogs`, `screenshot`.
124
+ `cloud.android` provides the same shared lifecycle and control operations.
164
125
 
165
- Do not invent SDK methods for scripted taps, typing, recording, app lifecycle,
166
- or Android screenshots. Browser-stream interaction and iframe commands are
167
- separate from the public SDK.
126
+ ## Diagnose App Failures
168
127
 
169
- ## Inject iOS Media
128
+ ```bash
129
+ runcloud ios logs "$SESSION_ID" --tail 1000
130
+ runcloud android logs "$SESSION_ID" --tail 1000
131
+ ```
132
+
133
+ Use `--follow` while reproducing an issue. Before releasing a failed session,
134
+ capture a bounded retained snapshot; a follow stream contains only new entries.
170
135
 
171
- Use `cloud.ios.uploadVideo(id, video, options)` for MP4 or QuickTime video. It
172
- stores a user-owned asset and imports it into Photos.
136
+ ## Connect Local Development
173
137
 
174
- Use `cloud.ios.uploadMicrophoneAudio(id, audio, options)` for AAC, M4A, MP3,
175
- MP4-audio, or WAV. Pass an optional `bundleId`; otherwise it targets the
176
- foreground app. The operation relaunches the target app with microphone
177
- permission and loops the decoded audio through `AVAudioEngine`.
138
+ Connect a local Metro or mock server through the supported sidecar flow:
139
+
140
+ ```bash
141
+ runcloud ios tunnel "$SESSION_ID" --local-port 8081 --service metro --json
142
+ runcloud ios tunnel-status --json
143
+ ```
178
144
 
179
- Delete uploaded assets when they are no longer needed.
145
+ Do not expose an unauthenticated third-party tunnel. If the sidecar is
146
+ unavailable, report that requirement instead of guessing a public URL.
180
147
 
181
148
  ## Embed a Session
182
149
 
183
- - In React, use `RemoteControl` from `@runcloud/ui` with the signed session URL.
184
- - Add `embed=1` to the signed session URL for the clean iframe UI.
185
- - Add `loadingGuard=1` when the iframe should block interaction until streaming
186
- and app launch are ready.
187
- - For raw iframes, verify both `event.source` and the exact signed-URL origin
188
- before processing messages, and use that exact origin as the command
189
- `postMessage` target.
190
- - Handle `ios-simulator:status`, `ios-simulator:auth-error`,
191
- `ios-simulator:session-ended`, and
192
- `ios-simulator:session-restart-requested`.
193
- - Create a new session after a restart request; never reuse an ended URL.
194
- - Use `ios-simulator:command` for `reload`, `home`, `rotate`, `screenshot`, and
195
- `toggleAccessibility`.
196
-
197
- ## Run Maintained Demos
150
+ - Use `RemoteControl` from `@run-cloud/ui` with the signed session URL.
151
+ - Its ref exposes promise-based `interact` and convenience methods matching the
152
+ SDK controls. Handle `onInteractionResult` for inspectable acknowledgements.
153
+ - Add `embed=1` to raw iframe URLs. Add `loadingGuard=1` when interaction should
154
+ wait for streaming and app readiness.
155
+ - Raw iframe requests use `run-cloud:interaction`; acknowledgements use
156
+ `run-cloud:interaction-result`. Correlate them by `requestId`. To stop a
157
+ pending request, post `run-cloud:interaction-cancel` with the same
158
+ `requestId` and `action`.
159
+ - Verify `event.source` and the exact signed-URL origin, and use that origin as
160
+ the `postMessage` target.
161
+ - Legacy `ios-simulator:command` messages remain compatibility-only. Prefer the
162
+ generic acknowledged interaction channel for new code.
163
+ - Create a new session after an `ios-simulator:session-restart-requested`
164
+ message; never reuse an ended URL.
165
+
166
+ ## Assets, Samples, and Demos
198
167
 
199
168
  ```bash
169
+ runcloud sample download ios
170
+ runcloud asset push ./build/MyApp.tar.gz --name my-app --json
171
+ runcloud asset pull <asset-id>
172
+ runcloud ios create --install-asset my-app --json
200
173
  runcloud demo run eight-device-mosaic --open
201
174
  runcloud demo run live-camera-relay --open
202
175
  ```
203
176
 
204
- The bundled demos release their sessions automatically.
177
+ Delete uploaded assets when no longer needed. Bundled demos release their
178
+ sessions automatically.
205
179
 
206
180
  ## Guardrails
207
181
 
208
- - Release every session created during a task unless the user explicitly asks
209
- to keep it open.
182
+ - Release every session created during a task unless asked to keep it open.
210
183
  - Use inactivity and hard timeouts for unattended work.
211
- - Verify platform compatibility before changing application code after an
184
+ - Verify artifact/platform compatibility before changing app code after an
212
185
  install failure.
213
186
  - Do not expose credentials, signed viewer URLs, tunnel URLs, or simulator
214
187
  tokens in logs, screenshots, PR comments, or chat output.
215
- - Do not claim that browser iframe controls are public SDK methods.