rnxsim 0.1.513 → 0.1.515

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/cli/cloud-client.ts +18 -8
  2. package/cli/commands/inspect/get-layout.ts +2 -1
  3. package/cli/outbound-endpoints.ts +1 -7
  4. package/dist-lib/agent-daemon-client.cjs +1 -1
  5. package/dist-lib/agent-events.cjs +1 -1
  6. package/dist-lib/agent-identity.cjs +1 -1
  7. package/dist-lib/agent-sessions.cjs +1 -1
  8. package/dist-lib/attached-projects.cjs +1 -1
  9. package/dist-lib/auth/shared-session.cjs +1 -1
  10. package/dist-lib/backend-origin.cjs +1 -1
  11. package/dist-lib/beta.cjs +1 -1
  12. package/dist-lib/beta.mjs +1 -1
  13. package/dist-lib/bridge-constants.cjs +1 -1
  14. package/dist-lib/bridge-contract-input.cjs +64 -1
  15. package/dist-lib/bridge-contract-input.mjs +63 -1
  16. package/dist-lib/bridge-contract.cjs +73 -3
  17. package/dist-lib/bridge-contract.mjs +69 -2
  18. package/dist-lib/capture-contract.cjs +1 -1
  19. package/dist-lib/capture-contract.mjs +1 -1
  20. package/dist-lib/cli-constants.cjs +1 -1
  21. package/dist-lib/cloud-contract.cjs +117 -22
  22. package/dist-lib/cloud-contract.mjs +104 -21
  23. package/dist-lib/cloud.cjs +121 -27
  24. package/dist-lib/cloud.mjs +121 -27
  25. package/dist-lib/config.cjs +1 -1
  26. package/dist-lib/detox/index.cjs +1 -1
  27. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  28. package/dist-lib/home-paths.cjs +1 -1
  29. package/dist-lib/host/bridge-host.cjs +45 -20
  30. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  31. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  32. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  33. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  34. package/dist-lib/host/websocket-proxy.cjs +1 -1
  35. package/dist-lib/index.cjs +62 -24
  36. package/dist-lib/jump-to-source-babel.cjs +1 -1
  37. package/dist-lib/jump-to-source-native.cjs +1 -1
  38. package/dist-lib/menu.cjs +1 -1
  39. package/dist-lib/menu.mjs +1 -1
  40. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  41. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  42. package/dist-lib/metro-production-bundle.cjs +1 -1
  43. package/dist-lib/metro-production-bundle.mjs +1 -1
  44. package/dist-lib/metro.cjs +1 -1
  45. package/dist-lib/profiles.cjs +1 -1
  46. package/dist-lib/public-brand.cjs +1 -1
  47. package/dist-lib/react-native-host-modules.cjs +1 -1
  48. package/dist-lib/react-native-host-modules.mjs +1 -1
  49. package/dist-lib/render-mode.cjs +1 -1
  50. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  51. package/dist-lib/sdk.cjs +1 -1
  52. package/dist-lib/sdk.mjs +1 -1
  53. package/dist-lib/skills.cjs +62 -24
  54. package/dist-lib/swift.cjs +1 -1
  55. package/dist-lib/vite.cjs +1 -1
  56. package/package.json +2 -1
  57. package/src/bridge-contract-input.ts +74 -0
  58. package/src/bridge-contract.ts +103 -0
  59. package/src/cloud-contract.ts +191 -19
  60. package/src/cloud.ts +104 -7
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.513 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.515 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -27513,35 +27513,66 @@ var init_metro_fingerprint = __esm({
27513
27513
  });
27514
27514
 
27515
27515
  // src/cloud-contract.ts
27516
+ function scopeTableKeys() {
27517
+ return Object.keys(RNX_CLOUD_COMMAND_SCOPES).filter(
27518
+ (key) => Object.prototype.hasOwnProperty.call(RNX_CLOUD_COMMAND_SCOPES, key)
27519
+ );
27520
+ }
27516
27521
  function isRnxCloudCommandType(value) {
27517
27522
  return RNX_CLOUD_COMMAND_TYPE_SET.has(value);
27518
27523
  }
27519
- var RNX_CLOUD_MAX_ARTIFACT_BYTES, RNX_CLOUD_DESIGN_MAX_STORAGE_BYTES, RNX_CLOUD_COMMAND_TYPES, RNX_CLOUD_COMMAND_TYPE_SET;
27524
+ function rnxCloudCommandScope(type) {
27525
+ return RNX_CLOUD_COMMAND_SCOPES[type];
27526
+ }
27527
+ function rnxCloudScopesAllowCommand(scopes, type) {
27528
+ return scopes.includes(RNX_CLOUD_COMMAND_SCOPES[type]);
27529
+ }
27530
+ var RNX_CLOUD_MAX_ARTIFACT_BYTES, RNX_CLOUD_DESIGN_MAX_STORAGE_BYTES, RNX_CLOUD_COMMAND_SCOPES, RNX_CLOUD_COMMAND_TYPES, RNX_CLOUD_COMMAND_TYPE_SET, RNX_CLOUD_TOKEN_SCOPES, RNX_CLOUD_ALL_COMMAND_SCOPES;
27520
27531
  var init_cloud_contract = __esm({
27521
27532
  "src/cloud-contract.ts"() {
27522
27533
  "use strict";
27523
27534
  RNX_CLOUD_MAX_ARTIFACT_BYTES = 20 * 1024 * 1024;
27524
27535
  RNX_CLOUD_DESIGN_MAX_STORAGE_BYTES = 1024 * 1024;
27525
- RNX_CLOUD_COMMAND_TYPES = Object.freeze([
27526
- "state",
27527
- "query",
27528
- "resolve",
27529
- "perform",
27530
- "waitFor",
27536
+ RNX_CLOUD_COMMAND_SCOPES = {
27537
+ state: "read",
27538
+ tree: "read",
27539
+ query: "read",
27540
+ resolve: "read",
27541
+ screenshot: "read",
27542
+ capture: "read",
27543
+ captureRegions: "read",
27544
+ memory: "read",
27545
+ diagnostics: "read",
27546
+ storageSnapshot: "read",
27547
+ perform: "drive",
27548
+ waitFor: "drive",
27549
+ tap: "drive",
27550
+ longPress: "drive",
27551
+ settle: "drive",
27552
+ openUrl: "drive",
27553
+ setAppearance: "drive",
27554
+ keyboard: "drive",
27555
+ focus: "drive",
27556
+ flowStatus: "drive",
27557
+ reset: "reset",
27558
+ camera: "media",
27559
+ // string evaluation and arbitrary bridge calls are maestro's boundary: the
27560
+ // customer sdk never carries this scope, and the service grants it only to
27561
+ // the account api key caller. close rides along, since ending the page ends
27562
+ // the sim and belongs to the sim's owner rather than any driver.
27563
+ evaluate: "debug",
27564
+ call: "debug",
27565
+ close: "debug"
27566
+ };
27567
+ RNX_CLOUD_COMMAND_TYPES = Object.freeze(scopeTableKeys());
27568
+ RNX_CLOUD_COMMAND_TYPE_SET = new Set(RNX_CLOUD_COMMAND_TYPES);
27569
+ RNX_CLOUD_TOKEN_SCOPES = Object.freeze([
27570
+ "read",
27571
+ "drive",
27531
27572
  "reset",
27532
- "screenshot",
27533
- "capture",
27534
- "captureRegions",
27535
- "settle",
27536
- "setAppearance",
27537
- "openUrl",
27538
- "tree",
27539
- "tap",
27540
- "longPress",
27541
- "memory",
27542
- "diagnostics"
27573
+ "media"
27543
27574
  ]);
27544
- RNX_CLOUD_COMMAND_TYPE_SET = new Set(RNX_CLOUD_COMMAND_TYPES);
27575
+ RNX_CLOUD_ALL_COMMAND_SCOPES = Object.freeze(["read", "drive", "reset", "media", "debug"]);
27545
27576
  }
27546
27577
  });
27547
27578
 
@@ -28270,10 +28301,17 @@ function cloudCommand(command) {
28270
28301
  for (const [key, value] of Object.entries(command)) {
28271
28302
  if (key !== "id" && key !== "simId") clean[key] = value;
28272
28303
  }
28273
- if (isRnxCloudCommandType(command.type)) return clean;
28274
- throw new Error(
28275
- `a remote simulator does not support bridge command type ${command.type}`
28276
- );
28304
+ if (!isRnxCloudCommandType(command.type)) {
28305
+ throw new Error(
28306
+ `a remote simulator does not support bridge command type ${command.type}`
28307
+ );
28308
+ }
28309
+ if (!rnxCloudScopesAllowCommand(RNX_CLOUD_TOKEN_SCOPES, command.type)) {
28310
+ throw new Error(
28311
+ `a remote simulator command ${command.type} requires the ${rnxCloudCommandScope(command.type)} scope`
28312
+ );
28313
+ }
28314
+ return clean;
28277
28315
  }
28278
28316
  function createCloudBridgeForParsed(parsed) {
28279
28317
  const session = resolveCloudSessionForParsed(parsed);
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.513 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.515 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.513 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.515 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rnxsim",
3
- "version": "0.1.513",
3
+ "version": "0.1.515",
4
4
  "description": "Vite and Metro plugins, testing drivers, and SDK exports for rnx.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -92,6 +92,7 @@
92
92
  "./bridge-contract-input": {
93
93
  "types": "./src/bridge-contract-input.ts",
94
94
  "source": "./src/bridge-contract-input.ts",
95
+ "bun": "./src/bridge-contract-input.ts",
95
96
  "import": "./dist-lib/bridge-contract-input.mjs",
96
97
  "default": "./dist-lib/bridge-contract-input.cjs"
97
98
  },
@@ -198,6 +198,46 @@ export function parsePerformStep(value: unknown, index: number): SimPerformStep
198
198
  return { type, key: stringValue(step.key, `${path}.key`) }
199
199
  case 'dismissKeyboard':
200
200
  return { type }
201
+ case 'setElementValue': {
202
+ const submit = optionalBoolean(step, 'submit', path)
203
+ return {
204
+ type,
205
+ id: stringValue(step.id, `${path}.id`),
206
+ text: stringValue(step.text, `${path}.text`),
207
+ ...(submit === undefined ? {} : { submit }),
208
+ }
209
+ }
210
+ case 'toggleKeyboard':
211
+ return { type }
212
+ case 'openUrl': {
213
+ const url = stringValue(step.url, `${path}.url`)
214
+ if (url.length === 0) validationFailure(`${path}.url`, 'must be non-empty')
215
+ return { type, url }
216
+ }
217
+ case 'keyDown':
218
+ case 'keyUp':
219
+ return { type, key: stringValue(step.key, `${path}.key`) }
220
+ case 'setOrientation': {
221
+ const orientation = stringValue(step.orientation, `${path}.orientation`)
222
+ if (orientation !== 'portrait' && orientation !== 'landscape') {
223
+ validationFailure(`${path}.orientation`, 'must be portrait or landscape')
224
+ }
225
+ return { type, orientation }
226
+ }
227
+ case 'deviceInfo':
228
+ return { type }
229
+ case 'buttonDown':
230
+ case 'buttonUp': {
231
+ const button =
232
+ step.button === undefined ? undefined : stringValue(step.button, `${path}.button`)
233
+ return { type, ...(button === undefined ? {} : { button }) }
234
+ }
235
+ case 'incrementElement':
236
+ case 'decrementElement':
237
+ return { type, id: stringValue(step.id, `${path}.id`) }
238
+ case 'discoverStoreKitConfig':
239
+ case 'clearStoreKitConfig':
240
+ return { type }
201
241
  case 'wait':
202
242
  return { type, ms: numberValue(step.ms, `${path}.ms`) }
203
243
  case 'waitFor': {
@@ -233,6 +273,40 @@ export function parsePerformStep(value: unknown, index: number): SimPerformStep
233
273
  }
234
274
  }
235
275
 
276
+ // an unclosed touchDown leaves a pointer stuck down, and the batch abort path
277
+ // would cancel it after the caller was told the batch failed. every batch is
278
+ // self-contained (pointers never survive it), so a touchDown without its
279
+ // touchUp or touchCancel in the same batch is rejected before anything runs.
280
+ export function findUnbalancedTouchDown(
281
+ steps: readonly SimPerformStep[],
282
+ ): { index: number; pointerId: number } | null {
283
+ const open = new Map<number, number>()
284
+ const pointerIdOf = (step: SimPerformStep): number | null => {
285
+ if (
286
+ step.type !== 'touchDown' &&
287
+ step.type !== 'touchMove' &&
288
+ step.type !== 'touchUp' &&
289
+ step.type !== 'touchCancel'
290
+ ) {
291
+ return null
292
+ }
293
+ const pointerId = step.pointerId ?? 999
294
+ return Number.isSafeInteger(pointerId) ? pointerId : null
295
+ }
296
+ for (const [index, step] of steps.entries()) {
297
+ if (step.type === 'touchDown') {
298
+ const pointerId = pointerIdOf(step) ?? 999
299
+ if (!open.has(pointerId)) open.set(pointerId, index)
300
+ } else if (step.type === 'touchUp' || step.type === 'touchCancel') {
301
+ const pointerId = pointerIdOf(step)
302
+ if (pointerId !== null) open.delete(pointerId)
303
+ }
304
+ }
305
+ const next = open.entries().next()
306
+ if (next.done) return null
307
+ return { index: next.value[1], pointerId: next.value[0] }
308
+ }
309
+
236
310
  export function parseStateOptions(value: unknown): SimStateOptions {
237
311
  const input = recordValue(value, 'state')
238
312
  const route = optionalBoolean(input, 'route', 'state')
@@ -163,6 +163,24 @@ export type PerformStep =
163
163
  condition?: 'present' | 'mounted' | 'absent'
164
164
  timeoutMs?: number
165
165
  }
166
+ // limrun parity steps. each maps onto an engine primitive and reports
167
+ // per step; the refused ones below parse but never run.
168
+ | { type: 'setElementValue'; id: string; text: string; submit?: boolean }
169
+ | { type: 'toggleKeyboard' }
170
+ | { type: 'openUrl'; url: string }
171
+ | { type: 'keyDown'; key: string }
172
+ | { type: 'keyUp'; key: string }
173
+ | { type: 'setOrientation'; orientation: 'portrait' | 'landscape' }
174
+ | { type: 'deviceInfo' }
175
+ // refused with a named reason, never emulated: no hardware-button model,
176
+ // no storekit simulation, no stepper primitive, and key/orientation state
177
+ // the engine does not hold.
178
+ | { type: 'buttonDown'; button?: string }
179
+ | { type: 'buttonUp'; button?: string }
180
+ | { type: 'incrementElement'; id: string }
181
+ | { type: 'decrementElement'; id: string }
182
+ | { type: 'discoverStoreKitConfig' }
183
+ | { type: 'clearStoreKitConfig' }
166
184
 
167
185
  export interface PerformOptions {
168
186
  // stop the batch at the first failing step. default true — a gesture whose
@@ -179,6 +197,91 @@ export interface PerformOptions {
179
197
  timeoutMs?: number
180
198
  }
181
199
 
200
+ // observed device identity for the `deviceInfo` step: the live device spec
201
+ // in both runtimes, so every field is measured, never a default.
202
+ export interface PerformDeviceInfo {
203
+ platform: string
204
+ model?: string
205
+ width: number
206
+ height: number
207
+ scale: number
208
+ }
209
+
210
+ // steps that parse but never run. one owner for the refusal vocabulary so
211
+ // the browser page and the cpu runtime refuse with the same named reason and
212
+ // neither plane emulates what the engine cannot do.
213
+ export const REFUSED_PERFORM_STEP_TYPES = [
214
+ 'keyDown',
215
+ 'keyUp',
216
+ 'setOrientation',
217
+ 'buttonDown',
218
+ 'buttonUp',
219
+ 'incrementElement',
220
+ 'decrementElement',
221
+ 'discoverStoreKitConfig',
222
+ 'clearStoreKitConfig',
223
+ ] as const
224
+
225
+ export type RefusedPerformStepType = (typeof REFUSED_PERFORM_STEP_TYPES)[number]
226
+
227
+ export function refusedPerformStepReason(type: RefusedPerformStepType): string {
228
+ switch (type) {
229
+ case 'keyDown':
230
+ case 'keyUp':
231
+ return `${type} is not supported: keys dispatch atomically; use the key step`
232
+ case 'setOrientation':
233
+ return 'setOrientation is not supported: the simulator viewport is fixed at boot'
234
+ case 'buttonDown':
235
+ case 'buttonUp':
236
+ return `${type} is not supported: the simulator has no hardware buttons`
237
+ case 'incrementElement':
238
+ case 'decrementElement':
239
+ return `${type} is not supported: steppers have no engine primitive; drive them with tapId`
240
+ case 'discoverStoreKitConfig':
241
+ case 'clearStoreKitConfig':
242
+ return `${type} is not supported: storekit has no simulator model`
243
+ }
244
+ }
245
+
246
+ // returns the refusal for a refused step, null when the step is runnable.
247
+ // every variant is listed so adding a step forces its classification here;
248
+ // the executors mirror the refused cases in their own switches.
249
+ export function performStepRefusal(type: PerformStep['type']): string | null {
250
+ switch (type) {
251
+ case 'keyDown':
252
+ case 'keyUp':
253
+ case 'setOrientation':
254
+ case 'buttonDown':
255
+ case 'buttonUp':
256
+ case 'incrementElement':
257
+ case 'decrementElement':
258
+ case 'discoverStoreKitConfig':
259
+ case 'clearStoreKitConfig':
260
+ return refusedPerformStepReason(type)
261
+ case 'touchDown':
262
+ case 'touchMove':
263
+ case 'touchUp':
264
+ case 'touchCancel':
265
+ case 'tap':
266
+ case 'tapId':
267
+ case 'doubleTap':
268
+ case 'longPress':
269
+ case 'drag':
270
+ case 'pinch':
271
+ case 'scroll':
272
+ case 'type':
273
+ case 'key':
274
+ case 'dismissKeyboard':
275
+ case 'setElementValue':
276
+ case 'toggleKeyboard':
277
+ case 'openUrl':
278
+ case 'deviceInfo':
279
+ case 'wait':
280
+ case 'waitFor':
281
+ return null
282
+ }
283
+ }
284
+
182
285
  export interface PerformStepResult {
183
286
  index: number
184
287
  type: PerformStep['type']
@@ -6,25 +6,51 @@ export const RNX_CLOUD_DESIGN_MAX_STORAGE_BYTES = 1024 * 1024
6
6
  export const RNX_CLOUD_TEST_FILES_MAX_COUNT = 256
7
7
  export const RNX_CLOUD_TEST_FILES_MAX_BYTES = 500_000
8
8
 
9
- export const RNX_CLOUD_COMMAND_TYPES = Object.freeze([
10
- 'state',
11
- 'query',
12
- 'resolve',
13
- 'perform',
14
- 'waitFor',
15
- 'reset',
16
- 'screenshot',
17
- 'capture',
18
- 'captureRegions',
19
- 'settle',
20
- 'setAppearance',
21
- 'openUrl',
22
- 'tree',
23
- 'tap',
24
- 'longPress',
25
- 'memory',
26
- 'diagnostics',
27
- ]) satisfies readonly BridgeCommandType[]
9
+ export type RnxCloudCommandScope = 'read' | 'drive' | 'reset' | 'media' | 'debug'
10
+
11
+ // one scope table for both remote planes: every bridge verb names the scope
12
+ // that may send it, so the nano service and the box relay admit the same
13
+ // vocabulary and differ only by caller scope. reads observe, drive moves,
14
+ // reset wipes, media injects camera fixtures, and debug evaluates.
15
+ export const RNX_CLOUD_COMMAND_SCOPES = {
16
+ state: 'read',
17
+ tree: 'read',
18
+ query: 'read',
19
+ resolve: 'read',
20
+ screenshot: 'read',
21
+ capture: 'read',
22
+ captureRegions: 'read',
23
+ memory: 'read',
24
+ diagnostics: 'read',
25
+ storageSnapshot: 'read',
26
+ perform: 'drive',
27
+ waitFor: 'drive',
28
+ tap: 'drive',
29
+ longPress: 'drive',
30
+ settle: 'drive',
31
+ openUrl: 'drive',
32
+ setAppearance: 'drive',
33
+ keyboard: 'drive',
34
+ focus: 'drive',
35
+ flowStatus: 'drive',
36
+ reset: 'reset',
37
+ camera: 'media',
38
+ // string evaluation and arbitrary bridge calls are maestro's boundary: the
39
+ // customer sdk never carries this scope, and the service grants it only to
40
+ // the account api key caller. close rides along, since ending the page ends
41
+ // the sim and belongs to the sim's owner rather than any driver.
42
+ evaluate: 'debug',
43
+ call: 'debug',
44
+ close: 'debug',
45
+ } satisfies Record<BridgeCommandType, RnxCloudCommandScope>
46
+
47
+ function scopeTableKeys(): BridgeCommandType[] {
48
+ return Object.keys(RNX_CLOUD_COMMAND_SCOPES).filter((key): key is BridgeCommandType =>
49
+ Object.prototype.hasOwnProperty.call(RNX_CLOUD_COMMAND_SCOPES, key),
50
+ )
51
+ }
52
+
53
+ export const RNX_CLOUD_COMMAND_TYPES = Object.freeze(scopeTableKeys())
28
54
 
29
55
  const RNX_CLOUD_COMMAND_TYPE_SET = new Set<string>(RNX_CLOUD_COMMAND_TYPES)
30
56
 
@@ -34,6 +60,32 @@ export function isRnxCloudCommandType(
34
60
  return RNX_CLOUD_COMMAND_TYPE_SET.has(value)
35
61
  }
36
62
 
63
+ export function rnxCloudCommandScope(type: BridgeCommandType): RnxCloudCommandScope {
64
+ return RNX_CLOUD_COMMAND_SCOPES[type]
65
+ }
66
+
67
+ // what the full per-simulator token and the box token carry until scoped
68
+ // tokens exist: every scope except debug.
69
+ export const RNX_CLOUD_TOKEN_SCOPES: readonly RnxCloudCommandScope[] = Object.freeze([
70
+ 'read',
71
+ 'drive',
72
+ 'reset',
73
+ 'media',
74
+ ])
75
+
76
+ // every scope, granted only to the account api key caller through the account
77
+ // service. the only credential the services accept for it today is the shared
78
+ // service key, which only the account service holds.
79
+ export const RNX_CLOUD_ALL_COMMAND_SCOPES: readonly RnxCloudCommandScope[] =
80
+ Object.freeze(['read', 'drive', 'reset', 'media', 'debug'])
81
+
82
+ export function rnxCloudScopesAllowCommand(
83
+ scopes: readonly RnxCloudCommandScope[],
84
+ type: BridgeCommandType,
85
+ ): boolean {
86
+ return scopes.includes(RNX_CLOUD_COMMAND_SCOPES[type])
87
+ }
88
+
37
89
  export interface RnxCloudClaim {
38
90
  id: string
39
91
  expiresAt: number
@@ -58,3 +110,123 @@ export interface RnxCloudBoxCreateReceipt {
58
110
  }
59
111
  simulator: RnxCloudCreateReceipt
60
112
  }
113
+
114
+ // what a cloud simulator reports. the stored lifecycle values are
115
+ // `creating`/`open`/`hibernated`/`deleting`; `deleted` is only ever a response,
116
+ // answered by a delete, because a deleted simulator has no row left to read.
117
+ export type RnxCloudSimStatus =
118
+ | 'creating'
119
+ | 'open'
120
+ | 'hibernated'
121
+ | 'deleting'
122
+ | 'deleted'
123
+
124
+ // one simulator as the instance list reports it: the lifecycle view plus the
125
+ // labels it was created with.
126
+ export interface RnxCloudSimView {
127
+ simId: string
128
+ status: RnxCloudSimStatus
129
+ labels: Record<string, string>
130
+ artifact: { sha256: string; bytes: number }
131
+ claim: RnxCloudClaim | null
132
+ createdAt: number
133
+ lastActiveAt: number
134
+ lastHibernatedAt?: number
135
+ storageFrom?: string
136
+ /** the idle window the creator set, or null for the service default. */
137
+ inactivityTimeoutMs: number | null
138
+ /** the instant the creator's hard timeout stops this simulator, or null. */
139
+ hardTimeoutAt: number | null
140
+ }
141
+
142
+ // one simulator as create and get report it, with the watch URL a browser
143
+ // opens to see it.
144
+ export interface RnxCloudSimInstance extends RnxCloudSimView {
145
+ /** a watch URL with its grant in the fragment, or null when none can be
146
+ * minted yet: an unconfirmed simulator is not watchable. */
147
+ streamUrl: string | null
148
+ }
149
+
150
+ // the create/reuse answer: the receipt the box path also returns, plus what
151
+ // only the instance API knows.
152
+ export interface RnxCloudSimCreateResponse extends RnxCloudCreateReceipt {
153
+ token: string
154
+ status: RnxCloudSimStatus
155
+ labels: Record<string, string>
156
+ streamUrl: string | null
157
+ }
158
+
159
+ // one grammar for the create map and the list selector: `key=value,key2=value2`.
160
+ // a value may be empty; a key may not; a repeated key is the caller's mistake
161
+ // rather than a silent last-one-wins. commas and control characters never
162
+ // belong in a key or value, and 63 characters is the ceiling for either.
163
+ export const RNX_CLOUD_LABELS_MAX_COUNT = 16
164
+ export const RNX_CLOUD_LABEL_MAX_LENGTH = 63
165
+
166
+ const RNX_CLOUD_LABEL_CONTROL = /[\u0000-\u001f\u007f]/
167
+
168
+ export function isValidRnxCloudLabel(key: string, value: string): boolean {
169
+ return (
170
+ key.length > 0 &&
171
+ key.length <= RNX_CLOUD_LABEL_MAX_LENGTH &&
172
+ value.length <= RNX_CLOUD_LABEL_MAX_LENGTH &&
173
+ !key.includes(',') &&
174
+ !key.includes('=') &&
175
+ !value.includes(',') &&
176
+ !RNX_CLOUD_LABEL_CONTROL.test(key) &&
177
+ !RNX_CLOUD_LABEL_CONTROL.test(value)
178
+ )
179
+ }
180
+
181
+ /** the wire form, or null when it is not one. `''` is the empty map. */
182
+ export function parseRnxCloudLabels(value: string): Record<string, string> | null {
183
+ if (value === '') return {}
184
+ const entries = value.split(',')
185
+ if (entries.length > RNX_CLOUD_LABELS_MAX_COUNT) return null
186
+ const labels: Record<string, string> = {}
187
+ for (const entry of entries) {
188
+ const separator = entry.indexOf('=')
189
+ if (separator < 1) return null
190
+ const key = entry.slice(0, separator).trim()
191
+ const label = entry.slice(separator + 1).trim()
192
+ if (!isValidRnxCloudLabel(key, label)) return null
193
+ if (Object.hasOwn(labels, key)) return null
194
+ labels[key] = label
195
+ }
196
+ return labels
197
+ }
198
+
199
+ /** the same grammar with the map already split apart, as a json body carries
200
+ * it. anything the wire form would refuse is refused here too. */
201
+ export function readRnxCloudLabels(value: unknown): Record<string, string> | null {
202
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
203
+ const entries = Object.entries(value)
204
+ if (entries.length > RNX_CLOUD_LABELS_MAX_COUNT) return null
205
+ const labels: Record<string, string> = {}
206
+ for (const [key, label] of entries) {
207
+ if (typeof label !== 'string') return null
208
+ if (!isValidRnxCloudLabel(key, label)) return null
209
+ labels[key] = label
210
+ }
211
+ return labels
212
+ }
213
+
214
+ /** every selector entry must be present with the same value. an empty
215
+ * selector selects everything. */
216
+ export function matchesRnxCloudLabels(
217
+ labels: Record<string, string>,
218
+ selector: Record<string, string>,
219
+ ): boolean {
220
+ return Object.entries(selector).every(([key, value]) => labels[key] === value)
221
+ }
222
+
223
+ /** the selector an instance list filters by, given the statuses it accepts. */
224
+ export function isRnxCloudSimStatus(value: string): value is RnxCloudSimStatus {
225
+ return (
226
+ value === 'creating' ||
227
+ value === 'open' ||
228
+ value === 'hibernated' ||
229
+ value === 'deleting' ||
230
+ value === 'deleted'
231
+ )
232
+ }