ReviewOS

also looking at this

craft-native/craft

feat(ios): the Zig runtime, offered to a Swift-hosted app

#103
Merged glennmichael123 wants to merge feat/ios-swift-host-seam into main
7 files +868 -59
packages/ios/src/index.tsmodified+112-0
Changes to packages/ios/src/index.ts
@@ -262,6 +262,36 @@ export function renderWatchEntitlements(config: CraftConfig): string {
262262 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`
263263}
264264
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 */
286export 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
265295export function renderPrivacyManifest(config: CraftConfig): string {
266296 const privacy = config.privacy ?? {}
267297 const collected = privacy.collectedDataTypes ?? []
@@ -334,6 +364,81 @@ export function syncWebAssets(source: string, output: string): void {
334364/**
335365 * Initialize a new iOS project
336366 */
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 */
384export 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. */
394const 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 */
413export 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
337442export async function init(options: InitOptions): Promise<void> {
338443 const { name, bundleId, teamId, output } = options
339444
@@ -426,7 +531,14 @@ export async function init(options: InitOptions): Promise<void> {
426531 SKIP_INSTALL: YES`)
427532 }
428533
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
429540 const projectYml = projectYmlTemplate
541 .replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '')
430542 .replace(/\{\{APP_NAME\}\}/g, name)
431543 .replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId)
432544 .replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix)