The swift-pjsua bring-up / debug softphone — and, later, the better-than-official PJSIP sample and Swiss-knife softphone. This first cut is the Phase 0 smoke test: prove the stack runs end-to-end (start → register → echo call → hear audio) before building breadth.
The authoritative architecture, plan, and debt register live under docs/:
Design (decisions + rejected alternatives), Roadmap
(Now/Next/Later), Tech-Debt (OH-# register), and
SIP-Test-Infrastructure (where to point the app). The
Architecture and Next-milestones notes below are a summary; the docs are authoritative.
- Xcode 16+, an iOS 17+ Simulator or device.
- XcodeGen (
brew install xcodegen) — the project is generated fromproject.yml, so there's no.xcodeprojin git. - A SIP account to register with (the smoke needs a real registrar + a live echo target).
cd offhook
xcodegen generate # writes Offhook.xcodeproj from project.yml
open Offhook.xcodeprojBuild & run on an iOS 17 Simulator (Audio works in the Simulator). For a device, copy
Config/Local.xcconfig.example to Config/Local.xcconfig (git-ignored) and put your team ID in
it; the push entitlement needs a real team. project.yml pins swift-pjsip to the local checkout (../swift-pjsip),
so no network fetch.
No XcodeGen? Create an iOS App target manually (iOS 17, bundle id
io.github.laconicman.offhook), add theSources/files, setINFOPLIST_FILE = Sources/Info.plist, and add the local../swift-pjsuapackage → product SwiftPJSUA.
- Edit the three account fields (or change the defaults in
PhoneModel): Registrar host, Username, Password, and a Dial target that echoes audio back. - The engine starts when the app comes to the foreground: the Engine row should read
runningand the log showengine started. Saved accounts register on their own. - Save & register → watch the Registration row flip to
registered (200). - Dial → the call goes through CallKit and appears in the Calls list (label = the
dialed target): state
dialing… → connectedfromCXCallObserver; audio starts once CallKit activates the session (didActivate→ engine sound device) — you should hear the far end. Multiple calls can be up at once — each row gets its own Hang up and Hold / Resume. - Hang up a row (or let the far end end it) → the row clears; audio is released by CallKit when the last call ends.
A green run validates the whole engine: the SerialExecutor/one-thread model, the callback
bridge, registration parsing, and the media-state → pjsua_conf_connect audio wiring.
See docs/SIP-Test-Infrastructure.md — verified providers,
echo endpoints, STUN, push, and a capability→where-to-test map. Fastest: register two free
Linphone accounts and call one from the other (the loopback
pattern — Linphone's echo 4443 answers 404 as of 2026-07-04). Register and dial with
;transport=tcp (the app default): Flexisip's authenticated INVITEs fragment on UDP (doc §6).
Flexisip negotiates iLBC with our binary; G.711 elsewhere (Opus absent —
../swift-pjsip/docs/Codec-Coverage.md).
A live-infrastructure XCTest suite drives the engine against real SIP servers — it is the
Phase 0 smoke, automated: headless engine start (null sound device — no mic permission),
negative auth (4xx/6xx), all-accounts registration (200 + expiration), an audio loopback call
with RTP/RTCP statistics + codec assertions, simultaneous calls (up to the binary's
PJSUA_MAX_CALLS = 8), and a video loopback (H264 negotiates on the Simulator; RTP flow needs
a camera, so that step skips off-device).
xcodebuild test -scheme Offhook -destination 'platform=iOS Simulator,name=iPhone 16'Credentials (never in this repo): OFFHOOK_TEST_ACC<n>_AOR / _PASSWORD
(+ optional _REGISTRAR override) read from the process environment first (CI:
TEST_RUNNER_-prefixed vars via xcodebuild), then from ../secrets/test-accounts.env —
outside the repo tree. ACC1+ACC2 must be a same-registrar pair (the loopback); slots 3–8 are
optional extras — tests that need them XCTSkip when unset. The suite is one ordered test
class: pjsua is process-global, so one engine instance serves all tests.
Live-infra weather: these tests hit real public servers. Back-to-back runs can trip provider rate limits (observed: iptel going silent → 408 after many runs in one hour); the suite tolerates no-answer weather on non-loopback accounts with a warning and skips — it only hard-fails on stack behavior. Space runs a couple of minutes apart; if a provider goes quiet, it recovers on its own.
PhoneModel—@MainActor @Observableview-model. Owns the engine +CallKitController(which owns theCXProviderand startsCallSessionRouter, the soleengine.eventsconsumer). Requests go out as CallKit transactions (CXCallController); state comes back fromCXCallObserverand the router's registration relay — the model never readsengine.eventsand never touchesAVAudioSession.RootView— renders model state, writes only through@Bindabletext bindings.
- CallKit is wired (2026-07-04) —
SwiftPJSUAKit'sCallKitController+CallSessionRouterare the soleengine.eventsconsumer; the app requests actions viaCXCallControllerand observes viaCXCallObserver(outgoing round trip verified live). VoIP push is wired (2026-10-05): aPKPushRegistryfrom launch, RFC 8599 parameters on REGISTER (apns.devfor development-signed builds, read from the embedded provisioning profile) with a 3600 s expiry, Flexisip's payload, wake-up re-REGISTER; end-to-end on a device still needs a server holding our certificate (docs §3). Not here yet: video rendering, and a live verification of the incoming-call UI (needs a second instance — Tech-Debt OH-8). - Scripted smoke: launch with
OFFHOOK_AUTOSMOKE=1(+OFFHOOK_USERNAME/OFFHOOK_PASSWORD/OFFHOOK_REGISTRAR/OFFHOOK_DIAL) to prefill and run start → register → dial with no taps — e.g.SIMCTL_CHILD_OFFHOOK_AUTOSMOKE=1 … xcrun simctl launch <sim> io.github.laconicman.offhook. - The app and the test suite can't run on one simulator at the same time — both bind SIP port 5060 (the second engine start fails with "Address already in use"). Terminate one first.
CallKit outgoing/incoming via SwiftPJSUAKit → PushKit (hybrid, no double ring) → the debug/SIP
tooling UI (raw event stream, conf-slot inspector, SIP log) → hold/parallel/conference/video
demos → Swiss-knife settings (STUN/DNS/codecs). See ../PROJECT-MEMORY.md §5.