The mobile stack is a Rust crate (mew-mobile-core) that owns all
protocol knowledge, exposed to Swift via UniFFI. The iOS app is a thin
SwiftUI layer on top.
Prerequisites
Section titled “Prerequisites”# iOS compile targetsrustup target add aarch64-apple-ios aarch64-apple-ios-sim
# xcodegen (for the Xcode project)brew install xcodegenBuild the mobile core for iOS
Section titled “Build the mobile core for iOS”just ios-coreThis recipe:
- Builds
mew-mobile-corefor bothaarch64-apple-ios(device) andaarch64-apple-ios-sim(simulator) in release mode. - Generates Swift bindings via
uniffi-bindgenfrom the host dylib. - Creates an XCFramework from the two
.afiles with FFI headers.
Output:
mew-ios/MewMobileCore/Sources/MewMobileCore/mew_mobile_core.swift— generated Swift bindingsmew-ios/MewMobileCore/XCFramework/mew_mobile_core.xcframework— universal binary (gitignored, regenerate withjust ios-core)
SwiftPM package
Section titled “SwiftPM package”mew-ios/MewMobileCore/ is a SwiftPM package with two targets:
mew_mobile_coreFFI(binary target): the XCFramework with the static library + FFI headers (modulemap defines themew_mobile_coreFFIC module).MewMobileCore(source target): the generated Swift bindings thatimport mew_mobile_coreFFIand expose typed Swift types (MobileCore,CoreEvent,CoreListener, etc.).
To verify the package builds:
cd mew-ios/MewMobileCorexcodebuild -scheme MewMobileCore \ -destination 'generic/platform=iOS Simulator' buildNote:
swift build(host) will fail because the XCFramework only has iOS slices. Usexcodebuildfor verification.
Build the iOS app
Section titled “Build the iOS app”cd mew-iosxcodegen generate # creates mew.xcodeproj from project.ymlxcodebuild -project mew.xcodeproj -scheme mew \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' buildOr open mew.xcodeproj in Xcode and build/run from the GUI.
Crate structure
Section titled “Crate structure”crates/mew-mobile-core/├── Cargo.toml uniffi + tokio + iroh deps, crate-type = [lib, staticlib, cdylib]├── uniffi.toml cdylib_name = "mew_mobile_core_ffi"├── src/│ ├── lib.rs MobileCore struct, connect_and_run, translate_message│ ├── codec.rs Lenient decoder (tolerate unknown ServerMessage variants)│ ├── events.rs CoreEvent enum, CoreListener trait, DaemonStatus│ ├── registry.rs On-device DaemonRegistry (JSON store, never synced)│ └── state.rs SessionState part-assembly, DaemonSnapshot├── src/bin/│ └── uniffi-bindgen.rs Binary entry point for `uniffi-bindgen generate`└── tests/ ├── m0_spike.rs Transport spike: iroh connect → WS upgrade → Ping/Pong → NewSession → Prompt └── m1_integration.rs Full event pipeline: MobileCore.connect() → events through listenerThe mobile core
Section titled “The mobile core”Phone identity
Section titled “Phone identity”One iroh SecretKey per install, generated on first launch. The Swift
layer persists 32 key bytes in the iOS keychain
(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — non-synchronizable,
the key must not iCloud-sync). The NodeId (public key) is displayed in
settings for pairing.
Connection model
Section titled “Connection model”One iroh connection per daemon, mirroring the web client’s one-WS model:
endpoint.connect(node_id, MEW_ALPN)→open_bi()→ WebSocket client handshake over the QUIC stream.- Send
Ping, recordPong { version }for version-skew warnings. - Sessions are switched with
AttachSessionon the same connection. SessionAlertarrives on any connection regardless of attachment.- Connections to daemons the user isn’t looking at are lazy: connect on demand, plus an optional “keep connected while foregrounded” toggle per daemon.
Reconnect
Section titled “Reconnect”Exponential backoff (1s, 2s, 4s… cap 30s) with jitter, reset on
success. After reconnect, re-sends AttachSession — the daemon
replays SessionHistory, and the core rebuilds session state from
the replay.
Lenient codec
Section titled “Lenient codec”mew-protocol’s serde enums reject unknown variants. A newer daemon
adding a ServerMessage variant must not kill the phone’s connection.
The core decodes each frame to serde_json::Value, reads the type
tag, and if full decode fails, logs and drops that frame.
State assembly
Section titled “State assembly”The core ports the web store’s part-assembly logic:
Provider(PartStart/PartDelta/PartEnd/MessageEnd) → parts → messages,
tool call states, pending permission/ask requests, session usage.
PartUpdated is authoritative — when it arrives for a part built from
accumulated deltas, replace the accumulated state wholesale.
File browser
Section titled “File browser”The core exposes a list_dir(daemon_id, session_id, path?) method that
sends ClientMessage::ListDir. The daemon responds with
ServerMessage::DirListing { path, entries: Vec<DirEntry> }, which the
core translates to CoreEvent::DirListing. DirEntry is a UniFFI
record with name, is_dir, and size.
The iOS app drives this from a + button on the chatbar: tapping a
folder navigates in, tapping a file appends its path to the composer.
Defaults to the session cwd (if known) or the daemon default.
TextDelta coalescing
Section titled “TextDelta coalescing”UniFFI callbacks cross the ObjC bridge per call. A callback per token is too expensive. The core batches text deltas on a ~16ms tick before FFI and emits one event per batch.
Testing
Section titled “Testing”# Unit tests (fast, no network)cargo test -p mew-mobile-core --lib
# Integration tests (real iroh, ~45s each)cargo test -p mew-mobile-core --features test-harness --test m0_spike -- --nocapturecargo test -p mew-mobile-core --features test-harness --test m1_integration -- --nocaptureThe test-harness feature flag gates the integration tests, which
spin up a real daemon with a fake provider over real iroh endpoints
(N0 preset, relay-based). The M1 test verifies the full event pipeline:
MobileCore.connect() → events arrive through the listener →
Connected → DaemonVersion → SessionReloaded → TurnEnded →
TextDelta → snapshot() has messages.
Integration tests use #[tokio::test(flavor = "multi_thread")] because
MobileCore::connect() calls tokio::spawn for the background
connection task.
The ios-ci job (.github/workflows/ci.yml) runs on macOS and does
three things:
cargo checkfor both iOS targets (aarch64-apple-iosandaarch64-apple-ios-sim).- Regenerates the UniFFI Swift bindings from the current Rust interface.
- Fails if the committed bindings (
mew_mobile_core.swiftandmew_mobile_coreFFI.h) differ from the regenerated ones.
This is the guard against bindings drifting behind the Rust core, which
breaks the Xcode build and can crash the app on an FFI mismatch. If the
job fails, run just ios-core and commit the regenerated files.
Adding a new CoreEvent variant
Section titled “Adding a new CoreEvent variant”- Add the variant to
CoreEventinevents.rs(with#[derive(uniffi::Enum)]). - Handle it in
translate_message()inlib.rs— emit the new event from the matchingServerMessagearm. - Regenerate Swift bindings:
just ios-core(orcargo run -p mew-mobile-core --bin uniffi-bindgen -- generate ...). - Handle the new event in
AppStore.handleEvent()inmew-ios/mew/AppStore.swift. - Rebuild the app:
cd mew-ios && xcodegen generate && xcodebuild ....
Known issues
Section titled “Known issues”- The XCFramework only has
arm64slices (device + simulator). Intel Macs running the simulator needx86_64-apple-ios-simadded to the build recipe. - The xcframework headers need
module.modulemap(not<name>.modulemap) for SwiftPM binary targets to import correctly. Thejust ios-corerecipe handles this with a temp headers dir. swift buildon macOS host fails — the xcframework has no macOS slice. Usexcodebuildfor verification.