also looking at this
feat(ios): the Zig runtime, offered to a Swift-hosted app
#103
7 files
+868
-59
| @@ -262,6 +262,36 @@ export function renderWatchEntitlements(config: CraftConfig): string { | ||
| 262 | 262 | return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n${appGroups}</dict>\n</plist>\n` |
| 263 | 263 | } |
| 264 | 264 | |
| 265 | /** | |
| 266 | * The build settings that link the Zig runtime into the app target. | |
| 267 | * | |
| 268 | * Two things are load-bearing here: | |
| 269 | * | |
| 270 | * `LIBRARY_SEARCH_PATHS` is written twice, once per SDK, so `-lcraft-ios` | |
| 271 | * resolves to the device archive for a device build and the simulator archive | |
| 272 | * for a simulator one without the flag itself changing. Xcode applies the | |
| 273 | * `[sdk=...]` condition; xcodegen passes these keys through verbatim. | |
| 274 | * | |
| 275 | * The four `-u` flags are the reason anything works at all. Nothing in the | |
| 276 | * Swift source *references* these symbols — `CraftZigRuntime` and | |
| 277 | * `CraftSwiftShim` both find them with `dlsym` at runtime — so a static | |
| 278 | * archive contributes no objects for them and the linker drops the entire | |
| 279 | * runtime as unreachable. `-u` names them as undefined so the objects are | |
| 280 | * pulled in and the symbols end up in the binary for `dlsym` to find. | |
| 281 | * | |
| 282 | * `-u` rather than `-force_load`: forcing the whole archive would pull in | |
| 283 | * every object whether reachable or not, and these four entry points already | |
| 284 | * reach everything the bridge actually uses. | |
| 285 | */ | |
| 286 | export function renderRuntimeSettings(): string { | |
| 287 | return [ | |
| 288 | ' LIBRARY_SEARCH_PATHS[sdk=iphoneos*]: "$(PROJECT_DIR)/Runtime/device"', | |
| 289 | ' LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]: "$(PROJECT_DIR)/Runtime/simulator"', | |
| 290 | ' OTHER_LDFLAGS: "-lcraft-ios -Wl,-u,_craft_ios_handle_action -Wl,-u,_craft_ios_set_webview ' | |
| 291 | + '-Wl,-u,_craft_ios_deliver_result -Wl,-u,_craft_ios_deliver_error"', | |
| 292 | ].join('\n') | |
| 293 | } | |
| 294 | ||
| 265 | 295 | export function renderPrivacyManifest(config: CraftConfig): string { |
| 266 | 296 | const privacy = config.privacy ?? {} |
| 267 | 297 | const collected = privacy.collectedDataTypes ?? [] |
| @@ -334,6 +364,81 @@ export function syncWebAssets(source: string, output: string): void { | ||
| 334 | 364 | /** |
| 335 | 365 | * Initialize a new iOS project |
| 336 | 366 | */ |
| 367 | /** | |
| 368 | * Where the Zig runtime archives live, or null when this build has none. | |
| 369 | * | |
| 370 | * `CRAFT_IOS_RUNTIME` points at a directory holding the archives `zig build | |
| 371 | * build-ios-all` produces. It is opt-in and there is deliberately no fallback | |
| 372 | * search: a generated project with no runtime is the shipping default today, | |
| 373 | * and it works — `CraftZigRuntime.offer` is a `dlsym` miss, every action | |
| 374 | * answers false, and the Swift switch serves the whole surface exactly as it | |
| 375 | * always has. Guessing at a path would turn "no runtime" into "some runtime, | |
| 376 | * from somewhere", which is the failure mode the pantry contract exists to | |
| 377 | * rule out. | |
| 378 | * | |
| 379 | * This is the same shape as `CRAFT_BIN`: an explicit override for the monorepo | |
| 380 | * dev loop, not a lookup path. Shipping the runtime to real apps means putting | |
| 381 | * these archives in the pantry package beside the `craft` binary, which is a | |
| 382 | * distribution decision this function does not make. | |
| 383 | */ | |
| 384 | export function resolveRuntimeDir(): string | null { | |
| 385 | const dir = process.env.CRAFT_IOS_RUNTIME | |
| 386 | if (!dir) return null | |
| 387 | if (!existsSync(dir)) { | |
| 388 | throw new Error(`CRAFT_IOS_RUNTIME points at ${dir}, which does not exist.`) | |
| 389 | } | |
| 390 | return dir | |
| 391 | } | |
| 392 | ||
| 393 | /** The archives a runtime directory must contain, by Xcode SDK. */ | |
| 394 | const RUNTIME_ARCHIVES = { | |
| 395 | device: ['libcraft-ios.a'], | |
| 396 | // Both simulator slices, merged into one archive below. A Mac on Apple | |
| 397 | // silicon runs arm64 simulators and an Intel one runs x86_64; shipping a | |
| 398 | // single-slice archive makes the project build on one machine and fail to | |
| 399 | // link on the other, which is the kind of break that only shows up on | |
| 400 | // someone else's laptop. | |
| 401 | simulator: ['libcraft-ios-simulator-arm64.a', 'libcraft-ios-simulator-x64.a'], | |
| 402 | } as const | |
| 403 | ||
| 404 | /** | |
| 405 | * Copy the Zig archives into the generated project as `Runtime/<sdk>/libcraft-ios.a`. | |
| 406 | * | |
| 407 | * One name for both SDKs, in two directories, because that is what lets a | |
| 408 | * single `-lcraft-ios` in OTHER_LDFLAGS work for device and simulator builds: | |
| 409 | * Xcode picks the directory by SDK and the flag never changes. | |
| 410 | * | |
| 411 | * Returns true when a runtime was installed. | |
| 412 | */ | |
| 413 | export async function installRuntime(output: string, runtimeDir: string): Promise<boolean> { | |
| 414 | const dest = join(output, 'Runtime') | |
| 415 | rmSync(dest, { recursive: true, force: true }) | |
| 416 | ||
| 417 | for (const [sdk, archives] of Object.entries(RUNTIME_ARCHIVES)) { | |
| 418 | const present = archives.filter(a => existsSync(join(runtimeDir, a))) | |
| 419 | if (present.length === 0) { | |
| 420 | throw new Error( | |
| 421 | `CRAFT_IOS_RUNTIME=${runtimeDir} has none of ${archives.join(', ')}. ` | |
| 422 | + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`, | |
| 423 | ) | |
| 424 | } | |
| 425 | ||
| 426 | const sdkDir = join(dest, sdk) | |
| 427 | mkdirSync(sdkDir, { recursive: true }) | |
| 428 | const target = join(sdkDir, 'libcraft-ios.a') | |
| 429 | ||
| 430 | if (present.length === 1) { | |
| 431 | cpSync(join(runtimeDir, present[0]!), target) | |
| 432 | } | |
| 433 | else { | |
| 434 | // `lipo -create` rather than picking one slice: see RUNTIME_ARCHIVES. | |
| 435 | await $`lipo -create ${present.map(a => join(runtimeDir, a))} -output ${target}`.quiet() | |
| 436 | } | |
| 437 | } | |
| 438 | ||
| 439 | return true | |
| 440 | } | |
| 441 | ||
| 337 | 442 | export async function init(options: InitOptions): Promise<void> { |
| 338 | 443 | const { name, bundleId, teamId, output } = options |
| 339 | 444 | |
| @@ -426,7 +531,14 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 426 | 531 | SKIP_INSTALL: YES`) |
| 427 | 532 | } |
| 428 | 533 | |
| 534 | // The Zig runtime, when this build has one. Installed before the project is | |
| 535 | // rendered because the settings below name the directories it creates. | |
| 536 | const runtimeDir = resolveRuntimeDir() | |
| 537 | const hasRuntime = runtimeDir ? await installRuntime(output, runtimeDir) : false | |
| 538 | if (hasRuntime) console.log(' Linked the Zig runtime from', runtimeDir) | |
| 539 | ||
| 429 | 540 | const projectYml = projectYmlTemplate |
| 541 | .replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '') | |
| 430 | 542 | .replace(/\{\{APP_NAME\}\}/g, name) |
| 431 | 543 | .replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId) |
| 432 | 544 | .replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix) |