p2p-swift / README.md
1,719 words

ird-p2p-swift

Native Swift SDK for building peer-to-peer apps on the ird p2p service: iOS 16+ and macOS 13+, one library product, IrdP2P.

An ird p2p app gives you a hostname (<name>.ap2p.ro or your own domain) that serves a blind signaling relay, STUN/TURN, and an offline mailbox. This SDK turns that into rooms: peers who hold a room's secret find each other through the relay, prove membership by decrypting each other's envelopes, open a WebRTC data channel when they can and keep talking through the relay when they cannot, and leave messages in the mailbox for peers who come later. Every payload is end-to-end encrypted before it reaches the network; the node never holds a key. Mobile realities are built in: the same peer id is reused across reconnects so the relay drops the zombie socket, and rooms wake up when the app returns to the foreground.

Install

There is no registry; the package is a git dependency served straight from this domain:

// Package.swift
dependencies: [
    .package(url: "https://ird.cod-sursa.ro/p2p-swift.git", from: "0.2.0"),
],
targets: [
    .target(name: "MyApp", dependencies: [.product(name: "IrdP2P", package: "p2p-swift")]),
]

In Xcode: File ▸ Add Package Dependencies…, paste https://ird.cod-sursa.ro/p2p-swift.git, choose "Up to Next Major Version" from 0.2.0, and add the IrdP2P product to your app target.

Or pinned to an exact commit:

git ls-remote https://ird.cod-sursa.ro/p2p-swift.git
.package(url: "https://ird.cod-sursa.ro/p2p-swift.git", revision: "…")

WebRTC comes transitively from the binary Swift package at https://github.com/stasel/WebRTC (module WebRTC, pinned to an exact milestone); nothing else is downloaded.

The landing page at https://ird.cod-sursa.ro/p2p-swift/ shows the current release coordinates ready to paste, and https://ird.cod-sursa.ro/releases.json carries them for scripts and agents (head, tag, tag_sha per repo).

Quickstart

import IrdP2P

// Your app hostname, a salt that namespaces your rooms, and an Origin
// the app's allowlist admits (see "Origins" below).
let app = P2PApp(host: "myapp.ap2p.ro", appSalt: "myapp/v1", origin: "https://myapp.example")

// Create a room and share its secret in an invite link…
let secret = RoomSecret.random()
let invite = InviteLink.url(base: URL(string: "myapp://join")!, secret: secret)

// …or join from one.
let room = try await app.join(secret: InviteLink.secret(from: invite)!)

Task {
    for await event in room.events {
        switch event {
        case .peerJoined(let peer):            print("member:", peer)
        case .peerReady(let peer, let via):    print(peer, "reachable", via)   // .relayed, then .direct
        case .message(let from, .json(let v)): print(from, "says", v["text"]?.stringValue ?? "")
        case .peerLeft(let peer):              print("left:", peer)
        default: break
        }
    }
}

struct Line: Codable { var text: String }
try await room.broadcast(json: Line(text: "salut"))
try await room.send(to: somePeer, .data(imageBytes))

await room.leave()

room.events hands every subscriber its own stream; drain it. Message is either .json(JSONValue) (decode with message.decode(Line.self)) or .data(Data). Messages of any size work: large ones are chunked, and anything above the relay's frame cap flows only once a data channel is open or, on the relay, at the node's paced rate.

A chunked message that never completes is reported as .assemblyDropped(peer:id:reason:) with an AssemblyDropReason (.ageExpired, .tooManyConcurrent, .tooManyBytes, .mismatched, .duplicateIndex) — a diagnostic, nothing to recover: the sender retries by sending the message again. Chunk ids are derived from the payload, so the retry fills the slices the first attempt lost instead of starting over. Age expiry is noticed when the peer's next chunk arrives.

Room options

app.join(secret:options:) takes a RoomOptions; the defaults suit most apps:

var options = RoomOptions()
options.maxChunkAge = .seconds(180)   // slow links: keep partial messages longer (default 60 s)
let room = try await app.join(secret: secret, options: options)
option default meaning
helloTimeout 10 s how long an unverified peer may stay silent before it is ignored
keepaliveInterval 20 s ping cadence on the relay socket
maxInflightChunkBytes 8 MiB bytes held in partially reassembled messages, per peer
maxChunkAge 60 s how long a partially reassembled message waits for its remaining chunks
preferDataChannel true open WebRTC data channels and prefer them over the relay
maxDirectPeers nil (everyone) cap on the WebRTC mesh: data channels only to the nearest peers, the rest stay relayed
continualGathering true keep gathering ICE candidates so a connection survives network changes; false gathers once (fewer TURN allocations, less radio)
pacing true pace relay frames against the node's budgets (disable only in tests)

A full mesh costs every member one peer connection per other member. maxDirectPeers = N bounds that: each peer's budget is the N verified members nearest to it by XOR distance on the peer id, and two peers connect only when each is within the other's budget — both ends derive the same answer from the roster, with no negotiation. Peers outside the budget are reached through the relay (.peerReady(peer, .relayed)), as usual. The budget gates new negotiations only: an open data channel is never torn down because a newcomer pushed its peer out of the budget, so a peer can briefly hold more than N channels; a leave that frees the budget is used at once. Give every member of a room the same value.

Identity and invite links

Rooms work anonymously. To let peers recognise each other across sessions, give them an Identity — a 32-byte Ed25519 seed — stored in the Keychain:

var identity = try KeychainIdentityStore().loadOrCreate()   // generated on first launch
identity.name = "Ana"
let room = try await app.join(secret: secret, identity: identity)
// peers receive .peerIdentity(peer, PeerIdentity) with a verified fingerprint:
print(identity.fingerprint.emoji)   // four emoji, e.g. 🐸📎🖍️📎

The identity signs a binding of the current session to the room; it never affects membership (only the room secret does). Invite links carry the secret in the URL fragment, which URL loaders never send to a server; InviteLink.secret(from:) parses it back.

Offline mailbox

When the app has its mailbox enabled, peers can leave sealed messages for members who are not online at the same time:

let mailbox = try await app.mailbox(secret: secret)          // throws .config if the app has no mailbox
_ = try await mailbox.deposit(.json(line))                    // sealed under the room's mailbox key
let history = try await mailbox.replayAll(after: savedCursor) // (items, cursor, gap)
if history.gap { /* messages were evicted between visits: show "history truncated" */ }

mailbox.enqueue(_:) + mailbox.outbox.flush() queue deposits with retry and backoff (413 drops the item). Mailbox sequence numbers are opaque Strings (MailboxSeq) that compare as ordered tokens — never parse them into a Double. Tokens come from the node's open mint by default, which requires an allowed Origin; a P2PApp mints one app-scoped token and shares it across all its mailboxes, however many rooms you open. If you turned the open mint off, inject a MailboxTokenProvider that fetches tokens from your own backend (POST /p2p/apps/{id}/mailbox-credentials on the ird API):

let tokens = StaticTokenProvider { room in try await myBackend.mintMailboxToken() }
let mailbox = try await app.mailbox(secret: secret, tokens: tokens)

Errors

Everything throws P2PError:

case when
.upgradeRejected(status:) the relay refused the WebSocket: 403 Origin not allowed (or missing), 404 unknown host, 429 too many connections
.relay(.roomFull), .relay(.tooManyRooms) the join was refused by the app's limits
.relay(.rateLimited) and the rest of RelayErrorCode reported as RoomEvent.relayError; the room stays joined
.http(status:code:body:) a mailbox or TURN request failed; error.mailboxCode maps the node's {"error": …} codes (.messageTooLarge, .tokenExpired, .nodeStorageFull, …)
.peerUnknown(_) sending to a peer that is not a verified member
.frameTooLarge(bytes:limit:) a single relay frame would exceed max_frame_bytes (the SDK chunks messages, so this means a misconfiguration)
.config(_) a precondition before any request: mailbox disabled, blank room, closed mint without a token provider
.transport(_), .timeout, .closed, .notJoined, .crypto(_) as named

Remote tampering is never an error: envelopes that do not decrypt are dropped silently, by design.

Origins

The app's origin allowlist gates the WebSocket upgrade, /turn-credentials and the mailbox open mint. It is a browser control, and a native app sends no Origin header on its own — so with a non-empty allowlist a native client is refused with 403 until you set origin: to a value the allowlist admits (any string of your choosing, e.g. https://ios.myapp.example, added with ird p2p origins). With an empty allowlist any client may connect and no origin: is needed. Mailbox reads and writes with a valid token need no Origin at all; only minting does.

Conventions

Example app

Examples/RoomChat is a SwiftUI room chat for iOS and macOS built on the package (invite links, peers with fingerprints and a direct/relayed badge, offline history). Open Examples/RoomChat/RoomChat.xcodeproj and run it on two simulators or a simulator and your Mac.

Tests

swift test                       # offline: crypto vectors, signaling with fake transports, rooms over an in-process relay

Two more suites are gated on the environment:

Build

swift build
swift test
scripts/release-check.sh         # manifest, build, offline tests, clean tree (IOS=1 adds an iOS Simulator build)

SwiftPM versions come from git tags (vX.Y.Z); there is no version field to bump.

License

MIT.

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/p2p-swift.git