laconicman/swift-pjsua
An idiomatic, Swift-only async wrapper over PJSIP's high-level C API (pjsua1),
Why pjsua1 (C), not PJSUA2 (C++)
PJSUA2 delivers events by having you subclass its C++ classes and override virtual methods. Swift/C++ interop cannot subclass C++ classes or override their virtuals, so "Swift only" + PJSUA2 would still require a hand-written C++ shim. pjsua1's events are plain C function pointers (pjsua_callback), which bridge to Swift cleanly. That makes pjsua1 the honest cornerstone for a Swift-only wrapper — this choice is load-bearing; don't reverse it casually.
Two products, one dependency edge
PJSIP (binary) ← SwiftPJSUA (pure engine) ← SwiftPJSUAKit (CallKit/PushKit/UI) ← your appSwiftPJSUA— the pure pjsua1 engine. Imports onlyPJSIP+Foundation. No
CallKit, PushKit, AVAudioSession, UIKit, or SwiftUI. Exposes call/account primitives plus an explicit audio-device API that the GUI layer drives.
SwiftPJSUAKit— CallKit + PushKit + AVAudioSession orchestration that depends on the
engine. On iOS, PJSIP is built with SETUP_AV_AUDIO_SESSION=0, so the app/CallKit owns the audio session, not PJSIP — this layer is where that ownership lives.
The boundary is compiler-enforced (the engine can't reach into Kit). Promote SwiftPJSUAKit to its own repository only if its release cadence diverges from the engine's.
Requirements
- iOS 17+. Custom actor executors (SE-0392)
need the Swift 5.9 concurrency runtime, which isn't back-deployed below iOS 17.
- Swift tools 5.9+.
swift-pjsipcurrently ships an iOS-only xcframework, so this package does not build
for macOS yet. (A macOS slice is planned in swift-pjsip in a separate effort.)
Usage sketch
import SwiftPJSUA
let phone = PJSUA()
try await phone.start(.init(port: 5060, transport: .udp))
let account = try await phone.addAccount(
id: "sip:alice@example.com",
registrar: "sip:example.com",
username: "alice",
password: "••••",
// RFC 8599 push (optional). You may also pass a fully custom params string.
push: .apns(teamID: "ABCDE12345", bundleID: "com.example.offhook", token: deviceToken)
)
// Observe the engine's event stream.
Task {
for await event in phone.events {
switch event {
case let .incomingCall(account, call, sipCallID, from, offeredVideo):
// In an app, hand sipCallID to SwiftPJSUAKit to dedup against a VoIP push;
// `from` seeds the CallKit handle and `offeredVideo` its hasVideo flag.
try? await phone.answer(call)
case let .callState(call, state, _, lastStatus):
print("call \(call) → \(state) (\(lastStatus))")
default:
break
}
}
}
let call = try await phone.makeCall(to: "sip:bob@example.com", from: account)Audio + CallKit
The engine never opens the mic on its own. CallKit tells it when the audio session is live:
func provider(_ provider: CXProvider, didActivate session: AVAudioSession) {
Task { try? await phone.activateAudioDevice() } // pjsua_set_snd_dev(default, default)
}
func provider(_ provider: CXProvider, didDeactivate session: AVAudioSession) {
Task { await phone.deactivateAudioDevice() } // pjsua_set_no_snd_dev()
}SwiftPJSUAKit wires this for you (see CallKitController).
Dual-mode (VoIP push + persisted connection), no double ring
One CallKit UUID per logical call, computed identically on both paths: prefer a server-supplied UUID in the push payload; otherwise derive a deterministic UUIDv5 from the SIP Call-ID (the INVITE over a persisted connection carries the same Call-ID, so both paths agree). See CallIdentity / CallRegistry in SwiftPJSUAKit.
Status — what's real vs. skeleton
This package is being built iteratively against a roadmap (M1 → M4).
Real now (M1 core):
- Engine lifecycle (
start/shutdown), accounts (addAccount/setRegistration/
reRegister(_:updatingPush:)), calls (makeCall/answer/hangup), audio-device API.
- pjsua1 callbacks bridged into a single
AsyncStream<PJSUAEvent>; media-state wiring
(pjsua_conf_connect) so audio actually flows; SIP Call-ID + disconnect/SIP status code surfaced on events; real on_reg_state2 parsing.
thread_cntpinned ≥ 1 (see invariants below); RFC 8599 push params (caller-controlled).- Deterministic UUIDv5 dedup logic (
CallIdentity, unit-tested with the RFC 4122 vector).
Skeleton (filled in next iterations), marked Skeleton in source:
SwiftPJSUAKitCXAction handling (answer/end/hold/mute/DTMF), provider configuration, and
the VoIP/silent-push payload schema in VoIPPushHandler.
- Call features: hold/unhold, transfer, DTMF, multiple calls, video, conferences.
- Network-change / lifecycle handling (
pjsua_handle_ip_changeonNWPathMonitor).
Load-bearing invariants
thread_cnt≥ 1. TheSerialExecutordesign works because pjsua's own worker
thread(s) pump events (pjsua_handle_events); the executor thread does not. With thread_cnt == 0, callbacks silently never fire. The engine owns this config and hard-sets it (with a precondition); it is deliberately not a public knob.
- Don't enter PJSIP from GCD/Swift-concurrency threads. All blocking pjsua calls go
through the actor → executor → the one registered PJSIP thread. The C callbacks are file-private free functions that hold no reference to the actor, so they can't re-enter it.
- Threads that call PJSIP must be registered (
pj_thread_register) and their
pj_thread_desc must outlive the thread (see Executor/PJThreadRegistration.swift).
Licensing
PJSIP is dual-licensed (GPLv2+ / commercial). swift-pjsip is built with G.729 (bcg729, GPLv3 + patent considerations) because Opus cannot transcode G.729 on the wire. A production / App Store build therefore needs the appropriate PJSIP commercial license and G.729 patent terms. This wrapper changes none of that — it only re-exposes the prebuilt binary's capabilities.
Package Metadata
Repository: laconicman/swift-pjsua
Default branch: main
README: README.md