August 26, 2026
3 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
Cuts v0.24.1. Merging this does not publish; pushing the v0.24.1 tag onto main afterwards is what fires release.yml.
Why a patch
Both commits in v0.24.0..main are iOS React Native bridge fixes (#416, #418). Public surface was checked mechanically rather than by intuition: the diffs for offline_protocol.udl, types.ts, config.rs and events.rs against v0.24.0 are empty once comment lines are stripped. ForwardMessageParams.priority stays optional in TypeScript; only the value crossing the bridge changed.
Version files
Every entry in CONTRIBUTING.md's table moved together:
Cargo.toml:[workspace.package].versionplus the ten internal-dependency versionscrates/offline-protocol-sealed/Cargo.toml(1) andcrates/offline-protocol-leaf/Cargo.toml(3): the four internal-dependency versions the dualstd/no_stdcrates declare locallyCargo.lock,tools/embedded-footprint/Cargo.lock,tools/mls-interop/Cargo.lock(both tool workspaces pin the SDK by path and CI builds them--locked)bindings/python/pyproject.tomlbindings/react-native/package.json+package-lock.jsonTHIRD-PARTY-NOTICES.mdx3, regenerated with cargo-about 0.9.1 (they list our own crates by version, so a bump makes all three stale and the CI drift gate fails)
SECURITY.md is deliberately untouched: a patch stays on the 0.24.x line.
Prose
0.24.0 moves to the new docs/changelog/0.24.md series file, with its relative links rewritten for the two-directory move and the move verified byte-for-byte against the text as tagged. Both archive tables gained a row.
docs/UPGRADING.md gains §18 and an intro paragraph, because nothing here breaks a build and three things change behaviour on iOS, so the version number warns nobody on its own:
- The relay config block was discarded from
create()since 0.22.0 while the engine keptRelayConfig::default(), which isallow_relay: truewith a 30% floor. The setting being ignored was therefore the opt-*out*: a device told not to relay has had its forwarding gate open and closes it on this release. A stricterminBatteryForRelaystarts being honoured, and the floor could not bite at all before, becausesetBatteryLevelclamped its garbled argument to 100 on every call. wipePersistedStatewas uncallable since 0.21.0, so every account signed out of since then left its MLS identity and sealed state on disk.- Presence updates meant to say
awayorofflineall went out asonline, and every message went outmedium, because bothdefault:arms are the innocuous value.
§18 also names the reason to take this promptly rather than batch it: twelve byte conversions aborted the process on any array element outside 0...255, reachable from a peer's frame rather than only from the app's own code.
Verification
Ran locally on this branch, all green:
| Gate | Result |
|---|---|
cargo clippy --workspace --locked -- -D warnings | clean |
cargo test --workspace --lib | 2532 passed, 0 failed |
cargo fmt --all -- --check | clean |
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps | clean |
clippy on thumbv8m.main-none-eabihf for core, sealed, leaf | clean |
scripts/check-license-consistency.sh | consistent |
npx tsc --noEmit (react-native) | clean |
npm run test:js | passed |
Changelog audit for the range: both commits touched CHANGELOG.md, every added line landed inside [Unreleased], and there was exactly one ### Changed-class heading between [Unreleased] and the previous release, so no entry was injected into the shipped 0.24.0 section. Every relative link and intra-document anchor in the four edited markdown files resolves.
After merge
Push v0.24.1 on main. The version-gate job runs ahead of both publish jobs and refuses a tag whose number disagrees with [workspace.package].version or pyproject.toml, so a mistake there fails before anything uploads. Do not force-move the tag once it has published.
One consumer note that is not a blocker: an application whose .npmrc sets min-release-age, as fernweh_v2 does at 7, cannot npm install this version until the cooldown passes and needs --min-release-age=0.
The problem
React Native forces every NSNumber argument to non-null, because numbers are not nullable on Android, and refuses a null one before the Swift method is entered. Neither the resolver nor the rejecter runs, so the promise never settles and await forwardMessage(...) hangs forever behind a redbox.
The TypeScript passed null whenever a caller omitted the priority, which made forwardMessage the one method in this bridge relying on a nullable number. As #417 documents, no spelling of the declaration fixes it: nullable is rejected at module load, unspecified and nonnull are both forced non-null, and a plain NSInteger did not match the Swift NSNumber?.
The fix
The nullability is removed rather than respelled, because the null carried no information in the first place:
- the core already does
priority.unwrap_or(MessagePriority::Medium)(crates/offline-protocol/src/protocol/send.rs:259), ForwardMessageParams.priorityalready documented "defaults to Medium", andsendMessagealready had the identical API and already resolves the default in TypeScript, crossing as a required int.
forwardMessage now matches its sibling exactly: TypeScript sends params.priority ?? MessagePriority.Medium, the shim takes NSInteger, Swift and Kotlin take Int, and each maps an unrecognised value back to Medium.
This is the RN bridge, not UniFFI output, so the UDL is untouched and no bindings were regenerated.
Impact
No caller sees a behaviour change on either platform: an omitted priority meant Medium before and means Medium now. The check that refused the null is inside #if RCT_DEBUG, so only development was affected. The public ForwardMessageParams type is unchanged.
Why a guard cannot hold this
react_native_ios_objc_shim_and_swift_agree_on_every_selector compares ABI classes, and a nullable number and a nullable object share one. Both halves agreed while React Native rejected the call anyway. So the rule is written down instead: both BRIDGE_MAINTENANCE.md and docs/bridges/swift.md lose the paragraph describing the breakage and gain the rule that a nullable number never crosses this bridge.
The TypeScript half is pinned by a new harness file, js-ci-harness/forward-priority.test.js, wired into npm run test:js. It covers the argument always being a number and the MessagePriority.Low-is-0 trap, where resolving the default with || instead of ?? would silently upgrade every Low forward to Medium.
Verification
| Check | Result | ||
|---|---|---|---|
cargo test --workspace --lib | pass | ||
cargo clippy --workspace -- -D warnings | pass | ||
| iOS ABI/selector guards (5 tests) | pass | ||
swiftc -typecheck, full hand-written iOS source set | pass, negative control confirmed at the changed method | ||
Android :offlineprotocol:testDebugUnitTest (clean-dir copy) | BUILD SUCCESSFUL | ||
npm run test:js (all 6 harness files) | pass | ||
| New harness cases mutation-tested | ?? null and `\ | \ | ` each fail the case that covers them |
What was broken
RCT_EXTERN_METHOD does not declare a Swift method. It records a selector string that React Native resolves against the class at module load, in parseExportedMethods. A selector no Swift method implements is dropped there behind an RCTLogWarn and the JS method is simply absent.
Neither half's compiler sees the other: the .m compiles standalone against the macro, and OfflineProtocolModule.swift is the one bridge source no CI job compiles at all (it needs real React headers). So three separate drifts shipped, costing eight methods across 0.21.0 to 0.24.0:
| Method(s) | Shape | Broken since | Symptom |
|---|---|---|---|
wipePersistedState | label renamed on one side only (userId: vs profile:) | 0.21.0 | boot WARN, undefined is not a function |
setBatteryState, getIsCharging, updateRelayConfig, getRelayConfig | never declared in the shim | 0.22.0 | silent: no WARN, and create() swallows the failure in a console.warn |
dataListSpaces, dataFlushAll, dataWipeAll | labelled first parameter | 0.23.0 | boot WARN, method unresolvable |
The reported issue was wipePersistedState only. The other seven were found by the guard added here.
Android was never affected. Its dispatch is by method name and position; Kotlin parameter names never participate, and the Kotlin side was correct throughout.
Behaviour change worth calling out to app teams
updateRelayConfig / getRelayConfig being absent meant that since 0.22.0 every relay setting an application passed to create() was discarded on iOS, behind a console.warn. Apps that set allowRelay, minBatteryForRelay or relayPriority will see those settings take effect on iOS for the first time on the release carrying this fix. Same for the battery feed, which had no iOS writer.
Why the three data methods are fixed in Swift, not in the shim
Swift exports f(resolver:rejecter:) as fWithResolver:rejecter:, not f:rejecter: (verified empirically against swiftc, not from memory). React Native names the JS method after the selector text before its first colon, so spelling dataListSpacesWithResolver: in the shim would have renamed the JS method rather than repairing it. Dropping the label in Swift is the only fix that restores dataListSpaces as a callable JS name, and it matches the other 162 methods.
The guard
react_native_ios_objc_shim_and_swift_agree_on_every_selector reads OfflineProtocolModule.m, OfflineProtocolModule.swift and src/index.ts and compares them as sets in three directions:
1. every declared selector is implemented in Swift (else RN drops the binding);
2. every exported Swift method is declared (else it is unreachable, with no diagnostic at all);
3. every native method the TypeScript calls is one the shim exports under that name.
This is C5's mechanism applied to a selector table rather than a constant, and it is a Rust guard for C5's stated reason: for sources CI typechecks at most and never runs, a source-reading guard is the only reachable pin.
The set of Swift methods held to this is derived, not listed: an @objc method is one React Native exports exactly when it takes the promise pair. That excludes the two NotificationCenter targets and RN's own addListener/removeListeners overrides without naming them, and puts a *new* bridge method inside the invariant the moment it is written.
Verification
- Negative control: the guard was written first and run against the unfixed tree. It failed, naming the four dangling selectors.
- Mutation tests, each restored and checksum-verified (no
git checkout, which would have eaten the uncommitted fix): - deleting a
RCT_EXTERN_METHOD→ direction 2 fires naminggetIsCharging:rejecter:; - pointing a JS call at an unexported name → direction 3 fires naming it.
cargo fmt --all -- --check,cargo clippy --workspace -- -D warnings,cargo test --workspace(27 suites),RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps: all clean.- Swift: the full hand-written iOS source set typechecks under the
BRIDGE_MAINTENANCE.mdsymlink-farm recipe (swiftc -typecheck, exit 0), and that harness was itself negative-controlled with a deliberate bad type before the clean run was trusted.swift testinios/: 252 tests pass. - No UDL change, so no binding regeneration; no FFI, wire or Android changes.
Docs
docs/bridges/swift.md S1 gains the label-agreement and unlabelled-first-parameter rules; docs/bridges/README.md C5 gains the selector table as its seventh entry; BRIDGE_MAINTENANCE.md gains a step and a Common Issues entry with the command to run.
Review round 2
Three findings from review, all addressed.
The guard could pass while checking nothing
Directions 1 and 2 each assert their parser found at least 150 methods, on the grounds that two empty sets agree perfectly. Direction 3 had no such floor, and it is the one that needs it most: the TypeScript scan keys off the literal OfflineProtocolNativeModule., so renaming that binding, destructuring it, or moving to a TurboModule spec would match nothing, find nothing, and pass. It now counts calls and requires 150 (there are 167). Mutation-tested: renaming the identifier fails with only found 0 native-module calls.
Thirteen conversions abort the app instead of rejecting
Review flagged processFileChunk's data.map { UInt8($0.intValue) }. Grepping the class found thirteen, not one:
| Sites | Reachable from |
|---|---|
| 12 array conversions | a peer's malformed BLE fragment, a Wi-Fi Direct or internet frame, an MLS ciphertext, a Welcome, a key package, a file chunk |
initialTtl | create(), for any app passing a number above 255 |
UInt8(_:) traps: it aborts the process rather than returning something the bridge could reject. Arrays now convert through a throwing jsBytes helper that lands in the rejection every one of these call sites already had; initialTtl is clamped, matching Android, which truncates through toUByte() and starts normally where iOS crashed.
These were never masked by the ABI bug above. Array arguments cross as NSArray * against [NSNumber], which has agreed since the UniFFI migration, so each has been live in every release that shipped the method.
Pinned by react_native_ios_bridge_bounds_every_byte_it_builds_from_javascript, which fails on any UInt8(...) in the module whose argument does not carry its own bound (exactly:, clamping:, min(, uint8Value, a mask or a shift). It reads text because what makes a conversion safe is local to where it is written; processFileChunk's scalar narrowings are bounded by a guard several lines above instead, which no textual rule can see. Mutation-tested: restoring one trapping conversion fails naming $0.intValue.
forwardMessage is now tracked, not just described
Its optional priority cannot cross the bridge at all: React Native forces every NSNumber argument to non-null (Android cannot express a nullable number), so a null one is refused in RCTModuleMethod.mm before invokeWithTarget: runs. Neither resolver nor rejecter fires and the promise never settles. It is #if RCT_DEBUG-only, so release builds are fine. No declaration fixes it; filed as #417 with the three candidate contract changes, and linked from BRIDGE_MAINTENANCE.md and docs/bridges/swift.md.
Verification
Both new guards mutation-tested and restored checksum-verified. cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps all clean. The full hand-written iOS source set typechecks (swiftc -typecheck, exit 0) under the BRIDGE_MAINTENANCE.md recipe, negative-controlled first; swift test 252 pass; tsc --noEmit clean. Still no UDL change, so no binding regeneration.
Review round 3
One finding, plus two comments of my own that were wrong.
The same bug class survived round 2, in a conversion that is not a byte
Round 2 swept the module for trapping conversions and pinned the result with a guard that reads every UInt8(...). Both DORS config paths were outside that net, because their conversion is an Int:
let historyWindowRaw = (config["historyWindowSize"] as? NSNumber)?.uint64Value ?? current.historyWindowSize
let historyWindow = max(1, min(100, Int(historyWindowRaw))) // clamp is too late
That reads as bounded and is not, because the narrowing runs first. historyWindowRaw is a UInt64, a negative JavaScript number reaches uint64Value as UInt64.max by C conversion, and Int(UInt64.max) traps. So create({dors: {historyWindowSize: -1}}) aborted the app, and so did the same field through updateDorsConfig. A value like 1e20 saturates to the same place.
Fixed by clamping in the domain the value arrives in rather than by making the narrowing safe: historyWindow is only ever consumed as a UInt64, so the round-trip through Int is deleted outright and the two UInt64(historyWindow) call sites become identity conversions and go with it. Nothing narrows any more, so there is nothing left to get wrong.
No new guard, deliberately. Whether Int(raw) is safe depends on what raw already is, which the text cannot say, and the module is full of legitimate widenings (Int(hops), Int(progress.chunksSent)) that a textual rule would flag. This is the processFileChunk situation again: held by review and the checklist. Both bridge docs now carry the rule that a clamp wrapped *around* a narrowing conversion does not count, which is the part that generalises.
I re-swept every other narrowing in the file while I was here. The rest are clean: the DORS UInt64(...uint64Value) forms are identity conversions of non-trapping accessors, the data-layer calls use UInt32(truncating:), UInt16(finalPort) sits behind an explicit 0...65535 guard, and the remaining Int(...) sites convert engine outputs rather than JavaScript input.
Two comments on the new guards were wrong
- The byte guard's empty-argument skip claimed to exempt
UInt8. It never reaches that check at all: the]before the paren meansUInt8(does not match in the first place. What the skip actually exempts is the zero-argumentUInt8(). - The ABI classifier claimed
Int?boxes into anNSNumber. It cannot.@objcrefuses a method outright when a parameter is an optional value type, so only reference types ever reach that branch.
Neither affected behaviour, but a guard that explains itself wrongly is a guard someone edits wrongly later.
Verification
cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, cargo test --workspace, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps all clean. The full hand-written iOS source set typechecks under the BRIDGE_MAINTENANCE.md symlink-farm recipe (swiftc -typecheck, exit 0), negative-controlled first, and the negative control doubles as proof of the fix's key assumption: it failed with cannot convert value of type 'UInt64' to specified type 'String', which is the compiler confirming historyWindow now infers as UInt64. The injected error was removed and the file checksum-verified back to its pre-injection state. Still no UDL change, so no binding regeneration.
Downstream: OFF-2462 closes once this ships and the companion app bumps its pin.