Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7084ae8
feat(bindings): peer-stream links keep the lower-opened stream, and a…
mizanisoffline Sep 30, 2026
c8498f3
feat(bindings): the iOS peer stream rides Network framework instead o…
mizanisoffline Sep 30, 2026
707b982
docs: ADR 0027, and the peer-stream docs for Network framework on iOS
mizanisoffline Sep 30, 2026
1f425e4
fix(bindings): read peer-stream frames in one copy per chunk, and tes…
mizanisoffline Sep 30, 2026
3acd4bc
test(bindings): a Python peer-stream host for testing the iOS manager…
mizanisoffline Oct 1, 2026
f839d36
fix(bindings): the iOS peer browser ignores DNS-SD service-instance r…
mizanisoffline Oct 1, 2026
cec7401
docs(transport): the iOS peer stream is a TCP connection, not a Multi…
mizanisoffline Oct 1, 2026
502a25e
fix(bindings): a stale DNS-SD record no longer takes a live peer's ad…
mizanisoffline Oct 1, 2026
3277367
fix(bindings): one LAN host cannot fill the iOS peer-stream listener
mizanisoffline Oct 1, 2026
c450ea9
fix(bindings): a peer-stream dial with no free slot is tried again
mizanisoffline Oct 1, 2026
84c9e28
test(bindings): pin the iOS peer-stream advert's bytes in the harness
mizanisoffline Oct 1, 2026
280a120
fix(bindings): stop redialing a peer-stream record that cannot prove …
mizanisoffline Oct 1, 2026
0e418d5
fix(bindings): bound an iOS peer-stream dial as a whole, not only whi…
mizanisoffline Oct 1, 2026
3eb587a
fix(bindings): a peer-stream dial that lost the tie-break is not unpr…
mizanisoffline Oct 1, 2026
6af7c33
docs(bindings): the iOS inbound bound counts addresses, and the reser…
mizanisoffline Oct 1, 2026
2d3507a
fix(bindings): regenerate the Python local API table after the UDL co…
mizanisoffline Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,7 @@ jobs:
run: swift test --package-path bindings/react-native/ios

# The largest bridge files are on Package.swift's `exclude:` list (they
# need CoreBluetooth / MultipeerConnectivity / React / the generated
# need CoreBluetooth / Network / React / the generated
# UniFFI module, none of which the SwiftPM harness can supply), so
# `swift test` above compiles none of them and every change to them has
# historically shipped with zero compile coverage. They CAN be
Expand Down
37 changes: 26 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,15 +501,16 @@ archived by series under [docs/changelog/](docs/changelog/); see the
core, whatever `appId` the app had configured. They now stamp the
configured id.
- **The mobile peer-stream managers carry traffic.** Android's Wi-Fi Direct
manager and iOS's Multipeer manager used to drop every inbound frame and
manager and iOS's peer-stream manager used to drop every inbound frame and
announce no peer, because nothing on the wire said who a peer was. Both now
exchange the identity preamble from
[the stream chapter](docs/spec/stream-framing.md): each side sends its
assertion first, checks the peer's with `verifyIdentityAssertion`, and
announces the peer only under the address it proved. A peer that sends
anything else first, or nothing for ten seconds, is disconnected unannounced.
One peer is announced per address, and a newer connection for the same
address replaces the older one without a loss event. A peer is reported
One peer is announced per address, and of two connections for the same
address one is closed without a loss event (on Android the newer is kept,
on iOS the one the lower address opened; see Changed). A peer is reported
lost exactly once, and no message is delivered after that report.
- **Android Wi-Fi Direct framing faults.** The reader refused a message of
exactly 1 MiB, and after refusing a length it read the skipped body as the
Expand Down Expand Up @@ -569,14 +570,28 @@ archived by series under [docs/changelog/](docs/changelog/); see the
short throttle while the user edits, at most about twice a second, and once
more when editing stops.

- **iOS: the Multipeer service type is `offlineprotocol`.** It was
`offline-proto`. An app that enables the `wifiDirect` transport on iOS must
list both `_offlineprotocol._tcp` and `_offlineprotocol._udp` under
`NSBonjourServices` in its `Info.plist`, or iOS blocks discovery with no
error. Its discovery info now carries `txtvers` and `addr` instead of the
app's `profile`. An iPhone on this release and one on an earlier release do
not see each other over Multipeer. Neither carried traffic before, so no
working path is lost.
- **iOS: the peer-stream slot runs on Network framework, not
MultipeerConnectivity.** The `wifiDirect` transport on iOS now opens TCP
streams with `NWListener`, `NWBrowser` and `NWConnection`, over the local
network or, with no shared network, over AWDL, so two iPhones still reach
each other with no access point. It advertises and browses
`_offlineprotocol._tcp` with `txtvers=1` and `addr`, the record the Python
`PeerStreamManager` uses, so an iPhone and a host on one LAN now find and
talk to each other. Breaking for apps: an iPhone on this release does not
see one on 0.27 or earlier over this slot; `NSBonjourServices` needs only
`_offlineprotocol._tcp` (the `_udp` entry can go), and
`NSLocalNetworkUsageDescription` is still required. A denied local-network
permission is now reported as an `error` diagnostic instead of failing
silently. The seven-peer cap Multipeer imposed is gone; at most sixteen
streams are open, twelve of them inbound and four from any one address. The
hop is plain TCP, as on Android and Python, where Multipeer encrypted it;
payloads are sealed either way. Both ends of a pair now dial, and of two
streams for one address both keep the one the lower address opened, the
Python manager's rule
([ADR 0027](docs/adr/0027-ios-peer-streams-ride-network-framework.md)).
The podspec links `Network` in place of `MultipeerConnectivity`. This
replaces the unreleased Multipeer service-type change, which never
shipped.

- **Python: `InternetManager` requires `app_id`.** It is now a keyword-only
argument with no default, because the default was the fixed id above.
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/offline_protocol_sdk/local_api/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from __future__ import annotations

UDL_SHA256 = "cff2c11d29e6149e3d9c04919c03992fd2c1f760ecf20af1a6054e796851661e"
UDL_SHA256 = "db1022f43254c9843ac4164e4993169e1d4886901be23f1340d6aed5652e43bc"

TABLE = {'callbacks': ('MlsStorageProvider',
'ProtocolStateStorageProvider',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Peer-stream transport manager: TCP streams behind the ``wifi_direct`` slot.

A peer stream is a byte stream established to exactly one other device. On a
phone that is a Wi-Fi Direct group socket or a Multipeer session; on a host
phone that is a Wi-Fi Direct group socket or a TCP stream over AWDL; on a host
it is a TCP connection over a LAN or a routed mesh. To the engine they are one
transport, registered in the slot the FFI names ``wifi_direct`` for
historical reasons, and this manager is the host's implementation of the
Expand Down
2 changes: 1 addition & 1 deletion bindings/react-native/MeshSdk.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Pod::Spec.new do |s|
}

# System libraries and frameworks
s.frameworks = "Foundation", "CoreBluetooth", "MultipeerConnectivity"
s.frameworks = "Foundation", "CoreBluetooth", "Network"

# React Native dependency
s.dependency "React-Core"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ class PeerStreamPreamble(
*
* Policy: the newer stream supersedes the older. On a phone the duplicate is
* almost always the same peer reconnecting past a stream that went half-open
* (a group client that re-joined, a Multipeer peer that restarted), and
* (a group client that re-joined), and
* refusing the newer one would leave that peer unreachable until the stale
* stream's socket noticed. The cost, recorded in R16, is that a replayer can
* choose when a real stream ends; it cannot use the stream it gets.
Expand Down
5 changes: 3 additions & 2 deletions bindings/react-native/ios/OfflineProtocolModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ class OfflineProtocolModule: RCTEventEmitter {

// MARK: - iOS background / Wi‑Fi suspension

/// When the app enters background, iOS kills MultipeerConnectivity. Notify Rust so DORS
/// stops routing over Wi‑Fi Direct and uses BLE (allowed in background).
/// When the app enters background, iOS suspends the peer-stream listener and its
/// connections. Notify Rust so DORS stops routing over the peer-stream slot and uses
/// BLE (allowed in background).
private func addBackgroundObservers() {
NotificationCenter.default.addObserver(
self,
Expand Down
2 changes: 2 additions & 0 deletions bindings/react-native/ios/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ let package = Package(
"NostrQueryTrackerTests.swift",
"OutboundFragmentQueueTests.swift",
"PeerIdentityBindingTests.swift",
"PeerStreamDialPolicyTests.swift",
"PeerStreamFramingTests.swift",
"PeerStreamReaderTests.swift",
"PeerStreamSessionTests.swift",
"PeripheralRestorationAgeOutPolicyTests.swift",
"PresenceWatchPolicyTests.swift",
Expand Down
140 changes: 123 additions & 17 deletions bindings/react-native/ios/PeerStreamFraming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
//
// The parts of docs/spec/stream-framing.md a platform manager owns: the
// length prefix, its bounds, the position rule that makes the first body on a
// stream the peer's identity assertion, and one announced stream per address.
// stream the peer's identity assertion, one announced stream per address, and
// the DNS-SD advert a peer finds it by.
//
// Foundation only, so the SwiftPM harness tests it without a Multipeer session
// Foundation and CryptoKit only, so the SwiftPM harness tests it without a socket
// (PeerStreamFramingTests replays the chapter's conformance vectors).
// WifiDirectManager owns the session and calls into this. Mirrors android's
// PeerStreamFraming.kt, keep in sync.
// WifiDirectManager owns the connections and calls into this. Mirrors
// android's PeerStreamFraming.kt, keep in sync.
//

import CryptoKit
import Foundation

/// Why a frame or a preamble was refused. The stream closes in every case.
Expand Down Expand Up @@ -55,13 +57,13 @@ enum PeerStreamFraming {
return nil
}

/// The body of one whole frame delivered by a message-oriented carrier.
/// The body of one whole frame, as `PeerStreamReader` cuts it off the
/// stream.
///
/// A Multipeer session already delivers whole messages, and the chapter
/// still wraps each as one frame so that the ceiling is read off the same
/// four bytes on every carrier. The prefix must therefore account for
/// every byte after it: a message carrying more or less than its prefix
/// says is refused, since there is no stream to resynchronise on.
/// The prefix must account for every byte after it: a message carrying
/// more or less than its prefix says is refused. The reader already
/// refused a length over the ceiling before buffering its body; the
/// preamble floor is checked here, where the position is known.
static func unframe(_ message: Data, preamble: Bool) -> Result<Data, PeerStreamRefusal> {
guard message.count >= prefixBytes else {
return .failure(PeerStreamRefusal(reason: "shorter than a prefix"))
Expand All @@ -78,6 +80,85 @@ enum PeerStreamFraming {
// Rebased, so the caller can index from zero.
return .success(Data(message.dropFirst(prefixBytes)))
}

/// The DNS-SD TXT record, built by hand because the chapter requires
/// `txtvers=1` to be the first entry and `NWTXTRecord` does not promise an
/// order. `addr` is absent until this device has an identity. An entry
/// longer than its one length byte can say is left out rather than
/// trapping the host app; an address is far shorter.
static func txtRecord(address: String?) -> Data {
var entries = ["txtvers=1"]
if let address = address, !address.isEmpty {
entries.append("addr=\(address)")
}
var out = Data()
for entry in entries {
let bytes = Data(entry.utf8)
guard let length = UInt8(exactly: bytes.count) else { continue }
out.append(length)
out.append(bytes)
}
return out
}

/// The DNS-SD instance name: a digest of the address, as the Python
/// manager names its own. A restarted listener (every return from the
/// background) then replaces its record in each peer's cache instead of
/// publishing a second one beside the stale one. Random only while there
/// is no identity, when the record carries no address and no browser
/// dials it.
static func instanceName(address: String?) -> String {
guard let address = address else { return "op-\(UUID().uuidString.prefix(8).lowercased())" }
let digest = SHA256.hash(data: Data(address.utf8))
return "op-" + digest.prefix(8).map { String(format: "%02x", $0) }.joined()
}
}

/// Cuts whole frames, prefix included, off a byte stream (the chapter's
/// "What a receiver owes", steps one and two).
///
/// A prefix over the ceiling, or zero, is refused the moment its four bytes
/// are in, before a byte of the body is buffered: a reader that buffered first
/// would hand the peer a megabyte of this device's memory per stream for four
/// bytes. So the reader holds at most one frame, the ceiling plus the prefix.
/// After a refusal the stream is garbage and the reader returns nothing more;
/// the owner closes the stream.
///
/// Not thread-safe. One stream's owner drives its instance from one queue.
final class PeerStreamReader {
private var buffer = Data()
private var refused = false

/// Every frame `chunk` completes, in order, ending with a refusal if one
/// was met.
func append(_ chunk: Data) -> [Result<Data, PeerStreamRefusal>] {
guard !refused else { return [] }
buffer.append(chunk)
var out: [Result<Data, PeerStreamRefusal>] = []
// Frames are read at an offset and the buffer compacted once at the
// end. Compacting per frame re-copied the rest of the buffer each
// time, so a chunk of many small frames cost its size squared.
var start = buffer.startIndex
while buffer.endIndex - start >= PeerStreamFraming.prefixBytes {
let length = UInt32(buffer[start]) << 24 | UInt32(buffer[start + 1]) << 16
| UInt32(buffer[start + 2]) << 8 | UInt32(buffer[start + 3])
// The floor is `unframe`'s to check, which knows the position.
if let refusal = PeerStreamFraming.refusal(forLength: length, preamble: false) {
refused = true
buffer = Data()
out.append(.failure(refusal))
return out
}
let end = start + PeerStreamFraming.prefixBytes + Int(length)
guard buffer.endIndex >= end else { break }
out.append(.success(Data(buffer[start..<end])))
start = end
}
if start != buffer.startIndex {
buffer = Data(buffer[start...])
}
return out
}
}

/// The position rule for one stream: the first body is the peer's identity
Expand Down Expand Up @@ -151,12 +232,17 @@ final class PeerStreamPreamble {
/// preamble is enough to open a second stream for a live address, so the count
/// has to be kept here, where the streams are.
///
/// Policy: the newer stream supersedes the older. On a phone the duplicate is
/// almost always the same peer reconnecting past a stream that went half-open
/// (a Multipeer peer that restarted and came back under a new `MCPeerID`), and
/// refusing the newer one would leave that peer unreachable until the stale
/// one timed out. The cost, recorded in R16, is that a replayer can choose when
/// a real stream ends; it cannot use the stream it gets.
/// Policy: the stream the lower address opened is kept, and between two of
/// those the newer supersedes the older. Both ends compute it alike, which is
/// the point: both ends of a pair may dial, and so does a Python host on the
/// same LAN, so without a shared rule each end would keep the stream the other
/// closes, and the pair would reconnect forever. It is the Python manager's
/// `_new_stream_wins`, and `ios_and_python_peer_streams_keep_the_same_stream`
/// pins the two copies together (ADR 0027). "Newer" among winners is what
/// lets the lower address reconnect past its own half-open stream; the higher
/// address's reconnect waits for keepalive to end the stale one. The cost,
/// recorded in R16, is that a replayer can end a real stream when its copy is
/// the winning kind; it cannot use the stream it gets.
///
/// Thread-safe, and deliberately knows nothing of the protocol: the send path
/// reads it from whichever thread the core calls `onMessagesAvailable` on,
Expand All @@ -168,13 +254,29 @@ final class PeerStreamLinks<Handle: Hashable> {
let firstForAddress: Bool
/// The older stream for the same address, to close without a loss report.
let superseded: Handle?
/// True when an older stream holds the address and wins: close this
/// one without a report, and leave the older untouched.
var refused = false
}

/// Whether a new stream for `peer` takes the address over from the one
/// that holds it. `outbound` is whether this device opened the new
/// stream. Addresses compare by their UTF-8 bytes, which is the code point
/// order Python's `<` uses. With no address of our own there is nothing to
/// order by, and the announced stream stays.
static func newStreamWins(outbound: Bool, localAddress: String?, peer: String) -> Bool {
guard let local = localAddress else { return false }
let weOpen = local.utf8.lexicographicallyPrecedes(peer.utf8)
return outbound == weOpen
}

private let lock = NSLock()
private var byAddress: [String: Handle] = [:]
private var byHandle: [Handle: String] = [:]

func announce(_ handle: Handle, address: String) -> Announcement {
func announce(
_ handle: Handle, address: String, outbound: Bool, localAddress: String?
) -> Announcement {
lock.lock(); defer { lock.unlock() }
if byHandle[handle] != nil {
// A stream proves one address, once. A second announcement, for
Expand All @@ -184,6 +286,10 @@ final class PeerStreamLinks<Handle: Hashable> {
// trap here would take the app down for a bookkeeping mistake.
return Announcement(firstForAddress: false, superseded: nil)
}
if byAddress[address] != nil,
!Self.newStreamWins(outbound: outbound, localAddress: localAddress, peer: address) {
return Announcement(firstForAddress: false, superseded: nil, refused: true)
}
let older = byAddress.updateValue(handle, forKey: address)
byHandle[handle] = address
if let older = older { byHandle.removeValue(forKey: older) }
Expand Down
Loading
Loading