Files
Luma/Docs/ARCHITECTURE.md
wt 16c9fab509
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
added .read state
2026-09-14 22:22:15 +07:00

261 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура 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.