peachsim 0.1.1625 → 0.1.1640

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -2
  3. package/cli/app-config.ts +18 -6
  4. package/cli/app-fonts.ts +37 -17
  5. package/cli/app-stubs.ts +284 -20
  6. package/cli/bridge-flow-runner.ts +66 -45
  7. package/cli/cloud-client.ts +60 -40
  8. package/cli/command-registry.ts +1 -0
  9. package/cli/commands/control.ts +283 -49
  10. package/cli/commands/doctor.ts +30 -189
  11. package/cli/commands/flow.ts +79 -11
  12. package/cli/commands/inspect/core.ts +10 -5
  13. package/cli/commands/inspect/settling.ts +14 -2
  14. package/cli/commands/inspect/shared.ts +9 -4
  15. package/cli/commands/inspect/wait-ready.ts +12 -0
  16. package/cli/commands/inspect.ts +91 -50
  17. package/cli/commands/install-desktop.ts +1 -0
  18. package/cli/commands/maestro-compare.ts +97 -38
  19. package/cli/commands/maestro.ts +9 -1
  20. package/cli/commands/preview.ts +1 -1
  21. package/cli/commands/record.ts +118 -48
  22. package/cli/commands/screenshot.ts +23 -1
  23. package/cli/commands/serve.ts +7 -2
  24. package/cli/commands/setup.ts +19 -42
  25. package/cli/commands/swift.ts +14 -3
  26. package/cli/commands/upload.ts +93 -48
  27. package/cli/desktop-companion.ts +102 -17
  28. package/cli/drivers/desktop.ts +14 -1
  29. package/cli/drivers/playwright-sim-host.ts +103 -45
  30. package/cli/drivers/playwright.ts +9 -8
  31. package/cli/drivers/types.ts +5 -0
  32. package/cli/maestro-accessibility.ts +1 -1
  33. package/cli/maestro-compare.ts +84 -12
  34. package/cli/main.ts +3 -2
  35. package/cli/outbound-endpoints.ts +20 -6
  36. package/cli/pause-aware-timeout.ts +106 -0
  37. package/cli/scene-video.ts +89 -0
  38. package/cli/ws-bridge.ts +37 -19
  39. package/detox/attempt-events.cjs +66 -5
  40. package/detox/attempt-events.d.cts +7 -0
  41. package/detox/expectations.ts +7 -3
  42. package/detox/index.ts +269 -170
  43. package/detox/motion-change.cjs +206 -21
  44. package/detox/motion-change.d.cts +39 -2
  45. package/detox/navigation-lifecycle.ts +35 -3
  46. package/dist-lib/agent-daemon-client.cjs +1 -1
  47. package/dist-lib/agent-events.cjs +1 -1
  48. package/dist-lib/attached-projects.cjs +1 -1
  49. package/dist-lib/auth/shared-session.cjs +1 -1
  50. package/dist-lib/backend-origin.cjs +8 -7
  51. package/dist-lib/beta.cjs +1 -1
  52. package/dist-lib/beta.mjs +1 -1
  53. package/dist-lib/bridge-constants.cjs +13 -1
  54. package/dist-lib/bridge-contract-input.cjs +1 -1
  55. package/dist-lib/bridge-contract-input.mjs +1 -1
  56. package/dist-lib/bridge-contract.cjs +14 -8
  57. package/dist-lib/bridge-contract.mjs +13 -7
  58. package/dist-lib/bundle-channel.cjs +11887 -7900
  59. package/dist-lib/capture-contract.cjs +1 -1
  60. package/dist-lib/capture-contract.mjs +1 -1
  61. package/dist-lib/cloud-contract.cjs +1 -1
  62. package/dist-lib/cloud-contract.mjs +1 -1
  63. package/dist-lib/cloud-test-args.cjs +1 -1
  64. package/dist-lib/cloud-test-args.mjs +1 -1
  65. package/dist-lib/cloud.cjs +1 -1
  66. package/dist-lib/cloud.mjs +1 -1
  67. package/dist-lib/config.cjs +6 -3
  68. package/dist-lib/config.mjs +6 -3
  69. package/dist-lib/detox/index.cjs +1379 -725
  70. package/dist-lib/dev-bundle-resolution.cjs +14 -2
  71. package/dist-lib/expo-swift-bundle.cjs +9341 -5462
  72. package/dist-lib/expo-swift-metadata.cjs +11 -7
  73. package/dist-lib/expo-swift-metadata.mjs +11 -7
  74. package/dist-lib/home-paths.cjs +32 -3
  75. package/dist-lib/host/bridge-host.cjs +50204 -7258
  76. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  77. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  78. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  79. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  80. package/dist-lib/host/websocket-proxy.cjs +1 -1
  81. package/dist-lib/index.cjs +3541 -448
  82. package/dist-lib/jump-to-source-babel.cjs +1 -1
  83. package/dist-lib/jump-to-source-native.cjs +1 -1
  84. package/dist-lib/kotlin-host.cjs +1 -1
  85. package/dist-lib/menu.cjs +1 -1
  86. package/dist-lib/menu.mjs +1 -1
  87. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  88. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  89. package/dist-lib/metro-production-bundle.cjs +1 -1
  90. package/dist-lib/metro-production-bundle.mjs +1 -1
  91. package/dist-lib/metro.cjs +2931 -104
  92. package/dist-lib/native-app-identity-project.cjs +289 -49
  93. package/dist-lib/native-app-identity-project.mjs +289 -49
  94. package/dist-lib/native-app-identity.cjs +77 -26
  95. package/dist-lib/native-app-identity.mjs +76 -26
  96. package/dist-lib/native-firebase-metadata.cjs +1 -1
  97. package/dist-lib/native-firebase-metadata.mjs +1 -1
  98. package/dist-lib/plist.cjs +1 -1
  99. package/dist-lib/plist.mjs +1 -1
  100. package/dist-lib/profiles.cjs +1 -1
  101. package/dist-lib/public-brand.cjs +1 -1
  102. package/dist-lib/react-native-host-modules.cjs +6 -2
  103. package/dist-lib/react-native-host-modules.mjs +6 -2
  104. package/dist-lib/scripts/dev-server-scanner.cjs +22 -8
  105. package/dist-lib/sdk.cjs +27 -11
  106. package/dist-lib/sdk.mjs +27 -10
  107. package/dist-lib/skills.cjs +72201 -26208
  108. package/dist-lib/swift-compile.cjs +1007 -140
  109. package/dist-lib/swift-compile.mjs +1049 -174
  110. package/dist-lib/swift-package.cjs +12 -3
  111. package/dist-lib/swift-package.mjs +12 -3
  112. package/dist-lib/swift-submit.cjs +13 -5
  113. package/dist-lib/swift-submit.mjs +11 -5
  114. package/dist-lib/swift-toolchain.cjs +423 -33
  115. package/dist-lib/swift.cjs +1 -1
  116. package/dist-lib/vite-swift.cjs +8628 -5268
  117. package/dist-lib/vite.cjs +2859 -12
  118. package/dist-lib/worklet-executable.cjs +14 -2
  119. package/dist-lib/worklet-executable.mjs +14 -2
  120. package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
  121. package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
  122. package/package.json +1 -1
  123. package/scripts/dev-server-scanner.ts +2 -1
  124. package/skills/peach-perf/SKILL.md +11 -5
  125. package/skills/peach-setup/SKILL.md +16 -19
  126. package/src/backend-origin.ts +13 -6
  127. package/src/bridge-constants.ts +11 -0
  128. package/src/bridge-contract.ts +22 -34
  129. package/src/config.ts +15 -0
  130. package/src/desktop-host-delivery.ts +5 -1
  131. package/src/dev-bundle-resolution.ts +2 -1
  132. package/src/expo-manifest-body.ts +13 -0
  133. package/src/expo-swift-host.ts +196 -49
  134. package/src/expo-swift-ready.ts +78 -0
  135. package/src/expoKotlinContract.ts +4 -7
  136. package/src/expoSwiftContract.ts +17 -6
  137. package/src/home-paths.ts +48 -2
  138. package/src/host/bridge-host.ts +5 -2
  139. package/src/host/staged-host-files.ts +9 -0
  140. package/src/native-app-identity-project.ts +27 -6
  141. package/src/native-app-identity.ts +91 -4
  142. package/src/native-firebase-config.ts +209 -13
  143. package/src/nativeAppIconContract.ts +1 -4
  144. package/src/output-lock-stdio.ts +36 -0
  145. package/src/process-group.ts +25 -2
  146. package/src/react-native-bridge-modules.ts +431 -0
  147. package/src/react-native-host-modules.ts +8 -1
  148. package/src/settings-bundle.ts +1 -4
  149. package/src/swift-binary-modules.ts +2 -2
  150. package/src/swift-compile.ts +918 -109
  151. package/src/swift-compiler-digest.ts +14 -0
  152. package/src/swift-distribution.lock.json +24 -12
  153. package/src/swift-distribution.ts +103 -4
  154. package/src/swift-host.ts +180 -55
  155. package/src/swift-icu.ts +27 -13
  156. package/src/swift-objc-compile.ts +355 -0
  157. package/src/swift-objc-sdk.ts +821 -27
  158. package/src/swift-objc-standin.ts +148 -14
  159. package/src/swift-package-manifest.ts +60 -7
  160. package/src/swift-package.ts +15 -1
  161. package/src/swift-packages.ts +154 -81
  162. package/src/swift-standin-emit.ts +96 -15
  163. package/src/swift-standins.ts +530 -116
  164. package/src/swift-submit.ts +13 -5
  165. package/src/swift-toolchain.ts +500 -42
  166. package/src/vite-plugin.ts +12 -1
  167. package/src/worker-system-chunks.ts +11 -12
  168. package/src/worklet-executable.ts +19 -1
  169. package/src/xcode-project.ts +270 -30
@@ -20,6 +20,7 @@
20
20
  * Browser-safe: node-only submitters live beside their call sites.
21
21
  */
22
22
 
23
+ import { isRecord } from './is-record'
23
24
  import {
24
25
  isSwiftMetalLibrarySources,
25
26
  type SwiftMetalLibrarySource,
@@ -32,6 +33,10 @@ export const SWIFT_HMR_PATH = '/__peach/swift-hmr'
32
33
 
33
34
  // shared with the compile service; collection enforces these before reading.
34
35
  export const MAX_SWIFT_COMPILE_FILES = 256
36
+ /** how long one swift build may run when the caller asks for no budget. */
37
+ export const DEFAULT_SWIFT_COMPILE_BUILD_MS = 10 * 60_000
38
+ /** the longest build budget a caller may request. */
39
+ export const MAX_SWIFT_COMPILE_BUILD_MS = 60 * 60_000
35
40
  export const MAX_SWIFT_COMPILE_SOURCE_BYTES = 2 * 1024 * 1024
36
41
  export const MAX_SWIFT_COMPILE_RESOURCES = 128
37
42
  export const MAX_SWIFT_COMPILE_RESOURCE_BYTES = 2 * 1024 * 1024
@@ -104,7 +109,8 @@ export const MAX_SWIFT_COMPILE_IMAGE_TOTAL_BYTES = 16 * 1024 * 1024
104
109
  // 45: confirmation dialogs present from a measured `presentationSource`, and a
105
110
  // refreshable's pull ends on the host's `refreshEnded` request.
106
111
  // 46: ImageRenderer produces shared CoreGraphics pixels through the isolated raster host.
107
- export const SWIFT_HOST_ABI = 46
112
+ // 47: backing-layer masks composite the view, border and children through mask alpha.
113
+ export const SWIFT_HOST_ABI = 47
108
114
 
109
115
  export interface SwiftImageUpload {
110
116
  /** the raster file picked for the asset (has the extension). */
@@ -237,6 +243,11 @@ export type SwiftCompileSubmit = (work: {
237
243
  entry: string
238
244
  /** the directory-derived id of the One package. */
239
245
  packageId: string
246
+ /**
247
+ * how long the build may run, up to `MAX_SWIFT_COMPILE_BUILD_MS`; absent means
248
+ * the default. hosted requests above the default require an internal compile key.
249
+ */
250
+ buildBudgetMs?: number
240
251
  }) => Promise<SwiftCompileOutcome>
241
252
 
242
253
  /** the HTTP submitter, shared by the browser worker and the node build. */
@@ -285,10 +296,6 @@ export function httpSwiftCompileSubmit(
285
296
  }
286
297
  }
287
298
 
288
- function isRecord(value: unknown): value is Record<string, unknown> {
289
- return typeof value === 'object' && value !== null && !Array.isArray(value)
290
- }
291
-
292
299
  export function isSwiftNativeModules(value: unknown): value is SwiftNativeModules {
293
300
  return (
294
301
  isRecord(value) &&
@@ -394,6 +401,7 @@ export async function compileSwiftProject(
394
401
  images?: Record<string, SwiftImageUpload>
395
402
  entry: string
396
403
  packageId: string
404
+ buildBudgetMs?: number
397
405
  },
398
406
  origin: string,
399
407
  signal?: AbortSignal,
@@ -1,4 +1,4 @@
1
- // the one place that finds the swift.org toolchain app Swift compiles with.
1
+ // the one place that selects and verifies the toolchain app Swift compiles with.
2
2
  // a binary .swiftmodule loads only in the compiler release that wrote it, so
3
3
  // every caller names the release from packages/sootsim-swift/toolchain.json
4
4
  // or the Swift distribution's lock (docs/swift-distribution.md).
@@ -6,21 +6,72 @@
6
6
  // node builtins only: packages/sootsim-swift/scripts/build.sh imports this
7
7
  // file straight from a checkout, with no install.
8
8
  import { execFileSync } from 'node:child_process'
9
+ import { createHash } from 'node:crypto'
9
10
  import fs from 'node:fs'
10
11
  import os from 'node:os'
11
12
  import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ /** swiftpm's SDK directory option, shared by discovery and compilation. */
16
+ export function swiftSdkOptions(): string[] {
17
+ const directory = process.env.PEACH_SWIFT_SDKS_PATH
18
+ return directory ? ['--swift-sdks-path', path.resolve(directory)] : []
19
+ }
20
+
21
+ // host archives include the official driver/runtime and the qualified patched frontend.
22
+ export const SWIFT_HOST_COMPILER_RELEASE = 'swift-6.4.0-RELEASE-custom-0d775d32d7c74eae'
23
+ export const SWIFT_HOST_COMPILER_ID = '0d775d32d7c74eae'
24
+ const compilerArchives: Record<string, { checksum: string; manifest: string }> = {
25
+ 'darwin-arm64': {
26
+ checksum: '957e85486f92327ee5cf8d3c5bb9336d17ab80145c87645c637c49130a50477a',
27
+ manifest: 'c63e2f81694e8d8ad046d10c71d0638dbae4e5a36601294ed670988d1cb5b6f1',
28
+ },
29
+ 'linux-x64': {
30
+ checksum: '6302230fb126b79c9af5fc2bc0208a936bb573c343da7ae949c5c1687e761cd2',
31
+ manifest: '31b507a6a8648a2266a187b9fed092e29a63cae99d57c5defdf2e82a7cbbc113',
32
+ },
33
+ 'linux-arm64': {
34
+ checksum: '9b40d39ea5ba82fd9a417868f0a449f5e98e37cf83acaac9cedd3a8b8d91a218',
35
+ manifest: 'fe07a29d4f4374cad47b29de70e90aa2e3b7664e08c8b53d304585e65637da24',
36
+ },
37
+ }
38
+
39
+ export function swiftHostCompilerArchive(
40
+ release: string,
41
+ host = `${process.platform}-${process.arch}`,
42
+ ) {
43
+ if (release !== SWIFT_HOST_COMPILER_RELEASE)
44
+ throw new Error(`unqualified host compiler ${release}`)
45
+ const archive = compilerArchives[host]
46
+ if (!archive) throw new Error(`unsupported Swift compiler host ${host}`)
47
+ return {
48
+ ...archive,
49
+ identity: SWIFT_HOST_COMPILER_ID,
50
+ url: `https://r2.sootsim.com/swift-host-compiler/${SWIFT_HOST_COMPILER_ID}/${release}-${host}.tar.gz`,
51
+ }
52
+ }
53
+
54
+ // source archives and bootstrap images retain the official release name.
55
+ export function swiftUpstreamRelease(release: string): string {
56
+ return release.replace(/-custom-[a-f0-9]{16}$/, '')
57
+ }
12
58
 
13
59
  // swift.org's x.y.0 archives report the compiler identity as x.y.
14
60
  export function swiftCompilerRelease(release: string): string {
15
- return release.replace(/^swift-(\d+\.\d+)\.0-RELEASE$/, 'swift-$1-RELEASE')
61
+ return swiftUpstreamRelease(release).replace(
62
+ /^swift-(\d+\.\d+)\.0-RELEASE$/,
63
+ 'swift-$1-RELEASE',
64
+ )
16
65
  }
17
66
 
18
67
  /**
19
68
  * the bin directory of the toolchain for `release`, unverified. macOS: the
20
- * Xcode Toolchains directory, the user's before the system's. Linux: a
21
- * builder's `$CONTRAST_SWIFT_TOOLCHAINS/<release>`
69
+ * Xcode Toolchains directory, the user's before the system's. Linux:
70
+ * `$CONTRAST_SWIFT_TOOLCHAINS/<release>` when set
22
71
  * (packages/sootsim-swift/scripts/install-linux-toolchain.sh fills it), else
23
- * the cf-build image's `/opt/swift/<release>`, else the `swift` first on PATH.
72
+ * the cf-build image's `/opt/swift/<release>` when present, else a pinned
73
+ * compiler's user-writable `$PEACH_HOME/swift/toolchains/<release>`, else the
74
+ * `swift` first on PATH.
24
75
  */
25
76
  export function swiftToolchainBin(release: string): string {
26
77
  if (process.platform === 'darwin') {
@@ -34,6 +85,8 @@ export function swiftToolchainBin(release: string): string {
34
85
  }
35
86
  const image = path.join('/opt/swift', release, 'usr/bin')
36
87
  if (fs.existsSync(path.join(image, 'swift'))) return image
88
+ if (release.includes('-custom-'))
89
+ return path.join(peachHome(), 'swift', 'toolchains', release, 'usr/bin')
37
90
  return (
38
91
  (process.env.PATH ?? '')
39
92
  .split(path.delimiter)
@@ -41,6 +94,106 @@ export function swiftToolchainBin(release: string): string {
41
94
  )
42
95
  }
43
96
 
97
+ // home-paths.ts's rnxHomeDir, repeated because this file imports node builtins only.
98
+ function peachHome(): string {
99
+ const override = process.env.PEACH_HOME
100
+ return override ? path.resolve(override) : path.join(os.homedir(), '.peach')
101
+ }
102
+
103
+ let scratch: string | undefined
104
+ let swiftHome: string | undefined
105
+ /**
106
+ * keep the temporary files swift processes write (swiftpm's lock files and
107
+ * TemporaryDirectory folders, which it never removes) in a per-process
108
+ * directory under `root`, removed when this process exits, and on linux its
109
+ * home directory in `root/home`, kept so the caches persist. directories left
110
+ * by processes that no longer run are removed first. without this call swift
111
+ * inherits the caller's TMPDIR.
112
+ */
113
+ export function confineSwiftScratch(root: string): void {
114
+ if (scratch) return
115
+ fs.mkdirSync(root, { recursive: true })
116
+ for (const name of fs.readdirSync(root)) {
117
+ const pid = Number(name)
118
+ if (Number.isInteger(pid) && pid > 0 && !processRuns(pid))
119
+ fs.rmSync(path.join(root, name), { recursive: true, force: true })
120
+ }
121
+ const dir = path.join(root, String(process.pid))
122
+ fs.mkdirSync(dir, { recursive: true })
123
+ scratch = dir
124
+ // swiftpm and clang write ~/.swiftpm and ~/.cache even with every path flag
125
+ // given; on linux no tool needs the real home, so they get a kept one here.
126
+ if (process.platform === 'linux') {
127
+ swiftHome = path.join(root, 'home')
128
+ fs.mkdirSync(swiftHome, { recursive: true })
129
+ }
130
+ // swiftpm makes ~/.swiftpm in the account's passwd home whatever HOME says;
131
+ // remove at exit only the entries swiftpm seeds there that were absent now:
132
+ // a cache link into a swiftpm cache folder and empty configuration and
133
+ // security folders. anything else, a link or file a user made included, stays.
134
+ const seeded = path.join(os.homedir(), '.swiftpm')
135
+ const dirAbsentBefore = lstatIfKnown(seeded) === null
136
+ const absentBefore = swiftpmSeeds.filter(
137
+ (name) => lstatIfKnown(path.join(seeded, name)) === null,
138
+ )
139
+ process.once('exit', () => {
140
+ fs.rmSync(dir, { recursive: true, force: true })
141
+ for (const name of absentBefore) removeSwiftpmSeed(path.join(seeded, name), name)
142
+ if (dirAbsentBefore && lstatIfKnown(seeded)?.isDirectory())
143
+ try {
144
+ fs.rmdirSync(seeded)
145
+ } catch {}
146
+ })
147
+ }
148
+
149
+ const swiftpmSeeds = ['cache', 'configuration', 'security']
150
+
151
+ // only a missing path establishes absence; other lookup failures stay unknown.
152
+ function lstatIfKnown(entry: string): fs.Stats | null | undefined {
153
+ try {
154
+ return fs.lstatSync(entry)
155
+ } catch (error) {
156
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null
157
+ return undefined
158
+ }
159
+ }
160
+
161
+ function removeSwiftpmSeed(entry: string, name: string): void {
162
+ const stat = lstatIfKnown(entry)
163
+ if (!stat) return
164
+ try {
165
+ if (name === 'cache') {
166
+ if (
167
+ stat.isSymbolicLink() &&
168
+ fs.readlinkSync(entry).endsWith('/.cache/org.swift.swiftpm')
169
+ )
170
+ fs.unlinkSync(entry)
171
+ } else if (stat.isDirectory()) {
172
+ fs.rmdirSync(entry)
173
+ }
174
+ } catch {}
175
+ }
176
+
177
+ function processRuns(pid: number): boolean {
178
+ try {
179
+ process.kill(pid, 0)
180
+ return true
181
+ } catch (error) {
182
+ return error instanceof Error && 'code' in error && error.code === 'EPERM'
183
+ }
184
+ }
185
+
186
+ /** the environment every swift process runs with: `base`, with TMPDIR in the
187
+ * confined scratch directory when the caller set one. */
188
+ export function swiftEnvironment(
189
+ base: NodeJS.ProcessEnv = process.env,
190
+ ): NodeJS.ProcessEnv {
191
+ if (!scratch) return base
192
+ return swiftHome
193
+ ? { ...base, TMPDIR: scratch, HOME: swiftHome }
194
+ : { ...base, TMPDIR: scratch }
195
+ }
196
+
44
197
  /** the wasm SDK a release compiles with: its installed id, where it
45
198
  * downloads from and its archive checksum (packages/sootsim-swift/toolchain.json). */
46
199
  export interface SwiftWasmSdk {
@@ -49,67 +202,372 @@ export interface SwiftWasmSdk {
49
202
  wasmSdkChecksum: string
50
203
  }
51
204
 
52
- const verified = new Map<string, string>()
53
-
54
205
  /**
55
206
  * swiftToolchainBin, checked: its swift is exactly `release` and has the
56
207
  * pinned wasm SDK installed. anything else fails with one message naming the
57
208
  * release and the commands that install it.
58
209
  */
59
210
  export function swiftToolchain(release: string, sdk: SwiftWasmSdk): string {
60
- const cached = verified.get(`${release}\0${sdk.wasmSdkId}`)
61
- if (cached) return cached
62
- const bin = swiftToolchainBin(release)
63
- const swift = path.join(bin, 'swift')
64
- const version = fs.existsSync(swift)
65
- ? execFileSync(swift, ['--version'], {
66
- encoding: 'utf8',
67
- stdio: ['ignore', 'pipe', 'pipe'],
68
- })
69
- : ''
70
- if (!version.includes(`(${swiftCompilerRelease(release)})`)) {
71
- const found = version
72
- ? `found ${version.split('\n')[0]?.trim()} at ${swift}`
73
- : process.platform === 'darwin'
74
- ? `found no ${release}.xctoolchain in ~/Library/Developer/Toolchains or /Library/Developer/Toolchains`
75
- : 'found no swift on PATH'
211
+ validateSwiftSdkIdentity(sdk)
212
+ let bin: string
213
+ try {
214
+ bin = swiftCompiler(release)
215
+ } catch (error) {
216
+ const reason = error instanceof Error ? error.message : String(error)
76
217
  throw new Error(
77
- `peach compiles Swift with the swift.org toolchain ${release} and its wasm SDK; ${found}.\n${swiftInstallSteps(release, sdk)}`,
218
+ `Swift compiler ${release} is unavailable or differs from its pin: ${reason}\n${swiftInstallSteps(release, sdk)}`,
78
219
  )
79
220
  }
80
- const sdks = execFileSync(swift, ['sdk', 'list'], {
221
+ const swift = path.join(bin, 'swift')
222
+ const sdks = execFileSync(swift, ['sdk', 'list', ...swiftSdkOptions()], {
81
223
  encoding: 'utf8',
224
+ env: swiftEnvironment(),
82
225
  stdio: ['ignore', 'pipe', 'pipe'],
83
226
  })
84
227
  if (!sdks.split('\n').some((line) => line.trim() === sdk.wasmSdkId)) {
85
228
  throw new Error(
86
- `peach compiles Swift with the ${sdk.wasmSdkId} SDK, which ${swift} does not have.\n${swiftInstallSteps(release, sdk)}`,
229
+ `peach compiles Swift with the ${sdk.wasmSdkId} SDK, which ${swift} does not have.\n${swiftSdkInstallStep(release, sdk)}`,
230
+ )
231
+ }
232
+ try {
233
+ verifySwiftSdk(swift, sdk)
234
+ } catch (error) {
235
+ const reason = error instanceof Error ? error.message : String(error)
236
+ throw new Error(`${reason}\n${swiftSdkInstallStep(release, sdk)}`)
237
+ }
238
+ return bin
239
+ }
240
+
241
+ // swiftpm's info.json describes SDK selection, not the archive it came from.
242
+ function validateSwiftSdkIdentity(sdk: SwiftWasmSdk): void {
243
+ if (
244
+ !/^[a-zA-Z0-9._-]+$/.test(sdk.wasmSdkId) ||
245
+ !/^[a-f0-9]{64}$/.test(sdk.wasmSdkChecksum)
246
+ )
247
+ throw new Error('invalid Swift SDK identity or checksum')
248
+ }
249
+
250
+ function swiftSdkBundle(swift: string, id: string, options: string[]): string {
251
+ const configuration = execFileSync(
252
+ swift,
253
+ ['sdk', 'configure', ...options, '--show-configuration', id, 'wasm32-unknown-wasip1'],
254
+ { encoding: 'utf8', env: swiftEnvironment(), stdio: ['ignore', 'pipe', 'pipe'] },
255
+ )
256
+ const sysroot = /^sdkRootPath: (.+)$/m.exec(configuration)?.[1]?.trim()
257
+ if (!sysroot || !path.isAbsolute(sysroot))
258
+ throw new Error(`Swift SDK ${id} reports no absolute sdkRootPath`)
259
+ let bundle = sysroot
260
+ while (!bundle.endsWith('.artifactbundle')) {
261
+ const parent = path.dirname(bundle)
262
+ if (parent === bundle)
263
+ throw new Error(`Swift SDK ${id} is outside an artifact bundle`)
264
+ bundle = parent
265
+ }
266
+ return fs.realpathSync(bundle)
267
+ }
268
+
269
+ // stat changes invalidate file hashes; every lookup enumerates the entire bundle,
270
+ // including additions, removals, executable modes and internal symlink targets.
271
+ const sdkFileHashes = new Map<string, { stamp: string; checksum: string }>()
272
+ function swiftSdkPayloadChecksum(bundle: string): string {
273
+ const hash = createHash('sha256')
274
+ function visit(relative: string): void {
275
+ const file = path.join(bundle, relative)
276
+ const stat = fs.lstatSync(file, { bigint: true })
277
+ if (stat.isSymbolicLink()) {
278
+ const resolved = path.relative(bundle, fs.realpathSync(file))
279
+ if (
280
+ resolved === '..' ||
281
+ resolved.startsWith(`..${path.sep}`) ||
282
+ path.isAbsolute(resolved)
283
+ )
284
+ throw new Error(`Swift SDK symlink escapes its bundle: ${relative}`)
285
+ hash.update(JSON.stringify([relative, 'link', fs.readlinkSync(file)]) + '\n')
286
+ } else if (stat.isDirectory()) {
287
+ hash.update(JSON.stringify([relative, 'directory']) + '\n')
288
+ for (const name of fs.readdirSync(file).sort()) visit(path.join(relative, name))
289
+ } else if (stat.isFile()) {
290
+ const stamp = [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':')
291
+ let cached = sdkFileHashes.get(file)
292
+ if (cached?.stamp !== stamp) {
293
+ cached = { stamp, checksum: swiftFileChecksum(file) }
294
+ sdkFileHashes.set(file, cached)
295
+ }
296
+ hash.update(
297
+ JSON.stringify([relative, 'file', Number(stat.mode & 0o111n), cached.checksum]) +
298
+ '\n',
299
+ )
300
+ } else throw new Error(`unsupported Swift SDK payload entry: ${relative}`)
301
+ }
302
+ visit('')
303
+ return hash.digest('hex')
304
+ }
305
+
306
+ function swiftSdkVerificationFile(bundle: string, sdk: SwiftWasmSdk): string {
307
+ return path.join(
308
+ path.dirname(bundle),
309
+ '.peach-sdk-checksums',
310
+ `${sdk.wasmSdkId}-${sdk.wasmSdkChecksum}.json`,
311
+ )
312
+ }
313
+
314
+ function verifySwiftSdk(swift: string, sdk: SwiftWasmSdk): void {
315
+ const bundle = swiftSdkBundle(swift, sdk.wasmSdkId, swiftSdkOptions())
316
+ const file = swiftSdkVerificationFile(bundle, sdk)
317
+ if (!fs.existsSync(file))
318
+ throw new Error(
319
+ `Swift SDK ${sdk.wasmSdkId} has no verified archive binding for ${sdk.wasmSdkChecksum}. Hydrate it with installSwiftWasmSdk before compiling.`,
87
320
  )
321
+ const receipt: unknown = JSON.parse(fs.readFileSync(file, 'utf8'))
322
+ if (
323
+ !receipt ||
324
+ typeof receipt !== 'object' ||
325
+ !('wasmSdkId' in receipt) ||
326
+ receipt.wasmSdkId !== sdk.wasmSdkId ||
327
+ !('wasmSdkChecksum' in receipt) ||
328
+ receipt.wasmSdkChecksum !== sdk.wasmSdkChecksum ||
329
+ !('payloadChecksum' in receipt) ||
330
+ receipt.payloadChecksum !== swiftSdkPayloadChecksum(bundle)
331
+ )
332
+ throw new Error(
333
+ `Swift SDK ${sdk.wasmSdkId} installed payload differs from its verified archive ${sdk.wasmSdkChecksum}`,
334
+ )
335
+ }
336
+
337
+ /** install or qualify existing bytes, serializing shared-store mutation without replacement. */
338
+ function installSwiftSdkArchive(swift: string, sdk: SwiftWasmSdk, archive: string): void {
339
+ validateSwiftSdkIdentity(sdk)
340
+ const staging = fs.realpathSync(
341
+ fs.mkdtempSync(path.join(scratch ?? os.tmpdir(), 'peach-sdk-verification-')),
342
+ )
343
+ try {
344
+ if (swiftFileChecksum(archive) !== sdk.wasmSdkChecksum)
345
+ throw new Error('declared Swift SDK archive checksum mismatch')
346
+ const store = path.join(staging, 'sdks')
347
+ fs.mkdirSync(store)
348
+ execFileSync(
349
+ swift,
350
+ [
351
+ 'sdk',
352
+ 'install',
353
+ archive,
354
+ '--checksum',
355
+ sdk.wasmSdkChecksum,
356
+ '--swift-sdks-path',
357
+ store,
358
+ ],
359
+ { env: swiftEnvironment(), stdio: 'inherit' },
360
+ )
361
+ const stagedBundle = swiftSdkBundle(swift, sdk.wasmSdkId, [
362
+ '--swift-sdks-path',
363
+ store,
364
+ ])
365
+ const expected = swiftSdkPayloadChecksum(stagedBundle)
366
+ const directory = process.env.PEACH_SWIFT_SDKS_PATH
367
+ const locks = directory
368
+ ? path.join(path.resolve(directory), '.peach-locks')
369
+ : path.join(os.homedir(), '.swiftpm', 'builder-locks')
370
+ fs.mkdirSync(locks, { recursive: true })
371
+ execFileSync(
372
+ 'perl',
373
+ [
374
+ '-e',
375
+ 'use Fcntl qw(:flock); my ($lock, $id, $bundle, $swift, @options) = @ARGV; open(my $fd, ">>", $lock) or die $!; flock($fd, LOCK_EX) or die $!; open(my $list, "-|", $swift, "sdk", "list", @options) or die $!; my $found = 0; while (<$list>) { s/^\\s+|\\s+$//g; $found = 1 if $_ eq $id; } close($list) or die "swift sdk list failed"; unless ($found) { system($swift, "sdk", "install", $bundle, @options); exit(($? & 127) ? 128 + ($? & 127) : $? >> 8); }',
376
+ path.join(locks, 'wasm-sdk.lock'),
377
+ sdk.wasmSdkId,
378
+ stagedBundle,
379
+ swift,
380
+ ...swiftSdkOptions(),
381
+ ],
382
+ { env: swiftEnvironment(), stdio: 'inherit' },
383
+ )
384
+ const bundle = swiftSdkBundle(swift, sdk.wasmSdkId, swiftSdkOptions())
385
+ if (swiftSdkPayloadChecksum(bundle) !== expected)
386
+ throw new Error(
387
+ `Swift SDK ${sdk.wasmSdkId} already exists with different payload bytes; select a separate --swift-sdks-path. No installed SDK was replaced.`,
388
+ )
389
+ const file = swiftSdkVerificationFile(bundle, sdk)
390
+ fs.mkdirSync(path.dirname(file), { recursive: true })
391
+ const temporary = `${file}.${process.pid}.tmp`
392
+ try {
393
+ fs.writeFileSync(
394
+ temporary,
395
+ JSON.stringify({
396
+ wasmSdkId: sdk.wasmSdkId,
397
+ wasmSdkChecksum: sdk.wasmSdkChecksum,
398
+ payloadChecksum: expected,
399
+ }) + '\n',
400
+ )
401
+ fs.renameSync(temporary, file)
402
+ } finally {
403
+ fs.rmSync(temporary, { force: true })
404
+ }
405
+ } finally {
406
+ fs.rmSync(staging, { recursive: true, force: true })
407
+ for (const file of sdkFileHashes.keys())
408
+ if (file.startsWith(staging + path.sep)) sdkFileHashes.delete(file)
409
+ }
410
+ }
411
+
412
+ /** verify the compiler and install a declared SDK under the shared store lock. */
413
+ export function installSwiftWasmSdk(release: string, sdk: SwiftWasmSdk): void {
414
+ validateSwiftSdkIdentity(sdk)
415
+ try {
416
+ swiftToolchain(release, sdk)
417
+ return
418
+ } catch {
419
+ // an unverified existing SDK must be compared with the declared archive.
420
+ }
421
+ const swift = path.join(swiftCompiler(release), 'swift')
422
+ const staging = fs.mkdtempSync(path.join(scratch ?? os.tmpdir(), 'builder-swift-sdk-'))
423
+ const archive = path.join(staging, 'sdk.artifactbundle.tar.gz')
424
+ try {
425
+ if (sdk.wasmSdkUrl.startsWith('file:'))
426
+ fs.copyFileSync(fileURLToPath(sdk.wasmSdkUrl), archive)
427
+ else
428
+ execFileSync('curl', ['-fsSL', sdk.wasmSdkUrl, '-o', archive], { stdio: 'inherit' })
429
+ installSwiftSdkArchive(swift, sdk, archive)
430
+ swiftToolchain(release, sdk)
431
+ } finally {
432
+ fs.rmSync(staging, { recursive: true, force: true })
433
+ }
434
+ }
435
+
436
+ // stream large compiler binaries without retaining a second copy in memory.
437
+ export function swiftFileChecksum(file: string): string {
438
+ const hash = createHash('sha256')
439
+ const fd = fs.openSync(file, 'r')
440
+ const buffer = Buffer.allocUnsafe(1024 * 1024)
441
+ try {
442
+ let size = fs.readSync(fd, buffer, 0, buffer.length, null)
443
+ while (size > 0) {
444
+ hash.update(buffer.subarray(0, size))
445
+ size = fs.readSync(fd, buffer, 0, buffer.length, null)
446
+ }
447
+ } finally {
448
+ fs.closeSync(fd)
449
+ }
450
+ return hash.digest('hex')
451
+ }
452
+
453
+ /** validate the pinned manifest and every replaced binary, independently of --version. */
454
+ export function verifySwiftHostCompiler(release: string, root: string): void {
455
+ const pin = swiftHostCompilerArchive(release)
456
+ const file = path.join(root, 'compiler.json')
457
+ if (swiftFileChecksum(file) !== pin.manifest)
458
+ throw new Error(`Swift compiler manifest mismatch at ${root}`)
459
+ const manifest: unknown = JSON.parse(fs.readFileSync(file, 'utf8'))
460
+ if (
461
+ !manifest ||
462
+ typeof manifest !== 'object' ||
463
+ !('identity' in manifest) ||
464
+ manifest.identity !== pin.identity ||
465
+ !('host' in manifest) ||
466
+ manifest.host !== `${process.platform}-${process.arch}` ||
467
+ !('files' in manifest) ||
468
+ !manifest.files ||
469
+ typeof manifest.files !== 'object'
470
+ ) {
471
+ throw new Error(`Swift compiler identity mismatch at ${root}`)
88
472
  }
89
- verified.set(`${release}\0${sdk.wasmSdkId}`, bin)
473
+ const files = Object.entries(manifest.files)
474
+ if (files.length !== 16) throw new Error('incomplete Swift compiler manifest')
475
+ for (const [relative, checksum] of files) {
476
+ if (
477
+ relative.startsWith('/') ||
478
+ relative.split('/').includes('..') ||
479
+ typeof checksum !== 'string' ||
480
+ swiftFileChecksum(path.join(root, relative)) !== checksum
481
+ ) {
482
+ throw new Error(`Swift compiler binary mismatch: ${relative}`)
483
+ }
484
+ }
485
+ }
486
+
487
+ const verifiedCompilers = new Map<string, string>()
488
+ /** select the actual compiler, including its patched identity, before consulting SDKs. */
489
+ export function swiftCompiler(release: string): string {
490
+ const cached = verifiedCompilers.get(release)
491
+ if (cached) return cached
492
+ const bin = swiftToolchainBin(release)
493
+ if (release.includes('-custom-'))
494
+ verifySwiftHostCompiler(release, path.resolve(bin, '../..'))
495
+ const version = execFileSync(path.join(bin, 'swift'), ['--version'], {
496
+ encoding: 'utf8',
497
+ env: swiftEnvironment(),
498
+ stdio: ['ignore', 'pipe', 'pipe'],
499
+ })
500
+ if (!version.includes(`(${swiftCompilerRelease(release)})`))
501
+ throw new Error(`compiler release mismatch at ${bin}`)
502
+ verifiedCompilers.set(release, bin)
90
503
  return bin
91
504
  }
92
505
 
93
- /** the commands that install `release` and its wasm SDK on this host. */
506
+ /** the command that installs and qualifies the pinned SDK for an installed compiler. */
507
+ export function swiftSdkInstallStep(release: string, wasmSdk: SwiftWasmSdk): string {
508
+ return `install the pinned SDK:\n ${swiftSdkInstallCommand(release, wasmSdk)}`
509
+ }
510
+
511
+ function swiftSdkInstallCommand(release: string, wasmSdk: SwiftWasmSdk): string {
512
+ const { wasmSdkId, wasmSdkUrl, wasmSdkChecksum } = wasmSdk
513
+ const program = `import { installSwiftWasmSdk } from "peachsim/swift-toolchain"; installSwiftWasmSdk(${JSON.stringify(release)}, ${JSON.stringify({ wasmSdkId, wasmSdkUrl, wasmSdkChecksum })})`
514
+ const directory = process.env.PEACH_SWIFT_SDKS_PATH
515
+ const environment = directory
516
+ ? `PEACH_SWIFT_SDKS_PATH='${path.resolve(directory).replaceAll("'", "'\\''")}' `
517
+ : ''
518
+ return `${environment}node --input-type=module -e '${program.replaceAll("'", "'\\''")}'`
519
+ }
520
+
521
+ /** the commands that install the pinned compiler and SDK on this host. */
94
522
  export function swiftInstallSteps(release: string, wasmSdk: SwiftWasmSdk): string {
523
+ const { note, commands } = swiftCompilerInstall(release)
524
+ return [
525
+ `install the pinned compiler and SDK${note}:`,
526
+ ...[...commands, swiftSdkInstallCommand(release, wasmSdk)].map((line) => ` ${line}`),
527
+ ].join('\n')
528
+ }
529
+
530
+ /** the commands that install the pinned compiler alone, for a caller that installs the SDK. */
531
+ export function swiftCompilerInstallSteps(release: string): string {
532
+ const { note, commands } = swiftCompilerInstall(release)
533
+ return [
534
+ `install the pinned compiler${note}:`,
535
+ ...commands.map((line) => ` ${line}`),
536
+ ].join('\n')
537
+ }
538
+
539
+ function swiftCompilerInstall(release: string): { note: string; commands: string[] } {
540
+ if (release.includes('-custom-')) {
541
+ const archive = swiftHostCompilerArchive(release)
542
+ const directory = path.resolve(swiftToolchainBin(release), '../..')
543
+ return {
544
+ note: '',
545
+ commands: [
546
+ `curl -fsSL ${archive.url} -o compiler.tar.gz`,
547
+ `echo '${archive.checksum} compiler.tar.gz' | shasum -a 256 -c -`,
548
+ `mkdir -p '${directory}'`,
549
+ `tar -xzf compiler.tar.gz --strip-components=1 -C '${directory}'`,
550
+ ],
551
+ }
552
+ }
95
553
  const version = release.replace(/^swift-/, '').toLowerCase()
96
554
  const downloads = `https://download.swift.org/swift-${version}`
97
- const sdk = `swift sdk install ${wasmSdk.wasmSdkUrl} --checksum ${wasmSdk.wasmSdkChecksum}`
98
555
  if (process.platform === 'darwin') {
99
- const swift = `~/Library/Developer/Toolchains/${release}.xctoolchain/usr/bin/swift`
100
- return [
101
- 'install it:',
102
- ` curl -fsSLO ${downloads}/xcode/${release}/${release}-osx.pkg`,
103
- ` installer -pkg ${release}-osx.pkg -target CurrentUserHomeDirectory`,
104
- ` ${swift} ${sdk.replace(/^swift /, '')}`,
105
- ].join('\n')
556
+ return {
557
+ note: '',
558
+ commands: [
559
+ `curl -fsSLO ${downloads}/xcode/${release}/${release}-osx.pkg`,
560
+ `installer -pkg ${release}-osx.pkg -target CurrentUserHomeDirectory`,
561
+ ],
562
+ }
106
563
  }
107
564
  const platform = `debian12${process.arch === 'arm64' ? '-aarch64' : ''}`
108
565
  const name = `${release}-${platform}`
109
- return [
110
- 'install it (Debian 12 shown; other distributions: https://www.swift.org/install/linux/):',
111
- ` curl -fsSL ${downloads}/${platform}/${release}/${name}.tar.gz | tar -xz -C /opt`,
112
- ` export PATH=/opt/${name}/usr/bin:$PATH`,
113
- ` ${sdk}`,
114
- ].join('\n')
566
+ return {
567
+ note: ' (Debian 12 shown; other distributions: https://www.swift.org/install/linux/)',
568
+ commands: [
569
+ `curl -fsSL ${downloads}/${platform}/${release}/${name}.tar.gz | tar -xz -C /opt`,
570
+ `export PATH=/opt/${name}/usr/bin:$PATH`,
571
+ ],
572
+ }
115
573
  }