ios/ and android/ are generated state, not durable source. They drift from their durable inputs
whenever a branch switch, a config plugin edit, or a new native source file lands — and the failure
is confusing: Xcode cannot resolve a Swift type that is plainly sitting in modules/vescape-core/ios/.
scripts/native-sync.ts fingerprints the durable inputs, compares them with the last successful
sync, and runs only the sync step that is actually stale. It is wired into the blessed run commands,
so there is nothing extra to remember:
bun run ios # native:sync ios && expo run:ios --device
bun run android # device picker (phones only) -> native:sync android && expo run:android
bun run native:sync ios # sync only, no app runbun run android:release is the same Android flow at --variant release: the JS is Hermes-compiled
and bundled into the APK instead of served by Metro, so the app runs at production speed. It still
reads .env.local, so the application id keeps the .dev suffix and the server URL stays whatever
local dev points at — it installs straight over the debug build (both sign with the debug keystore
locally; only Fastlane supplies the upload keystore). Use it whenever a debug build is too slow to
judge, camera and map motion especially. Rebuilding is the only way to pick up a JS change.
Output always names the reason before it does anything (+ added, - removed, ~ changed):
native-sync ios: pod install refreshes the Pods project because:
~ modules/vescape-core/ios#layout
Shared assets (Android only) — content hashes of everything copy:shared reads out of shared/
(alert audio, cell presets), plus a check that each expected copy is actually on disk. Drift or a
deleted copy re-runs copyShared(). Steps run in order, so this lands before any prebuild.
Prebuild (both platforms) — content hashes of app.config.ts, package.json, bun.lock,
plugins/, patches/, every modules/*/expo-module.config.json and modules/*/package.json, plus
targets/ on iOS (@bacons/apple-targets copies those into the Xcode project) and watch/ on
Android (withWearMirror copies the Wear OS Mirror into android/wearos/). Any change here means
the generated native project is stale, so the script runs expo prebuild --platform <p>.
Because watch/ is a prebuild input, editing the Wear Mirror is enough: the next bun run android
or bun run wear:* re-runs prebuild, and the plugin re-copies the source. Nothing hand-copies
android/wearos/.
Pods (iOS only) — content hashes of modules/*/ios/*.podspec, plus a hash of the sorted file
path list under each modules/*/ios/. CocoaPods compiles whatever the podspec globbed at
pod install time, so a newly added Swift file stays invisible to Xcode until Pods are regenerated.
Editing an already-compiled file needs no refresh, which is why this scope hashes paths, not
contents — normal Swift work never pays for a pod install.
On iOS the two scopes chain: a prebuild regenerates the Podfile, and expo prebuild does not
reliably install from it, so every iOS sync ends with pod install. It is a fast no-op when Pods
already match.
bun run test:ios builds modules/vescape-core/Package.swift, which globs ios/ instead of listing
files, so a new test needs no manifest edit. SwiftPM memoizes the evaluated manifest keyed on
Package.swift's contents, though, and a new file leaves those contents identical — the stale memo
would keep the old source list and the new test would silently never run. scripts/test-ios.ts
therefore reuses podsFingerprint() (the same sorted-path-list signal Pods drift on) and drops the
SwiftPM manifest memo when the layout moves. Its own fingerprint lives in .expo/test-ios/.
Android has no Pods equivalent: Gradle autolinking resolves Kotlin sources through a directory glob
at build time, so adding a file under modules/*/android/ needs no regeneration step.
iOS has no shared-asset copy: shared/ is the single source of truth for alert audio and cell
presets, and iOS reads it through committed symlinks under modules/vescape-core/ios/ (the podspec globs
alerts/*.wav and cell-presets.json). Gradle cannot follow a symlink out of the module, so Android
is the only platform that needs real copies — and those copies are generated state: gitignored, and
regenerated by copy:shared.
Never commit the copies under modules/vescape-core/android/src/main/res/raw/ (or assets/data/,
test/resources/data/). They duplicate shared/ byte for byte and go stale silently. Build paths
that skip native-sync get them from the postinstall hook, and CI/release call copy:shared
explicitly.
The last successful fingerprint lives in .expo/native-sync/<platform>.json (gitignored, so it is
per-machine and per-checkout). It is written only after the sync command exits 0, and it fingerprints
the tree after the sync, because prebuild can rewrite its own inputs. A missing cache — a fresh
clone, or after bun run clear — is treated as unknown state and triggers a prebuild.
To change what counts as a durable native input, edit the input lists at the top of
scripts/native-sync.ts. Never hand-edit ios/ or android/ to fix drift.