peachsim 0.1.1620 → 0.1.1625
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/README.md +35 -5
- package/cli/app-fonts.ts +2 -1
- package/cli/bridge-flow-runner.ts +184 -113
- package/cli/cloud-client.ts +68 -57
- package/cli/commands/box/search-command.ts +3 -2
- package/cli/commands/box.ts +10 -29
- package/cli/commands/control.ts +108 -43
- package/cli/commands/inspect/settling.ts +57 -20
- package/cli/commands/maestro-compare.ts +11 -32
- package/cli/commands/platform.ts +71 -36
- package/cli/commands/upload.ts +5 -1
- package/cli/dev-build-freshness.ts +37 -2
- package/cli/drivers/playwright-sim-host.ts +6 -5
- package/cli/maestro-accessibility.ts +53 -0
- package/cli/maestro-compare.ts +24 -0
- package/cli/outbound-endpoints.ts +22 -5
- package/cli/parent-pid.ts +0 -9
- package/cli/remote-simulators.ts +112 -0
- package/cli/remoteSimulatorContract.ts +21 -0
- package/detox/expectations.ts +5 -1
- package/detox/index.ts +133 -98
- package/detox/motion-change.cjs +37 -0
- package/detox/motion-change.d.cts +16 -0
- package/detox/navigation-lifecycle.ts +22 -4
- package/dist-lib/agent-daemon-client.cjs +3 -2
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +3 -2
- 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/bundle-channel.cjs +1520 -934
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +3 -2
- package/dist-lib/cloud.mjs +3 -2
- package/dist-lib/config.cjs +5 -4
- package/dist-lib/config.mjs +5 -4
- package/dist-lib/detox/index.cjs +1021 -364
- package/dist-lib/dev-bundle-resolution.cjs +51 -23
- package/dist-lib/expo-swift-bundle.cjs +1343 -801
- package/dist-lib/expo-swift-metadata.cjs +5 -4
- package/dist-lib/expo-swift-metadata.mjs +5 -4
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +2468 -1341
- 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 +1262 -300
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +1 -1
- package/dist-lib/menu.cjs +3 -2
- package/dist-lib/menu.mjs +3 -2
- 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 +5 -4
- package/dist-lib/metro-production-bundle.mjs +5 -4
- package/dist-lib/metro.cjs +799 -398
- package/dist-lib/native-app-identity-project.cjs +728 -22
- package/dist-lib/native-app-identity-project.mjs +727 -22
- package/dist-lib/native-app-identity.cjs +224 -9
- package/dist-lib/native-app-identity.mjs +222 -8
- package/dist-lib/native-firebase-metadata.cjs +5 -4
- package/dist-lib/native-firebase-metadata.mjs +5 -4
- package/dist-lib/plist.cjs +1 -1
- package/dist-lib/plist.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +3 -2
- package/dist-lib/react-native-host-modules.cjs +4 -1
- package/dist-lib/react-native-host-modules.mjs +4 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
- package/dist-lib/sdk.cjs +89 -24
- package/dist-lib/sdk.mjs +89 -24
- package/dist-lib/skills.cjs +4391 -2350
- package/dist-lib/swift-compile.cjs +328 -142
- package/dist-lib/swift-compile.mjs +342 -147
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift-toolchain.cjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +888 -559
- package/dist-lib/vite.cjs +68 -14
- package/dist-lib/worklet-executable.cjs +25 -2
- package/dist-lib/worklet-executable.mjs +25 -2
- package/package.json +2 -2
- package/scripts/dev-server-scanner.ts +33 -13
- package/src/admitted-mac-build.ts +34 -8
- package/src/android-app-identity.ts +146 -0
- package/src/android-autolinking.ts +75 -0
- package/src/android-string-value.ts +36 -0
- package/src/android-strings.ts +5 -36
- package/src/androidAutolinkingContract.ts +40 -0
- package/src/bundleGlobalFooter.ts +26 -7
- package/src/codex-client.ts +3 -2
- package/src/dev-bundle-resolution.ts +58 -25
- package/src/expo-kotlin-host.ts +6 -36
- package/src/expo-swift-bundle.ts +17 -12
- package/src/expo-swift-host.ts +477 -173
- package/src/expoSwiftContract.ts +6 -0
- package/src/host/bridge-host.ts +7 -55
- package/src/host/staged-host-files.ts +29 -2
- package/src/installed-package-versions.ts +54 -14
- package/src/launchMetadataContract.ts +24 -0
- package/src/metro-plugin.ts +37 -42
- package/src/native-app-identity-project.ts +116 -3
- package/src/native-app-identity.ts +86 -11
- package/src/nitro-compile.ts +2 -5
- package/src/nitro-source.ts +147 -63
- package/src/process-group.ts +90 -60
- package/src/process-output.ts +0 -9
- package/src/public-brand.ts +1 -0
- package/src/react-native-host-modules.ts +3 -0
- package/src/sim-client.ts +6 -4
- package/src/swift-compile.ts +176 -28
- package/src/swift-distribution.lock.json +37 -11
- package/src/swift-distribution.ts +61 -9
- package/src/swift-host.ts +58 -27
- package/src/swift-objc-standin.ts +13 -0
- package/src/swift-standin-emit.ts +1 -1
- package/src/swift-standins.ts +17 -5
- package/src/vite-plugin.ts +8 -19
- package/src/worklet-executable.ts +30 -3
- package/src/worklets-babel.ts +35 -14
- package/src/xcode-project.ts +10 -0
package/cli/cloud-client.ts
CHANGED
|
@@ -46,8 +46,8 @@ import {
|
|
|
46
46
|
RNX_METRO_MODULE_IDENTITY_VERSION,
|
|
47
47
|
type RNXMetroModuleIdentityMetadata,
|
|
48
48
|
} from '../src/metro-production-bundle.ts'
|
|
49
|
+
import { readPackageVersionsFooter } from '../src/packageVersionsContract'
|
|
49
50
|
import { rnxPublicBrand } from '../src/public-brand.ts'
|
|
50
|
-
import { PEACH_SIMULATOR_PATH } from '../src/simulator-route'
|
|
51
51
|
import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from './auth.ts'
|
|
52
52
|
import {
|
|
53
53
|
createCloudSession,
|
|
@@ -274,6 +274,26 @@ async function loadBundleSource(bundleInput: string, command: string): Promise<s
|
|
|
274
274
|
return source
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// a metro bundle carries react native's Platform module for exactly the
|
|
278
|
+
// platform it was built for, and that module's OS constant survives
|
|
279
|
+
// minification. a bundle naming only the other platform would boot against
|
|
280
|
+
// native modules it was never built for, so it refuses before a host exists.
|
|
281
|
+
export function assertBundleBuiltFor(
|
|
282
|
+
bytes: Buffer,
|
|
283
|
+
platform: RemotePlatform,
|
|
284
|
+
command: string,
|
|
285
|
+
): void {
|
|
286
|
+
const source = bytes.toString('latin1')
|
|
287
|
+
const ios = /\bOS:\s*["']ios["']/.test(source)
|
|
288
|
+
const android = /\bOS:\s*["']android["']/.test(source)
|
|
289
|
+
if (ios === android) return
|
|
290
|
+
const built = ios ? 'ios' : 'android'
|
|
291
|
+
if (built === platform) return
|
|
292
|
+
throw new Error(
|
|
293
|
+
`${command} needs a bundle built for ${platform}; this one was built for ${built}. Rebuild with \`react-native bundle --platform ${platform}\` or run \`peach ${built} --remote\`.`,
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
277
297
|
// `react-native bundle --assets-dest` dest paths are not always the JS
|
|
278
298
|
// descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does
|
|
279
299
|
// that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the
|
|
@@ -746,6 +766,11 @@ export async function produceCloudBoxArtifact(
|
|
|
746
766
|
assetsDirectory ? 'require-assets' : 'allow-placeholders',
|
|
747
767
|
assetsDirectory,
|
|
748
768
|
)
|
|
769
|
+
if (!readPackageVersionsFooter(prepared.source)?.['react-native']) {
|
|
770
|
+
throw new Error(
|
|
771
|
+
'remote simulator bundle is missing __peachPackageVersions with the installed react-native version. Wrap your project Metro config with withPeach from peachsim/metro, then rebuild the bundle before running peach ios --remote or peach android --remote.',
|
|
772
|
+
)
|
|
773
|
+
}
|
|
749
774
|
const warnings: string[] = []
|
|
750
775
|
if (prepared.search.unconfirmed && prepared.dest) {
|
|
751
776
|
warnings.push(
|
|
@@ -1662,39 +1687,54 @@ export class BoxBootFailedError extends Error {
|
|
|
1662
1687
|
}
|
|
1663
1688
|
}
|
|
1664
1689
|
|
|
1665
|
-
export interface BoxDisplayGrant {
|
|
1666
|
-
url: string
|
|
1667
|
-
authToken: string
|
|
1668
|
-
expectedPeer: unknown
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
1690
|
// how long `peach remote` waits for a fresh box's page to attach its simulator
|
|
1672
1691
|
// before giving up on the watch url. the page boots the bundle after the
|
|
1673
1692
|
// browser is up, and a large bundle takes a while to parse.
|
|
1674
1693
|
const BOX_DISPLAY_GRANT_WAIT_MS = 180_000
|
|
1675
1694
|
const BOX_DISPLAY_GRANT_RETRY_MS = 2_000
|
|
1676
1695
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1696
|
+
// waits for a fresh box's page to load its app or report that the app threw
|
|
1697
|
+
// while booting, reading the same runtime receipts the page records. a boot
|
|
1698
|
+
// error throws; a slow boot that reports neither within the wait is not an
|
|
1699
|
+
// error, so the caller still gets its watch url.
|
|
1700
|
+
export async function awaitBoxBoot(options: {
|
|
1701
|
+
apiOrigin: string
|
|
1702
|
+
boxId: string
|
|
1703
|
+
boxToken: string
|
|
1704
|
+
}): Promise<void> {
|
|
1705
|
+
const started = Date.now()
|
|
1706
|
+
while (Date.now() - started < BOX_DISPLAY_GRANT_WAIT_MS) {
|
|
1707
|
+
const response = await fetch(
|
|
1708
|
+
`${options.apiOrigin}/v1/boxes/${encodeURIComponent(options.boxId)}/lifecycle`,
|
|
1709
|
+
{ headers: { authorization: `Bearer ${options.boxToken}` } },
|
|
1710
|
+
)
|
|
1711
|
+
const text = await response.text()
|
|
1712
|
+
if (!response.ok) throw responseError(response.status, text)
|
|
1713
|
+
const status: unknown = JSON.parse(text)
|
|
1714
|
+
const generation =
|
|
1715
|
+
isRecord(status) && isRecord(status.browser)
|
|
1716
|
+
? status.browser.browserGeneration
|
|
1717
|
+
: null
|
|
1718
|
+
const receipts =
|
|
1719
|
+
isRecord(status) && Array.isArray(status.runtimeReceipts)
|
|
1720
|
+
? status.runtimeReceipts
|
|
1721
|
+
: []
|
|
1722
|
+
for (const receipt of receipts.toReversed()) {
|
|
1723
|
+
if (!isRecord(receipt) || receipt.browserGeneration !== generation) continue
|
|
1724
|
+
if (receipt.kind === 'boot-failed' && typeof receipt.error === 'string') {
|
|
1725
|
+
throw new BoxBootFailedError(options.boxId, receipt.error)
|
|
1726
|
+
}
|
|
1727
|
+
if (receipt.kind === 'loaded' || receipt.kind === 'painted') return
|
|
1728
|
+
}
|
|
1729
|
+
await new Promise((resolve) => setTimeout(resolve, BOX_DISPLAY_GRANT_RETRY_MS))
|
|
1690
1730
|
}
|
|
1691
|
-
return { url: grant.url, authToken: grant.authToken, expectedPeer: grant.expectedPeer }
|
|
1692
1731
|
}
|
|
1693
1732
|
|
|
1694
|
-
// mints a watch-only display grant from the box and
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1733
|
+
// mints a watch-only display grant from the box and returns the watch url the
|
|
1734
|
+
// box service built for it. the service puts it on the deploy that serves the
|
|
1735
|
+
// box page, so the viewer runs the same engine build, and display wire, as the
|
|
1736
|
+
// page hosting the simulator. the box token stays in this process; only the
|
|
1737
|
+
// grant travels, in the url's fragment.
|
|
1698
1738
|
export async function requestBoxWatchUrl(options: {
|
|
1699
1739
|
apiOrigin: string
|
|
1700
1740
|
boxId: string
|
|
@@ -1722,17 +1762,6 @@ export async function requestBoxWatchUrl(options: {
|
|
|
1722
1762
|
}`,
|
|
1723
1763
|
)
|
|
1724
1764
|
}
|
|
1725
|
-
if (response.status === 422) {
|
|
1726
|
-
const text = await response.text()
|
|
1727
|
-
let failure: unknown = null
|
|
1728
|
-
try {
|
|
1729
|
-
failure = JSON.parse(text)
|
|
1730
|
-
} catch {}
|
|
1731
|
-
if (isRecord(failure) && failure.code === 'boot-failed') {
|
|
1732
|
-
throw new BoxBootFailedError(options.boxId, String(failure.error))
|
|
1733
|
-
}
|
|
1734
|
-
throw responseError(response.status, text)
|
|
1735
|
-
}
|
|
1736
1765
|
if (response.status === 409) {
|
|
1737
1766
|
// the page has not attached its simulator yet. this is the normal state
|
|
1738
1767
|
// right after a create, so wait rather than refusing.
|
|
@@ -1752,27 +1781,9 @@ export async function requestBoxWatchUrl(options: {
|
|
|
1752
1781
|
} catch {
|
|
1753
1782
|
throw new Error('box display-grant returned unreadable JSON')
|
|
1754
1783
|
}
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
// hosts its simulator at the grant's device profile. without ?device= the
|
|
1759
|
-
// tenant refuses the host hello on dimensions and paints nothing — the
|
|
1760
|
-
// same param SimPage and BoxHostPage already set on their stage urls.
|
|
1761
|
-
if (
|
|
1762
|
-
isRecord(grant.expectedPeer) &&
|
|
1763
|
-
typeof grant.expectedPeer.deviceProfile === 'string' &&
|
|
1764
|
-
grant.expectedPeer.deviceProfile
|
|
1765
|
-
) {
|
|
1766
|
-
watch.searchParams.set('device', grant.expectedPeer.deviceProfile)
|
|
1767
|
-
}
|
|
1768
|
-
watch.hash = `remoteEngine=${encodeURIComponent(
|
|
1769
|
-
JSON.stringify({
|
|
1770
|
-
url: grant.url,
|
|
1771
|
-
authToken: grant.authToken,
|
|
1772
|
-
expectedPeer: grant.expectedPeer,
|
|
1773
|
-
}),
|
|
1774
|
-
)}`
|
|
1775
|
-
return watch.toString()
|
|
1784
|
+
if (!isRecord(parsed) || typeof parsed.watchUrl !== 'string' || !parsed.watchUrl)
|
|
1785
|
+
throw new Error('box display-grant returned no watch url')
|
|
1786
|
+
return parsed.watchUrl
|
|
1776
1787
|
}
|
|
1777
1788
|
}
|
|
1778
1789
|
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
import { spawn } from 'node:child_process'
|
|
35
35
|
import { defineCommand, type CustomCommand } from '@peach/box/shell'
|
|
36
36
|
import { PROJECT_MOUNT } from '@peach/box/shell'
|
|
37
|
+
import { terminateProcessGroup } from '../../../src/process-group'
|
|
37
38
|
|
|
38
39
|
// generous next to a 414ms p95: this bounds a wedged child, it does not cut off
|
|
39
40
|
// a large search.
|
|
@@ -63,9 +64,9 @@ function runGitGrep(args: string[], cwd: string) {
|
|
|
63
64
|
clearTimeout(timer)
|
|
64
65
|
resolve({ stdout, stderr, exitCode })
|
|
65
66
|
}
|
|
66
|
-
const timer = setTimeout(() => {
|
|
67
|
+
const timer = setTimeout(async () => {
|
|
67
68
|
try {
|
|
68
|
-
child
|
|
69
|
+
await terminateProcessGroup(child, 'SIGKILL')
|
|
69
70
|
} catch {}
|
|
70
71
|
stderr += `rg: search timed out after ${SEARCH_DEADLINE_MS}ms\n`
|
|
71
72
|
finish(2)
|
package/cli/commands/box.ts
CHANGED
|
@@ -52,9 +52,6 @@ interface BoxCommandOptions {
|
|
|
52
52
|
verbose?: boolean
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// no default. a cloud box service has to be deployed before one can exist, and
|
|
56
|
-
// defaulting to an origin that answers nothing would advertise a capability
|
|
57
|
-
// that always fails.
|
|
58
55
|
const ENDPOINT_ENV = 'PEACH_BOX_ENDPOINT'
|
|
59
56
|
const API_ORIGIN_ENV = 'PEACH_API_ORIGIN'
|
|
60
57
|
const BOX_TEMPLATES = new BoxTemplateRegistry([
|
|
@@ -212,23 +209,16 @@ async function runCloudBoxUse(args: string[]): Promise<number> {
|
|
|
212
209
|
}
|
|
213
210
|
}
|
|
214
211
|
|
|
215
|
-
function requireEndpoint(args: string[], label: string): string | null {
|
|
216
|
-
return cloudBoxEndpoint(args, `box ${label}`)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
212
|
/**
|
|
220
|
-
* the cloud box service
|
|
221
|
-
*
|
|
222
|
-
* spelling of where boxes live is a second place to configure.
|
|
213
|
+
* the cloud box service for every CLI command that selects a service. existing
|
|
214
|
+
* box references carry their own endpoint from the account service.
|
|
223
215
|
*/
|
|
224
|
-
export function cloudBoxEndpoint(args: string[]
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
` set ${ENDPOINT_ENV} or pass --endpoint=<url>.`,
|
|
216
|
+
export function cloudBoxEndpoint(args: string[]): string {
|
|
217
|
+
return (
|
|
218
|
+
flag(args, 'endpoint')?.trim() ||
|
|
219
|
+
process.env[ENDPOINT_ENV]?.trim() ||
|
|
220
|
+
rnxPublicBrand.boxOrigin
|
|
230
221
|
)
|
|
231
|
-
return null
|
|
232
222
|
}
|
|
233
223
|
|
|
234
224
|
/** the api origin a cloud box bills against. */
|
|
@@ -278,8 +268,7 @@ async function runBoxSend(args: string[], opts: BoxCommandOptions): Promise<numb
|
|
|
278
268
|
async function runCloudBoxStop(args: string[], purge: boolean): Promise<number> {
|
|
279
269
|
const peach = rnxPublicBrand.commandName
|
|
280
270
|
const label = purge ? 'delete' : 'stop'
|
|
281
|
-
const endpoint =
|
|
282
|
-
if (!endpoint) return 1
|
|
271
|
+
const endpoint = cloudBoxEndpoint(args)
|
|
283
272
|
const name = boxTarget(args, label)
|
|
284
273
|
if (!name) return 1
|
|
285
274
|
const { auth, header } = authHeaderOrExit(`box ${label}`)
|
|
@@ -321,7 +310,7 @@ async function runCloudBoxList(args: string[]): Promise<number> {
|
|
|
321
310
|
const accountId = cloudAccountIdOrExit(auth)
|
|
322
311
|
const client = createBoxClient(
|
|
323
312
|
createCloudBoxProvider({
|
|
324
|
-
endpoint:
|
|
313
|
+
endpoint: cloudBoxEndpoint(args),
|
|
325
314
|
authority: { kind: 'account', apiOrigin: apiOrigin(args, auth), apiKey, accountId },
|
|
326
315
|
}),
|
|
327
316
|
)
|
|
@@ -589,15 +578,7 @@ async function runCloudBoxCreate(
|
|
|
589
578
|
): Promise<number> {
|
|
590
579
|
const peach = rnxPublicBrand.commandName
|
|
591
580
|
const json = args.includes('--json')
|
|
592
|
-
const endpoint =
|
|
593
|
-
if (!endpoint) {
|
|
594
|
-
console.error(
|
|
595
|
-
` ${peach} box create: no cloud box service to create a box in.\n` +
|
|
596
|
-
` set ${ENDPOINT_ENV} or pass --endpoint=<url>. \`${peach} box\` runs the\n` +
|
|
597
|
-
` same shell locally over this checkout.`,
|
|
598
|
-
)
|
|
599
|
-
return 1
|
|
600
|
-
}
|
|
581
|
+
const endpoint = cloudBoxEndpoint(args)
|
|
601
582
|
|
|
602
583
|
// --bundle IS the template choice: the box runs an app that is already
|
|
603
584
|
// built, so naming a second template would ask for two different boxes.
|
package/cli/commands/control.ts
CHANGED
|
@@ -45,8 +45,14 @@ import {
|
|
|
45
45
|
runtimesDir,
|
|
46
46
|
} from '../../src/home-paths'
|
|
47
47
|
import { projectLoopbackUrl } from '../../src/host/loopback-ports'
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
isStagedHostFileRequest,
|
|
50
|
+
stageHostBytes,
|
|
51
|
+
stageHostFile,
|
|
52
|
+
stageHostLaunchMetadata,
|
|
53
|
+
} from '../../src/host/staged-host-files'
|
|
49
54
|
import { readInstalledNativePackageVersions } from '../../src/installed-package-versions'
|
|
55
|
+
import { LAUNCH_METADATA_QUERY_PARAM } from '../../src/launchMetadataContract'
|
|
50
56
|
import { detectBundleModeFromText } from '../../src/metro-production-bundle'
|
|
51
57
|
import {
|
|
52
58
|
canExtractNativeAppIdentity,
|
|
@@ -275,12 +281,15 @@ async function connectBridgeForOpen(
|
|
|
275
281
|
const lock = readDaemonLockfile()
|
|
276
282
|
assertExplicitBridgeLock(preferredPort, explicitPort, lock)
|
|
277
283
|
const preferredWorld = worldForBridgePort(preferredPort)
|
|
278
|
-
const
|
|
284
|
+
const handshakeTimeoutMs = LIVE_BRIDGE_HANDSHAKE_TIMEOUT_MS * flowTimeoutScale()
|
|
285
|
+
const handshake = await bridgeHandshake(preferredPort, handshakeTimeoutMs)
|
|
279
286
|
if ((explicitPort || preferredWorld !== null) && handshake === 'open') {
|
|
280
287
|
return preferredPort
|
|
281
288
|
}
|
|
282
289
|
if (explicitPort && preferredWorld !== null) {
|
|
283
|
-
throw new Error(
|
|
290
|
+
throw new Error(
|
|
291
|
+
unreachableBridgeMessage(preferredPort, handshake, handshakeTimeoutMs),
|
|
292
|
+
)
|
|
284
293
|
}
|
|
285
294
|
if (
|
|
286
295
|
!explicitPort &&
|
|
@@ -288,7 +297,7 @@ async function connectBridgeForOpen(
|
|
|
288
297
|
isDaemonLockfileFresh(lock) &&
|
|
289
298
|
(lock.bridgePort === preferredPort
|
|
290
299
|
? handshake === 'open'
|
|
291
|
-
: await canConnectToBridge(lock.bridgePort,
|
|
300
|
+
: await canConnectToBridge(lock.bridgePort, handshakeTimeoutMs))
|
|
292
301
|
) {
|
|
293
302
|
return lock.bridgePort
|
|
294
303
|
}
|
|
@@ -452,12 +461,16 @@ function bridgeHandshake(port: number, timeoutMs: number): Promise<BridgeHandsha
|
|
|
452
461
|
})
|
|
453
462
|
}
|
|
454
463
|
|
|
455
|
-
function unreachableBridgeMessage(
|
|
464
|
+
function unreachableBridgeMessage(
|
|
465
|
+
port: number,
|
|
466
|
+
handshake: BridgeHandshake,
|
|
467
|
+
timeoutMs: number,
|
|
468
|
+
): string {
|
|
456
469
|
if (handshake === 'timeout') {
|
|
457
470
|
const cores = Math.max(1, os.cpus().length)
|
|
458
471
|
const load = os.loadavg()[0]
|
|
459
472
|
if (load >= cores) {
|
|
460
|
-
return `peach open: requested bridge port ${port} did not finish its handshake within
|
|
473
|
+
return `peach open: requested bridge port ${port} did not finish its handshake within ${timeoutMs / 1000}s while load average was ${load.toFixed(1)} on ${cores} cores.`
|
|
461
474
|
}
|
|
462
475
|
}
|
|
463
476
|
return `peach open: requested bridge port ${port} is not reachable.`
|
|
@@ -585,11 +598,12 @@ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDev
|
|
|
585
598
|
const releaseListenerScan = pinSharedListenerScan()
|
|
586
599
|
try {
|
|
587
600
|
for (let attempt = 0; attempt < DEV_BUNDLE_RESOLVE_ATTEMPTS; attempt++) {
|
|
601
|
+
let resolutionTarget = normalized
|
|
588
602
|
if (registeredPort && registeredPort > 0) {
|
|
589
603
|
const discovered = await probePort(registeredPort)
|
|
590
604
|
// a provisional bundle URL is enough to LIST the server but not to open
|
|
591
|
-
// it;
|
|
592
|
-
//
|
|
605
|
+
// it; resolveConnectionInput joins the scanner's pending manifest with
|
|
606
|
+
// the full startup budget instead of starting the same work again.
|
|
593
607
|
if (discovered && !discovered.bundleUrlProvisional) {
|
|
594
608
|
return {
|
|
595
609
|
bundleUrl: projectLoopbackUrl(discovered.bundleUrl),
|
|
@@ -598,10 +612,15 @@ async function resolveConnectionInputForCli(target: string): Promise<ResolvedDev
|
|
|
598
612
|
projectName: discovered.projectName,
|
|
599
613
|
}
|
|
600
614
|
}
|
|
615
|
+
// keep the scanner's answering address and join its manifest request.
|
|
616
|
+
// probing both loopback families again duplicates cold config work.
|
|
617
|
+
if (discovered?.bundleUrlProvisional && /^\d+$/.test(normalized)) {
|
|
618
|
+
resolutionTarget = new URL(discovered.bundleUrl).origin
|
|
619
|
+
}
|
|
601
620
|
}
|
|
602
621
|
|
|
603
622
|
try {
|
|
604
|
-
const resolved = await resolveConnectionInput(
|
|
623
|
+
const resolved = await resolveConnectionInput(resolutionTarget)
|
|
605
624
|
return { ...resolved, bundleUrl: projectLoopbackUrl(resolved.bundleUrl) }
|
|
606
625
|
} catch (error) {
|
|
607
626
|
if (
|
|
@@ -850,7 +869,7 @@ export function resolveShellBaseUrlForBridgePort(bridgePort: number): string {
|
|
|
850
869
|
)
|
|
851
870
|
}
|
|
852
871
|
|
|
853
|
-
// stamp the engine page url
|
|
872
|
+
// stamp a bounded catalog reference on the engine page url so the
|
|
854
873
|
// engine's app-font-loader registers native config-only fonts at boot. fonts
|
|
855
874
|
// loaded by JS already flow through normal runtime fetch capture; this covers
|
|
856
875
|
// UIAppFonts-style assets that Metro can serve but the guest bundle never
|
|
@@ -874,7 +893,11 @@ function stampAppFonts(
|
|
|
874
893
|
nativeAssetOrigin,
|
|
875
894
|
})
|
|
876
895
|
if (specs.length === 0) return urlStr
|
|
877
|
-
|
|
896
|
+
const catalogUrl = stageHostBytes(
|
|
897
|
+
new TextEncoder().encode(encodeAppFontsWire(specs)),
|
|
898
|
+
nativeAssetOrigin,
|
|
899
|
+
)
|
|
900
|
+
url.searchParams.set('appFonts', `@${catalogUrl}`)
|
|
878
901
|
return url.toString()
|
|
879
902
|
} catch {
|
|
880
903
|
return urlStr
|
|
@@ -1059,45 +1082,74 @@ export interface BuildShellUrlOptions {
|
|
|
1059
1082
|
bridgePort?: number
|
|
1060
1083
|
}
|
|
1061
1084
|
|
|
1062
|
-
|
|
1085
|
+
type ProjectExpoConfigRead = { config: Record<string, unknown> } | { error: string }
|
|
1086
|
+
|
|
1087
|
+
function errorMessage(error: unknown): string {
|
|
1088
|
+
return error instanceof Error ? error.message : String(error)
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function readProjectExpoConfig(
|
|
1063
1092
|
project: ResolvedAppProject,
|
|
1064
|
-
): Promise<
|
|
1093
|
+
): Promise<ProjectExpoConfigRead | null> {
|
|
1065
1094
|
const require = createRequire(path.join(project.appDir, 'package.json'))
|
|
1066
1095
|
try {
|
|
1067
1096
|
require.resolve('@expo/config')
|
|
1068
1097
|
} catch {
|
|
1069
1098
|
return Promise.resolve(null)
|
|
1070
1099
|
}
|
|
1071
|
-
return readExpoConfigAsync(project.appDir)
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
.catch((error: unknown) => ({
|
|
1076
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1077
|
-
}))
|
|
1100
|
+
return readExpoConfigAsync(project.appDir).then(
|
|
1101
|
+
(config) => ({ config }),
|
|
1102
|
+
(error: unknown) => ({ error: errorMessage(error) }),
|
|
1103
|
+
)
|
|
1078
1104
|
}
|
|
1079
1105
|
|
|
1080
1106
|
// evaluating the Expo config spawns node, so `peach open` starts the read as
|
|
1081
1107
|
// soon as the project resolves and the shell url it builds takes that read. a
|
|
1082
1108
|
// later build reads the config again.
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
function startNativeFirebaseConfigRead(project: ResolvedAppProject | null) {
|
|
1089
|
-
if (project)
|
|
1090
|
-
startedNativeFirebaseConfigs.set(
|
|
1091
|
-
project.appDir,
|
|
1092
|
-
readProjectNativeFirebaseConfig(project),
|
|
1093
|
-
)
|
|
1109
|
+
const startedExpoConfigReads = new Map<string, Promise<ProjectExpoConfigRead | null>>()
|
|
1110
|
+
|
|
1111
|
+
function startExpoConfigRead(project: ResolvedAppProject | null) {
|
|
1112
|
+
if (project) startedExpoConfigReads.set(project.appDir, readProjectExpoConfig(project))
|
|
1094
1113
|
}
|
|
1095
1114
|
|
|
1096
|
-
function
|
|
1115
|
+
function takeExpoConfigRead(project: ResolvedAppProject | null | undefined) {
|
|
1097
1116
|
if (!project) return Promise.resolve(null)
|
|
1098
|
-
const started =
|
|
1099
|
-
|
|
1100
|
-
return started ??
|
|
1117
|
+
const started = startedExpoConfigReads.get(project.appDir)
|
|
1118
|
+
startedExpoConfigReads.delete(project.appDir)
|
|
1119
|
+
return started ?? readProjectExpoConfig(project)
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function nativeFirebaseFromExpoConfig(
|
|
1123
|
+
project: ResolvedAppProject,
|
|
1124
|
+
read: ProjectExpoConfigRead,
|
|
1125
|
+
): Record<string, unknown> {
|
|
1126
|
+
if ('error' in read) return read
|
|
1127
|
+
try {
|
|
1128
|
+
return readNativeFirebaseConfig(project.appDir, undefined, undefined, read.config)
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
return { error: errorMessage(error) }
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// a built bundle staged on the bridge has no packager to serve its manifest.
|
|
1135
|
+
// the built binary embeds its evaluated app config (expo's `app.config`
|
|
1136
|
+
// resource, and the Info.plist prebuild writes from it), so the bundle's
|
|
1137
|
+
// manifest is that config, staged beside it
|
|
1138
|
+
function stageBuiltBundleManifest(
|
|
1139
|
+
bundleUrl: string,
|
|
1140
|
+
config: Record<string, unknown>,
|
|
1141
|
+
): string | null {
|
|
1142
|
+
const bundle = new URL(bundleUrl)
|
|
1143
|
+
if (!isStagedHostFileRequest(bundle.pathname) || Object.keys(config).length === 0)
|
|
1144
|
+
return null
|
|
1145
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'peach-app-manifest-'))
|
|
1146
|
+
try {
|
|
1147
|
+
const file = path.join(directory, 'manifest.json')
|
|
1148
|
+
fs.writeFileSync(file, JSON.stringify({ extra: { expoClient: config } }))
|
|
1149
|
+
return stageHostFile(file, bundle.origin)
|
|
1150
|
+
} finally {
|
|
1151
|
+
fs.rmSync(directory, { recursive: true, force: true })
|
|
1152
|
+
}
|
|
1101
1153
|
}
|
|
1102
1154
|
|
|
1103
1155
|
// a plain Metro carries no identity footer or header, so the page carries the
|
|
@@ -1200,6 +1252,7 @@ export async function buildShellUrl(
|
|
|
1200
1252
|
// own `@expo/config` (a dependency-free recorder) leaves it to the Peach
|
|
1201
1253
|
// Metro plugin's endpoint.
|
|
1202
1254
|
expoStamped.searchParams.delete('nativeFirebase')
|
|
1255
|
+
expoStamped.searchParams.delete('appManifest')
|
|
1203
1256
|
expoStamped.searchParams.delete('packageVersions')
|
|
1204
1257
|
if (project && expoStamped.searchParams.get('bundle')) {
|
|
1205
1258
|
expoStamped.searchParams.set(
|
|
@@ -1207,9 +1260,19 @@ export async function buildShellUrl(
|
|
|
1207
1260
|
JSON.stringify(readInstalledNativePackageVersions(project.appDir)),
|
|
1208
1261
|
)
|
|
1209
1262
|
}
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1212
|
-
|
|
1263
|
+
const expoConfigRead = await takeExpoConfigRead(project)
|
|
1264
|
+
const stampedBundle = expoStamped.searchParams.get('bundle')
|
|
1265
|
+
if (project && expoConfigRead && stampedBundle) {
|
|
1266
|
+
expoStamped.searchParams.set(
|
|
1267
|
+
'nativeFirebase',
|
|
1268
|
+
JSON.stringify(nativeFirebaseFromExpoConfig(project, expoConfigRead)),
|
|
1269
|
+
)
|
|
1270
|
+
const manifest =
|
|
1271
|
+
'config' in expoConfigRead
|
|
1272
|
+
? stageBuiltBundleManifest(stampedBundle, expoConfigRead.config)
|
|
1273
|
+
: null
|
|
1274
|
+
if (manifest) expoStamped.searchParams.set('appManifest', manifest)
|
|
1275
|
+
}
|
|
1213
1276
|
if (opts.bundleOriginOverride)
|
|
1214
1277
|
expoStamped.searchParams.set('bundleOriginOverride', opts.bundleOriginOverride)
|
|
1215
1278
|
if (opts.hostDriver) expoStamped.searchParams.set('peachHostDriver', opts.hostDriver)
|
|
@@ -1235,7 +1298,7 @@ export async function buildShellUrl(
|
|
|
1235
1298
|
!opts.env?.length &&
|
|
1236
1299
|
!opts.dotEnv
|
|
1237
1300
|
)
|
|
1238
|
-
return expoStamped.
|
|
1301
|
+
return stageHostLaunchMetadata(expoStamped, `http://localhost:${port.port}`)
|
|
1239
1302
|
const url = expoStamped
|
|
1240
1303
|
if (opts.dotEnv) url.searchParams.set('dotEnv', JSON.stringify(opts.dotEnv))
|
|
1241
1304
|
if (opts.device) url.searchParams.set('device', opts.device)
|
|
@@ -1246,7 +1309,7 @@ export async function buildShellUrl(
|
|
|
1246
1309
|
if (opts.proof) url.searchParams.set('proof', '1')
|
|
1247
1310
|
if (opts.ephemeral) url.searchParams.set('ephemeral', '1')
|
|
1248
1311
|
for (const entry of opts.env ?? []) url.searchParams.append('env', entry)
|
|
1249
|
-
return url.
|
|
1312
|
+
return stageHostLaunchMetadata(url, `http://localhost:${port.port}`)
|
|
1250
1313
|
}
|
|
1251
1314
|
|
|
1252
1315
|
function deriveCurrentSimBaseUrl(
|
|
@@ -1278,6 +1341,7 @@ function deriveCurrentSimBaseUrl(
|
|
|
1278
1341
|
url.searchParams.delete('nativeFirebase')
|
|
1279
1342
|
url.searchParams.delete('packageVersions')
|
|
1280
1343
|
url.searchParams.delete('nativeAppIdentity')
|
|
1344
|
+
url.searchParams.delete(LAUNCH_METADATA_QUERY_PARAM)
|
|
1281
1345
|
url.searchParams.delete('expoSwift')
|
|
1282
1346
|
url.searchParams.delete('expoKotlin')
|
|
1283
1347
|
return url.toString()
|
|
@@ -1836,7 +1900,9 @@ export async function runOpenCommand(
|
|
|
1836
1900
|
peachExit(1)
|
|
1837
1901
|
}
|
|
1838
1902
|
|
|
1839
|
-
|
|
1903
|
+
// an absolute path to a built bundle is a file, not an app path
|
|
1904
|
+
const prebuiltBundle = prebuiltBundleFile(target)
|
|
1905
|
+
if (!prebuiltBundle && isAppDeepLinkTarget(target)) {
|
|
1840
1906
|
if (openInNewSim) {
|
|
1841
1907
|
console.error(
|
|
1842
1908
|
' peach open: deep links target the current app; remove --new, --profile, or --ephemeral',
|
|
@@ -1879,7 +1945,6 @@ export async function runOpenCommand(
|
|
|
1879
1945
|
return
|
|
1880
1946
|
}
|
|
1881
1947
|
|
|
1882
|
-
const prebuiltBundle = prebuiltBundleFile(target)
|
|
1883
1948
|
// a bundle file gets its url once the bridge that serves it is settled
|
|
1884
1949
|
let resolvedBundle =
|
|
1885
1950
|
!prebuiltBundle && shouldResolveBundleTargetInCli(target)
|
|
@@ -1895,7 +1960,7 @@ export async function runOpenCommand(
|
|
|
1895
1960
|
resolvedBundle?.bundleUrl,
|
|
1896
1961
|
projectRoot ?? (prebuiltBundle ? appDirAbove(prebuiltBundle) : undefined),
|
|
1897
1962
|
)
|
|
1898
|
-
|
|
1963
|
+
startExpoConfigRead(project)
|
|
1899
1964
|
// playwright is the browser-sim launcher. it owns an isolated profile and
|
|
1900
1965
|
// can reap exactly its own browser tree. desktop opens the native host and
|
|
1901
1966
|
// electron the Electron companion. none opens the user's browser profile.
|