peachsim 0.1.1625 → 0.1.1635
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 +1 -1
- package/README.md +10 -1
- package/cli/app-config.ts +4 -1
- package/cli/app-fonts.ts +37 -17
- package/cli/app-stubs.ts +224 -9
- package/cli/bridge-flow-runner.ts +64 -43
- package/cli/command-registry.ts +1 -0
- package/cli/commands/control.ts +283 -49
- package/cli/commands/doctor.ts +17 -184
- package/cli/commands/flow.ts +79 -11
- package/cli/commands/inspect/core.ts +10 -5
- package/cli/commands/inspect/settling.ts +14 -2
- package/cli/commands/inspect/wait-ready.ts +12 -0
- package/cli/commands/inspect.ts +26 -3
- package/cli/commands/install-desktop.ts +1 -0
- package/cli/commands/maestro-compare.ts +97 -38
- package/cli/commands/maestro.ts +9 -1
- package/cli/commands/record.ts +21 -1
- package/cli/commands/screenshot.ts +23 -1
- package/cli/commands/serve.ts +7 -2
- package/cli/commands/setup.ts +19 -42
- package/cli/commands/swift.ts +5 -0
- package/cli/commands/upload.ts +46 -7
- package/cli/desktop-companion.ts +102 -17
- package/cli/drivers/desktop.ts +14 -1
- package/cli/drivers/playwright-sim-host.ts +38 -3
- package/cli/drivers/playwright.ts +9 -8
- package/cli/drivers/types.ts +5 -0
- package/cli/maestro-accessibility.ts +1 -1
- package/cli/maestro-compare.ts +84 -12
- package/cli/main.ts +2 -1
- package/cli/outbound-endpoints.ts +13 -1
- package/cli/pause-aware-timeout.ts +102 -0
- package/cli/scene-video.ts +89 -0
- package/cli/ws-bridge.ts +23 -14
- package/detox/attempt-events.cjs +4 -0
- package/detox/index.ts +167 -142
- package/detox/motion-change.cjs +57 -21
- package/detox/motion-change.d.cts +7 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.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 +13 -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 +14 -8
- package/dist-lib/bridge-contract.mjs +13 -7
- package/dist-lib/bundle-channel.cjs +9536 -7604
- 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 +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +6 -3
- package/dist-lib/config.mjs +6 -3
- package/dist-lib/detox/index.cjs +895 -664
- package/dist-lib/dev-bundle-resolution.cjs +14 -2
- package/dist-lib/expo-swift-bundle.cjs +7818 -5916
- package/dist-lib/expo-swift-metadata.cjs +3 -1
- package/dist-lib/expo-swift-metadata.mjs +3 -1
- package/dist-lib/home-paths.cjs +32 -3
- package/dist-lib/host/bridge-host.cjs +8340 -6392
- 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 +812 -339
- 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 +1 -1
- package/dist-lib/menu.mjs +1 -1
- 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 +391 -28
- package/dist-lib/native-app-identity-project.cjs +55 -2
- package/dist-lib/native-app-identity-project.mjs +55 -2
- package/dist-lib/native-app-identity.cjs +53 -1
- package/dist-lib/native-app-identity.mjs +52 -1
- package/dist-lib/native-firebase-metadata.cjs +1 -1
- package/dist-lib/native-firebase-metadata.mjs +1 -1
- 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 +1 -1
- package/dist-lib/react-native-host-modules.cjs +5 -1
- package/dist-lib/react-native-host-modules.mjs +5 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
- package/dist-lib/sdk.cjs +27 -11
- package/dist-lib/sdk.mjs +27 -10
- package/dist-lib/skills.cjs +12276 -8947
- package/dist-lib/swift-compile.cjs +924 -118
- package/dist-lib/swift-compile.mjs +966 -152
- package/dist-lib/swift-package.cjs +12 -3
- package/dist-lib/swift-package.mjs +12 -3
- package/dist-lib/swift-submit.cjs +7 -1
- package/dist-lib/swift-submit.mjs +5 -1
- package/dist-lib/swift-toolchain.cjs +133 -18
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1955 -498
- package/dist-lib/vite.cjs +3 -1
- package/dist-lib/worklet-executable.cjs +1 -1
- package/dist-lib/worklet-executable.mjs +1 -1
- package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
- package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
- package/package.json +1 -1
- package/scripts/dev-server-scanner.ts +2 -1
- package/skills/peach-perf/SKILL.md +11 -5
- package/skills/peach-setup/SKILL.md +16 -19
- package/src/bridge-constants.ts +11 -0
- package/src/bridge-contract.ts +22 -34
- package/src/config.ts +15 -0
- package/src/desktop-host-delivery.ts +5 -1
- package/src/dev-bundle-resolution.ts +2 -1
- package/src/expo-manifest-body.ts +13 -0
- package/src/expo-swift-host.ts +191 -47
- package/src/expo-swift-ready.ts +78 -0
- package/src/expoSwiftContract.ts +13 -0
- package/src/home-paths.ts +48 -2
- package/src/host/bridge-host.ts +5 -2
- package/src/native-app-identity-project.ts +26 -2
- package/src/native-app-identity.ts +90 -0
- package/src/native-firebase-config.ts +209 -13
- package/src/process-group.ts +25 -2
- package/src/react-native-bridge-modules.ts +416 -0
- package/src/react-native-host-modules.ts +4 -0
- package/src/swift-binary-modules.ts +2 -2
- package/src/swift-compile.ts +835 -84
- package/src/swift-compiler-digest.ts +14 -0
- package/src/swift-distribution.lock.json +14 -12
- package/src/swift-host.ts +91 -29
- package/src/swift-icu.ts +14 -6
- package/src/swift-objc-sdk.ts +133 -10
- package/src/swift-objc-standin.ts +25 -3
- package/src/swift-package-manifest.ts +21 -6
- package/src/swift-package.ts +15 -1
- package/src/swift-packages.ts +119 -60
- package/src/swift-standin-emit.ts +57 -9
- package/src/swift-standins.ts +168 -20
- package/src/swift-submit.ts +10 -0
- package/src/swift-toolchain.ts +149 -23
- package/src/vite-plugin.ts +12 -1
package/src/expo-swift-host.ts
CHANGED
|
@@ -9,6 +9,7 @@ import path from 'node:path'
|
|
|
9
9
|
import { promisify } from 'node:util'
|
|
10
10
|
import { RNX_FORKED_SCRIPT_ENV } from '../cli/internal-child'
|
|
11
11
|
import { isLoopbackHost } from './backend-origin'
|
|
12
|
+
import { collectBridgeModuleSources } from './react-native-bridge-modules'
|
|
12
13
|
import { swiftBinaryModules } from './swift-binary-modules'
|
|
13
14
|
import {
|
|
14
15
|
swiftAvailableModules,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
icuUrl,
|
|
25
26
|
projectFor,
|
|
26
27
|
serveSwiftPackageAsset,
|
|
27
|
-
|
|
28
|
+
sourceContentStamp,
|
|
28
29
|
standinLibraries,
|
|
29
30
|
SwiftMissingModulesError,
|
|
30
31
|
} from './swift-host'
|
|
@@ -48,6 +49,37 @@ const run = promisify(execFile)
|
|
|
48
49
|
const scriptEnv = { ...process.env, [RNX_FORKED_SCRIPT_ENV]: '1' }
|
|
49
50
|
export const EXPO_SWIFT_ROUTE = '/__peach/expo-swift'
|
|
50
51
|
export const EXPO_SWIFT_ENTRY = 'Sources/PeachExpoApp/PeachExpoEntry.swift'
|
|
52
|
+
// a connected caller (the conformance waiter) waits this long. the old 10
|
|
53
|
+
// minute cap aborted a compile that caller was still holding.
|
|
54
|
+
const EXPO_SWIFT_REQUEST_TIMEOUT_MS = 30 * 60_000
|
|
55
|
+
// bun drops an unanswered fetch at six minutes and the waiter retries. aborting
|
|
56
|
+
// in that gap restarts the compile. a reconnect inside this window rejoins it.
|
|
57
|
+
const EXPO_SWIFT_CLIENT_GAP_MS = 5_000
|
|
58
|
+
|
|
59
|
+
// the autolinker's command line changed across Expo releases: before 1.8.0 it
|
|
60
|
+
// takes the project from its working directory, before 1.10.3 the Apple
|
|
61
|
+
// platform is named `ios`.
|
|
62
|
+
function autolinkerAppleFlags(autolinkerManifest: string, appRoot: string) {
|
|
63
|
+
const manifest: unknown = JSON.parse(fs.readFileSync(autolinkerManifest, 'utf8'))
|
|
64
|
+
const version =
|
|
65
|
+
manifest && typeof manifest === 'object' && Reflect.get(manifest, 'version')
|
|
66
|
+
const parts =
|
|
67
|
+
typeof version === 'string'
|
|
68
|
+
? version.split('.').map((part) => Number.parseInt(part, 10))
|
|
69
|
+
: []
|
|
70
|
+
const [major, minor, patch] = parts
|
|
71
|
+
if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch))
|
|
72
|
+
throw new Error(
|
|
73
|
+
`unreadable expo-modules-autolinking version in ${autolinkerManifest}`,
|
|
74
|
+
)
|
|
75
|
+
const atLeast = (m: number, n: number, p: number) =>
|
|
76
|
+
major !== m ? major > m : minor !== n ? minor > n : patch >= p
|
|
77
|
+
if (atLeast(1, 10, 3))
|
|
78
|
+
return { args: ['--platform', 'apple', '--project-root', appRoot], cwd: undefined }
|
|
79
|
+
if (atLeast(1, 8, 0))
|
|
80
|
+
return { args: ['--platform', 'ios', '--project-root', appRoot], cwd: undefined }
|
|
81
|
+
return { args: ['--platform', 'ios'], cwd: appRoot }
|
|
82
|
+
}
|
|
51
83
|
|
|
52
84
|
export async function collectExpoSwiftSources(
|
|
53
85
|
appRoot: string,
|
|
@@ -68,23 +100,25 @@ export async function collectExpoSwiftSources(
|
|
|
68
100
|
return null
|
|
69
101
|
throw error
|
|
70
102
|
}
|
|
103
|
+
// autolinkers before 1.0 predate app-owned Swift modules and `--project-root`.
|
|
104
|
+
const autolinkerVersion: unknown = JSON.parse(
|
|
105
|
+
fs.readFileSync(autolinker, 'utf8'),
|
|
106
|
+
).version
|
|
107
|
+
if (typeof autolinkerVersion === 'string' && Number.parseInt(autolinkerVersion, 10) < 1)
|
|
108
|
+
return null
|
|
109
|
+
const cli = path.join(path.dirname(autolinker), 'bin/expo-modules-autolinking.js')
|
|
110
|
+
const flags = autolinkerAppleFlags(autolinker, appRoot)
|
|
71
111
|
// both upstream commands discover their inputs independently. settle both
|
|
72
112
|
// before propagating errors so a failed request leaves no child running.
|
|
73
113
|
const [searched, resolvedModules] = await Promise.allSettled(
|
|
74
114
|
['search', 'resolve'].map((command) =>
|
|
75
|
-
run(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
'--project-root',
|
|
83
|
-
appRoot,
|
|
84
|
-
'--json',
|
|
85
|
-
],
|
|
86
|
-
{ timeout: 30_000, maxBuffer: 8 * 1024 * 1024, env: scriptEnv, signal },
|
|
87
|
-
),
|
|
115
|
+
run(process.execPath, [cli, command, ...flags.args, '--json'], {
|
|
116
|
+
timeout: 120_000,
|
|
117
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
118
|
+
env: scriptEnv,
|
|
119
|
+
cwd: flags.cwd,
|
|
120
|
+
signal,
|
|
121
|
+
}),
|
|
88
122
|
),
|
|
89
123
|
)
|
|
90
124
|
if (searched.status === 'rejected') throw searched.reason
|
|
@@ -226,10 +260,11 @@ export async function collectExpoSwiftSources(
|
|
|
226
260
|
}
|
|
227
261
|
walk(ios)
|
|
228
262
|
for (const module of classes) {
|
|
229
|
-
const unit = {
|
|
263
|
+
const unit: ExpoSwiftSourceUnit = {
|
|
230
264
|
namespace,
|
|
231
265
|
moduleId: `${namespace}.${module.name}`,
|
|
232
266
|
classes: [module.registration],
|
|
267
|
+
bridgeModules: [],
|
|
233
268
|
files: swiftSourceClosure(files, [module.name]),
|
|
234
269
|
frameworks: [...frameworks],
|
|
235
270
|
}
|
|
@@ -282,7 +317,12 @@ export async function collectExpoSwiftSources(
|
|
|
282
317
|
return units
|
|
283
318
|
}
|
|
284
319
|
|
|
285
|
-
|
|
320
|
+
// an app target's Swift compiles in its own language mode; Expo packages in the default
|
|
321
|
+
function expoManifest(
|
|
322
|
+
files: Record<string, string>,
|
|
323
|
+
generation = '',
|
|
324
|
+
modes: Map<string, number> = new Map(),
|
|
325
|
+
): string {
|
|
286
326
|
const targets = new Map<string, string>()
|
|
287
327
|
for (const file of Object.keys(files)) {
|
|
288
328
|
const match = /^(Sources|__standins__)\/([^/]+)\//.exec(file)
|
|
@@ -292,11 +332,29 @@ function expoManifest(files: Record<string, string>, generation = ''): string {
|
|
|
292
332
|
throw new Error(`duplicate Expo Swift compile target ${match[2]}`)
|
|
293
333
|
targets.set(match[2]!, dir)
|
|
294
334
|
}
|
|
295
|
-
return `// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: "PeachExpo", targets: [${[...targets].map(([name, dir]) => `.target(name: ${JSON.stringify(name)}, path: ${JSON.stringify(dir)}${dir.startsWith(`${SWIFT_STANDINS_DIR}/`) ? ', swiftSettings: [.swiftLanguageMode(.v5)]' : ''})`).join(', ')}, .executableTarget(name: "PeachExpoApp", path: "Sources/PeachExpoApp")])\n// stand-in inputs: ${generation}\n`
|
|
335
|
+
return `// swift-tools-version: 6.0\nimport PackageDescription\nlet package = Package(name: "PeachExpo", targets: [${[...targets].map(([name, dir]) => `.target(name: ${JSON.stringify(name)}, path: ${JSON.stringify(dir)}${dir.startsWith(`${SWIFT_STANDINS_DIR}/`) ? ', swiftSettings: [.swiftLanguageMode(.v5)]' : modes.has(name) ? `, swiftSettings: [.swiftLanguageMode(.v${modes.get(name)})]` : ''})`).join(', ')}, .executableTarget(name: "PeachExpoApp", path: "Sources/PeachExpoApp")])\n// stand-in inputs: ${generation}\n`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function languageModes(units: ExpoSwiftSourceUnit[]): Map<string, number> {
|
|
339
|
+
return new Map(
|
|
340
|
+
units.flatMap((unit) =>
|
|
341
|
+
unit.swiftLanguageMode === undefined
|
|
342
|
+
? []
|
|
343
|
+
: [[unit.namespace, unit.swiftLanguageMode]],
|
|
344
|
+
),
|
|
345
|
+
)
|
|
296
346
|
}
|
|
297
347
|
|
|
298
348
|
function expoEntry(units: ExpoSwiftSourceUnit[]): string {
|
|
299
|
-
|
|
349
|
+
// an Objective-C bridge module registers with no Swift of its own
|
|
350
|
+
const namespaces = [...new Set(units.map((unit) => unit.namespace))].filter(
|
|
351
|
+
(namespace) =>
|
|
352
|
+
units.some((unit) =>
|
|
353
|
+
Object.keys(unit.files).some((file) => file.startsWith(`Sources/${namespace}/`)),
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
const bridgeModules = units.flatMap((unit) => unit.bridgeModules)
|
|
357
|
+
return `import SwiftUI\nimport ExpoModulesCore\n${bridgeModules.length ? 'import React\n' : ''}${namespaces.map((namespace) => `import ${namespace}`).join('\n')}\n\n@main struct PeachExpoApp: PeachPackage {\n init() { MainActor.assumeIsolated { PeachExpoRuntime.register([${units.flatMap((unit) => unit.classes).join(', ')}])${bridgeModules.length ? `; RCTModuleHost.register([${bridgeModules.join(', ')}])` : ''} } }\n func view(props: JSON) -> PeachExpoNativeView { PeachExpoNativeView(props: props) }\n}\n\n@_expose(wasm, "peach_expo_call")\n@_cdecl("peach_expo_call")\n@MainActor public func peachExpoCall(_ payload: UnsafePointer<UInt8>?, _ length: Int32) -> UnsafeMutablePointer<UInt8>? {\n PeachExpoRuntime.dispatch(payload, length)\n}\n`
|
|
300
358
|
}
|
|
301
359
|
|
|
302
360
|
const compiling = new Map<
|
|
@@ -307,12 +365,41 @@ const compiling = new Map<
|
|
|
307
365
|
clients: number
|
|
308
366
|
}
|
|
309
367
|
>()
|
|
368
|
+
// identical sources already produced a wasm that is still registered on its
|
|
369
|
+
// project. a second caller otherwise relinks namespaces whose failure records
|
|
370
|
+
// did not stick, and that link blows the flow's ready bound.
|
|
371
|
+
const preparedExpoSwift = new Map<string, ExpoSwiftCompilationResult>()
|
|
372
|
+
|
|
373
|
+
function expoSwiftPreparedKey(base: string, units: ExpoSwiftSourceUnit[]): string {
|
|
374
|
+
const hash = createHash('sha256')
|
|
375
|
+
hash.update(base)
|
|
376
|
+
for (const unit of units) {
|
|
377
|
+
hash.update('\0')
|
|
378
|
+
hash.update(unit.moduleId)
|
|
379
|
+
for (const name of Object.keys(unit.files).sort()) {
|
|
380
|
+
hash.update('\0')
|
|
381
|
+
hash.update(name)
|
|
382
|
+
hash.update('\0')
|
|
383
|
+
hash.update(unit.files[name] ?? '')
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return hash.digest('hex')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// this process's copy of the app's expo sources. the build key covers the
|
|
390
|
+
// same directory, so another server compiling the app keeps its own tree.
|
|
391
|
+
function expoSwiftPackage(appKey: string, name: string): string {
|
|
392
|
+
return path.join(os.tmpdir(), 'peach-expo-swift', appKey, `p${process.pid}`, name)
|
|
393
|
+
}
|
|
394
|
+
|
|
310
395
|
class ExpoSwiftLinkRefusal extends Error {}
|
|
311
396
|
// a failed compiler result is valid only for the exact app and framework inputs.
|
|
312
|
-
// it is kept
|
|
313
|
-
// the namespace instead of relinking every namespace and
|
|
397
|
+
// it is kept at the app level, above each process's build copy, so a restarted
|
|
398
|
+
// dev server skips the namespace instead of relinking every namespace and
|
|
399
|
+
// isolating each again.
|
|
314
400
|
function failureRecordPath(packagePath: string) {
|
|
315
|
-
|
|
401
|
+
const app = path.dirname(path.dirname(packagePath))
|
|
402
|
+
return path.join(app, `${path.basename(packagePath)}.failed.json`)
|
|
316
403
|
}
|
|
317
404
|
|
|
318
405
|
function readFailedCompilation(packagePath: string) {
|
|
@@ -350,7 +437,9 @@ export async function compileExpoSwiftModules(
|
|
|
350
437
|
) => Promise<void>,
|
|
351
438
|
options: ExpoSwiftPreparationOptions = {},
|
|
352
439
|
): Promise<ExpoSwiftCompilationResult | null> {
|
|
353
|
-
|
|
440
|
+
// a cold isolation compiles the sdk once per module. one module ran past
|
|
441
|
+
// six minutes, and the readiness wait allows thirty, so the compile does too.
|
|
442
|
+
const timeoutMs = options.timeoutMs ?? 30 * 60_000
|
|
354
443
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
355
444
|
throw new Error('Expo Swift preparation timeout must be between 1 and 2147483647ms')
|
|
356
445
|
const controller = new AbortController()
|
|
@@ -367,9 +456,20 @@ export async function compileExpoSwiftModules(
|
|
|
367
456
|
try {
|
|
368
457
|
signal.throwIfAborted()
|
|
369
458
|
report(`discovering app-owned modules (budget ${timeoutMs}ms): ${root}`)
|
|
370
|
-
const
|
|
459
|
+
const expoUnits = await collectExpoSwiftSources(root, signal)
|
|
371
460
|
signal.throwIfAborted()
|
|
372
|
-
|
|
461
|
+
const bridge = collectBridgeModuleSources(fs.realpathSync(root), signal)
|
|
462
|
+
for (const unit of bridge?.units ?? []) {
|
|
463
|
+
if (expoUnits?.some((expo) => expo.namespace === unit.namespace))
|
|
464
|
+
throw new Error(
|
|
465
|
+
`app target ${unit.namespace} shares its Swift module name with a local Expo module`,
|
|
466
|
+
)
|
|
467
|
+
unit.files[EXPO_SWIFT_ENTRY] = expoEntry([unit])
|
|
468
|
+
unit.files['Package.swift'] = expoManifest(unit.files, '', languageModes([unit]))
|
|
469
|
+
}
|
|
470
|
+
const units = [...(expoUnits ?? []), ...(bridge?.units ?? [])]
|
|
471
|
+
const bridgeFailures = bridge?.failures ?? []
|
|
472
|
+
if (units.length === 0 && bridgeFailures.length === 0) return null
|
|
373
473
|
report(
|
|
374
474
|
`preparing ${units.length} modules: ${units.map((unit) => unit.moduleId).join(', ')}`,
|
|
375
475
|
)
|
|
@@ -380,9 +480,16 @@ export async function compileExpoSwiftModules(
|
|
|
380
480
|
if (!module && publishArtifact)
|
|
381
481
|
throw new Error('No local Swift toolchain or distribution is available')
|
|
382
482
|
const result: ExpoSwiftCompilationResult = {
|
|
383
|
-
namespaces:
|
|
483
|
+
namespaces: [
|
|
484
|
+
...units.map((unit) => unit.moduleId),
|
|
485
|
+
...bridgeFailures.map((failure) => failure.namespace),
|
|
486
|
+
],
|
|
487
|
+
bridgeModules: [
|
|
488
|
+
...(bridge?.units ?? []).map((unit) => unit.moduleId),
|
|
489
|
+
...bridgeFailures.map((failure) => failure.namespace),
|
|
490
|
+
],
|
|
384
491
|
modules: [],
|
|
385
|
-
failures: [],
|
|
492
|
+
failures: [...bridgeFailures],
|
|
386
493
|
}
|
|
387
494
|
if (!module) {
|
|
388
495
|
for (const unit of units)
|
|
@@ -475,10 +582,14 @@ export async function compileExpoSwiftModules(
|
|
|
475
582
|
unit,
|
|
476
583
|
[...built.gaps].map(([name, reason]) => `${name}: no stand-in: ${reason}`),
|
|
477
584
|
)
|
|
478
|
-
unit.files['Package.swift'] = expoManifest(
|
|
585
|
+
unit.files['Package.swift'] = expoManifest(
|
|
586
|
+
unit.files,
|
|
587
|
+
generation,
|
|
588
|
+
languageModes([unit]),
|
|
589
|
+
)
|
|
479
590
|
}
|
|
480
591
|
}
|
|
481
|
-
const packagePath =
|
|
592
|
+
const packagePath = expoSwiftPackage(key, unit.moduleId)
|
|
482
593
|
// preserve the compiler's input stamp across unchanged reloads.
|
|
483
594
|
// only this adapter's build copy is synchronized; app sources are read only.
|
|
484
595
|
syncSwiftTree(
|
|
@@ -491,7 +602,7 @@ export async function compileExpoSwiftModules(
|
|
|
491
602
|
),
|
|
492
603
|
)
|
|
493
604
|
projectFor(packagePath).libraries = libraries.get(unit)
|
|
494
|
-
const stamp =
|
|
605
|
+
const stamp = sourceContentStamp(packagePath, module.root)
|
|
495
606
|
const failed = readFailedCompilation(packagePath)
|
|
496
607
|
if (failed?.stamp === stamp) {
|
|
497
608
|
failures.set(unit.moduleId, failed.diagnostic)
|
|
@@ -501,6 +612,14 @@ export async function compileExpoSwiftModules(
|
|
|
501
612
|
stamps.set(unit, stamp)
|
|
502
613
|
candidates.push(unit)
|
|
503
614
|
}
|
|
615
|
+
const preparedKey = expoSwiftPreparedKey(base, units)
|
|
616
|
+
if (!publishArtifact) {
|
|
617
|
+
const prepared = preparedExpoSwift.get(preparedKey)
|
|
618
|
+
if (prepared) {
|
|
619
|
+
report('reusing prepared expo swift artifact')
|
|
620
|
+
return prepared
|
|
621
|
+
}
|
|
622
|
+
}
|
|
504
623
|
const link = async (linked: ExpoSwiftSourceUnit[]) => {
|
|
505
624
|
const files: Record<string, string> = {}
|
|
506
625
|
for (const unit of linked)
|
|
@@ -517,8 +636,9 @@ export async function compileExpoSwiftModules(
|
|
|
517
636
|
files['Package.swift'] = expoManifest(
|
|
518
637
|
files,
|
|
519
638
|
linked.map((unit) => generations.get(unit) ?? '').join(','),
|
|
639
|
+
languageModes(linked),
|
|
520
640
|
)
|
|
521
|
-
const packagePath =
|
|
641
|
+
const packagePath = expoSwiftPackage(key, 'linked-app')
|
|
522
642
|
syncSwiftTree(
|
|
523
643
|
packagePath,
|
|
524
644
|
new Map(
|
|
@@ -538,7 +658,7 @@ export async function compileExpoSwiftModules(
|
|
|
538
658
|
projectFor(packagePath).libraries =
|
|
539
659
|
linkedLibraries.size > 0 ? Object.fromEntries(linkedLibraries) : undefined
|
|
540
660
|
signal.throwIfAborted()
|
|
541
|
-
const stamp =
|
|
661
|
+
const stamp = sourceContentStamp(packagePath, module.root)
|
|
542
662
|
const refused = readFailedCompilation(packagePath)
|
|
543
663
|
if (refused?.stamp === stamp) {
|
|
544
664
|
report('reusing recent link refusal')
|
|
@@ -563,7 +683,7 @@ export async function compileExpoSwiftModules(
|
|
|
563
683
|
throw error
|
|
564
684
|
// admission and interrupted builds say nothing about individual modules.
|
|
565
685
|
// share their result briefly, including across dev server restarts.
|
|
566
|
-
if (
|
|
686
|
+
if (sourceContentStamp(packagePath, module.root) === stamp)
|
|
567
687
|
failureRecords.set(packagePath, {
|
|
568
688
|
stamp,
|
|
569
689
|
diagnostic,
|
|
@@ -587,12 +707,7 @@ export async function compileExpoSwiftModules(
|
|
|
587
707
|
report('linked build failed; isolating module diagnostics')
|
|
588
708
|
linked = []
|
|
589
709
|
for (const unit of candidates) {
|
|
590
|
-
const packagePath =
|
|
591
|
-
os.tmpdir(),
|
|
592
|
-
'peach-expo-swift',
|
|
593
|
-
key,
|
|
594
|
-
unit.moduleId,
|
|
595
|
-
)
|
|
710
|
+
const packagePath = expoSwiftPackage(key, unit.moduleId)
|
|
596
711
|
try {
|
|
597
712
|
signal.throwIfAborted()
|
|
598
713
|
report(`checking ${unit.moduleId}`)
|
|
@@ -630,13 +745,14 @@ export async function compileExpoSwiftModules(
|
|
|
630
745
|
)
|
|
631
746
|
throw error
|
|
632
747
|
// a source error or a module peach does not provide holds until the
|
|
633
|
-
// stamp changes
|
|
634
|
-
//
|
|
748
|
+
// stamp changes. the compiler also reports the same error at the
|
|
749
|
+
// build copy, so the app lines are enough to hold it. retrying
|
|
750
|
+
// either relinks every namespace and rewrites the linked package,
|
|
751
|
+
// so no reload would ever reuse its artifact.
|
|
635
752
|
const missing = error instanceof SwiftMissingModulesError
|
|
636
753
|
if (
|
|
637
|
-
(
|
|
638
|
-
|
|
639
|
-
sourceStamp(packagePath, module.root) === stamps.get(unit)
|
|
754
|
+
(missing || sourceErrors.length > 0) &&
|
|
755
|
+
sourceContentStamp(packagePath, module.root) === stamps.get(unit)
|
|
640
756
|
) {
|
|
641
757
|
failureRecords.set(packagePath, {
|
|
642
758
|
stamp: stamps.get(unit)!,
|
|
@@ -651,7 +767,29 @@ export async function compileExpoSwiftModules(
|
|
|
651
767
|
}
|
|
652
768
|
// every namespace compiles alone, so the link itself is what failed.
|
|
653
769
|
if (linked.length === candidates.length) throw linkError
|
|
654
|
-
if (linked.length)
|
|
770
|
+
if (linked.length) {
|
|
771
|
+
try {
|
|
772
|
+
artifact = await link(linked)
|
|
773
|
+
} catch (error) {
|
|
774
|
+
signal.throwIfAborted()
|
|
775
|
+
if (!(error instanceof SwiftMissingModulesError)) throw error
|
|
776
|
+
// the merged sources import a module none of the pieces needed
|
|
777
|
+
// alone. drop the pieces that import it and link the rest; a
|
|
778
|
+
// miss that none of them import is still a failed link.
|
|
779
|
+
const missing = new Set(
|
|
780
|
+
[...error.message.matchAll(/^ (\S+)/gm)].map((match) => match[1]),
|
|
781
|
+
)
|
|
782
|
+
const dropped = linked.filter((unit) =>
|
|
783
|
+
Object.values(unit.files).some((source) =>
|
|
784
|
+
swiftImportNames(source).some((name) => missing.has(name)),
|
|
785
|
+
),
|
|
786
|
+
)
|
|
787
|
+
if (dropped.length === 0) throw error
|
|
788
|
+
for (const unit of dropped) failures.set(unit.moduleId, error.message)
|
|
789
|
+
linked = linked.filter((unit) => !dropped.includes(unit))
|
|
790
|
+
if (linked.length) artifact = await link(linked)
|
|
791
|
+
}
|
|
792
|
+
}
|
|
655
793
|
}
|
|
656
794
|
}
|
|
657
795
|
}
|
|
@@ -677,6 +815,7 @@ export async function compileExpoSwiftModules(
|
|
|
677
815
|
report(
|
|
678
816
|
`ready: ${result.modules.length} artifact, ${result.failures.length} unavailable modules`,
|
|
679
817
|
)
|
|
818
|
+
if (!publishArtifact) preparedExpoSwift.set(preparedKey, result)
|
|
680
819
|
return result
|
|
681
820
|
} catch (error) {
|
|
682
821
|
const failure: unknown = signal.aborted ? signal.reason : error
|
|
@@ -746,6 +885,7 @@ export async function handleExpoSwiftRequest(
|
|
|
746
885
|
const controller = new AbortController()
|
|
747
886
|
const promise = compileExpoSwiftModules(key, `http://${host}`, undefined, {
|
|
748
887
|
signal: controller.signal,
|
|
888
|
+
timeoutMs: EXPO_SWIFT_REQUEST_TIMEOUT_MS,
|
|
749
889
|
}).finally(() => {
|
|
750
890
|
if (compiling.get(flightKey)?.controller === controller) compiling.delete(flightKey)
|
|
751
891
|
})
|
|
@@ -758,9 +898,13 @@ export async function handleExpoSwiftRequest(
|
|
|
758
898
|
const release = () => {
|
|
759
899
|
if (released) return
|
|
760
900
|
released = true
|
|
761
|
-
if (--current.clients
|
|
762
|
-
|
|
763
|
-
|
|
901
|
+
if (--current.clients > 0) return
|
|
902
|
+
const flight = current
|
|
903
|
+
setTimeout(() => {
|
|
904
|
+
if (flight.clients > 0 || flight.controller.signal.aborted) return
|
|
905
|
+
if (compiling.get(flightKey)?.controller !== flight.controller) return
|
|
906
|
+
flight.controller.abort(new Error('Expo Swift preparation client disconnected'))
|
|
907
|
+
}, EXPO_SWIFT_CLIENT_GAP_MS)
|
|
764
908
|
}
|
|
765
909
|
res.once('close', release)
|
|
766
910
|
try {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// an app with local Expo Swift modules only renders once the bridge compiles
|
|
2
|
+
// them, and a cold /__peach/expo-swift artifact compiles for minutes (a linked
|
|
3
|
+
// build plus per-namespace isolation builds in sequence) while a flow waits
|
|
4
|
+
// 120s for its first tree. so `peach open` GETs the same endpoint the engine
|
|
5
|
+
// will fetch and returns only once it answers; the bridge shares one compile
|
|
6
|
+
// per root, so the engine's own fetch then finds it done. a warm artifact
|
|
7
|
+
// answers in ~1s and an app without local modules answers null fast.
|
|
8
|
+
export const EXPO_SWIFT_READY_TIMEOUT_MS = 30 * 60_000
|
|
9
|
+
const EXPO_SWIFT_READY_PROGRESS_MS = 30_000
|
|
10
|
+
|
|
11
|
+
export type ExpoSwiftReadyOptions = {
|
|
12
|
+
timeoutMs?: number
|
|
13
|
+
progressMs?: number
|
|
14
|
+
signal?: AbortSignal
|
|
15
|
+
onProgress?: (elapsedMs: number) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function awaitExpoSwiftReady(
|
|
19
|
+
endpointUrl: string,
|
|
20
|
+
options: ExpoSwiftReadyOptions = {},
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const timeoutMs = options.timeoutMs ?? EXPO_SWIFT_READY_TIMEOUT_MS
|
|
23
|
+
const progressMs = options.progressMs ?? EXPO_SWIFT_READY_PROGRESS_MS
|
|
24
|
+
const start = Date.now()
|
|
25
|
+
const report =
|
|
26
|
+
options.onProgress ??
|
|
27
|
+
((elapsedMs: number) => {
|
|
28
|
+
console.log(
|
|
29
|
+
` still waiting on ${endpointUrl} after ${Math.round(elapsedMs / 1000)}s — expo swift compile in progress`,
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
const progress = setInterval(() => report(Date.now() - start), progressMs)
|
|
33
|
+
try {
|
|
34
|
+
options.signal?.throwIfAborted()
|
|
35
|
+
const timeout = AbortSignal.timeout(timeoutMs)
|
|
36
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout
|
|
37
|
+
let res: Response | undefined
|
|
38
|
+
while (!res) {
|
|
39
|
+
try {
|
|
40
|
+
// bun ends an unanswered fetch at six minutes. that close is the last
|
|
41
|
+
// client, so the bridge aborts the shared compile and the retry starts
|
|
42
|
+
// over. hold the socket for the caller's bound instead.
|
|
43
|
+
const init: RequestInit = { signal }
|
|
44
|
+
Object.assign(init, { timeout: false })
|
|
45
|
+
res = await fetch(endpointUrl, init)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (options.signal?.aborted) throw error
|
|
48
|
+
if (timeout.aborted) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`timed out after ${timeoutMs}ms waiting for ${endpointUrl} — the expo swift compile did not answer within the bound`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
// a dropped socket still rejoins the same compile when the bridge has
|
|
54
|
+
// another client. retry only that case.
|
|
55
|
+
if (error instanceof Error && error.name === 'TimeoutError') continue
|
|
56
|
+
throw new Error(`could not reach ${endpointUrl}`, { cause: error })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const body = (await res.text()).trim().slice(0, 500)
|
|
61
|
+
throw new Error(`${endpointUrl} answered ${res.status}${body ? `: ${body}` : ''}`)
|
|
62
|
+
}
|
|
63
|
+
await res.text()
|
|
64
|
+
console.log(
|
|
65
|
+
` expo swift modules ready in ${((Date.now() - start) / 1000).toFixed(1)}s`,
|
|
66
|
+
)
|
|
67
|
+
} finally {
|
|
68
|
+
clearInterval(progress)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// the expoSwift endpoint a built shell url carries, or null for an app with no
|
|
73
|
+
// project root
|
|
74
|
+
export async function awaitStampedExpoSwiftReady(shellUrl: string): Promise<void> {
|
|
75
|
+
const endpoint = new URL(shellUrl).searchParams.get('expoSwift')
|
|
76
|
+
if (!endpoint) return
|
|
77
|
+
await awaitExpoSwiftReady(endpoint)
|
|
78
|
+
}
|
package/src/expoSwiftContract.ts
CHANGED
|
@@ -9,8 +9,12 @@ export interface ExpoSwiftSourceUnit {
|
|
|
9
9
|
namespace: string
|
|
10
10
|
moduleId: string
|
|
11
11
|
classes: string[]
|
|
12
|
+
// legacy bridge module registrations (react-native-bridge-modules.ts)
|
|
13
|
+
bridgeModules: string[]
|
|
12
14
|
files: Record<string, string>
|
|
13
15
|
frameworks: string[]
|
|
16
|
+
// the app target's Swift language mode; Expo packages compile in the default
|
|
17
|
+
swiftLanguageMode?: number
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export interface ExpoSwiftCompiledModule {
|
|
@@ -23,6 +27,8 @@ export interface ExpoSwiftCompiledModule {
|
|
|
23
27
|
|
|
24
28
|
export interface ExpoSwiftCompilationResult {
|
|
25
29
|
namespaces: string[]
|
|
30
|
+
// the namespaces that are legacy bridge modules rather than Expo modules
|
|
31
|
+
bridgeModules?: string[]
|
|
26
32
|
modules: ExpoSwiftCompiledModule[]
|
|
27
33
|
failures: { namespace: string; diagnostic: string }[]
|
|
28
34
|
}
|
|
@@ -47,6 +53,13 @@ export function isExpoSwiftCompilationResult(
|
|
|
47
53
|
return false
|
|
48
54
|
const remaining = new Set(value.namespaces)
|
|
49
55
|
if (remaining.size !== value.namespaces.length) return false
|
|
56
|
+
// bundles published before legacy bridge modules carry no list
|
|
57
|
+
if (
|
|
58
|
+
value.bridgeModules !== undefined &&
|
|
59
|
+
(!strings(value.bridgeModules) ||
|
|
60
|
+
!value.bridgeModules.every((name) => remaining.has(name)))
|
|
61
|
+
)
|
|
62
|
+
return false
|
|
50
63
|
for (const artifact of value.modules) {
|
|
51
64
|
if (
|
|
52
65
|
!record(artifact) ||
|
package/src/home-paths.ts
CHANGED
|
@@ -185,6 +185,15 @@ export function cacheDir(): string {
|
|
|
185
185
|
return path.join(rnxHomeDir(), 'cache')
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// swiftpm otherwise uses shared caches and configuration outside PEACH_HOME.
|
|
189
|
+
export function swiftPackageCacheOptions(): string[] {
|
|
190
|
+
const root = path.join(cacheDir(), 'swiftpm')
|
|
191
|
+
return ['cache', 'config', 'security'].flatMap((name) => [
|
|
192
|
+
`--${name}-path`,
|
|
193
|
+
path.join(root, name),
|
|
194
|
+
])
|
|
195
|
+
}
|
|
196
|
+
|
|
188
197
|
export function daemonLockfilePath(): string {
|
|
189
198
|
return path.join(rnxHomeDir(), DAEMON_LOCKFILE)
|
|
190
199
|
}
|
|
@@ -261,7 +270,10 @@ export function writeSharedConfig(patch: Partial<SharedConfig>): SharedConfig {
|
|
|
261
270
|
return next
|
|
262
271
|
}
|
|
263
272
|
|
|
264
|
-
|
|
273
|
+
// version 2 made the native app the default simulator window. version 1 setup
|
|
274
|
+
// saved the browser without asking on a Mac lacking the app, so its saved
|
|
275
|
+
// driver is not read back and those machines run setup again.
|
|
276
|
+
const CURRENT_ONBOARDING_VERSION = 2
|
|
265
277
|
|
|
266
278
|
export type SimulatorDriverPreference = DriverId
|
|
267
279
|
|
|
@@ -270,7 +282,9 @@ export function isOnboardingComplete(): boolean {
|
|
|
270
282
|
}
|
|
271
283
|
|
|
272
284
|
export function readSimulatorDriverPreference(): SimulatorDriverPreference | null {
|
|
273
|
-
const
|
|
285
|
+
const config = readSharedConfig()
|
|
286
|
+
if (config.onboardingVersion !== CURRENT_ONBOARDING_VERSION) return null
|
|
287
|
+
const driver = config.simulatorDriver
|
|
274
288
|
return driver === 'desktop' || driver === 'electron' || driver === 'playwright'
|
|
275
289
|
? driver
|
|
276
290
|
: null
|
|
@@ -634,6 +648,38 @@ export function isDevBridgeLockfileFresh(
|
|
|
634
648
|
}
|
|
635
649
|
}
|
|
636
650
|
|
|
651
|
+
function hostBridgePortNumber(value: number | null | undefined): number | null {
|
|
652
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535)
|
|
653
|
+
return null
|
|
654
|
+
return value
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** port a desktop host's page should register on.
|
|
658
|
+
*
|
|
659
|
+
* the launch url's wsPort pin wins. an ambient PEACH_BRIDGE_PORT wins only
|
|
660
|
+
* when this home has no fresh daemon, the daemon already bound that port, or
|
|
661
|
+
* a fresh dev-bridge lockfile in this home claims it. otherwise the fresh
|
|
662
|
+
* daemon lock wins, so a second PEACH_HOME is not pulled onto another world's
|
|
663
|
+
* bridge. the mac user script and the native seed use the same rule. */
|
|
664
|
+
export function desktopHostBridgePort(input: {
|
|
665
|
+
pinnedPort?: number | null
|
|
666
|
+
daemonPort?: number | null
|
|
667
|
+
envPort?: number | null
|
|
668
|
+
devBridgePorts?: readonly number[]
|
|
669
|
+
}): number | null {
|
|
670
|
+
const pinned = hostBridgePortNumber(input.pinnedPort)
|
|
671
|
+
if (pinned != null) return pinned
|
|
672
|
+
const daemon = hostBridgePortNumber(input.daemonPort)
|
|
673
|
+
const env = hostBridgePortNumber(input.envPort)
|
|
674
|
+
const claimed = new Set(
|
|
675
|
+
(input.devBridgePorts ?? [])
|
|
676
|
+
.map((port) => hostBridgePortNumber(port))
|
|
677
|
+
.filter((port): port is number => port != null),
|
|
678
|
+
)
|
|
679
|
+
if (env != null && (daemon == null || env === daemon || claimed.has(env))) return env
|
|
680
|
+
return daemon
|
|
681
|
+
}
|
|
682
|
+
|
|
637
683
|
export function readLiveRuntimeVersions(): string[] {
|
|
638
684
|
const versions = new Set<string>()
|
|
639
685
|
const daemon = readDaemonLockfile()
|
package/src/host/bridge-host.ts
CHANGED
|
@@ -543,10 +543,13 @@ export class SootSimBridgeHost {
|
|
|
543
543
|
if (this.httpServer || this.wss) return this.effectivePort
|
|
544
544
|
|
|
545
545
|
// an installed runtime can serve while the startup update runs. only a
|
|
546
|
-
// machine without usable assets needs to wait for that download.
|
|
546
|
+
// machine without usable assets needs to wait for that download. only the
|
|
547
|
+
// daemon's runtime updater ever clears the flag, so a dev bridge (which
|
|
548
|
+
// serves the checkout build and runs no updater) never sets it.
|
|
547
549
|
const bootRuntimeDir = this.getActiveRuntime().runtimeDir
|
|
548
550
|
this.bootstrapping =
|
|
549
|
-
|
|
551
|
+
this.shouldWriteLockfile &&
|
|
552
|
+
(!bootRuntimeDir || !fs.existsSync(path.join(bootRuntimeDir, 'index.html')))
|
|
550
553
|
|
|
551
554
|
for (let attempt = 0; attempt < this.portFallbackCount; attempt++) {
|
|
552
555
|
const candidate = this.preferredPort === 0 ? 0 : this.preferredPort + attempt
|