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.
Files changed (152) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +10 -1
  3. package/cli/app-config.ts +4 -1
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +224 -9
  6. package/cli/bridge-flow-runner.ts +64 -43
  7. package/cli/command-registry.ts +1 -0
  8. package/cli/commands/control.ts +283 -49
  9. package/cli/commands/doctor.ts +17 -184
  10. package/cli/commands/flow.ts +79 -11
  11. package/cli/commands/inspect/core.ts +10 -5
  12. package/cli/commands/inspect/settling.ts +14 -2
  13. package/cli/commands/inspect/wait-ready.ts +12 -0
  14. package/cli/commands/inspect.ts +26 -3
  15. package/cli/commands/install-desktop.ts +1 -0
  16. package/cli/commands/maestro-compare.ts +97 -38
  17. package/cli/commands/maestro.ts +9 -1
  18. package/cli/commands/record.ts +21 -1
  19. package/cli/commands/screenshot.ts +23 -1
  20. package/cli/commands/serve.ts +7 -2
  21. package/cli/commands/setup.ts +19 -42
  22. package/cli/commands/swift.ts +5 -0
  23. package/cli/commands/upload.ts +46 -7
  24. package/cli/desktop-companion.ts +102 -17
  25. package/cli/drivers/desktop.ts +14 -1
  26. package/cli/drivers/playwright-sim-host.ts +38 -3
  27. package/cli/drivers/playwright.ts +9 -8
  28. package/cli/drivers/types.ts +5 -0
  29. package/cli/maestro-accessibility.ts +1 -1
  30. package/cli/maestro-compare.ts +84 -12
  31. package/cli/main.ts +2 -1
  32. package/cli/outbound-endpoints.ts +13 -1
  33. package/cli/pause-aware-timeout.ts +102 -0
  34. package/cli/scene-video.ts +89 -0
  35. package/cli/ws-bridge.ts +23 -14
  36. package/detox/attempt-events.cjs +4 -0
  37. package/detox/index.ts +167 -142
  38. package/detox/motion-change.cjs +57 -21
  39. package/detox/motion-change.d.cts +7 -2
  40. package/dist-lib/agent-daemon-client.cjs +1 -1
  41. package/dist-lib/agent-events.cjs +1 -1
  42. package/dist-lib/attached-projects.cjs +1 -1
  43. package/dist-lib/auth/shared-session.cjs +1 -1
  44. package/dist-lib/backend-origin.cjs +1 -1
  45. package/dist-lib/beta.cjs +1 -1
  46. package/dist-lib/beta.mjs +1 -1
  47. package/dist-lib/bridge-constants.cjs +13 -1
  48. package/dist-lib/bridge-contract-input.cjs +1 -1
  49. package/dist-lib/bridge-contract-input.mjs +1 -1
  50. package/dist-lib/bridge-contract.cjs +14 -8
  51. package/dist-lib/bridge-contract.mjs +13 -7
  52. package/dist-lib/bundle-channel.cjs +9536 -7604
  53. package/dist-lib/capture-contract.cjs +1 -1
  54. package/dist-lib/capture-contract.mjs +1 -1
  55. package/dist-lib/cloud-contract.cjs +1 -1
  56. package/dist-lib/cloud-contract.mjs +1 -1
  57. package/dist-lib/cloud-test-args.cjs +1 -1
  58. package/dist-lib/cloud-test-args.mjs +1 -1
  59. package/dist-lib/cloud.cjs +1 -1
  60. package/dist-lib/cloud.mjs +1 -1
  61. package/dist-lib/config.cjs +6 -3
  62. package/dist-lib/config.mjs +6 -3
  63. package/dist-lib/detox/index.cjs +895 -664
  64. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  65. package/dist-lib/expo-swift-bundle.cjs +7818 -5916
  66. package/dist-lib/expo-swift-metadata.cjs +3 -1
  67. package/dist-lib/expo-swift-metadata.mjs +3 -1
  68. package/dist-lib/home-paths.cjs +32 -3
  69. package/dist-lib/host/bridge-host.cjs +8340 -6392
  70. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  71. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  72. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  73. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  74. package/dist-lib/host/websocket-proxy.cjs +1 -1
  75. package/dist-lib/index.cjs +812 -339
  76. package/dist-lib/jump-to-source-babel.cjs +1 -1
  77. package/dist-lib/jump-to-source-native.cjs +1 -1
  78. package/dist-lib/kotlin-host.cjs +1 -1
  79. package/dist-lib/menu.cjs +1 -1
  80. package/dist-lib/menu.mjs +1 -1
  81. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  82. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  83. package/dist-lib/metro-production-bundle.cjs +1 -1
  84. package/dist-lib/metro-production-bundle.mjs +1 -1
  85. package/dist-lib/metro.cjs +391 -28
  86. package/dist-lib/native-app-identity-project.cjs +55 -2
  87. package/dist-lib/native-app-identity-project.mjs +55 -2
  88. package/dist-lib/native-app-identity.cjs +53 -1
  89. package/dist-lib/native-app-identity.mjs +52 -1
  90. package/dist-lib/native-firebase-metadata.cjs +1 -1
  91. package/dist-lib/native-firebase-metadata.mjs +1 -1
  92. package/dist-lib/plist.cjs +1 -1
  93. package/dist-lib/plist.mjs +1 -1
  94. package/dist-lib/profiles.cjs +1 -1
  95. package/dist-lib/public-brand.cjs +1 -1
  96. package/dist-lib/react-native-host-modules.cjs +5 -1
  97. package/dist-lib/react-native-host-modules.mjs +5 -1
  98. package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
  99. package/dist-lib/sdk.cjs +27 -11
  100. package/dist-lib/sdk.mjs +27 -10
  101. package/dist-lib/skills.cjs +12276 -8947
  102. package/dist-lib/swift-compile.cjs +924 -118
  103. package/dist-lib/swift-compile.mjs +966 -152
  104. package/dist-lib/swift-package.cjs +12 -3
  105. package/dist-lib/swift-package.mjs +12 -3
  106. package/dist-lib/swift-submit.cjs +7 -1
  107. package/dist-lib/swift-submit.mjs +5 -1
  108. package/dist-lib/swift-toolchain.cjs +133 -18
  109. package/dist-lib/swift.cjs +1 -1
  110. package/dist-lib/vite-swift.cjs +1955 -498
  111. package/dist-lib/vite.cjs +3 -1
  112. package/dist-lib/worklet-executable.cjs +1 -1
  113. package/dist-lib/worklet-executable.mjs +1 -1
  114. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  115. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  116. package/package.json +1 -1
  117. package/scripts/dev-server-scanner.ts +2 -1
  118. package/skills/peach-perf/SKILL.md +11 -5
  119. package/skills/peach-setup/SKILL.md +16 -19
  120. package/src/bridge-constants.ts +11 -0
  121. package/src/bridge-contract.ts +22 -34
  122. package/src/config.ts +15 -0
  123. package/src/desktop-host-delivery.ts +5 -1
  124. package/src/dev-bundle-resolution.ts +2 -1
  125. package/src/expo-manifest-body.ts +13 -0
  126. package/src/expo-swift-host.ts +191 -47
  127. package/src/expo-swift-ready.ts +78 -0
  128. package/src/expoSwiftContract.ts +13 -0
  129. package/src/home-paths.ts +48 -2
  130. package/src/host/bridge-host.ts +5 -2
  131. package/src/native-app-identity-project.ts +26 -2
  132. package/src/native-app-identity.ts +90 -0
  133. package/src/native-firebase-config.ts +209 -13
  134. package/src/process-group.ts +25 -2
  135. package/src/react-native-bridge-modules.ts +416 -0
  136. package/src/react-native-host-modules.ts +4 -0
  137. package/src/swift-binary-modules.ts +2 -2
  138. package/src/swift-compile.ts +835 -84
  139. package/src/swift-compiler-digest.ts +14 -0
  140. package/src/swift-distribution.lock.json +14 -12
  141. package/src/swift-host.ts +91 -29
  142. package/src/swift-icu.ts +14 -6
  143. package/src/swift-objc-sdk.ts +133 -10
  144. package/src/swift-objc-standin.ts +25 -3
  145. package/src/swift-package-manifest.ts +21 -6
  146. package/src/swift-package.ts +15 -1
  147. package/src/swift-packages.ts +119 -60
  148. package/src/swift-standin-emit.ts +57 -9
  149. package/src/swift-standins.ts +168 -20
  150. package/src/swift-submit.ts +10 -0
  151. package/src/swift-toolchain.ts +149 -23
  152. package/src/vite-plugin.ts +12 -1
@@ -6,6 +6,7 @@ import {
6
6
  extractNativeAppIdentityFromFiles,
7
7
  type NativeAppIdentity,
8
8
  readRootModuleName,
9
+ readGoogleSignInConfiguration,
9
10
  selectNativeAppIdentityReader,
10
11
  } from './native-app-identity'
11
12
  import { resolveNativeAppIconArtwork } from './nativeAppIconContract'
@@ -90,6 +91,11 @@ export function readNativeAppIdentityFromXcodeTarget(
90
91
  permissionUsageDescriptions[key] = value
91
92
  }
92
93
 
94
+ const infoPlistStrings: Record<string, string> = {}
95
+ for (const [key, value] of Object.entries(dictionary)) {
96
+ if (typeof value === 'string') infoPlistStrings[key] = value
97
+ }
98
+
93
99
  const querySchemes = dictionary.LSApplicationQueriesSchemes
94
100
  const urlQuerySchemes = Array.isArray(querySchemes) ? querySchemes : []
95
101
 
@@ -146,6 +152,15 @@ export function readNativeAppIdentityFromXcodeTarget(
146
152
  ...(rootModuleName ? { rootModuleName } : {}),
147
153
  ...(rootViewBackgroundColor !== undefined ? { rootViewBackgroundColor } : {}),
148
154
  ...(designRequiresCompatibility ? { designRequiresCompatibility } : {}),
155
+ ...(typeof dictionary.UIStatusBarHidden === 'boolean'
156
+ ? { statusBarHidden: dictionary.UIStatusBarHidden }
157
+ : {}),
158
+ ...(typeof dictionary.UIViewControllerBasedStatusBarAppearance === 'boolean'
159
+ ? {
160
+ viewControllerBasedStatusBarAppearance:
161
+ dictionary.UIViewControllerBasedStatusBarAppearance,
162
+ }
163
+ : {}),
149
164
  ...(typeof dictionary.DTPlatformVersion === 'string'
150
165
  ? { dtPlatformVersion: dictionary.DTPlatformVersion }
151
166
  : {}),
@@ -154,6 +169,14 @@ export function readNativeAppIdentityFromXcodeTarget(
154
169
  buildVersion,
155
170
  bundleIdentifier,
156
171
  platform: 'ios',
172
+ googleSignIn: readGoogleSignInConfiguration(
173
+ dictionary,
174
+ Object.fromEntries(
175
+ app.resources
176
+ .filter((file) => file.endsWith('.plist'))
177
+ .map((file) => [file, parseInfoPlist(fs.readFileSync(file, 'utf8'))]),
178
+ ),
179
+ ),
157
180
  supportedInterfaceOrientations,
158
181
  ipadSupportedInterfaceOrientations,
159
182
  ...(defaultScreenOrientationMask ? { defaultScreenOrientationMask } : {}),
@@ -177,6 +200,7 @@ export function readNativeAppIdentityFromXcodeTarget(
177
200
  (value) => value,
178
201
  ),
179
202
  permissionUsageDescriptions,
203
+ infoPlistStrings,
180
204
  urlQuerySchemes,
181
205
  ...(shareIntoAppGroupId ? { shareIntoAppGroupId } : {}),
182
206
  ...(rtcAppGroupIdentifier ? { rtcAppGroupIdentifier } : {}),
@@ -364,8 +388,8 @@ function readIosNativeAppIdentity(
364
388
  ? nonEmptyString(installedManifest.version)
365
389
  : undefined
366
390
  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
391
+ // the static feature flag schema starts with reanimated 4.
392
+ if (Number.parseInt(installedVersion, 10) < 4) return identity
369
393
  const defaults: unknown = JSON.parse(
370
394
  fs.readFileSync(
371
395
  path.join(
@@ -48,6 +48,9 @@ export interface NativeAppIdentity {
48
48
  // UIDesignRequiresCompatibility: UIKit draws its pre-liquid-glass chrome.
49
49
  // carried only when the plist sets it true.
50
50
  designRequiresCompatibility?: true
51
+ // initial UIKit visibility and the owner of subsequent status bar preferences.
52
+ statusBarHidden?: boolean
53
+ viewControllerBasedStatusBarAppearance?: boolean
51
54
  // the literal DTPlatformVersion stamp when the app plist supplies one.
52
55
  dtPlatformVersion?: string
53
56
  // CFBundleName, which CFNetwork stamps into the default User-Agent and iOS's
@@ -62,6 +65,10 @@ export interface NativeAppIdentity {
62
65
  // SDK filtering belongs to the evaluation's device, not the bundle builder.
63
66
  androidManifestPermissions?: readonly AndroidManifestPermission[]
64
67
  permissionUsageDescriptions?: Readonly<Record<string, unknown>>
68
+ // every top-level string the app's Info.plist declares, build settings
69
+ // resolved, for native code that reads its own plist keys.
70
+ infoPlistStrings?: Readonly<Record<string, string>>
71
+ googleSignIn?: Readonly<Record<string, unknown>>
65
72
  urlQuerySchemes?: readonly unknown[]
66
73
  appIntentIdentifiers?: readonly string[]
67
74
  backgroundTaskIdentifiers?: readonly string[]
@@ -102,6 +109,9 @@ function nonEmptyString(value: unknown): string | null {
102
109
 
103
110
  export function isNativeAppIdentity(value: unknown): value is NativeAppIdentity {
104
111
  if (!isRecord(value)) return false
112
+ for (const key of ['statusBarHidden', 'viewControllerBasedStatusBarAppearance']) {
113
+ if (value[key] !== undefined && typeof value[key] !== 'boolean') return false
114
+ }
105
115
  const oneUpdates = value.oneUpdates
106
116
  if (
107
117
  oneUpdates !== undefined &&
@@ -143,6 +153,7 @@ export function isNativeAppIdentity(value: unknown): value is NativeAppIdentity
143
153
  ))
144
154
  )
145
155
  return false
156
+ if (value.googleSignIn !== undefined && !isRecord(value.googleSignIn)) return false
146
157
  const descriptions = value.permissionUsageDescriptions
147
158
  if (
148
159
  value.reanimatedStaticFeatureFlags !== undefined &&
@@ -153,6 +164,12 @@ export function isNativeAppIdentity(value: unknown): value is NativeAppIdentity
153
164
  )
154
165
  return false
155
166
  if (descriptions !== undefined && !isRecord(descriptions)) return false
167
+ if (
168
+ value.infoPlistStrings !== undefined &&
169
+ (!isRecord(value.infoPlistStrings) ||
170
+ !Object.values(value.infoPlistStrings).every((entry) => typeof entry === 'string'))
171
+ )
172
+ return false
156
173
  if (value.urlQuerySchemes !== undefined && !Array.isArray(value.urlQuerySchemes))
157
174
  return false
158
175
  if (
@@ -267,6 +284,15 @@ export function readNativeAppIdentity(value: unknown): NativeAppIdentity | null
267
284
  ...(value.designRequiresCompatibility === undefined
268
285
  ? {}
269
286
  : { designRequiresCompatibility: value.designRequiresCompatibility }),
287
+ ...(value.statusBarHidden === undefined
288
+ ? {}
289
+ : { statusBarHidden: value.statusBarHidden }),
290
+ ...(value.viewControllerBasedStatusBarAppearance === undefined
291
+ ? {}
292
+ : {
293
+ viewControllerBasedStatusBarAppearance:
294
+ value.viewControllerBasedStatusBarAppearance,
295
+ }),
270
296
  ...(value.dtPlatformVersion === undefined
271
297
  ? {}
272
298
  : { dtPlatformVersion: value.dtPlatformVersion }),
@@ -291,11 +317,17 @@ export function readNativeAppIdentity(value: unknown): NativeAppIdentity | null
291
317
  (permission) => ({ ...permission }),
292
318
  ),
293
319
  }),
320
+ ...(value.googleSignIn === undefined
321
+ ? {}
322
+ : { googleSignIn: structuredClone(value.googleSignIn) }),
294
323
  ...(value.permissionUsageDescriptions === undefined
295
324
  ? {}
296
325
  : {
297
326
  permissionUsageDescriptions: { ...value.permissionUsageDescriptions },
298
327
  }),
328
+ ...(value.infoPlistStrings === undefined
329
+ ? {}
330
+ : { infoPlistStrings: { ...value.infoPlistStrings } }),
299
331
  ...(value.appIntentIdentifiers === undefined
300
332
  ? {}
301
333
  : { appIntentIdentifiers: [...value.appIntentIdentifiers] }),
@@ -496,6 +528,39 @@ export function insertNativeAppIdentityFooter(
496
528
 
497
529
  // ── native project extraction ────────────────────────────────────────────────
498
530
 
531
+ // Info.plist and bundled service plists are native configuration, independent of Firebase initialization.
532
+ export function readGoogleSignInConfiguration(
533
+ plist: Record<string, unknown>,
534
+ resources: Readonly<Record<string, unknown>>,
535
+ ): Record<string, unknown> {
536
+ const clientIds: Record<string, string> = {}
537
+ for (const [file, value] of Object.entries(resources)) {
538
+ if (isRecord(value) && typeof value.CLIENT_ID === 'string')
539
+ clientIds[
540
+ file
541
+ .split('/')
542
+ .pop()
543
+ ?.replace(/\.plist$/, '') ?? file
544
+ ] = value.CLIENT_ID
545
+ }
546
+ const urlSchemes: string[] = []
547
+ for (const entry of Array.isArray(plist.CFBundleURLTypes)
548
+ ? plist.CFBundleURLTypes
549
+ : []) {
550
+ if (!isRecord(entry) || !Array.isArray(entry.CFBundleURLSchemes)) continue
551
+ for (const scheme of entry.CFBundleURLSchemes)
552
+ if (typeof scheme === 'string') urlSchemes.push(scheme)
553
+ }
554
+ return {
555
+ clientId: plist.GIDClientID,
556
+ serverClientId: plist.GIDServerClientID,
557
+ hostedDomain: plist.GIDHostedDomain,
558
+ openIDRealm: plist.GIDOpenIDRealm,
559
+ urlSchemes,
560
+ clientIds,
561
+ }
562
+ }
563
+
499
564
  function plistString(plist: string, key: string): string | null {
500
565
  const match = plist.match(new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`))
501
566
  return match ? match[1].trim() : null
@@ -952,6 +1017,13 @@ function nativeAppIdentityFromIos(
952
1017
  permissionUsageDescriptions[key] = resolved
953
1018
  }
954
1019
 
1020
+ const infoPlistStrings: Record<string, string> = {}
1021
+ for (const [key, value] of Object.entries(dictionary)) {
1022
+ if (typeof value !== 'string') continue
1023
+ const resolved = resolveBuildSetting(value, settings)
1024
+ if (resolved !== null) infoPlistStrings[key] = resolved
1025
+ }
1026
+
955
1027
  // Xcode's generated Info.plist uses these build settings only when enabled.
956
1028
  // source: developer.apple.com/documentation/xcode/build-settings-reference.
957
1029
  if (settings.GENERATE_INFOPLIST_FILE === 'YES') {
@@ -1025,12 +1097,29 @@ function nativeAppIdentityFromIos(
1025
1097
  ...(rootModuleName === undefined ? {} : { rootModuleName }),
1026
1098
  ...(rootViewBackgroundColor === undefined ? {} : { rootViewBackgroundColor }),
1027
1099
  ...(designRequiresCompatibility ? { designRequiresCompatibility } : {}),
1100
+ ...(typeof dictionary.UIStatusBarHidden === 'boolean'
1101
+ ? { statusBarHidden: dictionary.UIStatusBarHidden }
1102
+ : {}),
1103
+ ...(typeof dictionary.UIViewControllerBasedStatusBarAppearance === 'boolean'
1104
+ ? {
1105
+ viewControllerBasedStatusBarAppearance:
1106
+ dictionary.UIViewControllerBasedStatusBarAppearance,
1107
+ }
1108
+ : {}),
1028
1109
  ...(dtPlatformVersion === undefined ? {} : { dtPlatformVersion }),
1029
1110
  bundleName,
1030
1111
  appVersion,
1031
1112
  buildVersion,
1032
1113
  bundleIdentifier,
1033
1114
  platform: 'ios',
1115
+ googleSignIn: readGoogleSignInConfiguration(
1116
+ dictionary,
1117
+ Object.fromEntries(
1118
+ [...resources]
1119
+ .filter((file) => file.endsWith('.plist') && files[file])
1120
+ .map((file) => [file, parseInfoPlist(files[file])]),
1121
+ ),
1122
+ ),
1034
1123
  supportedInterfaceOrientations,
1035
1124
  ipadSupportedInterfaceOrientations,
1036
1125
  ...(defaultScreenOrientationMask === undefined
@@ -1056,6 +1145,7 @@ function nativeAppIdentityFromIos(
1056
1145
  }
1057
1146
  : {}),
1058
1147
  permissionUsageDescriptions,
1148
+ infoPlistStrings,
1059
1149
  urlQuerySchemes,
1060
1150
  ...(shareIntoAppGroupId === undefined ? {} : { shareIntoAppGroupId }),
1061
1151
  ...(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) =>
@@ -13,6 +13,7 @@ import { setTimeout as sleep } from 'node:timers/promises'
13
13
 
14
14
  const GRACEFUL_TIMEOUT_MS = 3_000
15
15
  const FORCED_EXIT_TIMEOUT_MS = 1_000
16
+ const FREEZE_GUARD_NAME = 'process-freeze-guard'
16
17
 
17
18
  // keep this self-contained: the detached browser host embeds its source.
18
19
  export function listProcessChildren(parents: number[], timeoutMs = 3_000): number[] {
@@ -107,7 +108,7 @@ async function protectProcessFreeze(rootPid: number) {
107
108
  printf 'ready\n'
108
109
  if ! IFS= read -r command || [ "$command" != disarm ]; then cleanup; fi
109
110
  `,
110
- 'process-freeze-guard',
111
+ FREEZE_GUARD_NAME,
111
112
  journal,
112
113
  ],
113
114
  {
@@ -151,6 +152,28 @@ async function protectProcessFreeze(rootPid: number) {
151
152
  }
152
153
  }
153
154
 
155
+ // a nested capture's guard is a descendant of this tree but must keep running
156
+ // to resume its own targets, so a freeze never reaches it. ps exits 1 when
157
+ // some pids already vanished, so only a spawn failure or timeout is an error.
158
+ function withoutFreezeGuards(pids: number[]): number[] {
159
+ if (pids.length === 0) return pids
160
+ const result = spawnSync('ps', ['-o', 'pid=,command=', '-p', pids.join(',')], {
161
+ encoding: 'utf8',
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ timeout: FORCED_EXIT_TIMEOUT_MS,
164
+ killSignal: 'SIGKILL',
165
+ })
166
+ if (result.error || (result.status !== 0 && result.status !== 1))
167
+ throw new Error(result.error?.message || `ps exited ${result.status}`)
168
+ const guards = new Set(
169
+ result.stdout
170
+ .split('\n')
171
+ .filter((line) => line.includes(FREEZE_GUARD_NAME))
172
+ .map((line) => Number(line.trim().split(/\s+/)[0])),
173
+ )
174
+ return pids.filter((pid) => !guards.has(pid))
175
+ }
176
+
154
177
  // reparented members remain in the group; detached descendants need their
155
178
  // recorded pids. both can leave zombies, which signal zero cannot distinguish
156
179
  // from running processes. inspect their states together before confirming exit.
@@ -243,7 +266,7 @@ export async function terminateProcessGroup(
243
266
  }
244
267
  let children: number[]
245
268
  try {
246
- children = listProcessChildren(parents, remaining)
269
+ children = withoutFreezeGuards(listProcessChildren(parents, remaining))
247
270
  } catch (error) {
248
271
  inspectionError = new Error(`could not inspect descendants: ${String(error)}`)
249
272
  break