rnxsim 0.1.419 → 0.1.421

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 (62) hide show
  1. package/cli/cloud-client.ts +164 -12
  2. package/cli/cloud-dispatch.ts +7 -2
  3. package/cli/commands/box/checkout-plane.ts +78 -40
  4. package/cli/commands/platform.ts +30 -9
  5. package/cli/outbound-endpoints.ts +18 -0
  6. package/cli/shell-init.ts +1 -1
  7. package/cli/ws-bridge.ts +7 -5
  8. package/dist-lib/agent-daemon-client.cjs +1 -1
  9. package/dist-lib/agent-events.cjs +1 -1
  10. package/dist-lib/agent-identity.cjs +1 -1
  11. package/dist-lib/agent-sessions.cjs +1 -1
  12. package/dist-lib/attached-projects.cjs +1 -1
  13. package/dist-lib/auth/shared-session.cjs +1 -1
  14. package/dist-lib/backend-origin.cjs +1 -1
  15. package/dist-lib/beta.cjs +1 -1
  16. package/dist-lib/beta.mjs +1 -1
  17. package/dist-lib/bridge-constants.cjs +1 -1
  18. package/dist-lib/bridge-contract-input.cjs +1 -1
  19. package/dist-lib/bridge-contract-input.mjs +1 -1
  20. package/dist-lib/bridge-contract.cjs +1 -1
  21. package/dist-lib/bridge-contract.mjs +1 -1
  22. package/dist-lib/capture-contract.cjs +1 -1
  23. package/dist-lib/capture-contract.mjs +1 -1
  24. package/dist-lib/cli-constants.cjs +1 -1
  25. package/dist-lib/cloud-contract.cjs +1 -1
  26. package/dist-lib/cloud-contract.mjs +1 -1
  27. package/dist-lib/cloud.cjs +447 -0
  28. package/dist-lib/cloud.mjs +419 -0
  29. package/dist-lib/config.cjs +1 -1
  30. package/dist-lib/detox/index.cjs +1 -1
  31. package/dist-lib/dev-bundle-resolution.cjs +6 -4
  32. package/dist-lib/home-paths.cjs +1 -1
  33. package/dist-lib/host/bridge-host.cjs +55 -28
  34. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  35. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  36. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  37. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  38. package/dist-lib/host/websocket-proxy.cjs +1 -1
  39. package/dist-lib/index.cjs +7 -5
  40. package/dist-lib/jump-to-source-babel.cjs +1 -1
  41. package/dist-lib/menu.cjs +1 -1
  42. package/dist-lib/menu.mjs +1 -1
  43. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  44. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  45. package/dist-lib/metro-production-bundle.cjs +1 -1
  46. package/dist-lib/metro-production-bundle.mjs +1 -1
  47. package/dist-lib/metro.cjs +4 -3
  48. package/dist-lib/profiles.cjs +1 -1
  49. package/dist-lib/public-brand.cjs +1 -1
  50. package/dist-lib/react-native-host-modules.cjs +1 -1
  51. package/dist-lib/react-native-host-modules.mjs +1 -1
  52. package/dist-lib/render-mode.cjs +1 -1
  53. package/dist-lib/scripts/dev-server-scanner.cjs +9 -6
  54. package/dist-lib/sdk.cjs +1 -1
  55. package/dist-lib/sdk.mjs +1 -1
  56. package/dist-lib/skills.cjs +64 -34
  57. package/dist-lib/vite.cjs +1 -1
  58. package/package.json +8 -1
  59. package/scripts/dev-server-scanner.ts +9 -5
  60. package/src/cloud.ts +499 -0
  61. package/src/dev-bundle-resolution.ts +6 -3
  62. package/src/dev-server-open.ts +3 -2
package/src/cloud.ts ADDED
@@ -0,0 +1,499 @@
1
+ // typed RNX Cloud nano client for agents. the CLI produces artifact bytes
2
+ // (local file or a fetched URL); this module talks to the service in those
3
+ // bytes only. command types and receipts are the same ones the CLI already
4
+ // sends. do not invent a second vocabulary.
5
+
6
+ import { isLoopbackHost } from './backend-origin'
7
+ import {
8
+ isRnxCloudCommandType,
9
+ RNX_CLOUD_COMMAND_TYPES,
10
+ RNX_CLOUD_MAX_ARTIFACT_BYTES,
11
+ type RnxCloudBoxCreateReceipt,
12
+ type RnxCloudClaim,
13
+ } from './cloud-contract'
14
+ import { rnxPublicBrand } from './public-brand'
15
+
16
+ export { isRnxCloudCommandType, RNX_CLOUD_COMMAND_TYPES, RNX_CLOUD_MAX_ARTIFACT_BYTES }
17
+ export type { RnxCloudBoxCreateReceipt, RnxCloudClaim }
18
+
19
+ const DEFAULT_DEVICE = 'iphone-16'
20
+ const DEFAULT_WAIT_READY_MS = 20_000
21
+
22
+ export type NanoCommand = {
23
+ type: (typeof RNX_CLOUD_COMMAND_TYPES)[number]
24
+ [key: string]: unknown
25
+ }
26
+
27
+ export interface CreateNanoBoxInput {
28
+ artifact: Uint8Array
29
+ authorization: string
30
+ origin?: string
31
+ device?: string
32
+ sha256?: string
33
+ }
34
+
35
+ export interface AddNanoSimulatorInput {
36
+ device?: string
37
+ storageFrom?: string | null
38
+ }
39
+
40
+ interface NanoSession {
41
+ origin: string
42
+ authorization: string
43
+ boxId: string
44
+ boxToken: string
45
+ simId: string
46
+ simulatorToken: string
47
+ claim: RnxCloudClaim
48
+ artifact: RnxCloudBoxCreateReceipt['artifact']
49
+ }
50
+
51
+ function isRecord(value: unknown): value is Record<string, unknown> {
52
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
53
+ }
54
+
55
+ function normalizeCloudOrigin(source: string): string {
56
+ let parsed: URL
57
+ try {
58
+ parsed = new URL(source)
59
+ } catch {
60
+ throw new Error(`invalid RNX Cloud origin: ${source}`)
61
+ }
62
+ if (
63
+ (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') ||
64
+ parsed.username ||
65
+ parsed.password ||
66
+ parsed.pathname !== '/' ||
67
+ parsed.search ||
68
+ parsed.hash
69
+ ) {
70
+ throw new Error(`RNX Cloud origin must be an HTTP(S) origin: ${source}`)
71
+ }
72
+ if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) {
73
+ throw new Error(`RNX Cloud origin requires HTTPS outside loopback: ${source}`)
74
+ }
75
+ return parsed.origin
76
+ }
77
+
78
+ function resolveOrigin(value?: string): string {
79
+ const configured = value?.trim() || process.env.RNX_CLOUD_ORIGIN?.trim()
80
+ return normalizeCloudOrigin(configured || rnxPublicBrand.origin)
81
+ }
82
+
83
+ function responseError(status: number, text: string): Error {
84
+ if (text) {
85
+ try {
86
+ const parsed: unknown = JSON.parse(text)
87
+ const error = isRecord(parsed) ? parsed.error : null
88
+ if (isRecord(error) && typeof error.message === 'string') {
89
+ return new Error(`RNX Cloud request failed (${status}): ${error.message}`)
90
+ }
91
+ } catch {
92
+ // body was not json; fall through to the raw text
93
+ }
94
+ }
95
+ return new Error(
96
+ `RNX Cloud request failed (${status})${text.trim() ? `: ${text.trim()}` : ''}`,
97
+ )
98
+ }
99
+
100
+ function copyArrayBuffer(bytes: Uint8Array): ArrayBuffer {
101
+ const copy = new ArrayBuffer(bytes.byteLength)
102
+ new Uint8Array(copy).set(bytes)
103
+ return copy
104
+ }
105
+
106
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
107
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', copyArrayBuffer(bytes))
108
+ const view = new Uint8Array(digest)
109
+ let hex = ''
110
+ for (const byte of view) hex += byte.toString(16).padStart(2, '0')
111
+ return hex
112
+ }
113
+
114
+ function readJsArtifact(bytes: Uint8Array): string {
115
+ let source: string
116
+ try {
117
+ source = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
118
+ } catch {
119
+ throw new Error('rnx Cloud artifact must be valid UTF-8 JavaScript, not bytecode')
120
+ }
121
+ if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
122
+ throw new Error('rnx Cloud artifact must be a Metro JavaScript bundle')
123
+ }
124
+ return source
125
+ }
126
+
127
+ function readClaim(value: unknown): RnxCloudClaim {
128
+ if (
129
+ !isRecord(value) ||
130
+ typeof value.id !== 'string' ||
131
+ !value.id ||
132
+ typeof value.expiresAt !== 'number'
133
+ ) {
134
+ throw new Error('RNX Cloud returned an invalid claim')
135
+ }
136
+ return { id: value.id, expiresAt: value.expiresAt }
137
+ }
138
+
139
+ function artifactDigestId(sha256: string): `sha256:${string}` {
140
+ return `sha256:${sha256}`
141
+ }
142
+
143
+ function readArtifact(
144
+ value: unknown,
145
+ expectedId: `sha256:${string}`,
146
+ expectedBytes: number,
147
+ ): RnxCloudBoxCreateReceipt['artifact'] {
148
+ if (
149
+ !isRecord(value) ||
150
+ value.id !== expectedId ||
151
+ typeof value.bytes !== 'number' ||
152
+ !Number.isInteger(value.bytes) ||
153
+ value.bytes !== expectedBytes
154
+ ) {
155
+ throw new Error('RNX Cloud returned an invalid artifact receipt')
156
+ }
157
+ return { id: expectedId, bytes: expectedBytes }
158
+ }
159
+
160
+ function readSimulatorCreate(
161
+ value: unknown,
162
+ expectedId: `sha256:${string}`,
163
+ expectedBytes: number,
164
+ ): {
165
+ simId: string
166
+ token: string
167
+ claim: RnxCloudClaim
168
+ artifact: RnxCloudBoxCreateReceipt['artifact']
169
+ } {
170
+ if (!isRecord(value)) throw new Error('RNX Cloud create returned an invalid simulator')
171
+ const claim = readClaim(value.claim)
172
+ if (
173
+ typeof value.simId !== 'string' ||
174
+ !value.simId ||
175
+ typeof value.token !== 'string'
176
+ ) {
177
+ throw new Error('RNX Cloud create returned an invalid simulator')
178
+ }
179
+ return {
180
+ simId: value.simId,
181
+ token: value.token,
182
+ claim,
183
+ artifact: readArtifact(value.artifact, expectedId, expectedBytes),
184
+ }
185
+ }
186
+
187
+ function readBoxCreate(
188
+ value: unknown,
189
+ expectedBytes: number,
190
+ expectedSha256: string,
191
+ ): {
192
+ receipt: RnxCloudBoxCreateReceipt
193
+ boxToken: string
194
+ simulatorToken: string
195
+ } {
196
+ const artifactId = artifactDigestId(expectedSha256)
197
+ if (!isRecord(value)) throw new Error('RNX Cloud create returned invalid JSON')
198
+ const simulator = readSimulatorCreate(value.simulator, artifactId, expectedBytes)
199
+ if (
200
+ typeof value.boxId !== 'string' ||
201
+ !value.boxId ||
202
+ value.size !== 'nano' ||
203
+ typeof value.token !== 'string' ||
204
+ !value.token
205
+ ) {
206
+ throw new Error('RNX Cloud create returned an invalid box receipt')
207
+ }
208
+ return {
209
+ receipt: {
210
+ boxId: value.boxId,
211
+ size: 'nano',
212
+ artifact: readArtifact(value.artifact, artifactId, expectedBytes),
213
+ simulator: {
214
+ simId: simulator.simId,
215
+ artifact: simulator.artifact,
216
+ claim: simulator.claim,
217
+ },
218
+ },
219
+ boxToken: value.token,
220
+ simulatorToken: simulator.token,
221
+ }
222
+ }
223
+
224
+ function cloudCommand(command: { type: string; [key: string]: unknown }): NanoCommand {
225
+ const clean: Record<string, unknown> = {}
226
+ for (const [key, value] of Object.entries(command)) {
227
+ if (key !== 'id' && key !== 'simId') clean[key] = value
228
+ }
229
+ if (!isRnxCloudCommandType(command.type)) {
230
+ throw new Error(`RNX Cloud does not support bridge command type ${command.type}`)
231
+ }
232
+ return { ...clean, type: command.type }
233
+ }
234
+
235
+ export class NanoBox {
236
+ private session: NanoSession
237
+
238
+ constructor(session: NanoSession) {
239
+ this.session = session
240
+ }
241
+
242
+ get boxId(): string {
243
+ return this.session.boxId
244
+ }
245
+
246
+ get simId(): string {
247
+ return this.session.simId
248
+ }
249
+
250
+ get claim(): RnxCloudClaim {
251
+ return this.session.claim
252
+ }
253
+
254
+ get receipt(): RnxCloudBoxCreateReceipt {
255
+ return {
256
+ boxId: this.session.boxId,
257
+ size: 'nano',
258
+ artifact: this.session.artifact,
259
+ simulator: {
260
+ simId: this.session.simId,
261
+ artifact: this.session.artifact,
262
+ claim: this.session.claim,
263
+ },
264
+ }
265
+ }
266
+
267
+ async command(
268
+ command: { type: string; [key: string]: unknown },
269
+ options?: { timeoutMs?: number },
270
+ ): Promise<unknown> {
271
+ const body = {
272
+ id: globalThis.crypto.randomUUID(),
273
+ claimId: this.session.claim.id,
274
+ command: cloudCommand(command),
275
+ }
276
+ let response: Response
277
+ try {
278
+ response = await fetch(
279
+ `${this.session.origin}/v1/sims/${encodeURIComponent(this.session.simId)}/commands`,
280
+ {
281
+ method: 'POST',
282
+ headers: {
283
+ authorization: `Bearer ${this.session.simulatorToken}`,
284
+ 'content-type': 'application/json',
285
+ },
286
+ body: JSON.stringify(body),
287
+ ...(options?.timeoutMs
288
+ ? { signal: AbortSignal.timeout(options.timeoutMs) }
289
+ : {}),
290
+ },
291
+ )
292
+ } catch (error) {
293
+ throw new Error(
294
+ `could not reach RNX Cloud at ${this.session.origin}: ${
295
+ error instanceof Error ? error.message : String(error)
296
+ }`,
297
+ )
298
+ }
299
+ const text = await response.text()
300
+ if (!response.ok) throw responseError(response.status, text)
301
+ let parsed: unknown
302
+ try {
303
+ parsed = JSON.parse(text)
304
+ } catch {
305
+ throw new Error('RNX Cloud command returned unreadable JSON')
306
+ }
307
+ if (!isRecord(parsed) || parsed.id !== body.id) {
308
+ throw new Error('RNX Cloud command returned an invalid command receipt')
309
+ }
310
+ if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
311
+ if (!Object.hasOwn(parsed, 'result')) {
312
+ throw new Error('RNX Cloud command receipt has no result')
313
+ }
314
+ return parsed.result
315
+ }
316
+
317
+ describe(): Promise<unknown> {
318
+ return this.command({ type: 'tree' })
319
+ }
320
+
321
+ do(command: { type: string; [key: string]: unknown }): Promise<unknown> {
322
+ return this.command(command)
323
+ }
324
+
325
+ wait(
326
+ condition: 'ready' | { type: 'selector'; selector: string },
327
+ options?: { maxMs?: number },
328
+ ): Promise<unknown> {
329
+ const timeoutMs = options?.maxMs ?? DEFAULT_WAIT_READY_MS
330
+ const waitCondition = condition === 'ready' ? { type: 'ready' } : condition
331
+ return this.command(
332
+ {
333
+ type: 'waitFor',
334
+ waitForOptions: { condition: waitCondition, timeoutMs },
335
+ },
336
+ { timeoutMs: timeoutMs + 1_000 },
337
+ )
338
+ }
339
+
340
+ screenshot(options?: { layers?: 'full' | 'tenant' | 'shell' }): Promise<unknown> {
341
+ return this.command({
342
+ type: 'screenshot',
343
+ layers: options?.layers ?? 'tenant',
344
+ })
345
+ }
346
+
347
+ capture(): Promise<unknown> {
348
+ return this.command({ type: 'capture' })
349
+ }
350
+
351
+ reset(): Promise<unknown> {
352
+ return this.command({ type: 'reset' })
353
+ }
354
+
355
+ async confirm(): Promise<RnxCloudClaim> {
356
+ const response = await fetch(
357
+ `${this.session.origin}/v1/sims/${encodeURIComponent(this.session.simId)}/claim`,
358
+ {
359
+ method: 'POST',
360
+ headers: {
361
+ authorization: `Bearer ${this.session.simulatorToken}`,
362
+ 'content-type': 'application/json',
363
+ },
364
+ body: JSON.stringify({ claimId: this.session.claim.id }),
365
+ },
366
+ )
367
+ const text = await response.text()
368
+ if (!response.ok) throw responseError(response.status, text)
369
+ let parsed: unknown
370
+ try {
371
+ parsed = JSON.parse(text)
372
+ } catch {
373
+ throw new Error('RNX Cloud claim returned unreadable JSON')
374
+ }
375
+ const claim = readClaim(isRecord(parsed) ? parsed.claim : null)
376
+ if (claim.id !== this.session.claim.id) {
377
+ throw new Error('RNX Cloud confirmation replaced the initial claim')
378
+ }
379
+ this.session = { ...this.session, claim }
380
+ return claim
381
+ }
382
+
383
+ async addSimulator(input: AddNanoSimulatorInput = {}): Promise<NanoBox> {
384
+ const device = input.device?.trim() || DEFAULT_DEVICE
385
+ const response = await fetch(
386
+ `${this.session.origin}/v1/boxes/${encodeURIComponent(this.session.boxId)}/simulators`,
387
+ {
388
+ method: 'POST',
389
+ headers: {
390
+ authorization: `Bearer ${this.session.boxToken}`,
391
+ 'x-rnx-account-authorization': this.session.authorization,
392
+ 'content-type': 'application/json',
393
+ },
394
+ body: JSON.stringify({
395
+ device,
396
+ ...(input.storageFrom === undefined ? {} : { storageFrom: input.storageFrom }),
397
+ }),
398
+ },
399
+ )
400
+ const text = await response.text()
401
+ if (!response.ok) throw responseError(response.status, text)
402
+ let parsed: unknown
403
+ try {
404
+ parsed = JSON.parse(text)
405
+ } catch {
406
+ throw new Error('RNX Cloud simulator create returned unreadable JSON')
407
+ }
408
+ if (!isRecord(parsed) || parsed.boxId !== this.session.boxId) {
409
+ throw new Error('RNX Cloud simulator create returned an invalid box id')
410
+ }
411
+ const simulator = readSimulatorCreate(
412
+ parsed.simulator,
413
+ this.session.artifact.id,
414
+ this.session.artifact.bytes,
415
+ )
416
+ const pooled = new NanoBox({
417
+ ...this.session,
418
+ simId: simulator.simId,
419
+ simulatorToken: simulator.token,
420
+ claim: simulator.claim,
421
+ })
422
+ await pooled.confirm()
423
+ return pooled
424
+ }
425
+
426
+ async close(): Promise<void> {
427
+ const response = await fetch(
428
+ `${this.session.origin}/v1/boxes/${encodeURIComponent(this.session.boxId)}`,
429
+ {
430
+ method: 'DELETE',
431
+ headers: { authorization: `Bearer ${this.session.boxToken}` },
432
+ },
433
+ )
434
+ const text = await response.text()
435
+ if (!response.ok && response.status !== 404) {
436
+ throw responseError(response.status, text)
437
+ }
438
+ }
439
+ }
440
+
441
+ export async function createNanoBox(input: CreateNanoBoxInput): Promise<NanoBox> {
442
+ if (input.artifact.byteLength > RNX_CLOUD_MAX_ARTIFACT_BYTES) {
443
+ throw new Error(
444
+ `rnx Cloud artifact is ${input.artifact.byteLength} bytes and exceeds the ${RNX_CLOUD_MAX_ARTIFACT_BYTES}-byte limit`,
445
+ )
446
+ }
447
+ if (input.artifact.byteLength <= 0) {
448
+ throw new Error('rnx Cloud artifact is empty')
449
+ }
450
+ readJsArtifact(input.artifact)
451
+ const origin = resolveOrigin(input.origin)
452
+ const sha256 = await sha256Hex(input.artifact)
453
+ if (input.sha256 && input.sha256 !== sha256) {
454
+ throw new Error('rnx Cloud artifact sha256 does not match the provided digest')
455
+ }
456
+ const device = input.device?.trim() || DEFAULT_DEVICE
457
+ let response: Response
458
+ try {
459
+ response = await fetch(`${origin}/v1/boxes`, {
460
+ method: 'POST',
461
+ headers: {
462
+ authorization: input.authorization,
463
+ 'content-type': 'application/javascript',
464
+ 'x-rnx-box-size': 'nano',
465
+ 'x-rnx-artifact-sha256': sha256,
466
+ 'x-rnx-platform': 'ios',
467
+ 'x-rnx-device': device,
468
+ },
469
+ body: copyArrayBuffer(input.artifact),
470
+ })
471
+ } catch (error) {
472
+ throw new Error(
473
+ `could not reach RNX Cloud at ${origin}: ${
474
+ error instanceof Error ? error.message : String(error)
475
+ }`,
476
+ )
477
+ }
478
+ const responseText = await response.text()
479
+ if (!response.ok) throw responseError(response.status, responseText)
480
+ let parsed: unknown
481
+ try {
482
+ parsed = JSON.parse(responseText)
483
+ } catch {
484
+ throw new Error('RNX Cloud create returned unreadable JSON')
485
+ }
486
+ const created = readBoxCreate(parsed, input.artifact.byteLength, sha256)
487
+ const box = new NanoBox({
488
+ origin,
489
+ authorization: input.authorization,
490
+ boxId: created.receipt.boxId,
491
+ boxToken: created.boxToken,
492
+ simId: created.receipt.simulator.simId,
493
+ simulatorToken: created.simulatorToken,
494
+ claim: created.receipt.simulator.claim,
495
+ artifact: created.receipt.artifact,
496
+ })
497
+ await box.confirm()
498
+ return box
499
+ }
@@ -179,12 +179,15 @@ async function probeExpoManifest(
179
179
  async (res) => (res.ok ? { ok: true as const, body: await res.text() } : null),
180
180
  () => undefined,
181
181
  )
182
+ let probeDeadline: ReturnType<typeof setTimeout> | undefined
182
183
  const answered = await Promise.race([
183
184
  pending,
184
- new Promise<'timeout'>((resolve) =>
185
- setTimeout(() => resolve('timeout'), MANIFEST_PROBE_TIMEOUT_MS).unref?.(),
186
- ),
185
+ new Promise<'timeout'>((resolve) => {
186
+ probeDeadline = setTimeout(() => resolve('timeout'), MANIFEST_PROBE_TIMEOUT_MS)
187
+ }),
187
188
  ])
189
+ // the race is settled, so drop the deadline rather than leaving it pending.
190
+ clearTimeout(probeDeadline)
188
191
  if (answered === 'timeout' || answered === undefined) return { state: 'no-answer' }
189
192
  if (answered === null) return { state: 'no-manifest' }
190
193
  body = answered.body
@@ -129,9 +129,11 @@ export function openRnxDesktopWhenMetroIsReady({
129
129
 
130
130
  return new Promise((resolve) => {
131
131
  let finished = false
132
+ let retryTimer: ReturnType<typeof setTimeout> | undefined
132
133
  const finish = (opened: boolean) => {
133
134
  if (finished) return
134
135
  finished = true
136
+ clearTimeout(retryTimer)
135
137
  scheduledMetroOpens.delete(key)
136
138
  resolve(opened)
137
139
  }
@@ -144,8 +146,7 @@ export function openRnxDesktopWhenMetroIsReady({
144
146
  finish(false)
145
147
  return
146
148
  }
147
- const timer = setTimeout(check, intervalMs)
148
- timer.unref()
149
+ retryTimer = setTimeout(check, intervalMs)
149
150
  }
150
151
  const check = () => {
151
152
  const request = http.get(