rnxsim 0.1.442 → 0.1.444
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/cli/bin.ts +3 -1
- package/cli/cloud-client.ts +153 -17
- package/cli/cloud-dispatch.ts +15 -0
- package/cli/cloud-session.ts +69 -17
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box.ts +274 -115
- package/cli/commands/control.ts +1 -137
- package/cli/commands/flow.ts +1 -1
- package/cli/commands/inspect/list.ts +119 -2
- package/cli/commands/inspect.ts +3 -2
- package/cli/commands/platform.ts +95 -12
- package/cli/commands/reset.ts +7 -3
- package/cli/commands/state.ts +7 -3
- package/cli/flow-export.ts +55 -0
- package/cli/flow-session.ts +0 -47
- package/cli/outbound-endpoints.ts +7 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/ws-bridge.ts +30 -0
- package/detox/jest-setup-after-env.cjs +4 -0
- package/detox/record-block-execution.cjs +78 -0
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +69 -14
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +17 -1
- package/dist-lib/host/bridge-host.cjs +43 -32
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +1 -3
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +10 -19
- package/dist-lib/menu.mjs +10 -19
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +586 -269
- package/dist-lib/vite.cjs +1 -1
- package/package.json +8 -1
- package/src/home-paths.ts +31 -1
- package/src/menu.ts +8 -17
package/cli/bin.ts
CHANGED
|
@@ -31,7 +31,9 @@ if (process.argv[2] === '__cloud-confirm') {
|
|
|
31
31
|
])
|
|
32
32
|
const session = readCloudSession()
|
|
33
33
|
if (!session) throw new Error('RNX cloud confirmation has no shell session')
|
|
34
|
-
|
|
34
|
+
// a box has no claim to keep alive: its simulator is held by the Box page
|
|
35
|
+
// for as long as the box runs, so a shell on a box confirms nothing.
|
|
36
|
+
if (session.service === 'sim') await confirmCloudSimulator(session)
|
|
35
37
|
process.exit(0)
|
|
36
38
|
} catch (error) {
|
|
37
39
|
process.stderr.write(
|
package/cli/cloud-client.ts
CHANGED
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
createCloudSession,
|
|
42
42
|
normalizeCloudOrigin,
|
|
43
43
|
readCloudSession,
|
|
44
|
+
type CloudBoxSession,
|
|
44
45
|
type CloudSession,
|
|
46
|
+
type CloudSimSession,
|
|
45
47
|
} from './cloud-session'
|
|
46
48
|
import { detectProject, findProjectRoot } from './commands/detect'
|
|
47
49
|
import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract'
|
|
@@ -58,10 +60,6 @@ export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
|
|
|
58
60
|
// every message this module writes names the command through this constant.
|
|
59
61
|
const REMOTE_COMMAND = 'rnx ios --remote'
|
|
60
62
|
|
|
61
|
-
// an artifact this close to the ceiling is one asset away from being refused,
|
|
62
|
-
// and the producer knows which assets it would be.
|
|
63
|
-
const WARN_ARTIFACT_BYTES = Math.floor(MAX_RNX_CLOUD_BUNDLE_BYTES * 0.8)
|
|
64
|
-
|
|
65
63
|
// a Metro asset dest is a shallow tree of images. a walk that runs past this
|
|
66
64
|
// is pointed at something else entirely, and its extra entries cannot resolve
|
|
67
65
|
// an asset anyway.
|
|
@@ -372,12 +370,19 @@ function distinctByContent(directory: string, options: readonly string[]): strin
|
|
|
372
370
|
* reproduce, however complete that dest is. those come back neither verified
|
|
373
371
|
* nor mismatched, and the caller refuses when a whole bundle is like that
|
|
374
372
|
* rather than picking a directory it cannot check.
|
|
373
|
+
*
|
|
374
|
+
* a source tree holding both `icon.png` and `icon@1x.png` declares scale 1
|
|
375
|
+
* twice, and Metro hashes both files while a dest keeps one file per scale. the
|
|
376
|
+
* expo asset plugin records one md5 per declared file in `fileHashes`, and
|
|
377
|
+
* that is the identity check for such a descriptor: the file chosen for a
|
|
378
|
+
* scale must be one of the files Metro hashed at that scale.
|
|
375
379
|
*/
|
|
376
380
|
function placeMetroDescriptor(
|
|
377
381
|
listing: MetroAssetDestListing,
|
|
378
382
|
descriptor: MetroAssetDescriptor,
|
|
379
383
|
): DescriptorPlacement {
|
|
380
|
-
const
|
|
384
|
+
const scales = [...new Set(descriptor.scales)]
|
|
385
|
+
const perScale = scales.map((scale) =>
|
|
381
386
|
distinctByContent(
|
|
382
387
|
listing.directory,
|
|
383
388
|
resolveMetroIosAssetDestFiles(listing.files, descriptor, scale),
|
|
@@ -387,7 +392,7 @@ function placeMetroDescriptor(
|
|
|
387
392
|
// below is. one that resolved to several is left for the hash to settle.
|
|
388
393
|
const files = new Map<number, string>()
|
|
389
394
|
const ambiguous: string[] = []
|
|
390
|
-
|
|
395
|
+
scales.forEach((scale, index) => {
|
|
391
396
|
const options = perScale[index] ?? []
|
|
392
397
|
if (options.length === 1 && options[0]) files.set(scale, options[0])
|
|
393
398
|
else if (options.length > 1) ambiguous.push(...options)
|
|
@@ -395,6 +400,12 @@ function placeMetroDescriptor(
|
|
|
395
400
|
if (perScale.length === 0 || perScale.some((options) => options.length === 0)) {
|
|
396
401
|
return { files, ambiguous, verified: false, mismatched: false }
|
|
397
402
|
}
|
|
403
|
+
// a descriptor that is already embedded carries the embedder's sha256s in
|
|
404
|
+
// `fileHashes`; only Metro's own md5 per source file is an identity here.
|
|
405
|
+
const fileHashes =
|
|
406
|
+
!descriptor.fileUris && descriptor.fileHashes?.length === descriptor.scales.length
|
|
407
|
+
? descriptor.fileHashes
|
|
408
|
+
: null
|
|
398
409
|
for (const combination of placementCombinations(perScale)) {
|
|
399
410
|
let bytes: Buffer[]
|
|
400
411
|
try {
|
|
@@ -404,14 +415,29 @@ function placeMetroDescriptor(
|
|
|
404
415
|
} catch {
|
|
405
416
|
continue
|
|
406
417
|
}
|
|
407
|
-
|
|
418
|
+
const identical = fileHashes
|
|
419
|
+
? bytes.every((file, index) => {
|
|
420
|
+
const digest = createHash('md5').update(file).digest('hex')
|
|
421
|
+
return descriptor.scales.some(
|
|
422
|
+
(scale, declared) =>
|
|
423
|
+
scale === scales[index] && fileHashes[declared] === digest,
|
|
424
|
+
)
|
|
425
|
+
})
|
|
426
|
+
: scales.length === descriptor.scales.length &&
|
|
427
|
+
metroDescriptorHash(bytes) === descriptor.hash
|
|
428
|
+
if (!identical) continue
|
|
408
429
|
const chosen = new Map<number, string>()
|
|
409
|
-
|
|
430
|
+
scales.forEach((scale, index) => {
|
|
410
431
|
const relative = combination[index]
|
|
411
432
|
if (relative) chosen.set(scale, relative)
|
|
412
433
|
})
|
|
413
434
|
return { files: chosen, ambiguous: [], verified: true, mismatched: false }
|
|
414
435
|
}
|
|
436
|
+
// a duplicated scale with no per-file md5 leaves Metro's digest covering a
|
|
437
|
+
// file this dest does not hold, so nothing here can be called wrong.
|
|
438
|
+
if (!fileHashes && scales.length !== descriptor.scales.length) {
|
|
439
|
+
return { files, ambiguous, verified: false, mismatched: false }
|
|
440
|
+
}
|
|
415
441
|
// every declared scale is here and no arrangement of them is this asset, so
|
|
416
442
|
// the directory holds different images under these names.
|
|
417
443
|
return { files, ambiguous, verified: false, mismatched: true }
|
|
@@ -625,6 +651,11 @@ export interface CloudArtifactProduction {
|
|
|
625
651
|
export async function produceCloudArtifact(
|
|
626
652
|
bundlePath: string,
|
|
627
653
|
assetsPath?: string,
|
|
654
|
+
/**
|
|
655
|
+
* the largest artifact the consumer accepts. the nano simulator's limit is
|
|
656
|
+
* the default; a box that accepts a larger artifact passes its own.
|
|
657
|
+
*/
|
|
658
|
+
maxBytes: number = MAX_RNX_CLOUD_BUNDLE_BYTES,
|
|
628
659
|
): Promise<CloudArtifactProduction> {
|
|
629
660
|
const source = await loadBundleSource(bundlePath)
|
|
630
661
|
const descriptors = extractAssetDescriptors(source)
|
|
@@ -703,9 +734,9 @@ export async function produceCloudArtifact(
|
|
|
703
734
|
})
|
|
704
735
|
const bytes = Buffer.from(artifact.code, 'utf8')
|
|
705
736
|
const assets = artifact.receipt.assets
|
|
706
|
-
if (bytes.length >
|
|
737
|
+
if (bytes.length > maxBytes) {
|
|
707
738
|
throw new Error(
|
|
708
|
-
`${REMOTE_COMMAND} artifact is ${bytes.length} bytes and exceeds the ${
|
|
739
|
+
`${REMOTE_COMMAND} artifact is ${bytes.length} bytes and exceeds the ${maxBytes}-byte limit. Embedded assets weigh ${assets.bytes} bytes on disk, and base64 embedding costs about a third on top of that, so the practical asset budget is roughly ${Math.round((maxBytes * 0.75) / 1e6)} MB.${largestEmbeddedAssets(assets.sizes, 10)}`,
|
|
709
740
|
)
|
|
710
741
|
}
|
|
711
742
|
const warnings: string[] = []
|
|
@@ -714,9 +745,11 @@ export async function produceCloudArtifact(
|
|
|
714
745
|
`${REMOTE_COMMAND} embedded a placeholder image for ${substituted.length} asset(s) no scale of which is in ${search.searched.join(', ') || 'any asset dest directory'}: ${substituted.join(', ')}. Build the bundle with --assets-dest, or pass --assets <dir>, to ship the real images.`,
|
|
715
746
|
)
|
|
716
747
|
}
|
|
717
|
-
|
|
748
|
+
// an artifact this close to the ceiling is one asset away from being refused,
|
|
749
|
+
// and the producer knows which assets it would be.
|
|
750
|
+
if (bytes.length >= Math.floor(maxBytes * 0.8)) {
|
|
718
751
|
warnings.push(
|
|
719
|
-
`${REMOTE_COMMAND} artifact is ${bytes.length} bytes, ${Math.round((bytes.length /
|
|
752
|
+
`${REMOTE_COMMAND} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / maxBytes) * 100)}% of the ${maxBytes}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`,
|
|
720
753
|
)
|
|
721
754
|
}
|
|
722
755
|
return {
|
|
@@ -867,6 +900,18 @@ export function resolveCloudSessionForParsed(
|
|
|
867
900
|
): CloudSession | null {
|
|
868
901
|
const session = readCloudSession()
|
|
869
902
|
if (!session) return null
|
|
903
|
+
// a box holds one simulator and names it by the box, so --sim names a box
|
|
904
|
+
// rather than a sim there and this shell already knows which box it is on.
|
|
905
|
+
if (session.service === 'box') {
|
|
906
|
+
if (parsed.simIdSource === 'flag' && parsed.simId !== session.boxId) {
|
|
907
|
+
throw new Error(
|
|
908
|
+
`this shell is connected to box ${session.boxId}; ${
|
|
909
|
+
parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
|
|
910
|
+
} is not available in this shell`,
|
|
911
|
+
)
|
|
912
|
+
}
|
|
913
|
+
return session
|
|
914
|
+
}
|
|
870
915
|
if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
|
|
871
916
|
throw new Error(
|
|
872
917
|
`this shell is connected to remote simulator ${session.simId}; ${
|
|
@@ -894,7 +939,7 @@ function cloudCommand(command: {
|
|
|
894
939
|
class CloudBridge implements WsBridge {
|
|
895
940
|
readonly plane = 'cloud' as const
|
|
896
941
|
|
|
897
|
-
constructor(private readonly session:
|
|
942
|
+
constructor(private readonly session: CloudSimSession) {}
|
|
898
943
|
|
|
899
944
|
async send(
|
|
900
945
|
command: { type: string; [key: string]: unknown },
|
|
@@ -971,9 +1016,100 @@ class CloudBridge implements WsBridge {
|
|
|
971
1016
|
close(): void {}
|
|
972
1017
|
}
|
|
973
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* a micro box's bridge.
|
|
1021
|
+
*
|
|
1022
|
+
* the box holds no simulator of its own: the one it shows lives in its Box
|
|
1023
|
+
* page, which runs the same shell the local CLI drives over the daemon bridge.
|
|
1024
|
+
* so this transport moves the whole bridge vocabulary rather than the nano
|
|
1025
|
+
* subset (every verb that works against a local sim works against a box), and
|
|
1026
|
+
* the box service is only the relay in the middle. that is also why there is
|
|
1027
|
+
* no claim: the box token addresses the box, and the page answers for it.
|
|
1028
|
+
*/
|
|
1029
|
+
class CloudBoxBridge implements WsBridge {
|
|
1030
|
+
readonly plane = 'cloud' as const
|
|
1031
|
+
|
|
1032
|
+
constructor(private readonly session: CloudBoxSession) {}
|
|
1033
|
+
|
|
1034
|
+
async send(
|
|
1035
|
+
command: { type: string; [key: string]: unknown },
|
|
1036
|
+
options?: { timeoutMs?: number },
|
|
1037
|
+
): Promise<unknown> {
|
|
1038
|
+
const id = randomUUID()
|
|
1039
|
+
// the wire id is this request's, so the command carries none. `simId` names
|
|
1040
|
+
// a local sim among several and a box has exactly one.
|
|
1041
|
+
const payload: Record<string, unknown> = {}
|
|
1042
|
+
for (const [key, value] of Object.entries(command)) {
|
|
1043
|
+
if (key !== 'id' && key !== 'simId') payload[key] = value
|
|
1044
|
+
}
|
|
1045
|
+
let response: Response
|
|
1046
|
+
try {
|
|
1047
|
+
response = await fetch(
|
|
1048
|
+
`${this.session.apiOrigin}/v1/boxes/${encodeURIComponent(this.session.boxId)}/commands`,
|
|
1049
|
+
{
|
|
1050
|
+
method: 'POST',
|
|
1051
|
+
headers: {
|
|
1052
|
+
authorization: `Bearer ${this.session.boxToken}`,
|
|
1053
|
+
'content-type': 'application/json',
|
|
1054
|
+
},
|
|
1055
|
+
body: JSON.stringify({ id, command: payload }),
|
|
1056
|
+
...(options?.timeoutMs
|
|
1057
|
+
? { signal: AbortSignal.timeout(options.timeoutMs) }
|
|
1058
|
+
: {}),
|
|
1059
|
+
},
|
|
1060
|
+
)
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
throw new Error(
|
|
1063
|
+
`could not reach box ${this.session.boxId} at ${this.session.apiOrigin}: ${
|
|
1064
|
+
error instanceof Error ? error.message : String(error)
|
|
1065
|
+
}`,
|
|
1066
|
+
)
|
|
1067
|
+
}
|
|
1068
|
+
const text = await response.text()
|
|
1069
|
+
if (!response.ok) throw responseError(response.status, text)
|
|
1070
|
+
let parsed: unknown
|
|
1071
|
+
try {
|
|
1072
|
+
parsed = JSON.parse(text)
|
|
1073
|
+
} catch {
|
|
1074
|
+
throw new Error('box command returned unreadable JSON')
|
|
1075
|
+
}
|
|
1076
|
+
if (!isRecord(parsed) || parsed.id !== id) {
|
|
1077
|
+
throw new Error('box command returned an invalid command receipt')
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
|
|
1080
|
+
if (!Object.hasOwn(parsed, 'result')) {
|
|
1081
|
+
throw new Error('box command receipt has no result')
|
|
1082
|
+
}
|
|
1083
|
+
return parsed.result
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
listSims(): Promise<BridgeSimInfo[]> {
|
|
1087
|
+
return Promise.reject(new Error('a box does not expose a local simulator list'))
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
focusSim(): Promise<never> {
|
|
1091
|
+
return Promise.reject(new Error('a box simulator has no browser window to focus'))
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
closeSim(): Promise<never> {
|
|
1095
|
+
return Promise.reject(new Error('close a box simulator with rnx box stop'))
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
claim(): Promise<BridgeClaimResult> {
|
|
1099
|
+
return Promise.reject(
|
|
1100
|
+
new Error('a box simulator is held by its Box page and has nothing to claim'),
|
|
1101
|
+
)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
close(): void {}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
974
1107
|
export function createCloudBridgeForParsed(parsed: ParsedBridgeCliArgs): WsBridge | null {
|
|
975
1108
|
const session = resolveCloudSessionForParsed(parsed)
|
|
976
|
-
|
|
1109
|
+
if (!session) return null
|
|
1110
|
+
return session.service === 'box'
|
|
1111
|
+
? new CloudBoxBridge(session)
|
|
1112
|
+
: new CloudBridge(session)
|
|
977
1113
|
}
|
|
978
1114
|
|
|
979
1115
|
function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
|
|
@@ -990,11 +1126,11 @@ function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
|
|
|
990
1126
|
}
|
|
991
1127
|
|
|
992
1128
|
export async function claimCloudSimulator(
|
|
993
|
-
session:
|
|
1129
|
+
session: CloudSimSession,
|
|
994
1130
|
options: { force?: boolean } = {},
|
|
995
1131
|
): Promise<{
|
|
996
1132
|
claim: { id: string; expiresAt: number }
|
|
997
|
-
session:
|
|
1133
|
+
session: CloudSimSession
|
|
998
1134
|
}> {
|
|
999
1135
|
const response = await fetch(
|
|
1000
1136
|
`${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}/claim`,
|
|
@@ -1024,7 +1160,7 @@ export async function claimCloudSimulator(
|
|
|
1024
1160
|
}
|
|
1025
1161
|
}
|
|
1026
1162
|
|
|
1027
|
-
export async function confirmCloudSimulator(session:
|
|
1163
|
+
export async function confirmCloudSimulator(session: CloudSimSession): Promise<void> {
|
|
1028
1164
|
const confirmed = await claimCloudSimulator(session)
|
|
1029
1165
|
if (confirmed.session.claimId !== session.claimId) {
|
|
1030
1166
|
throw new Error('remote simulator confirmation replaced the initial claim')
|
package/cli/cloud-dispatch.ts
CHANGED
|
@@ -158,6 +158,21 @@ export async function dispatchCloudBoundary({
|
|
|
158
158
|
}
|
|
159
159
|
if (!session) return { handled: false }
|
|
160
160
|
|
|
161
|
+
// a box relays the whole bridge vocabulary to the page hosting its
|
|
162
|
+
// simulator, which runs the same shell the local CLI drives. so there is no
|
|
163
|
+
// subset to police: every command below reaches it through the ordinary
|
|
164
|
+
// bridge, which createBridgeFromParsed points at the box. the two exceptions
|
|
165
|
+
// are the two commands that are about a nano claim, which a box has none of.
|
|
166
|
+
if (session.service === 'box') {
|
|
167
|
+
if (command === 'claim' || command === 'close') {
|
|
168
|
+
console.error(
|
|
169
|
+
` rnx ${command} is not available for a box; its simulator is held by the Box page. use rnx box stop to end the box.`,
|
|
170
|
+
)
|
|
171
|
+
return { handled: true, code: 1 }
|
|
172
|
+
}
|
|
173
|
+
return { handled: false }
|
|
174
|
+
}
|
|
175
|
+
|
|
161
176
|
if (command === 'claim') {
|
|
162
177
|
try {
|
|
163
178
|
const descriptor = cloudSession.requireCloudSessionShellUpdate()
|
package/cli/cloud-session.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fstatSync, writeSync } from 'node:fs'
|
|
2
2
|
import { isLoopbackHost } from '../src/backend-origin'
|
|
3
3
|
|
|
4
|
-
const CLOUD_SESSION_VERSION =
|
|
4
|
+
const CLOUD_SESSION_VERSION = 3 as const
|
|
5
5
|
const SHELL_UPDATE_FD_ENV = 'RNX_SHELL_UPDATE_FD'
|
|
6
6
|
const MAX_ENCODED_SESSION_BYTES = 32 * 1024
|
|
7
7
|
|
|
@@ -10,10 +10,19 @@ export const RNX_SHELL_UPDATE_PREFIX = '__RNX_SHELL_UPDATE_V1__'
|
|
|
10
10
|
export const RNX_SHELL_UPDATE_SET_PREFIX = `${RNX_SHELL_UPDATE_PREFIX}set:`
|
|
11
11
|
export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset`
|
|
12
12
|
|
|
13
|
-
// the shell's
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
// the shell's remote target. two services answer rnx commands and they are
|
|
14
|
+
// addressed differently, so the session names which one it is rather than
|
|
15
|
+
// leaving a reader to infer it from which fields happen to be filled in.
|
|
16
|
+
export type CloudSession = CloudSimSession | CloudBoxSession
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* one remote host, addressed by the box token, and the simulator inside it
|
|
20
|
+
* that ordinary commands reach with the simulator token. this is what
|
|
21
|
+
* `rnx ios --remote` produces.
|
|
22
|
+
*/
|
|
23
|
+
export interface CloudSimSession {
|
|
24
|
+
version: 3
|
|
25
|
+
service: 'sim'
|
|
17
26
|
boxId: string
|
|
18
27
|
boxToken: string
|
|
19
28
|
simId: string
|
|
@@ -22,6 +31,20 @@ export interface CloudSession {
|
|
|
22
31
|
token: string
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
/**
|
|
35
|
+
* a micro box, whose simulator lives in its own Box page rather than in the
|
|
36
|
+
* service. there is nothing to claim and no separate simulator token: the box
|
|
37
|
+
* token addresses the box, and the box relays each command to the page that
|
|
38
|
+
* is hosting it.
|
|
39
|
+
*/
|
|
40
|
+
export interface CloudBoxSession {
|
|
41
|
+
version: 3
|
|
42
|
+
service: 'box'
|
|
43
|
+
boxId: string
|
|
44
|
+
boxToken: string
|
|
45
|
+
apiOrigin: string
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
26
49
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
27
50
|
}
|
|
@@ -56,16 +79,17 @@ export function createCloudSession(input: {
|
|
|
56
79
|
apiOrigin: string
|
|
57
80
|
claimId: string
|
|
58
81
|
token: string
|
|
59
|
-
}):
|
|
82
|
+
}): CloudSimSession {
|
|
60
83
|
const session = {
|
|
61
84
|
version: CLOUD_SESSION_VERSION,
|
|
85
|
+
service: 'sim',
|
|
62
86
|
boxId: input.boxId.trim(),
|
|
63
87
|
boxToken: input.boxToken,
|
|
64
88
|
simId: input.simId.trim(),
|
|
65
89
|
apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()),
|
|
66
90
|
claimId: input.claimId.trim(),
|
|
67
91
|
token: input.token,
|
|
68
|
-
} satisfies
|
|
92
|
+
} satisfies CloudSimSession
|
|
69
93
|
if (
|
|
70
94
|
!session.boxId ||
|
|
71
95
|
!session.boxToken ||
|
|
@@ -78,10 +102,30 @@ export function createCloudSession(input: {
|
|
|
78
102
|
return session
|
|
79
103
|
}
|
|
80
104
|
|
|
105
|
+
export function createCloudBoxSession(input: {
|
|
106
|
+
boxId: string
|
|
107
|
+
boxToken: string
|
|
108
|
+
apiOrigin: string
|
|
109
|
+
}): CloudBoxSession {
|
|
110
|
+
const session = {
|
|
111
|
+
version: CLOUD_SESSION_VERSION,
|
|
112
|
+
service: 'box',
|
|
113
|
+
boxId: input.boxId.trim(),
|
|
114
|
+
boxToken: input.boxToken,
|
|
115
|
+
apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()),
|
|
116
|
+
} satisfies CloudBoxSession
|
|
117
|
+
if (!session.boxId || !session.boxToken) {
|
|
118
|
+
throw new Error('cloud box session requires a box, an origin, and a box token')
|
|
119
|
+
}
|
|
120
|
+
return session
|
|
121
|
+
}
|
|
122
|
+
|
|
81
123
|
export function serializeCloudSession(session: CloudSession): string {
|
|
82
|
-
|
|
83
|
-
'
|
|
84
|
-
|
|
124
|
+
const validated =
|
|
125
|
+
session.service === 'sim'
|
|
126
|
+
? createCloudSession(session)
|
|
127
|
+
: createCloudBoxSession(session)
|
|
128
|
+
return Buffer.from(JSON.stringify(validated), 'utf8').toString('base64url')
|
|
85
129
|
}
|
|
86
130
|
|
|
87
131
|
export function parseCloudSession(encoded: string): CloudSession {
|
|
@@ -108,21 +152,29 @@ export function parseCloudSession(encoded: string): CloudSession {
|
|
|
108
152
|
}
|
|
109
153
|
const boxId = parsed.boxId
|
|
110
154
|
const boxToken = parsed.boxToken
|
|
111
|
-
const simId = parsed.simId
|
|
112
155
|
const apiOrigin = parsed.apiOrigin
|
|
113
|
-
const claimId = parsed.claimId
|
|
114
|
-
const token = parsed.token
|
|
115
156
|
if (
|
|
116
157
|
typeof boxId !== 'string' ||
|
|
117
158
|
typeof boxToken !== 'string' ||
|
|
118
|
-
typeof
|
|
119
|
-
typeof apiOrigin !== 'string' ||
|
|
120
|
-
typeof claimId !== 'string' ||
|
|
121
|
-
typeof token !== 'string'
|
|
159
|
+
typeof apiOrigin !== 'string'
|
|
122
160
|
) {
|
|
123
161
|
throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
|
|
124
162
|
}
|
|
125
163
|
try {
|
|
164
|
+
if (parsed.service === 'box') {
|
|
165
|
+
return createCloudBoxSession({ boxId, boxToken, apiOrigin })
|
|
166
|
+
}
|
|
167
|
+
const simId = parsed.simId
|
|
168
|
+
const claimId = parsed.claimId
|
|
169
|
+
const token = parsed.token
|
|
170
|
+
if (
|
|
171
|
+
parsed.service !== 'sim' ||
|
|
172
|
+
typeof simId !== 'string' ||
|
|
173
|
+
typeof claimId !== 'string' ||
|
|
174
|
+
typeof token !== 'string'
|
|
175
|
+
) {
|
|
176
|
+
throw new Error('unreadable cloud session')
|
|
177
|
+
}
|
|
126
178
|
return createCloudSession({ boxId, boxToken, simId, apiOrigin, claimId, token })
|
|
127
179
|
} catch {
|
|
128
180
|
throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ProjectFilePlane over an already-built bundle held in memory.
|
|
2
|
+
//
|
|
3
|
+
// a prebuilt Box has no sources, so there is no checkout to index and nothing
|
|
4
|
+
// on disk to stay in step with: the whole project is the bundle the page boots.
|
|
5
|
+
// CheckoutFilePlane cannot serve this because it asks git what belongs to the
|
|
6
|
+
// project, and a bundle produced by a build lives outside any checkout.
|
|
7
|
+
//
|
|
8
|
+
// the bundle is split here rather than by the caller, so the one place that
|
|
9
|
+
// decides where the parts go is the one place that decides how big they are.
|
|
10
|
+
|
|
11
|
+
import { PREBUILT_BUNDLE_PART_BYTES, prebuiltBundlePartPath } from '@rnx/box/template'
|
|
12
|
+
import type { ProjectFileMeta, ProjectFilePlane } from '@rnx/box/plane'
|
|
13
|
+
|
|
14
|
+
export class BundleFilePlane implements ProjectFilePlane {
|
|
15
|
+
private readonly parts = new Map<string, Uint8Array>()
|
|
16
|
+
private readonly entries = new Map<string, ProjectFileMeta>()
|
|
17
|
+
|
|
18
|
+
constructor(bundle: Uint8Array) {
|
|
19
|
+
if (bundle.byteLength === 0) throw new Error('a Box bundle cannot be empty')
|
|
20
|
+
for (
|
|
21
|
+
let offset = 0, index = 0;
|
|
22
|
+
offset < bundle.byteLength;
|
|
23
|
+
offset += PREBUILT_BUNDLE_PART_BYTES, index++
|
|
24
|
+
) {
|
|
25
|
+
const part = bundle.subarray(offset, offset + PREBUILT_BUNDLE_PART_BYTES)
|
|
26
|
+
this.record(prebuiltBundlePartPath(index), part)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private record(path: string, content: Uint8Array): void {
|
|
31
|
+
this.parts.set(path, content)
|
|
32
|
+
this.entries.set(path, {
|
|
33
|
+
size: content.byteLength,
|
|
34
|
+
readOnly: false,
|
|
35
|
+
// a Metro bundle is JavaScript. the flag is what decides whether a shell
|
|
36
|
+
// may overwrite a file with text, and marking it binary would make the
|
|
37
|
+
// bundle unwritable from inside the box for no reason.
|
|
38
|
+
binary: false,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
index(): ReadonlyMap<string, ProjectFileMeta> {
|
|
43
|
+
return this.entries
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// nothing else can change this plane, so the index is already current.
|
|
47
|
+
refresh(): Promise<void> {
|
|
48
|
+
return Promise.resolve()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async read(path: string): Promise<string> {
|
|
52
|
+
return new TextDecoder().decode(await this.readBytes(path))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
readBytes(path: string): Promise<Uint8Array> {
|
|
56
|
+
const part = this.parts.get(path)
|
|
57
|
+
if (!part) return Promise.reject(new Error(`no such file: ${path}`))
|
|
58
|
+
return Promise.resolve(part)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
write(path: string, content: Uint8Array): Promise<void> {
|
|
62
|
+
this.record(path, content)
|
|
63
|
+
return Promise.resolve()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
delete(path: string): Promise<void> {
|
|
67
|
+
this.parts.delete(path)
|
|
68
|
+
this.entries.delete(path)
|
|
69
|
+
return Promise.resolve()
|
|
70
|
+
}
|
|
71
|
+
}
|