0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,204 @@
|
||||
# Архитектура 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.
|
||||
Reference in new issue
Block a user