peachsim 0.1.1625 → 0.1.1640

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
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
- const CURRENT_ONBOARDING_VERSION = 1
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 driver = readSharedConfig().simulatorDriver
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()
@@ -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
- !bootRuntimeDir || !fs.existsSync(path.join(bootRuntimeDir, 'index.html'))
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
@@ -95,6 +95,15 @@ export async function handleStagedHostFileRequest(
95
95
  res.end('staged file not found')
96
96
  return
97
97
  }
98
+ if (req.method === 'OPTIONS') {
99
+ res.writeHead(204, {
100
+ 'Access-Control-Allow-Methods': 'GET, HEAD',
101
+ 'Access-Control-Allow-Headers':
102
+ req.headers['access-control-request-headers'] ?? '*',
103
+ })
104
+ res.end()
105
+ return
106
+ }
98
107
  if (req.method !== 'GET' && req.method !== 'HEAD') {
99
108
  res.writeHead(405, { Allow: 'GET, HEAD' })
100
109
  res.end('method not allowed')
@@ -2,10 +2,12 @@ import fs from 'node:fs'
2
2
  import { createRequire } from 'node:module'
3
3
  import path from 'node:path'
4
4
  import { androidMergedResourceValuesDirectory } from './android-app-identity'
5
+ import { isRecord } from './is-record'
5
6
  import {
6
7
  extractNativeAppIdentityFromFiles,
7
8
  type NativeAppIdentity,
8
9
  readRootModuleName,
10
+ readGoogleSignInConfiguration,
9
11
  selectNativeAppIdentityReader,
10
12
  } from './native-app-identity'
11
13
  import { resolveNativeAppIconArtwork } from './nativeAppIconContract'
@@ -16,10 +18,6 @@ import {
16
18
  type XcodeAppTarget,
17
19
  } from './xcode-project'
18
20
 
19
- function isRecord(value: unknown): value is Record<string, unknown> {
20
- return typeof value === 'object' && value !== null && !Array.isArray(value)
21
- }
22
-
23
21
  function nonEmptyString(value: unknown): string | undefined {
24
22
  return typeof value === 'string' && value.length > 0 ? value : undefined
25
23
  }
@@ -90,6 +88,11 @@ export function readNativeAppIdentityFromXcodeTarget(
90
88
  permissionUsageDescriptions[key] = value
91
89
  }
92
90
 
91
+ const infoPlistStrings: Record<string, string> = {}
92
+ for (const [key, value] of Object.entries(dictionary)) {
93
+ if (typeof value === 'string') infoPlistStrings[key] = value
94
+ }
95
+
93
96
  const querySchemes = dictionary.LSApplicationQueriesSchemes
94
97
  const urlQuerySchemes = Array.isArray(querySchemes) ? querySchemes : []
95
98
 
@@ -146,6 +149,15 @@ export function readNativeAppIdentityFromXcodeTarget(
146
149
  ...(rootModuleName ? { rootModuleName } : {}),
147
150
  ...(rootViewBackgroundColor !== undefined ? { rootViewBackgroundColor } : {}),
148
151
  ...(designRequiresCompatibility ? { designRequiresCompatibility } : {}),
152
+ ...(typeof dictionary.UIStatusBarHidden === 'boolean'
153
+ ? { statusBarHidden: dictionary.UIStatusBarHidden }
154
+ : {}),
155
+ ...(typeof dictionary.UIViewControllerBasedStatusBarAppearance === 'boolean'
156
+ ? {
157
+ viewControllerBasedStatusBarAppearance:
158
+ dictionary.UIViewControllerBasedStatusBarAppearance,
159
+ }
160
+ : {}),
149
161
  ...(typeof dictionary.DTPlatformVersion === 'string'
150
162
  ? { dtPlatformVersion: dictionary.DTPlatformVersion }
151
163
  : {}),
@@ -154,6 +166,14 @@ export function readNativeAppIdentityFromXcodeTarget(
154
166
  buildVersion,
155
167
  bundleIdentifier,
156
168
  platform: 'ios',
169
+ googleSignIn: readGoogleSignInConfiguration(
170
+ dictionary,
171
+ Object.fromEntries(
172
+ app.resources
173
+ .filter((file) => file.endsWith('.plist'))
174
+ .map((file) => [file, parseInfoPlist(fs.readFileSync(file, 'utf8'))]),
175
+ ),
176
+ ),
157
177
  supportedInterfaceOrientations,
158
178
  ipadSupportedInterfaceOrientations,
159
179
  ...(defaultScreenOrientationMask ? { defaultScreenOrientationMask } : {}),
@@ -177,6 +197,7 @@ export function readNativeAppIdentityFromXcodeTarget(
177
197
  (value) => value,
178
198
  ),
179
199
  permissionUsageDescriptions,
200
+ infoPlistStrings,
180
201
  urlQuerySchemes,
181
202
  ...(shareIntoAppGroupId ? { shareIntoAppGroupId } : {}),
182
203
  ...(rtcAppGroupIdentifier ? { rtcAppGroupIdentifier } : {}),
@@ -364,8 +385,8 @@ function readIosNativeAppIdentity(
364
385
  ? nonEmptyString(installedManifest.version)
365
386
  : undefined
366
387
  if (!installedVersion) throw new Error('Reanimated package version is malformed')
367
- // reanimated 3's native build has no static feature flag schema.
368
- if (installedVersion.startsWith('3.')) return identity
388
+ // the static feature flag schema starts with reanimated 4.
389
+ if (Number.parseInt(installedVersion, 10) < 4) return identity
369
390
  const defaults: unknown = JSON.parse(
370
391
  fs.readFileSync(
371
392
  path.join(
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { nativeAppIdentityFromAndroidManifest } from './android-app-identity'
12
12
  import { findBundleFooter } from './bundleGlobalFooter'
13
+ import { isRecord } from './is-record'
13
14
  import {
14
15
  resolveNativeAppIconArtwork,
15
16
  isNativeAppIconArtwork,
@@ -48,6 +49,9 @@ export interface NativeAppIdentity {
48
49
  // UIDesignRequiresCompatibility: UIKit draws its pre-liquid-glass chrome.
49
50
  // carried only when the plist sets it true.
50
51
  designRequiresCompatibility?: true
52
+ // initial UIKit visibility and the owner of subsequent status bar preferences.
53
+ statusBarHidden?: boolean
54
+ viewControllerBasedStatusBarAppearance?: boolean
51
55
  // the literal DTPlatformVersion stamp when the app plist supplies one.
52
56
  dtPlatformVersion?: string
53
57
  // CFBundleName, which CFNetwork stamps into the default User-Agent and iOS's
@@ -62,6 +66,10 @@ export interface NativeAppIdentity {
62
66
  // SDK filtering belongs to the evaluation's device, not the bundle builder.
63
67
  androidManifestPermissions?: readonly AndroidManifestPermission[]
64
68
  permissionUsageDescriptions?: Readonly<Record<string, unknown>>
69
+ // every top-level string the app's Info.plist declares, build settings
70
+ // resolved, for native code that reads its own plist keys.
71
+ infoPlistStrings?: Readonly<Record<string, string>>
72
+ googleSignIn?: Readonly<Record<string, unknown>>
65
73
  urlQuerySchemes?: readonly unknown[]
66
74
  appIntentIdentifiers?: readonly string[]
67
75
  backgroundTaskIdentifiers?: readonly string[]
@@ -92,16 +100,15 @@ interface NativeAppIdentityGlobals {
92
100
  __sootsim_native_app_identity?: NativeAppIdentity | null
93
101
  }
94
102
 
95
- function isRecord(value: unknown): value is Record<string, unknown> {
96
- return typeof value === 'object' && value !== null && !Array.isArray(value)
97
- }
98
-
99
103
  function nonEmptyString(value: unknown): string | null {
100
104
  return typeof value === 'string' && value.length > 0 ? value : null
101
105
  }
102
106
 
103
107
  export function isNativeAppIdentity(value: unknown): value is NativeAppIdentity {
104
108
  if (!isRecord(value)) return false
109
+ for (const key of ['statusBarHidden', 'viewControllerBasedStatusBarAppearance']) {
110
+ if (value[key] !== undefined && typeof value[key] !== 'boolean') return false
111
+ }
105
112
  const oneUpdates = value.oneUpdates
106
113
  if (
107
114
  oneUpdates !== undefined &&
@@ -143,6 +150,7 @@ export function isNativeAppIdentity(value: unknown): value is NativeAppIdentity
143
150
  ))
144
151
  )
145
152
  return false
153
+ if (value.googleSignIn !== undefined && !isRecord(value.googleSignIn)) return false
146
154
  const descriptions = value.permissionUsageDescriptions
147
155
  if (
148
156
  value.reanimatedStaticFeatureFlags !== undefined &&
@@ -153,6 +161,12 @@ export function isNativeAppIdentity(value: unknown): value is NativeAppIdentity
153
161
  )
154
162
  return false
155
163
  if (descriptions !== undefined && !isRecord(descriptions)) return false
164
+ if (
165
+ value.infoPlistStrings !== undefined &&
166
+ (!isRecord(value.infoPlistStrings) ||
167
+ !Object.values(value.infoPlistStrings).every((entry) => typeof entry === 'string'))
168
+ )
169
+ return false
156
170
  if (value.urlQuerySchemes !== undefined && !Array.isArray(value.urlQuerySchemes))
157
171
  return false
158
172
  if (
@@ -267,6 +281,15 @@ export function readNativeAppIdentity(value: unknown): NativeAppIdentity | null
267
281
  ...(value.designRequiresCompatibility === undefined
268
282
  ? {}
269
283
  : { designRequiresCompatibility: value.designRequiresCompatibility }),
284
+ ...(value.statusBarHidden === undefined
285
+ ? {}
286
+ : { statusBarHidden: value.statusBarHidden }),
287
+ ...(value.viewControllerBasedStatusBarAppearance === undefined
288
+ ? {}
289
+ : {
290
+ viewControllerBasedStatusBarAppearance:
291
+ value.viewControllerBasedStatusBarAppearance,
292
+ }),
270
293
  ...(value.dtPlatformVersion === undefined
271
294
  ? {}
272
295
  : { dtPlatformVersion: value.dtPlatformVersion }),
@@ -291,11 +314,17 @@ export function readNativeAppIdentity(value: unknown): NativeAppIdentity | null
291
314
  (permission) => ({ ...permission }),
292
315
  ),
293
316
  }),
317
+ ...(value.googleSignIn === undefined
318
+ ? {}
319
+ : { googleSignIn: structuredClone(value.googleSignIn) }),
294
320
  ...(value.permissionUsageDescriptions === undefined
295
321
  ? {}
296
322
  : {
297
323
  permissionUsageDescriptions: { ...value.permissionUsageDescriptions },
298
324
  }),
325
+ ...(value.infoPlistStrings === undefined
326
+ ? {}
327
+ : { infoPlistStrings: { ...value.infoPlistStrings } }),
299
328
  ...(value.appIntentIdentifiers === undefined
300
329
  ? {}
301
330
  : { appIntentIdentifiers: [...value.appIntentIdentifiers] }),
@@ -496,6 +525,39 @@ export function insertNativeAppIdentityFooter(
496
525
 
497
526
  // ── native project extraction ────────────────────────────────────────────────
498
527
 
528
+ // Info.plist and bundled service plists are native configuration, independent of Firebase initialization.
529
+ export function readGoogleSignInConfiguration(
530
+ plist: Record<string, unknown>,
531
+ resources: Readonly<Record<string, unknown>>,
532
+ ): Record<string, unknown> {
533
+ const clientIds: Record<string, string> = {}
534
+ for (const [file, value] of Object.entries(resources)) {
535
+ if (isRecord(value) && typeof value.CLIENT_ID === 'string')
536
+ clientIds[
537
+ file
538
+ .split('/')
539
+ .pop()
540
+ ?.replace(/\.plist$/, '') ?? file
541
+ ] = value.CLIENT_ID
542
+ }
543
+ const urlSchemes: string[] = []
544
+ for (const entry of Array.isArray(plist.CFBundleURLTypes)
545
+ ? plist.CFBundleURLTypes
546
+ : []) {
547
+ if (!isRecord(entry) || !Array.isArray(entry.CFBundleURLSchemes)) continue
548
+ for (const scheme of entry.CFBundleURLSchemes)
549
+ if (typeof scheme === 'string') urlSchemes.push(scheme)
550
+ }
551
+ return {
552
+ clientId: plist.GIDClientID,
553
+ serverClientId: plist.GIDServerClientID,
554
+ hostedDomain: plist.GIDHostedDomain,
555
+ openIDRealm: plist.GIDOpenIDRealm,
556
+ urlSchemes,
557
+ clientIds,
558
+ }
559
+ }
560
+
499
561
  function plistString(plist: string, key: string): string | null {
500
562
  const match = plist.match(new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`))
501
563
  return match ? match[1].trim() : null
@@ -952,6 +1014,13 @@ function nativeAppIdentityFromIos(
952
1014
  permissionUsageDescriptions[key] = resolved
953
1015
  }
954
1016
 
1017
+ const infoPlistStrings: Record<string, string> = {}
1018
+ for (const [key, value] of Object.entries(dictionary)) {
1019
+ if (typeof value !== 'string') continue
1020
+ const resolved = resolveBuildSetting(value, settings)
1021
+ if (resolved !== null) infoPlistStrings[key] = resolved
1022
+ }
1023
+
955
1024
  // Xcode's generated Info.plist uses these build settings only when enabled.
956
1025
  // source: developer.apple.com/documentation/xcode/build-settings-reference.
957
1026
  if (settings.GENERATE_INFOPLIST_FILE === 'YES') {
@@ -1025,12 +1094,29 @@ function nativeAppIdentityFromIos(
1025
1094
  ...(rootModuleName === undefined ? {} : { rootModuleName }),
1026
1095
  ...(rootViewBackgroundColor === undefined ? {} : { rootViewBackgroundColor }),
1027
1096
  ...(designRequiresCompatibility ? { designRequiresCompatibility } : {}),
1097
+ ...(typeof dictionary.UIStatusBarHidden === 'boolean'
1098
+ ? { statusBarHidden: dictionary.UIStatusBarHidden }
1099
+ : {}),
1100
+ ...(typeof dictionary.UIViewControllerBasedStatusBarAppearance === 'boolean'
1101
+ ? {
1102
+ viewControllerBasedStatusBarAppearance:
1103
+ dictionary.UIViewControllerBasedStatusBarAppearance,
1104
+ }
1105
+ : {}),
1028
1106
  ...(dtPlatformVersion === undefined ? {} : { dtPlatformVersion }),
1029
1107
  bundleName,
1030
1108
  appVersion,
1031
1109
  buildVersion,
1032
1110
  bundleIdentifier,
1033
1111
  platform: 'ios',
1112
+ googleSignIn: readGoogleSignInConfiguration(
1113
+ dictionary,
1114
+ Object.fromEntries(
1115
+ [...resources]
1116
+ .filter((file) => file.endsWith('.plist') && files[file])
1117
+ .map((file) => [file, parseInfoPlist(files[file])]),
1118
+ ),
1119
+ ),
1034
1120
  supportedInterfaceOrientations,
1035
1121
  ipadSupportedInterfaceOrientations,
1036
1122
  ...(defaultScreenOrientationMask === undefined
@@ -1056,6 +1142,7 @@ function nativeAppIdentityFromIos(
1056
1142
  }
1057
1143
  : {}),
1058
1144
  permissionUsageDescriptions,
1145
+ infoPlistStrings,
1059
1146
  urlQuerySchemes,
1060
1147
  ...(shareIntoAppGroupId === undefined ? {} : { shareIntoAppGroupId }),
1061
1148
  ...(rtcAppGroupIdentifier === undefined ? {} : { rtcAppGroupIdentifier }),
@@ -106,6 +106,172 @@ export async function readExpoConfigAsync(
106
106
  }
107
107
  }
108
108
 
109
+ // a launch method may guard configure() with the safe-configure idiom: an
110
+ // `if` whose comma-separated clauses read the bundled GoogleService-Info.plist
111
+ // and test its API_KEY prefix. every clause is evaluated against the plist the
112
+ // target ships, so the answer is the one native launch reaches. any other
113
+ // condition cannot be proven and stays refused.
114
+ function evaluateFirebaseGuard(header: string, resources: string[]): boolean | null {
115
+ const condition = header.replace(/^\s*if\b/, '').replace(/\{\s*$/, '')
116
+ const clauses: string[] = []
117
+ let depth = 0
118
+ let start = 0
119
+ let quoted = false
120
+ for (let index = 0; index < condition.length; index++) {
121
+ const char = condition[index]
122
+ if (char === '"') quoted = !quoted
123
+ if (quoted) continue
124
+ if (char === '(' || char === '[') depth += 1
125
+ if (char === ')' || char === ']') depth -= 1
126
+ if (char === ',' && depth === 0) {
127
+ clauses.push(condition.slice(start, index).trim())
128
+ start = index + 1
129
+ }
130
+ }
131
+ clauses.push(condition.slice(start).trim())
132
+ const sources = resources.filter(
133
+ (file) => path.basename(file) === 'GoogleService-Info.plist',
134
+ )
135
+ const bound = new Map<string, unknown>()
136
+ for (const clause of clauses) {
137
+ if (/^FirebaseApp\s*\.\s*app\s*\(\s*\)\s*==\s*nil$/.test(clause)) continue
138
+ const resource =
139
+ /^let\s+(\w+)\s*=\s*Bundle\.main\.path\s*\(\s*forResource:\s*"GoogleService-Info"\s*,\s*ofType:\s*"plist"\s*\)$/.exec(
140
+ clause,
141
+ )
142
+ if (resource) {
143
+ if (sources.length !== 1) return false
144
+ bound.set(resource[1], sources[0])
145
+ continue
146
+ }
147
+ const dictionary =
148
+ /^let\s+(\w+)\s*=\s*NSDictionary\s*\(\s*contentsOfFile:\s*(\w+)\s*\)$/.exec(clause)
149
+ if (dictionary && typeof bound.get(dictionary[2]) === 'string') {
150
+ const parsed = parsePlistSource(
151
+ fs.readFileSync(String(bound.get(dictionary[2])), 'utf8'),
152
+ )
153
+ if (!isRecord(parsed)) return false
154
+ bound.set(dictionary[1], parsed)
155
+ continue
156
+ }
157
+ const entry = /^let\s+(\w+)\s*=\s*(\w+)\s*\[\s*"(\w+)"\s*\]\s+as\?\s+String$/.exec(
158
+ clause,
159
+ )
160
+ const dictionaryValue = entry ? bound.get(entry[2]) : undefined
161
+ if (entry && isRecord(dictionaryValue)) {
162
+ const value = dictionaryValue[entry[3]]
163
+ if (typeof value !== 'string') return false
164
+ bound.set(entry[1], value)
165
+ continue
166
+ }
167
+ const prefix = /^(\w+)\.hasPrefix\s*\(\s*"([^"]*)"\s*\)$/.exec(clause)
168
+ const subject = prefix ? bound.get(prefix[1]) : undefined
169
+ if (prefix && typeof subject === 'string') {
170
+ if (!subject.startsWith(prefix[2])) return false
171
+ continue
172
+ }
173
+ return null
174
+ }
175
+ return true
176
+ }
177
+
178
+ // native source with comments and string contents blanked in place, so brace
179
+ // and keyword scans see only code and offsets still index the raw text.
180
+ function blankSwiftObjcLiterals(raw: string) {
181
+ return raw.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"/g, (token) =>
182
+ token.startsWith('"')
183
+ ? `"${' '.repeat(token.length - 2)}"`
184
+ : token.replace(/[^\n]/g, ' '),
185
+ )
186
+ }
187
+
188
+ // the text of the brace block opening at `open`, and the index after its end.
189
+ function braceBlock(source: string, open: number) {
190
+ let depth = 0
191
+ for (let index = open; index < source.length; index += 1) {
192
+ if (source[index] === '{') depth += 1
193
+ if (source[index] === '}') depth -= 1
194
+ if (depth === 0) return { body: source.slice(open + 1, index), end: index + 1 }
195
+ }
196
+ return null
197
+ }
198
+
199
+ // whether every path through the code configures the default Firebase app: a
200
+ // top-level configure call, or an if/else chain ending in else whose blocks each
201
+ // do.
202
+ function configuresDefaultApp(code: string): boolean {
203
+ const configureCall = /\bFirebaseApp\s*\.\s*configure\s*\(/
204
+ let topLevel = ''
205
+ let depth = 0
206
+ for (const char of code) {
207
+ if (char === '{') depth += 1
208
+ if (depth === 0) topLevel += char
209
+ if (char === '}') depth -= 1
210
+ }
211
+ if (configureCall.test(topLevel)) return true
212
+ for (const chain of code.matchAll(/\bif\b[^{]*\{/g)) {
213
+ let open = chain.index + chain[0].length - 1
214
+ const blocks: string[] = []
215
+ let complete = false
216
+ for (;;) {
217
+ const block = braceBlock(code, open)
218
+ if (!block) break
219
+ blocks.push(block.body)
220
+ const next = /^\s*else\s*(if\b[^{]*)?\{/.exec(code.slice(block.end))
221
+ if (!next) break
222
+ open = block.end + next[0].length - 1
223
+ if (!next[1]) {
224
+ const last = braceBlock(code, open)
225
+ if (last) {
226
+ blocks.push(last.body)
227
+ complete = true
228
+ }
229
+ break
230
+ }
231
+ }
232
+ if (complete && blocks.every(configuresDefaultApp)) return true
233
+ }
234
+ return false
235
+ }
236
+
237
+ // where a launch method configures the default Firebase app: directly, or by
238
+ // calling a static no-argument function of an app target type whose body
239
+ // configures it on every path. returns a match-like result carrying the call's
240
+ // index, or null.
241
+ function findDefaultConfigureCall(body: string, sources: readonly string[]) {
242
+ const direct =
243
+ /^\s*(?:FirebaseApp\s*\.\s*configure\s*\(\s*\)|\[FIRApp\s+configure\])/m.exec(body)
244
+ if (direct) return direct
245
+ for (const call of body.matchAll(/^\s*([A-Z]\w*)\s*\.\s*(\w+)\s*\(\s*\)/gm)) {
246
+ const [, type, name] = call
247
+ for (const file of sources) {
248
+ if (!file.endsWith('.swift')) continue
249
+ const source = blankSwiftObjcLiterals(fs.readFileSync(file, 'utf8'))
250
+ const declaration = new RegExp(
251
+ `\\b(?:enum|struct|class)\\s+${type}\\b[^{]*\\{`,
252
+ ).exec(source)
253
+ if (!declaration) continue
254
+ const owner = braceBlock(source, declaration.index + declaration[0].length - 1)
255
+ const method = owner
256
+ ? new RegExp(`\\bstatic\\s+func\\s+${name}\\s*\\(\\s*\\)[^{]*\\{`).exec(
257
+ owner.body,
258
+ )
259
+ : null
260
+ const helper =
261
+ owner && method
262
+ ? braceBlock(owner.body, method.index + method[0].length - 1)
263
+ : null
264
+ if (
265
+ helper &&
266
+ !/\b(?:return|throw)\b/.test(helper.body) &&
267
+ configuresDefaultApp(helper.body)
268
+ )
269
+ return call
270
+ }
271
+ }
272
+ return null
273
+ }
274
+
109
275
  // the GoogleService-Info.plist an app target's launch method configures as the
110
276
  // default app, relative to the project root, or null when it configures none.
111
277
  function readTargetGoogleServicesFile(
@@ -120,27 +286,43 @@ function readTargetGoogleServicesFile(
120
286
  /^AppDelegate\.(swift|mm|m)$/.test(path.basename(file)),
121
287
  )
122
288
  const defaultConfigured = delegates.some((file) => {
123
- const source = fs
124
- .readFileSync(file, 'utf8')
125
- .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"/g, (token) =>
126
- token.startsWith('"') ? '""' : token.replace(/[^\n]/g, ' '),
127
- )
289
+ const raw = fs.readFileSync(file, 'utf8')
290
+ const source = blankSwiftObjcLiterals(raw)
128
291
  const launch =
129
292
  /(?:\bfunc\s+application\s*\([\s\S]*?didFinishLaunchingWithOptions[\s\S]*?\)\s*->\s*Bool|^-\s*\(BOOL\)\s*application\s*:[\s\S]*?didFinishLaunchingWithOptions[^{]*)\s*\{/m.exec(
130
293
  source,
131
294
  )
132
295
  if (!launch) return false
133
- const body = source.slice(launch.index + launch[0].length)
134
- const configure =
135
- /^\s*(?:FirebaseApp\s*\.\s*configure\s*\(\s*\)|\[FIRApp\s+configure\])/m.exec(body)
296
+ const bodyStart = launch.index + launch[0].length
297
+ const body = source.slice(bodyStart)
298
+ const configure = findDefaultConfigureCall(body, app.allSources)
136
299
  if (!configure) return false
137
- const before = body.slice(0, configure.index)
300
+ let before = body.slice(0, configure.index)
138
301
  let depth = 1
139
- for (const token of before) {
140
- if (token === '{') depth += 1
302
+ let guardOpen = -1
303
+ for (let index = 0; index < before.length; index++) {
304
+ const token = before[index]
305
+ if (token === '{') {
306
+ depth += 1
307
+ if (depth === 2) guardOpen = index
308
+ }
141
309
  if (token === '}') depth -= 1
142
310
  if (depth === 0) return false
143
311
  }
312
+ let guard: string | null = null
313
+ if (depth === 2) {
314
+ const headers = [...before.slice(0, guardOpen).matchAll(/^[ \t]*if\b/gm)]
315
+ const header = headers.at(-1)
316
+ if (header) {
317
+ guard = raw.slice(bodyStart + header.index, bodyStart + guardOpen + 1)
318
+ before = before.slice(0, header.index)
319
+ depth = 1
320
+ for (const token of before) {
321
+ if (token === '{') depth += 1
322
+ if (token === '}') depth -= 1
323
+ }
324
+ }
325
+ }
144
326
  let conditionalDepth = 0
145
327
  for (const directive of before.matchAll(
146
328
  /^\s*#\s*(if|ifdef|ifndef|elseif|elif|else|endif)\b/gm,
@@ -155,11 +337,25 @@ function readTargetGoogleServicesFile(
155
337
  conditionalDepth -= 1
156
338
  }
157
339
  }
158
- if (depth !== 1 || conditionalDepth !== 0 || /\b(?:return|throw)\b/.test(before))
340
+ // a return inside a nested closure leaves the launch method running
341
+ let topLevelBefore = ''
342
+ let nesting = 0
343
+ for (const token of before) {
344
+ if (token === '{') nesting += 1
345
+ if (nesting === 0) topLevelBefore += token
346
+ if (token === '}') nesting -= 1
347
+ }
348
+ const guarded = guard === null ? true : evaluateFirebaseGuard(guard, app.resources)
349
+ if (
350
+ guarded === null ||
351
+ depth !== 1 ||
352
+ conditionalDepth !== 0 ||
353
+ /\b(?:return|throw)\b/.test(topLevelBefore)
354
+ )
159
355
  throw new Error(
160
356
  'native Firebase initialization must be unconditional in the app launch method',
161
357
  )
162
- return true
358
+ return guarded
163
359
  })
164
360
  if (!defaultConfigured) return null
165
361
  const resources = app.resources.filter((file) =>
@@ -1,14 +1,11 @@
1
1
  import { isSwiftImageVariants, type SwiftImageVariants } from './assetCatalogContract'
2
+ import { isRecord } from './is-record'
2
3
 
3
4
  export interface NativeAppIconArtwork {
4
5
  primary?: SwiftImageVariants
5
6
  alternates: Record<string, SwiftImageVariants>
6
7
  }
7
8
 
8
- function isRecord(value: unknown): value is Record<string, unknown> {
9
- return typeof value === 'object' && value !== null && !Array.isArray(value)
10
- }
11
-
12
9
  export function isNativeAppIconArtwork(value: unknown): value is NativeAppIconArtwork {
13
10
  return (
14
11
  isRecord(value) &&