Skip to content

Architecture

HSMM edited this page Oct 13, 2026 · 1 revision

Architecture

MacroSIP is one SwiftPM package (Package.swift, Swift 6, macOS 14+) with no Xcode project. Swift code never includes a PJSIP header: everything reaches PJSIP through the CPJSUA C target, and the UI never imports CPJSUA at all. The sources of truth for this page are docs/ENGINE.md, docs/DIRECTORY.md, docs/DEVELOPMENT.md and the ADRs in docs/adr/.

+----------------------------------------------------------------------+
|  MacroSIPApp  (SwiftUI + AppKit)                                     |
|  AppModel and its extensions · Views · Platform · Mock engine, demos |
+----------------------------------------------------------------------+
|  MacroSIPCore  (Swift, no UI imports)                                |
|  Engine actor · Models · Storage · Recording · Directory service     |
+-----------------------------------+----------------------------------+
|  CPJSUA  (C shim, umbrella header) |  CLDAP  (C shim over libldap)   |
+-----------------------------------+----------------------------------+
|  PJSIP 2.17 + OpenSSL 3.5 + Opus   |  libldap from the macOS SDK     |
|  static, universal archives        |                                 |
+-----------------------------------+----------------------------------+
   macrosip-cli -> MacroSIPCore      pjsip-smoke -> CPJSUA
   Tests/MacroSIPCoreTests -> MacroSIPCore

Targets

Target Path What it holds
CPJSUA Sources/CPJSUA Umbrella header CPJSUA.h (defines PJ_AUTOCONF=1, includes pjsua.h) and shim.c with helpers Swift cannot express (thread registration, recorder destroy-and-wait, tone and gain ports, DTMF, IP-change and nameserver helpers). Links the PJSIP, OpenSSL and Opus archives.
CLDAP Sources/CLDAP Opaque-handle wrapper over the system OpenLDAP client (libldap), so Swift never imports <ldap.h>.
MacroSIPCore Sources/MacroSIPCore The engine (Engine/), models (Model/), JSON stores and Keychain (Storage/), recording conversion (Recording/), the directory layer (Directory/), Product.swift.
MacroSIPApp Sources/MacroSIPApp The application: Model/ (AppModel), Views/, Platform/, Engine/ (factory, mock engine, mock directory) and Resources/ with en.lproj and ru.lproj. Depends on Sparkle 2.7.3 (ADR 0013).
macrosip-cli Sources/macrosip-cli Command-line phone over MacroSIPCore: an interactive mode for debugging against a PBX, and --selftest, which scripts/selftest-call.sh drives. Contains SIPStub, a small UDP registrar for the self-test.
pjsip-smoke Sources/pjsip-smoke Proves Swift links the static build: creates pjsua, prints version, codecs and audio devices, exits 0.
MacroSIPCoreTests Tests/MacroSIPCoreTests Swift Testing suites for Core (see Testing).

Engine (MacroSIPCore)

One actor. Engine is a Swift actor on a serial DispatchQueue("macrosip.engine"); every pjsua_* call runs inside it. PJSIP demands registered calling threads, and GCD may run the queue on different pool threads, so every entry point calls cpjsua_thread_register() (a thread-local pj_thread_desc, no-op once registered). pjsua_config.thread_cnt = 1; PJSIP's worker thread polls events.

Callbacks and the event stream. pjsua callbacks are C function pointers without context, so they are global Swift functions that reach the engine through EngineBridge.shared (a lock-protected class). A callback copies the minimal data into an EngineEvent, yields it to Engine.events (an AsyncStream, single consumer) and returns at once; work the engine must do is scheduled as a Task on the actor and is idempotent. The app iterates the stream on the main actor.

Call snapshots. CallInfo is an immutable value: id, account, direction, state, remote party, start time, mute/hold/recording/conference flags, codec, isSecure, isReconnecting. CallID is (slot, serial): pjsua reuses slots, serials never repeat, a stale id throws .noSuchCall. The UI upserts rows on .incomingCall and .callChanged and removes them on .callEnded. Media wiring is one desired-state reconcile (reconcileMedia()) over all live calls and recordings.

Accounts. AccountMapping turns AccountConfig into pjsua_acc_config (transport, SRTP, registrar, proxy, auth id, registration interval, publishing, RTP port window). Passwords are not in AccountConfig; they come from the Keychain. A disabled account is not added to pjsua at all; registerWithServer off means outgoing only. TLS has two listeners: the default one verifies the server against the macOS system roots exported to ca-roots.pem (TLSRoots.swift); the other serves accounts with verification switched off.

Reconnect and backoff (Engine+Network.swift). An NWPathMonitor reduces each path to its primary route; a route change, a return online or a wake runs one "round": pjsua_handle_ip_change re-registers the accounts and confirmed calls get a re-INVITE, so they survive an IP change. Temporary REGISTER failures become .reconnecting(attempt:nextRetryIn:lastError:) with delays of 2, 4, 8, 16, 32, then 60 s; permanent ones (401, 403, TLS certificate errors) become .failed. A media watchdog probes a silent call with in-dialog OPTIONS and ends it as "connection lost" on 408 or 481.

Quality model (Engine+Quality.swift, CallQualityModel.swift). quality(of:) reads the stream's RTCP statistics over the window since the previous sample (the app polls every 2 s) and computes a simplified ITU-T G.107 MOS from codec, loss, jitter and RTT: good from 3.8, fair from 3.0, else poor.

Presence. subscribePresence(uri:) adds a pjsua buddy keyed by a token; NOTIFY bodies map to PresenceStatus (available, away, dnd, ringing, offline), including Asterisk's "on the phone" and "Ringing" notes. PresenceRegistry is a reference-counted table of watchers: contacts and quick keys share one engine subscription per target; the last owner unsubscribes it.

Auto-answer is a pure function, AutoAnswerPolicy.decide(...): Do Not Disturb wins, never while another call exists, Call-Info: answer-after=N is honoured only from the account's own server, then mode all / contactsOnly / off with the configured delay.

Dial strings. DialString.parse(raw, account:) yields a full URI: sip:/sips: as is, tel: digits to sip:<digits>@<domain>, plain numbers stripped of spaces, parentheses and dashes, anything with @ prefixed with sip:; a transport parameter is added for TCP/TLS accounts.

Call options and deny rules. One engine-wide CallOptions (set at start, changed live with setCallOptions): incomingWhileOnCall (call waiting tone, window only, or 486 busy), dtmfMethod (automatic, RFC 2833, SIP INFO, in-band), doNotDisturb (486 at once), and denyIncoming (everyone sends 603, notInContacts checks knownNumbers, anonymous answers 433). The rules are evaluated inside on_incoming_call on the PJSIP thread, before the automatic 180, so nothing rings and the decision does not depend on the app being responsive. The app only sees .callEnded and writes the call-log note.

Application (MacroSIPApp)

AppModel (@MainActor @Observable, Model/AppModel.swift) is the single source of truth for the UI: it owns the engine (any SIPEngine), the stores, the Keychain vault and the observable state. Its extensions split the behaviour by area: +Engine (bootstrap, event handling, codec and network application), +Accounts, +Calls, +LiveCalls, +AutoAnswer, +DND, +Presence, +Quality, +QuickKeys, +Directory, +Presentation. RecordingCoordinator converts finished WAVs to M4A. Engine calls are Task { try await engine.x } with errors shown as a transient banner.

SIPEngine (Engine/SIPEngine.swift) is the protocol both the real Engine and MockEngine satisfy; EngineFactory.make(useMock:) picks one. LaunchOptions.swift parses --mock-engine, --demo-state, --data-dir, --snapshot, --snapshot-after, --appearance and --log-level; DemoState.swift seeds sample data and drives the mock into named states (see Testing).

Views/ follows docs/design/SCREENS.md, one folder per screen (Main/, Dialpad/, Call/, Contacts/, CallLog/, Incoming/, QuickKeys/, Directory/, Settings/ with one file per pane, FirstLaunch/) plus shared Components/ (Theme, form components, the @Local wrapper). Platform/ holds the AppKit and system pieces: AppDelegate, StatusItemController (menu bar), IncomingCallPanelController (floating window), CallNotifications, AudioTransport, RecordingPlayer, QuickKeysMenu, Updater (Sparkle), AppLog and AppInfo.

Storage

Everything except passwords is JSON in ~/Library/Application Support/MacroSIP/ (ADR 0004): accounts.json, contacts.json, calllog.json, settings.json, quickkeys.json. JSONStore<T> writes atomically; a corrupt file is renamed .broken-<timestamp>.json and the default is used. Contact photos are JPEG files in photos/<id>.jpg. Passwords live in the Keychain under service com.hsmm.macrosip, account = the account UUID (Storage/Keychain.swift); directory secrets go through DirectoryCredentials (LDAP bind password; Google token under com.hsmm.macrosip.google). Recordings default to ~/Music/MacroSIP Recordings, the log file to ~/Library/Logs/MacroSIP/MacroSIP.log.

Localization

ADR 0011: classic Localizable.strings and Localizable.stringsdict under Sources/MacroSIPApp/Resources/{en,ru}.lproj, because .xcstrings catalogs need Xcode's xcstringstool. All lookups go through L in Model/Strings.swift; make-app.sh copies the .lproj folders into Contents/Resources, and scripts/check-strings.sh keeps code and both languages in sync. The microphone and Contacts prompts are in packaging/InfoPlist/<lang>.lproj/InfoPlist.strings.

Directory layer

DirectoryService (an actor in MacroSIPCore/Directory/) holds one DirectoryProvider per source: MacContactsProvider (Contacts framework), LDAPDirectoryProvider (libldap through CLDAP), URLDirectoryProvider (CSV or JSON feed) and a GoogleDirectoryProvider stub that always reports "Sign in required". Search asks every enabled source at once and returns after all answered or after 3 s; caller lookup asks search-only sources for at most 1 s; an LDAP request has a 3 s deadline (30 s for import). Statuses flow through an AsyncStream<DirectoryEvent>. In the app, AppModel+Directory.swift runs search, lookup, import and the schedule, Engine/LiveDirectory.swift wraps the service, MockDirectory.swift serves demo launches, and Model/DirectoryMerge.swift merges imports into Contacts by number: a card the source created follows the directory, a user's card only receives what it lacks.

Decision records

0001 platform and UI stack · 0002 PJSIP build · 0003 product scope · 0004 storage · 0005 accounts and calls · 0006 quality and testing · 0007 license and distribution · 0008 development workflow · 0009 UI decisions · 0010 versioning · 0011 localization files · 0012 universal build · 0013 in-app updates

See also Building, Testing, Releasing, Contributing.

Clone this wiki locally