rnxsim 0.1.431 → 0.1.433

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 (78) hide show
  1. package/README.md +20 -50
  2. package/cli/auth.ts +33 -3
  3. package/cli/cloud-client.ts +524 -88
  4. package/cli/cloud-dispatch.ts +5 -10
  5. package/cli/cloud-session.ts +5 -5
  6. package/cli/commands/auth.ts +13 -2
  7. package/cli/commands/box.ts +189 -20
  8. package/cli/commands/control.ts +40 -13
  9. package/cli/commands/device.ts +1 -1
  10. package/cli/commands/flow.ts +189 -43
  11. package/cli/commands/inspect/get-layout.ts +1 -1
  12. package/cli/commands/inspect.ts +8 -1
  13. package/cli/commands/login.ts +79 -14
  14. package/cli/commands/maestro-generate.ts +70 -54
  15. package/cli/commands/maestro.ts +69 -20
  16. package/cli/commands/platform.ts +30 -32
  17. package/cli/commands/screenshot.ts +3 -2
  18. package/cli/commands/upload.ts +28 -10
  19. package/cli/help-core.ts +11 -1
  20. package/cli/outbound-endpoints.ts +27 -5
  21. package/cli/parse-args.ts +17 -0
  22. package/cli/shell-init.ts +4 -4
  23. package/cli/ws-bridge.ts +109 -62
  24. package/dist-lib/agent-daemon-client.cjs +1 -1
  25. package/dist-lib/agent-events.cjs +1 -1
  26. package/dist-lib/agent-identity.cjs +1 -1
  27. package/dist-lib/agent-sessions.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +17 -4
  30. package/dist-lib/backend-origin.cjs +1 -1
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/capture-contract.cjs +1 -1
  39. package/dist-lib/capture-contract.mjs +1 -1
  40. package/dist-lib/cli-constants.cjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud.cjs +108 -93
  44. package/dist-lib/cloud.mjs +106 -91
  45. package/dist-lib/config.cjs +1 -1
  46. package/dist-lib/detox/index.cjs +1 -1
  47. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  48. package/dist-lib/home-paths.cjs +1 -1
  49. package/dist-lib/host/bridge-host.cjs +1 -1
  50. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  51. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  52. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  53. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  54. package/dist-lib/host/websocket-proxy.cjs +1 -1
  55. package/dist-lib/index.cjs +58 -139
  56. package/dist-lib/jump-to-source-babel.cjs +1 -1
  57. package/dist-lib/menu.cjs +1 -1
  58. package/dist-lib/menu.mjs +1 -1
  59. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  60. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  61. package/dist-lib/metro-production-bundle.cjs +1 -1
  62. package/dist-lib/metro-production-bundle.mjs +1 -1
  63. package/dist-lib/metro.cjs +33 -212
  64. package/dist-lib/profiles.cjs +1 -1
  65. package/dist-lib/public-brand.cjs +1 -1
  66. package/dist-lib/react-native-host-modules.cjs +1 -1
  67. package/dist-lib/react-native-host-modules.mjs +1 -1
  68. package/dist-lib/render-mode.cjs +1 -1
  69. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  70. package/dist-lib/sdk.cjs +1 -1
  71. package/dist-lib/sdk.mjs +1 -1
  72. package/dist-lib/skills.cjs +436 -351
  73. package/dist-lib/vite.cjs +1 -1
  74. package/package.json +2 -1
  75. package/src/auth/shared-session.ts +31 -0
  76. package/src/cloud.ts +83 -113
  77. package/src/metro-plugin.ts +30 -113
  78. package/src/vite-plugin.ts +11 -2
@@ -121,7 +121,7 @@ function refuseCloudInvocation(
121
121
  ): CloudBoundaryResult {
122
122
  const verb = firstVerb(positional)
123
123
  const name = verb ? `${command} ${verb}` : command
124
- console.error(` rnx ${name} is not available for an RNX Cloud simulator`)
124
+ console.error(` rnx ${name} is not available for a remote simulator`)
125
125
  return { handled: true, code: 1 }
126
126
  }
127
127
 
@@ -129,18 +129,13 @@ export async function dispatchCloudBoundary({
129
129
  command,
130
130
  args,
131
131
  }: CloudBoundaryInvocation): Promise<CloudBoundaryResult> {
132
- if (command === 'ios' && (args.includes('--nano') || args.includes('--cloud')))
133
- return { handled: false }
134
- if (command === 'android' && (args.includes('--nano') || args.includes('--cloud'))) {
132
+ if (command === 'ios' && args.includes('--remote')) return { handled: false }
133
+ if (command === 'android' && args.includes('--remote')) {
135
134
  console.error(
136
- ' rnx android --nano is not available; RNX Cloud currently supports iOS',
135
+ ' rnx android --remote is not available; remote simulators are iOS only',
137
136
  )
138
137
  return { handled: true, code: 1 }
139
138
  }
140
- if ((command === 'ios' || command === 'android') && args.includes('--micro')) {
141
- console.error(` rnx ${command} --micro is not implemented`)
142
- return { handled: true, code: 1 }
143
- }
144
139
  if (!CLOUD_TARGET_COMMANDS.has(command)) return { handled: false }
145
140
 
146
141
  const [{ parseBridgeCliArgs }, cloudClient, cloudSession] = await Promise.all([
@@ -189,7 +184,7 @@ export async function dispatchCloudBoundary({
189
184
  const descriptor = cloudSession.requireCloudSessionShellUpdate()
190
185
  await cloudClient.closeCloudBox(session)
191
186
  cloudSession.publishCloudSessionToShell(null, descriptor)
192
- console.log(` closed: box ${session.boxId} and simulator ${session.simId}`)
187
+ console.log(` closed: remote simulator ${session.simId}`)
193
188
  return { handled: true, code: 0 }
194
189
  } catch (error) {
195
190
  console.error(
@@ -10,7 +10,7 @@ export const RNX_SHELL_UPDATE_PREFIX = '__RNX_SHELL_UPDATE_V1__'
10
10
  export const RNX_SHELL_UPDATE_SET_PREFIX = `${RNX_SHELL_UPDATE_PREFIX}set:`
11
11
  export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset`
12
12
 
13
- // the shell's target: one nano box, addressed by the box token, and the
13
+ // the shell's target: one remote host, addressed by the box token, and the
14
14
  // simulator inside it that ordinary commands reach with the simulator token
15
15
  export interface CloudSession {
16
16
  version: 2
@@ -31,7 +31,7 @@ export function normalizeCloudOrigin(source: string): string {
31
31
  try {
32
32
  parsed = new URL(source)
33
33
  } catch {
34
- throw new Error(`invalid RNX Cloud origin: ${source}`)
34
+ throw new Error(`invalid remote simulator origin: ${source}`)
35
35
  }
36
36
  if (
37
37
  (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') ||
@@ -41,10 +41,10 @@ export function normalizeCloudOrigin(source: string): string {
41
41
  parsed.search ||
42
42
  parsed.hash
43
43
  ) {
44
- throw new Error(`RNX Cloud origin must be an HTTP(S) origin: ${source}`)
44
+ throw new Error(`remote simulator origin must be an HTTP(S) origin: ${source}`)
45
45
  }
46
46
  if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {
47
- throw new Error(`RNX Cloud origin requires HTTPS outside loopback: ${source}`)
47
+ throw new Error(`remote simulator origin requires HTTPS outside loopback: ${source}`)
48
48
  }
49
49
  return parsed.origin
50
50
  }
@@ -157,7 +157,7 @@ export function requireCloudSessionShellUpdate(): number {
157
157
  const descriptor = shellUpdateFd()
158
158
  if (descriptor === null) {
159
159
  throw new Error(
160
- 'RNX shell integration is required so this cloud session stays in the current shell; restart the shell after installing RNX',
160
+ 'RNX shell integration is required so this remote simulator stays connected to the current shell; restart the shell after installing RNX',
161
161
  )
162
162
  }
163
163
  return descriptor
@@ -1,5 +1,8 @@
1
- import { refreshSharedDesktopAuthSession } from '../../src/auth/shared-session'
2
- import { resolveCliAuth } from '../auth'
1
+ import {
2
+ refreshSharedDesktopAuthSession,
3
+ sharedDesktopAuthAccounts,
4
+ } from '../../src/auth/shared-session'
5
+ import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from '../auth'
3
6
  import { rnxExit } from '../run-rnx'
4
7
 
5
8
  function printHelp(): void {
@@ -36,7 +39,15 @@ async function printStatus(): Promise<number> {
36
39
 
37
40
  const current = await refreshSharedDesktopAuthSession(auth.origin)
38
41
  const user = current?.user
42
+ const accounts = sharedDesktopAuthAccounts(user)
43
+ const accountId = current?.accountId ?? auth.accountId
44
+ const account = accounts.find((candidate) => candidate.id === accountId)
39
45
  console.log(` ${user?.email || user?.name || user?.id || 'signed in'}`)
46
+ console.log(
47
+ ` account: ${
48
+ account ? `${account.name} (${account.id})` : (accountId ?? CHOOSE_ACCOUNT_HINT)
49
+ }`,
50
+ )
40
51
  console.log(` origin: ${current?.origin ?? auth.origin}`)
41
52
  if (current?.updatedAt) console.log(` updated: ${current.updatedAt}`)
42
53
  return 0
@@ -2,9 +2,11 @@
2
2
  //
3
3
  // bare `rnx box` is LOCAL and mounts the current checkout at /project. cloud
4
4
  // create provisions through the shared client and enters the same shell unless
5
- // --json asks for a noninteractive result. reconnect still needs an authorized
6
- // provider lookup from public box identity to the provider reference.
5
+ // --json asks for a noninteractive result. connect asks the account service to
6
+ // turn a Box name into an authorized reference and enters that same shell, so
7
+ // this process never stores a Box token.
7
8
 
9
+ import { spawn } from 'node:child_process'
8
10
  import path from 'node:path'
9
11
  import { createInterface } from 'node:readline'
10
12
  import { BoxClientError, createBoxClient } from '@rnx/box/client'
@@ -16,9 +18,16 @@ import {
16
18
  sourceControlBoxTemplate,
17
19
  terminalBoxTemplate,
18
20
  } from '@rnx/box/template'
19
- import { createCloudBoxProvider, heartbeatSession } from 'rnx-cloud-box/client'
21
+ import {
22
+ cloudBoxBrowserLiveView,
23
+ cloudBoxConnectUrl,
24
+ createCloudBoxProvider,
25
+ ensureCloudBoxBrowser,
26
+ heartbeatSession,
27
+ resolveCloudBoxReference,
28
+ } from 'rnx-cloud-box/client'
20
29
  import { rnxPublicBrand } from '../../src/public-brand'
21
- import { authHeaderOrExit } from '../auth'
30
+ import { authHeaderOrExit, cloudAccountIdOrExit } from '../auth'
22
31
  import { findRepositoryPackageRoot } from '../setup-repository'
23
32
  import { resolvePlacement } from '../ws-bridge'
24
33
  import { CheckoutFilePlane, CheckoutRequiresGitError } from './box/checkout-plane'
@@ -58,6 +67,10 @@ function usage(): void {
58
67
  the box service to create it in, or set
59
68
  ${ENDPOINT_ENV}
60
69
  ${rnx} box create --json create without entering the shell
70
+ ${rnx} box connect <id> enter the shell of a cloud box you own
71
+ ${rnx} box connect <id> --json print its reference without a shell
72
+ ${rnx} box open <id> watch a cloud box in your browser
73
+ ${rnx} box open <id> --print print the viewing URL instead of opening it
61
74
  ${rnx} box ls cloud boxes running on this account
62
75
  ${rnx} box stop --name=<n> stop a cloud box and its billing
63
76
  ${rnx} box delete --name=<n> stop it and wipe its files
@@ -91,13 +104,8 @@ export async function runBox(
91
104
 
92
105
  const subcommand = args.find((arg) => !arg.startsWith('-'))
93
106
  if (subcommand === 'create') return runCloudBoxCreate(args, opts)
94
- if (subcommand === 'connect') {
95
- console.error(
96
- ` ${rnxPublicBrand.commandName} box connect: the cloud provider cannot yet resolve a Box ID or name to an authorized Box reference.\n` +
97
- ` the shared Box client requires that reference's access token, and the CLI does not persist Box tokens.`,
98
- )
99
- return 1
100
- }
107
+ if (subcommand === 'connect') return runCloudBoxConnect(args)
108
+ if (subcommand === 'open') return runCloudBoxOpen(args)
101
109
  if (subcommand === 'stop' || subcommand === 'delete') {
102
110
  return runCloudBoxStop(args, subcommand === 'delete')
103
111
  }
@@ -160,12 +168,13 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
160
168
  console.error(` ${rnx} box ${label}: which box? pass --name=<n>.`)
161
169
  return 1
162
170
  }
163
- const { header } = authHeaderOrExit(`box ${label}`)
171
+ const { auth, header } = authHeaderOrExit(`box ${label}`)
164
172
  const apiKey = header.replace(/^Bearer /, '')
173
+ const accountId = cloudAccountIdOrExit(auth)
165
174
  const client = createBoxClient(
166
175
  createCloudBoxProvider({
167
176
  endpoint,
168
- authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
177
+ authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey, accountId },
169
178
  }),
170
179
  )
171
180
  try {
@@ -189,12 +198,13 @@ async function runCloudBoxStop(args: string[], purge: boolean): Promise<number>
189
198
 
190
199
  async function runCloudBoxList(args: string[]): Promise<number> {
191
200
  const rnx = rnxPublicBrand.commandName
192
- const { header } = authHeaderOrExit('box ls')
201
+ const { auth, header } = authHeaderOrExit('box ls')
193
202
  const apiKey = header.replace(/^Bearer /, '')
203
+ const accountId = cloudAccountIdOrExit(auth)
194
204
  const client = createBoxClient(
195
205
  createCloudBoxProvider({
196
206
  endpoint: flag(args, 'endpoint') ?? process.env[ENDPOINT_ENV]?.trim() ?? '',
197
- authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
207
+ authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey, accountId },
198
208
  }),
199
209
  )
200
210
  try {
@@ -219,6 +229,161 @@ async function runCloudBoxList(args: string[]): Promise<number> {
219
229
  }
220
230
  }
221
231
 
232
+ async function runCloudBoxConnect(args: string[]): Promise<number> {
233
+ const rnx = rnxPublicBrand.commandName
234
+ const json = args.includes('--json')
235
+ // the first positional is `connect`; the second is the Box.
236
+ const boxId = args.filter((arg) => !arg.startsWith('-'))[1]
237
+ if (!boxId) {
238
+ console.error(
239
+ ` ${rnx} box connect: which box? pass its ID or name, as \`${rnx} box ls\` prints it.`,
240
+ )
241
+ return 1
242
+ }
243
+
244
+ // the ACCOUNT's credential. the account service is the only thing that knows
245
+ // which account a Box belongs to, so it resolves the name and hands back the
246
+ // Box's own token; nothing here writes that token down.
247
+ const { header } = authHeaderOrExit('box connect')
248
+ const apiKey = header.replace(/^Bearer /, '')
249
+ const origin = apiOrigin(args)
250
+
251
+ let session: BoxShellSession | null = null
252
+ let heartbeat: { stop: () => void } | null = null
253
+ try {
254
+ const reference = await resolveCloudBoxReference({ apiOrigin: origin, apiKey, boxId })
255
+ const box = await createBoxClient(
256
+ createCloudBoxProvider({
257
+ endpoint: reference.endpoint,
258
+ authority: { kind: 'attach' },
259
+ }),
260
+ ).attach({
261
+ // no stack here on purpose: the Box reports the one it was created
262
+ // with, and holding somebody else's Box to a stack this process guessed
263
+ // would offer primitives it does not actually have.
264
+ reference: { boxId: reference.boxId, accessToken: reference.token },
265
+ })
266
+ if (json) {
267
+ // the reference without its token. a caller scripting against this gets
268
+ // the Box's identity; the credential stays out of stdout and out of
269
+ // whatever collects it.
270
+ console.log(JSON.stringify({ endpoint: reference.endpoint, ...box.description }))
271
+ return 0
272
+ }
273
+ console.log(` ...connected to Box ${box.id}`)
274
+ if (box.description.pageUrl) {
275
+ console.log(` ...open ${box.description.pageUrl}`)
276
+ }
277
+ console.log(` ...accessing shell\n`)
278
+ session = await box.connect()
279
+ // the same reason `create` heartbeats: the box holds no account credential,
280
+ // so the client using it is what keeps its session alive.
281
+ const sessionId = box.description.sessionId
282
+ if (!sessionId) throw new BoxClientError('the box service returned no session id')
283
+ heartbeat = heartbeatSession({ apiOrigin: origin, apiKey, sessionId })
284
+ return await repl(session, '')
285
+ } catch (error) {
286
+ if (error instanceof BoxClientError) {
287
+ console.error(` ${rnx} box connect: ${error.message}`)
288
+ return 1
289
+ }
290
+ throw error
291
+ } finally {
292
+ heartbeat?.stop()
293
+ session?.close()
294
+ }
295
+ }
296
+
297
+ /**
298
+ * watch a cloud box in a browser.
299
+ *
300
+ * the box is already running its page in a headless Browser Run tab; this
301
+ * attaches a viewer to that same tab rather than starting a second one. the
302
+ * URL it mints is a short-lived bearer capability, so it is handed straight to
303
+ * the browser and this output is the only place it ever exists.
304
+ */
305
+ async function runCloudBoxOpen(args: string[]): Promise<number> {
306
+ const rnx = rnxPublicBrand.commandName
307
+ const json = args.includes('--json')
308
+ const print = args.includes('--print')
309
+ // the first positional is `open`; the second is the Box.
310
+ const boxId = args.filter((arg) => !arg.startsWith('-'))[1] ?? flag(args, 'name')
311
+ if (!boxId) {
312
+ console.error(
313
+ ` ${rnx} box open: which box? pass its ID or name, as \`${rnx} box ls\` prints it.`,
314
+ )
315
+ return 1
316
+ }
317
+
318
+ // the ACCOUNT's credential, for the same reason connect takes one: the
319
+ // account service is what knows which account owns this Box, and it hands
320
+ // back the Box's own token rather than this process storing one.
321
+ const { header } = authHeaderOrExit('box open')
322
+ const apiKey = header.replace(/^Bearer /, '')
323
+
324
+ try {
325
+ const reference = await resolveCloudBoxReference({
326
+ apiOrigin: apiOrigin(args),
327
+ apiKey,
328
+ boxId,
329
+ })
330
+ const connectUrl = cloudBoxConnectUrl(reference.endpoint, reference.boxId)
331
+ // ensure first: it is what navigates the generation's tab to the Box page,
332
+ // so a box whose browser lapsed gets a new generation with its page
333
+ // restored before anybody is looking at it.
334
+ const browser = await ensureCloudBoxBrowser({
335
+ connectUrl,
336
+ accessToken: reference.token,
337
+ })
338
+ if (!browser.reused && !json && !print) {
339
+ console.log(` ...started this box's browser`)
340
+ }
341
+ const view = await cloudBoxBrowserLiveView({
342
+ connectUrl,
343
+ accessToken: reference.token,
344
+ mode: 'tab',
345
+ })
346
+ if (json) {
347
+ console.log(
348
+ JSON.stringify({
349
+ url: view.url,
350
+ expiresAt: new Date(Date.now() + view.expiresInMs).toISOString(),
351
+ }),
352
+ )
353
+ return 0
354
+ }
355
+ if (print) {
356
+ console.log(view.url)
357
+ return 0
358
+ }
359
+ return await openInBrowser(view.url)
360
+ } catch (error) {
361
+ if (error instanceof BoxClientError) {
362
+ console.error(` ${rnx} box open: ${error.message}`)
363
+ return 1
364
+ }
365
+ throw error
366
+ }
367
+ }
368
+
369
+ /**
370
+ * hand a URL to the operating system's browser.
371
+ *
372
+ * argv rather than a shell string, because this URL is a bearer capability and
373
+ * a shell would take it through history, expansion and quoting on the way. when
374
+ * `open` cannot run it is printed instead: this process is the only place the
375
+ * URL exists, so dropping it would cost the caller another mint.
376
+ */
377
+ async function openInBrowser(url: string): Promise<number> {
378
+ const child = spawn('open', [url], { stdio: 'ignore' })
379
+ const opened = await new Promise<boolean>((resolve) => {
380
+ child.once('error', () => resolve(false))
381
+ child.once('close', (code) => resolve(code === 0))
382
+ })
383
+ if (!opened) console.log(url)
384
+ return 0
385
+ }
386
+
222
387
  async function runCloudBoxCreate(
223
388
  args: string[],
224
389
  opts: BoxCommandOptions,
@@ -270,14 +435,15 @@ async function runCloudBoxCreate(
270
435
  // the ACCOUNT's key. the box service forwards it to /v1/rnx/sessions, which
271
436
  // is what authenticates this caller, names the account, and starts the
272
437
  // session the box is billed against.
273
- const { header } = authHeaderOrExit('box create')
438
+ const { auth, header } = authHeaderOrExit('box create')
274
439
  const apiKey = header.replace(/^Bearer /, '')
440
+ const accountId = cloudAccountIdOrExit(auth)
275
441
 
276
442
  if (!json) console.log(` ...creating cloud box ${name}`)
277
443
  const client = createBoxClient(
278
444
  createCloudBoxProvider({
279
445
  endpoint,
280
- authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey },
446
+ authority: { kind: 'account', apiOrigin: apiOrigin(args), apiKey, accountId },
281
447
  }),
282
448
  )
283
449
  let session: BoxShellSession | null = null
@@ -303,13 +469,16 @@ async function runCloudBoxCreate(
303
469
  return 0
304
470
  }
305
471
  console.log(` ...created Box ${box.id}`)
306
- if (box.description.stableUrl) {
307
- console.log(` ...live ${box.description.stableUrl}`)
308
- }
309
472
  console.log(` ...seeded ${box.description.files} project files`)
310
473
  // shown once and never stored: this process does not own a credential
311
474
  // store, and inventing one here would be a second place secrets live.
312
475
  console.log(` ...box token (shown once): ${box.accessToken}`)
476
+ // last, so it is the line still on screen when the shell takes over. it
477
+ // carries no credential: opening it signed in resolves the box through the
478
+ // account service.
479
+ if (box.description.pageUrl) {
480
+ console.log(` ...open ${box.description.pageUrl}`)
481
+ }
313
482
  console.log(` ...accessing shell\n`)
314
483
  session = await box.connect()
315
484
  // bank the wall clock while the shell is open. the box holds no account
@@ -62,11 +62,11 @@ import { rethrowIfExit, rnxExit } from '../run-rnx'
62
62
  import { rnxSelfInvocation } from '../self-invocation'
63
63
  import {
64
64
  BridgeSimLockedError,
65
- bridgePortForShellBaseUrl,
66
65
  createBridge,
67
66
  createBridgeFromParsed,
68
67
  parseBridgeCliArgs,
69
68
  printBridgeWorldNotice,
69
+ resolveBridgePortForPin,
70
70
  resolveBridgeWorld,
71
71
  worldForBridgePort,
72
72
  type BridgeSimInfo,
@@ -747,6 +747,16 @@ export interface BuildShellUrlOptions {
747
747
  device?: string
748
748
  resolvedBundle?: ResolvedDevBundle
749
749
  project?: ResolvedAppProject | null
750
+ // deterministic-capture mode, stamped onto the engine URL as ?proof=1. the
751
+ // shell worker, compositor worker and tenant worker each read it off the
752
+ // host page URL at boot (syncProofModeFromInitUrl) and pin everything that
753
+ // would otherwise differ frame to frame: the TextInput caret stops blinking
754
+ // and stays visible, and notifications stop auto-dismissing. capture-frame
755
+ // also reads the raw param and stops clipping the device corners, so a
756
+ // capture matches simctl's rectangular framebuffer. without it a
757
+ // screen holding a focused text field never reaches three identical idle
758
+ // frames, so the screenshot guard burns all 12 attempts and then fails.
759
+ proof?: boolean
750
760
  }
751
761
 
752
762
  function bundleUrlOf(urlStr: string): string | undefined {
@@ -771,9 +781,10 @@ export async function buildShellUrl(
771
781
  const project =
772
782
  opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
773
783
  const stamped = await stampAppSplash(await stampAppFonts(resolved, project), project)
774
- if (!opts.device) return stamped
784
+ if (!opts.device && !opts.proof) return stamped
775
785
  const url = new URL(stamped)
776
- url.searchParams.set('device', opts.device)
786
+ if (opts.device) url.searchParams.set('device', opts.device)
787
+ if (opts.proof) url.searchParams.set('proof', '1')
777
788
  return url.toString()
778
789
  }
779
790
 
@@ -1417,6 +1428,7 @@ export async function runOpenCommand(
1417
1428
  '--no-hmr',
1418
1429
  '--no-describe',
1419
1430
  '--quiet',
1431
+ '--proof',
1420
1432
  ],
1421
1433
  stripValueFlags: [
1422
1434
  '--base-url',
@@ -1516,14 +1528,25 @@ export async function runOpenCommand(
1516
1528
  await ensureConfiguredRuntime(configuredRuntimeVersion)
1517
1529
  }
1518
1530
 
1519
- // keep the bridge port and the shell base URL in one world: an explicit
1520
- // --base-url without --port retargets polling at THAT world's bridge
1521
- // (otherwise open builds the page against one runtime while polling the
1522
- // other bridge for registration and times out after ~125s).
1531
+ // keep the bridge port and the shell base URL in one world: --base-url
1532
+ // retargets polling at THAT world's bridge (otherwise open builds the page
1533
+ // against one runtime while polling the other bridge for registration and
1534
+ // times out after ~125s), and a pin nothing serves is refused rather than
1535
+ // falling back to the discovered default, which used to send
1536
+ // ensureBridgeForOpen below off to start a second shell.
1523
1537
  const explicitBaseUrl = args.find((_, i) => args[i - 1] === '--base-url')
1524
- if (explicitBaseUrl && !parsed.explicitPort) {
1525
- const matched = bridgePortForShellBaseUrl(explicitBaseUrl)
1526
- if (matched) parsed.wsPort = matched
1538
+ if (explicitBaseUrl) {
1539
+ const pinned = resolveBridgePortForPin({
1540
+ baseUrl: explicitBaseUrl,
1541
+ wsPort: parsed.wsPort,
1542
+ explicitPort: parsed.explicitPort,
1543
+ })
1544
+ if (pinned.error !== undefined) {
1545
+ console.error(` error: ${pinned.error}`)
1546
+ rnxExit(1)
1547
+ } else {
1548
+ parsed.wsPort = pinned.port
1549
+ }
1527
1550
  }
1528
1551
 
1529
1552
  parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort)
@@ -1597,6 +1620,7 @@ export async function runOpenCommand(
1597
1620
  device: deviceArg,
1598
1621
  resolvedBundle,
1599
1622
  project,
1623
+ proof: args.includes('--proof'),
1600
1624
  }
1601
1625
 
1602
1626
  // resolve the shell base URL from the bridge port we settled on, so the page
@@ -1762,9 +1786,12 @@ export async function runOpenCommand(
1762
1786
  const result = await driver.launch({
1763
1787
  url: openUrl,
1764
1788
  // the shell serves the sim page but its WS bridge may have drifted off
1765
- // the shell-port-implied default. inject the resolved bridge port so the
1766
- // page does not reconnect to a stale world.
1767
- bridgePort: bridgePortForShellBaseUrl(baseUrl) ?? parsed.wsPort,
1789
+ // the shell-port-implied default. inject the bridge this command settled
1790
+ // on so the page does not reconnect to a stale world. re-deriving it from
1791
+ // the shell URL could name a third port, since a bridge with no lockfile
1792
+ // resolves back to the default shell URL and that shell may belong to a
1793
+ // live world of its own.
1794
+ bridgePort: parsed.wsPort,
1768
1795
  headless: driverHeadless,
1769
1796
  profileId,
1770
1797
  ephemeralProfile,
@@ -36,7 +36,7 @@ usage:
36
36
 
37
37
  examples:
38
38
  rnx device
39
- rnx device set iphone-14
39
+ rnx device set iphone-16
40
40
  rnx device set iphone-16-pro-max --sim a7
41
41
  `)
42
42
  }