peachsim 0.1.1620 → 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 (138) hide show
  1. package/README.md +35 -5
  2. package/cli/app-fonts.ts +2 -1
  3. package/cli/bridge-flow-runner.ts +184 -113
  4. package/cli/cloud-client.ts +68 -57
  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 +71 -36
  11. package/cli/commands/upload.ts +5 -1
  12. package/cli/dev-build-freshness.ts +37 -2
  13. package/cli/drivers/playwright-sim-host.ts +6 -5
  14. package/cli/maestro-accessibility.ts +53 -0
  15. package/cli/maestro-compare.ts +24 -0
  16. package/cli/outbound-endpoints.ts +22 -5
  17. package/cli/parent-pid.ts +0 -9
  18. package/cli/remote-simulators.ts +112 -0
  19. package/cli/remoteSimulatorContract.ts +21 -0
  20. package/detox/expectations.ts +5 -1
  21. package/detox/index.ts +133 -98
  22. package/detox/motion-change.cjs +37 -0
  23. package/detox/motion-change.d.cts +16 -0
  24. package/detox/navigation-lifecycle.ts +22 -4
  25. package/dist-lib/agent-daemon-client.cjs +3 -2
  26. package/dist-lib/agent-events.cjs +1 -1
  27. package/dist-lib/attached-projects.cjs +1 -1
  28. package/dist-lib/auth/shared-session.cjs +3 -2
  29. package/dist-lib/backend-origin.cjs +1 -1
  30. package/dist-lib/beta.cjs +1 -1
  31. package/dist-lib/beta.mjs +1 -1
  32. package/dist-lib/bridge-constants.cjs +1 -1
  33. package/dist-lib/bridge-contract-input.cjs +1 -1
  34. package/dist-lib/bridge-contract-input.mjs +1 -1
  35. package/dist-lib/bridge-contract.cjs +1 -1
  36. package/dist-lib/bridge-contract.mjs +1 -1
  37. package/dist-lib/bundle-channel.cjs +1520 -934
  38. package/dist-lib/capture-contract.cjs +1 -1
  39. package/dist-lib/capture-contract.mjs +1 -1
  40. package/dist-lib/cloud-contract.cjs +1 -1
  41. package/dist-lib/cloud-contract.mjs +1 -1
  42. package/dist-lib/cloud-test-args.cjs +1 -1
  43. package/dist-lib/cloud-test-args.mjs +1 -1
  44. package/dist-lib/cloud.cjs +3 -2
  45. package/dist-lib/cloud.mjs +3 -2
  46. package/dist-lib/config.cjs +5 -4
  47. package/dist-lib/config.mjs +5 -4
  48. package/dist-lib/detox/index.cjs +1021 -364
  49. package/dist-lib/dev-bundle-resolution.cjs +51 -23
  50. package/dist-lib/expo-swift-bundle.cjs +1343 -801
  51. package/dist-lib/expo-swift-metadata.cjs +5 -4
  52. package/dist-lib/expo-swift-metadata.mjs +5 -4
  53. package/dist-lib/home-paths.cjs +1 -1
  54. package/dist-lib/host/bridge-host.cjs +2468 -1341
  55. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  56. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  57. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  58. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  59. package/dist-lib/host/websocket-proxy.cjs +1 -1
  60. package/dist-lib/index.cjs +1262 -300
  61. package/dist-lib/jump-to-source-babel.cjs +1 -1
  62. package/dist-lib/jump-to-source-native.cjs +1 -1
  63. package/dist-lib/kotlin-host.cjs +1 -1
  64. package/dist-lib/menu.cjs +3 -2
  65. package/dist-lib/menu.mjs +3 -2
  66. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  67. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  68. package/dist-lib/metro-production-bundle.cjs +5 -4
  69. package/dist-lib/metro-production-bundle.mjs +5 -4
  70. package/dist-lib/metro.cjs +799 -398
  71. package/dist-lib/native-app-identity-project.cjs +728 -22
  72. package/dist-lib/native-app-identity-project.mjs +727 -22
  73. package/dist-lib/native-app-identity.cjs +224 -9
  74. package/dist-lib/native-app-identity.mjs +222 -8
  75. package/dist-lib/native-firebase-metadata.cjs +5 -4
  76. package/dist-lib/native-firebase-metadata.mjs +5 -4
  77. package/dist-lib/plist.cjs +1 -1
  78. package/dist-lib/plist.mjs +1 -1
  79. package/dist-lib/profiles.cjs +1 -1
  80. package/dist-lib/public-brand.cjs +3 -2
  81. package/dist-lib/react-native-host-modules.cjs +4 -1
  82. package/dist-lib/react-native-host-modules.mjs +4 -1
  83. package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
  84. package/dist-lib/sdk.cjs +89 -24
  85. package/dist-lib/sdk.mjs +89 -24
  86. package/dist-lib/skills.cjs +4391 -2350
  87. package/dist-lib/swift-compile.cjs +328 -142
  88. package/dist-lib/swift-compile.mjs +342 -147
  89. package/dist-lib/swift-package.cjs +1 -1
  90. package/dist-lib/swift-package.mjs +1 -1
  91. package/dist-lib/swift-submit.cjs +1 -1
  92. package/dist-lib/swift-submit.mjs +1 -1
  93. package/dist-lib/swift-toolchain.cjs +1 -1
  94. package/dist-lib/swift.cjs +1 -1
  95. package/dist-lib/vite-swift.cjs +888 -559
  96. package/dist-lib/vite.cjs +68 -14
  97. package/dist-lib/worklet-executable.cjs +25 -2
  98. package/dist-lib/worklet-executable.mjs +25 -2
  99. package/package.json +2 -2
  100. package/scripts/dev-server-scanner.ts +33 -13
  101. package/src/admitted-mac-build.ts +34 -8
  102. package/src/android-app-identity.ts +146 -0
  103. package/src/android-autolinking.ts +75 -0
  104. package/src/android-string-value.ts +36 -0
  105. package/src/android-strings.ts +5 -36
  106. package/src/androidAutolinkingContract.ts +40 -0
  107. package/src/bundleGlobalFooter.ts +26 -7
  108. package/src/codex-client.ts +3 -2
  109. package/src/dev-bundle-resolution.ts +58 -25
  110. package/src/expo-kotlin-host.ts +6 -36
  111. package/src/expo-swift-bundle.ts +17 -12
  112. package/src/expo-swift-host.ts +477 -173
  113. package/src/expoSwiftContract.ts +6 -0
  114. package/src/host/bridge-host.ts +7 -55
  115. package/src/host/staged-host-files.ts +29 -2
  116. package/src/installed-package-versions.ts +54 -14
  117. package/src/launchMetadataContract.ts +24 -0
  118. package/src/metro-plugin.ts +37 -42
  119. package/src/native-app-identity-project.ts +116 -3
  120. package/src/native-app-identity.ts +86 -11
  121. package/src/nitro-compile.ts +2 -5
  122. package/src/nitro-source.ts +147 -63
  123. package/src/process-group.ts +90 -60
  124. package/src/process-output.ts +0 -9
  125. package/src/public-brand.ts +1 -0
  126. package/src/react-native-host-modules.ts +3 -0
  127. package/src/sim-client.ts +6 -4
  128. package/src/swift-compile.ts +176 -28
  129. package/src/swift-distribution.lock.json +37 -11
  130. package/src/swift-distribution.ts +61 -9
  131. package/src/swift-host.ts +58 -27
  132. package/src/swift-objc-standin.ts +13 -0
  133. package/src/swift-standin-emit.ts +1 -1
  134. package/src/swift-standins.ts +17 -5
  135. package/src/vite-plugin.ts +8 -19
  136. package/src/worklet-executable.ts +30 -3
  137. package/src/worklets-babel.ts +35 -14
  138. 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,8 @@ 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++
722
725
  // v47: target ownership honors exclusions before lowering manifest-named files
723
726
  // v46: view builder literals compile as design-time lookups
724
727
  // v45: the link runs no wasm-opt pass, whatever the compiling machine has
@@ -766,7 +769,7 @@ export function swiftArtifactHash(
766
769
  // v6: app and scene delegate classes compile final
767
770
  // (v5: a target declaring resources gets SwiftPM's Bundle.module accessor;
768
771
  // v4: sources compile through swiftSourceAsIOS)
769
- hash.update(`swift-compile/v47\n${release}\n`)
772
+ hash.update(`swift-compile/v49\n${release}\n`)
770
773
  if (input.oneModule) hash.update('one-module\n')
771
774
  hash.update(swiftModuleDigest(moduleRoot))
772
775
  for (const file of Object.keys(input.files).sort()) {
@@ -1606,12 +1609,73 @@ function compiledSwiftSources(
1606
1609
  const sources = lifted.map(([, contents]) => contents)
1607
1610
  const extensions = objcExtensionMemberIndex(sources)
1608
1611
  const objc = objcIndex(sources)
1612
+ const rooted = objcRootedClasses(sources)
1609
1613
  for (const [file, contents] of lifted)
1610
- compiled.set(file, swiftSourceAsIOS(contents, extensions, objc))
1614
+ compiled.set(
1615
+ file,
1616
+ hideObjCClassesFromCxx(swiftSourceAsIOS(contents, extensions, objc), rooted),
1617
+ )
1611
1618
  }
1612
1619
  return compiled
1613
1620
  }
1614
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
+
1615
1679
  // wasm Swift has no ObjC runtime, so a member read on AnyObject never
1616
1680
  // resolves: `value of type 'AnyObject' has no member 'doubleValue'`. native
1617
1681
  // code that bridges dictionary values through AnyObject to reach NSNumber's
@@ -3602,6 +3666,8 @@ export type ObjCIndex = {
3602
3666
  // each @objc type method's selector to its result type, null when two
3603
3667
  // declarations disagree
3604
3668
  typeMethods: Map<string, string | null>
3669
+ // NSObject and protocols constrained to it without a Swift init requirement
3670
+ nsObjectTypes: Set<string>
3605
3671
  }
3606
3672
 
3607
3673
  // the @objc type methods `source` declares, by selector, into `found`
@@ -3739,15 +3805,43 @@ export function objcIndex(sources: string[]): ObjCIndex {
3739
3805
  /\b(?:var|let)\s+([A-Za-z_]\w*)/g,
3740
3806
  ))
3741
3807
  variables.add(declared[1]!)
3742
- const parents = requirements.size
3743
- ? inheritedTypes(sources)
3744
- : new Map<string, Set<string>>()
3808
+ const parents = inheritedTypes(sources)
3745
3809
  for (const [protocol, inherited] of sdkParents)
3746
3810
  parents.set(protocol, new Set([...(parents.get(protocol) ?? []), ...inherited]))
3747
3811
  const conformers = requirements.size
3748
3812
  ? conformingTypes(parents, new Set(requirements.keys()))
3749
3813
  : new Map<string, Set<string>>()
3750
- 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
+ }
3751
3845
  }
3752
3846
 
3753
3847
  // the non-generic file-scope typealiases `sources` declare, each resolved
@@ -4117,7 +4211,7 @@ const IMP_CONVENTION = /@convention\(c\)(?=\s*\(\s*AnyObject\s*,\s*Selector\b)/g
4117
4211
 
4118
4212
  function lowerObjCRuntimeCalls(source: string, index: ObjCIndex): string {
4119
4213
  const casts = source.includes('@convention(c)')
4120
- const classes = /\b(?:AnyClass|AnyObject)\b/.test(source)
4214
+ const classes = /\b(?:AnyClass|AnyObject)\b|\.\s*init\s*\(/.test(source)
4121
4215
  if (!casts && !classes) return source
4122
4216
  const code = swiftCodeMask(source)
4123
4217
  const edits: [number, number, string][] = []
@@ -4148,15 +4242,33 @@ function lowerObjCRuntimeCalls(source: string, index: ObjCIndex): string {
4148
4242
  }
4149
4243
  }
4150
4244
  if (classes) {
4151
- // every declaration of a name, typed or not; a receiver is an AnyClass
4152
- // 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
4153
4247
  const declarations = new Map<string, { at: number; type: string }[]>()
4154
4248
  for (const declared of code.matchAll(
4155
- /(?:\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,
4156
4250
  )) {
4157
4251
  const name = declared[1] ?? declared[4] ?? declared[5]!
4158
4252
  const list = declarations.get(name) ?? []
4159
- 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 })
4160
4272
  declarations.set(name, list)
4161
4273
  }
4162
4274
  // a tuple or enum pattern (`let (a, b)`, `case let .some(a)`) binds
@@ -4174,7 +4286,7 @@ function lowerObjCRuntimeCalls(source: string, index: ObjCIndex): string {
4174
4286
  }
4175
4287
  for (const list of declarations.values()) list.sort((a, b) => a.at - b.at)
4176
4288
  const braces = [...code.matchAll(/[{}]/g)].map((match) => match.index)
4177
- const receives = (name: string, at: number, type: string): boolean => {
4289
+ const receiverType = (name: string, at: number): string | undefined => {
4178
4290
  let innermost: { from: number; type: string } | undefined
4179
4291
  for (const declared of declarations.get(name) ?? []) {
4180
4292
  if (declared.at > at) break
@@ -4182,22 +4294,40 @@ function lowerObjCRuntimeCalls(source: string, index: ObjCIndex): string {
4182
4294
  if (at > from && at < to && (!innermost || from >= innermost.from))
4183
4295
  innermost = { from, type: declared.type }
4184
4296
  }
4185
- return innermost?.type === type
4297
+ return innermost?.type
4186
4298
  }
4187
4299
  let reached = -1
4188
4300
  for (const call of code.matchAll(
4189
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,
4190
4302
  )) {
4191
- if (
4192
- call.index < reached ||
4193
- !(call[2]
4194
- ? receives(call[2], call.index, 'AnyObject')
4195
- : receives(call[1]!, call.index, 'AnyClass'))
4196
- )
4197
- 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
4198
4312
  const open = call.index + call[0].length - 1
4199
4313
  const shut = closingParen(code, open)
4200
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
4201
4331
  const selector = objcSelectorName(
4202
4332
  call[3]!,
4203
4333
  passed.map(({ label }) => label),
@@ -5219,18 +5349,29 @@ export function compileSwiftProjectUnit(
5219
5349
  multi: compileTargets(input).length > 0,
5220
5350
  }
5221
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')
5222
5356
  return serialize(
5223
5357
  root,
5224
5358
  async () => {
5225
5359
  // reuse one verified result across server restarts before admission or
5226
5360
  // swiftpm. the address covers sources, framework inputs and toolchain.
5227
5361
  const retained = retainedArtifacts.get(root)?.find((old) => old.hash === hash)
5228
- if (retained) return retain(root, retained)
5229
- const cached = readCompiledArtifact(path.join(root, 'artifact'), hash)
5230
- if (cached) return retain(root, cached)
5231
- // an admitted mac slot already holds the build lock and the pinned
5232
- // toolchain, so it compiles here. a bare CONTRAST_REMOTE_BUILD value is
5233
- // 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.
5234
5375
  if (
5235
5376
  process.platform === 'darwin' &&
5236
5377
  process.env.TM_SESSION &&
@@ -5254,6 +5395,7 @@ export function compileSwiftProjectUnit(
5254
5395
  )
5255
5396
  try {
5256
5397
  const repo = path.resolve(moduleRoot, '../..')
5398
+ report('requesting builder admission or cached result')
5257
5399
  await runCompiler(
5258
5400
  'bun',
5259
5401
  [
@@ -5282,6 +5424,8 @@ export function compileSwiftProjectUnit(
5282
5424
  )
5283
5425
  }
5284
5426
  saveCompiledArtifact(root, artifact)
5427
+ saveCompiledArtifact(sharedRoot, artifact)
5428
+ report('builder artifact verified')
5285
5429
  return retain(root, artifact)
5286
5430
  } finally {
5287
5431
  rmSync(requestRoot, { recursive: true, force: true })
@@ -5294,6 +5438,7 @@ export function compileSwiftProjectUnit(
5294
5438
  `swift-compile: no swift.org toolchain at ${options.toolchainBin}; install ${options.release} and its matching wasm sdk`,
5295
5439
  )
5296
5440
  }
5441
+ report('preparing compiler plugins')
5297
5442
  const macroPlugins = await ensureMacroPlugins(
5298
5443
  moduleRoot,
5299
5444
  options.toolchainBin,
@@ -5349,8 +5494,9 @@ export function compileSwiftProjectUnit(
5349
5494
  timeout: options.timeoutMs,
5350
5495
  signal: options.signal,
5351
5496
  }
5497
+ report('compiling and linking Swift modules')
5352
5498
  try {
5353
- await runCompiler(swift, buildArgs, buildOptions)
5499
+ await runCompiler(swift, buildArgs, buildOptions, true)
5354
5500
  } catch (error) {
5355
5501
  const output = outputOf(error)
5356
5502
  const moduleCache = path.join(
@@ -5366,7 +5512,7 @@ export function compileSwiftProjectUnit(
5366
5512
  ) {
5367
5513
  rmSync(moduleCache, { recursive: true, force: true })
5368
5514
  try {
5369
- await runCompiler(swift, buildArgs, buildOptions)
5515
+ await runCompiler(swift, buildArgs, buildOptions, true)
5370
5516
  } catch (retryError) {
5371
5517
  throw new Error(
5372
5518
  projectDiagnostic(outputOf(retryError), layout, input.files),
@@ -5387,6 +5533,8 @@ export function compileSwiftProjectUnit(
5387
5533
  icu: await icu,
5388
5534
  }
5389
5535
  saveCompiledArtifact(root, artifact)
5536
+ saveCompiledArtifact(sharedRoot, artifact)
5537
+ report('compiled artifact verified')
5390
5538
  return retain(root, artifact)
5391
5539
  },
5392
5540
  options.signal,