iOS platform integration
iOS platform integration (mobile client)
What the iOS shell of outl-mobile needs from outside Rust.
Three things: the bundle identifiers Apple treats as global, the background-sync wiring across Info.plist / Swift / FFI, and the iCloud catches that only exist when the workspace folder lives in a ubiquity container.
Every item here can only be validated with a device or simulator build — a host cargo check proves nothing about it.
The crate contract (what stays thin, what delegates to outl-actions) lives in crates/outl-mobile/CLAUDE.md.
The user-facing background-sync behaviour is sync.md → Background sync on iOS; the build + release commands are development.md.
Bundle / signing
- Bundle id:
app.outl.mobile-app - Team:
CPEEKT3E77(paid Apple Developer Program) - iCloud container:
iCloud.app.outl.mobile-app - Display name (Files.app / iCloud Drive):
outl - Category:
public.app-category.productivity - Entitlements:
com.apple.developer.icloud-services+icloud-container-identifiers+ubiquity-container-identifiers
Bundle ID + iCloud container are global in the Apple Developer ecosystem. If you change either, also update:
tauri.conf.json→identifiergen/apple/outl-mobile.xcodeproj/project.pbxproj→PRODUCT_BUNDLE_IDENTIFIERgen/apple/outl-mobile_iOS/outl-mobile_iOS.entitlementsgen/apple/outl-mobile_iOS/Info.plist→NSUbiquitousContainerskeygen/apple/project.yml→bundleIdPrefixandPRODUCT_BUNDLE_IDENTIFIER
There is no sixth entry in Rust: lib.rs holds no ICLOUD_CONTAINER_ID, because the Rust-side iCloud path was removed.
Background sync (iOS)
iOS suspends the app’s sockets shortly after it backgrounds, so there is no continuous background P2P.
Three mechanisms cover three different moments, and the first one is not a BGTaskScheduler window at all:
0. The handover — finishing the pass that is already running.
Locking the screen suspends the process within seconds, tearing down whatever delta sync is mid-flight.
That is safe: the responder confirms durable ingest by closing with code 0, so an interrupted push is re-sent on the next tick.
It is also visible — the peer logs peer did not confirm durable ingest (closed: timed out), and the desktop Sync panel shows a red row every time the user pockets their phone.
Neither BGTaskScheduler window helps here — both are requests for a window later, at the scheduler’s discretion.
OutlBackgroundRefresh.flushOnBackground() closes it with a beginBackgroundTask assertion on didEnterBackground.
That buys ~30s of runtime in which the process stays resident, so the forced pass it drives and any inbound sync a peer is mid-way through both complete.
It needs no UIBackgroundModes entry — an assertion is not a mode.
The rules that matter, each of which a first version got wrong:
- End it exactly once.
The expiration handler is a hard deadline — iOS terminates an app that overruns — and
endBackgroundTaskmust never run twice on one identifier.endFlush(matching:)takes the identifier the caller owns, because a foreground bounce can end flush A while its worker still runs, and that worker would otherwise end B’s assertion. - Release on your own pass, not on any pass.
The FFI waits for the sequence number
sync_now_seq()returned and forinbound_serves()to reach zero. Waiting on “the completed-pass counter moved” reads the foreground timer’s pass (mobile fires one every 3s) as your own and releases the window ~250ms in. - Size the window from
backgroundTimeRemaining, not a constant. One unreachable peer costs 5s direct + 10s relay, so a fixed 20s cap overran a real budget with two peers — guaranteeing the tear-down the assertion exists to prevent. Clamped to 3–20s with 5s held back so the FFI returns before the expiration handler fires. - Take it synchronously.
The
didEnterBackgroundobserver registers withqueue: nil; anOperationQueuehop is exactly the window in which iOS begins suspending. - Skip it with zero paired peers (checked on the worker, not on the main thread — it reads
peers.jsonoff disk) or a denied (.invalid) assertion, and never re-enter while one is in flight.
Then the later windows — the two opportunistic BGTaskScheduler tasks, numbered 1 and 2 below.
Both sync, wired across three pieces:
- Info.plist declares
UIBackgroundModes(fetch+processing) andBGTaskSchedulerPermittedIdentifiers(app.outl.mobile-app.refresh,app.outl.mobile-app.sync). Without these the toggle never shows in Settings andBGTaskScheduler.register/submitfail silently. OutlBackgroundRefresh.swiftregisters both tasks (+load→install) through one sharedhandleTaskhelper (reschedule first, FFI on a background queue, complete exactly once — the work and the OS expiration handler race). Therefresh(BGAppRefreshTask, ~30s windows) drives the short FFI; thesync(BGProcessingTask,requiresNetworkConnectivity = true) drives the long one. Scheduling is gated on having paired peers (outl_ios_peer_count() > 0) so an unpaired device never boots the stack for nothing. AdidEnterBackgroundNotificationobserver re-submits on every backgrounding, which also arms the gate right after the first pairing.bg_sync.rsowns the two FFIs (C ABI,@_silgen_nameon the Swift side). They areoutl_ios_background_sync_capped(seconds)andoutl_ios_peer_count()(reads<root>/.outl/peers.jsonfresh from disk, so post-boot pairings count). One capped symbol serves all three windows — each caller passes its own ceiling (12s refresh, 20s processing,backgroundTimeRemainingfor the flush) and Rust clamps it, so an expired budget can’t hang the thread.wire_iroh_transportregisters aCloneof the liveIrohSyncTransportplus the workspace root into a re-settable global. The sync FFIs firesync_now()(a forced delta-sync against every peer, mobile side initiating, which is NAT-friendly). They then pollcompleted_sync_passes()every 250ms, returning as soon as the pass lands — the cap is a fallback, not a fixed sleep.
The FFI + Swift handler can only be validated with a device build.
The simulator has no BGTaskScheduler daemon, so submit always fails there and is swallowed; the Rust side is cargo check-clean on its own.
LAN peer discovery (Bonjour, and why not multicast)
Same-LAN peer discovery (issue #149) reaches every client through outl-sync-iroh’s bind::attach_mdns, but iOS is the one platform that cannot take the path behind it.
iroh-mdns-address-lookup speaks mDNS over a plain BSD socket — bind on 0.0.0.0:5353, then IP_ADD_MEMBERSHIP on 224.0.0.251.
Since iOS 14 that group join needs com.apple.developer.networking.multicast, a restricted entitlement Apple grants by request and commonly declines, with the suggestion to use Bonjour.
So iOS does not join a multicast group at all.
OutlBonjour.swift asks mDNSResponder, the system’s own mDNS daemon, to advertise and browse on the app’s behalf.
Apple’s Local Network Privacy FAQ names exactly two Bonjour operations that still require the entitlement — working with arbitrary service types, and browsing for advertised service types (_services._dns-sd._udp.local.) — and outl does neither.
Never add
com.apple.developer.networking.multicastto the entitlements. It is not needed, and an entitlement the provisioning profile does not carry fails code signing, breaking the TestFlight pipeline.
Two Info.plist keys are what this does need, and both are already in place:
| Key | Why |
|---|---|
NSBonjourServices = _irohv1._udp | The fixed service type. Declaring it is what keeps this out of the “arbitrary service type” case that would need the entitlement |
NSLocalNetworkUsageDescription | The reason string on the one-time local-network prompt |
Why NetService, not NWListener / NWBrowser
Network.framework is the modern API and does not fit, in both directions:
- Publishing.
NWListeneradvertises a port it opened. The port that has to be advertised is the one iroh’s QUIC socket already holds, and noNetwork.frameworktype will advertise a socket it did not create.NetService(domain:type:name:port:)publishes a record for an arbitrary port. - Browsing.
NWBrowserreturns an opaqueNWEndpoint.serviceby design — the framework wants you to connect through it rather than learn addresses. iroh needs the actual socket addresses to hand to QUIC, andNetService.addressesis what exposes them.
NetService is soft-deprecated, not removed, and deprecation has no bearing on App Review.
The failure mode that has no error
If the user declines the local-network prompt, iOS tells the app nothing. Browsing continues, resolves nobody, and looks exactly like a LAN with no peers on it. Nothing in the app can distinguish the two, so there is no in-app warning to write — when an iPhone never finds a peer its laptop finds fine, the answer is Settings › Privacy & Security › Local Network › outl.
Sync still works meanwhile: the relay path is untouched.
iCloud layout (opt-in destination)
When the user opts into iCloud, the root is <ubiquity-container>/Documents/ — one option, not the default, and one the user reaches through the OS file picker.
No Rust code resolves it: workspace_open::icloud_workspace_root() was removed (see crates/outl-mobile/CLAUDE.md → “Change detection: the iroh signal”).
The container is already the outl namespace, so no extra outl/ nesting; the TUI uses --path "<container>/Documents".
Layout is the standard journals/ + pages/ (.md + .outl sidecar) + ops/ (one ops-<actor>.jsonl per device).
iCloud trap: every path must be undotted — iCloud Documents skips .-prefixed paths across devices, so ops/ (not .ops/) and pages/<slug>.outl, else the file never leaves its origin.
Peer-file materialisation (the iCloud catch)
iCloud syncs file metadata aggressively and file content lazily.
When NSMetadataQuery fires on a peer’s ops-<actor>.jsonl, the file’s bytes may not be on disk yet — a std::fs::open returns an empty placeholder.
The Rust side sees a truncated op log; the merge is wrong; the projection writes a broken .md back.
main.mm’s OutlOpsWatcher.onUpdate: works around this in two steps:
[fm startDownloadingUbiquitousItemAtURL:url error:&startErr];
NSFileCoordinator *coord = [[NSFileCoordinator alloc] initWithFilePresenter:nil];
[coord coordinateReadingItemAtURL:url
options:NSFileCoordinatorReadingForUploading
error:&coordErr
byAccessor:^(NSURL *u) { (void)u; }];
startDownloadingUbiquitousItemAtURL requests materialisation; NSFileCoordinator blocks until the file is fully on disk.
Only after that does the watcher fire window.__outlOpsChanged() so the frontend can call reload_workspace.
Skip either step and you race the iCloud download daemon.