261 lines
25 KiB
Markdown
261 lines
25 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
|
||
сообщения и сохраняет их в per-account SwiftData-хранилище (`ArchiveStore`,
|
||
отдельный `ModelContainer` на аккаунт); список чатов, лента и список
|
||
получателей при пересылке читают данные напрямую через `@Query`.
|
||
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; XEP-0333 `<displayed/>`-маркер
|
||
собеседника переводит исходящее сообщение в состояние «прочитано».
|
||
|
||
## OMEMO 2
|
||
|
||
Помимо legacy `eu.siacs.conversations.axolotl` (MartinOMEMO) поддерживается
|
||
`urn:xmpp:omemo:2` (XEP-0384 0.8.3) через собственный модуль
|
||
`LumaOMEMO2Module`: payload — AES-256-CBC + HMAC-SHA-256 (HKDF, info
|
||
«OMEMO Payload»), plaintext — SCE-envelope (XEP-0420); device list и bundle
|
||
публикуются в PEP-узлах `urn:xmpp:omemo:2:devices` /
|
||
`urn:xmpp:omemo:2:bundles`. Double Ratchet-сессии общие с legacy-модулем
|
||
(тот же `LumaOMEMOStore`). Исходящие предпочитают OMEMO 2, когда у собеседника
|
||
есть OMEMO 2-устройства (в группах — только если устройства есть у всех
|
||
участников), иначе используется legacy. MartinOMEMO завендорен в
|
||
`ThirdParty/` с точечными патчами видимости (см. `THIRD_PARTY_NOTICES.md`).
|
||
|
||
Входящая stanza может прийти напрямую, через carbons или MAM. Сервис определяет
|
||
peer, расшифровывает payload, использует `origin-id`/stanza id для дедупликации и
|
||
передаёт value-type envelope в `AppModel`. Дедупликация выполняется по
|
||
хеш-индексам (`origin-id`, `stanza-id`, `id`), поэтому стоимость применения пачки
|
||
не растёт квадратично с размером архива.
|
||
|
||
Свежая установка начинает MAM с ограниченной последней страницы RSM, а не с
|
||
самого старого сообщения. Инкрементальные проходы используют сохранённый
|
||
непрозрачный UID архива как RSM `after`; timestamp с небольшим перекрытием —
|
||
только миграционный и одноразовый recovery-путь для удалённого сервером UID.
|
||
Каждая stanza проходит проверку `queryid` и источника личного архива. Martin
|
||
публикует MAM-результаты на parser queue: ограниченный inbox принимает там всю
|
||
страницу и делает единственный hand-off на main actor после финального IQ.
|
||
Расшифровка OMEMO выполняется на отдельной серийной фоновой очереди, поэтому
|
||
Signal-криптография не блокирует главный поток; построение envelope и публикация
|
||
остаются на main actor. Видимый индикатор «Синхронизация истории…» показывается
|
||
только на первичной странице, а инкрементальная догрузка backlog продолжается в
|
||
фоне молча, публикуя декодированные изменения атомарными пачками по мере
|
||
готовности страниц. MUC-архив проходит тем же путём и флашит декодированные
|
||
групповые сообщения по завершении catch-up комнаты. Ошибка отдельной страницы
|
||
автоматически повторяется ограниченное число раз. При уходе приложения с экрана
|
||
и на время записи/отправки видеосообщения активный MAM-запрос закрывается, чтобы
|
||
дешифрование истории не конкурировало с камерой и upload. Только после commit
|
||
`AppModel` обновляет SwiftUI, SwiftData-хранилище и 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,
|
||
временно удерживается в ограниченной очереди. Локальное удаление физически
|
||
удаляет строку из SwiftData-хранилища (при открытии хранилище также вычищает
|
||
строки, помеченные удалёнными старыми сборками) и сохраняет отдельный набор
|
||
подавленных 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 не создаст дубликат карточки.
|
||
|
||
Карточка звонка синхронизируется между устройствами: по завершении вызова
|
||
устройство отправляет самому себе (собственный bare JID) служебное сообщение с
|
||
`<call-history xmlns='https://luma.chat/call-history'>` (направление, статус,
|
||
длительность, время начала, собеседник, видео). Carbons доставляют его на
|
||
остальные устройства, MAM сохраняет в архиве; получатели парсят payload и
|
||
создают ту же карточку, а дедупликация идёт по origin-id, совпадающему с
|
||
clientID карточки на исходном устройстве. Пропущенный входящий звонок
|
||
увеличивает счётчик непрочитанных на каждом устройстве.
|
||
|
||
Состояние «прочитано» (`.read`) синхронизируется между устройствами тем же
|
||
каналом. Устройство, получившее от собеседника `<displayed/>`-маркер
|
||
(XEP-0333) или, для MUC, маркер от участника комнаты, переводит исходящее
|
||
сообщение в `.read` и отправляет собственному bare JID служебное сообщение с
|
||
`<read-marker xmlns='https://luma.chat/read-marker'>` (чат, origin-id, stanza-id
|
||
сообщения). Carbons доставляют его на остальные устройства, MAM архивирует —
|
||
поэтому свежая установка и повторные прогоны архива сходятся к одному
|
||
состоянию, даже когда маркер приходит раньше самого сообщения (MAM отдаёт
|
||
станзы от новых к старым): маркер ждёт в `pendingReadMarkers` до upsert
|
||
сообщения. Для комнат маркер адресован собственному bare JID и ссылается на
|
||
stanza-id из MUC-MAM, так что состояние прочтения не попадает в архив
|
||
комнаты. При открытии 1:1-чата Luma отправляет собеседнику `<displayed/>` для
|
||
последнего входящего сообщения (в MUC маркеры не отправляются — XEP-0333).
|
||
Слияние копий одного исходящего сообщения (live/MAM/MUC-MAM/Carbons)
|
||
монотонно: `Delivery.merged(with:)` не позволяет архивному повтору с `.sent`
|
||
понизить уже установленные `.delivered`/`.read`, а `.failed` остаётся
|
||
неизменным до явной повторной отправки.
|
||
|
||
Текущая реализация принимает вызов при живом 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 и реакции;
|
||
- шифрование базы SQLCipher поверх SwiftData SQLite (история уже хранится
|
||
в SwiftData, а не в JSON snapshot);
|
||
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
|
||
- share extension и notification service extension.
|