Устройство
MacroSIP — один пакет SwiftPM (Package.swift, Swift 6, macOS 14+) без проекта Xcode. Код на Swift никогда не подключает заголовки PJSIP: всё идёт через C-таргет CPJSUA, а UI вообще не импортирует CPJSUA. Первоисточники для этой страницы — docs/ENGINE.md, docs/DIRECTORY.md, docs/DEVELOPMENT.md и ADR в 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
Таргеты
| Таргет | Путь | Содержимое |
|---|---|---|
CPJSUA |
Sources/CPJSUA |
Зонтичный заголовок CPJSUA.h (определяет PJ_AUTOCONF=1, подключает pjsua.h) и shim.c с помощниками, которые нельзя выразить на Swift (регистрация потоков, уничтожение рекордера с ожиданием, порты тонов и усиления, DTMF, помощники для смены IP и DNS-серверов). Линкует архивы PJSIP, OpenSSL и Opus. |
CLDAP |
Sources/CLDAP |
Обёртка с непрозрачным дескриптором над системным клиентом OpenLDAP (libldap), чтобы Swift никогда не импортировал <ldap.h>. |
MacroSIPCore |
Sources/MacroSIPCore |
Движок (Engine/), модели (Model/), JSON-хранилища и связка ключей (Storage/), преобразование записей (Recording/), слой справочника (Directory/), Product.swift. |
MacroSIPApp |
Sources/MacroSIPApp |
Приложение: Model/ (AppModel), Views/, Platform/, Engine/ (фабрика, имитация движка, имитация справочника) и Resources/ с en.lproj и ru.lproj. Зависит от Sparkle 2.7.3 (ADR 0013). |
macrosip-cli |
Sources/macrosip-cli |
Телефон командной строки поверх MacroSIPCore: интерактивный режим для отладки с АТС и --selftest, которым управляет scripts/selftest-call.sh. Содержит SIPStub — небольшой UDP-регистратор для самопроверки. |
pjsip-smoke |
Sources/pjsip-smoke |
Доказывает, что Swift линкуется со статической сборкой: создаёт pjsua, печатает версию, кодеки и аудиоустройства, завершается с кодом 0. |
MacroSIPCoreTests |
Tests/MacroSIPCoreTests |
Наборы Swift Testing для Core (см. Тесты). |
Движок (MacroSIPCore)
Один актор. Engine — Swift-actor на последовательной очереди DispatchQueue("macrosip.engine"); каждый вызов pjsua_* выполняется внутри него. PJSIP требует регистрации вызывающих потоков, а GCD может выполнять очередь на разных потоках пула, поэтому каждая точка входа вызывает cpjsua_thread_register() (thread-local pj_thread_desc, повторный вызов ничего не делает). pjsua_config.thread_cnt = 1; рабочий поток PJSIP опрашивает события.
Обратные вызовы и поток событий. Обратные вызовы pjsua — это указатели на C-функции без контекста, поэтому это глобальные функции Swift, которые достигают движка через EngineBridge.shared (класс под замком). Обратный вызов копирует минимум данных в EngineEvent, отдаёт его в Engine.events (AsyncStream с одним потребителем) и сразу возвращается; работа, которую должен сделать движок, планируется как Task на акторе и идемпотентна. Приложение перебирает поток на главном акторе.
Снимки звонков. CallInfo — неизменяемое значение: id, аккаунт, направление, состояние, собеседник, время начала, флаги mute/hold/recording/conference, кодек, isSecure, isReconnecting. CallID — это (slot, serial): pjsua переиспользует слоты, серийные номера никогда не повторяются, устаревший id бросает .noSuchCall. UI обновляет или добавляет строки по .incomingCall и .callChanged и удаляет по .callEnded. Коммутация медиа — одна сверка желаемого состояния (reconcileMedia()) по всем живым звонкам и записям.
Аккаунты. AccountMapping превращает AccountConfig в pjsua_acc_config (транспорт, SRTP, регистратор, прокси, имя для входа, интервал регистрации, публикация статуса, окно портов RTP). Паролей в AccountConfig нет; они берутся из связки ключей. Выключенный аккаунт в pjsua не добавляется вовсе; выключенный registerWithServer означает «только исходящие». Для TLS два слушателя: основной проверяет сервер по системным корневым сертификатам macOS, экспортированным в ca-roots.pem (TLSRoots.swift); второй обслуживает аккаунты с выключенной проверкой.
Переподключение и повтор с нарастающей паузой (Engine+Network.swift). NWPathMonitor сводит каждый путь к его основному маршруту; смена маршрута, возвращение в сеть или пробуждение запускают один «раунд»: pjsua_handle_ip_change регистрирует аккаунты заново, а установленные звонки получают re-INVITE и переживают смену IP. Временные сбои REGISTER становятся .reconnecting(attempt:nextRetryIn:lastError:) с задержками 2, 4, 8, 16, 32, затем 60 с; окончательные (401, 403, ошибки сертификата TLS) — .failed. Сторож медиа проверяет молчащий звонок внутридиалоговым OPTIONS и завершает его как «связь потеряна» при 408 или 481.
Модель качества (Engine+Quality.swift, CallQualityModel.swift). quality(of:) читает статистику RTCP потока за окно с предыдущей выборки (приложение опрашивает каждые 2 с) и вычисляет упрощённый MOS по ITU-T G.107 из кодека, потерь, джиттера и RTT: хорошо от 3,8, средне от 3,0, иначе плохо.
Статусы. subscribePresence(uri:) добавляет buddy pjsua с ключом-токеном; тела NOTIFY отображаются в PresenceStatus (available, away, dnd, ringing, offline), включая пометки Asterisk «on the phone» и «Ringing». PresenceRegistry — таблица наблюдателей со счётчиком ссылок: контакты и быстрые клавиши делят одну подписку движка на цель; последний владелец отписывает её.
Автоответ — чистая функция AutoAnswerPolicy.decide(...): «Не беспокоить» главнее, никогда при наличии другого звонка, Call-Info: answer-after=N учитывается только от собственного сервера аккаунта, затем режим all / contactsOnly / off с настроенной задержкой.
Набранные строки. DialString.parse(raw, account:) выдаёт полный URI: sip:/sips: как есть, цифры tel: в sip:<digits>@<domain>, простые номера без пробелов, скобок и дефисов, всё с @ получает префикс sip:; для аккаунтов TCP/TLS добавляется параметр транспорта.
Параметры звонков и правила отклонения. Один CallOptions на весь движок (задаётся при start, меняется на лету через setCallOptions): incomingWhileOnCall (сигнал ожидания, только окно или 486 занято), dtmfMethod (автоматически, RFC 2833, SIP INFO, in-band), doNotDisturb (сразу 486) и denyIncoming (everyone отправляет 603, notInContacts сверяется с knownNumbers, anonymous отвечает 433). Правила вычисляются внутри on_incoming_call на потоке PJSIP, до автоматического 180, поэтому ничего не звонит и решение не зависит от отзывчивости приложения. Приложение видит только .callEnded и пишет пометку в журнал звонков.
Приложение (MacroSIPApp)
AppModel (@MainActor @Observable, Model/AppModel.swift) — единственный источник истины для UI: он владеет движком (any SIPEngine), хранилищами, хранилищем связки ключей и наблюдаемым состоянием. Его расширения делят поведение по областям: +Engine (запуск, обработка событий, применение кодеков и сети), +Accounts, +Calls, +LiveCalls, +AutoAnswer, +DND, +Presence, +Quality, +QuickKeys, +Directory, +Presentation. RecordingCoordinator преобразует готовые WAV в M4A. Вызовы движка — это Task { try await engine.x }, ошибки показываются временным баннером.
SIPEngine (Engine/SIPEngine.swift) — протокол, которому отвечают и настоящий Engine, и MockEngine; EngineFactory.make(useMock:) выбирает один из них. LaunchOptions.swift разбирает --mock-engine, --demo-state, --data-dir, --snapshot, --snapshot-after, --appearance и --log-level; DemoState.swift заполняет образцы данных и переводит имитацию в именованные состояния (см. Тесты).
Views/ следует docs/design/SCREENS.md, по папке на экран (Main/, Dialpad/, Call/, Contacts/, CallLog/, Incoming/, QuickKeys/, Directory/, Settings/ с файлом на вкладку, FirstLaunch/) плюс общие Components/ (Theme, компоненты форм, обёртка @Local). В Platform/ лежат части AppKit и системные: AppDelegate, StatusItemController (строка меню), IncomingCallPanelController (плавающее окно), CallNotifications, AudioTransport, RecordingPlayer, QuickKeysMenu, Updater (Sparkle), AppLog и AppInfo.
Хранение
Всё, кроме паролей, — JSON в ~/Library/Application Support/MacroSIP/ (ADR 0004): accounts.json, contacts.json, calllog.json, settings.json, quickkeys.json. JSONStore<T> пишет атомарно; повреждённый файл переименовывается в .broken-<timestamp>.json, и используется значение по умолчанию. Фото контактов — файлы JPEG в photos/<id>.jpg. Пароли живут в связке ключей под службой com.hsmm.macrosip, account = UUID аккаунта (Storage/Keychain.swift); секреты справочника идут через DirectoryCredentials (пароль LDAP bind; токен Google под com.hsmm.macrosip.google). Записи по умолчанию в ~/Music/MacroSIP Recordings, файл журнала — ~/Library/Logs/MacroSIP/MacroSIP.log.
Локализация
ADR 0011: классические Localizable.strings и Localizable.stringsdict в Sources/MacroSIPApp/Resources/{en,ru}.lproj, потому что каталогам .xcstrings нужен xcstringstool из Xcode. Все обращения идут через L в Model/Strings.swift; make-app.sh копирует папки .lproj в Contents/Resources, а scripts/check-strings.sh следит за согласованностью кода и обоих языков. Запросы на доступ к микрофону и Контактам лежат в packaging/InfoPlist/<lang>.lproj/InfoPlist.strings.
Слой справочника
DirectoryService (актор в MacroSIPCore/Directory/) держит по одному DirectoryProvider на источник: MacContactsProvider (фреймворк Contacts), LDAPDirectoryProvider (libldap через CLDAP), URLDirectoryProvider (лента CSV или JSON) и заглушка GoogleDirectoryProvider, которая всегда сообщает «Нужен вход». Поиск опрашивает все включённые источники одновременно и возвращается, когда все ответили или через 3 с; определение звонящего опрашивает источники только для поиска не дольше 1 с; у запроса LDAP предел 3 с (30 с для импорта). Состояния идут через AsyncStream<DirectoryEvent>. В приложении AppModel+Directory.swift выполняет поиск, определение, импорт и расписание, Engine/LiveDirectory.swift оборачивает сервис, MockDirectory.swift обслуживает демонстрационные запуски, а Model/DirectoryMerge.swift сливает импорт в Контакты по номеру: карточка, созданная источником, следует за справочником, карточка пользователя получает только то, чего ей не хватает.
Записи о решениях
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