peachsim 0.1.527
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/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
|
@@ -0,0 +1,2154 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
devices,
|
|
7
|
+
listSelectableDeviceModels,
|
|
8
|
+
type DeviceModel,
|
|
9
|
+
} from 'sootsim-engine/settings'
|
|
10
|
+
import { WebSocket } from 'ws'
|
|
11
|
+
import { probePort } from '../../scripts/dev-server-scanner'
|
|
12
|
+
import { isAppDeepLinkTarget, openAppUrl } from '../../src/app-url'
|
|
13
|
+
import { isLoopbackHost } from '../../src/backend-origin'
|
|
14
|
+
import { resolveSootsimShellBaseUrlForBridgePort } from '../../src/bridge-constants'
|
|
15
|
+
import {
|
|
16
|
+
applyRNXConfigToUrl,
|
|
17
|
+
mergeRNXConfig,
|
|
18
|
+
RNX_CONFIG_QUERY_PARAM,
|
|
19
|
+
type RNXConfig,
|
|
20
|
+
} from '../../src/config'
|
|
21
|
+
import {
|
|
22
|
+
resolveConnectionInput,
|
|
23
|
+
type ResolvedDevBundle,
|
|
24
|
+
} from '../../src/dev-bundle-resolution'
|
|
25
|
+
import { flowTimeoutScale } from '../../src/flow-timeout-scale'
|
|
26
|
+
import {
|
|
27
|
+
isDaemonLockfileFresh,
|
|
28
|
+
isDevBridgeLockfileFresh,
|
|
29
|
+
readActiveRuntime,
|
|
30
|
+
readDaemonLockfile,
|
|
31
|
+
readDevBridgeLockfiles,
|
|
32
|
+
readLiveRuntimeVersions,
|
|
33
|
+
readSimulatorDriverPreference,
|
|
34
|
+
runtimeDir,
|
|
35
|
+
} from '../../src/home-paths'
|
|
36
|
+
import { applyHotMode } from '../../src/native-dev-bundle-url'
|
|
37
|
+
import { ensureProfile } from '../../src/profiles'
|
|
38
|
+
import { rnxPublicBrand } from '../../src/public-brand'
|
|
39
|
+
import {
|
|
40
|
+
resolveDevCheckoutRuntimeRoot,
|
|
41
|
+
runtimeVersionHostname,
|
|
42
|
+
} from '../../src/runtime-assets'
|
|
43
|
+
import { rnxRuntime } from '../../src/runtime-delivery'
|
|
44
|
+
import { loadRNXAppConfig } from '../app-config'
|
|
45
|
+
import {
|
|
46
|
+
type DiscoveredAppFontSpec,
|
|
47
|
+
discoverNativeLinkedAppFonts,
|
|
48
|
+
encodeAppFontsWire,
|
|
49
|
+
resolveMetroProjectRoot,
|
|
50
|
+
} from '../app-fonts'
|
|
51
|
+
import { resolveAppProject, type ResolvedAppProject } from '../app-project'
|
|
52
|
+
import { discoverConfigPluginAppSplash, encodeAppSplashWire } from '../app-splash'
|
|
53
|
+
import { printBridgeFailureDiagnostics } from '../bridge-diagnostics'
|
|
54
|
+
import {
|
|
55
|
+
clearCurrentSimId,
|
|
56
|
+
getStableOwnerPid,
|
|
57
|
+
readCurrentSimId,
|
|
58
|
+
saveCurrentSimId,
|
|
59
|
+
} from '../current-sim'
|
|
60
|
+
import { getDriver } from '../drivers/registry'
|
|
61
|
+
import { openUrl as openUrlInBrowser } from '../open-url'
|
|
62
|
+
import { rethrowIfExit, rnxExit } from '../run-rnx'
|
|
63
|
+
import { rnxSelfInvocation } from '../self-invocation'
|
|
64
|
+
import {
|
|
65
|
+
BridgeSimLockedError,
|
|
66
|
+
createBridge,
|
|
67
|
+
createBridgeFromParsed,
|
|
68
|
+
formatLockOwner,
|
|
69
|
+
parseBridgeCliArgs,
|
|
70
|
+
printBridgeWorldNotice,
|
|
71
|
+
resolveBridgePortForPin,
|
|
72
|
+
resolveBridgeWorld,
|
|
73
|
+
worldForBridgePort,
|
|
74
|
+
type BridgeSimInfo,
|
|
75
|
+
} from '../ws-bridge'
|
|
76
|
+
import { inspectDescribe, inspectWaitReady } from './inspect/core'
|
|
77
|
+
import { isAgentEnv } from './inspect/env'
|
|
78
|
+
import type { DriverId, DriverLaunchResult } from '../drivers/types'
|
|
79
|
+
|
|
80
|
+
interface ControlCommandOptions {
|
|
81
|
+
port?: number
|
|
82
|
+
timeoutMs?: number
|
|
83
|
+
runtimeConfig?: RNXConfig
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface DriverConnectWaitOptions {
|
|
87
|
+
attempts: number
|
|
88
|
+
intervalMs: number
|
|
89
|
+
timeoutMs: number
|
|
90
|
+
hostTimeoutMs: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const DEFAULT_DRIVER_CONNECT_TIMEOUT_MS = 120_000
|
|
94
|
+
const DRIVER_CONNECT_INTERVAL_MS = 100
|
|
95
|
+
const DRIVER_CONNECT_HOST_GRACE_MS = 5_000
|
|
96
|
+
const OPEN_BRIDGE_SPAWN_TIMEOUT_MS = 10_000
|
|
97
|
+
|
|
98
|
+
// built-in shell apps use pathname-based routing, not ?bundle= params
|
|
99
|
+
const SHELL_APPS: Record<string, string> = {
|
|
100
|
+
rn: '/rn',
|
|
101
|
+
connectrn: '/rn',
|
|
102
|
+
'connect-rn': '/rn',
|
|
103
|
+
clock: '/app/clock',
|
|
104
|
+
'native-ui': '/app/native-ui',
|
|
105
|
+
tamagui: '/app/tamagui',
|
|
106
|
+
settings: '/app/settings',
|
|
107
|
+
photos: '/app/photos',
|
|
108
|
+
camera: '/app/camera',
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sleep(ms: number) {
|
|
112
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parsePositiveIntegerEnv(name: string): number | null {
|
|
116
|
+
const value = process.env[name]?.trim()
|
|
117
|
+
if (!value) return null
|
|
118
|
+
const parsed = Number(value)
|
|
119
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolveDriverConnectWaitOptions(): DriverConnectWaitOptions {
|
|
123
|
+
const timeoutMs =
|
|
124
|
+
parsePositiveIntegerEnv('SOOTSIM_DRIVER_CONNECT_TIMEOUT_MS') ??
|
|
125
|
+
parsePositiveIntegerEnv('SOOTSIM_PW_CONNECT_TIMEOUT_MS') ??
|
|
126
|
+
DEFAULT_DRIVER_CONNECT_TIMEOUT_MS
|
|
127
|
+
return {
|
|
128
|
+
timeoutMs,
|
|
129
|
+
hostTimeoutMs: timeoutMs + DRIVER_CONNECT_HOST_GRACE_MS,
|
|
130
|
+
intervalMs: DRIVER_CONNECT_INTERVAL_MS,
|
|
131
|
+
attempts: Math.max(1, Math.ceil(timeoutMs / DRIVER_CONNECT_INTERVAL_MS)),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isDriverId(value: string): value is DriverId {
|
|
136
|
+
return value === 'electron' || value === 'playwright'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function simUsesDriver(sim: BridgeSimInfo, driverId: DriverId): boolean {
|
|
140
|
+
if (driverId === 'playwright') {
|
|
141
|
+
return sim.meta?.sootsimHostDriver === 'playwright'
|
|
142
|
+
}
|
|
143
|
+
return sim.userAgent?.includes('Electron/') === true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// resolve headless for a driver launch. explicit flags always win
|
|
147
|
+
// (--headless → true, --headed → false). otherwise: a headed default
|
|
148
|
+
// on mac/windows. on linux with no DISPLAY/WAYLAND_DISPLAY there is no
|
|
149
|
+
// X server, so the playwright driver auto-flips to headless with a
|
|
150
|
+
// single-line note. electron ignores headless mode, so it defaults false.
|
|
151
|
+
function resolveDriverHeadless(args: string[], driverId: string): boolean {
|
|
152
|
+
if (args.includes('--headless')) return true
|
|
153
|
+
if (args.includes('--headed')) return false
|
|
154
|
+
if (
|
|
155
|
+
driverId === 'playwright' &&
|
|
156
|
+
process.platform === 'linux' &&
|
|
157
|
+
!process.env.DISPLAY &&
|
|
158
|
+
!process.env.WAYLAND_DISPLAY
|
|
159
|
+
) {
|
|
160
|
+
console.error(
|
|
161
|
+
' no display detected → running playwright headless (override with --headed)',
|
|
162
|
+
)
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeKnownTarget(target: string): string {
|
|
169
|
+
return target.trim()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function ensureBridgeForOpen(
|
|
173
|
+
preferredPort: number,
|
|
174
|
+
explicitPort = false,
|
|
175
|
+
): Promise<number> {
|
|
176
|
+
const lock = readDaemonLockfile()
|
|
177
|
+
assertExplicitBridgeLock(preferredPort, explicitPort, lock)
|
|
178
|
+
const preferredWorld = worldForBridgePort(preferredPort)
|
|
179
|
+
if (
|
|
180
|
+
(explicitPort || preferredWorld !== null) &&
|
|
181
|
+
(await canConnectToBridge(preferredPort, 250))
|
|
182
|
+
) {
|
|
183
|
+
return preferredPort
|
|
184
|
+
}
|
|
185
|
+
if (explicitPort && preferredWorld !== null) {
|
|
186
|
+
throw new Error(`rnx open: requested bridge port ${preferredPort} is not reachable.`)
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
!explicitPort &&
|
|
190
|
+
lock &&
|
|
191
|
+
isDaemonLockfileFresh(lock) &&
|
|
192
|
+
(await canConnectToBridge(lock.bridgePort, 500))
|
|
193
|
+
) {
|
|
194
|
+
return lock.bridgePort
|
|
195
|
+
}
|
|
196
|
+
if (lock && isDaemonLockfileFresh(lock)) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`rnx bridge lockfile is fresh (port ${lock.bridgePort}) but the bridge is not reachable. run \`rnx daemon restart\` or \`rnx daemon uninstall\`.`,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await ensureRuntimeForOpen()
|
|
203
|
+
const child = spawnSootsim(['serve', '--quiet', '--port', String(preferredPort)])
|
|
204
|
+
let childExit: string | null = null
|
|
205
|
+
child.once('exit', (code, signal) => {
|
|
206
|
+
childExit = String(code ?? signal ?? 'unknown')
|
|
207
|
+
})
|
|
208
|
+
child.unref()
|
|
209
|
+
|
|
210
|
+
const deadline = Date.now() + OPEN_BRIDGE_SPAWN_TIMEOUT_MS
|
|
211
|
+
while (Date.now() < deadline) {
|
|
212
|
+
const nextLock = readDaemonLockfile()
|
|
213
|
+
assertExplicitBridgeLock(preferredPort, explicitPort, nextLock)
|
|
214
|
+
const selectedPort = explicitPort ? preferredPort : nextLock?.bridgePort
|
|
215
|
+
if (
|
|
216
|
+
nextLock &&
|
|
217
|
+
isDaemonLockfileFresh(nextLock) &&
|
|
218
|
+
selectedPort !== undefined &&
|
|
219
|
+
(await canConnectToBridge(selectedPort, 250))
|
|
220
|
+
) {
|
|
221
|
+
console.error(
|
|
222
|
+
` Started a local bridge on port ${selectedPort}. Run \`rnx daemon install\` to keep it ready between commands.`,
|
|
223
|
+
)
|
|
224
|
+
return selectedPort
|
|
225
|
+
}
|
|
226
|
+
if (childExit) {
|
|
227
|
+
throw new Error(`rnx bridge exited before becoming ready (exit ${childExit})`)
|
|
228
|
+
}
|
|
229
|
+
await sleep(150)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
throw new Error(
|
|
233
|
+
`rnx bridge did not start within ${Math.round(OPEN_BRIDGE_SPAWN_TIMEOUT_MS / 1000)}s`,
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function assertExplicitBridgeLock(
|
|
238
|
+
preferredPort: number,
|
|
239
|
+
explicitPort: boolean,
|
|
240
|
+
lock: ReturnType<typeof readDaemonLockfile>,
|
|
241
|
+
): void {
|
|
242
|
+
if (
|
|
243
|
+
!explicitPort ||
|
|
244
|
+
!lock ||
|
|
245
|
+
!isDaemonLockfileFresh(lock) ||
|
|
246
|
+
lock.bridgePort === preferredPort
|
|
247
|
+
) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
readDevBridgeLockfiles().some(
|
|
252
|
+
(devBridge) =>
|
|
253
|
+
devBridge.bridgePort === preferredPort && isDevBridgeLockfileFresh(devBridge),
|
|
254
|
+
)
|
|
255
|
+
) {
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
throw new Error(
|
|
259
|
+
`${rnxPublicBrand.commandName} open: requested bridge port ${preferredPort}, but the active daemon owns port ${lock.bridgePort}. explicit --port never selects another bridge; stop that daemon or use an isolated RNX_HOME.`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function ensureRuntimeForOpen() {
|
|
264
|
+
const active = readActiveRuntime()
|
|
265
|
+
if (active && fs.existsSync(runtimeDir(active))) return
|
|
266
|
+
console.error(' installing rnx engine runtime...')
|
|
267
|
+
const { runRuntime } = await import('./runtime')
|
|
268
|
+
await runRuntime(['install'], {})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function spawnSootsim(args: string[]) {
|
|
272
|
+
const { executable, prefixArgs } = rnxSelfInvocation()
|
|
273
|
+
return spawn(executable, [...prefixArgs, ...args], {
|
|
274
|
+
detached: true,
|
|
275
|
+
stdio: 'ignore',
|
|
276
|
+
env: process.env,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function canConnectToBridge(port: number, timeoutMs: number): Promise<boolean> {
|
|
281
|
+
return new Promise((resolve) => {
|
|
282
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`, { handshakeTimeout: timeoutMs })
|
|
283
|
+
let settled = false
|
|
284
|
+
const finish = (ok: boolean) => {
|
|
285
|
+
if (settled) return
|
|
286
|
+
settled = true
|
|
287
|
+
if (ws.readyState === WebSocket.OPEN) ws.close()
|
|
288
|
+
else if (ws.readyState === WebSocket.CONNECTING) ws.terminate()
|
|
289
|
+
resolve(ok)
|
|
290
|
+
}
|
|
291
|
+
ws.once('open', () => finish(true))
|
|
292
|
+
ws.on('error', () => finish(false))
|
|
293
|
+
setTimeout(() => finish(false), timeoutMs)
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function looksLikeSootsimUrl(target: string): boolean {
|
|
298
|
+
try {
|
|
299
|
+
const url = new URL(target)
|
|
300
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
301
|
+
return (
|
|
302
|
+
url.searchParams.has('open') ||
|
|
303
|
+
url.searchParams.has('port') ||
|
|
304
|
+
url.searchParams.has('bundle') ||
|
|
305
|
+
url.searchParams.has('demo') ||
|
|
306
|
+
url.pathname.includes('/sootsim/index.html') ||
|
|
307
|
+
pathname === '/sootsim' ||
|
|
308
|
+
url.pathname === '/__soot' ||
|
|
309
|
+
url.pathname === '/__soot/' ||
|
|
310
|
+
pathname === '/rn' ||
|
|
311
|
+
/^\/rn\/[^/]+$/i.test(pathname) ||
|
|
312
|
+
/^\/app\/[^/]+$/i.test(pathname) ||
|
|
313
|
+
pathname === '/__soot/rn' ||
|
|
314
|
+
/^\/__soot\/rn\/[^/]+$/i.test(pathname) ||
|
|
315
|
+
/^\/__soot\/app\/[^/]+$/i.test(pathname)
|
|
316
|
+
)
|
|
317
|
+
} catch {
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function looksLikeShellPathTarget(target: string): boolean {
|
|
323
|
+
try {
|
|
324
|
+
const url = new URL(target, 'http://sootsim.local')
|
|
325
|
+
if (url.origin !== 'http://sootsim.local') return false
|
|
326
|
+
const pathname = url.pathname.replace(/\/+$/, '') || '/'
|
|
327
|
+
return (
|
|
328
|
+
pathname === '/rn' ||
|
|
329
|
+
/^\/rn\/[^/]+$/i.test(pathname) ||
|
|
330
|
+
/^\/app\/[^/]+$/i.test(pathname) ||
|
|
331
|
+
pathname === '/__soot/rn' ||
|
|
332
|
+
/^\/__soot\/rn\/[^/]+$/i.test(pathname) ||
|
|
333
|
+
/^\/__soot\/app\/[^/]+$/i.test(pathname)
|
|
334
|
+
)
|
|
335
|
+
} catch {
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function buildShellPathOpenUrl(target: string, baseUrl: string): string {
|
|
341
|
+
const targetUrl = new URL(target, 'http://sootsim.local')
|
|
342
|
+
const url = new URL(baseUrl)
|
|
343
|
+
const prefix = getShellRoutePrefix(url.pathname)
|
|
344
|
+
const routePath = targetUrl.pathname.startsWith('/__soot/')
|
|
345
|
+
? targetUrl.pathname.slice('/__soot'.length)
|
|
346
|
+
: targetUrl.pathname
|
|
347
|
+
|
|
348
|
+
url.pathname = `${prefix}${routePath}`
|
|
349
|
+
url.search = targetUrl.search
|
|
350
|
+
url.hash = targetUrl.hash
|
|
351
|
+
return url.toString()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// rnx share/preview/build links (e.g. https://rnxsim.com/preview/<id>,
|
|
355
|
+
// https://contrast.dev/build/<owner>/<repo>/<branch>) are full player pages
|
|
356
|
+
// served by the Contrast app — NOT Metro bundles and NOT driveable shell sims.
|
|
357
|
+
// passing one to `rnx open` used to fall through to the bundle resolver,
|
|
358
|
+
// which fetched the HTML page and choked with "Unexpected token '<'". two
|
|
359
|
+
// hard reasons they can't become a CLI-driveable sim:
|
|
360
|
+
// 1. the engine only attaches to the local CLI bridge from a localhost /
|
|
361
|
+
// 127.0.0.1 origin (see engine `mcp/ws-bridge.ts` connect gate), so a
|
|
362
|
+
// remote preview page never registers a sim.
|
|
363
|
+
// 2. a captured share's transformed bundle is pinned to the engine build
|
|
364
|
+
// that produced it — its lazy `/engine-tenant/assets/*` chunk hashes
|
|
365
|
+
// only exist on that deploy, so a remote share can't be re-hosted
|
|
366
|
+
// through the local dev shell (the dynamic chunk import 404s).
|
|
367
|
+
// recognize these URLs so `open` opens them for viewing instead.
|
|
368
|
+
function looksLikeSootSimShareUrl(target: string): boolean {
|
|
369
|
+
try {
|
|
370
|
+
const pathname = new URL(target).pathname.replace(/\/+$/, '') || '/'
|
|
371
|
+
return /^\/preview\/[^/]+$/.test(pathname) || /^\/build\/.+/.test(pathname)
|
|
372
|
+
} catch {
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function resolveBundleTarget(target: string): Promise<string> {
|
|
378
|
+
const normalized = normalizeKnownTarget(target)
|
|
379
|
+
const resolved = await resolveConnectionInputForCli(normalized)
|
|
380
|
+
return resolved.bundleUrl
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function localBasePort(target: string): number | null {
|
|
384
|
+
try {
|
|
385
|
+
const url = new URL(target.startsWith('http') ? target : `http://${target}`)
|
|
386
|
+
const pathname = url.pathname || '/'
|
|
387
|
+
if ((pathname !== '/' && pathname !== '') || url.search || url.hash) return null
|
|
388
|
+
if (!isLoopbackHost(url.hostname)) return null
|
|
389
|
+
const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80
|
|
390
|
+
return Number.isFinite(port) && port > 0 ? port : null
|
|
391
|
+
} catch {
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// a dev server started alongside open connect may still be booting when the
|
|
397
|
+
// first probe lands. retry briefly so staging does not fail on a server that
|
|
398
|
+
// becomes probe-ready a few seconds later.
|
|
399
|
+
const DEV_BUNDLE_RESOLVE_ATTEMPTS = 15
|
|
400
|
+
const DEV_BUNDLE_RESOLVE_INTERVAL_MS = 1000
|
|
401
|
+
|
|
402
|
+
async function resolveConnectionInputForCli(target: string): Promise<ResolvedDevBundle> {
|
|
403
|
+
const normalized = normalizeKnownTarget(target)
|
|
404
|
+
const registeredPort = /^\d+$/.test(normalized)
|
|
405
|
+
? Number(normalized)
|
|
406
|
+
: localBasePort(normalized)
|
|
407
|
+
|
|
408
|
+
let lastError: Error | null = null
|
|
409
|
+
for (let attempt = 0; attempt < DEV_BUNDLE_RESOLVE_ATTEMPTS; attempt++) {
|
|
410
|
+
if (registeredPort && registeredPort > 0) {
|
|
411
|
+
const discovered = await probePort(registeredPort)
|
|
412
|
+
// a provisional bundle URL is enough to LIST the server but not to open
|
|
413
|
+
// it; fall through to resolveConnectionInput, which asks the manifest
|
|
414
|
+
// again with a budget that fits a real dev server.
|
|
415
|
+
if (discovered && !discovered.bundleUrlProvisional) {
|
|
416
|
+
return {
|
|
417
|
+
bundleUrl: discovered.bundleUrl,
|
|
418
|
+
port: discovered.port,
|
|
419
|
+
framework: discovered.framework,
|
|
420
|
+
projectName: discovered.projectName,
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
return await resolveConnectionInput(normalized)
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (!(error instanceof Error) || !error.message.startsWith("could not resolve a native bundle")) {
|
|
429
|
+
throw error
|
|
430
|
+
}
|
|
431
|
+
lastError = error
|
|
432
|
+
}
|
|
433
|
+
await sleep(DEV_BUNDLE_RESOLVE_INTERVAL_MS)
|
|
434
|
+
}
|
|
435
|
+
throw lastError ?? new Error("could not resolve a native bundle for " + normalized + " after retrying.")
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function getShellRoutePrefix(pathname: string): string {
|
|
439
|
+
const normalized = pathname.replace(/\/+$/, '') || '/'
|
|
440
|
+
if (normalized === '/__soot' || normalized.startsWith('/__soot/')) {
|
|
441
|
+
return '/__soot'
|
|
442
|
+
}
|
|
443
|
+
return ''
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function buildConnectOpenUrl(target: string, baseUrl: string): string {
|
|
447
|
+
const normalizedTarget = normalizeKnownTarget(target)
|
|
448
|
+
const url = new URL(baseUrl)
|
|
449
|
+
const prefix = getShellRoutePrefix(url.pathname)
|
|
450
|
+
|
|
451
|
+
url.pathname = `${prefix}/rn`
|
|
452
|
+
url.searchParams.delete('bundle')
|
|
453
|
+
url.searchParams.delete('demo')
|
|
454
|
+
url.searchParams.delete('app')
|
|
455
|
+
url.searchParams.delete('open')
|
|
456
|
+
url.searchParams.delete('port')
|
|
457
|
+
|
|
458
|
+
if (/^\d+$/.test(normalizedTarget)) {
|
|
459
|
+
url.pathname = `${prefix}/rn/${normalizedTarget}`
|
|
460
|
+
} else {
|
|
461
|
+
url.pathname = `${prefix}/rn`
|
|
462
|
+
url.searchParams.set('open', normalizedTarget)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return url.toString()
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function shouldResolveBundleTargetInCli(target: string): boolean {
|
|
469
|
+
const normalizedTarget = normalizeKnownTarget(target)
|
|
470
|
+
if (/^\d+$/.test(normalizedTarget)) return true
|
|
471
|
+
if (/^https?:\/\//i.test(normalizedTarget)) return true
|
|
472
|
+
return /^(localhost|127\.0\.0\.1|\[::1\]|[^/]+\.localhost):\d+(?:\/.*)?$/i.test(
|
|
473
|
+
normalizedTarget,
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function buildResolvedBundleOpenUrl(
|
|
478
|
+
target: string,
|
|
479
|
+
baseUrl: string,
|
|
480
|
+
opts: BuildShellUrlOptions = {},
|
|
481
|
+
): Promise<string> {
|
|
482
|
+
const resolved = opts.resolvedBundle ?? (await resolveConnectionInputForCli(target))
|
|
483
|
+
const url = new URL(baseUrl)
|
|
484
|
+
const prefix = getShellRoutePrefix(url.pathname)
|
|
485
|
+
url.pathname = `${prefix}/rn`
|
|
486
|
+
url.searchParams.delete('open')
|
|
487
|
+
url.searchParams.delete('port')
|
|
488
|
+
url.searchParams.delete('demo')
|
|
489
|
+
url.searchParams.delete('app')
|
|
490
|
+
const bundleUrl =
|
|
491
|
+
opts.hot === undefined
|
|
492
|
+
? resolved.bundleUrl
|
|
493
|
+
: applyHotMode(resolved.bundleUrl, opts.hot)
|
|
494
|
+
url.searchParams.set('bundle', bundleUrl)
|
|
495
|
+
return url.toString()
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizeReplacementPath(filePath: string): string {
|
|
499
|
+
if (filePath.startsWith('~/')) {
|
|
500
|
+
return path.join(os.homedir(), filePath.slice(2))
|
|
501
|
+
}
|
|
502
|
+
if (path.isAbsolute(filePath)) return filePath
|
|
503
|
+
return path.resolve(process.cwd(), filePath)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function parseOpenRuntimeConfig(args: string[]): RNXConfig | undefined {
|
|
507
|
+
const modules: NonNullable<RNXConfig['modules']> = {}
|
|
508
|
+
const remap: Record<string, string> = {}
|
|
509
|
+
|
|
510
|
+
for (let i = 0; i < args.length; i++) {
|
|
511
|
+
if (args[i] === '--replace') {
|
|
512
|
+
const raw = args[i + 1]
|
|
513
|
+
if (!raw) {
|
|
514
|
+
console.error(' rnx open: --replace expects <module>=<file>')
|
|
515
|
+
rnxExit(1)
|
|
516
|
+
}
|
|
517
|
+
const eqIndex = raw.indexOf('=')
|
|
518
|
+
if (eqIndex <= 0 || eqIndex === raw.length - 1) {
|
|
519
|
+
console.error(' rnx open: --replace expects <module>=<file>')
|
|
520
|
+
rnxExit(1)
|
|
521
|
+
}
|
|
522
|
+
const moduleName = raw.slice(0, eqIndex).trim()
|
|
523
|
+
const filePath = normalizeReplacementPath(raw.slice(eqIndex + 1).trim())
|
|
524
|
+
if (!fs.existsSync(filePath)) {
|
|
525
|
+
console.error(` rnx open: replacement file not found: ${filePath}`)
|
|
526
|
+
rnxExit(1)
|
|
527
|
+
}
|
|
528
|
+
modules[moduleName] = { file: filePath }
|
|
529
|
+
i++
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
// --remap <host:port>=<host:port> rewrites a guest fetch's host:port at
|
|
533
|
+
// runtime (config.network.remap). repeatable. lets a flow against an app
|
|
534
|
+
// that hardcodes a port reach a differently-bound service.
|
|
535
|
+
if (args[i] === '--remap') {
|
|
536
|
+
const raw = args[i + 1]
|
|
537
|
+
const eqIndex = raw ? raw.indexOf('=') : -1
|
|
538
|
+
if (!raw || eqIndex <= 0 || eqIndex === raw.length - 1) {
|
|
539
|
+
console.error(' rnx open: --remap expects <host:port>=<host:port>')
|
|
540
|
+
rnxExit(1)
|
|
541
|
+
}
|
|
542
|
+
remap[raw.slice(0, eqIndex).trim()] = raw.slice(eqIndex + 1).trim()
|
|
543
|
+
i++
|
|
544
|
+
continue
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const config: RNXConfig = {}
|
|
549
|
+
if (Object.keys(modules).length > 0) config.modules = modules
|
|
550
|
+
if (Object.keys(remap).length > 0) config.network = { remap }
|
|
551
|
+
return config.modules || config.network ? config : undefined
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function resolveAppProjectForBundleUrl(
|
|
555
|
+
bundleUrl?: string,
|
|
556
|
+
): Promise<ResolvedAppProject | null> {
|
|
557
|
+
if (!bundleUrl) return null
|
|
558
|
+
const advertisedRoot = await resolveMetroProjectRoot(bundleUrl)
|
|
559
|
+
return advertisedRoot ? resolveAppProject(advertisedRoot) : null
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function ensureConfiguredRuntime(version: string): Promise<void> {
|
|
563
|
+
if (fs.existsSync(path.join(runtimeDir(version), 'index.html'))) return
|
|
564
|
+
console.error(` installing configured engine runtime ${version}...`)
|
|
565
|
+
const result = await rnxRuntime.install({
|
|
566
|
+
version,
|
|
567
|
+
setActive: false,
|
|
568
|
+
protectVersions: readLiveRuntimeVersions(),
|
|
569
|
+
})
|
|
570
|
+
console.error(` installed engine runtime ${result.version}`)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function applyRuntimeGenerationOrigin(baseUrl: string, version?: string): string {
|
|
574
|
+
const selectedVersion =
|
|
575
|
+
version ??
|
|
576
|
+
(resolveDevCheckoutRuntimeRoot() ? undefined : (readActiveRuntime() ?? undefined))
|
|
577
|
+
if (!selectedVersion) return baseUrl
|
|
578
|
+
const url = new URL(baseUrl)
|
|
579
|
+
if (!isLoopbackHost(url.hostname)) {
|
|
580
|
+
if (!version) return baseUrl
|
|
581
|
+
throw new Error(
|
|
582
|
+
`runtimeVersion ${selectedVersion} requires a local rnx host; got ${url.origin}`,
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
url.hostname = runtimeVersionHostname(selectedVersion)
|
|
586
|
+
return url.toString()
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function resolveDefaultShellBaseUrl(): string {
|
|
590
|
+
return resolveBridgeWorld().shellBaseUrl
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** the shell that belongs to a bridge port: the world a fresh lockfile claims
|
|
594
|
+
* for it, otherwise the shell that shares the port's own offset. every caller
|
|
595
|
+
* that has already settled on a bridge port goes through this, so a page can
|
|
596
|
+
* never open in one stack's shell while the CLI polls another's bridge. the
|
|
597
|
+
* development bridge registry keeps every live port addressable, while this
|
|
598
|
+
* function preserves an explicit caller's already-selected world. */
|
|
599
|
+
export function resolveShellBaseUrlForBridgePort(bridgePort: number): string {
|
|
600
|
+
return (
|
|
601
|
+
worldForBridgePort(bridgePort)?.shellBaseUrl ??
|
|
602
|
+
resolveSootsimShellBaseUrlForBridgePort(bridgePort) ??
|
|
603
|
+
resolveDefaultShellBaseUrl()
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// stamp the engine page url with `?appFonts=<url[::family]>,...` so the
|
|
608
|
+
// engine's app-font-loader registers native config-only fonts at boot. fonts
|
|
609
|
+
// loaded by JS already flow through normal runtime fetch capture; this covers
|
|
610
|
+
// UIAppFonts-style assets that Metro can serve but the guest bundle never
|
|
611
|
+
// fetches itself. auto-discovery only — scans the repo for expo-font config
|
|
612
|
+
// plugin declarations, no user-facing flag.
|
|
613
|
+
//
|
|
614
|
+
// each discovered font is HEAD-checked against the metro host before we
|
|
615
|
+
// include it on the URL. fonts whose metro asset URL 404s (typo in the
|
|
616
|
+
// expo-font plugin config, missing file, or a wrong app.config picked up
|
|
617
|
+
// by the scan) are dropped silently so the engine never tries to fetch a
|
|
618
|
+
// known-bad URL at boot — that would just log a registration failure and
|
|
619
|
+
// confuse anyone reading the page console.
|
|
620
|
+
async function stampAppFonts(
|
|
621
|
+
urlStr: string,
|
|
622
|
+
project?: ResolvedAppProject | null,
|
|
623
|
+
): Promise<string> {
|
|
624
|
+
try {
|
|
625
|
+
const url = new URL(urlStr)
|
|
626
|
+
const bundleUrl = url.searchParams.get('bundle') || ''
|
|
627
|
+
if (!bundleUrl || !project) return urlStr
|
|
628
|
+
const specs = discoverNativeLinkedAppFonts({
|
|
629
|
+
bundleUrl,
|
|
630
|
+
projectDir: project.appDir,
|
|
631
|
+
})
|
|
632
|
+
if (specs.length === 0) return urlStr
|
|
633
|
+
const reachable = await filterReachableAppFonts(specs)
|
|
634
|
+
if (reachable.length === 0) return urlStr
|
|
635
|
+
url.searchParams.set('appFonts', encodeAppFontsWire(reachable))
|
|
636
|
+
return url.toString()
|
|
637
|
+
} catch {
|
|
638
|
+
return urlStr
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// HEAD-check each font URL with a short timeout. metro returns 200 (or
|
|
643
|
+
// sometimes a redirect) for assets it can resolve and 500 with an ENOENT
|
|
644
|
+
// trace for paths it cannot. anything not in the 2xx-3xx range gets dropped.
|
|
645
|
+
async function filterReachableAppFonts(
|
|
646
|
+
specs: DiscoveredAppFontSpec[],
|
|
647
|
+
): Promise<DiscoveredAppFontSpec[]> {
|
|
648
|
+
const results = await Promise.all(
|
|
649
|
+
specs.map(async (spec): Promise<DiscoveredAppFontSpec | null> => {
|
|
650
|
+
const controller = new AbortController()
|
|
651
|
+
const timer = setTimeout(() => controller.abort(), 1500)
|
|
652
|
+
try {
|
|
653
|
+
const res = await fetch(spec.url, {
|
|
654
|
+
method: 'HEAD',
|
|
655
|
+
signal: controller.signal,
|
|
656
|
+
})
|
|
657
|
+
if (res.status >= 200 && res.status < 400) return spec
|
|
658
|
+
return null
|
|
659
|
+
} catch {
|
|
660
|
+
return null
|
|
661
|
+
} finally {
|
|
662
|
+
clearTimeout(timer)
|
|
663
|
+
}
|
|
664
|
+
}),
|
|
665
|
+
)
|
|
666
|
+
const reachable: DiscoveredAppFontSpec[] = []
|
|
667
|
+
for (const spec of results) {
|
|
668
|
+
if (spec !== null) reachable.push(spec)
|
|
669
|
+
}
|
|
670
|
+
return reachable
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function resolveShellUrl(
|
|
674
|
+
target: string,
|
|
675
|
+
baseUrl: string,
|
|
676
|
+
opts: BuildShellUrlOptions = {},
|
|
677
|
+
): Promise<string> {
|
|
678
|
+
// no target = open home screen
|
|
679
|
+
if (!target) {
|
|
680
|
+
return new URL(baseUrl).toString()
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (looksLikeSootsimUrl(target)) {
|
|
684
|
+
const url = new URL(target)
|
|
685
|
+
const base = new URL(baseUrl)
|
|
686
|
+
const baseConfig = base.searchParams.get(RNX_CONFIG_QUERY_PARAM)
|
|
687
|
+
if (baseConfig && !url.searchParams.has(RNX_CONFIG_QUERY_PARAM)) {
|
|
688
|
+
url.searchParams.set(RNX_CONFIG_QUERY_PARAM, baseConfig)
|
|
689
|
+
}
|
|
690
|
+
return url.toString()
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (looksLikeShellPathTarget(target)) {
|
|
694
|
+
return buildShellPathOpenUrl(target, baseUrl)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// check if this is a shell app (pathname-based route, not bundle)
|
|
698
|
+
const shellPath = SHELL_APPS[target.toLowerCase()]
|
|
699
|
+
if (shellPath) {
|
|
700
|
+
const url = new URL(baseUrl)
|
|
701
|
+
const prefix = getShellRoutePrefix(url.pathname)
|
|
702
|
+
url.pathname = `${prefix}${shellPath}`
|
|
703
|
+
return url.toString()
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (shouldResolveBundleTargetInCli(target)) {
|
|
707
|
+
return buildResolvedBundleOpenUrl(target, baseUrl, opts)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return buildConnectOpenUrl(target, baseUrl)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// stamp the engine page url with `?appSplash=<encodeURIComponent(JSON)>` so the
|
|
714
|
+
// engine paints the app's REAL configured splash image + background on boot
|
|
715
|
+
// (instead of the contrast device-default chrome). same discovery path as
|
|
716
|
+
// fonts: scan the bundle's project for the expo-splash-screen /
|
|
717
|
+
// react-native-bootsplash config plugin (or legacy `expo.splash`). the metro
|
|
718
|
+
// manifest fallback in the engine covers the typed-URL case; this wire is the
|
|
719
|
+
// instant-first-paint optimization.
|
|
720
|
+
async function stampAppSplash(
|
|
721
|
+
urlStr: string,
|
|
722
|
+
project?: ResolvedAppProject | null,
|
|
723
|
+
): Promise<string> {
|
|
724
|
+
try {
|
|
725
|
+
const url = new URL(urlStr)
|
|
726
|
+
const bundleUrl = url.searchParams.get('bundle') || ''
|
|
727
|
+
if (!bundleUrl || !project) return urlStr
|
|
728
|
+
const spec = discoverConfigPluginAppSplash({
|
|
729
|
+
bundleUrl,
|
|
730
|
+
projectDir: project.appDir,
|
|
731
|
+
})
|
|
732
|
+
if (!spec) return urlStr
|
|
733
|
+
// HEAD-check the image before stamping so a typo'd/missing splash path never
|
|
734
|
+
// makes the engine fetch a known-bad URL at boot.
|
|
735
|
+
if (spec.imageUrl) {
|
|
736
|
+
const reachable = await splashImageReachable(spec.imageUrl)
|
|
737
|
+
if (!reachable) return urlStr
|
|
738
|
+
}
|
|
739
|
+
url.searchParams.set('appSplash', encodeAppSplashWire(spec))
|
|
740
|
+
return url.toString()
|
|
741
|
+
} catch {
|
|
742
|
+
return urlStr
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function splashImageReachable(imageUrl: string): Promise<boolean> {
|
|
747
|
+
const controller = new AbortController()
|
|
748
|
+
const timer = setTimeout(() => controller.abort(), 1500)
|
|
749
|
+
try {
|
|
750
|
+
const res = await fetch(imageUrl, { method: 'HEAD', signal: controller.signal })
|
|
751
|
+
return res.status >= 200 && res.status < 400
|
|
752
|
+
} catch {
|
|
753
|
+
return false
|
|
754
|
+
} finally {
|
|
755
|
+
clearTimeout(timer)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export interface BuildShellUrlOptions {
|
|
760
|
+
// when set, forces the metro `hot` query on the resolved bundle URL. true
|
|
761
|
+
// for interactive opens (HMR runtime in bundle, edits hot-apply), false
|
|
762
|
+
// for `--driver playwright`/agent runs (no in-bundle HMR wiring, no ws
|
|
763
|
+
// chatter, deterministic e2e). undefined leaves whatever value metro's
|
|
764
|
+
// manifest emitted — matches real iOS sims so metro's bundle cache is
|
|
765
|
+
// shared. set by `runOpenCommand` only.
|
|
766
|
+
hot?: boolean
|
|
767
|
+
// device model slug (e.g. iphone-16) stamped onto the engine URL as
|
|
768
|
+
// ?device=<model> — the shell reads it at boot (main.tsx) and live-switch
|
|
769
|
+
// uses the same param. without this, every driver-opened sim silently ran
|
|
770
|
+
// whatever profile the shell last persisted.
|
|
771
|
+
device?: string
|
|
772
|
+
resolvedBundle?: ResolvedDevBundle
|
|
773
|
+
project?: ResolvedAppProject | null
|
|
774
|
+
// deterministic-capture mode, stamped onto the engine URL as ?proof=1. the
|
|
775
|
+
// shell worker, compositor worker and tenant worker each read it off the
|
|
776
|
+
// host page URL at boot (syncProofModeFromInitUrl) and pin everything that
|
|
777
|
+
// would otherwise differ frame to frame: the TextInput caret stops blinking
|
|
778
|
+
// and stays visible, and notifications stop auto-dismissing. capture-frame
|
|
779
|
+
// also reads the raw param and stops clipping the device corners, so a
|
|
780
|
+
// capture matches simctl's rectangular framebuffer. without it a
|
|
781
|
+
// screen holding a focused text field never reaches three identical idle
|
|
782
|
+
// frames, so the screenshot guard burns all 12 attempts and then fails.
|
|
783
|
+
proof?: boolean
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function bundleUrlOf(urlStr: string): string | undefined {
|
|
787
|
+
try {
|
|
788
|
+
return new URL(urlStr).searchParams.get('bundle') || undefined
|
|
789
|
+
} catch {
|
|
790
|
+
return undefined
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export async function buildShellUrl(
|
|
795
|
+
target: string,
|
|
796
|
+
baseUrl: string = resolveDefaultShellBaseUrl(),
|
|
797
|
+
opts: BuildShellUrlOptions = {},
|
|
798
|
+
): Promise<string> {
|
|
799
|
+
const resolved = await resolveShellUrl(target, baseUrl, opts)
|
|
800
|
+
// callers that hand us a bare port or url (the flow/maestro runner, the
|
|
801
|
+
// electron companion) have no project to pass. derive it from the bundle
|
|
802
|
+
// metro advertises so those paths get the same UIAppFonts and splash
|
|
803
|
+
// stamping an `rnx open` gets. without it a flow renders the app's text in a
|
|
804
|
+
// fallback typeface, which then reads as a conformance diff.
|
|
805
|
+
const project =
|
|
806
|
+
opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
|
|
807
|
+
const stamped = await stampAppSplash(await stampAppFonts(resolved, project), project)
|
|
808
|
+
if (!opts.device && !opts.proof) return stamped
|
|
809
|
+
const url = new URL(stamped)
|
|
810
|
+
if (opts.device) url.searchParams.set('device', opts.device)
|
|
811
|
+
if (opts.proof) url.searchParams.set('proof', '1')
|
|
812
|
+
return url.toString()
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function deriveCurrentSimBaseUrl(
|
|
816
|
+
sim: BridgeSimInfo | null | undefined,
|
|
817
|
+
fallback: string,
|
|
818
|
+
): string {
|
|
819
|
+
const source = sim?.url || sim?.origin || fallback
|
|
820
|
+
try {
|
|
821
|
+
const url = new URL(source)
|
|
822
|
+
url.searchParams.delete('bundle')
|
|
823
|
+
url.searchParams.delete('demo')
|
|
824
|
+
url.searchParams.delete('app')
|
|
825
|
+
url.searchParams.delete('open')
|
|
826
|
+
url.searchParams.delete('port')
|
|
827
|
+
url.searchParams.delete('inspectOpen')
|
|
828
|
+
// appFonts is target-specific (it points at the metro asset endpoint of
|
|
829
|
+
// the bundle being launched). carrying it over from a prior sim URL
|
|
830
|
+
// means a stale font URL from an earlier launch leaks into the new one,
|
|
831
|
+
// and `stampAppFonts` doesn't get the chance to re-derive against the
|
|
832
|
+
// current bundle. drop it so the rebuild starts clean.
|
|
833
|
+
url.searchParams.delete('appFonts')
|
|
834
|
+
// appSplash is target-specific in the same way — drop it so the configured
|
|
835
|
+
// splash re-derives against the current bundle.
|
|
836
|
+
url.searchParams.delete('appSplash')
|
|
837
|
+
return url.toString()
|
|
838
|
+
} catch {
|
|
839
|
+
return fallback
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export async function buildOpenUrl(
|
|
844
|
+
target: string,
|
|
845
|
+
baseUrl: string,
|
|
846
|
+
openToken: string,
|
|
847
|
+
opts: BuildShellUrlOptions = {},
|
|
848
|
+
): Promise<string> {
|
|
849
|
+
const url = new URL(await buildShellUrl(target, baseUrl, opts))
|
|
850
|
+
url.searchParams.set('inspectOpen', openToken)
|
|
851
|
+
return url.toString()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function waitForSimReady(
|
|
855
|
+
wsPort: number,
|
|
856
|
+
commandTimeoutMs: number,
|
|
857
|
+
simId: string,
|
|
858
|
+
opts: {
|
|
859
|
+
attempts?: number
|
|
860
|
+
intervalMs?: number
|
|
861
|
+
minNodeCount?: number
|
|
862
|
+
} = {},
|
|
863
|
+
) {
|
|
864
|
+
// the ceiling here is attempts * intervalMs. a hosted launchApp re-evals the
|
|
865
|
+
// whole guest bundle over a tunnel, which routinely outlasts the local
|
|
866
|
+
// default, so scale the poll ceiling by the same factor the flow runner
|
|
867
|
+
// scales its own waits. this still returns the moment the sim reports enough
|
|
868
|
+
// nodes; only the deadline moves, and locally the scale is 1.
|
|
869
|
+
const intervalMs = opts.intervalMs ?? 500
|
|
870
|
+
const attempts = opts.attempts ?? Math.round(30 * flowTimeoutScale())
|
|
871
|
+
const minNodeCount = opts.minNodeCount ?? 10
|
|
872
|
+
|
|
873
|
+
// a poll that only ever reports "timed out" cannot say whether the guest was
|
|
874
|
+
// still booting, never installed the test hook, or was unreachable, so keep
|
|
875
|
+
// the last observation and hand it to the caller for the failure message.
|
|
876
|
+
let lastCount: unknown
|
|
877
|
+
let lastError = ''
|
|
878
|
+
|
|
879
|
+
for (let i = 0; i < attempts; i++) {
|
|
880
|
+
const bridge = createBridge(wsPort, {
|
|
881
|
+
commandTimeoutMs,
|
|
882
|
+
simId,
|
|
883
|
+
simIdSource: 'flag',
|
|
884
|
+
})
|
|
885
|
+
try {
|
|
886
|
+
const count = await bridge.send({
|
|
887
|
+
type: 'evaluate',
|
|
888
|
+
code: '(async () => (await window.__sootsimTest?.getNodeCount()) ?? null)()',
|
|
889
|
+
})
|
|
890
|
+
lastCount = count
|
|
891
|
+
lastError = ''
|
|
892
|
+
if (typeof count === 'number' && count > minNodeCount) {
|
|
893
|
+
return { bridge, count }
|
|
894
|
+
}
|
|
895
|
+
} catch (err) {
|
|
896
|
+
lastError = err instanceof Error ? err.message : String(err)
|
|
897
|
+
}
|
|
898
|
+
bridge.close()
|
|
899
|
+
await sleep(intervalMs)
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return { bridge: null, attempts, intervalMs, minNodeCount, lastCount, lastError }
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
export async function waitForSimMatch(
|
|
906
|
+
wsPort: number,
|
|
907
|
+
commandTimeoutMs: number,
|
|
908
|
+
predicate: (sim: BridgeSimInfo) => boolean,
|
|
909
|
+
opts: { attempts?: number; intervalMs?: number } = {},
|
|
910
|
+
) {
|
|
911
|
+
const attempts = opts.attempts ?? 30
|
|
912
|
+
const intervalMs = opts.intervalMs ?? 500
|
|
913
|
+
for (let i = 0; i < attempts; i++) {
|
|
914
|
+
const bridge = createBridge(wsPort, { commandTimeoutMs })
|
|
915
|
+
try {
|
|
916
|
+
const sims = await bridge.listSims()
|
|
917
|
+
const match = sims.find(predicate)
|
|
918
|
+
if (match) return match
|
|
919
|
+
} catch {
|
|
920
|
+
// server/sim may still be booting
|
|
921
|
+
} finally {
|
|
922
|
+
bridge.close()
|
|
923
|
+
}
|
|
924
|
+
await sleep(intervalMs)
|
|
925
|
+
}
|
|
926
|
+
return null
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// same as waitForSimMatch, but also watches a detached host PID so a host
|
|
930
|
+
// crash mid-connect is surfaced as a distinct outcome rather than waiting
|
|
931
|
+
// out the full timeout (and silently treating a stale bridge entry as
|
|
932
|
+
// success). 'host-exit' wins as soon as the host process is gone, even if
|
|
933
|
+
// the bridge still lists the sim.
|
|
934
|
+
export type WaitForSimMatchOrHostExitResult =
|
|
935
|
+
| { kind: 'match'; sim: BridgeSimInfo }
|
|
936
|
+
| { kind: 'host-exit' }
|
|
937
|
+
| { kind: 'timeout' }
|
|
938
|
+
|
|
939
|
+
export async function waitForSimMatchOrHostExit(
|
|
940
|
+
wsPort: number,
|
|
941
|
+
commandTimeoutMs: number,
|
|
942
|
+
predicate: (sim: BridgeSimInfo) => boolean,
|
|
943
|
+
hostPid: number | undefined,
|
|
944
|
+
opts: { attempts?: number; intervalMs?: number } = {},
|
|
945
|
+
): Promise<WaitForSimMatchOrHostExitResult> {
|
|
946
|
+
const attempts = opts.attempts ?? 30
|
|
947
|
+
const intervalMs = opts.intervalMs ?? 500
|
|
948
|
+
const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1
|
|
949
|
+
const hostExited = () => watchHost && hostPid !== undefined && !isProcessAlive(hostPid)
|
|
950
|
+
for (let i = 0; i < attempts; i++) {
|
|
951
|
+
if (hostExited()) {
|
|
952
|
+
return { kind: 'host-exit' }
|
|
953
|
+
}
|
|
954
|
+
const bridge = createBridge(wsPort, { commandTimeoutMs })
|
|
955
|
+
try {
|
|
956
|
+
const sims = await bridge.listSims()
|
|
957
|
+
const match = sims.find(predicate)
|
|
958
|
+
if (match) {
|
|
959
|
+
if (hostExited()) return { kind: 'host-exit' }
|
|
960
|
+
return { kind: 'match', sim: match }
|
|
961
|
+
}
|
|
962
|
+
} catch {
|
|
963
|
+
// server/sim may still be booting
|
|
964
|
+
} finally {
|
|
965
|
+
bridge.close()
|
|
966
|
+
}
|
|
967
|
+
if (hostExited()) {
|
|
968
|
+
return { kind: 'host-exit' }
|
|
969
|
+
}
|
|
970
|
+
await sleep(intervalMs)
|
|
971
|
+
}
|
|
972
|
+
return { kind: 'timeout' }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function waitForSimGone(
|
|
976
|
+
wsPort: number,
|
|
977
|
+
commandTimeoutMs: number,
|
|
978
|
+
simId: string,
|
|
979
|
+
opts: { attempts?: number; intervalMs?: number } = {},
|
|
980
|
+
) {
|
|
981
|
+
const attempts = opts.attempts ?? 20
|
|
982
|
+
const intervalMs = opts.intervalMs ?? 250
|
|
983
|
+
for (let i = 0; i < attempts; i++) {
|
|
984
|
+
const bridge = createBridge(wsPort, { commandTimeoutMs })
|
|
985
|
+
try {
|
|
986
|
+
const sims = await bridge.listSims()
|
|
987
|
+
const sim = sims.find((entry) => entry.id === simId)
|
|
988
|
+
if (!sim || sim.readyState !== 'open') {
|
|
989
|
+
return true
|
|
990
|
+
}
|
|
991
|
+
} catch {
|
|
992
|
+
return true
|
|
993
|
+
} finally {
|
|
994
|
+
bridge.close()
|
|
995
|
+
}
|
|
996
|
+
await sleep(intervalMs)
|
|
997
|
+
}
|
|
998
|
+
return false
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export function playwrightHostPidForSim(sim: BridgeSimInfo): number | null {
|
|
1002
|
+
const meta = sim.meta
|
|
1003
|
+
if (!meta || meta.sootsimHostDriver !== 'playwright') return null
|
|
1004
|
+
const pid = Number(meta.sootsimHostPid)
|
|
1005
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return null
|
|
1006
|
+
return pid
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export function signalDriverLaunchConnected(result: DriverLaunchResult): boolean {
|
|
1010
|
+
if (!result.connectAckFile) return false
|
|
1011
|
+
try {
|
|
1012
|
+
fs.writeFileSync(
|
|
1013
|
+
result.connectAckFile,
|
|
1014
|
+
`${JSON.stringify({ connectedAt: Date.now(), pid: result.pid ?? null })}\n`,
|
|
1015
|
+
{ flag: 'w' },
|
|
1016
|
+
)
|
|
1017
|
+
return true
|
|
1018
|
+
} catch {
|
|
1019
|
+
return false
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function printDriverDiagnosticLogTail(result: DriverLaunchResult) {
|
|
1024
|
+
if (!result.diagnosticLogPath) return
|
|
1025
|
+
try {
|
|
1026
|
+
const detail = fs.readFileSync(result.diagnosticLogPath, 'utf8').trim()
|
|
1027
|
+
if (!detail) {
|
|
1028
|
+
console.error(` ${path.basename(result.diagnosticLogPath)} was empty`)
|
|
1029
|
+
return
|
|
1030
|
+
}
|
|
1031
|
+
const shown = detail.length > 4_000 ? `…\n${detail.slice(-4_000)}` : detail
|
|
1032
|
+
console.error(` ${path.basename(result.diagnosticLogPath)}:`)
|
|
1033
|
+
console.error(shown)
|
|
1034
|
+
} catch (err) {
|
|
1035
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1036
|
+
console.error(
|
|
1037
|
+
` failed to read ${path.basename(result.diagnosticLogPath)}: ${message}`,
|
|
1038
|
+
)
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function isProcessAlive(pid: number): boolean {
|
|
1043
|
+
try {
|
|
1044
|
+
process.kill(pid, 0)
|
|
1045
|
+
return true
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
return (err as NodeJS.ErrnoException)?.code === 'EPERM'
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function waitForProcessExit(pid: number, timeoutMs = 2_500): Promise<boolean> {
|
|
1052
|
+
const deadline = Date.now() + timeoutMs
|
|
1053
|
+
while (Date.now() < deadline) {
|
|
1054
|
+
if (!isProcessAlive(pid)) return true
|
|
1055
|
+
await sleep(100)
|
|
1056
|
+
}
|
|
1057
|
+
return !isProcessAlive(pid)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// a sim record's host pid can outlive its process; after pid reuse the number
|
|
1061
|
+
// may point at an unrelated process (real incident 2026-07-14: a stale record
|
|
1062
|
+
// made `rnx close` SIGTERM the shared vite shell dev server, taking the
|
|
1063
|
+
// whole dev stack down). only signal a pid whose argv proves it is still a
|
|
1064
|
+
// rnx playwright host launcher.
|
|
1065
|
+
function isPlaywrightHostProcess(pid: number): boolean {
|
|
1066
|
+
// `ps eww` prints argv then the process ENVIRONMENT for same-user
|
|
1067
|
+
// processes. the marker must live in the env SUFFIX (everything past the
|
|
1068
|
+
// plain argv), because argv text can contain the string coincidentally —
|
|
1069
|
+
// e.g. a shell wrapper that exports SOOTSIM_PW_* vars in its script body.
|
|
1070
|
+
const plain = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], {
|
|
1071
|
+
encoding: 'utf8',
|
|
1072
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1073
|
+
})
|
|
1074
|
+
const withEnv = spawnSync('ps', ['eww', '-o', 'command=', '-p', String(pid)], {
|
|
1075
|
+
encoding: 'utf8',
|
|
1076
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1077
|
+
})
|
|
1078
|
+
if (plain.status !== 0 || withEnv.status !== 0) return false
|
|
1079
|
+
const argv = (plain.stdout || '').trimEnd()
|
|
1080
|
+
const full = (withEnv.stdout || '').trimEnd()
|
|
1081
|
+
const envSuffix = full.startsWith(argv) ? full.slice(argv.length) : full
|
|
1082
|
+
return envSuffix.includes('SOOTSIM_PW_URL=')
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
export async function terminatePlaywrightHostsForSims(
|
|
1086
|
+
sims: BridgeSimInfo[],
|
|
1087
|
+
ids: string[],
|
|
1088
|
+
) {
|
|
1089
|
+
const wanted = new Set(ids)
|
|
1090
|
+
const pids = new Set<number>()
|
|
1091
|
+
for (const sim of sims) {
|
|
1092
|
+
if (!wanted.has(sim.id)) continue
|
|
1093
|
+
const pid = playwrightHostPidForSim(sim)
|
|
1094
|
+
if (pid) pids.add(pid)
|
|
1095
|
+
}
|
|
1096
|
+
for (const pid of pids) {
|
|
1097
|
+
if (!isPlaywrightHostProcess(pid)) {
|
|
1098
|
+
console.log(` skipped pid ${pid}: not a rnx playwright host (stale sim record)`)
|
|
1099
|
+
continue
|
|
1100
|
+
}
|
|
1101
|
+
try {
|
|
1102
|
+
process.kill(pid, 'SIGTERM')
|
|
1103
|
+
} catch {}
|
|
1104
|
+
if (await waitForProcessExit(pid)) {
|
|
1105
|
+
console.log(` closed playwright host process ${pid}`)
|
|
1106
|
+
continue
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
process.kill(pid, 'SIGKILL')
|
|
1110
|
+
console.log(` force-closed playwright host process ${pid}`)
|
|
1111
|
+
} catch {}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function resolveTargetSim(sims: BridgeSimInfo[], requestedId?: string): BridgeSimInfo {
|
|
1116
|
+
if (requestedId) {
|
|
1117
|
+
const normalizedId = requestedId.trim()
|
|
1118
|
+
const match = sims.find((sim) => sim.id === normalizedId)
|
|
1119
|
+
if (!match) {
|
|
1120
|
+
throw new Error(`no sim connected with id ${normalizedId}`)
|
|
1121
|
+
}
|
|
1122
|
+
return match
|
|
1123
|
+
}
|
|
1124
|
+
const primary = sims.find((sim) => sim.isPrimary && sim.readyState === 'open')
|
|
1125
|
+
if (primary) return primary
|
|
1126
|
+
const firstOpen = sims.find((sim) => sim.readyState === 'open')
|
|
1127
|
+
if (firstOpen) return firstOpen
|
|
1128
|
+
throw new Error('no sim connected')
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
export function printOpenedSim(
|
|
1132
|
+
openUrl: string,
|
|
1133
|
+
sim: BridgeSimInfo,
|
|
1134
|
+
mode:
|
|
1135
|
+
| 'desktop companion'
|
|
1136
|
+
| 'bridge'
|
|
1137
|
+
| 'direct shell open'
|
|
1138
|
+
| 'current sim'
|
|
1139
|
+
| `${string} driver`,
|
|
1140
|
+
quiet = false,
|
|
1141
|
+
) {
|
|
1142
|
+
const [printableOpenUrl, printableSimUrl] = [openUrl, sim.url].map((value) => {
|
|
1143
|
+
if (!value) return value
|
|
1144
|
+
try {
|
|
1145
|
+
const parsed = new URL(value)
|
|
1146
|
+
let changed = false
|
|
1147
|
+
if (parsed.searchParams.has(RNX_CONFIG_QUERY_PARAM)) {
|
|
1148
|
+
parsed.searchParams.set(RNX_CONFIG_QUERY_PARAM, '[redacted]')
|
|
1149
|
+
changed = true
|
|
1150
|
+
}
|
|
1151
|
+
const bundle = parsed.searchParams.get('bundle')
|
|
1152
|
+
if (bundle) {
|
|
1153
|
+
try {
|
|
1154
|
+
const parsedBundle = new URL(bundle)
|
|
1155
|
+
if (parsedBundle.searchParams.has(RNX_CONFIG_QUERY_PARAM)) {
|
|
1156
|
+
parsedBundle.searchParams.set(RNX_CONFIG_QUERY_PARAM, '[redacted]')
|
|
1157
|
+
parsed.searchParams.set('bundle', parsedBundle.toString())
|
|
1158
|
+
changed = true
|
|
1159
|
+
}
|
|
1160
|
+
} catch {}
|
|
1161
|
+
}
|
|
1162
|
+
if (changed) return parsed.toString()
|
|
1163
|
+
} catch {}
|
|
1164
|
+
return value
|
|
1165
|
+
})
|
|
1166
|
+
if (quiet) {
|
|
1167
|
+
console.log(` Opened ConnectRN [${mode}]`)
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
console.log(
|
|
1171
|
+
` ${mode === 'current sim' ? 'loaded' : 'opened'}: ${printableOpenUrl} [${mode}]`,
|
|
1172
|
+
)
|
|
1173
|
+
// agents pipe around the JSON to grab the id; humans just want to know
|
|
1174
|
+
// the current sim got updated without scanning the JSON blob.
|
|
1175
|
+
console.log(` current sim: ${sim.id}`)
|
|
1176
|
+
console.log(JSON.stringify({ simId: sim.id, url: printableSimUrl }, null, 2))
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// after a successful open, agents almost always want to know what's on the
|
|
1180
|
+
// screen. running describe inline saves a separate roundtrip. tightly
|
|
1181
|
+
// bounded — a cold guest bundle takes 5-15s to fully route, so we don't
|
|
1182
|
+
// try to wait for that. instead: settle for a moment to let the first
|
|
1183
|
+
// paint land, then describe whatever's on screen. for cold loads that's
|
|
1184
|
+
// a splash; the caller can `rnx describe` again a second later. for
|
|
1185
|
+
// warm loads it's the real screen. guarded by --no-describe.
|
|
1186
|
+
async function maybeDescribeAfterOpen(wsPort: number, simId: string, args: string[]) {
|
|
1187
|
+
if (args.includes('--no-describe')) return
|
|
1188
|
+
const prevQuiet = process.env.SOOTSIM_QUIET_TARGET_NOTICE
|
|
1189
|
+
process.env.SOOTSIM_QUIET_TARGET_NOTICE = '1'
|
|
1190
|
+
try {
|
|
1191
|
+
// brief settle, then dump the tree directly. don't go through
|
|
1192
|
+
// runDescribeSubcommand — its screen-transition wait can block for many
|
|
1193
|
+
// seconds on a still-routing guest app, which is exactly what we want
|
|
1194
|
+
// to skip for the open-side describe. caller can `rnx describe`
|
|
1195
|
+
// again later for post-routing state.
|
|
1196
|
+
await waitForTreeStable(wsPort, simId, { stableMs: 150, maxMs: 400 })
|
|
1197
|
+
const bridge = createBridge(wsPort, {
|
|
1198
|
+
commandTimeoutMs: 3000,
|
|
1199
|
+
simId,
|
|
1200
|
+
cliLabel: 'open --describe',
|
|
1201
|
+
simIdSource: 'flag',
|
|
1202
|
+
})
|
|
1203
|
+
try {
|
|
1204
|
+
let readyProbe: Awaited<ReturnType<typeof inspectWaitReady>> | null = null
|
|
1205
|
+
try {
|
|
1206
|
+
readyProbe = await inspectWaitReady(bridge, 1)
|
|
1207
|
+
} catch {}
|
|
1208
|
+
if (readyProbe) {
|
|
1209
|
+
if (!readyProbe.ready) {
|
|
1210
|
+
const reason = readyProbe.loadingText
|
|
1211
|
+
? `still showing "${readyProbe.loadingText}"`
|
|
1212
|
+
: readyProbe.flag !== true
|
|
1213
|
+
? 'guest ready event has not fired'
|
|
1214
|
+
: readyProbe.targets <= 0
|
|
1215
|
+
? 'no visible app content is inspectable yet'
|
|
1216
|
+
: 'app tree is still stabilizing'
|
|
1217
|
+
console.log(
|
|
1218
|
+
` app still loading: ${reason} (nodes: ${readyProbe.nodes}, targets: ${readyProbe.targets})`,
|
|
1219
|
+
)
|
|
1220
|
+
console.log(' before interacting, run: rnx wait ready --max-ms 120000')
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
const result = await inspectDescribe(bridge, {
|
|
1224
|
+
describe: true,
|
|
1225
|
+
verbose: false,
|
|
1226
|
+
filter: '',
|
|
1227
|
+
compact: true,
|
|
1228
|
+
hideXy: false,
|
|
1229
|
+
maxDepth: 12,
|
|
1230
|
+
})
|
|
1231
|
+
if (!result?.tree) return
|
|
1232
|
+
console.log('')
|
|
1233
|
+
console.log(result.tree)
|
|
1234
|
+
} finally {
|
|
1235
|
+
bridge.close()
|
|
1236
|
+
}
|
|
1237
|
+
} catch {
|
|
1238
|
+
// describe is a convenience — never fail the open command on it
|
|
1239
|
+
} finally {
|
|
1240
|
+
if (prevQuiet === undefined) {
|
|
1241
|
+
delete process.env.SOOTSIM_QUIET_TARGET_NOTICE
|
|
1242
|
+
} else {
|
|
1243
|
+
process.env.SOOTSIM_QUIET_TARGET_NOTICE = prevQuiet
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// poll node count and return as soon as two samples match across `stableMs`,
|
|
1249
|
+
// capped by `maxMs`. brief and bounded so this never blocks the open command.
|
|
1250
|
+
async function waitForTreeStable(
|
|
1251
|
+
wsPort: number,
|
|
1252
|
+
simId: string,
|
|
1253
|
+
opts: { stableMs: number; maxMs: number },
|
|
1254
|
+
): Promise<void> {
|
|
1255
|
+
const deadline = Date.now() + opts.maxMs
|
|
1256
|
+
const probeCode = `(async () => (await window.__sootsimTest?.getNodeCount?.()) || 0)()`
|
|
1257
|
+
const bridge = createBridge(wsPort, {
|
|
1258
|
+
commandTimeoutMs: 2000,
|
|
1259
|
+
simId,
|
|
1260
|
+
simIdSource: 'flag',
|
|
1261
|
+
})
|
|
1262
|
+
try {
|
|
1263
|
+
let lastCount = -1
|
|
1264
|
+
let stableSince = 0
|
|
1265
|
+
while (Date.now() < deadline) {
|
|
1266
|
+
let count = -1
|
|
1267
|
+
try {
|
|
1268
|
+
const c = await bridge.send({ type: 'evaluate', code: probeCode })
|
|
1269
|
+
if (typeof c === 'number') count = c
|
|
1270
|
+
} catch {
|
|
1271
|
+
// transient — try again
|
|
1272
|
+
}
|
|
1273
|
+
if (count >= 0 && count === lastCount) {
|
|
1274
|
+
if (Date.now() - stableSince >= opts.stableMs) return
|
|
1275
|
+
} else {
|
|
1276
|
+
lastCount = count
|
|
1277
|
+
stableSince = Date.now()
|
|
1278
|
+
}
|
|
1279
|
+
await sleep(50)
|
|
1280
|
+
}
|
|
1281
|
+
} finally {
|
|
1282
|
+
bridge.close()
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
export async function runLinkCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1287
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1288
|
+
port: opts.port,
|
|
1289
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
1290
|
+
})
|
|
1291
|
+
if (parsed.positional.length !== 1) {
|
|
1292
|
+
console.error(' rnx open: expected exactly one <url-or-route>')
|
|
1293
|
+
console.error(' examples: rnx open /settings | rnx open myapp://settings')
|
|
1294
|
+
rnxExit(1)
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
1298
|
+
const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
|
|
1299
|
+
try {
|
|
1300
|
+
const target = await openAppUrl(bridge, parsed.positional[0] ?? '')
|
|
1301
|
+
console.log(` opened deep link: ${target}`)
|
|
1302
|
+
} catch (err) {
|
|
1303
|
+
console.error(` link failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1304
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
1305
|
+
errorsCommand: `rnx get errors 5${simHint}`,
|
|
1306
|
+
warningsCommand: `rnx get warnings 5${simHint}`,
|
|
1307
|
+
requestsCommand: `rnx get requests 5${simHint}`,
|
|
1308
|
+
})
|
|
1309
|
+
rnxExit(1)
|
|
1310
|
+
} finally {
|
|
1311
|
+
bridge.close()
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
export async function runOpenCommand(
|
|
1316
|
+
args: string[],
|
|
1317
|
+
opts: ControlCommandOptions = {},
|
|
1318
|
+
): Promise<{ simId: string; launched: boolean } | undefined> {
|
|
1319
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1320
|
+
port: opts.port,
|
|
1321
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
1322
|
+
stripBooleanFlags: [
|
|
1323
|
+
'--new',
|
|
1324
|
+
'--headless',
|
|
1325
|
+
'--headed',
|
|
1326
|
+
'--ephemeral',
|
|
1327
|
+
'--hot',
|
|
1328
|
+
'--no-hmr',
|
|
1329
|
+
'--no-describe',
|
|
1330
|
+
'--quiet',
|
|
1331
|
+
'--proof',
|
|
1332
|
+
],
|
|
1333
|
+
stripValueFlags: [
|
|
1334
|
+
'--base-url',
|
|
1335
|
+
'--replace',
|
|
1336
|
+
'--driver',
|
|
1337
|
+
'--profile',
|
|
1338
|
+
'--cdp-port',
|
|
1339
|
+
'--device',
|
|
1340
|
+
'--viewport',
|
|
1341
|
+
],
|
|
1342
|
+
})
|
|
1343
|
+
const requestedProfileId = args.find((_, i) => args[i - 1] === '--profile')
|
|
1344
|
+
const quiet = args.includes('--quiet')
|
|
1345
|
+
const ephemeralProfile = args.includes('--ephemeral')
|
|
1346
|
+
// --cdp-port exposes Chrome's remote-debugging endpoint on the opened sim so
|
|
1347
|
+
// it can be cpu-profiled while bridge-driven (playwright driver only).
|
|
1348
|
+
const cdpPortArg = args.find((_, i) => args[i - 1] === '--cdp-port')
|
|
1349
|
+
const cdpPort = cdpPortArg ? Number(cdpPortArg) : undefined
|
|
1350
|
+
if (cdpPortArg && (!Number.isFinite(cdpPort) || cdpPort! <= 0)) {
|
|
1351
|
+
console.error(
|
|
1352
|
+
` rnx open: --cdp-port must be a positive port number, got "${cdpPortArg}"`,
|
|
1353
|
+
)
|
|
1354
|
+
rnxExit(1)
|
|
1355
|
+
}
|
|
1356
|
+
if (requestedProfileId && ephemeralProfile) {
|
|
1357
|
+
console.error(' rnx open: --profile cannot be combined with --ephemeral')
|
|
1358
|
+
rnxExit(1)
|
|
1359
|
+
}
|
|
1360
|
+
const profileId = requestedProfileId ? ensureProfile(requestedProfileId).id : undefined
|
|
1361
|
+
const openInNewSim = args.includes('--new') || !!profileId || ephemeralProfile
|
|
1362
|
+
const commandConfig = parseOpenRuntimeConfig(args)
|
|
1363
|
+
if (openInNewSim && parsed.simIdSource === 'flag') {
|
|
1364
|
+
console.error(
|
|
1365
|
+
' rnx open: --new, --profile, and --ephemeral cannot be combined with --sim',
|
|
1366
|
+
)
|
|
1367
|
+
rnxExit(1)
|
|
1368
|
+
}
|
|
1369
|
+
const target = parsed.positional[0] || ''
|
|
1370
|
+
|
|
1371
|
+
if (isAppDeepLinkTarget(target)) {
|
|
1372
|
+
if (openInNewSim) {
|
|
1373
|
+
console.error(
|
|
1374
|
+
' rnx open: deep links target the current app; remove --new, --profile, or --ephemeral',
|
|
1375
|
+
)
|
|
1376
|
+
rnxExit(1)
|
|
1377
|
+
}
|
|
1378
|
+
await runLinkCommand(args, opts)
|
|
1379
|
+
return
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// a preview/build link is a viewing page, not a driveable sim target —
|
|
1383
|
+
// open it in the browser and explain rather than mis-loading it as a
|
|
1384
|
+
// bundle. see looksLikeSootSimShareUrl for why these can't be driven.
|
|
1385
|
+
if (looksLikeSootSimShareUrl(target)) {
|
|
1386
|
+
const isLocal = (() => {
|
|
1387
|
+
try {
|
|
1388
|
+
return isLoopbackHost(new URL(target).hostname)
|
|
1389
|
+
} catch {
|
|
1390
|
+
return false
|
|
1391
|
+
}
|
|
1392
|
+
})()
|
|
1393
|
+
console.log(
|
|
1394
|
+
' that’s an rnx preview/build link — a full player page, not a driveable sim.',
|
|
1395
|
+
)
|
|
1396
|
+
console.log(' opening it in your browser for viewing…')
|
|
1397
|
+
try {
|
|
1398
|
+
await openUrlInBrowser(target, { background: false })
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
console.error(
|
|
1401
|
+
` could not launch a browser: ${err instanceof Error ? err.message : String(err)}`,
|
|
1402
|
+
)
|
|
1403
|
+
console.error(` open it yourself: ${target}`)
|
|
1404
|
+
rnxExit(1)
|
|
1405
|
+
}
|
|
1406
|
+
console.log(
|
|
1407
|
+
isLocal
|
|
1408
|
+
? ' to drive it under the CLI, point `rnx open` at the app’s dev port instead (e.g. `rnx open 8081`).'
|
|
1409
|
+
: ' the CLI can’t drive a remote preview (the engine only attaches to the local bridge from localhost). to drive the app, run it locally and `rnx open <port>`.',
|
|
1410
|
+
)
|
|
1411
|
+
return
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
const resolvedBundle = shouldResolveBundleTargetInCli(target)
|
|
1415
|
+
? await resolveConnectionInputForCli(target)
|
|
1416
|
+
: undefined
|
|
1417
|
+
const project = await resolveAppProjectForBundleUrl(resolvedBundle?.bundleUrl)
|
|
1418
|
+
const appConfig = await loadRNXAppConfig(project)
|
|
1419
|
+
const configuredRuntimeVersion = appConfig?.runtimeVersion
|
|
1420
|
+
const runtimeConfig = mergeRNXConfig(
|
|
1421
|
+
mergeRNXConfig(
|
|
1422
|
+
appConfig ? { ...appConfig, runtimeVersion: undefined } : undefined,
|
|
1423
|
+
opts.runtimeConfig,
|
|
1424
|
+
),
|
|
1425
|
+
commandConfig,
|
|
1426
|
+
)
|
|
1427
|
+
if (configuredRuntimeVersion) {
|
|
1428
|
+
await ensureConfiguredRuntime(configuredRuntimeVersion)
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// keep the bridge port and the shell base URL in one world: --base-url
|
|
1432
|
+
// retargets polling at THAT world's bridge (otherwise open builds the page
|
|
1433
|
+
// against one runtime while polling the other bridge for registration and
|
|
1434
|
+
// times out after ~125s), and a pin nothing serves is refused rather than
|
|
1435
|
+
// falling back to the discovered default, which used to send
|
|
1436
|
+
// ensureBridgeForOpen below off to start a second shell.
|
|
1437
|
+
const explicitBaseUrl = args.find((_, i) => args[i - 1] === '--base-url')
|
|
1438
|
+
if (explicitBaseUrl) {
|
|
1439
|
+
const pinned = resolveBridgePortForPin({
|
|
1440
|
+
baseUrl: explicitBaseUrl,
|
|
1441
|
+
wsPort: parsed.wsPort,
|
|
1442
|
+
explicitPort: parsed.explicitPort,
|
|
1443
|
+
})
|
|
1444
|
+
if (pinned.error !== undefined) {
|
|
1445
|
+
console.error(` error: ${pinned.error}`)
|
|
1446
|
+
rnxExit(1)
|
|
1447
|
+
} else {
|
|
1448
|
+
parsed.wsPort = pinned.port
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort)
|
|
1453
|
+
|
|
1454
|
+
// playwright is the browser-sim launcher. it owns an isolated profile and
|
|
1455
|
+
// can reap exactly its own browser tree. electron remains available for the
|
|
1456
|
+
// native desktop surface. neither path opens the user's browser profile.
|
|
1457
|
+
const requestedDriverId = args.find((_, i) => args[i - 1] === '--driver') || ''
|
|
1458
|
+
if (requestedDriverId && !isDriverId(requestedDriverId)) {
|
|
1459
|
+
console.error(` unknown driver "${requestedDriverId}" — run \`rnx list --drivers\``)
|
|
1460
|
+
rnxExit(1)
|
|
1461
|
+
}
|
|
1462
|
+
const driverId: DriverId = isDriverId(requestedDriverId)
|
|
1463
|
+
? requestedDriverId
|
|
1464
|
+
: process.env.CI === '1' ||
|
|
1465
|
+
process.env.CI === 'true' ||
|
|
1466
|
+
process.env.GITHUB_ACTIONS === 'true' ||
|
|
1467
|
+
isAgentEnv() ||
|
|
1468
|
+
!process.stdin.isTTY
|
|
1469
|
+
? 'playwright'
|
|
1470
|
+
: readSimulatorDriverPreference() || 'playwright'
|
|
1471
|
+
const driverHeadless = resolveDriverHeadless(args, driverId)
|
|
1472
|
+
if (cdpPort && driverId !== 'playwright') {
|
|
1473
|
+
console.error(' rnx open: --cdp-port is only honored by the playwright driver')
|
|
1474
|
+
rnxExit(1)
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// metro HMR mode. interactive opens default to hot=true so edits hot-apply
|
|
1478
|
+
// through rnx's HmrClient — expo CLI's createBundleUrlSearchParams
|
|
1479
|
+
// hardcodes hot=false on every launchAsset URL (a leftover toggle real iOS
|
|
1480
|
+
// sims tolerate but rnx couldn't until now), so without this flip the
|
|
1481
|
+
// bundle prelude doesn't include metro's HMR runtime + react-refresh and
|
|
1482
|
+
// edits silently no-op. `--driver playwright` (agent / CI runs) defaults
|
|
1483
|
+
// to hot=false for deterministic e2e. explicit `--hot` / `--no-hmr` win.
|
|
1484
|
+
const hotFlag = args.includes('--hot')
|
|
1485
|
+
const noHmrFlag = args.includes('--no-hmr')
|
|
1486
|
+
if (hotFlag && noHmrFlag) {
|
|
1487
|
+
console.error(' rnx open: --hot and --no-hmr cannot be combined')
|
|
1488
|
+
rnxExit(1)
|
|
1489
|
+
}
|
|
1490
|
+
const hotMode = hotFlag ? true : noHmrFlag ? false : driverId !== 'playwright'
|
|
1491
|
+
|
|
1492
|
+
// --device <model> seeds the device profile for the opened sim (stamped on
|
|
1493
|
+
// the engine URL, so both playwright and electron honor it). without it
|
|
1494
|
+
// the sim runs whatever profile the
|
|
1495
|
+
// shell last persisted, which silently skews browser comparisons.
|
|
1496
|
+
const deviceArg = args.find((_, i) => args[i - 1] === '--device')
|
|
1497
|
+
if (deviceArg && !(deviceArg in devices)) {
|
|
1498
|
+
console.error(` rnx open: unknown device "${deviceArg}"`)
|
|
1499
|
+
console.error(` known devices: ${listSelectableDeviceModels().join(', ')}`)
|
|
1500
|
+
rnxExit(1)
|
|
1501
|
+
}
|
|
1502
|
+
const deviceSpec = deviceArg ? devices[deviceArg as DeviceModel] : undefined
|
|
1503
|
+
|
|
1504
|
+
// --viewport <WxH> sizes the driver-launched page explicitly (film and
|
|
1505
|
+
// promo captures frame the 3d stage in landscape). takes precedence over
|
|
1506
|
+
// the device-derived window; without either, the driver keeps its own
|
|
1507
|
+
// default.
|
|
1508
|
+
const viewportArg = args.find((_, i) => args[i - 1] === '--viewport')
|
|
1509
|
+
const viewportMatch = viewportArg ? /^(\d+)x(\d+)$/.exec(viewportArg) : null
|
|
1510
|
+
if (viewportArg && !viewportMatch) {
|
|
1511
|
+
console.error(` rnx open: --viewport must be <width>x<height>, got "${viewportArg}"`)
|
|
1512
|
+
rnxExit(1)
|
|
1513
|
+
}
|
|
1514
|
+
const explicitViewport = viewportMatch
|
|
1515
|
+
? { width: Number(viewportMatch[1]), height: Number(viewportMatch[2]) }
|
|
1516
|
+
: undefined
|
|
1517
|
+
|
|
1518
|
+
const buildShellUrlOpts: BuildShellUrlOptions = {
|
|
1519
|
+
hot: hotMode,
|
|
1520
|
+
device: deviceArg,
|
|
1521
|
+
resolvedBundle,
|
|
1522
|
+
project,
|
|
1523
|
+
proof: args.includes('--proof'),
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// resolve the shell base URL from the bridge port we settled on, so the page
|
|
1527
|
+
// and the polling never split across worlds.
|
|
1528
|
+
const selectedShellBaseUrl = configuredRuntimeVersion
|
|
1529
|
+
? `http://localhost:${parsed.wsPort}/`
|
|
1530
|
+
: explicitBaseUrl || resolveShellBaseUrlForBridgePort(parsed.wsPort)
|
|
1531
|
+
const baseUrl = applyRuntimeGenerationOrigin(
|
|
1532
|
+
selectedShellBaseUrl,
|
|
1533
|
+
configuredRuntimeVersion,
|
|
1534
|
+
)
|
|
1535
|
+
const hasExplicitBaseUrl = args.includes('--base-url')
|
|
1536
|
+
printBridgeWorldNotice(parsed.wsPort, baseUrl)
|
|
1537
|
+
const savedSimId = readCurrentSimId()
|
|
1538
|
+
const shouldReuseCurrentSim = !openInNewSim
|
|
1539
|
+
// every open stamps its page URL with a one-off token and matches the sim by
|
|
1540
|
+
// it. a sim id alone cannot tell the page this open loaded from the page it
|
|
1541
|
+
// replaced, which stays registered until the browser commits the navigation.
|
|
1542
|
+
const token = `cli-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
1543
|
+
const simMatchesToken = (sim: BridgeSimInfo) =>
|
|
1544
|
+
sim.url?.includes(`inspectOpen=${token}`) ?? false
|
|
1545
|
+
|
|
1546
|
+
if (shouldReuseCurrentSim) {
|
|
1547
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
1548
|
+
const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
|
|
1549
|
+
let fallBackToNewSim = false
|
|
1550
|
+
let navigatedShellUrl: string | null = null
|
|
1551
|
+
|
|
1552
|
+
try {
|
|
1553
|
+
let targetSim: BridgeSimInfo | null = null
|
|
1554
|
+
let targetShellUrl: string | null = null
|
|
1555
|
+
try {
|
|
1556
|
+
const sims = await bridge.listSims()
|
|
1557
|
+
if (parsed.simIdSource === 'saved') {
|
|
1558
|
+
targetSim =
|
|
1559
|
+
sims.find(
|
|
1560
|
+
(sim) =>
|
|
1561
|
+
sim.id === savedSimId &&
|
|
1562
|
+
sim.readyState === 'open' &&
|
|
1563
|
+
simUsesDriver(sim, driverId),
|
|
1564
|
+
) ?? null
|
|
1565
|
+
if (!targetSim) {
|
|
1566
|
+
clearCurrentSimId()
|
|
1567
|
+
}
|
|
1568
|
+
} else if (parsed.simIdSource === 'flag') {
|
|
1569
|
+
targetSim = resolveTargetSim(sims, parsed.simId)
|
|
1570
|
+
if (!simUsesDriver(targetSim, driverId)) {
|
|
1571
|
+
throw new Error(
|
|
1572
|
+
`sim ${targetSim.id} is not managed by the ${driverId} driver`,
|
|
1573
|
+
)
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
if (!targetSim && parsed.simIdSource !== 'flag') {
|
|
1577
|
+
targetShellUrl = await buildShellUrl(
|
|
1578
|
+
target,
|
|
1579
|
+
applyRNXConfigToUrl(baseUrl, runtimeConfig),
|
|
1580
|
+
buildShellUrlOpts,
|
|
1581
|
+
)
|
|
1582
|
+
const normalizedTarget = new URL(targetShellUrl)
|
|
1583
|
+
normalizedTarget.searchParams.delete('inspectOpen')
|
|
1584
|
+
targetSim =
|
|
1585
|
+
sims.find((sim) => {
|
|
1586
|
+
if (
|
|
1587
|
+
sim.readyState !== 'open' ||
|
|
1588
|
+
!sim.url ||
|
|
1589
|
+
!simUsesDriver(sim, driverId)
|
|
1590
|
+
) {
|
|
1591
|
+
return false
|
|
1592
|
+
}
|
|
1593
|
+
try {
|
|
1594
|
+
const normalizedCurrent = new URL(sim.url)
|
|
1595
|
+
normalizedCurrent.searchParams.delete('inspectOpen')
|
|
1596
|
+
return normalizedCurrent.toString() === normalizedTarget.toString()
|
|
1597
|
+
} catch {
|
|
1598
|
+
return false
|
|
1599
|
+
}
|
|
1600
|
+
}) ?? null
|
|
1601
|
+
}
|
|
1602
|
+
if (!targetSim) {
|
|
1603
|
+
if (parsed.simIdSource !== 'flag') {
|
|
1604
|
+
fallBackToNewSim = true
|
|
1605
|
+
} else {
|
|
1606
|
+
throw new Error('no sim connected')
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if (!fallBackToNewSim && targetSim) {
|
|
1610
|
+
const resolvedBaseUrl =
|
|
1611
|
+
hasExplicitBaseUrl || looksLikeSootsimUrl(target)
|
|
1612
|
+
? baseUrl
|
|
1613
|
+
: deriveCurrentSimBaseUrl(targetSim, baseUrl)
|
|
1614
|
+
navigatedShellUrl =
|
|
1615
|
+
targetShellUrl ??
|
|
1616
|
+
(await buildShellUrl(
|
|
1617
|
+
target,
|
|
1618
|
+
applyRNXConfigToUrl(resolvedBaseUrl, runtimeConfig),
|
|
1619
|
+
buildShellUrlOpts,
|
|
1620
|
+
))
|
|
1621
|
+
const navigationUrl = new URL(navigatedShellUrl)
|
|
1622
|
+
navigationUrl.searchParams.set('inspectOpen', token)
|
|
1623
|
+
bridge
|
|
1624
|
+
.send({
|
|
1625
|
+
type: 'evaluate',
|
|
1626
|
+
simId: targetSim.id,
|
|
1627
|
+
code: `window.location.href = ${JSON.stringify(navigationUrl.toString())}`,
|
|
1628
|
+
})
|
|
1629
|
+
.catch(() => {})
|
|
1630
|
+
}
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
console.error(
|
|
1633
|
+
` open failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1634
|
+
)
|
|
1635
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
1636
|
+
errorsCommand: `rnx get errors 5${simHint}`,
|
|
1637
|
+
warningsCommand: `rnx get warnings 5${simHint}`,
|
|
1638
|
+
requestsCommand: `rnx get requests 5${simHint}`,
|
|
1639
|
+
})
|
|
1640
|
+
rnxExit(1)
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
if (!fallBackToNewSim && targetSim && navigatedShellUrl) {
|
|
1644
|
+
// the navigated page may keep the sim id (restored from session
|
|
1645
|
+
// storage) or register a new one, so match by token, never by id.
|
|
1646
|
+
const navigatedSim = await waitForSimMatch(
|
|
1647
|
+
parsed.wsPort,
|
|
1648
|
+
parsed.commandTimeoutMs,
|
|
1649
|
+
(sim) =>
|
|
1650
|
+
sim.readyState === 'open' &&
|
|
1651
|
+
simUsesDriver(sim, driverId) &&
|
|
1652
|
+
simMatchesToken(sim),
|
|
1653
|
+
{ attempts: Math.round(30 * flowTimeoutScale()) },
|
|
1654
|
+
)
|
|
1655
|
+
if (!navigatedSim) {
|
|
1656
|
+
console.error(
|
|
1657
|
+
` timed out waiting for sim ${targetSim.id} to load ${navigatedShellUrl}`,
|
|
1658
|
+
)
|
|
1659
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
1660
|
+
errorsCommand: `rnx get errors 5${simHint}`,
|
|
1661
|
+
warningsCommand: `rnx get warnings 5${simHint}`,
|
|
1662
|
+
requestsCommand: `rnx get requests 5${simHint}`,
|
|
1663
|
+
})
|
|
1664
|
+
rnxExit(1)
|
|
1665
|
+
}
|
|
1666
|
+
targetSim = navigatedSim
|
|
1667
|
+
const ready = await waitForSimReady(
|
|
1668
|
+
parsed.wsPort,
|
|
1669
|
+
parsed.commandTimeoutMs,
|
|
1670
|
+
targetSim.id,
|
|
1671
|
+
)
|
|
1672
|
+
if (!ready.bridge) {
|
|
1673
|
+
const observed = ready.lastError
|
|
1674
|
+
? `last bridge error: ${ready.lastError}`
|
|
1675
|
+
: ready.lastCount === null
|
|
1676
|
+
? 'guest never installed window.__sootsimTest'
|
|
1677
|
+
: `last node count: ${String(ready.lastCount)} (needs > ${ready.minNodeCount})`
|
|
1678
|
+
console.error(
|
|
1679
|
+
` timed out waiting for current sim to load target after ${ready.attempts} polls over ${Math.round((ready.attempts * ready.intervalMs) / 1000)}s; ${observed}`,
|
|
1680
|
+
)
|
|
1681
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
1682
|
+
errorsCommand: `rnx get errors 5${simHint}`,
|
|
1683
|
+
warningsCommand: `rnx get warnings 5${simHint}`,
|
|
1684
|
+
requestsCommand: `rnx get requests 5${simHint}`,
|
|
1685
|
+
})
|
|
1686
|
+
rnxExit(1)
|
|
1687
|
+
}
|
|
1688
|
+
ready.bridge.close()
|
|
1689
|
+
saveCurrentSimId(targetSim.id)
|
|
1690
|
+
printOpenedSim(
|
|
1691
|
+
navigatedShellUrl,
|
|
1692
|
+
{ ...targetSim, url: navigatedShellUrl },
|
|
1693
|
+
'current sim',
|
|
1694
|
+
quiet,
|
|
1695
|
+
)
|
|
1696
|
+
await maybeDescribeAfterOpen(parsed.wsPort, targetSim.id, args)
|
|
1697
|
+
return { simId: targetSim.id, launched: false }
|
|
1698
|
+
}
|
|
1699
|
+
} finally {
|
|
1700
|
+
bridge.close()
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const configuredBaseUrl = applyRNXConfigToUrl(baseUrl, runtimeConfig)
|
|
1705
|
+
const openUrl = await buildOpenUrl(target, configuredBaseUrl, token, buildShellUrlOpts)
|
|
1706
|
+
const ownerPid = getStableOwnerPid()
|
|
1707
|
+
if (!ownerPid) {
|
|
1708
|
+
console.error(
|
|
1709
|
+
' could not resolve a stable owning process; refusing unsupervised sim host',
|
|
1710
|
+
)
|
|
1711
|
+
rnxExit(1)
|
|
1712
|
+
}
|
|
1713
|
+
const driver = getDriver(driverId)
|
|
1714
|
+
if (!driver) {
|
|
1715
|
+
console.error(` unknown driver "${driverId}" — run \`rnx list --drivers\``)
|
|
1716
|
+
rnxExit(1)
|
|
1717
|
+
}
|
|
1718
|
+
const connectWait = resolveDriverConnectWaitOptions()
|
|
1719
|
+
const result = await driver.launch({
|
|
1720
|
+
url: openUrl,
|
|
1721
|
+
// the shell serves the sim page but its WS bridge may have drifted off
|
|
1722
|
+
// the shell-port-implied default. inject the bridge this command settled
|
|
1723
|
+
// on so the page does not reconnect to a stale world. re-deriving it from
|
|
1724
|
+
// the shell URL could name a third port, since a bridge with no lockfile
|
|
1725
|
+
// resolves back to the default shell URL and that shell may belong to a
|
|
1726
|
+
// live world of its own.
|
|
1727
|
+
bridgePort: parsed.wsPort,
|
|
1728
|
+
headless: driverHeadless,
|
|
1729
|
+
profileId,
|
|
1730
|
+
ephemeralProfile,
|
|
1731
|
+
cdpPort,
|
|
1732
|
+
device: deviceArg,
|
|
1733
|
+
ownerPid,
|
|
1734
|
+
viewport:
|
|
1735
|
+
explicitViewport ??
|
|
1736
|
+
(deviceSpec
|
|
1737
|
+
? { width: deviceSpec.width + 120, height: deviceSpec.height + 60 }
|
|
1738
|
+
: undefined),
|
|
1739
|
+
connectTimeoutMs: connectWait.hostTimeoutMs,
|
|
1740
|
+
})
|
|
1741
|
+
if (!result.launched) {
|
|
1742
|
+
console.error(` ${driver.name} driver: ${result.message}`)
|
|
1743
|
+
rnxExit(1)
|
|
1744
|
+
}
|
|
1745
|
+
const outcome = await waitForSimMatchOrHostExit(
|
|
1746
|
+
parsed.wsPort,
|
|
1747
|
+
parsed.commandTimeoutMs,
|
|
1748
|
+
simMatchesToken,
|
|
1749
|
+
result.pid,
|
|
1750
|
+
{ attempts: connectWait.attempts, intervalMs: connectWait.intervalMs },
|
|
1751
|
+
)
|
|
1752
|
+
if (outcome.kind === 'host-exit') {
|
|
1753
|
+
printDriverDiagnosticLogTail(result)
|
|
1754
|
+
console.error(` ${driver.name} host crashed — sim is no longer attached`)
|
|
1755
|
+
rnxExit(1)
|
|
1756
|
+
}
|
|
1757
|
+
if (outcome.kind === 'timeout') {
|
|
1758
|
+
if (result.pid) {
|
|
1759
|
+
try {
|
|
1760
|
+
process.kill(result.pid, 'SIGTERM')
|
|
1761
|
+
console.error(` closed ${driver.name} host process ${result.pid}`)
|
|
1762
|
+
} catch {}
|
|
1763
|
+
}
|
|
1764
|
+
printDriverDiagnosticLogTail(result)
|
|
1765
|
+
console.error(
|
|
1766
|
+
` timed out after ${connectWait.timeoutMs}ms waiting for opened sim to connect`,
|
|
1767
|
+
)
|
|
1768
|
+
rnxExit(1)
|
|
1769
|
+
}
|
|
1770
|
+
const match = outcome.sim
|
|
1771
|
+
saveCurrentSimId(match.id)
|
|
1772
|
+
signalDriverLaunchConnected(result)
|
|
1773
|
+
printOpenedSim(openUrl, match, `${driver.name} driver`, quiet)
|
|
1774
|
+
await maybeDescribeAfterOpen(parsed.wsPort, match.id, args)
|
|
1775
|
+
return { simId: match.id, launched: true }
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
async function runUseLikeCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1779
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1780
|
+
port: opts.port,
|
|
1781
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
1782
|
+
stripBooleanFlags: ['--force'],
|
|
1783
|
+
})
|
|
1784
|
+
const force = args.includes('--force')
|
|
1785
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
1786
|
+
const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
|
|
1787
|
+
|
|
1788
|
+
try {
|
|
1789
|
+
try {
|
|
1790
|
+
const sims = await bridge.listSims()
|
|
1791
|
+
const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
|
|
1792
|
+
// `use`/`focus` retarget the CLI at a sim — silently grabbing one a
|
|
1793
|
+
// human is actively driving is the same footgun `claim` guards against,
|
|
1794
|
+
// so honor the lock here too (this command historically did not, which
|
|
1795
|
+
// let `rnx use <id>` steal the user's live tab).
|
|
1796
|
+
if (target.lockedBy && target.lockExpiresAt && target.lockExpiresAt > Date.now()) {
|
|
1797
|
+
const secs = Math.max(0, Math.round((target.lockExpiresAt - Date.now()) / 1000))
|
|
1798
|
+
if (target.lockedByKind === 'user-active') {
|
|
1799
|
+
// the active human is interacting with this tab. never steal it,
|
|
1800
|
+
// even with --force — mirrors `claim`, which refuses to even
|
|
1801
|
+
// report a user-active takeover. open a fresh sim instead.
|
|
1802
|
+
console.error(
|
|
1803
|
+
` refused: ${target.id} is locked by the active user (${secs}s) — that's a live human tab`,
|
|
1804
|
+
)
|
|
1805
|
+
console.error(` run \`rnx open --new\` for a fresh investigation sim`)
|
|
1806
|
+
rnxExit(1)
|
|
1807
|
+
}
|
|
1808
|
+
if (!force) {
|
|
1809
|
+
// another CLI agent leases it. don't hijack its target silently;
|
|
1810
|
+
// a cli lease is short (60s, refreshes per command) so this is
|
|
1811
|
+
// usually a real conflict, not a stale lock.
|
|
1812
|
+
console.error(
|
|
1813
|
+
` refused: ${target.id} is leased by ${target.lockedBy} (${secs}s)`,
|
|
1814
|
+
)
|
|
1815
|
+
console.error(
|
|
1816
|
+
` \`rnx use ${target.id} --force\` to take it, or \`rnx open --new\` for a fresh sim`,
|
|
1817
|
+
)
|
|
1818
|
+
rnxExit(1)
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
await bridge.focusSim(target.id)
|
|
1822
|
+
saveCurrentSimId(target.id)
|
|
1823
|
+
console.log(` using: ${target.id}`)
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
rethrowIfExit(err)
|
|
1826
|
+
console.error(` use failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1827
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
1828
|
+
errorsCommand: `rnx get errors 5${simHint}`,
|
|
1829
|
+
warningsCommand: `rnx get warnings 5${simHint}`,
|
|
1830
|
+
requestsCommand: `rnx get requests 5${simHint}`,
|
|
1831
|
+
})
|
|
1832
|
+
rnxExit(1)
|
|
1833
|
+
}
|
|
1834
|
+
} finally {
|
|
1835
|
+
bridge.close()
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
export async function runUseCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1840
|
+
await runUseLikeCommand(args, opts)
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
export async function runFocusCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1844
|
+
await runUseLikeCommand(args, opts)
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
export async function runClaimCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
1848
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1849
|
+
port: opts.port,
|
|
1850
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
1851
|
+
stripBooleanFlags: ['--force'],
|
|
1852
|
+
})
|
|
1853
|
+
const force = args.includes('--force')
|
|
1854
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
1855
|
+
|
|
1856
|
+
try {
|
|
1857
|
+
try {
|
|
1858
|
+
const sims = await bridge.listSims()
|
|
1859
|
+
const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
|
|
1860
|
+
const priorHolder =
|
|
1861
|
+
force && target.lockedBy && target.lockedByKind !== 'user-active'
|
|
1862
|
+
? target.lockedBy
|
|
1863
|
+
: null
|
|
1864
|
+
const result = await bridge.claim(target.id, { force })
|
|
1865
|
+
saveCurrentSimId(target.id)
|
|
1866
|
+
const ttl = Math.max(0, Math.round((result.lockExpiresAt - Date.now()) / 1000))
|
|
1867
|
+
const booted = result.bootedCount > 0 ? ` (booted ${result.bootedCount})` : ''
|
|
1868
|
+
console.log(` claimed: ${result.simId} [${ttl}s]${booted}`)
|
|
1869
|
+
if (priorHolder) {
|
|
1870
|
+
console.log(` took over from: ${priorHolder}`)
|
|
1871
|
+
}
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
if (err instanceof BridgeSimLockedError) {
|
|
1874
|
+
const secs = Math.max(0, Math.round(err.lock.expiresInMs / 1000))
|
|
1875
|
+
console.error(` claim failed: locked by ${err.lock.by} for ${secs}s more`)
|
|
1876
|
+
console.error(` use --force to take it, or \`rnx open --new\` for a fresh sim`)
|
|
1877
|
+
rnxExit(1)
|
|
1878
|
+
}
|
|
1879
|
+
console.error(` claim failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1880
|
+
rnxExit(1)
|
|
1881
|
+
}
|
|
1882
|
+
} finally {
|
|
1883
|
+
bridge.close()
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// close many sims at once. fires every close, then polls listSims until the
|
|
1888
|
+
// whole set is gone or the timeout elapses — far faster than awaiting
|
|
1889
|
+
// waitForSimGone serially when clearing dozens of leaked sims.
|
|
1890
|
+
export async function closeSimsBulk(
|
|
1891
|
+
bridge: ReturnType<typeof createBridgeFromParsed>,
|
|
1892
|
+
wsPort: number,
|
|
1893
|
+
commandTimeoutMs: number,
|
|
1894
|
+
ids: string[],
|
|
1895
|
+
): Promise<{ closed: string[]; remaining: string[] }> {
|
|
1896
|
+
if (ids.length === 0) return { closed: [], remaining: [] }
|
|
1897
|
+
await Promise.all(ids.map((id) => bridge.closeSim(id).catch(() => {})))
|
|
1898
|
+
const wanted = new Set(ids)
|
|
1899
|
+
for (let i = 0; i < 40; i++) {
|
|
1900
|
+
let stillOpen: string[] = []
|
|
1901
|
+
try {
|
|
1902
|
+
const probe = createBridge(wsPort, { commandTimeoutMs })
|
|
1903
|
+
try {
|
|
1904
|
+
const sims = await probe.listSims()
|
|
1905
|
+
stillOpen = sims
|
|
1906
|
+
.filter((s) => wanted.has(s.id) && s.readyState === 'open')
|
|
1907
|
+
.map((s) => s.id)
|
|
1908
|
+
} finally {
|
|
1909
|
+
probe.close()
|
|
1910
|
+
}
|
|
1911
|
+
} catch {
|
|
1912
|
+
stillOpen = []
|
|
1913
|
+
}
|
|
1914
|
+
if (stillOpen.length === 0) {
|
|
1915
|
+
return { closed: [...wanted], remaining: [] }
|
|
1916
|
+
}
|
|
1917
|
+
if (i === 39) {
|
|
1918
|
+
return {
|
|
1919
|
+
closed: [...wanted].filter((id) => !stillOpen.includes(id)),
|
|
1920
|
+
remaining: stillOpen,
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
await sleep(250)
|
|
1924
|
+
}
|
|
1925
|
+
return { closed: [...wanted], remaining: [] }
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
export interface BulkCloseRefusal {
|
|
1929
|
+
id: string
|
|
1930
|
+
lockedBy: string
|
|
1931
|
+
kind: 'cli' | 'user-active'
|
|
1932
|
+
expiresInMs: number
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
export interface BulkClosePlan {
|
|
1936
|
+
openIds: string[]
|
|
1937
|
+
keepId: string | null
|
|
1938
|
+
targets: string[]
|
|
1939
|
+
staleSavedId: string | null
|
|
1940
|
+
missingExplicitKeepId: string | null
|
|
1941
|
+
fallbackKeepId: string | null
|
|
1942
|
+
refused: BulkCloseRefusal[]
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
export function planBulkCloseTargets(
|
|
1946
|
+
sims: BridgeSimInfo[],
|
|
1947
|
+
opts: {
|
|
1948
|
+
closeOthers: boolean
|
|
1949
|
+
explicitKeepId?: string
|
|
1950
|
+
savedKeepId?: string | null
|
|
1951
|
+
force?: boolean
|
|
1952
|
+
now?: number
|
|
1953
|
+
},
|
|
1954
|
+
): BulkClosePlan {
|
|
1955
|
+
const open = sims.filter((sim) => sim.readyState === 'open')
|
|
1956
|
+
const openIds = open.map((sim) => sim.id)
|
|
1957
|
+
const explicitKeepId = opts.explicitKeepId?.trim() || ''
|
|
1958
|
+
const savedKeepId = opts.savedKeepId?.trim() || ''
|
|
1959
|
+
const savedIsOpen = savedKeepId ? open.some((sim) => sim.id === savedKeepId) : false
|
|
1960
|
+
const staleSavedId = savedKeepId && !savedIsOpen ? savedKeepId : null
|
|
1961
|
+
let keepId: string | null = null
|
|
1962
|
+
let missingExplicitKeepId: string | null = null
|
|
1963
|
+
let fallbackKeepId: string | null = null
|
|
1964
|
+
|
|
1965
|
+
if (opts.closeOthers) {
|
|
1966
|
+
if (explicitKeepId) {
|
|
1967
|
+
const match = open.find((sim) => sim.id === explicitKeepId)
|
|
1968
|
+
if (match) {
|
|
1969
|
+
keepId = match.id
|
|
1970
|
+
} else {
|
|
1971
|
+
missingExplicitKeepId = explicitKeepId
|
|
1972
|
+
}
|
|
1973
|
+
} else if (savedKeepId && savedIsOpen) {
|
|
1974
|
+
keepId = savedKeepId
|
|
1975
|
+
} else {
|
|
1976
|
+
const fallback = open.find((sim) => sim.isPrimary) ?? open[0] ?? null
|
|
1977
|
+
if (fallback) {
|
|
1978
|
+
keepId = fallback.id
|
|
1979
|
+
if (savedKeepId && !savedIsOpen) fallbackKeepId = fallback.id
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// a sim another agent holds is not ours to close. `use`/`focus`/`claim` all
|
|
1985
|
+
// refuse a live lease; bulk close historically did not look at leases at
|
|
1986
|
+
// all, so `close --all` silently killed other agents' sims mid-capture.
|
|
1987
|
+
const now = opts.now ?? Date.now()
|
|
1988
|
+
const refused: BulkCloseRefusal[] = []
|
|
1989
|
+
for (const sim of open) {
|
|
1990
|
+
if (sim.id === keepId) continue
|
|
1991
|
+
const lockedBy = sim.lockedBy
|
|
1992
|
+
const expiresAt = sim.lockExpiresAt
|
|
1993
|
+
if (!lockedBy || !expiresAt || expiresAt <= now || sim.lockedByMe) continue
|
|
1994
|
+
const kind = sim.lockedByKind ?? 'cli'
|
|
1995
|
+
// --force takes over another agent's cli lease, the same escape hatch
|
|
1996
|
+
// `use --force` offers. a `user-active` lease is a live human tab and is
|
|
1997
|
+
// never closable, matching `claim`, which refuses to report a takeover.
|
|
1998
|
+
if (kind === 'cli' && opts.force) continue
|
|
1999
|
+
refused.push({
|
|
2000
|
+
id: sim.id,
|
|
2001
|
+
lockedBy,
|
|
2002
|
+
kind,
|
|
2003
|
+
expiresInMs: Math.max(0, expiresAt - now),
|
|
2004
|
+
})
|
|
2005
|
+
}
|
|
2006
|
+
const refusedIds = new Set(refused.map((entry) => entry.id))
|
|
2007
|
+
|
|
2008
|
+
return {
|
|
2009
|
+
openIds,
|
|
2010
|
+
keepId,
|
|
2011
|
+
targets: missingExplicitKeepId
|
|
2012
|
+
? []
|
|
2013
|
+
: openIds.filter((id) => id !== keepId && !refusedIds.has(id)),
|
|
2014
|
+
staleSavedId,
|
|
2015
|
+
missingExplicitKeepId,
|
|
2016
|
+
fallbackKeepId,
|
|
2017
|
+
refused,
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
export async function runCloseCommand(args: string[], opts: ControlCommandOptions = {}) {
|
|
2022
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
2023
|
+
port: opts.port,
|
|
2024
|
+
commandTimeoutMs: opts.timeoutMs,
|
|
2025
|
+
stripBooleanFlags: ['--all', '--others', '--force'],
|
|
2026
|
+
})
|
|
2027
|
+
const bridge = createBridgeFromParsed(parsed)
|
|
2028
|
+
const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
|
|
2029
|
+
const closeAll = args.includes('--all')
|
|
2030
|
+
const closeOthers = args.includes('--others')
|
|
2031
|
+
const force = args.includes('--force')
|
|
2032
|
+
|
|
2033
|
+
if (closeAll || closeOthers) {
|
|
2034
|
+
try {
|
|
2035
|
+
const sims = await bridge.listSims()
|
|
2036
|
+
const savedId = readCurrentSimId()
|
|
2037
|
+
const explicitKeepId =
|
|
2038
|
+
parsed.positional[0] || (parsed.simIdSource === 'flag' ? parsed.simId : undefined)
|
|
2039
|
+
// `--others` keeps the explicitly-targeted sim, else the saved/current
|
|
2040
|
+
// sim, else primary. if an explicit keep target is gone, abort instead
|
|
2041
|
+
// of treating "keep nothing" as permission to close every open sim.
|
|
2042
|
+
const plan = planBulkCloseTargets(sims, {
|
|
2043
|
+
closeOthers,
|
|
2044
|
+
explicitKeepId,
|
|
2045
|
+
savedKeepId: savedId,
|
|
2046
|
+
force,
|
|
2047
|
+
})
|
|
2048
|
+
for (const entry of plan.refused) {
|
|
2049
|
+
const secs = Math.round(entry.expiresInMs / 1000)
|
|
2050
|
+
console.error(
|
|
2051
|
+
entry.kind === 'user-active'
|
|
2052
|
+
? ` skipped: ${entry.id} is locked by the active user (${secs}s) — that's a live human tab`
|
|
2053
|
+
: ` skipped: ${entry.id} is leased by ${formatLockOwner(entry.lockedBy)} (${secs}s)`,
|
|
2054
|
+
)
|
|
2055
|
+
}
|
|
2056
|
+
if (plan.refused.some((entry) => entry.kind === 'cli') && !force) {
|
|
2057
|
+
console.error(` \`rnx close --all --force\` to close leased sims too`)
|
|
2058
|
+
}
|
|
2059
|
+
if (plan.staleSavedId) {
|
|
2060
|
+
clearCurrentSimId()
|
|
2061
|
+
}
|
|
2062
|
+
if (plan.fallbackKeepId) {
|
|
2063
|
+
saveCurrentSimId(plan.fallbackKeepId)
|
|
2064
|
+
console.log(
|
|
2065
|
+
` saved sim ${plan.staleSavedId} is gone — keeping primary ${plan.fallbackKeepId}`,
|
|
2066
|
+
)
|
|
2067
|
+
}
|
|
2068
|
+
if (plan.missingExplicitKeepId) {
|
|
2069
|
+
console.error(
|
|
2070
|
+
` close failed: keep sim ${plan.missingExplicitKeepId} is not connected; not closing other sims`,
|
|
2071
|
+
)
|
|
2072
|
+
rnxExit(1)
|
|
2073
|
+
}
|
|
2074
|
+
if (plan.targets.length === 0) {
|
|
2075
|
+
console.log(
|
|
2076
|
+
plan.keepId
|
|
2077
|
+
? ` nothing to close — only the kept sim ${plan.keepId} is connected`
|
|
2078
|
+
: plan.refused.length > 0
|
|
2079
|
+
? ` nothing to close — every other connected sim is leased by someone else`
|
|
2080
|
+
: plan.staleSavedId
|
|
2081
|
+
? ` nothing to close — no sims connected (cleared stale current sim ${plan.staleSavedId})`
|
|
2082
|
+
: ' nothing to close — no sims connected',
|
|
2083
|
+
)
|
|
2084
|
+
return
|
|
2085
|
+
}
|
|
2086
|
+
const result = await closeSimsBulk(
|
|
2087
|
+
bridge,
|
|
2088
|
+
parsed.wsPort,
|
|
2089
|
+
parsed.commandTimeoutMs,
|
|
2090
|
+
plan.targets,
|
|
2091
|
+
)
|
|
2092
|
+
await terminatePlaywrightHostsForSims(sims, plan.targets)
|
|
2093
|
+
// saved current sim may have just been closed — repoint or clear it.
|
|
2094
|
+
const nextSavedId = readCurrentSimId()
|
|
2095
|
+
if (nextSavedId && result.closed.includes(nextSavedId)) {
|
|
2096
|
+
if (plan.keepId) saveCurrentSimId(plan.keepId)
|
|
2097
|
+
else clearCurrentSimId()
|
|
2098
|
+
}
|
|
2099
|
+
const summary = ` closed ${result.closed.length} sim(s)${
|
|
2100
|
+
plan.keepId ? ` (kept ${plan.keepId})` : ''
|
|
2101
|
+
}`
|
|
2102
|
+
console.log(summary)
|
|
2103
|
+
if (result.remaining.length > 0) {
|
|
2104
|
+
console.error(` close failed: still connected: ${result.remaining.join(', ')}`)
|
|
2105
|
+
rnxExit(1)
|
|
2106
|
+
}
|
|
2107
|
+
} catch (err) {
|
|
2108
|
+
rethrowIfExit(err)
|
|
2109
|
+
console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
2110
|
+
rnxExit(1)
|
|
2111
|
+
} finally {
|
|
2112
|
+
bridge.close()
|
|
2113
|
+
}
|
|
2114
|
+
return
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
try {
|
|
2118
|
+
try {
|
|
2119
|
+
const sims = await bridge.listSims()
|
|
2120
|
+
const target = resolveTargetSim(sims, parsed.positional[0] || parsed.simId)
|
|
2121
|
+
const fallbackTarget = sims.find(
|
|
2122
|
+
(sim) => sim.id !== target.id && sim.readyState === 'open',
|
|
2123
|
+
)
|
|
2124
|
+
await bridge.closeSim(target.id)
|
|
2125
|
+
const closed = await waitForSimGone(
|
|
2126
|
+
parsed.wsPort,
|
|
2127
|
+
parsed.commandTimeoutMs,
|
|
2128
|
+
target.id,
|
|
2129
|
+
)
|
|
2130
|
+
if (!closed) {
|
|
2131
|
+
await terminatePlaywrightHostsForSims(sims, [target.id])
|
|
2132
|
+
console.error(` close failed: ${target.id} is still connected`)
|
|
2133
|
+
rnxExit(1)
|
|
2134
|
+
}
|
|
2135
|
+
await terminatePlaywrightHostsForSims(sims, [target.id])
|
|
2136
|
+
if (readCurrentSimId() === target.id) {
|
|
2137
|
+
if (fallbackTarget) saveCurrentSimId(fallbackTarget.id)
|
|
2138
|
+
else clearCurrentSimId()
|
|
2139
|
+
}
|
|
2140
|
+
console.log(` closed: ${target.id}`)
|
|
2141
|
+
} catch (err) {
|
|
2142
|
+
rethrowIfExit(err)
|
|
2143
|
+
console.error(` close failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
2144
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
2145
|
+
errorsCommand: `rnx get errors 5${simHint}`,
|
|
2146
|
+
warningsCommand: `rnx get warnings 5${simHint}`,
|
|
2147
|
+
requestsCommand: `rnx get requests 5${simHint}`,
|
|
2148
|
+
})
|
|
2149
|
+
rnxExit(1)
|
|
2150
|
+
}
|
|
2151
|
+
} finally {
|
|
2152
|
+
bridge.close()
|
|
2153
|
+
}
|
|
2154
|
+
}
|