205 lines
19 KiB
Markdown
205 lines
19 KiB
Markdown
# Архитектура Luma
|
||
|
||
## Слои
|
||
|
||
1. `XMPPService` конфигурирует Martin, TLS/SASL, XEP-модули, MUC, MAM,
|
||
HTTP Upload, XEP-0084 PEP User Avatar и Jingle.
|
||
2. `LumaCallEngine` связывает Martin Jingle с WebRTC, получает STUN/TURN через
|
||
XEP-0215 и публикует value-type `CallSnapshot` для интерфейса.
|
||
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
|
||
AES-GCM выполняется CryptoKit.
|
||
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
|
||
сообщения и сохраняет snapshot локально.
|
||
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
|
||
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
|
||
текстовые ответы возвращаются немедленно или через гарантированную очередь,
|
||
а голосовые `.m4a` — фоновой файловой передачей WatchConnectivity. iPhone
|
||
копирует временный файл внутри callback, при необходимости переподключает
|
||
XMPP, отправляет запись через тот же XEP-0363/OMEMO-пайплайн и возвращает
|
||
часам отдельный результат фактической отправки.
|
||
|
||
## Контакты
|
||
|
||
`RosterModule` Martin передаёт initial roster и последующие roster-push в
|
||
`AppModel`. Модель отдельно сохраняет множество roster JID, поэтому удаление
|
||
контакта на Prosody сразу убирает его из раздела «Люди», но не уничтожает
|
||
локальную историю чата. Стандартный XMPP roster содержит людей, а не членство в
|
||
MUC, поэтому экран «Контакты» объединяет серверный roster с сохранёнными
|
||
XEP-0045-комнатами в отдельном разделе «Групповые чаты».
|
||
|
||
## Поток сообщения
|
||
|
||
Исходящий текст получает `origin-id`. Итоговая политика шифрования вычисляется
|
||
из глобальной настройки аккаунта и переопределения конкретного чата. При
|
||
включённом режиме текст шифруется OMEMO для известных устройств контакта и
|
||
собственных дополнительных устройств; при выключенном отправляется обычный
|
||
`<body>` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический
|
||
fallback на plaintext. UI сразу показывает optimistic message и обновляет
|
||
состояние по результату записи/receipt.
|
||
|
||
Входящая stanza может прийти напрямую, через carbons или MAM. Сервис определяет
|
||
peer, расшифровывает payload, использует `origin-id`/stanza id для дедупликации и
|
||
передаёт value-type envelope в `AppModel`.
|
||
|
||
Свежая установка начинает MAM с ограниченной последней страницы RSM, а не с
|
||
самого старого сообщения. Инкрементальные проходы используют сохранённый
|
||
непрозрачный UID архива как RSM `after`; timestamp с небольшим перекрытием —
|
||
только миграционный и одноразовый recovery-путь для удалённого сервером UID.
|
||
Каждая stanza проходит проверку `queryid` и источника личного архива. Martin
|
||
публикует MAM-результаты на parser queue: ограниченный inbox принимает там всю
|
||
страницу и делает единственный hand-off на main actor после финального IQ.
|
||
Дешифрование выполняется по одной stanza с уступкой event loop, а декодированные
|
||
изменения становятся видимыми только одной атомарной пачкой вместе с новой
|
||
контрольной точкой. Один foreground-проход имеет конечный бюджет страниц и
|
||
времени; ошибка страницы не перезапускает весь архив сама. При уходе приложения
|
||
с экрана и на время записи/отправки видеосообщения активный MAM-запрос
|
||
закрывается, чтобы дешифрование истории не конкурировало с камерой и upload.
|
||
Только после commit `AppModel` обновляет SwiftUI, локальный snapshot и Apple
|
||
Watch; незавершённый проход при timeout/disconnect отбрасывается целиком.
|
||
|
||
Редактирование реализовано стандартным XEP-0308: исправление получает новый id и
|
||
`<replace>` с id исходного сообщения. `AppModel` проверяет совпадение bare JID
|
||
отправителя, заменяет только текстовый payload и отмечает сообщение как
|
||
изменённое. Один и тот же путь обрабатывает live, Carbons и MAM; исправления,
|
||
пришедшие раньше исходной stanza во время синхронизации, временно удерживаются в
|
||
очереди.
|
||
|
||
Ответ содержит `<reply xmlns="urn:xmpp:reply:0">` с id и JID автора исходного
|
||
сообщения; в MUC это полный occupant JID комнаты. Для клиентов без XEP-0461 тело дополнено `>`-цитатой, а её
|
||
границы отмечены XEP-0428 в Unicode scalar offsets. На приёме Luma удаляет
|
||
fallback из отображаемого текста и строит компактный bubble исходного сообщения.
|
||
Нажатие собирает все сообщения с тем же reply target id и открывает отдельный
|
||
фокус-слой: источник, количество ответов, ветку, дату и delivery status выбранного
|
||
ответа. Если XML-метаданных нет, но тело начинается с цитаты Conversations/Monal,
|
||
legacy parser отделяет цитату от ответа и показывает неподвижный bubble без
|
||
ложной ссылки на id.
|
||
|
||
Для XEP-0045 комната хранится как отдельный тип conversation. Martin MUC module
|
||
управляет входом, приглашениями, состоянием и occupants; сообщения `groupchat`
|
||
попадают в общий `ChatMessage` pipeline с псевдонимом отправителя. Ответ в
|
||
комнате разрешён только после получения XEP-0359 `stanza-id`, выданного самой
|
||
комнатой: локальный или origin id для межклиентной MUC-цитаты не используется.
|
||
При включённом OMEMO сервис проверяет disco feature `muc_nonanonymous`, получает
|
||
реальные JID online occupants и полные affiliation lists member/admin/owner,
|
||
строит Signal sessions для устройств каждого bare JID и передаёт их в
|
||
MartinOMEMO одним encrypted groupchat stanza. Если список недоступен, хотя бы
|
||
один получатель не имеет устройства или комната скрывает JID, stanza не
|
||
отправляется. Эхо собственной зашифрованной
|
||
stanza отдельно сохраняет назначенный комнатой `stanza-id`, даже когда OMEMO
|
||
декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются
|
||
межклиентно совместимыми.
|
||
|
||
XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное
|
||
сообщение. Перед применением `AppModel` проверяет conversation и JID автора,
|
||
после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее
|
||
редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM,
|
||
временно удерживается в ограниченной очереди. Локальное удаление физически
|
||
убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы
|
||
последующая MAM-синхронизация не добавила её обратно.
|
||
|
||
Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не
|
||
переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и
|
||
запускает новую XEP-0363/OMEMO-загрузку для политики шифрования выбранного чата.
|
||
|
||
При включённом OMEMO медиа сначала шифруется случайным AES-GCM ключом, затем
|
||
encrypted blob загружается по XEP-0363. В OMEMO-сообщении передаётся
|
||
`aesgcm://` URL с ключевым материалом во fragment, поэтому upload service не
|
||
получает ключ расшифрования. При выключенном режиме исходный файл загружается по
|
||
HTTPS. Тело сообщения остаётся ровно одной обычной ссылкой, поэтому другие
|
||
клиенты могут распознать файл; для plaintext дополнительно отправляется
|
||
стандартный XEP-0066 OOB URL. По XEP-0454 `aesgcm` используется как схема URI,
|
||
но не добавляется к имени файла: исходное расширение остаётся в upload URL, чтобы
|
||
Conversations, Monal и другие клиенты могли восстановить медиатип после
|
||
расшифровки. Luma при этом продолжает читать старые собственные ссылки с
|
||
суффиксом `.aesgcm`. Тип и длительность созданных в приложении voice/video-note
|
||
кодируются в имени файла без изменения расширения.
|
||
|
||
Для исходящих фото и видео preview строится до загрузки. Для входящего файла
|
||
отдельной XMPP-миниатюры нет, поэтому общий медиакэш скачивает blob один раз, при
|
||
необходимости расшифровывает его, уменьшает фото через ImageIO или извлекает
|
||
первый кадр видео через AVFoundation. Корневой SwiftUI overlay показывает
|
||
исходное фото с pan/zoom либо видео через `VideoPlayer` на всё окно приложения.
|
||
Круглые видеосообщения продолжают воспроизводить локальный файл через
|
||
`AVPlayerLayer` прямо в ленте. Для voice тот же процессор читает PCM через
|
||
`AVAudioFile` и строит waveform. Один `MediaPlaybackCoordinator` управляет
|
||
музыкой и голосовыми: он гарантирует одно активное воспроизведение,
|
||
синхронизирует прогресс, перемотку и скорость речи.
|
||
|
||
Множественный picker сначала копирует security-scoped файлы во временный draft
|
||
каталог. `MediaPreviewProcessor` строит миниатюры и длительности до отправки;
|
||
пользователь может удалить элементы и добавить подпись. Файлы затем загружаются
|
||
последовательно тем же XEP-0363 pipeline, а подпись отправляется стандартным
|
||
текстовым ответом на первое вложение, когда доступен стабильный target id.
|
||
|
||
Геопозиция запрашивается через Core Location только после явного выбора пункта
|
||
вложения. Пользователь может поправить точку на MapKit-карте; сообщение
|
||
передаётся обычным телом RFC 5870 `geo:latitude,longitude`, поэтому проходит тем
|
||
же OMEMO/plaintext-путём, что и текст, и распознаётся совместимыми клиентами.
|
||
|
||
Аватары публикуются и читаются через XEP-0084. PNG нормализуется до 512 пикселей,
|
||
PEP-события обновляют интерфейс, а локальный cache позволяет показать изображение
|
||
до завершения подключения. vCard-temp используется только как best-effort мост
|
||
для старых клиентов.
|
||
|
||
## Поток звонка
|
||
|
||
В личном чате `AppModel` сначала запрашивает доступ к микрофону и, для видео,
|
||
камере. `LumaCallEngine` выбирает доступный full JID по presence/capabilities и
|
||
начинает XEP-0353 Jingle Message Initiation на выбранный full JID, а для клиента
|
||
без этой возможности использует прямой IQ session-initiate. Адресация JMI на
|
||
конкретный presence/caps resource нужна потому, что некоторые комбинации
|
||
клиента и сервера возвращают bare `proceed`: ожидание full JID тогда блокирует
|
||
trickle ICE, а отправка на bare JID маршрутизирует `transport-info` случайному
|
||
ресурсу. SDP преобразуется между Martin Jingle RTP и WebRTC; ICE-кандидаты
|
||
передаются через transport-info.
|
||
|
||
WebRTC создаёт отдельные audio/video tracks и шифрует медиапоток DTLS-SRTP.
|
||
XEP-0215 External Service Discovery даёт временные STUN/TURN параметры Prosody;
|
||
если сервер их не публикует, остаются host candidates и резервный публичный
|
||
STUN. Перед передачей в WebRTC записи проверяются: STUN URI формируется без
|
||
TURN-only query, а TURN принимается только с полной парой временных credentials.
|
||
Если WebRTC всё же отклоняет серверную конфигурацию, движок повторяет создание с
|
||
публичным STUN и затем только с host candidates. Активный звонок проецируется в
|
||
`CallSnapshot`, поэтому SwiftUI не получает
|
||
владение сетевой сессией или peer connection. Одновременно разрешён один звонок.
|
||
|
||
Для исходящего аудио+видео звонка порядок `<content/>` в `session-accept`
|
||
приводится к порядку `m=`-линий локального offer: Jingle идентифицирует потоки по
|
||
имени, тогда как WebRTC требует стабильного порядка media sections. Локальные
|
||
trickle ICE candidates накапливаются до успешного начального Jingle stanza и
|
||
получения удалённого SDP. Full JID уже выбран по presence/caps; последующие
|
||
`proceed`, `session-accept` и `transport-info` дополнительно проверяются против
|
||
этого endpoint, поэтому Prosody не маршрутизирует кандидаты другому ресурсу.
|
||
Полученный Jingle candidate преобразуется из SDP-атрибута
|
||
`a=candidate:…` в ожидаемую WebRTC candidate line `candidate:…`. Краткий ICE
|
||
`failed` во время поступления позднего TURN/video candidate получает
|
||
восьмисекундное окно восстановления.
|
||
|
||
Каждая точка завершения сессии передаёт в `CallHistoryPolicy` направление, фазу и
|
||
причину. Движок публикует один `CallHistoryEntry`, а `AppModel` сохраняет его как
|
||
локальный `ChatMessage.Kind.system` с типизированными call-метаданными. Наличие
|
||
`connectedAt` имеет приоритет над поздней ошибкой ICE: состоявшийся звонок остаётся
|
||
успешным и получает длительность. Удаление `activeCall` до закрытия Jingle-сессии
|
||
гарантирует, что ответный callback не создаст дубликат карточки.
|
||
|
||
Текущая реализация принимает вызов при живом XMPP-соединении. Wake-up завершённого
|
||
iOS-приложения намеренно не имитируется: production-путь требует VoIP push,
|
||
PushKit/CallKit и серверной APNs-инфраструктуры.
|
||
|
||
## Локальные уведомления
|
||
|
||
`NotificationCoordinator` является delegate системного notification center и
|
||
разрешает banner/list/sound в foreground. `NotificationPolicy` создаёт
|
||
уведомление только для нового входящего сообщения и подавляет дубликат, когда
|
||
пользователь уже смотрит этот же диалог. Для выгруженного процесса по-прежнему
|
||
нужен XEP-0357/APNs.
|
||
|
||
## Следующие модули
|
||
|
||
- APNs provider + XEP-0357 registration;
|
||
- современный `urn:xmpp:omemo:2` adapter и миграция устройств;
|
||
- MIX и реакции;
|
||
- SQLite/SQLCipher вместо JSON snapshot;
|
||
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
|
||
- share extension и notification service extension.
|