peachsim 0.1.1640 → 0.1.1644

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 (125) hide show
  1. package/README.md +5 -0
  2. package/cli/app-bundle-resources.ts +57 -0
  3. package/cli/app-stubs.ts +38 -13
  4. package/cli/cloud-session.ts +16 -0
  5. package/cli/commands/assert.ts +17 -4
  6. package/cli/commands/box.ts +33 -19
  7. package/cli/commands/control.ts +12 -0
  8. package/cli/commands/doctor.ts +24 -15
  9. package/cli/commands/inspect/actions.ts +3 -2
  10. package/cli/commands/inspect/core.ts +0 -8
  11. package/cli/commands/inspect.ts +0 -14
  12. package/cli/commands/platform.ts +2 -1
  13. package/cli/commands/record.ts +64 -28
  14. package/cli/commands/swift.ts +10 -2
  15. package/cli/commands/three-mode.ts +4 -38
  16. package/cli/commands/upload.ts +8 -8
  17. package/cli/drivers/playwright.ts +39 -0
  18. package/cli/flow-live-status.ts +0 -2
  19. package/cli/main.ts +1 -1
  20. package/cli/outbound-endpoints.ts +4 -2
  21. package/cli/ws-bridge.ts +0 -6
  22. package/detox/attempt-events.cjs +41 -5
  23. package/detox/index.ts +155 -109
  24. package/detox/jest-environment.cjs +5 -1
  25. package/detox/motion-change.cjs +7 -5
  26. package/detox/motion-change.d.cts +1 -0
  27. package/detox/motion-leg-starts.cjs +20 -0
  28. package/detox/motion-leg-starts.d.cts +8 -0
  29. package/dist-lib/agent-daemon-client.cjs +1 -1
  30. package/dist-lib/agent-events.cjs +1 -1
  31. package/dist-lib/attached-projects.cjs +1 -1
  32. package/dist-lib/auth/shared-session.cjs +1 -1
  33. package/dist-lib/backend-origin.cjs +12 -3
  34. package/dist-lib/beta.cjs +1 -1
  35. package/dist-lib/beta.mjs +1 -1
  36. package/dist-lib/bridge-constants.cjs +1 -1
  37. package/dist-lib/bridge-contract-input.cjs +1 -1
  38. package/dist-lib/bridge-contract-input.mjs +1 -1
  39. package/dist-lib/bridge-contract.cjs +1 -1
  40. package/dist-lib/bridge-contract.mjs +1 -1
  41. package/dist-lib/bundle-channel.cjs +2671 -1833
  42. package/dist-lib/capture-contract.cjs +1 -1
  43. package/dist-lib/capture-contract.mjs +1 -1
  44. package/dist-lib/cloud-contract.cjs +1 -1
  45. package/dist-lib/cloud-contract.mjs +1 -1
  46. package/dist-lib/cloud-test-args.cjs +1 -1
  47. package/dist-lib/cloud-test-args.mjs +1 -1
  48. package/dist-lib/cloud.cjs +1 -1
  49. package/dist-lib/cloud.mjs +1 -1
  50. package/dist-lib/config.cjs +1 -1
  51. package/dist-lib/config.mjs +1 -1
  52. package/dist-lib/detox/index.cjs +578 -130
  53. package/dist-lib/detox/jest-environment.cjs +5 -1
  54. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  55. package/dist-lib/expo-swift-bundle.cjs +2374 -1554
  56. package/dist-lib/expo-swift-metadata.cjs +1 -1
  57. package/dist-lib/expo-swift-metadata.mjs +1 -1
  58. package/dist-lib/home-paths.cjs +1 -1
  59. package/dist-lib/host/bridge-host.cjs +2968 -2141
  60. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  61. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  62. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  63. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  64. package/dist-lib/host/websocket-proxy.cjs +1 -1
  65. package/dist-lib/index.cjs +132 -57
  66. package/dist-lib/jump-to-source-babel.cjs +1 -1
  67. package/dist-lib/jump-to-source-native.cjs +1 -1
  68. package/dist-lib/kotlin-host.cjs +1 -1
  69. package/dist-lib/menu.cjs +14 -6
  70. package/dist-lib/menu.mjs +14 -6
  71. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  72. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  73. package/dist-lib/metro-production-bundle.cjs +1 -1
  74. package/dist-lib/metro-production-bundle.mjs +1 -1
  75. package/dist-lib/metro.cjs +120 -53
  76. package/dist-lib/native-app-identity-project.cjs +96 -44
  77. package/dist-lib/native-app-identity-project.mjs +95 -44
  78. package/dist-lib/native-app-identity.cjs +1 -1
  79. package/dist-lib/native-app-identity.mjs +1 -1
  80. package/dist-lib/native-firebase-metadata.cjs +1 -1
  81. package/dist-lib/native-firebase-metadata.mjs +1 -1
  82. package/dist-lib/plist.cjs +1 -1
  83. package/dist-lib/plist.mjs +1 -1
  84. package/dist-lib/profiles.cjs +1 -1
  85. package/dist-lib/public-brand.cjs +1 -1
  86. package/dist-lib/react-native-host-modules.cjs +1 -1
  87. package/dist-lib/react-native-host-modules.mjs +1 -1
  88. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  89. package/dist-lib/sdk.cjs +5 -3
  90. package/dist-lib/sdk.mjs +5 -3
  91. package/dist-lib/skills.cjs +3932 -2868
  92. package/dist-lib/swift-compile.cjs +587 -374
  93. package/dist-lib/swift-compile.mjs +595 -369
  94. package/dist-lib/swift-package.cjs +1 -1
  95. package/dist-lib/swift-package.mjs +1 -1
  96. package/dist-lib/swift-submit.cjs +8 -3
  97. package/dist-lib/swift-submit.mjs +8 -3
  98. package/dist-lib/swift-toolchain.cjs +1 -1
  99. package/dist-lib/swift.cjs +1 -1
  100. package/dist-lib/vite-swift.cjs +2263 -1467
  101. package/dist-lib/vite.cjs +1 -1
  102. package/dist-lib/worklet-executable.cjs +1 -1
  103. package/dist-lib/worklet-executable.mjs +1 -1
  104. package/package.json +1 -1
  105. package/src/appBundleResourcesContract.ts +51 -0
  106. package/src/backend-origin.ts +11 -1
  107. package/src/host/bridge-host.ts +17 -11
  108. package/src/host/staged-host-files.ts +28 -4
  109. package/src/launchMetadataContract.ts +1 -0
  110. package/src/menu.ts +12 -4
  111. package/src/native-app-identity-project.ts +47 -38
  112. package/src/react-native-bridge-modules.ts +89 -76
  113. package/src/runtime-delivery.ts +6 -7
  114. package/src/swift-compile.ts +150 -112
  115. package/src/swift-compiler-digest.ts +5 -3
  116. package/src/swift-distribution.lock.json +9 -8
  117. package/src/swift-host.ts +6 -4
  118. package/src/swift-objc-compile.ts +416 -194
  119. package/src/swift-objc-metadata.ts +204 -0
  120. package/src/swift-objc-sdk.ts +195 -2
  121. package/src/swift-package-manifest.ts +6 -3
  122. package/src/swift-root-lock.ts +302 -0
  123. package/src/swift-submit.ts +15 -2
  124. package/src/swiftContract.ts +12 -0
  125. package/src/xcode-project.ts +77 -7
@@ -0,0 +1,302 @@
1
+ import {
2
+ spawn,
3
+ spawnSync,
4
+ type ChildProcess,
5
+ type ChildProcessByStdio,
6
+ type SpawnOptions,
7
+ } from 'node:child_process'
8
+ import { createHash, randomUUID } from 'node:crypto'
9
+ import {
10
+ linkSync,
11
+ mkdirSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from 'node:fs'
17
+ import path from 'node:path'
18
+ import { rnxHomeDir } from './home-paths'
19
+ import { terminateProcessGroup } from './process-group'
20
+ import type { Readable, Writable } from 'node:stream'
21
+
22
+ const POLL_MS = 250
23
+ // ps derives a start time from the boot time, which drifts by a second or two
24
+ // on linux; a reused pid starts later than this.
25
+ const START_TOLERANCE_MS = 2_000
26
+
27
+ interface ProcessRow {
28
+ pid: number
29
+ pgid: number
30
+ zombie: boolean
31
+ start: number
32
+ }
33
+
34
+ interface Identity {
35
+ pid: number
36
+ start: number
37
+ }
38
+
39
+ interface Generation {
40
+ number: number
41
+ holder: Identity
42
+ released: boolean
43
+ }
44
+
45
+ function errorCode(error: unknown): string | undefined {
46
+ return error instanceof Error && 'code' in error && typeof error.code === 'string'
47
+ ? error.code
48
+ : undefined
49
+ }
50
+
51
+ // ps prints start times without a zone, so every reader asks for utc: a
52
+ // holder and a waiter then agree whatever their own time zones are.
53
+ function parseStart(text: string): number {
54
+ const start = Date.parse(`${text} UTC`)
55
+ if (Number.isNaN(start)) throw new Error(`unreadable process start time "${text}"`)
56
+ return start
57
+ }
58
+
59
+ // the whole table in one read. an unreadable table throws: a lock that cannot
60
+ // tell whether its holder is gone must not guess.
61
+ function processTable(): ProcessRow[] {
62
+ const result = spawnSync('ps', ['-A', '-o', 'pid=,pgid=,stat=,lstart='], {
63
+ encoding: 'utf8',
64
+ env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
65
+ stdio: ['ignore', 'pipe', 'pipe'],
66
+ timeout: 10_000,
67
+ killSignal: 'SIGKILL',
68
+ })
69
+ if (result.error || result.status !== 0) {
70
+ throw new Error(
71
+ `swift root lock cannot read the process table: ${
72
+ result.error?.message || result.stderr.trim() || `ps exited ${result.status}`
73
+ }`,
74
+ )
75
+ }
76
+ return result.stdout
77
+ .split('\n')
78
+ .filter((line) => line.trim())
79
+ .map((line) => {
80
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line)
81
+ if (!match)
82
+ throw new Error(`swift root lock cannot parse a process table row: ${line}`)
83
+ return {
84
+ pid: Number(match[1]),
85
+ pgid: Number(match[2]),
86
+ zombie: match[3]!.startsWith('Z'),
87
+ start: parseStart(match[4]!),
88
+ }
89
+ })
90
+ }
91
+
92
+ function sameProcess(row: ProcessRow, identity: Identity): boolean {
93
+ return (
94
+ row.pid === identity.pid && Math.abs(row.start - identity.start) <= START_TOLERANCE_MS
95
+ )
96
+ }
97
+
98
+ function ownIdentity(pid: number): Identity {
99
+ const row = processTable().find(
100
+ (candidate) => candidate.pid === pid && !candidate.zombie,
101
+ )
102
+ if (!row) throw new Error(`swift root lock cannot find process ${pid}`)
103
+ return { pid, start: row.start }
104
+ }
105
+
106
+ function parseIdentity(text: string): Identity {
107
+ const value: unknown = JSON.parse(text)
108
+ if (
109
+ value &&
110
+ typeof value === 'object' &&
111
+ 'pid' in value &&
112
+ typeof value.pid === 'number' &&
113
+ 'start' in value &&
114
+ typeof value.start === 'number'
115
+ )
116
+ return { pid: value.pid, start: value.start }
117
+ throw new Error(`swift root lock record is not a process identity: ${text}`)
118
+ }
119
+
120
+ function pause(ms: number, signal?: AbortSignal): Promise<void> {
121
+ return new Promise((resolve) => {
122
+ const done = () => {
123
+ clearTimeout(timer)
124
+ signal?.removeEventListener('abort', done)
125
+ resolve()
126
+ }
127
+ const timer = setTimeout(done, ms)
128
+ signal?.addEventListener('abort', done, { once: true })
129
+ })
130
+ }
131
+
132
+ const name = (number: number, suffix: string) =>
133
+ `${String(number).padStart(12, '0')}.${suffix}`
134
+
135
+ function generationNumbers(directory: string): number[] {
136
+ const numbers = new Set<number>()
137
+ for (const entry of readdirSync(directory)) {
138
+ const match = /^(\d{12})\./.exec(entry)
139
+ if (match) numbers.add(Number(match[1]))
140
+ }
141
+ return [...numbers].sort((a, b) => a - b)
142
+ }
143
+
144
+ function readGeneration(directory: string, number: number): Generation | null {
145
+ let owner: string
146
+ try {
147
+ owner = readFileSync(path.join(directory, name(number, 'owner')), 'utf8')
148
+ } catch (error) {
149
+ // a newer holder cleaned this generation after it was listed.
150
+ if (errorCode(error) === 'ENOENT') return null
151
+ throw error
152
+ }
153
+ return {
154
+ number,
155
+ holder: parseIdentity(owner),
156
+ released: readdirSync(directory).includes(name(number, 'released')),
157
+ }
158
+ }
159
+
160
+ // children are registered before they run and only while the holder lives, so
161
+ // reading them after the holder is seen dead lists every one it started.
162
+ function readChildren(directory: string, number: number): Identity[] {
163
+ const prefix = name(number, 'child.')
164
+ return readdirSync(directory)
165
+ .filter((entry) => entry.startsWith(prefix))
166
+ .map((entry) => parseIdentity(readFileSync(path.join(directory, entry), 'utf8')))
167
+ }
168
+
169
+ // a child leads its own session, so its group is its pid; a group id stays
170
+ // taken while any member lives, so a leader started later is another process.
171
+ function groupAlive(table: ProcessRow[], leader: Identity): boolean {
172
+ const row = table.find((candidate) => candidate.pid === leader.pid)
173
+ if (row && !sameProcess(row, leader)) return false
174
+ return table.some((candidate) => candidate.pgid === leader.pid && !candidate.zombie)
175
+ }
176
+
177
+ /** compiler processes whose lifetime the root lock covers. */
178
+ export interface SwiftRootLease {
179
+ /** starts `command` in its own process group, recorded under the lock before
180
+ * it runs, so the root stays held while that group lives even if this
181
+ * process dies. */
182
+ spawn(
183
+ command: string,
184
+ args: string[],
185
+ options: SpawnOptions,
186
+ ): ChildProcessByStdio<Writable, Readable, Readable>
187
+ }
188
+
189
+ /** runs `work` as the only process using a swift build root. the in-process
190
+ * queue orders one process's requests; two processes sharing a root each
191
+ * rewrite its sources and manifest, and swiftpm's own build lock only orders
192
+ * the builds, so a build can compile the other process's sources and then
193
+ * read back that process's executable.
194
+ *
195
+ * holders are numbered. the newest generation holds the root; claiming the
196
+ * next number is one exclusive link, so exactly one claimant wins and no
197
+ * record is ever moved or replaced. a generation is free once its holder
198
+ * released it, or once its holder process and every compiler group it
199
+ * recorded are gone. an unreadable process table or an unkillable compiler
200
+ * keeps the root held. the lock lives beside the root's tree, never inside
201
+ * it: a write sweeps every file of the tree it does not name. */
202
+ export async function withSwiftRootLock<T>(
203
+ root: string,
204
+ work: (lease: SwiftRootLease) => Promise<T>,
205
+ signal?: AbortSignal,
206
+ ): Promise<T> {
207
+ const directory = path.join(
208
+ rnxHomeDir(),
209
+ 'cache',
210
+ 'swift-root-locks',
211
+ createHash('sha256').update(root).digest('hex').slice(0, 32),
212
+ )
213
+ mkdirSync(directory, { recursive: true })
214
+ const token = randomUUID()
215
+ const owner = JSON.stringify(ownIdentity(process.pid))
216
+ const pending = path.join(directory, `pending.${token}`)
217
+ let held = 0
218
+ for (;;) {
219
+ signal?.throwIfAborted()
220
+ const newest = generationNumbers(directory).at(-1) ?? 0
221
+ const current = newest > 0 ? readGeneration(directory, newest) : null
222
+ if (newest > 0 && !current) continue
223
+ if (current && !current.released) {
224
+ const table = processTable()
225
+ if (table.some((row) => !row.zombie && sameProcess(row, current.holder))) {
226
+ await pause(POLL_MS, signal)
227
+ continue
228
+ }
229
+ const children = readChildren(directory, newest)
230
+ if (children.some((child) => groupAlive(table, child))) {
231
+ await pause(POLL_MS, signal)
232
+ continue
233
+ }
234
+ }
235
+ // the record is complete before it takes the number, so a waiter never
236
+ // reads half a record.
237
+ writeFileSync(pending, owner)
238
+ try {
239
+ linkSync(pending, path.join(directory, name(newest + 1, 'owner')))
240
+ } catch (error) {
241
+ if (errorCode(error) === 'EEXIST') continue
242
+ throw error
243
+ } finally {
244
+ rmSync(pending, { force: true })
245
+ }
246
+ // a claimant that listed the directory before older generations were
247
+ // cleaned can take a number below the newest; it never held the root.
248
+ const numbers = generationNumbers(directory)
249
+ if (numbers.at(-1) !== newest + 1) {
250
+ rmSync(path.join(directory, name(newest + 1, 'owner')), { force: true })
251
+ continue
252
+ }
253
+ held = newest + 1
254
+ for (const number of numbers)
255
+ if (number < held)
256
+ for (const entry of readdirSync(directory))
257
+ if (entry.startsWith(name(number, '')))
258
+ rmSync(path.join(directory, entry), { force: true })
259
+ break
260
+ }
261
+ const children: ChildProcess[] = []
262
+ const lease: SwiftRootLease = {
263
+ spawn(command, args, options) {
264
+ // the child waits for its registration before it runs, and exits without
265
+ // running if this process dies first.
266
+ const child = spawn(
267
+ '/bin/sh',
268
+ ['-c', 'IFS= read -r go || exit 1; exec "$@" </dev/null', 'sh', command, ...args],
269
+ { ...options, detached: true, stdio: ['pipe', 'pipe', 'pipe'] },
270
+ )
271
+ child.stdin!.on('error', () => {})
272
+ children.push(child)
273
+ try {
274
+ if (child.pid === undefined) {
275
+ child.once('error', () => {})
276
+ throw new Error(`could not start ${command}`)
277
+ }
278
+ const childPending = `${pending}.child.${child.pid}`
279
+ try {
280
+ // publish a complete identity before opening the compiler gate. a
281
+ // crash or failed write leaves only an ignored pending file.
282
+ writeFileSync(childPending, JSON.stringify(ownIdentity(child.pid)))
283
+ linkSync(childPending, path.join(directory, name(held, `child.${child.pid}`)))
284
+ } finally {
285
+ rmSync(childPending, { force: true })
286
+ }
287
+ } catch (error) {
288
+ child.stdin!.destroy()
289
+ throw error
290
+ }
291
+ child.stdin!.end('go\n')
292
+ return child
293
+ },
294
+ }
295
+ try {
296
+ return await work(lease)
297
+ } finally {
298
+ // a compiler that outlives its caller would still read the root.
299
+ await Promise.all(children.map((child) => terminateProcessGroup(child, 'SIGKILL', 0)))
300
+ writeFileSync(path.join(directory, name(held, 'released')), '')
301
+ }
302
+ }
@@ -154,8 +154,10 @@ export type SwiftCompileOutcome =
154
154
  * the build is running; poll the same source set again. One build is tens of
155
155
  * seconds and a service-binding request is cancelled at about four, so the
156
156
  * container answers this before the connection dies and the caller polls.
157
+ * `stage` is the latest stage the compiler reported for this build, absent
158
+ * before it reports one.
157
159
  */
158
- | { status: 'compiling'; hash: string }
160
+ | { status: 'compiling'; hash: string; stage?: string }
159
161
  /** refused before any work: no entry, too many files, over a cap, over a rate limit. */
160
162
  | { status: 'refused'; detail: string }
161
163
  /** the compiler ran and failed; detail carries its diagnostic verbatim. */
@@ -333,7 +335,11 @@ function isSwiftCompileOutcome(value: unknown): value is SwiftCompileOutcome {
333
335
  (value.native === undefined || isSwiftNativeModules(value.native))
334
336
  )
335
337
  case 'compiling':
336
- return typeof value.hash === 'string' && /^[a-f0-9]{64}$/.test(value.hash)
338
+ return (
339
+ typeof value.hash === 'string' &&
340
+ /^[a-f0-9]{64}$/.test(value.hash) &&
341
+ (value.stage === undefined || typeof value.stage === 'string')
342
+ )
337
343
  case 'refused':
338
344
  case 'failed':
339
345
  return typeof value.detail === 'string'
@@ -405,7 +411,10 @@ export async function compileSwiftProject(
405
411
  },
406
412
  origin: string,
407
413
  signal?: AbortSignal,
414
+ /** called when a poll reports a compiler stage the previous poll did not. */
415
+ onStage?: (stage: string) => void,
408
416
  ): Promise<SwiftModuleAddress> {
417
+ let lastStage: string | undefined
409
418
  while (true) {
410
419
  signal?.throwIfAborted()
411
420
  const outcome = await submit(work)
@@ -431,6 +440,10 @@ export async function compileSwiftProject(
431
440
  ...(outcome.native === undefined ? {} : { native: outcome.native }),
432
441
  }
433
442
  case 'compiling':
443
+ if (outcome.stage !== undefined && outcome.stage !== lastStage) {
444
+ lastStage = outcome.stage
445
+ onStage?.(outcome.stage)
446
+ }
434
447
  await new Promise<void>((resolve, reject) => {
435
448
  const finish = () => {
436
449
  signal?.removeEventListener('abort', abort)
@@ -17,6 +17,18 @@ export interface SwiftResourceBundle {
17
17
  path: string
18
18
  }
19
19
 
20
+ export interface SwiftObjCPeerClass {
21
+ name: string
22
+ header: string
23
+ }
24
+
25
+ export interface SwiftObjCImplementationPreparation {
26
+ receipt: string
27
+ objects: { target: string; source: string; file: string }[]
28
+ metadata?: string
29
+ registration?: string
30
+ }
31
+
20
32
  export interface SwiftMetalLibrarySource extends SwiftMetalSource {
21
33
  bundlePath: string
22
34
  }
@@ -399,15 +399,22 @@ function targetSettings(
399
399
  if (!config) return []
400
400
  const name = str(config.name) ?? configuration
401
401
  const base = str(config.baseConfigurationReference)
402
+ const anchor = str(config.baseConfigurationReferenceAnchor)
403
+ const relative = str(config.baseConfigurationReferenceRelativePath)
402
404
  const own = isDict(config.buildSettings) ? config.buildSettings : {}
403
- const baseFile = base ? project.resolvePath(base) : null
405
+ const directory = anchor ? project.resolvePath(anchor) : null
406
+ const baseFile = base
407
+ ? project.resolvePath(base)
408
+ : directory && relative
409
+ ? path.resolve(directory, relative)
410
+ : null
404
411
  if (
405
412
  options.requireSources !== false &&
406
- base &&
413
+ (base || anchor || relative) &&
407
414
  (!baseFile || !fs.existsSync(baseFile))
408
415
  )
409
416
  throw new Error(
410
- `Unable to open base configuration reference file '${baseFile ?? base}'`,
417
+ `Unable to open base configuration reference file '${baseFile ?? base ?? relative ?? anchor}'`,
411
418
  )
412
419
  const read: string[] = []
413
420
  const fromFile = baseFile ? readXcconfig(baseFile, read) : {}
@@ -458,6 +465,9 @@ function targetSettings(
458
465
  return applyOperator(get('PRODUCT_NAME'), 'c99extidentifier')
459
466
  case 'EXECUTABLE_NAME':
460
467
  return get('PRODUCT_NAME')
468
+ // the native default of CoreBuildSystem.xcspec
469
+ case 'EXCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES':
470
+ return '*.nib *.lproj *.framework *.gch *.xcode* *.xcassets *.icon (*) .DS_Store CVS .svn .git .hg *.pbproj *.pbxproj'
461
471
  case 'AppIdentifierPrefix':
462
472
  case 'TeamIdentifierPrefix': {
463
473
  // only a signed build has a team prefix
@@ -485,6 +495,65 @@ function settingList(value: string): string[] {
485
495
  return items
486
496
  }
487
497
 
498
+ // xcode expands a trailing `/**` into the directory and every directory below
499
+ // it, skipping subdirectories whose name matches EXCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES
500
+ // unless INCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES matches it too, and following
501
+ // links unless RECURSIVE_SEARCH_PATHS_FOLLOW_SYMLINKS is NO; a missing base names nothing.
502
+ function expandSearchPath(
503
+ directory: string,
504
+ rules: { excluded: RegExp[]; included: RegExp[]; followLinks: boolean },
505
+ ): string[] {
506
+ if (path.basename(directory) !== '**') return [directory]
507
+ const expanded: string[] = []
508
+ const seen = new Set<string>()
509
+ // native lists each level before the next, entries sorted by name.
510
+ const queue = [path.dirname(directory)]
511
+ for (let at = 0; at < queue.length; at++) {
512
+ const current = queue[at]!
513
+ let real: string
514
+ try {
515
+ real = fs.realpathSync(current)
516
+ } catch {
517
+ continue
518
+ }
519
+ if (!fs.statSync(real).isDirectory() || seen.has(real)) continue
520
+ seen.add(real)
521
+ expanded.push(current)
522
+ for (const entry of fs
523
+ .readdirSync(real, { withFileTypes: true })
524
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
525
+ if (entry.isSymbolicLink() ? !rules.followLinks : !entry.isDirectory()) continue
526
+ if (
527
+ rules.excluded.some((pattern) => pattern.test(entry.name)) &&
528
+ !rules.included.some((pattern) => pattern.test(entry.name))
529
+ )
530
+ continue
531
+ queue.push(path.join(current, entry.name))
532
+ }
533
+ }
534
+ return expanded
535
+ }
536
+
537
+ // the recursive search path rules a target's build settings select.
538
+ function recursiveSearchRules(settings: (key: string) => string) {
539
+ const glob = (pattern: string) =>
540
+ new RegExp(
541
+ `^${pattern
542
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
543
+ .replaceAll('*', '.*')
544
+ .replaceAll('?', '.')}$`,
545
+ )
546
+ return {
547
+ excluded: settingList(settings('EXCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES')).map(
548
+ glob,
549
+ ),
550
+ included: settingList(settings('INCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES')).map(
551
+ glob,
552
+ ),
553
+ followLinks: settings('RECURSIVE_SEARCH_PATHS_FOLLOW_SYMLINKS') !== 'NO',
554
+ }
555
+ }
556
+
488
557
  // the conditions swift compiles a target with: its active compilation
489
558
  // conditions and each `-D` in its other swift flags, as xcode passes them.
490
559
  function compilationConditions(settings: (key: string) => string): string[] {
@@ -1035,6 +1104,7 @@ export function readXcodeAppTarget(
1035
1104
  for (const file of read) xcconfigs.add(file)
1036
1105
  const swiftVersion = Number.parseFloat(settings('SWIFT_VERSION') || '5')
1037
1106
  const members = targetMembers(ref, settings, options, keys, xcconfigs)
1107
+ const searchRules = recursiveSearchRules(settings)
1038
1108
  const implementations = members.allSources.filter((file) =>
1039
1109
  /\.(c|cc|cpp|cxx|m|mm)$/.test(file),
1040
1110
  )
@@ -1085,10 +1155,10 @@ export function readXcodeAppTarget(
1085
1155
  headerSearchPaths: [
1086
1156
  ...settingList(settings('HEADER_SEARCH_PATHS')),
1087
1157
  ...settingList(settings('USER_HEADER_SEARCH_PATHS')),
1088
- ].map(absolute),
1089
- frameworkSearchPaths: settingList(settings('FRAMEWORK_SEARCH_PATHS')).map(
1090
- absolute,
1091
- ),
1158
+ ].flatMap((raw) => expandSearchPath(absolute(raw), searchRules)),
1159
+ frameworkSearchPaths: settingList(
1160
+ settings('FRAMEWORK_SEARCH_PATHS'),
1161
+ ).flatMap((raw) => expandSearchPath(absolute(raw), searchRules)),
1092
1162
  flags: [
1093
1163
  ...(settings('CLANG_ENABLE_OBJC_ARC') === 'YES' ? ['-fobjc-arc'] : []),
1094
1164
  ...(settings('CLANG_ENABLE_OBJC_WEAK') === 'YES'