diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d011bd2e..f2f13c91 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -109,6 +109,34 @@ jobs:
working-directory: packages/typescript
run: bun run build
+ # `packages/ios` had a test suite that nothing ran. That is how its
+ # generator kept a defect nobody saw: `init()` read `CRAFT_IOS_RUNTIME`
+ # straight from the environment, so the suite passed 12/12 in a clean shell
+ # and 8/12 in a developer's, and CI never had an opinion either way.
+ ios-builder:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Pantry (provides Bun, Zig, etc.)
+ uses: pantry-pm/pantry/packages/action@235036fa0f48bae99b2293df5a3dc35c809b1777 # pinned: last SHA whose bundled typescript resolves on linux-x64
+
+ - name: Install dependencies
+ run: bun install
+
+ # Unset deliberately. The suite must not depend on it, and this is the
+ # job that would notice if it started to again.
+ - name: Test iOS builder
+ working-directory: packages/ios
+ run: bun test
+ env:
+ CRAFT_IOS_RUNTIME: ''
+
+ - name: Build iOS builder
+ working-directory: packages/ios
+ run: bun run build
+
create-craft:
runs-on: ubuntu-latest
@@ -219,7 +247,7 @@ jobs:
run: bun run verify
publish-commit:
- needs: [zig-core, typescript-sdk, create-craft, ts-maps, lint, integration]
+ needs: [zig-core, typescript-sdk, ios-builder, create-craft, ts-maps, lint, integration]
runs-on: ubuntu-latest
steps:
diff --git a/packages/ios/src/index.test.ts b/packages/ios/src/index.test.ts
index ec9f7abd..e8a74bf0 100644
--- a/packages/ios/src/index.test.ts
+++ b/packages/ios/src/index.test.ts
@@ -5,6 +5,9 @@ import { describe, expect, it } from 'bun:test'
import {
build,
init,
+ installRuntime,
+ renderRuntimeSettings,
+ resolveRuntimeDir,
orderSimulators,
renderBackgroundModes,
renderEntitlements,
@@ -83,6 +86,7 @@ describe('Craft iOS builder', () => {
it('generates a production project whose bundled index lives under dist', async () => {
const output = mkdtempSync(join(tmpdir(), 'craft-ios-project-'))
await init({
+ runtimeDir: null,
name: 'WildLoop',
bundleId: 'org.wildloop.app',
output,
@@ -127,7 +131,7 @@ describe('Craft iOS builder', () => {
const output = join(root, 'ios')
Bun.spawnSync(['mkdir', '-p', web])
writeFileSync(join(web, 'index.html'), 'Available offline')
- await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output })
+ await init({ runtimeDir: null, name: 'WildLoop', bundleId: 'org.wildloop.app', output })
await build({ htmlPath: web, devServer: 'https://wildloop.org', output, generateProject: false })
const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8')
@@ -138,6 +142,7 @@ describe('Craft iOS builder', () => {
it('generates a native Live Activity extension when enabled', async () => {
const output = mkdtempSync(join(tmpdir(), 'craft-ios-live-activity-'))
await init({
+ runtimeDir: null,
name: 'WildLoop',
bundleId: 'org.wildloop.app',
output,
@@ -161,6 +166,7 @@ describe('Craft iOS builder', () => {
it('generates an embedded watchOS companion when enabled', async () => {
const output = mkdtempSync(join(tmpdir(), 'craft-ios-watch-'))
await init({
+ runtimeDir: null,
name: 'WildLoop',
bundleId: 'org.wildloop.app',
output,
@@ -243,3 +249,140 @@ describe('choosing a simulator', () => {
expect(devices[0]?.name).toBe('iPad Air')
})
})
+
+describe('Zig runtime installation', () => {
+ // A runtime directory holding *one* simulator slice, so `installRuntime`
+ // takes the `cpSync` branch. The two-slice branch shells out to `lipo`,
+ // which needs genuine Mach-O input and does not exist off macOS — it is
+ // covered by building a real generated app, not from here. What these do
+ // cover is everything around it: resolution, ordering, and the warning.
+ function fakeRuntime(archives = ['libcraft-ios.a', 'libcraft-ios-simulator-arm64.a']): string {
+ const dir = mkdtempSync(join(tmpdir(), 'craft-rt-'))
+ for (const a of archives) writeFileSync(join(dir, a), 'stand-in for an archive')
+ return dir
+ }
+
+ it('resolves an explicit directory, and null means no runtime whatever the environment says', () => {
+ const dir = fakeRuntime()
+ const saved = process.env.CRAFT_IOS_RUNTIME
+ process.env.CRAFT_IOS_RUNTIME = dir
+ try {
+ // The gap this closes: the suite used to inherit the developer's shell,
+ // and went from 12 passing to 8 passing and 4 failing when this variable
+ // happened to be set.
+ expect(resolveRuntimeDir(null)).toBeNull()
+ expect(resolveRuntimeDir(dir)).toBe(dir)
+ expect(resolveRuntimeDir()).toBe(dir)
+ }
+ finally {
+ if (saved === undefined) delete process.env.CRAFT_IOS_RUNTIME
+ else process.env.CRAFT_IOS_RUNTIME = saved
+ }
+ })
+
+ it('names the source in the error, so a bad path says which knob set it', () => {
+ expect(() => resolveRuntimeDir('/no/such/runtime')).toThrow(/runtimeDir is/)
+ })
+
+ it('writes one archive name per SDK, which is what a single -lcraft-ios needs', async () => {
+ const output = mkdtempSync(join(tmpdir(), 'craft-out-'))
+ expect(await installRuntime(output, fakeRuntime())).toBe(true)
+ expect(existsSync(join(output, 'Runtime', 'device', 'libcraft-ios.a'))).toBe(true)
+ expect(existsSync(join(output, 'Runtime', 'simulator', 'libcraft-ios.a'))).toBe(true)
+ })
+
+ it('warns when only one simulator slice is present, rather than shipping it silently', async () => {
+ const warnings: string[] = []
+ const saved = console.warn
+ console.warn = (...args: unknown[]) => void warnings.push(args.join(' '))
+ try {
+ await installRuntime(mkdtempSync(join(tmpdir(), 'craft-out-')), fakeRuntime())
+ }
+ finally {
+ console.warn = saved
+ }
+ // RUNTIME_ARCHIVES' own comment calls this the failure that "only shows up
+ // on someone else's laptop", so it must not be silent.
+ expect(warnings.join('\n')).toContain('libcraft-ios-simulator-x64.a')
+ })
+
+ it('leaves a working install alone when the source directory is incomplete', async () => {
+ // The ordering bug: validation ran after the wipe, so a bad runtime dir
+ // destroyed the archives already in place and left project.yml linking
+ // against a Runtime/ that no longer existed.
+ const output = mkdtempSync(join(tmpdir(), 'craft-out-'))
+ await installRuntime(output, fakeRuntime())
+ const installed = join(output, 'Runtime', 'device', 'libcraft-ios.a')
+ expect(existsSync(installed)).toBe(true)
+
+ const empty = mkdtempSync(join(tmpdir(), 'craft-rt-empty-'))
+ await expect(installRuntime(output, empty)).rejects.toThrow(/has none of/)
+ expect(existsSync(installed)).toBe(true)
+ })
+
+ it('renders both SDK search paths and forces the four entry points', () => {
+ const settings = renderRuntimeSettings()
+ expect(settings).toContain('LIBRARY_SEARCH_PATHS[sdk=iphoneos*]')
+ expect(settings).toContain('LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]')
+ // Without -u the linker drops the whole archive as unreachable, because
+ // nothing in the Swift references these symbols — both seams use dlsym.
+ for (const sym of ['handle_action', 'set_webview', 'deliver_result', 'deliver_error']) {
+ expect(settings).toContain(`-Wl,-u,_craft_ios_${sym}`)
+ }
+ // Six-space indent: this is spliced into project.yml under `settings:`.
+ for (const line of settings.split('\n')) expect(line.startsWith(' ')).toBe(true)
+ })
+
+ it('generates a project whose settings match whether a runtime was installed', async () => {
+ const output = mkdtempSync(join(tmpdir(), 'craft-app-'))
+ await init({ runtimeDir: fakeRuntime(), name: 'HasRuntime', output })
+ expect(readFileSync(join(output, 'project.yml'), 'utf8')).toContain('-lcraft-ios')
+
+ // And re-running without one leaves no orphaned archives claiming otherwise.
+ await init({ runtimeDir: null, name: 'HasRuntime', output })
+ expect(readFileSync(join(output, 'project.yml'), 'utf8')).not.toContain('-lcraft-ios')
+ expect(existsSync(join(output, 'Runtime'))).toBe(false)
+ })
+})
+
+describe('runtime staleness', () => {
+ function runtimeWith(marker: string): string {
+ const dir = mkdtempSync(join(tmpdir(), 'craft-rt-'))
+ for (const a of ['libcraft-ios.a', 'libcraft-ios-simulator-arm64.a']) {
+ writeFileSync(join(dir, a), marker)
+ }
+ return dir
+ }
+
+ it('build picks up a rebuilt runtime instead of relinking the one init copied', async () => {
+ const output = mkdtempSync(join(tmpdir(), 'craft-app-'))
+ await init({ runtimeDir: runtimeWith('first build'), name: 'Stale', output })
+ const installed = join(output, 'Runtime', 'device', 'libcraft-ios.a')
+ expect(readFileSync(installed, 'utf8')).toBe('first build')
+
+ // The dev loop: edit Zig, rebuild the archives, run the app again. Before
+ // this, xcodebuild relinked the copy from init and the change was absent
+ // with no error anywhere.
+ await build({ output, runtimeDir: runtimeWith('second build'), generateProject: false })
+ expect(readFileSync(installed, 'utf8')).toBe('second build')
+ })
+
+ it('build leaves a runtimeless project alone rather than installing one behind init', async () => {
+ const output = mkdtempSync(join(tmpdir(), 'craft-app-'))
+ await init({ runtimeDir: null, name: 'NoRuntime', output })
+ expect(existsSync(join(output, 'Runtime'))).toBe(false)
+
+ // A runtime is available, but this project does not link one: its
+ // project.yml has no link settings, so archives here would be dead weight.
+ await build({ output, runtimeDir: runtimeWith('ignored'), generateProject: false })
+ expect(existsSync(join(output, 'Runtime'))).toBe(false)
+ })
+
+ it('build keeps the installed runtime when no runtime directory is configured', async () => {
+ const output = mkdtempSync(join(tmpdir(), 'craft-app-'))
+ await init({ runtimeDir: runtimeWith('from init'), name: 'Keep', output })
+ // A shell that forgot the variable must not quietly turn the runtime off.
+ await build({ output, runtimeDir: null, generateProject: false })
+ expect(readFileSync(join(output, 'Runtime', 'device', 'libcraft-ios.a'), 'utf8')).toBe('from init')
+ })
+})
diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts
index d98c1a39..b9ac808a 100644
--- a/packages/ios/src/index.ts
+++ b/packages/ios/src/index.ts
@@ -91,6 +91,20 @@ export interface InitOptions {
teamId?: string
output: string
config?: Partial
+ /**
+ * Where the Zig runtime archives live, overriding `CRAFT_IOS_RUNTIME`.
+ *
+ * `null` means "no runtime, whatever the environment says" — the shape
+ * `AppConfig.craftPath` has for `CRAFT_BIN`, and the reason it exists here
+ * is the same: a caller that does not opt in should not be steered by an
+ * ambient variable. Without it this package's own tests inherited whatever
+ * the developer's shell exported, and went from 12 passing to 8 passing and
+ * 4 failing when `CRAFT_IOS_RUNTIME` happened to be set.
+ *
+ * Omitted (`undefined`) keeps the environment lookup, which is what the
+ * monorepo dev loop uses.
+ */
+ runtimeDir?: string | null
}
export interface BuildOptions {
@@ -98,6 +112,12 @@ export interface BuildOptions {
devServer?: string
output: string
generateProject?: boolean
+ /**
+ * Where to re-read the Zig runtime archives from, overriding
+ * `CRAFT_IOS_RUNTIME`. Same meaning as `InitOptions.runtimeDir`; `null`
+ * leaves whatever `init` installed exactly as it is.
+ */
+ runtimeDir?: string | null
}
export interface OpenOptions {
@@ -380,12 +400,18 @@ export function syncWebAssets(source: string, output: string): void {
* dev loop, not a lookup path. Shipping the runtime to real apps means putting
* these archives in the pantry package beside the `craft` binary, which is a
* distribution decision this function does not make.
+ *
+ * `override` is what `InitOptions.runtimeDir` passes: a path to use instead of
+ * the variable, or `null` to declare there is no runtime regardless of what the
+ * environment says. `undefined` falls through to `CRAFT_IOS_RUNTIME`.
*/
-export function resolveRuntimeDir(): string | null {
- const dir = process.env.CRAFT_IOS_RUNTIME
+export function resolveRuntimeDir(override?: string | null): string | null {
+ if (override === null) return null
+ const dir = override ?? process.env.CRAFT_IOS_RUNTIME
if (!dir) return null
if (!existsSync(dir)) {
- throw new Error(`CRAFT_IOS_RUNTIME points at ${dir}, which does not exist.`)
+ const source = override === undefined ? 'CRAFT_IOS_RUNTIME points at' : 'runtimeDir is'
+ throw new Error(`${source} ${dir}, which does not exist.`)
}
return dir
}
@@ -411,23 +437,44 @@ const RUNTIME_ARCHIVES = {
* Returns true when a runtime was installed.
*/
export async function installRuntime(output: string, runtimeDir: string): Promise {
- const dest = join(output, 'Runtime')
- rmSync(dest, { recursive: true, force: true })
-
- for (const [sdk, archives] of Object.entries(RUNTIME_ARCHIVES)) {
+ // Every SDK resolved before anything is written, and long before the
+ // `rmSync` below. The check used to run per-SDK *after* the wipe, so a
+ // runtime directory missing an archive destroyed a working install on its
+ // way to throwing, and left `project.yml` linking `-lcraft-ios` against a
+ // `Runtime/` that no longer existed.
+ const resolved = Object.entries(RUNTIME_ARCHIVES).map(([sdk, archives]) => {
const present = archives.filter(a => existsSync(join(runtimeDir, a)))
if (present.length === 0) {
throw new Error(
- `CRAFT_IOS_RUNTIME=${runtimeDir} has none of ${archives.join(', ')}. `
+ `${runtimeDir} has none of ${archives.join(', ')}. `
+ `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`,
)
}
+ return { sdk, archives, present }
+ })
+
+ const dest = join(output, 'Runtime')
+ rmSync(dest, { recursive: true, force: true })
+ for (const { sdk, archives, present } of resolved) {
const sdkDir = join(dest, sdk)
mkdirSync(sdkDir, { recursive: true })
const target = join(sdkDir, 'libcraft-ios.a')
if (present.length === 1) {
+ // Said out loud, because the comment on RUNTIME_ARCHIVES describes this
+ // exact outcome as the thing to avoid: a single-slice simulator archive
+ // links on the machine that built it and fails on the other kind, which
+ // is a break that only ever shows up on someone else's laptop. Copying
+ // is still better than refusing — a one-arch dev loop is legitimate —
+ // but it should not happen silently.
+ const missing = archives.filter(a => !present.includes(a))
+ if (missing.length > 0) {
+ console.warn(
+ ` ⚠ ${sdk}: only ${present[0]} was found; ${missing.join(', ')} is missing. `
+ + `The generated project will not link on the other architecture.`,
+ )
+ }
cpSync(join(runtimeDir, present[0]!), target)
}
else {
@@ -439,6 +486,37 @@ export async function installRuntime(output: string, runtimeDir: string): Promis
return true
}
+/**
+ * Re-copy the Zig runtime into a project that already links one.
+ *
+ * The archives are *copied* by `init`, not symlinked, so without this a
+ * rebuilt runtime never reached the app: edit `packages/zig/src`, run `zig
+ * build build-ios-all`, then `craft ios run`, and xcodebuild happily relinks
+ * the archive that was copied when the project was first generated. The build
+ * succeeds, the app launches, and the change is simply absent — the worst
+ * shape a stale artefact can take, because there is no error to chase.
+ *
+ * Only refreshes; never installs and never removes. A project with no
+ * `Runtime/` was generated without a runtime and its `project.yml` carries no
+ * link settings, so copying archives in would leave them unreferenced — that
+ * decision belongs to `init`. And a project that *does* link one keeps working
+ * from the archives it already has when no runtime directory is configured,
+ * because a shell that forgot the variable should not quietly turn the runtime
+ * off.
+ */
+async function refreshRuntime(output: string, override?: string | null): Promise {
+ if (!existsSync(join(output, 'Runtime'))) return
+
+ const dir = resolveRuntimeDir(override)
+ if (!dir) {
+ console.log(' Keeping the Zig runtime installed at init (no runtime directory configured)')
+ return
+ }
+
+ await installRuntime(output, dir)
+ console.log(' Refreshed the Zig runtime from', dir)
+}
+
export async function init(options: InitOptions): Promise {
const { name, bundleId, teamId, output } = options
@@ -533,9 +611,18 @@ export async function init(options: InitOptions): Promise {
// The Zig runtime, when this build has one. Installed before the project is
// rendered because the settings below name the directories it creates.
- const runtimeDir = resolveRuntimeDir()
+ const runtimeDir = resolveRuntimeDir(options.runtimeDir)
const hasRuntime = runtimeDir ? await installRuntime(output, runtimeDir) : false
- if (hasRuntime) console.log(' Linked the Zig runtime from', runtimeDir)
+ if (hasRuntime) {
+ console.log(' Linked the Zig runtime from', runtimeDir)
+ }
+ else {
+ // Re-running init without a runtime used to strip the link settings from
+ // project.yml and leave the archives behind, so the directory said one
+ // thing and the project said another. Whatever this run decides, the tree
+ // agrees with it.
+ rmSync(join(output, 'Runtime'), { recursive: true, force: true })
+ }
const projectYml = projectYmlTemplate
.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : '')
@@ -656,6 +743,8 @@ export async function build(options: BuildOptions): Promise {
console.log(` Synced: ${htmlPath} → dist/`)
}
+ await refreshRuntime(output, options.runtimeDir)
+
if (!generateProject) return
// Generate Xcode project using xcodegen