peachsim 0.1.1619 → 0.1.1625

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +35 -5
  2. package/cli/app-fonts.ts +2 -1
  3. package/cli/bridge-flow-runner.ts +222 -175
  4. package/cli/cloud-client.ts +81 -48
  5. package/cli/commands/box/search-command.ts +3 -2
  6. package/cli/commands/box.ts +10 -29
  7. package/cli/commands/control.ts +108 -43
  8. package/cli/commands/inspect/settling.ts +57 -20
  9. package/cli/commands/maestro-compare.ts +11 -32
  10. package/cli/commands/platform.ts +74 -17
  11. package/cli/commands/record.ts +20 -15
  12. package/cli/commands/upload.ts +5 -1
  13. package/cli/dev-build-freshness.ts +37 -2
  14. package/cli/drivers/playwright-sim-host.ts +18 -6
  15. package/cli/maestro-accessibility.ts +53 -0
  16. package/cli/maestro-compare.ts +24 -0
  17. package/cli/outbound-endpoints.ts +27 -8
  18. package/cli/parent-pid.ts +0 -9
  19. package/cli/remote-simulators.ts +112 -0
  20. package/cli/remoteSimulatorContract.ts +21 -0
  21. package/detox/expectations.ts +5 -1
  22. package/detox/index.ts +133 -98
  23. package/detox/motion-change.cjs +37 -0
  24. package/detox/motion-change.d.cts +16 -0
  25. package/detox/navigation-lifecycle.ts +22 -4
  26. package/dist-lib/agent-daemon-client.cjs +3 -2
  27. package/dist-lib/agent-events.cjs +1 -1
  28. package/dist-lib/attached-projects.cjs +1 -1
  29. package/dist-lib/auth/shared-session.cjs +3 -2
  30. package/dist-lib/backend-origin.cjs +1 -1
  31. package/dist-lib/beta.cjs +1 -1
  32. package/dist-lib/beta.mjs +1 -1
  33. package/dist-lib/bridge-constants.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.cjs +1 -1
  35. package/dist-lib/bridge-contract-input.mjs +1 -1
  36. package/dist-lib/bridge-contract.cjs +1 -1
  37. package/dist-lib/bridge-contract.mjs +1 -1
  38. package/dist-lib/bundle-channel.cjs +1528 -937
  39. package/dist-lib/capture-contract.cjs +1 -1
  40. package/dist-lib/capture-contract.mjs +1 -1
  41. package/dist-lib/cloud-contract.cjs +1 -1
  42. package/dist-lib/cloud-contract.mjs +1 -1
  43. package/dist-lib/cloud-test-args.cjs +1 -1
  44. package/dist-lib/cloud-test-args.mjs +1 -1
  45. package/dist-lib/cloud.cjs +4 -3
  46. package/dist-lib/cloud.mjs +4 -3
  47. package/dist-lib/config.cjs +5 -4
  48. package/dist-lib/config.mjs +5 -4
  49. package/dist-lib/detox/index.cjs +1021 -364
  50. package/dist-lib/dev-bundle-resolution.cjs +51 -23
  51. package/dist-lib/expo-swift-bundle.cjs +1351 -804
  52. package/dist-lib/expo-swift-metadata.cjs +5 -4
  53. package/dist-lib/expo-swift-metadata.mjs +5 -4
  54. package/dist-lib/home-paths.cjs +1 -1
  55. package/dist-lib/host/bridge-host.cjs +2475 -1343
  56. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  58. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  59. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  60. package/dist-lib/host/websocket-proxy.cjs +1 -1
  61. package/dist-lib/index.cjs +1269 -300
  62. package/dist-lib/jump-to-source-babel.cjs +1 -1
  63. package/dist-lib/jump-to-source-native.cjs +1 -1
  64. package/dist-lib/kotlin-host.cjs +1 -1
  65. package/dist-lib/menu.cjs +3 -2
  66. package/dist-lib/menu.mjs +3 -2
  67. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  68. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  69. package/dist-lib/metro-production-bundle.cjs +5 -4
  70. package/dist-lib/metro-production-bundle.mjs +5 -4
  71. package/dist-lib/metro.cjs +806 -398
  72. package/dist-lib/native-app-identity-project.cjs +729 -21
  73. package/dist-lib/native-app-identity-project.mjs +728 -21
  74. package/dist-lib/native-app-identity.cjs +232 -9
  75. package/dist-lib/native-app-identity.mjs +230 -8
  76. package/dist-lib/native-firebase-metadata.cjs +5 -4
  77. package/dist-lib/native-firebase-metadata.mjs +5 -4
  78. package/dist-lib/plist.cjs +1 -1
  79. package/dist-lib/plist.mjs +1 -1
  80. package/dist-lib/profiles.cjs +1 -1
  81. package/dist-lib/public-brand.cjs +3 -2
  82. package/dist-lib/react-native-host-modules.cjs +4 -1
  83. package/dist-lib/react-native-host-modules.mjs +4 -1
  84. package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
  85. package/dist-lib/sdk.cjs +89 -24
  86. package/dist-lib/sdk.mjs +89 -24
  87. package/dist-lib/skills.cjs +4471 -2394
  88. package/dist-lib/swift-compile.cjs +336 -145
  89. package/dist-lib/swift-compile.mjs +350 -150
  90. package/dist-lib/swift-package.cjs +1 -1
  91. package/dist-lib/swift-package.mjs +1 -1
  92. package/dist-lib/swift-submit.cjs +1 -1
  93. package/dist-lib/swift-submit.mjs +1 -1
  94. package/dist-lib/swift-toolchain.cjs +1 -1
  95. package/dist-lib/swift.cjs +1 -1
  96. package/dist-lib/vite-swift.cjs +891 -557
  97. package/dist-lib/vite.cjs +68 -14
  98. package/dist-lib/worklet-executable.cjs +25 -2
  99. package/dist-lib/worklet-executable.mjs +25 -2
  100. package/package.json +2 -2
  101. package/scripts/dev-server-scanner.ts +33 -13
  102. package/src/admitted-mac-build.ts +34 -8
  103. package/src/android-app-identity.ts +146 -0
  104. package/src/android-autolinking.ts +75 -0
  105. package/src/android-string-value.ts +36 -0
  106. package/src/android-strings.ts +5 -36
  107. package/src/androidAutolinkingContract.ts +40 -0
  108. package/src/bundleGlobalFooter.ts +26 -7
  109. package/src/cloud.ts +2 -2
  110. package/src/codex-client.ts +3 -2
  111. package/src/dev-bundle-resolution.ts +58 -25
  112. package/src/expo-kotlin-host.ts +6 -36
  113. package/src/expo-swift-bundle.ts +17 -12
  114. package/src/expo-swift-host.ts +477 -173
  115. package/src/expoSwiftContract.ts +6 -0
  116. package/src/host/bridge-host.ts +7 -55
  117. package/src/host/staged-host-files.ts +29 -2
  118. package/src/installed-package-versions.ts +54 -14
  119. package/src/launchMetadataContract.ts +24 -0
  120. package/src/metro-plugin.ts +37 -42
  121. package/src/native-app-identity-project.ts +118 -3
  122. package/src/native-app-identity.ts +103 -11
  123. package/src/nitro-compile.ts +2 -5
  124. package/src/nitro-source.ts +147 -63
  125. package/src/process-group.ts +90 -60
  126. package/src/process-output.ts +0 -9
  127. package/src/public-brand.ts +1 -0
  128. package/src/react-native-host-modules.ts +3 -0
  129. package/src/sim-client.ts +6 -4
  130. package/src/swift-compile.ts +185 -31
  131. package/src/swift-distribution.lock.json +37 -11
  132. package/src/swift-distribution.ts +61 -9
  133. package/src/swift-host.ts +58 -27
  134. package/src/swift-objc-standin.ts +13 -0
  135. package/src/swift-standin-emit.ts +1 -1
  136. package/src/swift-standins.ts +17 -5
  137. package/src/vite-plugin.ts +8 -19
  138. package/src/worklet-executable.ts +30 -3
  139. package/src/worklets-babel.ts +35 -14
  140. package/src/xcode-project.ts +10 -0
@@ -1,5 +1,12 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
2
- import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import {
3
+ appendFileSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
3
10
  import { tmpdir } from 'node:os'
4
11
  import { join } from 'node:path'
5
12
  import { setTimeout as sleep } from 'node:timers/promises'
@@ -7,6 +14,57 @@ import { setTimeout as sleep } from 'node:timers/promises'
7
14
  const GRACEFUL_TIMEOUT_MS = 3_000
8
15
  const FORCED_EXIT_TIMEOUT_MS = 1_000
9
16
 
17
+ // keep this self-contained: the detached browser host embeds its source.
18
+ export function listProcessChildren(parents: number[], timeoutMs = 3_000): number[] {
19
+ if (parents.some((pid) => !Number.isInteger(pid) || pid <= 1))
20
+ throw new Error('process inspection requires valid parent pids')
21
+ if (parents.length === 0) return []
22
+ const deadline = Date.now() + timeoutMs
23
+ let children: string
24
+ if (process.platform === 'linux') {
25
+ // pgrep scans unrelated host processes. every thread can own children.
26
+ const lists: string[] = []
27
+ for (const parent of parents) {
28
+ try {
29
+ const tasks = `/proc/${parent}/task`
30
+ for (const thread of readdirSync(tasks)) {
31
+ if (Date.now() >= deadline)
32
+ throw new Error('process descendant inspection exceeded its deadline')
33
+ try {
34
+ lists.push(readFileSync(`${tasks}/${thread}/children`, 'utf8'))
35
+ } catch (error) {
36
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
37
+ throw error
38
+ }
39
+ }
40
+ } catch (error) {
41
+ // an exiting parent or thread has no remaining children to inspect.
42
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
43
+ throw error
44
+ }
45
+ }
46
+ children = lists.join(' ')
47
+ } else {
48
+ const result = spawnSync('pgrep', ['-P', parents.join(',')], {
49
+ encoding: 'utf8',
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ timeout: Math.max(1, timeoutMs),
52
+ killSignal: 'SIGKILL',
53
+ })
54
+ if (result.error || (result.status !== 0 && result.status !== 1)) {
55
+ throw new Error(
56
+ `could not inspect descendants of processes ${parents.join(',')}: ${
57
+ result.error?.message || result.stderr.trim() || `pgrep exited ${result.status}`
58
+ }`,
59
+ )
60
+ }
61
+ children = result.stdout
62
+ }
63
+ return [...new Set(children.trim().split(/\s+/).map(Number))].filter(
64
+ (pid) => Number.isInteger(pid) && pid > 1 && !parents.includes(pid),
65
+ )
66
+ }
67
+
10
68
  // synchronous group-or-pid signal for `process.on('exit')` sweeps, where no
11
69
  // async wait is possible. never throws: a dead group reads as ESRCH.
12
70
  export function signalProcessGroup(child: ChildProcess, signal: NodeJS.Signals) {
@@ -93,63 +151,49 @@ async function protectProcessFreeze(rootPid: number) {
93
151
  }
94
152
  }
95
153
 
96
- function runningPids(pids: number[]): number[] {
97
- return pids.filter((pid) => {
154
+ // reparented members remain in the group; detached descendants need their
155
+ // recorded pids. both can leave zombies, which signal zero cannot distinguish
156
+ // from running processes. inspect their states together before confirming exit.
157
+ function processesHaveExited(pids: number[], pgid: number): boolean {
158
+ const mayExist = [-pgid, ...pids].some((target) => {
98
159
  try {
99
- process.kill(pid, 0)
160
+ process.kill(target, 0)
100
161
  return true
101
162
  } catch (error) {
102
- return error instanceof Error && 'code' in error && error.code === 'EPERM'
163
+ return !(error instanceof Error && 'code' in error && error.code === 'ESRCH')
103
164
  }
104
165
  })
105
- }
106
-
107
- // a descendant whose parent exited first is reparented to init, so `pgrep -P`
108
- // no longer finds it and it never enters the pid list. it stays in the leader's
109
- // process group though, and the group outlives its leader while any member is
110
- // left, so the group is the survivor check that still sees it.
111
- //
112
- // a signal cannot finish the answer. a zombie is an exit status its parent has
113
- // not collected: it runs nothing and holds nothing, and it is what a finished
114
- // tree leaves behind. macos reports one with EPERM, which otherwise means a
115
- // live member we may not signal, and linux reports one as signalable, so on
116
- // either platform a kill that does not fail reads a finished group as a live
117
- // one, which holds shutdown open for the whole graceful timeout and then
118
- // SIGKILLs something already dead. ps reports the state, so it settles which
119
- // it is, and it costs a process only when a survivor we never enumerated is
120
- // really there: this runs after the known pids are gone, and an empty group
121
- // fails the kill with ESRCH before reaching it.
122
- //
123
- // exported for its test: node reaps its own children, so a group left holding
124
- // only a zombie cannot be built through terminateProcessGroup from inside a
125
- // test, and this is the function that has to read one correctly.
126
- export function groupIsEmpty(pgid: number): boolean {
127
- try {
128
- process.kill(-pgid, 0)
129
- } catch (error) {
130
- if (!(error instanceof Error && 'code' in error && error.code === 'EPERM'))
131
- return true
132
- }
133
- const result = spawnSync('ps', ['-A', '-o', 'pgid=,stat='], {
166
+ if (!mayExist) return true
167
+ const result = spawnSync('ps', ['-A', '-o', 'pid=,pgid=,stat='], {
134
168
  encoding: 'utf8',
135
169
  stdio: ['ignore', 'pipe', 'pipe'],
170
+ timeout: FORCED_EXIT_TIMEOUT_MS,
171
+ killSignal: 'SIGKILL',
136
172
  })
137
- // an unreadable process table is not evidence of an empty group. keep waiting
138
- // and let the caller escalate to SIGKILL the way it does for any live tree.
173
+ // an unreadable process table cannot confirm exit.
139
174
  if (result.error || result.status !== 0) return false
175
+ const knownPids = new Set(pids)
140
176
  return !result.stdout.split('\n').some((line) => {
141
- const [group, state] = line.trim().split(/\s+/)
142
- return Number(group) === pgid && state !== undefined && !state.startsWith('Z')
177
+ const [pid, group, state] = line.trim().split(/\s+/)
178
+ return (
179
+ (Number(group) === pgid || knownPids.has(Number(pid))) &&
180
+ state !== undefined &&
181
+ !state.startsWith('Z')
182
+ )
143
183
  })
144
184
  }
145
185
 
186
+ export function groupIsEmpty(pgid: number): boolean {
187
+ return processesHaveExited([], pgid)
188
+ }
189
+
146
190
  async function waitForExit(
147
191
  pids: number[],
148
192
  pgid: number,
149
193
  timeoutMs: number,
150
194
  ): Promise<boolean> {
151
195
  const deadline = Date.now() + timeoutMs
152
- const gone = () => runningPids(pids).length === 0 && groupIsEmpty(pgid)
196
+ const gone = () => processesHaveExited(pids, pgid)
153
197
  while (Date.now() < deadline) {
154
198
  if (gone()) return true
155
199
  await sleep(50)
@@ -197,30 +241,16 @@ export async function terminateProcessGroup(
197
241
  inspectionError = new Error('process descendant inspection exceeded its deadline')
198
242
  break
199
243
  }
200
- const result = spawnSync('pgrep', ['-P', parents.join(',')], {
201
- encoding: 'utf8',
202
- stdio: ['ignore', 'pipe', 'pipe'],
203
- timeout: remaining,
204
- killSignal: 'SIGKILL',
205
- })
206
- if (result.error || (result.status !== 0 && result.status !== 1)) {
207
- inspectionError = new Error(
208
- `could not inspect descendants of processes ${parents.join(',')}: ${
209
- result.error?.message ||
210
- result.stderr.trim() ||
211
- `pgrep exited ${result.status}`
212
- }`,
213
- )
244
+ let children: number[]
245
+ try {
246
+ children = listProcessChildren(parents, remaining)
247
+ } catch (error) {
248
+ inspectionError = new Error(`could not inspect descendants: ${String(error)}`)
214
249
  break
215
250
  }
216
251
  const next: number[] = []
217
- for (const line of result.stdout.split('\n')) {
218
- const descendantPid = Number(line.trim())
219
- if (
220
- Number.isInteger(descendantPid) &&
221
- descendantPid > 1 &&
222
- !descendants.has(descendantPid)
223
- ) {
252
+ for (const descendantPid of children) {
253
+ if (!descendants.has(descendantPid)) {
224
254
  freeze.record(descendantPid)
225
255
  descendants.add(descendantPid)
226
256
  next.push(descendantPid)
@@ -1,12 +1,3 @@
1
- // a consumer such as head may close its pipe before the command finishes.
2
- // exit directly: reporting this through stderr can fail on the same pipe.
3
- for (const stream of [process.stdout, process.stderr]) {
4
- stream.on('error', (error: NodeJS.ErrnoException) => {
5
- if (error.code === 'EPIPE') process.exit(1)
6
- throw error
7
- })
8
- }
9
-
10
1
  // wait for both pipes before forcing an exit with live handles.
11
2
  // console writers must use these streams rather than a separate runtime sink.
12
3
  export async function exitWithFlushedOutput(code: number): Promise<never> {
@@ -6,6 +6,7 @@ export const peachPublicBrand = Object.freeze({
6
6
  commandName: 'peach',
7
7
  domain: 'peachsim.com',
8
8
  origin: 'https://peachsim.com',
9
+ boxOrigin: 'https://rnx-box.contrast.dev',
9
10
  })
10
11
 
11
12
  export const rnxPublicBrand = peachPublicBrand
@@ -6,6 +6,9 @@
6
6
  * preserves upstream platform, style, event-emitter, and TurboModule behavior.
7
7
  */
8
8
  export const REACT_NATIVE_PASSTHROUGH_SPECIFIERS: readonly string[] = [
9
+ // one's native bootstrap runs the upstream environment entry.
10
+ 'react-native/setup-env',
11
+ 'react-native/src/setup-env',
9
12
  // sdk 58 reads these upstream getters and wraps the original metro logger.
10
13
  'react-native/unstable-internals-do-not-use',
11
14
  'react-native/src/unstable-internals-do-not-use',
package/src/sim-client.ts CHANGED
@@ -48,6 +48,10 @@ import {
48
48
  type WaitForSootsimIdleResult,
49
49
  } from '../cli/commands/inspect/settling'
50
50
  import { openAppUrl } from './app-url'
51
+ import {
52
+ LAUNCH_METADATA_KEYS,
53
+ LAUNCH_METADATA_QUERY_PARAM,
54
+ } from './launchMetadataContract'
51
55
  import { sleep } from './sleep'
52
56
  import type { BridgeClaimResult, BridgeSimInfo, WsBridge } from '../cli/ws-bridge'
53
57
  import type {
@@ -187,10 +191,8 @@ const TARGET_SEARCH_PARAMS = [
187
191
  'open',
188
192
  'port',
189
193
  'inspectOpen',
190
- 'appFonts',
191
- 'appManifest',
192
- 'appSplash',
193
- 'appCatalog',
194
+ ...LAUNCH_METADATA_KEYS,
195
+ LAUNCH_METADATA_QUERY_PARAM,
194
196
  ]
195
197
 
196
198
  export function buildSimOpenUrl(currentUrl: string, target: number | string): string {
@@ -16,6 +16,7 @@ import {
16
16
  import os from 'node:os'
17
17
  import path from 'node:path'
18
18
  import { admittedMacBuilderSlot } from './admitted-mac-build'
19
+ import { rnxHomeDir } from './home-paths'
19
20
  import { terminateProcessGroup } from './process-group'
20
21
  import { designTimeDiagnostic, designTimeSource } from './swift-design-time'
21
22
  import {
@@ -719,6 +720,9 @@ export function swiftArtifactHash(
719
720
  moduleRoot: string,
720
721
  ): string {
721
722
  const hash = createHash('sha256')
723
+ // v49: NSObject metatype initialization reaches its explicit runtime boundary
724
+ // v48: top-level classes rooted in an Apple framework class stay out of C++
725
+ // v47: target ownership honors exclusions before lowering manifest-named files
722
726
  // v46: view builder literals compile as design-time lookups
723
727
  // v45: the link runs no wasm-opt pass, whatever the compiling machine has
724
728
  // v44: fixed-signature optional type methods retain metatype dispatch
@@ -765,7 +769,7 @@ export function swiftArtifactHash(
765
769
  // v6: app and scene delegate classes compile final
766
770
  // (v5: a target declaring resources gets SwiftPM's Bundle.module accessor;
767
771
  // v4: sources compile through swiftSourceAsIOS)
768
- hash.update(`swift-compile/v46\n${release}\n`)
772
+ hash.update(`swift-compile/v49\n${release}\n`)
769
773
  if (input.oneModule) hash.update('one-module\n')
770
774
  hash.update(swiftModuleDigest(moduleRoot))
771
775
  for (const file of Object.keys(input.files).sort()) {
@@ -1565,9 +1569,14 @@ function compiledSwiftSources(
1565
1569
  const orderedTargets = [...targets].sort((a, b) => b.path.length - a.path.length)
1566
1570
  for (const [file, contents] of Object.entries(files)) {
1567
1571
  if (!file.endsWith('.swift')) continue
1568
- const owner = orderedTargets.find(
1569
- (candidate) => candidate.path === '.' || file.startsWith(`${candidate.path}/`),
1570
- )
1572
+ const owner = orderedTargets.find((candidate) => {
1573
+ const prefix = candidate.path === '.' ? '' : `${candidate.path}/`
1574
+ if (!file.startsWith(prefix)) return false
1575
+ const relative = file.slice(prefix.length)
1576
+ return !candidate.exclude?.some(
1577
+ (excluded) => relative === excluded || relative.startsWith(`${excluded}/`),
1578
+ )
1579
+ })
1571
1580
  if (
1572
1581
  /(?:^|\/)Package(?:@swift-[\d.]+)?\.swift$/.test(file) &&
1573
1582
  (!owner || /^Package(?:@swift-[\d.]+)?\.swift$/.test(file))
@@ -1600,12 +1609,73 @@ function compiledSwiftSources(
1600
1609
  const sources = lifted.map(([, contents]) => contents)
1601
1610
  const extensions = objcExtensionMemberIndex(sources)
1602
1611
  const objc = objcIndex(sources)
1612
+ const rooted = objcRootedClasses(sources)
1603
1613
  for (const [file, contents] of lifted)
1604
- compiled.set(file, swiftSourceAsIOS(contents, extensions, objc))
1614
+ compiled.set(
1615
+ file,
1616
+ hideObjCClassesFromCxx(swiftSourceAsIOS(contents, extensions, objc), rooted),
1617
+ )
1605
1618
  }
1606
1619
  return compiled
1607
1620
  }
1608
1621
 
1622
+ // an Apple framework protocol a class may list first, which is no superclass
1623
+ const APPLE_PROTOCOL =
1624
+ /^NS(?:Mutable)?Copying$|^NS(?:Secure)?Coding$|^NSLocking$|^NSFastEnumeration$|^NSObjectProtocol$|(?:Delegate|DataSource)$/
1625
+
1626
+ // the module's classes whose superclass chain, through the classes `sources`
1627
+ // declare, reaches an Apple framework class: Objective-C classes on iOS
1628
+ function objcRootedClasses(sources: string[]): Set<string> {
1629
+ const bases = new Map<string, string>()
1630
+ for (const source of sources)
1631
+ for (const match of swiftCodeMask(source).matchAll(
1632
+ /\bclass\s+(?!(?:func|var|let|subscript)\b)([A-Za-z_]\w*)\s*(?:<[^>{]*>)?\s*:\s*(?:[A-Za-z_]\w*\.)*([A-Za-z_]\w*)/g,
1633
+ ))
1634
+ bases.set(match[1]!, match[2]!)
1635
+ const rooted = new Set<string>()
1636
+ for (const name of bases.keys()) {
1637
+ const seen = new Set([name])
1638
+ let base = bases.get(name)!
1639
+ while (bases.has(base) && !seen.has(base)) {
1640
+ seen.add(base)
1641
+ base = bases.get(base)!
1642
+ }
1643
+ if (
1644
+ !bases.has(base) &&
1645
+ APPLE_CLASS.test(base) &&
1646
+ !APPLE_PROTOCOL.test(base) &&
1647
+ !(base in SDK_OBJC_OPTIONAL)
1648
+ )
1649
+ rooted.add(name)
1650
+ }
1651
+ return rooted
1652
+ }
1653
+
1654
+ // the header swift emits on iOS keeps an Objective-C class out of its C++
1655
+ // section. here its root is a swift class from a module with no C++ header,
1656
+ // so C++ that includes the header fails on the undeclared base. a top-level
1657
+ // class in `rooted` gains @_expose(!Cxx) in front of its declaration,
1658
+ // shifting that one line's columns. swift rejects the attribute in a local
1659
+ // scope, so a nested class keeps its exposure.
1660
+ function hideObjCClassesFromCxx(source: string, rooted: Set<string>): string {
1661
+ if (rooted.size === 0) return source
1662
+ const code = swiftCodeMask(source)
1663
+ const starts: number[] = []
1664
+ let depth = 0
1665
+ for (const match of code.matchAll(
1666
+ /[{}]|\bclass\s+(?!(?:func|var|let|subscript)\b)([A-Za-z_]\w*)/g,
1667
+ )) {
1668
+ if (match[0] === '{') depth++
1669
+ else if (match[0] === '}') depth--
1670
+ else if (depth === 0 && rooted.has(match[1]!))
1671
+ starts.push(declarationStart(code, match.index, 0))
1672
+ }
1673
+ let out = source
1674
+ for (const at of starts.reverse())
1675
+ out = `${out.slice(0, at)}@_expose(!Cxx) ${out.slice(at)}`
1676
+ return out
1677
+ }
1678
+
1609
1679
  // wasm Swift has no ObjC runtime, so a member read on AnyObject never
1610
1680
  // resolves: `value of type 'AnyObject' has no member 'doubleValue'`. native
1611
1681
  // code that bridges dictionary values through AnyObject to reach NSNumber's
@@ -3596,6 +3666,8 @@ export type ObjCIndex = {
3596
3666
  // each @objc type method's selector to its result type, null when two
3597
3667
  // declarations disagree
3598
3668
  typeMethods: Map<string, string | null>
3669
+ // NSObject and protocols constrained to it without a Swift init requirement
3670
+ nsObjectTypes: Set<string>
3599
3671
  }
3600
3672
 
3601
3673
  // the @objc type methods `source` declares, by selector, into `found`
@@ -3733,15 +3805,43 @@ export function objcIndex(sources: string[]): ObjCIndex {
3733
3805
  /\b(?:var|let)\s+([A-Za-z_]\w*)/g,
3734
3806
  ))
3735
3807
  variables.add(declared[1]!)
3736
- const parents = requirements.size
3737
- ? inheritedTypes(sources)
3738
- : new Map<string, Set<string>>()
3808
+ const parents = inheritedTypes(sources)
3739
3809
  for (const [protocol, inherited] of sdkParents)
3740
3810
  parents.set(protocol, new Set([...(parents.get(protocol) ?? []), ...inherited]))
3741
3811
  const conformers = requirements.size
3742
3812
  ? conformingTypes(parents, new Set(requirements.keys()))
3743
3813
  : new Map<string, Set<string>>()
3744
- return { requirements, conformers, callable, variables, aliases, typeMethods }
3814
+ const protocols = new Set<string>()
3815
+ const initializers = new Set<string>()
3816
+ for (const source of sources) {
3817
+ if (!/\bprotocol\b/.test(source)) continue
3818
+ const code = swiftCodeMask(source)
3819
+ const braces = [...code.matchAll(/[{}]/g)].map((match) => match.index)
3820
+ for (const protocol of code.matchAll(/\bprotocol\s+([A-Za-z_]\w*)[^{}]*\{/g)) {
3821
+ const name = protocol[1]!
3822
+ protocols.add(name)
3823
+ const open = protocol.index + protocol[0].length - 1
3824
+ if (
3825
+ /\binit\s*[?!]?\s*\(/.test(code.slice(open + 1, closingBrace(code, braces, open)))
3826
+ )
3827
+ initializers.add(name)
3828
+ }
3829
+ }
3830
+ const rooted = conformingTypes(parents, new Set(['NSObject']))
3831
+ const initialized = conformingTypes(parents, initializers)
3832
+ const nsObjectTypes = new Set([
3833
+ 'NSObject',
3834
+ ...[...protocols].filter((name) => rooted.has(name) && !initialized.has(name)),
3835
+ ])
3836
+ return {
3837
+ requirements,
3838
+ conformers,
3839
+ callable,
3840
+ variables,
3841
+ aliases,
3842
+ typeMethods,
3843
+ nsObjectTypes,
3844
+ }
3745
3845
  }
3746
3846
 
3747
3847
  // the non-generic file-scope typealiases `sources` declare, each resolved
@@ -4111,7 +4211,7 @@ const IMP_CONVENTION = /@convention\(c\)(?=\s*\(\s*AnyObject\s*,\s*Selector\b)/g
4111
4211
 
4112
4212
  function lowerObjCRuntimeCalls(source: string, index: ObjCIndex): string {
4113
4213
  const casts = source.includes('@convention(c)')
4114
- const classes = /\b(?:AnyClass|AnyObject)\b/.test(source)
4214
+ const classes = /\b(?:AnyClass|AnyObject)\b|\.\s*init\s*\(/.test(source)
4115
4215
  if (!casts && !classes) return source
4116
4216
  const code = swiftCodeMask(source)
4117
4217
  const edits: [number, number, string][] = []
@@ -4142,15 +4242,33 @@ function lowerObjCRuntimeCalls(source: string, index: ObjCIndex): string {
4142
4242
  }
4143
4243
  }
4144
4244
  if (classes) {
4145
- // every declaration of a name, typed or not; a receiver is an AnyClass
4146
- // or AnyObject only where its innermost declaration in scope says so
4245
+ // every declaration of a name, typed or not; a receiver's innermost
4246
+ // declaration in scope determines its runtime operation
4147
4247
  const declarations = new Map<string, { at: number; type: string }[]>()
4148
4248
  for (const declared of code.matchAll(
4149
- /(?:\b(?:let|var)\s+|[(,]\s*(?:[A-Za-z_]\w*\s+)?)([A-Za-z_]\w*)\s*:\s*(?:([A-Z]\w*(?:\.\w+)*)\s*([?!])?|[[(])|\b(?:let|var|for)\s+([A-Za-z_]\w*)\b(?!\s*:)|[{,]\s*([A-Za-z_]\w*)\s+in\b/g,
4249
+ /(?:\b(?:let|var)\s+|[(,]\s*(?:[A-Za-z_]\w*\s+)?)([A-Za-z_]\w*)\s*:\s*(?:(?:any\s+)?([A-Z]\w*(?:\.\w+)*)\s*([?!])?|[[(])|\b(?:let|var|for)\s+([A-Za-z_]\w*)\b(?!\s*:)|[{,]\s*([A-Za-z_]\w*)\s+in\b/g,
4150
4250
  )) {
4151
4251
  const name = declared[1] ?? declared[4] ?? declared[5]!
4152
4252
  const list = declarations.get(name) ?? []
4153
- list.push({ at: declared.index, type: declared[3] ? '' : (declared[2] ?? '') })
4253
+ let type = declared[3] ? '' : (declared[2] ?? '')
4254
+ if (!type && declared[4]) {
4255
+ // NSClassFromString's checked cast establishes the metatype of a
4256
+ // successful conditional binding, including a guard's remaining body.
4257
+ const lookup = /^\s*=\s*NSClassFromString\s*\(/.exec(
4258
+ code.slice(declared.index + declared[0].length),
4259
+ )
4260
+ if (lookup) {
4261
+ const open = declared.index + declared[0].length + lookup[0].length - 1
4262
+ type =
4263
+ /^\s*as\?\s*([A-Za-z_]\w*(?:\.\w+)*\.Type)\b/.exec(
4264
+ code.slice(closingParen(code, open) + 1),
4265
+ )?.[1] ?? ''
4266
+ }
4267
+ }
4268
+ const resolved = type.endsWith('.Type')
4269
+ ? `${resolveAliases(type.slice(0, -5), index.aliases)}.Type`
4270
+ : resolveAliases(type, index.aliases)
4271
+ list.push({ at: declared.index, type: resolved })
4154
4272
  declarations.set(name, list)
4155
4273
  }
4156
4274
  // a tuple or enum pattern (`let (a, b)`, `case let .some(a)`) binds
@@ -4168,7 +4286,7 @@ function lowerObjCRuntimeCalls(source: string, index: ObjCIndex): string {
4168
4286
  }
4169
4287
  for (const list of declarations.values()) list.sort((a, b) => a.at - b.at)
4170
4288
  const braces = [...code.matchAll(/[{}]/g)].map((match) => match.index)
4171
- const receives = (name: string, at: number, type: string): boolean => {
4289
+ const receiverType = (name: string, at: number): string | undefined => {
4172
4290
  let innermost: { from: number; type: string } | undefined
4173
4291
  for (const declared of declarations.get(name) ?? []) {
4174
4292
  if (declared.at > at) break
@@ -4176,22 +4294,40 @@ function lowerObjCRuntimeCalls(source: string, index: ObjCIndex): string {
4176
4294
  if (at > from && at < to && (!innermost || from >= innermost.from))
4177
4295
  innermost = { from, type: declared.type }
4178
4296
  }
4179
- return innermost?.type === type
4297
+ return innermost?.type
4180
4298
  }
4181
4299
  let reached = -1
4182
4300
  for (const call of code.matchAll(
4183
4301
  /(?<![\w.])((?:type\s*\(\s*of\s*:\s*([A-Za-z_]\w*)\s*\))|[A-Za-z_]\w*)\s*\.\s*([A-Za-z_]\w*)\s*\(/g,
4184
4302
  )) {
4185
- if (
4186
- call.index < reached ||
4187
- !(call[2]
4188
- ? receives(call[2], call.index, 'AnyObject')
4189
- : receives(call[1]!, call.index, 'AnyClass'))
4190
- )
4191
- continue
4303
+ if (call.index < reached) continue
4304
+ const type = receiverType(call[2] ?? call[1]!, call.index)
4305
+ const constructed = call[2] ? type : type?.replace(/\.Type$/, '')
4306
+ const constructs =
4307
+ call[3] === 'init' &&
4308
+ constructed &&
4309
+ (call[2] || type?.endsWith('.Type')) &&
4310
+ index.nsObjectTypes.has(constructed.replace(/^Foundation\./, ''))
4311
+ if (!constructs && type !== (call[2] ? 'AnyObject' : 'AnyClass')) continue
4192
4312
  const open = call.index + call[0].length - 1
4193
4313
  const shut = closingParen(code, open)
4194
4314
  const passed = callArguments(source, code, open, shut)
4315
+ if (constructs && passed.length === 0) {
4316
+ // a class-constrained protocol metatype otherwise erases a generic
4317
+ // initializer result to NSObject, losing its protocol requirements.
4318
+ const result =
4319
+ constructed.replace(/^Foundation\./, '') === 'NSObject'
4320
+ ? constructed
4321
+ : `any ${constructed}`
4322
+ edits.push([
4323
+ call.index,
4324
+ shut + 1 - call.index,
4325
+ `_objcNSObjectDefaultInit(${call[1]}, returning: (${result}).self)`,
4326
+ ])
4327
+ reached = shut + 1
4328
+ continue
4329
+ }
4330
+ if (type !== (call[2] ? 'AnyObject' : 'AnyClass')) continue
4195
4331
  const selector = objcSelectorName(
4196
4332
  call[3]!,
4197
4333
  passed.map(({ label }) => label),
@@ -5213,18 +5349,29 @@ export function compileSwiftProjectUnit(
5213
5349
  multi: compileTargets(input).length > 0,
5214
5350
  }
5215
5351
  const hash = swiftArtifactHash(input, options.release, moduleRoot)
5352
+ const sharedRoot = path.join(rnxHomeDir(), 'cache', 'swift-projects', hash)
5353
+ const report = (message: string) =>
5354
+ console.error(`[swift-project ${hash.slice(0, 16)}] ${message}`)
5355
+ report('checking verified artifacts')
5216
5356
  return serialize(
5217
5357
  root,
5218
5358
  async () => {
5219
5359
  // reuse one verified result across server restarts before admission or
5220
5360
  // swiftpm. the address covers sources, framework inputs and toolchain.
5221
5361
  const retained = retainedArtifacts.get(root)?.find((old) => old.hash === hash)
5222
- if (retained) return retain(root, retained)
5223
- const cached = readCompiledArtifact(path.join(root, 'artifact'), hash)
5224
- if (cached) return retain(root, cached)
5225
- // an admitted mac slot already holds the build lock and the pinned
5226
- // toolchain, so it compiles here. a bare CONTRAST_REMOTE_BUILD value is
5227
- // only an inherited marker and still dispatches through admission.
5362
+ if (retained) {
5363
+ report('reusing memory artifact')
5364
+ return retain(root, retained)
5365
+ }
5366
+ const cached = readCompiledArtifact(path.join(sharedRoot, 'artifact'), hash)
5367
+ const previous = cached ?? readCompiledArtifact(path.join(root, 'artifact'), hash)
5368
+ if (previous) {
5369
+ if (!cached) saveCompiledArtifact(sharedRoot, previous)
5370
+ report('reusing verified disk artifact')
5371
+ return retain(root, previous)
5372
+ }
5373
+ // a mac builder with a live destination grant compiles here. inherited
5374
+ // transport markers alone still dispatch through admission.
5228
5375
  if (
5229
5376
  process.platform === 'darwin' &&
5230
5377
  process.env.TM_SESSION &&
@@ -5248,6 +5395,7 @@ export function compileSwiftProjectUnit(
5248
5395
  )
5249
5396
  try {
5250
5397
  const repo = path.resolve(moduleRoot, '../..')
5398
+ report('requesting builder admission or cached result')
5251
5399
  await runCompiler(
5252
5400
  'bun',
5253
5401
  [
@@ -5276,6 +5424,8 @@ export function compileSwiftProjectUnit(
5276
5424
  )
5277
5425
  }
5278
5426
  saveCompiledArtifact(root, artifact)
5427
+ saveCompiledArtifact(sharedRoot, artifact)
5428
+ report('builder artifact verified')
5279
5429
  return retain(root, artifact)
5280
5430
  } finally {
5281
5431
  rmSync(requestRoot, { recursive: true, force: true })
@@ -5288,6 +5438,7 @@ export function compileSwiftProjectUnit(
5288
5438
  `swift-compile: no swift.org toolchain at ${options.toolchainBin}; install ${options.release} and its matching wasm sdk`,
5289
5439
  )
5290
5440
  }
5441
+ report('preparing compiler plugins')
5291
5442
  const macroPlugins = await ensureMacroPlugins(
5292
5443
  moduleRoot,
5293
5444
  options.toolchainBin,
@@ -5343,8 +5494,9 @@ export function compileSwiftProjectUnit(
5343
5494
  timeout: options.timeoutMs,
5344
5495
  signal: options.signal,
5345
5496
  }
5497
+ report('compiling and linking Swift modules')
5346
5498
  try {
5347
- await runCompiler(swift, buildArgs, buildOptions)
5499
+ await runCompiler(swift, buildArgs, buildOptions, true)
5348
5500
  } catch (error) {
5349
5501
  const output = outputOf(error)
5350
5502
  const moduleCache = path.join(
@@ -5360,7 +5512,7 @@ export function compileSwiftProjectUnit(
5360
5512
  ) {
5361
5513
  rmSync(moduleCache, { recursive: true, force: true })
5362
5514
  try {
5363
- await runCompiler(swift, buildArgs, buildOptions)
5515
+ await runCompiler(swift, buildArgs, buildOptions, true)
5364
5516
  } catch (retryError) {
5365
5517
  throw new Error(
5366
5518
  projectDiagnostic(outputOf(retryError), layout, input.files),
@@ -5381,6 +5533,8 @@ export function compileSwiftProjectUnit(
5381
5533
  icu: await icu,
5382
5534
  }
5383
5535
  saveCompiledArtifact(root, artifact)
5536
+ saveCompiledArtifact(sharedRoot, artifact)
5537
+ report('compiled artifact verified')
5384
5538
  return retain(root, artifact)
5385
5539
  },
5386
5540
  options.signal,