Develop #19

Merged
wt merged 107 commits from develop into main 2026-10-13 09:42:23 +00:00
165 changed files with 21368 additions and 3264 deletions

No files matched your search

+32 -24
View File
@@ -1,34 +1,42 @@
name: iOS CI
name: Build Unsigned iOS ipa
on:
push:
branches: [ main ]
branches:
- main
- develop
- issue-*
pull_request:
branches: [ main ]
branches:
- main
- develop
jobs:
build-and-test:
name: Build and Test SwiftUI App
runs-on: macos-26 # Uses Apple Silicon M1/M2 runners
build-ios:
name: Build Unsigned iOS IPA
runs-on: macos-26
steps:
# Step 1: Check out the repository code
- name: Checkout Repository
- name: Checkout repository
uses: actions/checkout@v4
# Step 2: Select the correct Xcode version
- name: Set Xcode Version
- name: Set up Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
# Step 3: Load dependencies if using Swift Package Manager
- name: Resolve Swift Packages
run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma
# Step 4: Build and run tests using xcodebuild
- name: Build and Test
- name: Build Xcode Workspace
run: |
xcodebuild test \
xcodebuild build \
-project Luma.xcodeproj \
-scheme Luma \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
-quiet
-configuration Release \
-destination 'generic/platform=iOS' \
-derivedDataPath build/DerivedData \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
PROVISIONING_PROFILE_SPECIFIER=""
- name: Package into IPA
run: |
mkdir -p build/Payload
mv build/DerivedData/Build/Products/Release-iphoneos/Luma.app build/Payload/
cd build && zip -r Luma.ipa Payload/
- name: Upload iOS Artifact
uses: actions/upload-artifact@v4
with:
name: Luma${{ github.run_id }}
path: build/Luma.ipa
+37
View File
@@ -0,0 +1,37 @@
name: Build Unsigned macOS App
on:
push:
branches:
- main
- develop
- issue-*
pull_request:
branches:
- main
- develop
jobs:
build-macos:
name: Build macOS ZIP
runs-on: macos-26
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
- name: Build macOS App
run: |
xcodebuild build \
-project Luma.xcodeproj \
-scheme LumaMac \
-configuration Release \
-derivedDataPath build/DerivedData \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
- name: Prepare macOS Artifact
run: mkdir -p upload && cp -rv build/DerivedData/Build/Products/Release/Luma.app upload/
- name: Upload macOS Artifact
uses: actions/upload-artifact@v4
with:
name: Luma
path: upload
+36
View File
@@ -0,0 +1,36 @@
name: iOS CI
on:
push:
branches:
- main
- develop
- issue-*
pull_request:
branches:
- main
- develop
jobs:
build-and-test:
name: Build and Test SwiftUI App
runs-on: macos-26
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set Xcode Version
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
- name: Resolve Swift Packages
run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma
- name: Build and Test
run: |
xcodebuild test \
-project Luma.xcodeproj \
-scheme Luma \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
-only-testing:LumaTests \
-resultBundlePath TestResults.xcresult
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
with:
name: Luma-TestResults
path: TestResults.xcresult
+74
View File
@@ -0,0 +1,74 @@
# Repository Guidelines
Luma is an XMPP client for iOS, iPadOS, macOS, and watchOS, built with SwiftUI
and the Martin / MartinOMEMO libraries. The Xcode project is generated from
`project.yml` by XcodeGen — never edit `Luma.xcodeproj` directly.
## Project Structure
- `Sources/App/` — iOS/macOS app entry point (`LumaApp.swift`).
- `Sources/Shared/` — shared code:
- `Models/` — value types and pure policy enums (`ArchiveSyncPagination`,
`ChatTypingPolicy`, …) plus the SwiftData `@Model` classes
(`ChatMessage`, `Conversation`).
- `UI/` — SwiftUI views; chat/contact lists read SwiftData through
`@Query` (`MainTabView`, `ChatView`, `ForwardMessageView`); the main
screen is Telegram-style: an iOS native `TabView` tab bar
(Контакты/Звонки/Чаты/Настройки) with inline search fields above
the lists, and a
macOS Telegram Desktop-style split view (`MainSplitView`: sidebar
with menu/search/list plus a chat detail pane).
- `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store,
`LumaOMEMO2Module` (urn:xmpp:omemo:2 wire format: AES-256-CBC + HMAC
payload, SCE envelope, device/bundle PEP nodes — shares the Double
Ratchet sessions with the legacy module), `SASLprep` (RFC 4013),
SCRAM-SHA-512 mechanism, SASL failure observer/messages.
- `Persistence/` — `ArchiveStore` (per-account SwiftData container),
`ArchiveMetadataRecord` (durable MAM checkpoint metadata),
`LegacyArchiveImporter` (one-time legacy JSON snapshot migration),
preferences.
- `Services/`, `Security/` — media, notifications, credentials.
- `Sources/Watch/` — single-target watchOS app.
- `Tests/` — XCTest unit tests.
- `Resources/`, `Config/`, `Brand/`, `Docs/` — assets, plists/entitlements,
icons, architecture/security docs.
## Build, Test & Development
- `make project` — regenerate `Luma.xcodeproj` from `project.yml`
(requires `brew install xcodegen`).
- `make open` — regenerate and open Xcode.
- `make verify` — run `Scripts/verify.sh`: grep-based structural invariants
plus an optional simulator build.
- `make clean` — remove the generated project and DerivedData.
- `xcodebuild test -project Luma.xcodeproj -scheme Luma \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max'` — build and
run the test suite.
## Coding Style
- Swift 5.9; 4-space indentation (set in `project.yml`).
- UI-facing state is `@MainActor` (`AppModel`, `XMPPService`); keep heavy work
(OMEMO decryption, media, persistence) off the main actor.
- Prefer small, pure value types and unit-testable policy enums over inline
branching.
- UI strings are Russian; identifiers and code comments are English.
- No formatter/linter; `Scripts/verify.sh` is the guard — keep its greps in
sync when you change constants or symbols.
## Testing
- XCTest with `@testable import Luma`; one file per model/policy
(e.g., `ArchiveSyncPaginationTests.swift`), methods named `test...`.
- Unit-test pure logic (policies, pagination, checkpoints); network/UI flows
are verified manually.
- When a policy constant changes, update the matching `Tests/*` assertions and
the `Scripts/verify.sh` guard in the same commit.
## Commit & Pull Request
- Short, imperative, English subject lines (see `git log`: "mam fixes",
"fix errors and warnings").
- CI (`.github/workflows/ios.yml`) runs `xcodebuild test` on push to
`main`/`develop` and on PRs to `main`; PRs must keep it green.
- Update `README.md` / `Docs/*` whenever behaviour or architecture changes.
+17
View File
@@ -24,12 +24,16 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSCameraUsageDescription</key>
<string>Luma использует камеру для фотографий, видеосообщений и видеозвонков.</string>
<key>NSFaceIDUsageDescription</key>
<string>Luma использует Face ID для разблокировки приложения.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Luma использует локальную сеть для прямого соединения аудио- и видеозвонков.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Luma использует геопозицию только когда вы выбираете отправку точки в чате.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Luma использует микрофон для голосовых сообщений и звонков.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Luma сохраняет фотографии и видео в вашу медиатеку.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
@@ -40,6 +44,19 @@
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportsDocumentBrowser</key>
<false/>
</dict>
+2
View File
@@ -26,6 +26,8 @@
<string>public.app-category.social-networking</string>
<key>NSCameraUsageDescription</key>
<string>Luma использует камеру для фотографий, видеосообщений и видеозвонков.</string>
<key>NSFaceIDUsageDescription</key>
<string>Luma использует Touch ID для разблокировки приложения.</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSLocationUsageDescription</key>
+2
View File
@@ -10,6 +10,8 @@
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-input</key>
+1 -1
View File
@@ -27,6 +27,6 @@
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>app.luma.chat</string>
<string>$(COMPANION_BUNDLE_IDENTIFIER)</string>
</dict>
</plist>
+70 -14
View File
@@ -9,7 +9,9 @@
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
AES-GCM выполняется CryptoKit.
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
сообщения и сохраняет snapshot локально.
сообщения и сохраняет их в per-account SwiftData-хранилище (`ArchiveStore`,
отдельный `ModelContainer` на аккаунт); список чатов, лента и список
получателей при пересылке читают данные напрямую через `@Query`.
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
текстовые ответы возвращаются немедленно или через гарантированную очередь,
@@ -35,11 +37,27 @@ XEP-0045-комнатами в отдельном разделе «Группо
собственных дополнительных устройств; при выключенном отправляется обычный
`<body>` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический
fallback на plaintext. UI сразу показывает optimistic message и обновляет
состояние по результату записи/receipt.
состояние по результату записи/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`.
передаёт value-type envelope в `AppModel`. Дедупликация выполняется по
хеш-индексам (`origin-id`, `stanza-id`, `id`), поэтому стоимость применения пачки
не растёт квадратично с размером архива.
Свежая установка начинает MAM с ограниченной последней страницы RSM, а не с
самого старого сообщения. Инкрементальные проходы используют сохранённый
@@ -48,14 +66,18 @@ peer, расшифровывает payload, использует `origin-id`/sta
Каждая 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 отбрасывается целиком.
Расшифровка 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
@@ -89,13 +111,19 @@ stanza отдельно сохраняет назначенный комнато
декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются
межклиентно совместимыми.
Выход из комнаты оставляет чат в списке как «Не подключено». Удаление чата
убирает его и локальную историю с устройства (и предварительно выходит из
комнаты на сервере, если участник в ней); подавление на время сессии не даёт
событиям комнаты создать удалённый чат заново.
XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное
сообщение. Перед применением `AppModel` проверяет conversation и JID автора,
после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее
редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM,
временно удерживается в ограниченной очереди. Локальное удаление физически
убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы
последующая MAM-синхронизация не добавила её обратно.
удаляет строку из SwiftData-хранилища (при открытии хранилище также вычищает
строки, помеченные удалёнными старыми сборками) и сохраняет отдельный набор
подавленных id, чтобы последующая MAM-синхронизация не добавила её обратно.
Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не
переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и
@@ -182,6 +210,33 @@ trickle ICE candidates накапливаются до успешного нач
успешным и получает длительность. Удаление `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-инфраструктуры.
@@ -199,6 +254,7 @@ PushKit/CallKit и серверной APNs-инфраструктуры.
- APNs provider + XEP-0357 registration;
- современный `urn:xmpp:omemo:2` adapter и миграция устройств;
- MIX и реакции;
- SQLite/SQLCipher вместо JSON snapshot;
- шифрование базы SQLCipher поверх SwiftData SQLite (история уже хранится
в SwiftData, а не в JSON snapshot);
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
- share extension и notification service extension.
+6 -6
View File
@@ -105,18 +105,18 @@ participant читать member list. Для уже существующей к
Для `upload.example.org` нужен корректный HTTPS URL. Если компонент не является
прямым поддоменом VirtualHost, добавьте его в `disco_items`. При reverse proxy
задайте `http_host`/`http_external_url` согласно вашей топологии. Например, для
`wthome.ru` за HTTPS reverse proxy:
`example.com` за HTTPS reverse proxy:
```lua
Component "upload.wthome.ru" "http_file_share"
Component "upload.example.com" "http_file_share"
http_file_share_size_limit = 1024 * 1024 * 1024
http_host = "upload.wthome.ru"
http_external_url = "https://upload.wthome.ru/"
http_host = "upload.example.com"
http_external_url = "https://upload.example.com/"
trusted_proxies = { "127.0.0.1", "::1" }
modules_disabled = { "s2s" }
```
Reverse proxy должен передавать `Host: upload.wthome.ru` и
Reverse proxy должен передавать `Host: upload.example.com` и
`X-Forwarded-Proto: https`. Иначе Prosody может выдать клиенту `http://` URL или
маршрут, возвращающий 404; Luma намеренно не понижает upload до HTTP.
@@ -138,7 +138,7 @@ Reverse proxy должен передавать `Host: upload.wthome.ru` и
prosodyctl check config
prosodyctl check certs
prosodyctl shell module info http_file_share
prosodyctl shell http list upload.wthome.ru
prosodyctl shell http list upload.example.com
```
Начиная с 0.3.0 Luma различает ошибки discovery, получения upload slot,
+50 -11
View File
@@ -1,12 +1,39 @@
# Безопасность и модель угроз MVP
- Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или
snapshot истории.
локальное хранилище истории.
- Проверка сертификата не отключается. Ручной host меняет маршрут подключения,
но XMPP domain/JID остаётся исходной идентичностью.
- TLS trust оценивается системным Apple Security framework как сертификат
сервера (`SecPolicyCreateSSL(true, ...)`) для домена из JID; hostname и цепочка
доверия обязательны даже при ручном адресе подключения.
- Приложение можно заблокировать паролем (хранится в Keychain, выключено по
умолчанию): блокировка срабатывает при запуске и при уходе в фон, а
разблокировка возможна по Face ID/Touch ID.
- Пароль для SCRAM нормализуется по RFC 4013 (SASLprep), чтобы совпадать с
серверной нормализацией; для PLAIN пароль отправляется как введён.
- SCRAM доступен в вариантах SHA-512, SHA-256 и SHA-1 (SHA-512 —
реализация Luma), а также в PLUS-вариантах с канальной привязкой
(SCRAM-SHA-512-PLUS > SHA-256-PLUS > SHA-1-PLUS): gs2-заголовок
«p=tls-exporter,» (RFC 9266) или «p=tls-server-end-point,» доказывает,
что SASL-обмен идёт по тому же TLS-каналу, который видит клиент, и
ломает MITM-ретрансляцию. PLUS выбирается только когда сервер
рекламирует соответствующий channel-binding тип, иначе — обычный SCRAM;
механизм выбирается по рекламе сервера.
- OMEMO работает в legacy-неймспейсе `eu.siacs.conversations.axolotl` (так
умеет закреплённая версия MartinOMEMO) и в OMEMO 2 (`urn:xmpp:omemo:2`,
payload AES-256-CBC + HMAC-SHA-256 в конверте SCE `urn:xmpp:sce:0`). Luma
публикует omemo:2 bundle и список устройств, делит Double Ratchet-сессии с
legacy-модулем и помечает протокол каждого ключа на экране шифрования.
- Канал шифруется STARTTLS/direct TLS собственным TLS-процессором Luma на
OpenSSL 3 (SecureTransport ограничивает потоковый TLS версией 1.2 и не
имеет keying exporter): минимум TLS 1.2, максимум TLS 1.3, ALPN
«xmpp-client», SNI по домену XMPP. Сертификат проверяется через
SecTrust с серверной политикой домена из JID (та же логика
CertificateTrustEvaluator). Фактическая версия TLS, шифр и выбранный
SASL-механизм (самый стойкий из рекламируемых сервером, с канальной
привязкой при её доступности) показываются на экране информации о
сервере.
- Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения
не откатываются на plaintext при ошибке: ошибка показывается пользователю.
- Пользователь может осознанно отключить OMEMO глобально или для отдельного
@@ -49,22 +76,34 @@
STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже
зашифрованный медиапоток. Если Prosody не публикует собственный сервис через
XEP-0215, резервный публичный STUN получает запрос на определение адреса.
- На iOS файлы OMEMO-состояния и локальной истории получают data-protection
`completeUntilFirstUserAuthentication`.
- На iOS файл OMEMO-состояния и база локальной истории (SwiftData
`.store`) получают data-protection `completeUntilFirstUserAuthentication`.
- Архивные эхо собственных служебных станз (receipts, chat markers,
повторно доставленные копии) и собственные сообщения, которые невозможно
расшифровать обратно (нет encrypt-to-self ключа или self-сессия устарела),
не превращаются в пузыри «Не удалось расшифровать сообщение»: они
отбрасываются, как это делают другие клиенты, чтобы не засорять историю
фантомами, которых нет в виде сообщений на сервере.
- Сохранение медиа выполняется только по явному действию пользователя:
фото/видео/видеосообщения — в медиатеку, файлы и голосовые — в «Файлы»
(Загрузки). Расшифрованное содержимое при этом покидает sandbox приложения.
## Известные ограничения
- На macOS история и OMEMO state лежат в sandbox Application Support без
дополнительного SQLCipher-слоя; защиту обеспечивает учётная запись и FileVault
пользователя. Для повышенной модели угроз нужна зашифрованная база.
- На macOS история (SwiftData SQLite в Application Support) и OMEMO state
лежат в sandbox без дополнительного SQLCipher-слоя; защиту обеспечивает
учётная запись и FileVault пользователя. Для повышенной модели угроз нужна
зашифрованная база.
- Локальная история содержит уже расшифрованные тексты, как и у большинства
клиентов. Полное удаление требует выхода с опцией удаления истории.
- «Удалить у меня» удаляет запись только из локального snapshot этого устройства
и запоминает id против повторной MAM-загрузки; оно не удаляет серверный архив,
другие собственные устройства или копию собеседника.
- «Удалить у меня» удаляет запись только из локального SwiftData-хранилища
этого устройства и запоминает id против повторной MAM-загрузки; оно не
удаляет серверный архив, другие собственные устройства или копию собеседника.
- Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого
JID, а переопределение чата — в локальном snapshot. Эти значения не являются
секретами, но прямо влияют на конфиденциальность следующей отправки.
JID, а переопределение чата — в локальном SwiftData-хранилище. Эти значения
не являются секретами, но прямо влияют на конфиденциальность следующей
отправки.
- Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется
настройками PEP и roster на сервере; не используйте аватар как секретное фото.
- Используемая реализация MartinOMEMO поддерживает распространённый legacy
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,4 @@
{
"originHash" : "a9b7decfad90e115a57d2c941faf30bf1f041c5fcd0d87ea51c92b76dfac7ca8",
"pins" : [
{
"identity" : "libsignal",
@@ -20,12 +19,12 @@
}
},
{
"identity" : "martinomemo",
"identity" : "openssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tigase/MartinOMEMO.git",
"location" : "https://github.com/krzyzanowskim/OpenSSL.git",
"state" : {
"revision" : "3c162154d646aa258c9a86c0a07655a536e55a94",
"version" : "2.2.3"
"revision" : "4b1188ba947ddc23d03b13096b9e44b804e146c9",
"version" : "3.6.3000"
}
},
{
@@ -47,5 +46,5 @@
}
}
],
"version" : 3
"version" : 2
}
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -62,6 +62,16 @@
ReferencedContainer = "container:Luma.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A086B6D25E7969CB643F1C8A"
BuildableName = "LumaUITests.xctest"
BlueprintName = "LumaUITests"
ReferencedContainer = "container:Luma.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
LastUpgradeVersion = "1600"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -55,7 +55,6 @@
BuildableIdentifier = "primary"
BlueprintIdentifier = "BAB78463D7DE07A8F7B3D27D"
BuildableName = "LumaWatch.app"
BlueprintName = "LumaWatch"
ReferencedContainer = "container:Luma.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
@@ -72,7 +71,6 @@
BuildableIdentifier = "primary"
BlueprintIdentifier = "BAB78463D7DE07A8F7B3D27D"
BuildableName = "LumaWatch.app"
BlueprintName = "LumaWatch"
ReferencedContainer = "container:Luma.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
+11 -1
View File
@@ -1,8 +1,9 @@
.PHONY: project open verify clean
.PHONY: project open verify clean reset
project:
@command -v xcodegen >/dev/null || (echo "Install XcodeGen first: brew install xcodegen" && exit 1)
xcodegen generate
@python3 Scripts/patch-xcodeproj.py
open: project
open Luma.xcodeproj
@@ -13,3 +14,12 @@ verify:
clean:
rm -rf Luma.xcodeproj DerivedData .build
# Fixes Xcode GUI "Missing package product" errors: the GUI caches the
# SwiftPM package graph in DerivedData, which goes stale when packages
# change (e.g. the remote -> vendored local MartinOMEMO switch). Quit Xcode
# first, run this, then reopen Luma.xcodeproj and let it re-resolve.
reset:
rm -rf ~/Library/Developer/Xcode/DerivedData/Luma-*
rm -f Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@echo "Xcode package state cleared. Reopen Luma.xcodeproj to re-resolve packages."
+10 -5
View File
@@ -78,6 +78,7 @@ Luma — рабочий MVP XMPP-клиента для iOS, iPadOS, macOS и App
iPhone, iPad, Mac и Apple Watch; один и тот же catalog явно подключён ко всем
app-target’ам, а имя закреплено в build settings и Info.plist;
- адаптивный SwiftUI-интерфейс для iPhone, iPad и Mac;
- поддержка портретной и альбомной ориентации на iPhone и всех четырёх ориентаций на iPad;
- companion-приложение watchOS: последние чаты, диктовка/ответы, а также запись
голосового до 60 секунд с отменой и предпрослушиванием; `.m4a` передаётся на
iPhone фоновой файловой очередью WatchConnectivity, после чего iPhone
@@ -145,14 +146,18 @@ OMEMO, и для XEP-0084 аватаров. Для медиа и вложени
XMPP-сокет. Для production нужны Apple Developer credentials, APNs provider,
XEP-0357 push-компонент и регистрация приложения в этом компоненте.
- При первом запуске Luma запрашивает ограниченную последнюю страницу
серверного MAM-архива, чтобы огромная история не блокировала интерфейс.
серверного MAM-архива, чтобы огромная история не блокировала интерфейс, и
показывает индикатор «Синхронизация истории…» только на этом первичном этапе.
После успешного прохода сохраняются серверный UID последней записи и время;
следующие подключения продолжают RSM строго после UID. Временное перекрытие
используется только для старых snapshots без UID или один раз, если сервер
удалил сохранённый UID. Сырые stanza собираются вне main queue, расшифровываются
по одной с уступкой UI, а готовый проход публикуется одной пачкой. Один
foreground-проход также ограничен по страницам и времени; после ошибки он не
перезапускает сам себя бесконечно.
удалил сохранённый UID. Сырые stanza собираются вне main queue, а расшифровка
OMEMO выполняется на отдельной фоновой очереди — главный поток освобождается на
время Signal-криптографии, поэтому большая история не лагает интерфейс.
Инкрементальная догрузка backlog продолжается в фоне без вечного спиннера, а
сообщения применяются атомарными пачками с дедупликацией по `origin-id`/stanza
id через хеш-индексы. После сбоя отдельной страницы синхронизация автоматически
повторяется ограниченное число раз, не дожидаясь повторного открытия приложения.
- Клиент не вводит собственного ограничения размера вложения: фактический
предел сообщает XEP-0363 upload-компонент сервера. Видеосообщение ограничено
60 секундами.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -1,47 +1,305 @@
{
"images" : [
{ "filename" : "AppIcon-iPhone-20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" },
{ "filename" : "AppIcon-iPhone-20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" },
{ "filename" : "AppIcon-iPhone-29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },
{ "filename" : "AppIcon-iPhone-29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },
{ "filename" : "AppIcon-iPhone-40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },
{ "filename" : "AppIcon-iPhone-40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" },
{ "filename" : "AppIcon-iPhone-60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" },
{ "filename" : "AppIcon-iPhone-60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" },
{ "filename" : "AppIcon-iPad-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
{ "filename" : "AppIcon-iPad-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
{ "filename" : "AppIcon-iPad-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
{ "filename" : "AppIcon-iPad-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" },
{ "filename" : "AppIcon-iPad-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" },
{ "filename" : "AppIcon-iPad-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" },
{ "filename" : "AppIcon-iPad-76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },
{ "filename" : "AppIcon-iPad-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
{ "filename" : "AppIcon-iPad-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
{ "filename" : "AppIcon-AppStore-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" },
{ "filename" : "AppIcon-Mac-16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
{ "filename" : "AppIcon-Mac-16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
{ "filename" : "AppIcon-Mac-32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
{ "filename" : "AppIcon-Mac-32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
{ "filename" : "AppIcon-Mac-128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
{ "filename" : "AppIcon-Mac-128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
{ "filename" : "AppIcon-Mac-256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
{ "filename" : "AppIcon-Mac-256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
{ "filename" : "AppIcon-Mac-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
{ "filename" : "AppIcon-Mac-512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" },
{ "filename" : "AppIcon-Watch-Notification-38mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" },
{ "filename" : "AppIcon-Watch-Notification-42mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "27.5x27.5", "subtype" : "42mm" },
{ "filename" : "AppIcon-Watch-Settings@2x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "2x", "size" : "29x29" },
{ "filename" : "AppIcon-Watch-Settings@3x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "3x", "size" : "29x29" },
{ "filename" : "AppIcon-Watch-Launcher-38mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" },
{ "filename" : "AppIcon-Watch-Launcher-40mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" },
{ "filename" : "AppIcon-Watch-Launcher-41mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" },
{ "filename" : "AppIcon-Watch-Launcher-44mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "50x50", "subtype" : "44mm" },
{ "filename" : "AppIcon-Watch-Launcher-45mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "51x51", "subtype" : "45mm" },
{ "filename" : "AppIcon-Watch-Launcher-49mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "54x54", "subtype" : "49mm" },
{ "filename" : "AppIcon-Watch-QuickLook-38mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" },
{ "filename" : "AppIcon-Watch-QuickLook-42mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" },
{ "filename" : "AppIcon-Watch-QuickLook-44mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" },
{ "filename" : "AppIcon-WatchStore-1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" }
{
"filename" : "AppIcon-iPhone-20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "AppIcon-iPhone-20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "AppIcon-iPhone-29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "AppIcon-iPhone-29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "AppIcon-iPhone-40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "AppIcon-iPhone-40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "AppIcon-iPhone-60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "AppIcon-iPhone-60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "AppIcon-iPad-20@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "AppIcon-iPad-20@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "AppIcon-iPad-29@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "AppIcon-iPad-29@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "AppIcon-iPad-40@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "AppIcon-iPad-40@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "AppIcon-iPad-76@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "AppIcon-iPad-76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "AppIcon-iPad-83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "AppIcon-AppStore-1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
},
{
"filename" : "AppIcon-Mac-16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AppIcon-Mac-16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AppIcon-Mac-32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AppIcon-Mac-32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AppIcon-Mac-128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AppIcon-Mac-128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AppIcon-Mac-256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AppIcon-Mac-256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AppIcon-Mac-512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AppIcon-Mac-512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
},
{
"filename" : "AppIcon-Watch-Notification-38mm@2x.png",
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "24x24",
"subtype" : "38mm"
},
{
"filename" : "AppIcon-Watch-Notification-42mm@2x.png",
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "27.5x27.5",
"subtype" : "42mm"
},
{
"filename" : "AppIcon-Watch-Settings@2x.png",
"idiom" : "watch",
"role" : "companionSettings",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "AppIcon-Watch-Settings@3x.png",
"idiom" : "watch",
"role" : "companionSettings",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "AppIcon-Watch-Notification-45mm@2x.png",
"idiom" : "watch",
"role" : "notificationCenter",
"scale" : "2x",
"size" : "33x33",
"subtype" : "45mm"
},
{
"filename" : "AppIcon-Watch-Launcher-38mm@2x.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "40x40",
"subtype" : "38mm"
},
{
"filename" : "AppIcon-Watch-Launcher-40mm@2x.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "44x44",
"subtype" : "40mm"
},
{
"filename" : "AppIcon-Watch-Launcher-41mm@2x.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "46x46",
"subtype" : "41mm"
},
{
"filename" : "AppIcon-Watch-Launcher-44mm@2x.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "50x50",
"subtype" : "44mm"
},
{
"filename" : "AppIcon-Watch-Launcher-45mm@2x.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "51x51",
"subtype" : "45mm"
},
{
"filename" : "AppIcon-Watch-Launcher-49mm@2x.png",
"idiom" : "watch",
"role" : "appLauncher",
"scale" : "2x",
"size" : "54x54",
"subtype" : "49mm"
},
{
"filename" : "AppIcon-Watch-QuickLook-38mm@2x.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "86x86",
"subtype" : "38mm"
},
{
"filename" : "AppIcon-Watch-QuickLook-42mm@2x.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "98x98",
"subtype" : "42mm"
},
{
"filename" : "AppIcon-Watch-QuickLook-44mm@2x.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "108x108",
"subtype" : "44mm"
},
{
"filename" : "AppIcon-Watch-QuickLook-45mm@2x.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "117x117",
"subtype" : "45mm"
},
{
"filename" : "AppIcon-Watch-QuickLook-49mm@2x.png",
"idiom" : "watch",
"role" : "quickLook",
"scale" : "2x",
"size" : "129x129",
"subtype" : "49mm"
},
{
"filename" : "AppIcon-WatchStore-1024.png",
"idiom" : "watch-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Post-generation fix for XcodeGen issue #1549: XcodeGen does not link
XCSwiftPackageProductDependency entries to local (path-based) Swift packages,
which makes the Xcode GUI report "Missing package product". This script
attaches the MartinOMEMO product dependencies to the vendored local package
reference in the generated project file.
Run after every `xcodegen generate` (see the Makefile `project` target).
"""
import sys
PBXPROJ = "Luma.xcodeproj/project.pbxproj"
def main() -> int:
with open(PBXPROJ, encoding="utf-8") as handle:
text = handle.read()
# Find the object id of the local package reference.
ref_id = None
for line in text.splitlines():
marker = " /* XCLocalSwiftPackageReference \"ThirdParty/MartinOMEMO\" */ = {"
if marker in line:
ref_id = line.split(" /*")[0].strip()
break
if ref_id is None:
print("patch-xcodeproj: local package reference not found; nothing to do")
return 0
needle = (
"/* MartinOMEMO */ = {\n"
"\t\t\tisa = XCSwiftPackageProductDependency;\n"
"\t\t\tproductName = MartinOMEMO;"
)
patch = (
"/* MartinOMEMO */ = {\n"
"\t\t\tisa = XCSwiftPackageProductDependency;\n"
"\t\t\tpackage = " + ref_id + " /* XCLocalSwiftPackageReference \"ThirdParty/MartinOMEMO\" */;\n"
"\t\t\tproductName = MartinOMEMO;"
)
count = text.count(needle)
if count == 0:
print("patch-xcodeproj: MartinOMEMO product dependencies already linked")
return 0
patched = text.replace(needle, patch)
with open(PBXPROJ, "w", encoding="utf-8") as handle:
handle.write(patched)
print("patch-xcodeproj: linked " + str(count) + " MartinOMEMO product dependencies")
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -10,4 +10,7 @@ if ! command -v xcodegen >/dev/null; then
fi
xcodegen generate
# XcodeGen issue #1549: link the vendored local package to its product
# dependencies, otherwise the Xcode GUI reports "Missing package product".
python3 Scripts/patch-xcodeproj.py
open Luma.xcodeproj
+284 -12
View File
@@ -18,12 +18,14 @@ required=(
Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift
Sources/Shared/Models/ArchiveSyncWorkBudget.swift
Sources/Shared/Models/ArchiveMessageBatchPolicy.swift
Sources/Shared/Models/OlderHistoryScrollPolicy.swift
Sources/Shared/Models/ChatScrollPositionPolicy.swift
Sources/Shared/Models/ChatTimelineEntry.swift
Sources/Shared/Models/ChatTypingPolicy.swift
Sources/Shared/Models/MessageReaction.swift
Sources/Shared/Models/MediaPickerSelectionPolicy.swift
Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift
Sources/Shared/Models/VideoNoteRotationPolicy.swift
Sources/Shared/Models/VideoNoteRecordingLifecycle.swift
Sources/Shared/Models/MessageReplySwipePolicy.swift
Sources/Shared/Models/MessageReplyFallback.swift
@@ -66,6 +68,7 @@ required=(
Tests/MediaViewerDismissGestureTests.swift
Tests/ArchiveSyncCheckpointTests.swift
Tests/ArchiveSyncPaginationTests.swift
Tests/OlderHistoryScrollPolicyTests.swift
Tests/ArchiveSyncRecoveryPolicyTests.swift
Tests/ArchiveSyncWorkBudgetTests.swift
Tests/ArchiveMessageBatchPolicyTests.swift
@@ -77,9 +80,34 @@ required=(
Tests/MediaPickerSelectionPolicyTests.swift
Tests/VideoNoteRecordingCompletionPolicyTests.swift
Tests/VideoNoteRecordingLifecycleTests.swift
Tests/VideoNoteRotationPolicyTests.swift
Tests/VideoNoteStopPolicyTests.swift
Tests/MessageReplySwipeTests.swift
Tests/WatchVoiceMessageTests.swift
Tests/ArchiveStoreTests.swift
Tests/SASLprepTests.swift
Tests/SaslFailureMessageTests.swift
Tests/MediaPreviewProcessorVideoTests.swift
Tests/SCRAMSHA512Tests.swift
Tests/AppLockPolicyTests.swift
Tests/CallHistorySyncTests.swift
Tests/ReadStateSyncTests.swift
Tests/ChatMessageDeliveryTests.swift
UITests/TimelineUITests.swift
Sources/Shared/XMPP/LumaScramSha512Mechanism.swift
Sources/Shared/XMPP/CallHistorySync.swift
Sources/Shared/XMPP/ReadStateSync.swift
Sources/Shared/Security/AppLockVault.swift
Sources/Shared/Models/AppLockPolicy.swift
Sources/Shared/UI/AppLockView.swift
Sources/Shared/XMPP/SASLprep.swift
Sources/Shared/XMPP/LumaSaslFailureModule.swift
Sources/Shared/XMPP/SaslFailureMessage.swift
Sources/Shared/Models/ChatMessage.swift
Sources/Shared/Models/Conversation.swift
Sources/Shared/Persistence/ArchiveStore.swift
Sources/Shared/Persistence/ArchiveMetadataRecord.swift
Sources/Shared/Persistence/LegacyArchiveImporter.swift
Brand/LumaIcon-1024.png
Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
Regenerate-Luma-Project.command
@@ -141,7 +169,7 @@ test "$(grep -c -- '- package: WebRTC' project.yml)" -eq 2 || {
exit 1
}
if grep -R -q 'ChatBubbleShape\|maximumMediaSize\|25 МБ' Sources/Shared; then
if grep -R -q 'ChatBubbleTailShape\|maximumMediaSize\|25 МБ' Sources/Shared; then
echo "Legacy bubble tails or the old client attachment limit are still present"
exit 1
fi
@@ -225,6 +253,10 @@ grep -q 'archiveWorkBudget.recordCompletedPage' Sources/Shared/XMPP/XMPPService.
echo "Incremental MAM passes must have a finite foreground work budget"
exit 1
}
grep -q 'OlderHistoryScrollPolicy.page(' Sources/Shared/XMPP/XMPPService.swift || {
echo "Interactive history paging must advance via the server's RSM first cursor"
exit 1
}
if grep -q 'scheduleArchiveRestart\|restartDelayNanoseconds' Sources/Shared/XMPP/XMPPService.swift; then
echo "Failed MAM queries must not restart themselves indefinitely"
exit 1
@@ -249,12 +281,244 @@ grep -q 'applyArchiveBatch(mutations)' Sources/Shared/Models/AppModel.swift || {
echo "MAM mutations must be applied atomically before publishing SwiftUI state"
exit 1
}
grep -q 'decodeSliceSize = 1' Sources/Shared/Models/ArchiveMessageBatchPolicy.swift || {
echo "MAM decoding must remain limited to one stanza per main-actor slice"
grep -q 'decodeSliceSize = 8' Sources/Shared/Models/ArchiveMessageBatchPolicy.swift || {
echo "MAM decoding must stay batched in small per-slice groups"
exit 1
}
grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ChatArchive.swift || {
echo "The durable archive snapshot must persist the XEP-0313 UID cursor"
grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ArchiveStore.swift || {
echo "The durable archive store must persist the XEP-0313 UID cursor"
exit 1
}
if test -f Sources/Shared/Persistence/ChatArchive.swift; then
echo "The legacy JSON ChatArchive actor must stay removed"
exit 1
fi
grep -q '@Query' Sources/Shared/UI/MainTabView.swift || {
echo "Chat list must be SwiftData @Query-driven"
exit 1
}
grep -q 'NavigationSplitView' Sources/Shared/UI/MainTabView.swift || {
echo "macOS must keep the Telegram Desktop-style split view"
exit 1
}
grep -q 'SearchField' Sources/Shared/UI/MainTabView.swift || {
echo "Search fields must stay inline above the lists"
exit 1
}
grep -q 'TabView(selection:' Sources/Shared/UI/MainTabView.swift || {
echo "The iOS main screen must use the native TabView tab bar"
exit 1
}
grep -q 'toolbar(.hidden, for: .tabBar)' Sources/Shared/UI/ChatView.swift || {
echo "The tab bar must be hidden inside a pushed chat"
exit 1
}
grep -q 'PhoneWatchMessageData' Sources/Shared/Services/PhoneWatchBridge.swift || {
echo "The watch bridge must snapshot SwiftData models on the main actor"
exit 1
}
grep -q 'urn:xmpp:omemo:2' Sources/Shared/XMPP/LumaOMEMO2Module.swift || {
echo "OMEMO 2 must be implemented by the app-level module"
exit 1
}
grep -q 'decodeOmemo2OffMain' Sources/Shared/XMPP/XMPPService.swift || {
echo "Incoming messages must route OMEMO 2 payloads to the OMEMO 2 module"
exit 1
}
grep -q 'XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO"' Luma.xcodeproj/project.pbxproj || {
echo "The vendored MartinOMEMO package must stay linked in the generated project (run make project)"
exit 1
}
grep -q '@Query' Sources/Shared/UI/ChatView.swift || {
echo "Chat timeline must be SwiftData @Query-driven"
exit 1
}
grep -q '@Query' Sources/Shared/UI/ForwardMessageView.swift || {
echo "Forward destination list must be SwiftData @Query-driven"
exit 1
}
grep -q '\.modelContext' Sources/Shared/UI/RootView.swift || {
echo "RootView must inject the SwiftData ModelContext"
exit 1
}
grep -q 'modelContext.delete(message)' Sources/Shared/Models/AppModel.swift || {
echo "Local message deletion must remove the SwiftData row"
exit 1
}
grep -q 'purgeLocallyDeletedMessages' Sources/Shared/Persistence/ArchiveStore.swift || {
echo "ArchiveStore must purge locally deleted rows for @Query views"
exit 1
}
grep -q 'completeUntilFirstUserAuthentication' Sources/Shared/Persistence/ArchiveStore.swift || {
echo "The SwiftData store must keep the data protection attribute"
exit 1
}
grep -q 'SASLprep' Sources/Shared/XMPP/XMPPService.swift || {
echo "SCRAM passwords must be RFC 4013 (SASLprep) normalized"
exit 1
}
grep -q 'LumaSaslFailureModule' Sources/Shared/XMPP/XMPPService.swift || {
echo "The raw SASL failure condition must be captured for the UI"
exit 1
}
grep -q 'Сервер отклонил имя пользователя или пароль' Sources/Shared/XMPP/SaslFailureMessage.swift || {
echo "SASL failures must produce an actionable Russian message"
exit 1
}
grep -q "doesn't match the one we calculated" Sources/Shared/XMPP/SaslFailureMessage.swift || {
echo "Prosody SCRAM proof mismatches must be explained in Russian"
exit 1
}
grep -q 'passwordVisible' Sources/Shared/UI/LoginView.swift || {
echo "The login screen must let the user verify the typed password"
exit 1
}
grep -q 'SCRAM-SHA-512' Sources/Shared/XMPP/LumaScramSha512Mechanism.swift || {
echo "The SCRAM-SHA-512 mechanism must be available"
exit 1
}
grep -q 'LumaScramSha512Mechanism(channelBindingStore: channelBindingStore)' Sources/Shared/XMPP/XMPPService.swift || {
echo "SCRAM-SHA-512 must be registered ahead of Martin's mechanisms"
exit 1
}
grep -q 'NSFaceIDUsageDescription' project.yml || {
echo "Biometric unlock must declare its usage description"
exit 1
}
grep -q 'unlockWithBiometrics' Sources/Shared/Models/AppModel.swift || {
echo "The app lock must support biometric unlock"
exit 1
}
grep -q 'AppLockView' Sources/Shared/UI/RootView.swift || {
echo "The lock screen must cover every app layer"
exit 1
}
grep -q 'Блокировка приложения' Sources/Shared/UI/SettingsView.swift || {
echo "Settings must offer the app lock toggle"
exit 1
}
grep -q 'https://luma.chat/call-history' Sources/Shared/XMPP/CallHistorySync.swift || {
echo "Call history must sync through its service namespace"
exit 1
}
grep -q 'syncCallHistory' Sources/Shared/Models/AppModel.swift || {
echo "Call cards must be synced to the user's other devices"
exit 1
}
grep -q 'CallHistorySync.envelope' Sources/Shared/XMPP/XMPPService.swift || {
echo "Incoming call-history payloads must be intercepted"
exit 1
}
grep -q 'https://luma.chat/read-marker' Sources/Shared/XMPP/ReadStateSync.swift || {
echo "Read state must sync through its service namespace"
exit 1
}
grep -q 'ReadStateSync.envelope' Sources/Shared/XMPP/XMPPService.swift || {
echo "Incoming read-marker payloads must be intercepted"
exit 1
}
grep -q 'syncReadState' Sources/Shared/Models/AppModel.swift || {
echo "Read receipts must be synced to the user's other devices"
exit 1
}
grep -q 'delivery = \$0.delivery.merged(with: .delivered)' Sources/Shared/Models/AppModel.swift || {
echo "Delivery receipts must never downgrade an already read message"
exit 1
}
if grep -q 'message.body = Self.readMarkerBody' Sources/Shared/XMPP/XMPPService.swift; then
echo "Read-marker service stanzas must not carry a plaintext body (it leaks into self-chat on other clients)"
exit 1
fi
grep -q 'message.isMarkable = true' Sources/Shared/XMPP/XMPPService.swift || {
echo "Outgoing 1:1 messages must be <markable/> or peers never answer with <displayed/>"
exit 1
}
grep -q 'markersPublisher' Sources/Shared/XMPP/XMPPService.swift || {
echo "XEP-0333 displayed markers must mark messages as read"
exit 1
}
grep -q 'markRead(at: index, cameFromPeer: false)' Sources/Shared/Models/AppModel.swift || {
echo "Self-chat must mark its own outgoing messages as read locally"
exit 1
}
grep -q 'case read(conversationJID' Sources/Shared/XMPP/XMPPService.swift || {
echo "Read receipts must reference the conversation"
exit 1
}
grep -q 'merged(with: message.delivery)' Sources/Shared/Models/AppModel.swift || {
echo "MAM replays must not downgrade delivered or read state"
exit 1
}
grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || {
echo "Group chats must support local deletion"
exit 1
}
grep -q 'deletedGroupChatJIDs' Sources/Shared/Models/AppModel.swift || {
echo "Deleted group chats must stay suppressed for the session"
exit 1
}
grep -q 'Удалить чат' Sources/Shared/UI/GroupInfoView.swift || {
echo "Group info must offer deleting the chat"
exit 1
}
grep -q 'pendingGroupDeletion' Sources/Shared/UI/MainTabView.swift || {
echo "Chat list must offer deleting group chats"
exit 1
}
grep -q 'pickerResetToken' Sources/Shared/UI/ChatView.swift || {
echo "The photo picker must be recreated after each selection"
exit 1
}
grep -q 'let items = pickedMediaItems' Sources/Shared/UI/ChatView.swift || {
echo "Gallery staging must run on picker dismissal only"
exit 1
}
grep -q 'connectionStatus == .connected' Sources/Shared/Models/AppModel.swift || {
echo "Older-history loads must skip silently while disconnected"
exit 1
}
grep -q 'removeSessionWithOwnDeviceOnce' Sources/Shared/XMPP/LumaOMEMOStore.swift || {
echo "The self-session purge must be a one-time migration"
exit 1
}
grep -q 'ensureSelfSession' Sources/Shared/XMPP/XMPPService.swift || {
echo "A missing self-session must be rebuilt from the own bundle"
exit 1
}
grep -q 'urn:xmpp:omemo:2' Sources/Shared/XMPP/XMPPService.swift || {
echo "OMEMO 2 payloads must be shown as undecryptable, not empty"
exit 1
}
grep -q 'biometricUnlockAvailable' Sources/Shared/Models/AppModel.swift || {
echo "Biometric availability must be cached off the view body"
exit 1
}
grep -q 'Звонки' Sources/Shared/UI/MainTabView.swift || {
echo "The main screen must offer the Calls tab"
exit 1
}
grep -q 'Настройки' Sources/Shared/UI/MainTabView.swift || {
echo "The main screen must offer the Settings tab"
exit 1
}
grep -q 'case .background:' Sources/App/LumaApp.swift || {
echo "The app lock must trigger on background only, not on inactive"
exit 1
}
grep -q 'onDismissRequest' Sources/Shared/UI/SystemPhotoCameraView.swift || {
echo "The camera picker must request dismissal before background preparation"
exit 1
}
grep -q 'onDismissRequest' Sources/Shared/UI/ChatView.swift || {
echo "The camera cover must dismiss through the SwiftUI binding"
exit 1
}
grep -q 'moveItem(at: sourceURL' Sources/Shared/UI/SystemPhotoCameraView.swift || {
echo "The picker movie must be claimed synchronously before dismissal cleanup"
exit 1
}
grep -q 'video-preview' Sources/Shared/UI/ChatView.swift || {
echo "The camera video path must log diagnostics"
exit 1
}
grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
@@ -318,7 +582,7 @@ grep -q 'MediaPickerSelectionPolicy.preferredOrder' Sources/Shared/UI/ChatView.s
echo "Ambiguous photo/video providers must retain a fallback representation"
exit 1
}
grep -q 'item.loadTransferable(type: Data.self)' Sources/Shared/UI/ChatView.swift || {
grep -q 'type: Data.self' Sources/Shared/UI/ChatView.swift || {
echo "PhotosPicker images need a data fallback when no file representation is available"
exit 1
}
@@ -358,7 +622,7 @@ grep -q 'scrollDismissesKeyboard(.interactively)' Sources/Shared/UI/ChatView.swi
echo "Interactive keyboard dismissal is missing"
exit 1
}
grep -q 'selectedTimelineEntries' Sources/Shared/UI/ChatView.swift || {
grep -q 'rebuildTimelineEntries' Sources/Shared/UI/ChatView.swift || {
echo "Chat rows and day boundaries must use the cached stable timeline"
exit 1
}
@@ -429,6 +693,10 @@ if grep -q 'Режим видеосообщения\|composerCaptureMode.*video'
echo "Video-note recording must not reuse the inline voice-message gesture"
exit 1
fi
grep -q 'VideoNoteRotationPolicy.currentInterfaceOrientation' Sources/Shared/Services/VideoNoteRecorder.swift || {
echo "Video note capture must rotate by the interface orientation, not the device sensor"
exit 1
}
grep -q 'resetCaptureGraph()' Sources/Shared/Services/VideoNoteRecorder.swift || {
echo "Reusable video-note capture cleanup is missing"
exit 1
@@ -537,8 +805,12 @@ grep -q 'urn:xmpp:reactions:0' Sources/Shared/XMPP/XMPPService.swift || {
echo "XEP-0444 capability advertisement is missing"
exit 1
}
grep -q 'MessageReactionPolicy.quickChoices' Sources/Shared/UI/Components/MessageBubble.swift || {
echo "Message reaction UI is missing"
grep -q 'onReactPicker' Sources/Shared/UI/Components/MessageBubble.swift || {
echo "Message reaction picker is missing"
exit 1
}
grep -q 'EmojiPickerView' Sources/Shared/UI/ChatView.swift || {
echo "The emoji picker is not wired into the chat"
exit 1
}
grep -q 'ChatStateNotificationsModule' Sources/Shared/XMPP/XMPPService.swift || {
@@ -554,8 +826,8 @@ grep -q 'snapshotQueue.async' Sources/Shared/Services/PhoneWatchBridge.swift ||
exit 1
}
awk '
/^#if os\(iOS\)$/ { ios_only = 1; next }
/^#endif$/ && ios_only { ios_only = 0; next }
/^[[:space:]]*#if os\(iOS\)$/ { ios_only = 1; next }
/^[[:space:]]*#endif$/ && ios_only { ios_only = 0; next }
/availableVideoCodecTypes/ {
found = 1
if (!ios_only) escaped = 1
@@ -593,7 +865,7 @@ grep -q 'failedDrafts' Sources/Shared/UI/AttachmentPreviewView.swift || {
echo "Failed media drafts must remain available for retry"
exit 1
}
grep -q 'rosterContactJIDs' Sources/Shared/UI/MainChatView.swift || {
grep -q 'rosterContactJIDs' Sources/Shared/UI/MainTabView.swift || {
echo "Prosody roster contacts screen is missing"
exit 1
}
+14 -1
View File
@@ -11,7 +11,20 @@ struct LumaApp: App {
RootView(model: model)
.tint(Color(red: 0.14, green: 0.56, blue: 0.96))
.onChange(of: scenePhase) { _, phase in
model.setApplicationActive(phase == .active)
switch phase {
case .active:
model.setApplicationActive(true)
case .background:
model.setApplicationActive(false)
case .inactive:
// App switcher, Control Center and system pickers
// (photo gallery) make the scene briefly inactive;
// locking here would block the picker and the
// unlock screen would replace the app mid-flow.
break
@unknown default:
break
}
}
}
#if os(macOS)
@@ -22,7 +22,10 @@ struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendabl
var effectiveResource: String {
let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Luma" : trimmed
if trimmed.isEmpty || trimmed == DeviceResource.legacyDefault {
return DeviceResource.default
}
return trimmed
}
init(
+12
View File
@@ -0,0 +1,12 @@
import Foundation
/// Pure policy for the app-lock passcode: minimum length and validation.
/// Kept separate from the Keychain storage so it is unit-testable.
enum AppLockPolicy {
static let minimumLength = 4
static func isValid(_ passcode: String) -> Bool {
passcode.count >= minimumLength
}
}
File diff suppressed because it is too large. Load diff
@@ -4,12 +4,16 @@ import Foundation
/// still published atomically; these values only bound network and decryption
/// work performed before that publication.
enum ArchiveMessageBatchPolicy {
static let pageSize = 24
static let pageSize = 40
static let bootstrapMessageLimit = 40
static let decodeSliceSize = 1
static let maximumBufferedStanzas = 48
/// Leave roughly half of a 60 Hz frame between archived decryptions. This
/// keeps UIScrollView touch handling responsive even when OMEMO work is
/// unusually expensive on older devices.
static let interSliceDelayNanoseconds: UInt64 = 8_000_000
/// Decrypt this many archived stanzas per main-actor slice before yielding.
/// One-by-one decryption turns a large MAM catch-up into hours of serialized
/// work; a small batch keeps frames mostly intact while cutting the number
/// of run-loop hand-offs by the same factor.
static let decodeSliceSize = 8
static let maximumBufferedStanzas = 96
/// A short yield between archived decryption slices keeps touch handling
/// responsive without the 8 ms per single message that previously dominated
/// catch-up time.
static let interSliceDelayNanoseconds: UInt64 = 1_000_000
}
@@ -1,5 +1,40 @@
import Foundation
/// A MAM UID is only meaningful inside one archive. Account MAM and every MUC
/// therefore get their own namespace and durable checkpoint.
struct MAMArchiveKey: Codable, Hashable, Sendable {
enum Kind: String, Codable, Sendable {
case account
case muc
}
let kind: Kind
let jid: String
init(kind: Kind, jid: String) {
self.kind = kind
self.jid = jid.lowercased()
}
static func account(_ jid: String) -> Self {
Self(kind: .account, jid: jid)
}
static func muc(_ jid: String) -> Self {
Self(kind: .muc, jid: jid)
}
}
struct MAMArchiveCheckpoint: Codable, Equatable, Sendable {
let timestamp: Date
let cursor: String?
init(timestamp: Date, cursor: String? = nil) {
self.timestamp = timestamp
self.cursor = ArchiveSyncCheckpoint.normalizedCursor(cursor)
}
}
/// Durable MAM position. The archive UID is the primary synchronization
/// anchor; the timestamp is retained as a compatibility fallback for legacy
/// snapshots and servers that have expired an old UID.
@@ -5,8 +5,15 @@ import Foundation
enum ArchiveSyncRecoveryPolicy {
static let incrementalOverlap: TimeInterval = 60
static let interPageDelayNanoseconds: UInt64 = 100_000_000
static let queryTimeoutNanoseconds: UInt64 = 12_000_000_000
static let pageApplyTimeoutNanoseconds: UInt64 = 8_000_000_000
static let queryTimeoutNanoseconds: UInt64 = 20_000_000_000
static let pageApplyTimeoutNanoseconds: UInt64 = 20_000_000_000
static let pageRetryLimit = 1
static let resumeAfterCaptureDelayNanoseconds: UInt64 = 3_000_000_000
/// Delay before automatically resuming a failed catch-up pass while the
/// app stays connected and foregrounded. Prevents a single slow page from
/// silently leaving history unloaded until the next app activation.
static let retryAfterFailureDelayNanoseconds: UInt64 = 20_000_000_000
/// Consecutive automatic catch-up retries before falling back to
/// resume-on-activation, so a persistently broken server cannot loop.
static let maximumAutomaticRetries = 3
}
+52 -70
View File
@@ -1,25 +1,49 @@
import Foundation
import SwiftData
struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
enum Direction: String, Codable, Sendable {
@Model
final class ChatMessage {
enum Direction: String, Codable {
case incoming
case outgoing
}
enum Delivery: String, Codable, Sendable {
enum Delivery: String, Codable {
case sending
case sent
case delivered
case failed
case read
/// Monotonic promotion used when the same outgoing message is replayed
/// from MAM/MUC-MAM or Carbons: a copy that was already delivered or
/// read locally must never be downgraded by an archive replay carrying
/// `.sent`. `.failed` is sticky in both directions — an archive
/// replay must not silently "fix" a failed send; the user resends it
/// explicitly.
func merged(with other: Delivery) -> Delivery {
guard self != .failed, other != .failed else { return .failed }
return other.rank > self.rank ? other : self
}
private var rank: Int {
switch self {
case .sending: return 0
case .sent: return 1
case .delivered: return 2
case .read: return 3
case .failed: return -1
}
}
}
enum Security: String, Codable, Sendable {
enum Security: String, Codable {
case omemo
case plaintext
case decryptionFailed
}
enum Kind: String, Codable, Sendable {
enum Kind: String, Codable {
case text
case attachment
case photo
@@ -40,7 +64,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
}
}
let id: String
@Attribute(.unique) var clientID: String
var conversationID: String
var senderJID: String
var body: String
@@ -61,12 +85,15 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
var replyPreview: String?
var forwardedFrom: String?
var retractedAt: Date?
var originID: String?
var stanzaID: String?
var senderDisplayName: String?
var isGroupMessage: Bool
var callHistory: CallHistoryMetadata?
var reactions: [MessageReaction]
var conversation: Conversation?
init(
id: String = UUID().uuidString,
conversationID: String,
@@ -89,13 +116,14 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
replyPreview: String? = nil,
forwardedFrom: String? = nil,
retractedAt: Date? = nil,
originID: String? = nil,
stanzaID: String? = nil,
senderDisplayName: String? = nil,
isGroupMessage: Bool = false,
callHistory: CallHistoryMetadata? = nil,
reactions: [MessageReaction] = []
) {
self.id = id
self.clientID = id
self.conversationID = conversationID.lowercased()
self.senderJID = Self.normalizedSenderJID(senderJID)
self.body = body
@@ -116,6 +144,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
self.replyPreview = replyPreview
self.forwardedFrom = forwardedFrom
self.retractedAt = retractedAt
self.originID = originID ?? (direction == .outgoing ? id : nil)
self.stanzaID = stanzaID
self.senderDisplayName = senderDisplayName
self.isGroupMessage = isGroupMessage
@@ -123,71 +152,12 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
self.reactions = reactions
}
private enum CodingKeys: String, CodingKey {
case id
case conversationID
case senderJID
case body
case timestamp
case direction
case delivery
case security
case kind
case remoteAttachmentURL
case localFilename
case mimeType
case duration
case byteCount
case encryptionFingerprint
case editedAt
case replyToID
case replyToJID
case replyPreview
case forwardedFrom
case retractedAt
case stanzaID
case senderDisplayName
case isGroupMessage
case callHistory
case reactions
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
senderJID = Self.normalizedSenderJID(try values.decode(String.self, forKey: .senderJID))
body = try values.decode(String.self, forKey: .body)
timestamp = try values.decode(Date.self, forKey: .timestamp)
direction = try values.decode(Direction.self, forKey: .direction)
delivery = try values.decode(Delivery.self, forKey: .delivery)
security = try values.decode(Security.self, forKey: .security)
kind = try values.decode(Kind.self, forKey: .kind)
remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
encryptionFingerprint = try values.decodeIfPresent(String.self, forKey: .encryptionFingerprint)
editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID).map(Self.normalizedSenderJID)
replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
}
var canBeEdited: Bool {
!isRetracted
&& !isGroupMessage
&& direction == .outgoing
&& kind == .text
&& (delivery == .sent || delivery == .delivered)
&& (delivery == .sent || delivery == .delivered || delivery == .read)
}
var isRetracted: Bool {
@@ -209,17 +179,18 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
&& !isGroupMessage
&& !isRetracted
&& kind != .system
&& (delivery == .sent || delivery == .delivered)
&& (delivery == .sent || delivery == .delivered || delivery == .read)
}
var replyIdentifier: String? {
isGroupMessage ? stanzaID : id
isGroupMessage ? stanzaID : clientID
}
var quotePreview: String {
if isRetracted { return "Сообщение удалено" }
let value = kind == .text ? body : previewText
let normalized = value
let normalized =
value
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized.count > 140 else { return normalized }
@@ -316,4 +287,15 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
let bare = value[..<slash].lowercased()
return bare + String(value[slash...])
}
var hasText: Bool {
// 1. Удалённые сообщения не копируем
guard !isRetracted else { return false }
// 2. Исключаем системные уведомления
guard kind != .system else { return false }
// 3. Текст считается существующим, если body не пустая строка (с обрезкой пробелов)
return !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
@@ -0,0 +1,82 @@
import Foundation
import SwiftUI
/// Timing and geometry for the Messages-style chat animations.
///
/// Two effects live here: the staggered rise of the first visible page when a
/// conversation opens, and the transition a freshly appended row uses while the
/// user already watches the timeline (an outgoing bubble launches from the
/// composer's send control, an incoming one springs from its own edge).
enum ChatMessageEntrancePolicy {
/// A row waits this long behind every newer row below it, so the visible
/// page unfolds bottom-up the way Messages does.
static let entranceStagger: TimeInterval = 0.035
/// Rows further from the bottom than this are left alone: past a screenful
/// the cascade is invisible and the row may only appear on a later scroll.
static let entranceCascadeLimit = 12
static let entranceRise: CGFloat = 16
static let entranceScale: CGFloat = 0.94
static let sendScale: CGFloat = 0.2
/// Launch point sits next to the composer's send control so the bubble
/// reads as thrown out of the field instead of fading in place.
static let sendRise: CGFloat = 12
static let receiveScale: CGFloat = 0.4
static let receiveRise: CGFloat = 10
/// Upper bound of the spring used by both effects. The owning view keeps a
/// row's entrance state alive for this long so a recycled row that reappears
/// while scrolling plays nothing.
static let entranceAnimationDuration: TimeInterval = 0.75
/// A burst larger than this is a history page arriving, not a conversation:
/// animating every row of a page would flash the whole timeline.
static let appendAnimationLimit = 4
/// Total time the opening cascade can still be pending for the last row.
static var entranceCascadeDuration: TimeInterval {
TimeInterval(entranceCascadeLimit - 1) * entranceStagger
+ entranceAnimationDuration
}
static var appendAnimation: Animation {
.spring(response: 0.44, dampingFraction: 0.68)
}
/// Per-row delays keyed by client ID. The newest row leads the cascade and
/// every older row follows one stagger step behind it; rows outside the
/// cascade get no delay and therefore no entrance at all.
static func entranceDelays(forIDs ids: [String]) -> [String: TimeInterval] {
let last = ids.count - 1
guard last >= 0 else { return [:] }
let firstAnimated = max(0, ids.count - entranceCascadeLimit)
var delays: [String: TimeInterval] = [:]
delays.reserveCapacity(ids.count - firstAnimated)
for index in firstAnimated...last {
delays[ids[index]] = TimeInterval(last - index) * entranceStagger
}
return delays
}
/// Rows a query refresh added below everything already on screen.
///
/// A MAM page paged in above the viewport is deliberately ignored: replaying
/// the entrance mid-scroll would yank the timeline the user is reading. A
/// burst of rows is treated as the same kind of archive delivery.
static func appendedLaunchIDs(
in ids: [String],
after previous: Set<String>
) -> [String] {
guard !previous.isEmpty else { return [] }
guard let lastKnownIndex = ids.lastIndex(where: previous.contains)
else { return [] }
let appended = ids.dropFirst(lastKnownIndex + 1).filter {
!previous.contains($0)
}
guard appended.count <= appendAnimationLimit else { return [] }
return Array(appended)
}
}
+12 -2
View File
@@ -3,11 +3,21 @@ import Foundation
/// A cached presentation row for the selected chat. Building day boundaries
/// once per message mutation avoids walking the full history during every
/// scroll-position or composer-state update.
struct ChatTimelineEntry: Identifiable, Hashable, Sendable {
struct ChatTimelineEntry: Identifiable, Hashable {
let message: ChatMessage
let startsNewDay: Bool
var id: String { message.id }
var id: String { message.clientID }
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message.clientID == rhs.message.clientID
&& lhs.startsNewDay == rhs.startsNewDay
}
func hash(into hasher: inout Hasher) {
hasher.combine(message.clientID)
hasher.combine(startsNewDay)
}
static func make(
from messages: [ChatMessage],
+17 -45
View File
@@ -1,13 +1,14 @@
import Foundation
import SwiftData
struct Conversation: Codable, Identifiable, Hashable, Sendable {
enum Kind: String, Codable, Sendable {
@Model
final class Conversation {
enum Kind: String, Codable {
case direct
case group
}
let id: String
var jid: String
@Attribute(.unique) var jid: String
var displayName: String
var lastMessage: String
var lastActivity: Date
@@ -22,6 +23,9 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
var occupantCount: Int
var invitedBy: String?
@Relationship(deleteRule: .cascade, inverse: \ChatMessage.conversation)
var messages: [ChatMessage] = []
init(
jid: String,
displayName: String? = nil,
@@ -39,7 +43,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
invitedBy: String? = nil
) {
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self.id = normalized
self.jid = normalized
self.displayName = displayName?.nilIfBlank ?? normalized
self.lastMessage = lastMessage
@@ -56,46 +59,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
self.invitedBy = invitedBy?.nilIfBlank
}
private enum CodingKeys: String, CodingKey {
case id
case jid
case displayName
case lastMessage
case lastActivity
case unreadCount
case isOnline
case isPinned
case encryptionPreference
case kind
case groupNickname
case isGroupJoined
case shouldAutojoin
case occupantCount
case invitedBy
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
jid = try values.decode(String.self, forKey: .jid)
id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
displayName = try values.decode(String.self, forKey: .displayName)
lastMessage = try values.decode(String.self, forKey: .lastMessage)
lastActivity = try values.decode(Date.self, forKey: .lastActivity)
unreadCount = try values.decode(Int.self, forKey: .unreadCount)
isOnline = try values.decode(Bool.self, forKey: .isOnline)
isPinned = try values.decode(Bool.self, forKey: .isPinned)
encryptionPreference = try values.decodeIfPresent(
EncryptionPreference.self,
forKey: .encryptionPreference
) ?? .inheritGlobal
kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
}
var isGroup: Bool { kind == .group }
var initials: String {
@@ -115,6 +78,15 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
}
}
/// A row in the cross-account inbox. JID alone is not a stable identity when
/// two logged-in accounts have the same contact or a self-chat.
struct AccountConversation: Identifiable {
let account: AccountConfiguration
let conversation: Conversation
var id: String { "\(account.normalizedJID)|\(conversation.jid)" }
}
private extension String {
var nilIfBlank: String? {
trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
@@ -0,0 +1,30 @@
import Foundation
/// Stable, per-installation XMPP resource. Two devices that both fall back to
/// the default must never share a resource: an XMPP server resolves a resource
/// collision with a `conflict` stream error, which disconnects the older
/// session. A UUID persisted in UserDefaults keeps the value stable across
/// launches (so presence and stream-management resume stay consistent) while
/// remaining distinct between macOS and iOS installs.
enum DeviceResource {
/// The legacy hard-coded default that older Luma builds sent from every
/// device. Treated as "automatic" so existing accounts migrate without the
/// user having to re-enter credentials.
static let legacyDefault = "Luma"
private static let storageKey = "app.luma.xmpp.resource"
static var `default`: String {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: storageKey), !existing.isEmpty {
return existing
}
let suffix = UUID().uuidString
.replacingOccurrences(of: "-", with: "")
.prefix(8)
.lowercased()
let resource = "Luma-\(suffix)"
defaults.set(resource, forKey: storageKey)
return resource
}
}
+200
View File
@@ -0,0 +1,200 @@
import Foundation
/// A category of emojis shown in the emoji picker.
struct EmojiCategory: Identifiable, Equatable, Sendable {
let title: String
let symbol: String
let emojis: [String]
var id: String { title }
}
/// Curated, widely-supported emoji set grouped into categories. Used by both
/// the reaction picker and the composer's emoji picker.
enum EmojiCatalog {
static let categories: [EmojiCategory] = [
EmojiCategory(
title: "Смайлы",
symbol: "face.smiling",
emojis: [
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🥲", "☺️",
"😊", "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", "😗",
"😙", "😚", "😋", "😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓",
"😎", "🥸", "🤩", "🥳", "😏", "😒", "😞", "😔", "😟", "😕",
"🙁", "☹️", "😣", "😖", "😫", "😩", "🥺", "😢", "😭", "😤",
"😠", "😡", "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰",
"😥", "😓", "🤗", "🤔", "🫣", "🤭", "🫢", "🤫", "🤥", "😶",
"😐", "😑", "😬", "🙄", "😯", "😦", "😧", "😮", "😲", "🥱",
"😴", "🤤", "😪", "😵", "🤐", "🥴", "🤢", "🤮", "🤧", "😷",
"🤒", "🤕", "🤑", "🤠", "😈", "👿", "👹", "👺", "🤡", "💩",
"👻", "💀", "☠️", "👽", "👾", "🤖", "🎃", "😺", "😸", "😹",
"😻", "😼", "😽", "🙀", "😿", "😾", "❤️", "🧡", "💛", "💚",
"💙", "💜", "🖤", "🤍", "🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞",
"💓", "💗", "💖", "💘", "💝", "💟", "❣️", "💌", "💢", "💥",
"💫", "💦", "💨", "🕳️", "💬", "💭", "🗯️",
]
),
EmojiCategory(
title: "Жесты",
symbol: "hand.raised",
emojis: [
"👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞",
"🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️",
"👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "🫶", "👐",
"🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦵", "🦶",
"👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁️", "👅", "👄",
"👶", "🧒", "👦", "👧", "🧑", "👱", "👨", "🧔", "👩", "🧓",
"👴", "👵", "🙍", "🙎", "🙅", "🙆", "💁", "🙋", "🧏", "🙇",
"🤦", "🤷", "💆", "💇", "🚶", "🧍", "🧎", "🏃", "💃", "🕺",
"🧖", "🧗", "🤺", "🏇", "⛷️", "🏂", "🏌️", "🏄", "🚣", "🏊",
"⛹️", "🏋️", "🚴", "🚵", "🤸", "🤼", "🤽", "🤾", "🤹", "🧘",
"🛀", "🛌", "🧑‍🤝‍🧑", "👭", "👫", "👬", "💏", "💑", "👪",
]
),
EmojiCategory(
title: "Животные и природа",
symbol: "leaf",
emojis: [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐻‍❄️", "🐨",
"🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", "🙉", "🙊", "🐔",
"🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺",
"🐗", "🐴", "🦄", "🐝", "🪱", "🐛", "🦋", "🐌", "🐞", "🐜",
"🪰", "🪲", "🪳", "🦟", "🦗", "🕷️", "🕸️", "🦂", "🐢", "🐍",
"🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠",
"🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍",
"🦧", "🦣", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🦬",
"🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌",
"🐕", "🐩", "🦮", "🐈", "🐈‍⬛", "🪶", "🐓", "🦃", "🦤", "🦚",
"🦜", "🦢", "🦩", "🕊️", "🐇", "🦝", "🦨", "🦡", "🦫", "🦦",
"🦥", "🐁", "🐀", "🐿️", "🦔", "🐾", "🐉", "🐲", "🌵", "🎄",
"🌲", "🌳", "🌴", "🌱", "🌿", "☘️", "🍀", "🎍", "🎋", "🍃",
"🍂", "🍁", "🍄", "🐚", "🌾", "💐", "🌷", "🌹", "🥀", "🌺",
"🌸", "🌼", "🌻", "🌞", "🌝", "🌛", "🌜", "🌚", "🌕", "🌖",
"🌗", "🌘", "🌑", "🌒", "🌓", "🌔", "🌙", "🌎", "🌍", "🌏",
"💫", "⭐", "🌟", "✨", "⚡", "☄️", "💥", "🔥", "🌪️", "🌈",
"☀️", "🌤️", "⛅", "🌥️", "☁️", "🌦️", "🌧️", "⛈️", "🌩️", "🌨️",
"❄️", "☃️", "⛄", "🌬️", "💨", "💧", "💦", "☔", "☂️", "🌊", "🌫️",
]
),
EmojiCategory(
title: "Еда и напитки",
symbol: "fork.knife",
emojis: [
"🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐",
"🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑",
"🥦", "🥬", "🥒", "🌶️", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅",
"🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳",
"🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔",
"🍟", "🍕", "🫓", "🥪", "🥙", "🧆", "🌮", "🌯", "🫔", "🥗",
"🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟",
"🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡",
"🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬",
"🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "🫖",
"☕", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷",
"🥃", "🍸", "🍹", "🧉", "🍾", "🧊", "🥄", "🍴", "🍽️", "🥣",
"🥡", "🥢",
]
),
EmojiCategory(
title: "Активности",
symbol: "soccerball",
emojis: [
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱",
"🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳",
"🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛼", "🛷",
"⛸️", "🥌", "🎿", "⛷️", "🏂", "🪂", "🏋️", "🤼", "🤸", "⛹️",
"🤺", "🤾", "🏌️", "🏇", "🧘", "🏄", "🏊", "🤽", "🚣", "🧗",
"🚵", "🚴", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖️", "🏵️", "🎗️",
"🎫", "🎟️", "🎪", "🤹", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧",
"🎼", "🎹", "🥁", "🪘", "🎷", "🎺", "🪗", "🎸", "🪕", "🎻",
"🎲", "♟️", "🎯", "🎳", "🎮", "🎰", "🧩",
]
),
EmojiCategory(
title: "Путешествия",
symbol: "airplane",
emojis: [
"🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐",
"🛻", "🚚", "🚛", "🚜", "🛵", "🏍️", "🛺", "🚲", "🛴", "🛹",
"🚨", "🚔", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🚃", "🚋",
"🚞", "🚝", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚉",
"✈️", "🛫", "🛬", "🛩️", "💺", "🛰️", "🚀", "🛸", "🚁", "🛶",
"⛵", "🚤", "🛥️", "🛳️", "⛴️", "🚢", "⚓", "🪝", "🚧", "🚦",
"🚥", "🚏", "🗺️", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟️", "🎡",
"🎢", "🎠", "⛲", "⛱️", "🏖️", "🏝️", "🏜️", "🌋", "⛰️", "🏔️",
"🗻", "🏕️", "⛺", "🛖", "🏠", "🏡", "🏘️", "🏚️", "🏗️", "🏭",
"🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩",
"💒", "🏛️", "⛪", "🕌", "🕍", "🛕", "🕋", "⛩️", "🛤️", "🛣️",
"🗾", "🎑", "🏞️", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆",
"🏙️", "🌃", "🌌", "🌉", "🌁",
]
),
EmojiCategory(
title: "Предметы",
symbol: "lightbulb",
emojis: [
"⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", "🖲️", "🕹️",
"🗜️", "💽", "💾", "💿", "📀", "📼", "📷", "📸", "📹", "🎥",
"📽️", "🎞️", "📞", "☎️", "📟", "📠", "📺", "📻", "🎙️", "🎚️",
"🎛️", "🧭", "⏱️", "⏲️", "⏰", "🕰️", "⌛", "⏳", "📡", "🔋",
"🔌", "💡", "🔦", "🕯️", "🪔", "🧯", "🛢️", "💸", "💵", "💴",
"💶", "💷", "🪙", "💰", "💳", "💎", "⚖️", "🪜", "🧰", "🪛",
"🔧", "🔨", "⚒️", "🛠️", "⛏️", "🪚", "🔩", "⚙️", "🪤", "🧱",
"⛓️", "🧲", "🔫", "💣", "🧨", "🪓", "🔪", "🗡️", "⚔️", "🛡️",
"🚬", "⚰️", "🪦", "⚱️", "🏺", "🔮", "📿", "🧿", "💈", "⚗️",
"🔭", "🔬", "🕳️", "🩹", "🩺", "💊", "💉", "🩸", "🧬", "🦠",
"🧫", "🧪", "🌡️", "🧹", "🪠", "🧺", "🧻", "🚽", "🚰", "🚿",
"🛁", "🛀", "🧼", "🪥", "🪒", "🧽", "🪣", "🧴", "🛎️", "🔑",
"🗝️", "🚪", "🪑", "🛋️", "🛏️", "🛌", "🧸", "🪆", "🖼️", "🪞",
"🪟", "🛍️", "🛒", "🎁", "🎈", "🎏", "🎀", "🪄", "🪅", "🎊",
"🎉", "🎎", "🏮", "🎐", "🧧", "✉️", "📩", "📨", "📧", "💌",
"📥", "📤", "📦", "🏷️", "🪧", "📪", "📫", "📬", "📭", "📮",
"📯", "📜", "📃", "📄", "📑", "🧾", "📊", "📈", "📉", "🗒️",
"🗓️", "📆", "📅", "🗑️", "📇", "🗃️", "🗳️", "🗄️", "📋", "📁",
"📂", "🗂️", "🗞️", "📰", "📓", "📔", "📒", "📕", "📗", "📘",
"📙", "📚", "📖", "🔖", "🧷", "🔗", "📎", "🖇️", "📐", "📏",
"🧮", "📌", "📍", "✂️", "🖊️", "🖋️", "✒️", "🖌️", "🖍️", "📝",
"✏️", "🔍", "🔎", "🔏", "🔐", "🔒", "🔓",
]
),
EmojiCategory(
title: "Символы",
symbol: "heart",
emojis: [
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔",
"❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "♥️",
"♠️", "♣️", "♦️", "🃏", "🀄", "🎴", "🔇", "🔈", "🔉", "🔊",
"📢", "📣", "📯", "🔔", "🔕", "🎼", "🎵", "🎶", "🎙️", "🎚️",
"🎛️", "🎤", "🎧", "📻", "🎷", "🪗", "🎸", "🎹", "🎺", "🎻",
"🥁", "🪘", "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌",
"💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "💽", "💾", "💿", "📀",
"🧮", "🎥", "🎞️", "📽️", "🎬", "📺", "📷", "📸", "📹", "📼",
"🔍", "🔎", "🕯️", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖",
"📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰",
"🗞️", "📑", "🔖", "🏷️", "💰", "🪙", "💴", "💵", "💶", "💷",
"💸", "💳", "🧾", "✉️", "📧", "📨", "📩", "📤", "📥", "📦",
"📫", "📪", "📬", "📭", "📮", "🗳️", "✏️", "✒️", "🖋️", "🖊️",
"🖌️", "🖍️", "📝", "💼", "📁", "📂", "🗂️", "📅", "📆", "🗒️",
"🗓️", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇️",
"📏", "📐", "✂️", "🗃️", "🗄️", "🗑️", "🔒", "🔓", "🔏", "🔐",
"🔑", "🗝️", "🔨", "🪓", "⛏️", "⚒️", "🛠️", "🗡️", "⚔️", "🔫",
"🪃", "🏹", "🛡️", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️",
"🦯", "🔗", "⛓️", "🪝", "🧰", "🧲", "🪜", "⚗️", "🧪", "🧫",
"🧬", "🔬", "🔭", "📡", "💉", "🩸", "💊", "🩹", "🩺", "🚪",
"🛏️", "🛋️", "🪑", "🚽", "🪠", "🚿", "🛁", "🪒", "🧴", "🧷",
"🧹", "🧺", "🧻", "🧼", "🪥", "🧽", "🧯", "🛒", "🚬", "⚰️",
"🪦", "⚱️", "🗿", "🪧", "🪪", "🆗", "🆕", "🆒", "🆓", "🆙",
"🆖", "🆚", "🈁", "🈂️", "🈷️", "🈶", "🈯", "🉐", "🈹", "🈚",
"🈲", "🉑", "🈸", "🈴", "🈳", "㊗️", "㊙️", "🈺", "🈵", "🔴",
"🟠", "🟡", "🟢", "🔵", "🟣", "🟤", "⚫", "⚪", "🟥", "🟧",
"🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜", "◼️", "◻️", "◾",
"◽", "▪️", "▫️", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "💠",
"🔘", "🔳", "🔲",
]
),
]
static var allEmojis: [String] {
categories.flatMap(\.emojis)
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ enum MessageReactionPolicy {
extension ChatMessage {
var reactionIdentifier: String? {
isGroupMessage ? stanzaID : id
isGroupMessage ? stanzaID : clientID
}
var canBeReactedTo: Bool {
@@ -2,30 +2,62 @@ import CoreGraphics
struct MessageReplySwipePolicy {
static let triggerDistance: CGFloat = 64
/// Horizontal travel required before the indicator starts following the
/// finger; keeps per-cell state untouched during ordinary scrolling.
static let activationDistance: CGFloat = 24
static let maximumOffset: CGFloat = 76
static let horizontalDominance: CGFloat = 1.4
/// Ratio required to lock the gesture into a reply swipe. Mostly vertical
/// timeline scrolls (even diagonal ones) never reach it.
static let lockDominance: CGFloat = 1.6
/// Relaxed ratio once locked, so the finger can drift while finishing
/// the swipe without it being cancelled by the scroll view.
static let followDominance: CGFloat = 1.05
static func offset(for translation: CGSize) -> CGFloat {
guard isHorizontal(translation) else { return 0 }
let distance = min(abs(translation.width) * 0.72, maximumOffset)
/// Whether this translation can lock the gesture as a horizontal swipe.
/// Both directions are accepted, and the lock is only available early:
/// once the finger has travelled mostly vertically the scroll view owns
/// the touch and the swipe must never activate for the rest of that
/// gesture.
static func canLock(_ translation: CGSize) -> Bool {
abs(translation.height) <= 24
&& isHorizontal(translation, dominance: lockDominance)
&& abs(translation.width) >= activationDistance * 0.5
}
static func isHorizontal(_ translation: CGSize) -> Bool {
isHorizontal(translation, dominance: lockDominance)
}
/// Visual offset of the bubble: negative for a left swipe, positive for
/// a right swipe, zero while the movement is not clearly horizontal.
static func offset(locked: Bool, translation: CGSize) -> CGFloat {
let dominance = locked ? followDominance : lockDominance
guard isHorizontal(translation, dominance: dominance) else { return 0 }
let width = abs(translation.width)
guard width >= activationDistance else { return 0 }
let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset)
return translation.width < 0 ? -distance : distance
}
static func shouldReply(
locked: Bool,
translation: CGSize,
predictedEndTranslation: CGSize
) -> Bool {
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
? predictedEndTranslation
: translation
return isHorizontal(candidate) && abs(candidate.width) >= triggerDistance
let dominance = locked ? followDominance : lockDominance
return isHorizontal(candidate, dominance: dominance)
&& abs(candidate.width) >= triggerDistance
}
static func progress(for offset: CGFloat) -> CGFloat {
min(1, abs(offset) / triggerDistance)
}
private static func isHorizontal(_ translation: CGSize) -> Bool {
abs(translation.width) > abs(translation.height) * horizontalDominance
private static func isHorizontal(_ translation: CGSize, dominance: CGFloat) -> Bool {
abs(translation.width) > abs(translation.height) * dominance
}
}
+7 -1
View File
@@ -14,8 +14,14 @@ struct OMEMODevice: Identifiable, Hashable, Sendable {
let trust: Trust
let isActive: Bool
let isOwn: Bool
let isOMEMO2: Bool
var id: String { "\(jid)|\(deviceID)" }
var id: String { "\(jid)|\(deviceID)|\(isOMEMO2 ? "omemo2" : "legacy")" }
/// Short protocol label for the encryption screen badge.
var protocolName: String {
isOMEMO2 ? "OMEMO 2" : "OMEMO"
}
var formattedFingerprint: String {
fingerprint.chunked(every: 8).joined(separator: " ")
@@ -0,0 +1,30 @@
import Foundation
/// One interactive backward-history page handed to the UI: whether older
/// history remains and the cursor the next request must use.
struct OlderHistoryScrollPage: Equatable, Sendable {
let hasMore: Bool
let nextBefore: String?
}
/// Interactive scroll-back policy. The cursor for the next page must come
/// from the server's RSM <first> of the page that was just returned, not from
/// the oldest local message: a page can consist entirely of reactions,
/// retractions or already-known stanzas, which insert no new message rows, so
/// a locally computed anchor would never advance and every subsequent scroll
/// would re-request the same page forever (spinner spins, nothing loads).
enum OlderHistoryScrollPolicy {
/// - Parameter complete: the server's <fin complete="true"> flag.
/// - Parameter pageFirstID: RSM <first> UID of the returned page — the
/// oldest item in it, i.e. the anchor for the next, older page.
static func page(
complete: Bool,
pageFirstID: String?
) -> OlderHistoryScrollPage {
let nextBefore = ArchiveSyncCheckpoint.normalizedCursor(pageFirstID)
// An empty page (or one without RSM) cannot advance the cursor, so it
// must never report "more" — otherwise the UI would loop on it.
let hasMore = !complete && nextBefore != nil
return OlderHistoryScrollPage(hasMore: hasMore, nextBefore: nextBefore)
}
}
@@ -0,0 +1,49 @@
import Foundation
/// Order in which Luma prefers SASL mechanisms during authentication.
/// Channel-binding SCRAM first (tls-exporter / tls-server-end-point prove
/// the exchange runs over the client's own TLS channel, defeating MITM
/// relays), then the strongest plain SCRAM variant, and PLAIN as the
/// last-resort fallback. The server must advertise a mechanism for it to be
/// eligible — Martin's `SaslModule.guessSaslMechanism` walks exactly this
/// order, and the PLUS mechanisms additionally gate on available binding
/// data in `isAllowedToUse`.
enum SASLMechanismPreference {
static let order: [String] = [
"SCRAM-SHA-512-PLUS",
"SCRAM-SHA-256-PLUS",
"SCRAM-SHA-1-PLUS",
"SCRAM-SHA-512",
"SCRAM-SHA-256",
"SCRAM-SHA-1",
"PLAIN",
]
/// The most secure mechanism from `order` that the server advertises,
/// or nil when no password-based mechanism is offered. When
/// `allowsChannelBinding` is false the -PLUS variants are skipped, since
/// without binding data the exchange would fail.
static func selected(
among offered: [String],
allowsChannelBinding: Bool = true
) -> String? {
order.first { candidate in
guard offered.contains(candidate) else { return false }
return allowsChannelBinding || !candidate.hasSuffix("-PLUS")
}
}
/// Mechanisms that send credentials in a way a TLS MITM can replay or
/// read directly; shown with a warning on the server-information screen.
static let weakMechanisms: Set<String> = ["PLAIN", "OAUTHBEARER"]
/// True when `mechanism` is the strongest one available among
/// `offered`: the server offers nothing better than the chosen one.
static func isStrongestAvailable(_ mechanism: String, among offered: [String]) -> Bool {
guard let chosenRank = order.firstIndex(of: mechanism) else { return false }
for stronger in order[..<chosenRank] where offered.contains(stronger) {
return false
}
return offered.contains(mechanism)
}
}
@@ -0,0 +1,180 @@
import Foundation
/// Snapshot of everything the "server details" screen shows, modelled after
/// Monal's `ServerDetails`: server software (XEP-0092), disco#info identities
/// and features (XEP-0030), conference (MUC) services, STUN/TURN services
/// (XEP-0215) and a few connection-level capability flags.
struct ServerInformation: Equatable, Sendable {
enum CapabilityStatus: Equatable, Sendable {
case success, normal, warning, error
}
struct Software: Equatable, Sendable {
let name: String?
let version: String?
let os: String?
}
struct Identity: Equatable, Sendable, Identifiable {
let category: String
let type: String
let name: String?
var id: String { "\(category)/\(type)" }
var title: String { "\(category) / \(type)" }
}
struct ConferenceServer: Equatable, Sendable, Identifiable {
let jid: String
let name: String?
let type: String
let category: String
var id: String { jid }
}
struct ExternalService: Equatable, Sendable, Identifiable {
let type: String
let host: String
let port: UInt16?
let transport: String?
var id: String { "\(type)|\(host)|\(port.map(String.init) ?? "")" }
}
struct ConnectionStats: Equatable, Sendable {
let lastLogin: Date?
let smacksSessionEstablished: Date?
let sent: UInt32
let acknowledged: UInt32
let received: UInt32
var unacknowledged: UInt32 {
sent > acknowledged ? sent - acknowledged : 0
}
}
struct Capability: Equatable, Sendable, Identifiable {
let title: String
let detail: String
let status: CapabilityStatus
var id: String { title }
}
let serverDomain: String
let software: Software
let identities: [Identity]
let serverFeatures: [String]
let accountFeatures: [String]
let conferenceServers: [ConferenceServer]
let externalServices: [ExternalService]
let connectionStats: ConnectionStats
let saslMethods: [String]
/// SASL2 (RFC 9050) mechanisms advertised in `<authentication/>`.
let sasl2Methods: [String]
/// XEP-0440 channel-binding types advertised by the server, used to
/// detect MITM attacks on the TLS layer during SCRAM-PLUS.
let channelBindingTypes: [String]
/// The channel-binding type actually used for this connection's SASL
/// exchange, when a -PLUS mechanism was selected.
let usedChannelBindingType: String?
/// True when the server included its XEP-0474 downgrade-protection
/// hash in the SCRAM exchange (`h` attribute of the server-first message).
let supportsSCRAMDowngradeProtection: Bool
/// Raw name of the SASL mechanism used for this connection's auth
/// (without the channel-binding suffix), highlighted in the method lists.
let usedSASLMechanism: String?
/// TLS version negotiated with the server during this connection
/// (probed from a parallel handshake when the library does not expose it).
let tlsVersion: String?
/// Cipher suite negotiated alongside the TLS version.
let tlsCipher: String?
/// SASL mechanism actually selected for authentication (the strongest
/// one the server advertises).
let saslMechanism: String?
let supportsStreamManagement: Bool
let supportsCarbons: Bool
let supportsClientState: Bool
let supportsHTTPUpload: Bool
let supportsRosterVersioning: Bool
let supportsRosterPreApproval: Bool
/// The curated XEP list Monal shows, with a status derived from the server
/// and account disco features plus the connection-level flags.
var capabilities: [Capability] {
let server = Set(serverFeatures)
let account = Set(accountFeatures)
let supportsPubSub = account.contains { $0.hasPrefix("http://jabber.org/protocol/pubsub") }
return [
Capability(
title: "XEP-0163 Personal Eventing Protocol",
detail: "Публикация событий состояния аккаунта (аватары, статусы) через publish-subscribe.",
status: supportsPubSub ? .success : .error
),
Capability(
title: "XEP-0191 Blocking Command",
detail: "Расширение XMPP для блокировки контактов.",
status: server.contains("urn:xmpp:blocking") ? .success : .error
),
Capability(
title: "XEP-0198 Stream Management",
detail: "Возобновление потока при обрыве соединения — быстрее переподключение и экономия заряда.",
status: supportsStreamManagement ? .success : .error
),
Capability(
title: "XEP-0199 XMPP Ping",
detail: "Пинг уровня приложения поверх XML-потока.",
status: server.contains("urn:xmpp:ping") ? .success : .error
),
Capability(
title: "XEP-0215 External Service Discovery",
detail: "Обнаружение внешних сервисов (STUN/TURN) для аудио- и видеозвонков.",
status: server.contains("urn:xmpp:extdisco:2") ? .success : .error
),
Capability(
title: "XEP-0237 Roster Versioning",
detail: "Версионирование ростера, чтобы сервер не отправлял его повторно, если он не менялся.",
status: supportsRosterVersioning ? .success : .error
),
Capability(
title: "XEP-0280 Message Carbons",
detail: "Синхронизация сообщений между всеми вашими устройствами.",
status: supportsCarbons ? .success : .error
),
Capability(
title: "XEP-0313 Message Archive Management",
detail: "Доступ к архиву сообщений на сервере.",
status: account.contains("urn:xmpp:mam:2") ? .success : .error
),
Capability(
title: "XEP-0352 Client State Indication",
detail: "Указывает, активно ли устройство. Экономит заряд.",
status: supportsClientState ? .success : .error
),
Capability(
title: "XEP-0357 Push Notifications",
detail: "Push-уведомления через Apple даже когда приложение выгружено.",
status: account.contains("urn:xmpp:push:0") ? .success : .error
),
Capability(
title: "XEP-0363 HTTP File Upload",
detail: "Загрузка файлов на сервер для обмена с собеседниками.",
status: supportsHTTPUpload ? .success : .error
),
Capability(
title: "XEP-0379 Pre-Authenticated Roster Subscription",
detail: "Ссылки для предварительно аутентифицированной подписки на присутствие.",
status: supportsRosterPreApproval ? .success : .error
),
Capability(
title: "XEP-0474 SASL SCRAM Downgrade Protection",
detail: supportsSCRAMDowngradeProtection
? "Сервер включил downgrade-protection hash в SCRAM-обмен (атрибут h): списки SASL-механизмов и channel-binding защищены от подмены MITM."
: "Сервер не включил downgrade-protection hash в SCRAM-обмен (или вход выполнен через PLAIN): списки механизмов и channel-binding не защищены от подмены.",
status: supportsSCRAMDowngradeProtection ? .success : .error
),
]
}
}
@@ -0,0 +1,48 @@
import CoreGraphics
#if os(iOS)
import UIKit
#endif
/// Clockwise rotation, in degrees, applied to the video-note capture so the
/// recorded QuickTime track plays upright. The camera sensor is
/// landscape-native: per Apple's videoOrientation<->videoRotationAngle
/// equivalence a portrait recording needs a 90-degree clockwise rotation
/// (a QuickTime track matrix), never 0 — 0 leaves the circle sideways.
enum VideoNoteRotationPolicy {
enum InterfaceOrientation: Equatable, Sendable {
case portrait
case portraitUpsideDown
case landscapeLeft
case landscapeRight
}
static func angle(for orientation: InterfaceOrientation) -> CGFloat {
switch orientation {
case .portrait: return 90
case .portraitUpsideDown: return 270
case .landscapeLeft: return 180
case .landscapeRight: return 0
}
}
#if os(iOS)
/// The orientation of the interface the user actually sees. The device
/// sensor (UIDevice.current.orientation) can report landscape or
/// face-up while the app stays portrait, which is exactly how a
/// portrait circle ended up encoded sideways.
@MainActor
static var currentInterfaceOrientation: InterfaceOrientation {
let orientation = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }?
.effectiveGeometry.interfaceOrientation
switch orientation {
case .portraitUpsideDown: return .portraitUpsideDown
case .landscapeLeft: return .landscapeLeft
case .landscapeRight: return .landscapeRight
default: return .portrait
}
}
#endif
}
@@ -3,6 +3,7 @@ import Foundation
final class AccountPreferences {
private let defaults: UserDefaults
private let accountKey = "luma.active-account.v1"
private let accountsKey = "luma.accounts.v1"
private let encryptionSettingsKey = "luma.encryption-settings.v1"
private let chatStateSettingsKey = "luma.chat-state-settings.v1"
@@ -15,6 +16,39 @@ final class AccountPreferences {
return try? JSONDecoder().decode(AccountConfiguration.self, from: data)
}
/// Returns every configured account. The original single-account key is
/// migrated lazily so existing installations keep working.
func loadAccounts() -> [AccountConfiguration] {
if let data = defaults.data(forKey: accountsKey),
let accounts = try? JSONDecoder().decode([AccountConfiguration].self, from: data) {
return accounts
}
guard let account = load() else { return [] }
return [account]
}
func saveAccounts(_ accounts: [AccountConfiguration]) throws {
let normalized = accounts.reduce(into: [String: AccountConfiguration]()) { result, account in
result[account.normalizedJID] = account
}.values.sorted { $0.normalizedJID < $1.normalizedJID }
defaults.set(try JSONEncoder().encode(normalized), forKey: accountsKey)
if let active = load(), normalized.contains(where: { $0.normalizedJID == active.normalizedJID }) {
try save(active)
} else if let first = normalized.first {
try save(first)
} else {
clear()
}
}
func add(_ account: AccountConfiguration) throws {
try saveAccounts(loadAccounts() + [account])
}
func remove(jid: String) throws {
try saveAccounts(loadAccounts().filter { $0.normalizedJID != jid.lowercased() })
}
func save(_ account: AccountConfiguration) throws {
let data = try JSONEncoder().encode(account)
defaults.set(data, forKey: accountKey)
@@ -0,0 +1,37 @@
import Foundation
import SwiftData
/// Codable representation of a single durable MAM checkpoint, keyed by the
/// archive it belongs to (account or a specific MUC).
struct MAMCheckpointEntry: Codable, Equatable, Sendable {
let key: MAMArchiveKey
let checkpoint: MAMArchiveCheckpoint
}
/// Singleton per-account metadata that used to live in the JSON snapshot:
/// locally deleted message IDs, roster contact JIDs and MAM checkpoints.
@Model
final class ArchiveMetadata {
@Attribute(.unique) var accountJID: String
var locallyDeletedMessageIDs: [String]
var rosterContactJIDs: [String]
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMCheckpointEntry]
init(
accountJID: String,
locallyDeletedMessageIDs: [String] = [],
rosterContactJIDs: [String] = [],
lastSuccessfulMAMSync: Date? = nil,
lastSuccessfulMAMCursor: String? = nil,
mamCheckpoints: [MAMCheckpointEntry] = []
) {
self.accountJID = accountJID
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
self.rosterContactJIDs = rosterContactJIDs
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
self.mamCheckpoints = mamCheckpoints
}
}
@@ -0,0 +1,184 @@
import Foundation
import SwiftData
/// SwiftData-backed replacement for the legacy JSON `ChatArchive`. Owns the
/// per-account `ModelContainer`/`ModelContext`, imports the old JSON snapshot
/// once, and exposes load/save/erase.
@MainActor
final class ArchiveStore {
struct Loaded {
var conversations: [Conversation]
var messages: [ChatMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
}
let container: ModelContainer
let context: ModelContext
private let accountJID: String
private let storeURL: URL
private let legacyJSONURL: URL
private(set) var metadata: ArchiveMetadata?
init(accountJID: String) throws {
self.accountJID = accountJID
let fileManager = FileManager.default
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
let hash = Self.stableHash(accountJID)
storeURL = directory.appendingPathComponent("\(hash).store")
legacyJSONURL = directory.appendingPathComponent("\(hash).json")
let schema = Schema([
Conversation.self,
ChatMessage.self,
ArchiveMetadata.self,
])
let configuration = ModelConfiguration(schema: schema, url: storeURL)
container = try ModelContainer(for: schema, configurations: [configuration])
context = ModelContext(container)
context.autosaveEnabled = false
applyDataProtection()
migrateLegacyJSONIfNeeded()
purgeLocallyDeletedMessages()
}
func load() -> Loaded {
let conversationSort = [SortDescriptor(\Conversation.lastActivity, order: .reverse)]
let conversations = (try? context.fetch(FetchDescriptor<Conversation>(sortBy: conversationSort))) ?? []
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
let metadata = fetchMetadata()
let mamCheckpoints = Dictionary(
metadata.mamCheckpoints.map { ($0.key, $0.checkpoint) },
uniquingKeysWith: { first, _ in first }
)
return Loaded(
conversations: conversations,
messages: messages,
locallyDeletedMessageIDs: Set(metadata.locallyDeletedMessageIDs),
rosterContactJIDs: Set(metadata.rosterContactJIDs),
lastSuccessfulMAMSync: metadata.lastSuccessfulMAMSync,
lastSuccessfulMAMCursor: metadata.lastSuccessfulMAMCursor,
mamCheckpoints: mamCheckpoints
)
}
/// Persists the passed metadata plus all pending model mutations.
func save(
locallyDeletedMessageIDs: Set<String>,
rosterContactJIDs: Set<String>,
lastSuccessfulMAMSync: Date?,
lastSuccessfulMAMCursor: String?,
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
) throws {
let metadata = fetchMetadata()
metadata.locallyDeletedMessageIDs = Array(locallyDeletedMessageIDs)
metadata.rosterContactJIDs = Array(rosterContactJIDs)
metadata.lastSuccessfulMAMSync = lastSuccessfulMAMSync
metadata.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(lastSuccessfulMAMCursor)
metadata.mamCheckpoints = mamCheckpoints.map {
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
}
try context.save()
applyDataProtection()
}
func erase() throws {
try context.delete(model: Conversation.self)
try context.delete(model: ChatMessage.self)
try context.delete(model: ArchiveMetadata.self)
try context.save()
}
private func fetchMetadata() -> ArchiveMetadata {
if let metadata { return metadata }
let existing = try? context.fetch(FetchDescriptor<ArchiveMetadata>()).first
let metadata = existing ?? ArchiveMetadata(accountJID: accountJID)
context.insert(metadata)
self.metadata = metadata
return metadata
}
private func migrateLegacyJSONIfNeeded() {
guard FileManager.default.fileExists(atPath: legacyJSONURL.path) else { return }
guard let imported = try? LegacyArchiveImporter.importIfNeeded(from: legacyJSONURL) else {
return
}
for conversation in imported.conversations {
context.insert(conversation)
}
for message in imported.messages {
context.insert(message)
}
let metadata = fetchMetadata()
metadata.locallyDeletedMessageIDs = imported.locallyDeletedMessageIDs
metadata.rosterContactJIDs = imported.rosterContactJIDs
metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
metadata.mamCheckpoints = imported.mamCheckpoints
do {
try context.save()
} catch {
// The imported rows stay pending in memory so this session keeps
// the history, but the legacy JSON remains on disk as the only
// durable copy until a save actually succeeds.
return
}
applyDataProtection()
try? FileManager.default.removeItem(at: legacyJSONURL)
}
/// Removes rows that older builds only marked as locally deleted (the
/// legacy snapshot filtered them at load time). `@Query`-based views read
/// the store directly, so these rows must physically disappear.
func purgeLocallyDeletedMessages() {
let metadata = fetchMetadata()
let deletedKeys = Set(metadata.locallyDeletedMessageIDs)
guard !deletedKeys.isEmpty else { return }
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
var removedAny = false
for message in messages {
let key = Self.deletionKey(
messageID: message.clientID,
conversationID: message.conversationID
)
guard deletedKeys.contains(key) else { continue }
context.delete(message)
removedAny = true
}
if removedAny {
try? context.save()
applyDataProtection()
}
}
/// Key format shared with `AppModel.localDeletionKey`:
/// `<conversationID>\u{1F}<messageID>`.
static func deletionKey(messageID: String, conversationID: String) -> String {
conversationID.lowercased() + "\u{1F}" + messageID
}
private func applyDataProtection() {
#if os(iOS)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: storeURL.path
)
#endif
}
static func stableHash(_ value: String) -> String {
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
return String(hash, radix: 16, uppercase: false)
}
}
@@ -1,118 +0,0 @@
import Foundation
actor ChatArchive {
struct Snapshot: Codable, Sendable {
static let currentSchemaVersion = 5
var schemaVersion: Int
var conversations: [Conversation]
var messages: [ChatMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
init(
conversations: [Conversation],
messages: [ChatMessage],
locallyDeletedMessageIDs: Set<String> = [],
rosterContactJIDs: Set<String> = [],
lastSuccessfulMAMSync: Date? = nil,
lastSuccessfulMAMCursor: String? = nil
) {
self.schemaVersion = Self.currentSchemaVersion
self.conversations = conversations
self.messages = messages
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
self.rosterContactJIDs = rosterContactJIDs
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
self.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
lastSuccessfulMAMCursor
)
}
private enum CodingKeys: String, CodingKey {
case schemaVersion
case conversations
case messages
case locallyDeletedMessageIDs
case rosterContactJIDs
case lastSuccessfulMAMSync
case lastSuccessfulMAMCursor
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
conversations = try values.decode([Conversation].self, forKey: .conversations)
messages = try values.decode([ChatMessage].self, forKey: .messages)
locallyDeletedMessageIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .locallyDeletedMessageIDs
) ?? []
rosterContactJIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .rosterContactJIDs
) ?? []
lastSuccessfulMAMSync = try values.decodeIfPresent(
Date.self,
forKey: .lastSuccessfulMAMSync
)
lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
)
}
static let empty = Snapshot(conversations: [], messages: [])
}
private let fileURL: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
init(accountJID: String) {
let fileManager = FileManager.default
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .millisecondsSince1970
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
}
func load() -> Snapshot {
guard let data = try? Data(contentsOf: fileURL),
let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
return .empty
}
return snapshot
}
func save(_ snapshot: Snapshot) throws {
let data = try encoder.encode(snapshot)
try data.write(to: fileURL, options: [.atomic])
#if os(iOS)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
#endif
}
func erase() throws {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
try FileManager.default.removeItem(at: fileURL)
}
private static func stableHash(_ value: String) -> String {
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
return String(hash, radix: 16, uppercase: false)
}
}
@@ -0,0 +1,417 @@
import Foundation
/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model`
/// objects once, so existing history survives the migration from the JSON file.
/// Decoding mirrors the tolerant decoders of the removed `ChatArchive`:
/// every field that was ever optional or added later falls back to a default,
/// so snapshots written by older schema versions still import.
enum LegacyArchiveImporter {
struct LegacyConversation: Codable {
enum Kind: String, Codable { case direct, group }
var id: String
var jid: String
var displayName: String
var lastMessage: String
var lastActivity: Date
var unreadCount: Int
var isOnline: Bool
var isPinned: Bool
var encryptionPreference: EncryptionPreference
var kind: Kind
var groupNickname: String?
var isGroupJoined: Bool
var shouldAutojoin: Bool
var occupantCount: Int
var invitedBy: String?
init(
id: String,
jid: String,
displayName: String,
lastMessage: String,
lastActivity: Date,
unreadCount: Int,
isOnline: Bool,
isPinned: Bool,
encryptionPreference: EncryptionPreference,
kind: Kind,
groupNickname: String? = nil,
isGroupJoined: Bool,
shouldAutojoin: Bool,
occupantCount: Int,
invitedBy: String? = nil
) {
self.id = id
self.jid = jid
self.displayName = displayName
self.lastMessage = lastMessage
self.lastActivity = lastActivity
self.unreadCount = unreadCount
self.isOnline = isOnline
self.isPinned = isPinned
self.encryptionPreference = encryptionPreference
self.kind = kind
self.groupNickname = groupNickname
self.isGroupJoined = isGroupJoined
self.shouldAutojoin = shouldAutojoin
self.occupantCount = occupantCount
self.invitedBy = invitedBy
}
private enum CodingKeys: String, CodingKey {
case id
case jid
case displayName
case lastMessage
case lastActivity
case unreadCount
case isOnline
case isPinned
case encryptionPreference
case kind
case groupNickname
case isGroupJoined
case shouldAutojoin
case occupantCount
case invitedBy
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
jid = try values.decode(String.self, forKey: .jid)
id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
displayName = try values.decode(String.self, forKey: .displayName)
lastMessage = try values.decode(String.self, forKey: .lastMessage)
lastActivity = try values.decode(Date.self, forKey: .lastActivity)
unreadCount = try values.decode(Int.self, forKey: .unreadCount)
isOnline = try values.decode(Bool.self, forKey: .isOnline)
isPinned = try values.decode(Bool.self, forKey: .isPinned)
encryptionPreference = try values.decodeIfPresent(
EncryptionPreference.self,
forKey: .encryptionPreference
) ?? .inheritGlobal
kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
}
}
struct LegacyMessage: Codable {
enum Direction: String, Codable { case incoming, outgoing }
enum Delivery: String, Codable { case sending, sent, delivered, failed }
enum Security: String, Codable { case omemo, plaintext, decryptionFailed }
enum Kind: String, Codable {
case text, attachment, photo, video, audio, voice, videoNote, location, system
}
var id: String
var conversationID: String
var senderJID: String
var body: String
var timestamp: Date
var direction: Direction
var delivery: Delivery
var security: Security
var kind: Kind
var remoteAttachmentURL: String?
var localFilename: String?
var mimeType: String?
var duration: TimeInterval?
var byteCount: Int?
var encryptionFingerprint: String?
var editedAt: Date?
var replyToID: String?
var replyToJID: String?
var replyPreview: String?
var forwardedFrom: String?
var retractedAt: Date?
var originID: String?
var stanzaID: String?
var senderDisplayName: String?
var isGroupMessage: Bool
var callHistory: CallHistoryMetadata?
var reactions: [MessageReaction]
init(
id: String,
conversationID: String,
senderJID: String,
body: String,
timestamp: Date,
direction: Direction,
delivery: Delivery,
security: Security,
kind: Kind,
remoteAttachmentURL: String? = nil,
localFilename: String? = nil,
mimeType: String? = nil,
duration: TimeInterval? = nil,
byteCount: Int? = nil,
encryptionFingerprint: String? = nil,
editedAt: Date? = nil,
replyToID: String? = nil,
replyToJID: String? = nil,
replyPreview: String? = nil,
forwardedFrom: String? = nil,
retractedAt: Date? = nil,
originID: String? = nil,
stanzaID: String? = nil,
senderDisplayName: String? = nil,
isGroupMessage: Bool = false,
callHistory: CallHistoryMetadata? = nil,
reactions: [MessageReaction] = []
) {
self.id = id
self.conversationID = conversationID
self.senderJID = senderJID
self.body = body
self.timestamp = timestamp
self.direction = direction
self.delivery = delivery
self.security = security
self.kind = kind
self.remoteAttachmentURL = remoteAttachmentURL
self.localFilename = localFilename
self.mimeType = mimeType
self.duration = duration
self.byteCount = byteCount
self.encryptionFingerprint = encryptionFingerprint
self.editedAt = editedAt
self.replyToID = replyToID
self.replyToJID = replyToJID
self.replyPreview = replyPreview
self.forwardedFrom = forwardedFrom
self.retractedAt = retractedAt
self.originID = originID
self.stanzaID = stanzaID
self.senderDisplayName = senderDisplayName
self.isGroupMessage = isGroupMessage
self.callHistory = callHistory
self.reactions = reactions
}
private enum CodingKeys: String, CodingKey {
case id
case conversationID
case senderJID
case body
case timestamp
case direction
case delivery
case security
case kind
case remoteAttachmentURL
case localFilename
case mimeType
case duration
case byteCount
case encryptionFingerprint
case editedAt
case replyToID
case replyToJID
case replyPreview
case forwardedFrom
case retractedAt
case originID
case stanzaID
case senderDisplayName
case isGroupMessage
case callHistory
case reactions
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
senderJID = try values.decode(String.self, forKey: .senderJID)
body = try values.decode(String.self, forKey: .body)
timestamp = try values.decode(Date.self, forKey: .timestamp)
direction = try values.decode(Direction.self, forKey: .direction)
delivery = try values.decode(Delivery.self, forKey: .delivery)
security = try values.decode(Security.self, forKey: .security)
kind = try values.decode(Kind.self, forKey: .kind)
remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
encryptionFingerprint = try values.decodeIfPresent(
String.self,
forKey: .encryptionFingerprint
)
editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID)
replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
originID = try values.decodeIfPresent(String.self, forKey: .originID)
stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
}
}
struct Snapshot: Codable {
static let currentSchemaVersion = 6
var schemaVersion: Int
var conversations: [LegacyConversation]
var messages: [LegacyMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
init(
conversations: [LegacyConversation],
messages: [LegacyMessage],
locallyDeletedMessageIDs: Set<String> = [],
rosterContactJIDs: Set<String> = [],
lastSuccessfulMAMSync: Date? = nil,
lastSuccessfulMAMCursor: String? = nil,
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
) {
self.schemaVersion = Self.currentSchemaVersion
self.conversations = conversations
self.messages = messages
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
self.rosterContactJIDs = rosterContactJIDs
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
self.mamCheckpoints = mamCheckpoints
}
private enum CodingKeys: String, CodingKey {
case schemaVersion
case conversations
case messages
case locallyDeletedMessageIDs
case rosterContactJIDs
case lastSuccessfulMAMSync
case lastSuccessfulMAMCursor
case mamCheckpoints
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
conversations = try values.decode([LegacyConversation].self, forKey: .conversations)
messages = try values.decode([LegacyMessage].self, forKey: .messages)
locallyDeletedMessageIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .locallyDeletedMessageIDs
) ?? []
rosterContactJIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .rosterContactJIDs
) ?? []
lastSuccessfulMAMSync = try values.decodeIfPresent(
Date.self,
forKey: .lastSuccessfulMAMSync
)
lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
)
mamCheckpoints = try values.decodeIfPresent(
[MAMArchiveKey: MAMArchiveCheckpoint].self,
forKey: .mamCheckpoints
) ?? [:]
}
}
struct Imported {
let conversations: [Conversation]
let messages: [ChatMessage]
let locallyDeletedMessageIDs: [String]
let rosterContactJIDs: [String]
let lastSuccessfulMAMSync: Date?
let lastSuccessfulMAMCursor: String?
let mamCheckpoints: [MAMCheckpointEntry]
}
static func importIfNeeded(from url: URL) throws -> Imported? {
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
let snapshot = try decoder.decode(Snapshot.self, from: data)
let conversations = snapshot.conversations.map { legacy -> Conversation in
let conversation = Conversation(
jid: legacy.jid,
displayName: legacy.displayName,
lastMessage: legacy.lastMessage,
lastActivity: legacy.lastActivity,
unreadCount: legacy.unreadCount,
isOnline: legacy.isOnline,
isPinned: legacy.isPinned,
encryptionPreference: legacy.encryptionPreference,
kind: legacy.kind == .group ? .group : .direct,
groupNickname: legacy.groupNickname,
isGroupJoined: legacy.isGroupJoined,
shouldAutojoin: legacy.shouldAutojoin,
occupantCount: legacy.occupantCount,
invitedBy: legacy.invitedBy
)
return conversation
}
let conversationByJID = Dictionary(
conversations.map { ($0.jid, $0) },
uniquingKeysWith: { first, _ in first }
)
let messages = snapshot.messages.map { legacy -> ChatMessage in
let message = ChatMessage(
id: legacy.id,
conversationID: legacy.conversationID,
senderJID: legacy.senderJID,
body: legacy.body,
timestamp: legacy.timestamp,
direction: legacy.direction == .outgoing ? .outgoing : .incoming,
delivery: ChatMessage.Delivery(rawValue: legacy.delivery.rawValue) ?? .sent,
security: ChatMessage.Security(rawValue: legacy.security.rawValue) ?? .plaintext,
kind: ChatMessage.Kind(rawValue: legacy.kind.rawValue) ?? .text,
remoteAttachmentURL: legacy.remoteAttachmentURL,
localFilename: legacy.localFilename,
mimeType: legacy.mimeType,
duration: legacy.duration,
byteCount: legacy.byteCount,
encryptionFingerprint: legacy.encryptionFingerprint,
editedAt: legacy.editedAt,
replyToID: legacy.replyToID,
replyToJID: legacy.replyToJID,
replyPreview: legacy.replyPreview,
forwardedFrom: legacy.forwardedFrom,
retractedAt: legacy.retractedAt,
originID: legacy.originID,
stanzaID: legacy.stanzaID,
senderDisplayName: legacy.senderDisplayName,
isGroupMessage: legacy.isGroupMessage,
callHistory: legacy.callHistory,
reactions: legacy.reactions
)
message.conversation = conversationByJID[legacy.conversationID.lowercased()]
return message
}
return Imported(
conversations: conversations,
messages: messages,
locallyDeletedMessageIDs: Array(snapshot.locallyDeletedMessageIDs),
rosterContactJIDs: Array(snapshot.rosterContactJIDs),
lastSuccessfulMAMSync: snapshot.lastSuccessfulMAMSync,
lastSuccessfulMAMCursor: snapshot.lastSuccessfulMAMCursor,
mamCheckpoints: snapshot.mamCheckpoints.map {
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
}
)
}
}
+103
View File
@@ -0,0 +1,103 @@
import Foundation
import Security
/// Stores the app-lock passcode in the Keychain and the lock preferences in
/// UserDefaults. The lock is a UI-level gate: the passcode is kept only in
/// the Keychain and is never mirrored into memory beyond verification.
final class AppLockVault {
private let service = "app.luma.chat.applock"
private let account = "applock-passcode"
private let enabledKey = "appLockEnabled"
private let biometricKey = "appLockBiometricUnlock"
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
var isEnabled: Bool {
get { defaults.bool(forKey: enabledKey) }
set { defaults.set(newValue, forKey: enabledKey) }
}
var biometricUnlockEnabled: Bool {
get { defaults.bool(forKey: biometricKey) }
set { defaults.set(newValue, forKey: biometricKey) }
}
func save(passcode: String) throws {
let data = Data(passcode.utf8)
let baseQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
]
let updateStatus = SecItemUpdate(
baseQuery as CFDictionary,
[kSecValueData: data] as CFDictionary
)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
throw AppLockVaultError.unhandled(updateStatus)
}
var insertion = baseQuery
insertion[kSecValueData] = data
#if !os(macOS)
insertion[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
#endif
let addStatus = SecItemAdd(insertion as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw AppLockVaultError.unhandled(addStatus)
}
}
func deletePasscode() throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw AppLockVaultError.unhandled(status)
}
}
func verify(passcode: String) -> Bool {
guard let stored = storedPasscode() else { return false }
return passcode == stored
}
private func storedPasscode() -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let data = item as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
}
enum AppLockVaultError: LocalizedError {
case unhandled(OSStatus)
var errorDescription: String? {
switch self {
case .unhandled(let status):
let message = SecCopyErrorMessageString(status, nil) as String?
return message ?? "Не удалось обратиться к Keychain (\(status))."
}
}
}
@@ -24,7 +24,7 @@ enum ChatMediaImageCache {
for message: ChatMessage,
data: Data
) -> ChatMediaPlatformImage? {
let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString
let cacheKey = "\(message.conversationID)\u{1F}\(message.clientID)" as NSString
if let cached = cache.object(forKey: cacheKey) {
return cached
}
@@ -0,0 +1,247 @@
import Foundation
import UniformTypeIdentifiers
#if os(iOS)
import Photos
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Saves a message's cached media to the right destination: photos, videos and
/// video notes go to the Photos library on iOS (or Downloads on macOS, which
/// would otherwise need a Photo Library entitlement), while files, audio and
/// voice messages go to the user's Downloads location. Kept dependency-free so
/// every bubble, the full-screen viewer and the audio player share one path.
@MainActor
enum MediaDownloadService {
enum SaveError: LocalizedError {
case missingLocalFile
case unsupportedKind
var errorDescription: String? {
switch self {
case .missingLocalFile:
return "Не удалось загрузить файл для сохранения."
case .unsupportedKind:
return "Этот тип сообщения нельзя сохранить."
}
}
}
// MARK: - Entry points
/// Resolve the cached copy for `message` (downloading through AppModel when
/// needed) and save it. Failures are surfaced through AppModel's error alert.
static func save(_ message: ChatMessage, model: AppModel) async {
do {
guard let sourceURL = await resolvedLocalURL(for: message, model: model) else {
throw SaveError.missingLocalFile
}
let filename = message.localFilename ?? Self.defaultFilename(for: message)
let didSave = try await save(
url: sourceURL,
kind: message.kind,
filename: filename
)
if didSave {
model.informationalMessage = "Сохранено."
}
} catch {
model.errorMessage = error.localizedDescription
}
}
/// Save an already-downloaded file, routing by kind and platform. Returns
/// `false` when the user cancelled an interactive save sheet.
@discardableResult
static func save(
url: URL,
kind: ChatMessage.Kind,
filename: String
) async throws -> Bool {
switch kind {
case .photo, .video, .videoNote:
#if os(iOS)
try await saveToPhotos(url: url, kind: kind)
return true
#else
return try await saveToDownloads(url: url, filename: filename)
#endif
case .attachment, .audio, .voice:
return try await saveToDownloads(url: url, filename: filename)
case .text, .location, .system:
throw SaveError.unsupportedKind
}
}
// MARK: - Local file resolution
private static func resolvedLocalURL(
for message: ChatMessage,
model: AppModel
) async -> URL? {
if let cached = model.mediaPreviewURL(for: message) {
return cached
}
// Inline media (photo/video/audio/voice/video note) is fetched by
// AppModel into its preview cache; files are not auto-previewed and fall
// through to the QuickLook handoff below.
await model.prepareMediaPreview(message)
if let cached = model.mediaPreviewURL(for: message) {
return cached
}
// Attachments are only fetched on demand via previewAttachment(_:),
// which parks the result in the single-shot previewURL property. Clear it
// before and after so the QuickLook sheet does not flash open.
model.previewURL = nil
await model.previewAttachment(message)
let url = model.previewURL
model.previewURL = nil
return url
}
// MARK: - Photos
#if os(iOS)
private static func saveToPhotos(url: URL, kind: ChatMessage.Kind) async throws {
try await PHPhotoLibrary.shared().performChanges {
if kind == .photo {
_ = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url)
} else {
_ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url)
}
}
}
#endif
// MARK: - Downloads
private static func saveToDownloads(url: URL, filename: String) async throws -> Bool {
#if os(macOS)
return try await saveWithPanel(url: url, filename: filename)
#else
return await FileExportPresenter.shared.present(url: url)
#endif
}
#if os(macOS)
/// Present a save panel defaulting to ~/Downloads. The app sandbox grants
/// write access to the location the user picks, so no Downloads entitlement
/// is required.
private static func saveWithPanel(url: URL, filename: String) async throws -> Bool {
let panel = NSSavePanel()
panel.nameFieldStringValue = filename
panel.canCreateDirectories = true
if let downloads = FileManager.default.urls(
for: .downloadsDirectory,
in: .userDomainMask
).first {
panel.directoryURL = downloads
}
guard panel.runModal() == .OK, let destination = panel.url else {
return false
}
try FileManager.default.copyItem(at: url, to: destination)
return true
}
#endif
// MARK: - Helpers
private static func defaultFilename(for message: ChatMessage) -> String {
let ext = Self.fileExtension(for: message)
let base: String
switch message.kind {
case .photo: base = "Фото"
case .video: base = "Видео"
case .videoNote: base = "Видеосообщение"
case .voice: base = "Голосовое сообщение"
case .audio: base = "Аудио"
case .attachment: base = "Вложение"
default: base = "Файл"
}
return ext.isEmpty ? base : "\(base).\(ext)"
}
private static func fileExtension(for message: ChatMessage) -> String {
if let filename = message.localFilename {
let ext = (filename as NSString).pathExtension
if !ext.isEmpty { return ext }
}
if let mime = message.mimeType,
let type = UTType(mimeType: mime),
let ext = type.preferredFilenameExtension
{
return ext
}
switch message.kind {
case .video, .videoNote: return "mp4"
case .voice, .audio: return "m4a"
case .photo: return "jpg"
default: return ""
}
}
}
#if os(iOS)
/// Presents the system "save to Files" sheet for a cached file and reports
/// whether the user completed the save or cancelled. Held alive by the
/// continuation until the delegate fires.
@MainActor
private final class FileExportPresenter: NSObject, UIDocumentPickerDelegate {
static let shared = FileExportPresenter()
private var continuation: CheckedContinuation<Bool, Never>?
func present(url: URL) async -> Bool {
await withCheckedContinuation { continuation in
self.continuation = continuation
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.delegate = self
Task { @MainActor in
// Let a just-dismissed context menu finish its transition before
// presenting, otherwise the sheet can be rejected.
try? await Task.sleep(nanoseconds: 350_000_000)
guard let presenter = Self.topPresenter(),
presenter.presentedViewController == nil
else {
self.finish(false)
return
}
presenter.present(picker, animated: true)
}
}
}
func documentPicker(
_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]
) {
finish(true)
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
finish(false)
}
private func finish(_ result: Bool) {
continuation?.resume(returning: result)
continuation = nil
}
private static func topPresenter() -> UIViewController? {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }),
let window = scene.windows.first(where: { $0.isKeyWindow }),
let root = window.rootViewController
else { return nil }
var top = root
while let presented = top.presentedViewController {
top = presented
}
return top
}
}
#endif
@@ -1,6 +1,7 @@
import AVFoundation
import Foundation
import ImageIO
import os
import UniformTypeIdentifiers
actor MediaPreviewProcessor {
@@ -44,6 +45,12 @@ actor MediaPreviewProcessor {
}
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
if kind == .video {
let logger = Logger(subsystem: "Luma", category: "video-preview")
logger.info(
"analyze video: \(url.lastPathComponent) duration=\(String(describing: duration)) thumbnailBytes=\(thumbnailData?.count ?? 0)"
)
}
return Analysis(
duration: duration,
thumbnailData: thumbnailData,
@@ -94,6 +101,8 @@ actor MediaPreviewProcessor {
return data
}
} catch {
Logger(subsystem: "Luma", category: "video-preview")
.error("image generator failed at \(second)s: \(error.localizedDescription)")
continue
}
}
+77 -41
View File
@@ -19,6 +19,24 @@ struct PhoneWatchSnapshot: Codable {
let chats: [Chat]
}
/// Plain-value snapshots of the SwiftData models, taken on the main actor
/// before the background queue builds the watch payload. Reading @Model
/// properties off the main actor crashes with EXC_BAD_ACCESS.
private struct PhoneWatchChatData {
let jid: String
let name: String
let unread: Int
}
private struct PhoneWatchMessageData {
let id: String
let conversationID: String
let body: String
let timestamp: Date
let outgoing: Bool
let encrypted: Bool
}
struct WatchVoiceMessage: Sendable {
let transferID: String
let jid: String
@@ -32,9 +50,9 @@ struct WatchVoiceMessage: Sendable {
}
#if os(iOS) && canImport(WatchConnectivity)
import WatchConnectivity
@preconcurrency import WatchConnectivity
final class PhoneWatchBridge: NSObject, WCSessionDelegate {
final class PhoneWatchBridge: NSObject, WCSessionDelegate, @unchecked Sendable {
var onReply: ((String, String) -> Void)?
var onVoiceMessage: ((WatchVoiceMessage) -> Void)? {
didSet { deliverPendingVoiceMessages() }
@@ -49,25 +67,43 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
private var deliveredVoiceTransferIDs: Set<String> = []
override init() {
session = WCSession.isSupported() ? .default : nil
session =
!RuntimeEnvironment.isRunningTests && WCSession.isSupported()
? .default
: nil
super.init()
pendingVoiceMessages = Self.loadPersistedVoiceMessages()
session?.delegate = self
session?.activate()
}
@MainActor
func update(conversations: [Conversation], messages: [ChatMessage]) {
guard let session else { return }
// Copy-on-write snapshots are cheap here; the filtering, sorting and
// JSON encoding are not. Keep that work away from SwiftUI's main run
// loop, especially immediately after a MAM page is committed.
// SwiftData models must never be read off the main actor: snapshot
// the fields the watch needs here, then hand plain values to the
// background queue for the filtering, sorting and JSON encoding.
let chats = conversations.map { conversation in
PhoneWatchChatData(
jid: conversation.jid,
name: conversation.displayName,
unread: conversation.unreadCount
)
}
let entries = messages.map { message in
PhoneWatchMessageData(
id: message.clientID,
conversationID: message.conversationID,
body: message.previewText,
timestamp: message.timestamp,
outgoing: message.direction == .outgoing,
encrypted: message.security == .omemo
)
}
snapshotQueue.async { [weak self, weak session] in
guard let self,
let session,
let data = Self.makeSnapshotData(
conversations: conversations,
messages: messages
),
let data = Self.makeSnapshotData(chats: chats, entries: entries),
data != self.latestSnapshot else { return }
self.latestSnapshot = data
self.publishLatestSnapshot(using: session)
@@ -75,57 +111,56 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
}
private static func makeSnapshotData(
conversations: [Conversation],
messages: [ChatMessage]
chats: [PhoneWatchChatData],
entries: [PhoneWatchMessageData]
) -> Data? {
let visibleConversations = Array(conversations.prefix(20))
let visibleConversationIDs = Set(visibleConversations.map(\.id))
var recentMessagesByConversation: [String: [ChatMessage]] = [:]
let visibleChats = Array(chats.prefix(20))
let visibleChatIDs = Set(visibleChats.map(\.jid))
var recentMessagesByChat: [String: [PhoneWatchMessageData]] = [:]
// Keep a bounded, chronologically sorted window. MAM can append an old
// overlapping stanza after a newer live message, so array order alone
// cannot identify the latest messages reliably.
for message in messages where visibleConversationIDs.contains(message.conversationID) {
var recent = recentMessagesByConversation[message.conversationID] ?? []
let insertionIndex = Self.insertionIndex(for: message, in: recent)
recent.insert(message, at: insertionIndex)
for entry in entries where visibleChatIDs.contains(entry.conversationID) {
var recent = recentMessagesByChat[entry.conversationID] ?? []
let insertionIndex = Self.insertionIndex(for: entry, in: recent)
recent.insert(entry, at: insertionIndex)
if recent.count > 20 {
recent.removeFirst(recent.count - 20)
}
recentMessagesByConversation[message.conversationID] = recent
recentMessagesByChat[entry.conversationID] = recent
}
let chats = visibleConversations.map { conversation in
let snapshotChats = visibleChats.map { chat in
PhoneWatchSnapshot.Chat(
jid: conversation.jid,
name: conversation.displayName,
unread: conversation.unreadCount,
messages: (recentMessagesByConversation[conversation.id] ?? [])
.map {
PhoneWatchSnapshot.Message(
id: $0.id,
body: $0.previewText,
timestamp: $0.timestamp,
outgoing: $0.direction == .outgoing,
encrypted: $0.security == .omemo
)
}
jid: chat.jid,
name: chat.name,
unread: chat.unread,
messages: (recentMessagesByChat[chat.jid] ?? []).map { entry in
PhoneWatchSnapshot.Message(
id: entry.id,
body: entry.body,
timestamp: entry.timestamp,
outgoing: entry.outgoing,
encrypted: entry.encrypted
)
}
)
}
return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats))
return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: snapshotChats))
}
private static func insertionIndex(
for message: ChatMessage,
in sortedMessages: [ChatMessage]
for entry: PhoneWatchMessageData,
in sorted: [PhoneWatchMessageData]
) -> Int {
var lowerBound = 0
var upperBound = sortedMessages.count
var upperBound = sorted.count
while lowerBound < upperBound {
let middle = lowerBound + (upperBound - lowerBound) / 2
let existing = sortedMessages[middle]
let existingComesFirst = existing.timestamp < message.timestamp
|| (existing.timestamp == message.timestamp && existing.id < message.id)
let existing = sorted[middle]
let existingComesFirst = existing.timestamp < entry.timestamp
|| (existing.timestamp == entry.timestamp && existing.id < entry.id)
if existingComesFirst {
lowerBound = middle + 1
} else {
@@ -150,6 +185,7 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
guard !RuntimeEnvironment.isRunningTests else { return }
session.activate()
}
File diff suppressed because it is too large. Load diff
+334
View File
@@ -0,0 +1,334 @@
import LocalAuthentication
import SwiftUI
@MainActor
struct AppLockView: View {
@ObservedObject var model: AppModel
@State private var passcode = ""
@State private var attemptFailed = false
@FocusState private var isFocused: Bool
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.08, green: 0.38, blue: 0.78),
Color(red: 0.12, green: 0.62, blue: 0.96),
Color(red: 0.44, green: 0.84, blue: 0.96),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 22) {
Image(systemName: "lock.fill")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(.white)
Text("Luma заблокирован")
.font(.title2.weight(.bold))
.foregroundStyle(.white)
SecureField("Пароль", text: $passcode)
.focused($isFocused)
.textContentType(.password)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.center)
.frame(maxWidth: 260)
.onSubmit(submit)
if attemptFailed {
Text("Неверный пароль")
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.95))
}
Button(action: submit) {
Text("Разблокировать")
.fontWeight(.semibold)
.frame(maxWidth: 260)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.disabled(passcode.isEmpty)
if model.biometricUnlockAvailable,
model.appLockBiometricIsEnabled
{
Button {
Task { await biometricUnlock() }
} label: {
Label(
"Войти по \(model.biometricUnlockName)",
systemImage: model.biometricUnlockName == "Face ID"
? "faceid" : "touchid"
)
.foregroundStyle(.white)
}
}
}
.padding(28)
.background(
.regularMaterial,
in: RoundedRectangle(cornerRadius: 28, style: .continuous)
)
.padding(24)
}
.onAppear {
isFocused = true
}
#if os(iOS)
.task {
// Auto-prompt biometrics once when the lock screen appears. iOS
// only: on macOS the system Touch ID dialog blocks the window
// and freezes the lock screen.
guard model.biometricUnlockAvailable,
model.appLockBiometricIsEnabled
else {
return
}
await biometricUnlock()
}
#endif
}
private func submit() {
guard !passcode.isEmpty else { return }
if model.unlockWithPasscode(passcode) {
attemptFailed = false
passcode = ""
} else {
attemptFailed = true
passcode = ""
}
}
private func biometricUnlock() async {
_ = await model.unlockWithBiometrics()
}
}
/// Configurable passcode sheet: setting up a new passcode, changing it, or
/// verifying the current one to disable the lock / toggle biometrics.
@MainActor
struct AppLockPasscodeSheet: View {
enum Mode: Identifiable {
case setup
case change
case verify(VerificationAction)
enum VerificationAction: String {
case disableLock
case enableBiometrics
case disableBiometrics
}
var id: String {
switch self {
case .setup: return "setup"
case .change: return "change"
case .verify(let action): return "verify-\(action.rawValue)"
}
}
}
let model: AppModel
let mode: Mode
@Environment(\.dismiss) private var dismiss
@State private var oldPasscode = ""
@State private var passcode = ""
@State private var repeatedPasscode = ""
@State private var errorText: String?
var body: some View {
#if os(iOS)
NavigationStack {
Form {
formcontent()
}
.navigationTitle(navigationTitle)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Готово", action: submit)
.disabled(!canSubmit)
}
}
}
#else
List {
formcontent()
}
.navigationTitle(navigationTitle)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Готово", action: submit)
.disabled(!canSubmit)
}
}
.frame(minWidth: 420, minHeight: 320)
#endif
}
@ViewBuilder
private func formcontent() -> some View {
switch mode {
case .setup:
Section(title) {
SecureField(fieldLabel, text: $passcode)
.textContentType(.password)
SecureField("Повторите пароль", text: $repeatedPasscode)
.textContentType(.password)
}
case .verify:
Section("Текущий пароль") {
SecureField("Текущий пароль", text: $oldPasscode)
.textContentType(.password)
}
case .change:
Section("Текущий пароль") {
SecureField("Текущий пароль", text: $oldPasscode)
.textContentType(.password)
}
Section(title) {
SecureField(fieldLabel, text: $passcode)
.textContentType(.password)
SecureField("Повторите пароль", text: $repeatedPasscode)
.textContentType(.password)
}
}
if let errorText {
Section {
Text(errorText)
.font(.caption)
.foregroundStyle(.red)
}
}
}
private var title: String {
switch mode {
case .setup: return "Новый пароль"
case .change: return "Новый пароль"
case .verify: return "Пароль"
}
}
private var navigationTitle: String {
switch mode {
case .setup: return "Включить блокировку"
case .change: return "Сменить пароль"
case .verify(let action):
switch action {
case .disableLock: return "Выключить блокировку"
case .enableBiometrics, .disableBiometrics:
return "Подтвердите пароль"
}
}
}
private var fieldLabel: String {
switch mode {
case .setup, .change:
return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)"
case .verify: return "Пароль"
}
}
private var canSubmit: Bool {
switch mode {
case .setup:
return !passcode.isEmpty && !repeatedPasscode.isEmpty
case .change:
return !oldPasscode.isEmpty && !passcode.isEmpty
&& !repeatedPasscode.isEmpty
case .verify:
return !oldPasscode.isEmpty
}
}
private func submit() {
errorText = nil
switch mode {
case .setup:
guard AppLockPolicy.isValid(passcode) else {
errorText =
"Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
return
}
guard passcode == repeatedPasscode else {
errorText = "Пароли не совпадают."
repeatedPasscode = ""
return
}
if model.enableAppLock(passcode: passcode) {
dismiss()
} else {
errorText =
model.errorMessage ?? "Не удалось включить блокировку."
}
case .change:
guard AppLockPolicy.isValid(passcode) else {
errorText =
"Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа."
return
}
guard passcode == repeatedPasscode else {
errorText = "Пароли не совпадают."
repeatedPasscode = ""
return
}
if model.changeAppLockPasscode(from: oldPasscode, to: passcode) {
dismiss()
} else {
errorText = "Неверный текущий пароль."
oldPasscode = ""
}
case .verify(let action):
switch action {
case .disableLock:
if model.disableAppLock(passcode: oldPasscode) {
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
case .enableBiometrics:
if model.setAppLockBiometricUnlock(true, passcode: oldPasscode)
{
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
case .disableBiometrics:
if model.setAppLockBiometricUnlock(false, passcode: oldPasscode)
{
dismiss()
} else {
errorText = "Неверный пароль."
oldPasscode = ""
}
}
}
}
}
#Preview("Экран блокировки") {
AppLockView(model: PreviewSupport.model)
}
#Preview("Настройка пароля") {
AppLockPasscodeSheet(model: PreviewSupport.model, mode: .setup)
}
+24 -1
View File
@@ -1,4 +1,5 @@
import Foundation
import os
import SwiftUI
#if os(iOS)
@@ -239,7 +240,13 @@ struct AttachmentPreviewView: View {
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
for draft in drafts where thumbnailImages[draft.id] == nil {
guard let data = draft.thumbnailData,
let image = DraftThumbnailImage(data: data) else { continue }
let image = DraftThumbnailImage(data: data) else {
if draft.kind == .video {
Logger(subsystem: "Luma", category: "video-preview")
.warning("video draft has no decodable thumbnail: \(draft.filename) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)")
}
continue
}
thumbnailImages[draft.id] = image
await Task.yield()
}
@@ -291,3 +298,19 @@ struct AttachmentPreviewView: View {
}
}
}
private struct AttachmentPreviewHost: View {
@State private var drafts = PreviewSupport.drafts()
var body: some View {
AttachmentPreviewView(
model: PreviewSupport.model,
conversation: PreviewSupport.conversation(displayName: "Алиса"),
drafts: $drafts
)
}
}
#Preview {
AttachmentPreviewHost()
}
+16 -1
View File
@@ -8,7 +8,7 @@ struct CallView: View {
let call: CallSnapshot
private var conversation: Conversation {
model.conversations.first(where: { $0.id == call.peerJID.lowercased() })
model.conversations.first(where: { $0.jid == call.peerJID.lowercased() })
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
}
@@ -241,3 +241,18 @@ private struct CallControlButton: View {
.accessibilityLabel(title)
}
}
#Preview("Входящий видеозвонок") {
CallView(model: PreviewSupport.model, call: PreviewSupport.callSnapshot())
}
#Preview("Аудиозвонок") {
CallView(
model: PreviewSupport.model,
call: PreviewSupport.callSnapshot(
media: [.audio],
phase: .connected,
isSpeakerEnabled: true
)
)
}
File diff suppressed because it is too large. Load diff
@@ -53,6 +53,16 @@ struct AudioMessagePlayer: View {
.font(.caption2.monospacedDigit())
.foregroundStyle(secondaryText)
Spacer()
Button(action: saveToFiles) {
Image(systemName: "square.and.arrow.down")
.font(.caption.weight(.bold))
.foregroundStyle(accent)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(accent.opacity(0.14), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel("Сохранить в Файлы")
Button(action: playback.cycleVoiceRate) {
Text(rateText)
.font(.caption2.monospacedDigit().weight(.bold))
@@ -95,6 +105,13 @@ struct AudioMessagePlayer: View {
countStyle: .file
))
}
Spacer()
Button(action: saveToFiles) {
Image(systemName: "square.and.arrow.down")
.foregroundStyle(accent)
}
.buttonStyle(.plain)
.accessibilityLabel("Сохранить в Файлы")
}
.font(.caption2.monospacedDigit())
.foregroundStyle(secondaryText)
@@ -129,7 +146,7 @@ struct AudioMessagePlayer: View {
}
private var isCurrent: Bool {
playback.currentMessageID == message.id
playback.currentMessageID == message.clientID
}
private var isPlaying: Bool {
@@ -185,6 +202,10 @@ struct AudioMessagePlayer: View {
message.direction == .outgoing ? .white.opacity(0.78) : .secondary
}
private func saveToFiles() {
Task { await MediaDownloadService.save(message, model: model) }
}
private func togglePlayback() {
guard let localURL else {
Task {
@@ -194,7 +215,7 @@ struct AudioMessagePlayer: View {
return
}
playback.toggle(
messageID: message.id,
messageID: message.clientID,
url: downloadedURL,
durationHint: message.duration,
isVoice: message.kind == .voice
@@ -203,7 +224,7 @@ struct AudioMessagePlayer: View {
return
}
playback.toggle(
messageID: message.id,
messageID: message.clientID,
url: localURL,
durationHint: message.duration,
isVoice: message.kind == .voice
@@ -213,7 +234,7 @@ struct AudioMessagePlayer: View {
private func seek(_ fraction: Double) {
guard let localURL else { return }
playback.seek(
messageID: message.id,
messageID: message.clientID,
url: localURL,
durationHint: message.duration,
isVoice: message.kind == .voice,
@@ -305,3 +326,28 @@ enum MediaTimeFormatter {
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
#Preview("Голосовое") {
AudioMessagePlayer(
model: PreviewSupport.model,
message: PreviewSupport.voiceMessage(),
onFallbackTap: {}
)
.padding()
}
#Preview("Аудиофайл") {
AudioMessagePlayer(
model: PreviewSupport.model,
message: PreviewSupport.message(
body: "",
kind: .audio,
localFilename: "track.mp3",
mimeType: "audio/mpeg",
duration: 214,
byteCount: 8_550_000
),
onFallbackTap: {}
)
.padding()
}
@@ -90,3 +90,21 @@ struct AvatarView: View {
)
}
}
#Preview("Контакт") {
AvatarView(
conversation: PreviewSupport.conversation(displayName: "Алиса", isOnline: true)
)
}
#Preview("Группа") {
AvatarView(
conversation: PreviewSupport.conversation(
jid: "team@conference.example.org",
displayName: "Команда Luma",
kind: .group,
isGroupJoined: true,
occupantCount: 7
)
)
}
@@ -50,3 +50,27 @@ struct ReplyThreadBracket: Shape {
return path
}
}
#Preview("Кривая ответа") {
VStack(spacing: 24) {
ReplyThreadCurve(sourceDirection: .incoming)
.stroke(Color.blue, lineWidth: 2)
.frame(width: 90, height: 26)
ReplyThreadCurve(sourceDirection: .outgoing)
.stroke(Color.blue, lineWidth: 2)
.frame(width: 90, height: 26)
}
.padding()
}
#Preview("Скобка ветки") {
HStack(spacing: 40) {
ReplyThreadBracket(opensToTrailing: true)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 38, height: 260)
ReplyThreadBracket(opensToTrailing: false)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 38, height: 260)
}
.padding()
}
@@ -0,0 +1,86 @@
import SwiftUI
struct ChatBubbleShape: Shape {
let isOutgoing: Bool
func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
var p = Path()
if isOutgoing {
p.move(to: CGPoint(x: 25, y: height))
p.addLine(to: CGPoint(x: 20, y: height))
p.addCurve(
to: CGPoint(x: 0, y: height - 20),
control1: CGPoint(x: 8, y: height),
control2: CGPoint(x: 0, y: height - 8)
)
p.addLine(to: CGPoint(x: 0, y: 20))
p.addCurve(
to: CGPoint(x: 20, y: 0),
control1: CGPoint(x: 0, y: 8),
control2: CGPoint(x: 8, y: 0)
)
p.addLine(to: CGPoint(x: width - 21, y: 0))
p.addCurve(
to: CGPoint(x: width - 4, y: 20),
control1: CGPoint(x: width - 12, y: 0),
control2: CGPoint(x: width - 4, y: 8)
)
p.addLine(to: CGPoint(x: width - 4, y: height - 11))
p.addCurve(
to: CGPoint(x: width, y: height),
control1: CGPoint(x: width - 4, y: height - 1),
control2: CGPoint(x: width, y: height)
)
p.addLine(to: CGPoint(x: width + 0.05, y: height - 0.01))
p.addCurve(
to: CGPoint(x: width - 11, y: height - 4),
control1: CGPoint(x: width - 4, y: height + 0.5),
control2: CGPoint(x: width - 8, y: height - 1)
)
p.addCurve(
to: CGPoint(x: width - 25, y: height),
control1: CGPoint(x: width - 16, y: height),
control2: CGPoint(x: width - 20, y: height)
)
} else {
p.move(to: CGPoint(x: 25, y: height))
p.addLine(to: CGPoint(x: width - 20, y: height))
p.addCurve(
to: CGPoint(x: width, y: height - 20),
control1: CGPoint(x: width - 8, y: height),
control2: CGPoint(x: width, y: height - 8)
)
p.addLine(to: CGPoint(x: width, y: 20))
p.addCurve(
to: CGPoint(x: width - 20, y: 0),
control1: CGPoint(x: width, y: 8),
control2: CGPoint(x: width - 8, y: 0)
)
p.addLine(to: CGPoint(x: 21, y: 0))
p.addCurve(
to: CGPoint(x: 4, y: 20),
control1: CGPoint(x: 12, y: 0),
control2: CGPoint(x: 4, y: 8)
)
p.addLine(to: CGPoint(x: 4, y: height - 11))
p.addCurve(
to: CGPoint(x: 0, y: height),
control1: CGPoint(x: 4, y: height - 1),
control2: CGPoint(x: 0, y: height)
)
p.addLine(to: CGPoint(x: -0.05, y: height - 0.01))
p.addCurve(
to: CGPoint(x: 11.0, y: height - 4.0),
control1: CGPoint(x: 4.0, y: height + 0.5),
control2: CGPoint(x: 8, y: height - 1)
)
p.addCurve(
to: CGPoint(x: 25, y: height),
control1: CGPoint(x: 16, y: height),
control2: CGPoint(x: 20, y: height)
)
}
return p
}
}
@@ -2,53 +2,42 @@ import SwiftUI
struct ConnectionBanner: View {
@ObservedObject var model: AppModel
var text: String
var body: some View {
switch model.connectionStatus {
case .connected:
if model.isArchiveSyncing || !model.isOMEMOReady {
banner(
icon: model.isArchiveSyncing ? "arrow.triangle.2.circlepath" : "lock.rotation",
text: model.isArchiveSyncing ? "Синхронизация истории…" : "Публикация ключей OMEMO…",
color: .blue,
text: "Обновление",
color: .clear,
progress: true
)
} else {
Text(text)
.font(.headline)
}
case .connecting:
banner(icon: "antenna.radiowaves.left.and.right", text: "Подключение…", color: .orange, progress: true)
case .disconnected(let reason):
HStack(spacing: 8) {
Image(systemName: "wifi.exclamationmark")
Text(reason ?? "Нет соединения")
.lineLimit(1)
Spacer()
Button("Повторить") { Task { await model.reconnect() } }
.buttonStyle(.borderless)
.fontWeight(.semibold)
}
.font(.caption)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.red.opacity(0.88))
banner(text: "Подключение", color: .clear, progress: true)
case .disconnected:
banner(text: "Ожидание сети", color: .clear, progress: true)
}
}
private func banner(icon: String, text: String, color: Color, progress: Bool) -> some View {
private func banner(text: String, color: Color, progress: Bool) -> some View {
HStack(spacing: 8) {
if progress {
ProgressView().controlSize(.mini).tint(.white)
} else {
Image(systemName: icon)
}
Text(text)
Spacer()
}
.font(.caption)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(color.opacity(0.88))
}
}
#Preview {
ConnectionBanner(model: PreviewSupport.model, text: "Чаты")
.padding()
}
@@ -58,3 +58,36 @@ struct ConversationRow: View {
.padding(.vertical, 2)
}
}
#Preview("Прямой чат") {
ConversationRow(
conversation: PreviewSupport.conversation(
displayName: "Алиса",
lastMessage: "Привет! Как дела?",
lastActivity: Date().addingTimeInterval(-120),
unreadCount: 2,
isOnline: true
),
imageData: nil,
isEncrypted: true
)
.padding()
}
#Preview("Групповой чат") {
ConversationRow(
conversation: PreviewSupport.conversation(
jid: "team@conference.example.org",
displayName: "Команда Luma",
lastMessage: "Иван: созвон в 15:00",
lastActivity: Date().addingTimeInterval(-3_600),
unreadCount: 5,
kind: .group,
isGroupJoined: true,
occupantCount: 7
),
imageData: nil,
isEncrypted: true
)
.padding()
}
@@ -0,0 +1,96 @@
import SwiftUI
/// A reusable emoji picker: category tabs on the bottom and a grid of emojis
/// for the selected category. Used both for reactions and for inserting an
/// emoji into the composer.
struct EmojiPickerView: View {
let onSelect: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var selectedCategoryID = EmojiCatalog.categories.first?.id ?? ""
private let columns = [GridItem(.adaptive(minimum: 44), spacing: 4)]
private var selectedCategory: EmojiCategory {
EmojiCatalog.categories.first { $0.id == selectedCategoryID }
?? EmojiCatalog.categories[0]
}
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
LazyVGrid(columns: columns, spacing: 6) {
ForEach(selectedCategory.emojis, id: \.self) { emoji in
Button {
onSelect(emoji)
dismiss()
} label: {
Text(emoji)
.font(.system(size: 28))
.frame(width: 46, height: 46)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(emoji)
}
}
.padding(.horizontal, 12)
.padding(.top, 8)
.padding(.bottom, 12)
}
Divider()
categorySelector
.padding(.vertical, 8)
}
.frame(minHeight: 360)
#if os(macOS)
.frame(minWidth: 380)
#endif
}
private var header: some View {
HStack {
Text("Выберите эмодзи")
.font(.headline)
Spacer()
Button("Готово") { dismiss() }
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var categorySelector: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(EmojiCatalog.categories) { category in
let isSelected = category.id == selectedCategoryID
Button {
selectedCategoryID = category.id
} label: {
Image(systemName: category.symbol)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 44, height: 44)
.background(
isSelected
? Color.accentColor.opacity(0.15)
: Color.clear,
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
)
}
.buttonStyle(.plain)
.accessibilityLabel(category.title)
}
}
.padding(.horizontal, 12)
}
}
}
#Preview {
EmojiPickerView { _ in }
}
@@ -71,3 +71,10 @@ struct InlineVideoPlayer: NSViewRepresentable {
}
}
#endif
#Preview {
InlineVideoPlayer(player: AVPlayer())
.frame(width: 260, height: 260)
.background(Color.black)
.clipShape(Circle())
}
@@ -56,8 +56,14 @@ struct LocationMessagePreview: View {
}
private func openInMaps() {
let item = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
let item = MKMapItem(location: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude), address: nil)
item.name = "Геопозиция из Luma"
item.openInMaps()
}
}
#Preview {
LocationMessagePreview(
location: GeoLocation(latitude: 55.7558, longitude: 37.6173)!
)
}
+54 -6
View File
@@ -45,6 +45,7 @@ struct MediaViewer: View {
@State private var isTrackingDismissGesture = false
@State private var isCompletingDismissal = false
@State private var isPhotoZoomed = false
@State private var saveErrorMessage: String?
var body: some View {
GeometryReader { geometry in
@@ -58,13 +59,24 @@ struct MediaViewer: View {
.ignoresSafeArea(edges: .bottom)
VStack(spacing: 0) {
Spacer()
HStack {
Text(item.title)
.font(.headline)
.foregroundStyle(.white)
.lineLimit(1)
// Text(item.title)
// .font(.headline)
// .foregroundStyle(.white)
// .lineLimit(1)
Spacer()
Button(action: save) {
Image(systemName: "square.and.arrow.down")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(.white.opacity(0.16), in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel("Сохранить")
}
.padding(.horizontal, 16)
.padding(.top, 12)
@@ -77,8 +89,6 @@ struct MediaViewer: View {
)
.ignoresSafeArea()
)
Spacer()
}
}
.offset(y: dragOffset)
@@ -96,6 +106,11 @@ struct MediaViewer: View {
.accessibilityAction(.escape) {
closeImmediately()
}
.alert("Ошибка сохранения", isPresented: saveErrorBinding) {
Button("OK", role: .cancel) {}
} message: {
Text(saveErrorMessage ?? "Не удалось сохранить медиа.")
}
}
#if os(macOS)
.onExitCommand {
@@ -183,6 +198,27 @@ struct MediaViewer: View {
onClose()
}
private var saveErrorBinding: Binding<Bool> {
Binding(
get: { saveErrorMessage != nil },
set: { visible in if !visible { saveErrorMessage = nil } }
)
}
private func save() {
Task {
do {
try await MediaDownloadService.save(
url: item.url,
kind: item.kind,
filename: item.title
)
} catch {
saveErrorMessage = error.localizedDescription
}
}
}
private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat {
min(1, abs(dragOffset) / max(1, containerHeight * 0.62))
}
@@ -361,3 +397,15 @@ private struct ZoomablePhotoView: View {
}
}
}
#Preview {
MediaViewer(
item: MediaViewerItem(
id: "preview",
url: URL(fileURLWithPath: "/tmp/preview-photo.jpg"),
kind: .photo,
title: "IMG_1234.jpg"
),
onClose: {}
)
}
+364 -113
View File
@@ -1,9 +1,9 @@
import SwiftUI
#if os(iOS)
import UIKit
import UIKit
#elseif os(macOS)
import AppKit
import AppKit
#endif
@MainActor
@@ -23,9 +23,12 @@ struct MessageBubble: View {
let onDelete: () -> Void
let onReplyTap: (String) -> Void
let onReact: (String) -> Void
let onReactPicker: () -> Void
@State private var replySwipeOffset: CGFloat = 0
@State private var replySwipeArmed = false
@State private var replySwipeLocked = false
@State private var scrollOwnsGesture = false
var body: some View {
ZStack {
@@ -34,8 +37,8 @@ struct MessageBubble: View {
.offset(x: replySwipeOffset)
}
.frame(maxWidth: .infinity)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.contentShape(Rectangle())
.accessibilityIdentifier("bubble-\(message.clientID)")
.onTapGesture {
guard isSelectionMode else { return }
onToggleSelection()
@@ -44,39 +47,59 @@ struct MessageBubble: View {
.vertical,
message.kind == .photo || message.kind == .video ? 7 : 2
)
.simultaneousGesture(
replySwipeGesture,
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
)
.onChange(of: isSelectionMode) { _, selecting in
guard selecting else { return }
replySwipeOffset = 0
replySwipeArmed = false
replySwipeLocked = false
scrollOwnsGesture = false
}
.contextMenu {
if !isSelectionMode {
if message.canBeReactedTo {
Menu {
ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in
Button(emoji) { onReact(emoji) }
}
} label: {
Button(action: onReactPicker) {
Label("Реакция", systemImage: "face.smiling")
}
}
if message.canBeRepliedTo {
Button(action: onReply) {
Label("Ответить", systemImage: "arrowshape.turn.up.left")
Label(
"Ответить",
systemImage: "arrowshape.turn.up.left"
)
}
}
if message.canBeForwarded {
Button(action: onForward) {
Label("Переслать", systemImage: "arrowshape.turn.up.right")
Label(
"Переслать",
systemImage: "arrowshape.turn.up.right"
)
}
}
if message.hasText {
Button(action: copyText) {
Label("Копировать текст", systemImage: "doc.on.doc")
}
}
if message.kind.isMedia, !message.isRetracted {
Button {
Task {
await MediaDownloadService.save(
message,
model: model
)
}
} label: {
Label("Сохранить", systemImage: "square.and.arrow.down")
}
}
if model.canRetryMediaMessage(message) {
Button(action: onRetry) {
Label("Повторить отправку", systemImage: "arrow.clockwise")
Label(
"Повторить отправку",
systemImage: "arrow.clockwise"
)
}
}
if message.canBeEdited {
@@ -99,7 +122,9 @@ struct MessageBubble: View {
}
}
.accessibilityLabel(Text(selectionAccessibilityLabel))
.accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) {
.accessibilityAction(
named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")
) {
if isSelectionMode {
onToggleSelection()
} else {
@@ -108,15 +133,28 @@ struct MessageBubble: View {
}
}
private func copyText() {
#if os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(message.body, forType: .string)
#else
UIPasteboard.general.string = message.body
#endif
}
private var messageContainer: some View {
HStack(spacing: 2) {
if isSelectionMode {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 23, weight: .semibold))
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 34)
.padding(.leading, 7)
.accessibilityHidden(true)
Image(
systemName: isSelected ? "checkmark.circle.fill" : "circle"
)
.font(.system(size: 23, weight: .semibold))
.foregroundStyle(
isSelected ? Color.accentColor : Color.secondary
)
.frame(width: 34)
.padding(.leading, 7)
.accessibilityHidden(true)
}
VStack(spacing: 0) {
@@ -145,30 +183,65 @@ struct MessageBubble: View {
}
private var replySwipeIcon: some View {
Image(systemName: replySwipeArmed
? "arrowshape.turn.up.left.fill"
: "arrowshape.turn.up.left")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(Color.accentColor)
.scaleEffect(
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
Image(
systemName: replySwipeArmed
? "arrowshape.turn.up.left.fill"
: "arrowshape.turn.up.left"
)
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(Color.accentColor)
.scaleEffect(x: replySwipeOffset > 0 ? -1 : 1, y: 1)
.scaleEffect(
CGFloat(0.8) + CGFloat(0.2)
* MessageReplySwipePolicy.progress(for: replySwipeOffset)
)
.opacity(
Double(
CGFloat(0.35) + CGFloat(0.65)
* MessageReplySwipePolicy.progress(for: replySwipeOffset)
)
.opacity(Double(
CGFloat(0.35) + CGFloat(0.65) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
))
)
}
private var replySwipeGesture: some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { value in
let offset = MessageReplySwipePolicy.offset(for: value.translation)
guard offset != 0 || replySwipeOffset != 0 else {
// A vertical timeline pan must not publish per-cell state
// changes or compete with UIScrollView's own recognizer.
// Arbitration: once the touch is clearly vertical the scroll
// view owns it for good, so a later horizontal arc can never
// move bubbles mid-scroll. A reply swipe locks in only while
// the finger is still moving mostly horizontally.
if !replySwipeLocked, !scrollOwnsGesture {
if MessageReplySwipePolicy.canLock(value.translation) {
replySwipeLocked = true
} else if abs(value.translation.height) > 16,
abs(value.translation.width)
< abs(value.translation.height)
{
scrollOwnsGesture = true
}
}
guard replySwipeLocked, !scrollOwnsGesture else { return }
let offset = MessageReplySwipePolicy.offset(
locked: replySwipeLocked,
translation: value.translation
)
guard offset != 0 else {
// Vertical (or not clearly horizontal) movement: snap the
// indicator back and never publish per-cell state changes
// during an ordinary timeline scroll.
if replySwipeOffset != 0 || replySwipeArmed {
withAnimation(
.spring(response: 0.24, dampingFraction: 0.82)
) {
replySwipeOffset = 0
}
replySwipeArmed = false
}
return
}
replySwipeOffset = offset
let armed = MessageReplySwipePolicy.shouldReply(
locked: replySwipeLocked,
translation: value.translation,
predictedEndTranslation: value.translation
)
@@ -178,12 +251,23 @@ struct MessageBubble: View {
replySwipeArmed = armed
}
.onEnded { value in
let shouldReply = MessageReplySwipePolicy.shouldReply(
translation: value.translation,
predictedEndTranslation: value.predictedEndTranslation
)
// The lock is a hint, not a requirement: a fast flick may
// deliver no intermediate frame that passes canLock, yet its
// end state is still a clear horizontal swipe. scrollOwnsGesture
// alone protects the timeline from reply activation.
let shouldReply =
!scrollOwnsGesture
&& MessageReplySwipePolicy.shouldReply(
locked: replySwipeLocked,
translation: value.translation,
predictedEndTranslation: value.predictedEndTranslation
)
replySwipeLocked = false
scrollOwnsGesture = false
if replySwipeOffset != 0 || replySwipeArmed {
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
withAnimation(
.spring(response: 0.24, dampingFraction: 0.82)
) {
replySwipeOffset = 0
replySwipeArmed = false
}
@@ -195,28 +279,47 @@ struct MessageBubble: View {
}
private func replySwipeFeedback() {
#if os(iOS)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
#endif
#if os(iOS)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
#endif
}
private var selectionAccessibilityLabel: String {
guard isSelectionMode else { return message.previewText }
return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")"
return
"\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")"
}
private var messageRow: some View {
HStack(alignment: .bottom, spacing: 8) {
if message.direction == .outgoing { Spacer(minLength: sideSpacerMinimum) }
if message.direction == .outgoing {
Spacer(minLength: sideSpacerMinimum)
}
VStack(alignment: message.direction == .outgoing ? .trailing : .leading, spacing: 4) {
VStack(
alignment: message.direction == .outgoing
? .trailing : .leading,
spacing: 4
) {
if message.isGroupMessage, message.direction == .incoming {
Text(message.senderDisplayName ?? model.displayName(for: message.senderJID))
.font(.caption.weight(.semibold))
.foregroundStyle(senderColor)
.padding(.horizontal, 8)
Text(
message.senderDisplayName
?? model.displayName(for: message.senderJID)
)
.font(.caption.weight(.semibold))
.foregroundStyle(senderColor)
.padding(.horizontal, 8)
}
// The drag lives on the bubble itself so a swipe can only start
// a reply when it begins on the bubble — not on the empty
// space beside it, the sender name, or the metadata row.
messageContent
.contentShape(Rectangle())
.simultaneousGesture(
replySwipeGesture,
including: !isSelectionMode && message.canBeRepliedTo
? .gesture : .none
)
if !reactionSummaries.isEmpty {
reactionStrip
@@ -229,13 +332,16 @@ struct MessageBubble: View {
}
}
if message.direction == .incoming { Spacer(minLength: sideSpacerMinimum) }
if message.direction == .incoming {
Spacer(minLength: sideSpacerMinimum)
}
}
.padding(.horizontal, 12)
.contentShape(Rectangle())
.onTapGesture {
guard let targetID = message.replyToID,
message.kind == .text || message.kind == .system else { return }
message.kind == .text || message.kind == .system
else { return }
onReplyTap(targetID)
}
}
@@ -249,8 +355,13 @@ struct MessageBubble: View {
if message.security != .omemo || message.delivery == .failed {
HStack(spacing: 4) {
if message.security != .omemo {
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
.foregroundStyle(message.security == .plaintext ? .orange : .red)
Image(
systemName: message.security == .plaintext
? "lock.open" : "exclamationmark.shield"
)
.foregroundStyle(
message.security == .plaintext ? .orange : .red
)
}
if message.delivery == .failed {
Image(systemName: "exclamationmark.circle.fill")
@@ -265,8 +376,13 @@ struct MessageBubble: View {
private var standardMetadata: some View {
HStack(spacing: 4) {
if message.callHistory == nil, message.security != .omemo {
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
.foregroundStyle(message.security == .plaintext ? .orange : .red)
Image(
systemName: message.security == .plaintext
? "lock.open" : "exclamationmark.shield"
)
.foregroundStyle(
message.security == .plaintext ? .orange : .red
)
}
if message.editedAt != nil {
Text("изм.")
@@ -274,7 +390,10 @@ struct MessageBubble: View {
Text(message.timestamp, format: .dateTime.hour().minute())
if message.direction == .outgoing, message.callHistory == nil {
Image(systemName: deliveryIcon)
.foregroundStyle(message.delivery == .failed ? .red : .secondary)
.foregroundStyle(
message.delivery == .failed ? .red : .secondary
)
Text(deliveryText)
}
}
.font(.caption2)
@@ -293,7 +412,10 @@ struct MessageBubble: View {
.font(.subheadline)
.padding(.horizontal, 13)
.padding(.vertical, 9)
.foregroundStyle(message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary)
.foregroundStyle(
message.direction == .outgoing
? Color.white.opacity(0.86) : Color.secondary
)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
} else {
@@ -340,7 +462,10 @@ struct MessageBubble: View {
.lumaTextSelection()
.padding(.horizontal, 13)
.padding(.vertical, 9)
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
.foregroundStyle(
message.direction == .outgoing
? Color.white : Color.primary
)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
}
@@ -351,7 +476,9 @@ struct MessageBubble: View {
.buttonStyle(.plain)
.padding(.horizontal, 13)
.padding(.vertical, 9)
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
.foregroundStyle(
message.direction == .outgoing ? Color.white : Color.primary
)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
case .text, .system:
@@ -362,27 +489,36 @@ struct MessageBubble: View {
.lumaTextSelection()
.padding(.horizontal, 13)
.padding(.vertical, 9)
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
.foregroundStyle(
message.direction == .outgoing
? Color.white : Color.primary
)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
}
}
}
private func callHistoryContent(_ history: CallHistoryMetadata) -> some View {
private func callHistoryContent(_ history: CallHistoryMetadata) -> some View
{
HStack(spacing: 12) {
ZStack {
Circle()
.fill(callAccentColor(for: history).opacity(
message.direction == .outgoing ? 0.28 : 0.14
))
.fill(
callAccentColor(for: history).opacity(
message.direction == .outgoing ? 0.28 : 0.14
)
)
Image(systemName: history.isVideo ? "video.fill" : "phone.fill")
.font(.system(size: 17, weight: .semibold))
Image(systemName: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
.font(.system(size: 8, weight: .heavy))
.padding(3)
.background(Circle().fill(callDirectionBadgeBackground))
.offset(x: 14, y: 14)
Image(
systemName: message.direction == .outgoing
? "arrow.up.right" : "arrow.down.left"
)
.font(.system(size: 8, weight: .heavy))
.padding(3)
.background(Circle().fill(callDirectionBadgeBackground))
.offset(x: 14, y: 14)
}
.frame(width: 42, height: 42)
.foregroundStyle(callIconForeground(for: history))
@@ -402,7 +538,9 @@ struct MessageBubble: View {
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: 300, alignment: .leading)
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
.foregroundStyle(
message.direction == .outgoing ? Color.white : Color.primary
)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
.accessibilityElement(children: .combine)
@@ -463,7 +601,9 @@ struct MessageBubble: View {
}
}
.buttonStyle(.plain)
.accessibilityLabel("\(summary.emoji), реакций: \(summary.count)")
.accessibilityLabel(
"\(summary.emoji), реакций: \(summary.count)"
)
}
}
.padding(.horizontal, 4)
@@ -491,9 +631,10 @@ struct MessageBubble: View {
.padding(.bottom, -2)
.task(id: replyTarget?.remoteAttachmentURL) {
if let replyTarget,
replyTarget.kind == .photo
replyTarget.kind == .photo
|| replyTarget.kind == .video
|| replyTarget.kind == .videoNote {
|| replyTarget.kind == .videoNote
{
await model.prepareMediaPreview(replyTarget)
}
}
@@ -506,7 +647,8 @@ struct MessageBubble: View {
}
VStack(
alignment: compactTargetDirection == .outgoing ? .trailing : .leading,
alignment: compactTargetDirection == .outgoing
? .trailing : .leading,
spacing: -1
) {
compactSourceBubble
@@ -550,7 +692,9 @@ struct MessageBubble: View {
@ViewBuilder
private var compactReplyThumbnail: some View {
if let target = replyTarget, target.kind != .text, target.kind != .system {
if let target = replyTarget, target.kind != .text,
target.kind != .system
{
ZStack {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.08))
@@ -564,25 +708,31 @@ struct MessageBubble: View {
@ViewBuilder
private func replyThumbnailImage(for target: ChatMessage) -> some View {
if let data = model.mediaThumbnail(for: target) {
#if os(iOS)
if let image = ChatMediaImageCache.image(for: target, data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
Image(systemName: replyMediaIcon(for: target.kind))
.foregroundStyle(compactReplyColor)
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(for: target, data: data) {
Image(nsImage: image)
.resizable()
.scaledToFill()
} else {
Image(systemName: replyMediaIcon(for: target.kind))
.foregroundStyle(compactReplyColor)
}
#endif
#if os(iOS)
if let image = ChatMediaImageCache.image(
for: target,
data: data
) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
Image(systemName: replyMediaIcon(for: target.kind))
.foregroundStyle(compactReplyColor)
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(
for: target,
data: data
) {
Image(nsImage: image)
.resizable()
.scaledToFill()
} else {
Image(systemName: replyMediaIcon(for: target.kind))
.foregroundStyle(compactReplyColor)
}
#endif
} else {
Image(systemName: replyMediaIcon(for: target.kind))
.font(.system(size: 12, weight: .semibold))
@@ -591,7 +741,9 @@ struct MessageBubble: View {
}
private var replyTarget: ChatMessage? {
message.replyToID.flatMap { model.message(withID: $0, in: message.conversationID) }
message.replyToID.flatMap {
model.message(withID: $0, in: message.conversationID)
}
}
private var compactTargetDirection: ChatMessage.Direction {
@@ -606,15 +758,23 @@ struct MessageBubble: View {
}
private var replyText: String {
replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно"
replyTarget?.quotePreview ?? message.replyPreview
?? "Исходное сообщение недоступно"
}
private var mediaContent: some View {
HStack(spacing: 11) {
ZStack {
Circle()
.fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12))
.frame(width: message.kind == .videoNote ? 58 : 38, height: message.kind == .videoNote ? 58 : 38)
.fill(
.white.opacity(
message.direction == .outgoing ? 0.22 : 0.12
)
)
.frame(
width: message.kind == .videoNote ? 58 : 38,
height: message.kind == .videoNote ? 58 : 38
)
Image(systemName: mediaIcon)
.font(.system(size: message.kind == .videoNote ? 24 : 17))
}
@@ -652,9 +812,15 @@ struct MessageBubble: View {
parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60))
}
if let byteCount = message.byteCount {
parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
parts.append(
ByteCountFormatter.string(
fromByteCount: Int64(byteCount),
countStyle: .file
)
)
}
return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ")
return parts.isEmpty
? "Нажмите для просмотра" : parts.joined(separator: " · ")
}
private var mediaIcon: String {
@@ -687,7 +853,10 @@ struct MessageBubble: View {
private var bubbleBackground: some View {
if message.direction == .outgoing {
LinearGradient(
colors: [Color(red: 0.13, green: 0.51, blue: 0.94), Color(red: 0.12, green: 0.63, blue: 0.94)],
colors: [
Color(red: 0.13, green: 0.51, blue: 0.94),
Color(red: 0.12, green: 0.63, blue: 0.94),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
@@ -696,16 +865,27 @@ struct MessageBubble: View {
}
}
private var messageBubbleShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 18, style: .continuous)
private var messageBubbleShape: ChatBubbleShape {
ChatBubbleShape(isOutgoing: message.direction == .outgoing)
}
private var deliveryIcon: String {
switch message.delivery {
case .sending: return "clock"
case .sent: return "checkmark"
case .delivered: return "checkmark.circle.fill"
case .delivered: return "checkmark.circle.dotted"
case .failed: return "exclamationmark.circle.fill"
case .read: return "checkmark.circle.fill"
}
}
private var deliveryText: String {
switch message.delivery {
case .sending: return "Sending..."
case .sent: return "Sended"
case .delivered: return "Delivered"
case .failed: return "Error"
case .read: return "Read"
}
}
@@ -725,13 +905,84 @@ struct MessageBubble: View {
}
}
private extension View {
extension View {
@ViewBuilder
func lumaTextSelection() -> some View {
#if os(macOS)
textSelection(.enabled)
#else
self
#endif
fileprivate func lumaTextSelection() -> some View {
#if os(macOS)
textSelection(.enabled)
#else
self
#endif
}
}
#Preview("Входящее") {
MessageBubble(
model: PreviewSupport.model,
message: PreviewSupport.message(),
isSelectionMode: false,
isSelected: false,
onAttachmentTap: {},
onEdit: {},
onReply: {},
onForward: {},
onRetry: {},
onToggleSelection: {},
onBeginSelection: {},
onRetract: {},
onDelete: {},
onReplyTap: { _ in },
onReact: { _ in },
onReactPicker: {}
)
.padding()
}
#Preview("Исходящее") {
MessageBubble(
model: PreviewSupport.model,
message: PreviewSupport.message(
senderJID: "me@example.org",
body: "Отлично! Созвонимся вечером?",
direction: .outgoing,
delivery: .delivered
),
isSelectionMode: false,
isSelected: false,
onAttachmentTap: {},
onEdit: {},
onReply: {},
onForward: {},
onRetry: {},
onToggleSelection: {},
onBeginSelection: {},
onRetract: {},
onDelete: {},
onReplyTap: { _ in },
onReact: { _ in },
onReactPicker: {}
)
.padding()
}
#Preview("Фото") {
MessageBubble(
model: PreviewSupport.model,
message: PreviewSupport.photoMessage(),
isSelectionMode: false,
isSelected: false,
onAttachmentTap: {},
onEdit: {},
onReply: {},
onForward: {},
onRetry: {},
onToggleSelection: {},
onBeginSelection: {},
onRetract: {},
onDelete: {},
onReplyTap: { _ in },
onReact: { _ in },
onReactPicker: {}
)
.padding()
}
@@ -0,0 +1,87 @@
import SwiftUI
/// Messages-style entrance for chat rows.
///
/// Opening a conversation reveals its first page as a bottom-up cascade
/// (`.cascade`), and a row appended while the timeline is already rendered
/// springs out of the composer's send control when outgoing (`.launch`).
/// Everything else stays `.settled`, which keeps history scrolling, reactions
/// and selection updates free of animation.
struct MessageBubbleEntrance: ViewModifier {
enum Mode: Equatable {
case settled
case cascade(delay: TimeInterval)
case launch(outgoing: Bool)
var isSettled: Bool {
if case .settled = self { return true }
return false
}
}
let mode: Mode
// A settled row must never pass through an invisible frame, so only the
// rows that actually animate start hidden.
@State private var isRevealed: Bool
@State private var hasStarted = false
init(mode: Mode) {
self.mode = mode
_isRevealed = State(initialValue: mode.isSettled)
}
func body(content: Content) -> some View {
content
.opacity(isRevealed ? 1 : 0)
.scaleEffect(isRevealed ? 1 : displacedScale, anchor: scaleAnchor)
.offset(y: isRevealed ? 0 : displacedRise)
.onAppear(perform: reveal)
}
/// Outgoing bubbles shrink toward the trailing bottom corner, where the
/// composer's send control sits; incoming ones grow from their own edge.
private var displacedScale: CGFloat {
switch mode {
case .settled, .cascade:
return ChatMessageEntrancePolicy.entranceScale
case .launch(let outgoing):
return outgoing
? ChatMessageEntrancePolicy.sendScale
: ChatMessageEntrancePolicy.receiveScale
}
}
private var scaleAnchor: UnitPoint {
if case .launch(let outgoing) = mode, outgoing { return .bottomTrailing }
return .bottom
}
private var displacedRise: CGFloat {
switch mode {
case .settled, .cascade:
return ChatMessageEntrancePolicy.entranceRise
case .launch(let outgoing):
return outgoing
? ChatMessageEntrancePolicy.sendRise
: ChatMessageEntrancePolicy.receiveRise
}
}
private func reveal() {
guard !hasStarted else { return }
hasStarted = true
guard !isRevealed else { return }
let delay: TimeInterval
if case .cascade(let cascadeDelay) = mode {
delay = cascadeDelay
} else {
delay = 0
}
withAnimation(
ChatMessageEntrancePolicy.appendAnimation.delay(delay)
) {
isRevealed = true
}
}
}
@@ -124,10 +124,19 @@ struct PhotoAttachmentPreview: View {
private func openViewer() {
Task {
await model.presentMediaViewer(message)
if model.mediaViewerItem?.id != message.id,
if model.mediaViewerItem?.id != message.clientID,
message.remoteAttachmentURL == nil {
onFallbackTap()
}
}
}
}
#Preview {
PhotoAttachmentPreview(
model: PreviewSupport.model,
message: PreviewSupport.photoMessage(),
onFallbackTap: {}
)
.padding()
}
@@ -97,3 +97,9 @@ struct RTCVideoRendererView: NSViewRepresentable {
}
}
#endif
#Preview {
RTCVideoRendererView(track: nil)
.frame(width: 280, height: 320)
.background(Color.black)
}
@@ -71,12 +71,13 @@ struct ReplyThreadOverlay: View {
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .bold))
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
.frame(width: 32, height: 32)
.background(.white.opacity(0.12), in: Circle())
.padding(7)
}
.buttonStyle(.plain)
.buttonStyle(.glass)
.buttonBorderShape(.circle)
.background(.clear)
.keyboardShortcut(.cancelAction)
.accessibilityLabel("Закрыть ветку ответов")
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
@@ -112,10 +113,10 @@ struct ReplyThreadOverlay: View {
model: model,
message: reply,
maximumBubbleWidth: maximumBubbleWidth,
isSelected: reply.id == selectedReplyID,
showsDeliveryStatus: reply.id == selectedReplyID
isSelected: reply.clientID == selectedReplyID,
showsDeliveryStatus: reply.clientID == selectedReplyID
)
.id(reply.id)
.id(reply.clientID)
}
}
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
@@ -338,6 +339,7 @@ private struct ReplyThreadMessageRow: View {
case .sent: state = "Отправлено"
case .delivered: state = "Доставлено"
case .failed: state = "Не отправлено"
case .read: state = "Прочитано"
}
let value = Calendar.current.isDateInToday(message.timestamp)
? message.timestamp.formatted(date: .omitted, time: .shortened)
@@ -345,3 +347,27 @@ private struct ReplyThreadMessageRow: View {
return "\(state) \(value)"
}
}
#Preview {
ReplyThreadOverlay(
model: PreviewSupport.model,
rootMessage: PreviewSupport.message(
body: "Кто пойдёт на конференцию?",
timestamp: Date().addingTimeInterval(-3_600)
),
replies: [
PreviewSupport.message(
id: "preview-reply-1",
body: "Я пойду!",
timestamp: Date().addingTimeInterval(-2_400)
),
PreviewSupport.message(
id: "preview-reply-2",
body: "Я тоже, но опоздаю на полчаса",
timestamp: Date().addingTimeInterval(-1_800)
),
],
selectedReplyID: "preview-reply-1",
onDismiss: {}
)
}
@@ -0,0 +1,17 @@
//
// SwiftChatView.swift
// Luma
//
// Created by tola on 9/14/26.
//
//import SwiftUI
//import SwiftChat
//
//struct SwiftChatView: View {
// var body: some View {
// Chat(messages) { message in
// Message(...)
// }
// }
//}
@@ -142,10 +142,19 @@ struct VideoAttachmentPreview: View {
private func openViewer() {
Task {
await model.presentMediaViewer(message)
if model.mediaViewerItem?.id != message.id,
if model.mediaViewerItem?.id != message.clientID,
message.remoteAttachmentURL == nil {
onFallbackTap()
}
}
}
}
#Preview {
VideoAttachmentPreview(
model: PreviewSupport.model,
message: PreviewSupport.videoMessage(),
onFallbackTap: {}
)
.padding()
}
@@ -50,7 +50,7 @@ struct VideoNotePreview: View {
VStack {
Spacer()
HStack {
Spacer()
// Spacer()
Text(durationText)
.font(.caption2.monospacedDigit().weight(.semibold))
.foregroundStyle(.white)
@@ -82,7 +82,7 @@ struct VideoNotePreview: View {
}
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
guard let activeID = notification.object as? String,
activeID != message.id else { return }
activeID != message.clientID else { return }
player?.pause()
isPlaying = false
}
@@ -146,10 +146,19 @@ struct VideoNotePreview: View {
model.audioPlayback.stop()
NotificationCenter.default.post(
name: .lumaExclusiveMediaPlayback,
object: message.id
object: message.clientID
)
player.play()
}
isPlaying.toggle()
}
}
#Preview {
VideoNotePreview(
model: PreviewSupport.model,
message: PreviewSupport.videoNoteMessage(),
onFallbackTap: {}
)
.padding()
}
+38 -15
View File
@@ -16,19 +16,21 @@ struct EncryptionDevicesView: View {
)
.foregroundStyle(statusColor)
} footer: {
Text("Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное.")
Text(
"Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное."
)
}
if devices.isEmpty {
Section("Устройства контакта") {
Section("Устройства контакта") {
if devices.isEmpty {
ContentUnavailableView(
"Ключи ещё не получены",
systemImage: "key.horizontal",
description: Text("Они появятся после первой попытки отправки или получения OMEMO-сообщения.")
description: Text(
"Они появятся после первой попытки отправки или получения OMEMO-сообщения."
)
)
}
} else {
Section("Устройства контакта") {
} else {
ForEach(devices) { device in
DeviceRow(device: device) {
model.setDeviceVerified(
@@ -58,9 +60,9 @@ struct EncryptionDevicesView: View {
}
.onAppear(perform: reload)
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 500)
#endif
#if os(macOS)
.frame(minWidth: 480, minHeight: 500)
#endif
}
private func reload() {
@@ -68,7 +70,9 @@ struct EncryptionDevicesView: View {
}
private var statusTitle: String {
guard model.encryptionEnabled(for: conversation.jid) else { return "OMEMO выключено для этого чата" }
guard model.encryptionEnabled(for: conversation.jid) else {
return "OMEMO выключено для этого чата"
}
return model.isOMEMOReady ? "OMEMO активно" : "OMEMO подготавливается"
}
@@ -89,8 +93,20 @@ private struct DeviceRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Устройство \(device.deviceID)", systemImage: device.isActive ? "iphone" : "iphone.slash")
.font(.headline)
Label(
"Устройство \(device.deviceID)",
systemImage: device.isActive ? "iphone" : "iphone.slash"
)
.font(.headline)
Text(device.protocolName)
.font(.caption2.weight(.medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.foregroundStyle(device.isOMEMO2 ? Color.indigo : Color.secondary)
.background(
(device.isOMEMO2 ? Color.indigo : Color.secondary).opacity(0.14),
in: Capsule()
)
Spacer()
Button(action: toggleVerification) {
Label(trustTitle, systemImage: trustIcon)
@@ -134,8 +150,8 @@ private struct DeviceRow: View {
}
}
private extension String {
func chunked(every size: Int) -> [String] {
extension String {
fileprivate func chunked(every size: Int) -> [String] {
guard size > 0 else { return [self] }
var result: [String] = []
var cursor = startIndex
@@ -147,3 +163,10 @@ private extension String {
return result
}
}
#Preview {
EncryptionDevicesView(
model: PreviewSupport.model,
conversation: PreviewSupport.conversation(displayName: "Алиса")
)
}
+35 -2
View File
@@ -1,3 +1,4 @@
import SwiftData
import SwiftUI
@MainActor
@@ -10,6 +11,9 @@ struct ForwardMessageView: View {
@State private var destinationJID = ""
@State private var isForwarding = false
/// SwiftData-backed destination list, ordered like the main chat list.
@Query private var conversations: [Conversation]
init(
model: AppModel,
messages: [ChatMessage],
@@ -18,6 +22,11 @@ struct ForwardMessageView: View {
_model = ObservedObject(wrappedValue: model)
self.messages = messages.filter(\.canBeForwarded)
self.onComplete = onComplete
_conversations = Query(
sort: [
SortDescriptor(\Conversation.lastActivity, order: .reverse),
]
)
}
var body: some View {
@@ -39,9 +48,9 @@ struct ForwardMessageView: View {
}
}
if !model.conversations.isEmpty {
if !orderedConversations.isEmpty {
Section("Чаты") {
ForEach(model.conversations) { conversation in
ForEach(orderedConversations) { conversation in
Button {
forward(to: conversation.jid)
} label: {
@@ -105,6 +114,14 @@ struct ForwardMessageView: View {
#endif
}
/// Pinned-first ordering, matching the main chat list.
private var orderedConversations: [Conversation] {
conversations.sorted { lhs, rhs in
if lhs.isPinned != rhs.isPinned { return lhs.isPinned }
return lhs.lastActivity > rhs.lastActivity
}
}
private func forward(to jid: String) {
guard !isForwarding, !messages.isEmpty else { return }
isForwarding = true
@@ -135,3 +152,19 @@ struct ForwardMessageView: View {
return "\(count) \(word)"
}
}
#Preview {
ForwardMessageView(
model: PreviewSupport.model,
messages: [
PreviewSupport.message(body: "Привет! Как дела?"),
PreviewSupport.message(
senderJID: "me@example.org",
body: "Созвонимся вечером?",
direction: .outgoing,
delivery: .sent
),
]
)
.modelContainer(PreviewSupport.container)
}
+34 -1
View File
@@ -11,6 +11,7 @@ struct GroupInfoView: View {
@State private var invitees = ""
@State private var isJoining = false
@State private var isInviting = false
@State private var confirmingDeletion = false
init(model: AppModel, conversation: Conversation) {
self.model = model
@@ -68,6 +69,16 @@ struct GroupInfoView: View {
}
}
Section {
Button(role: .destructive) {
confirmingDeletion = true
} label: {
Label("Удалить чат", systemImage: "trash")
}
} footer: {
Text("Чат и его история будут удалены с этого устройства.")
}
Section {
Label(
model.encryptionEnabled(for: conversation.jid)
@@ -85,6 +96,15 @@ struct GroupInfoView: View {
Button("Готово") { dismiss() }
}
}
.alert("Удалить групповой чат?", isPresented: $confirmingDeletion) {
Button("Удалить", role: .destructive) {
model.deleteGroupChat(jid: conversation.jid)
dismiss()
}
Button("Отмена", role: .cancel) {}
} message: {
Text("Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней.")
}
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 500)
@@ -92,7 +112,7 @@ struct GroupInfoView: View {
}
private var liveConversation: Conversation {
model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
model.conversations.first(where: { $0.jid == conversation.jid }) ?? conversation
}
private var parsedInvitees: [String] {
@@ -119,3 +139,16 @@ struct GroupInfoView: View {
}
}
}
#Preview {
GroupInfoView(
model: PreviewSupport.model,
conversation: PreviewSupport.conversation(
jid: "team@conference.example.org",
displayName: "Команда Luma",
kind: .group,
isGroupJoined: true,
occupantCount: 7
)
)
}
@@ -146,3 +146,7 @@ struct LocationPickerView: View {
dismiss()
}
}
#Preview {
LocationPickerView { _ in }
}
+87 -19
View File
@@ -2,10 +2,12 @@ import SwiftUI
struct LoginView: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var jid = ""
@State private var password = ""
@State private var passwordVisible = false
@State private var displayName = ""
@State private var resource = "Luma"
@State private var resource = ""
@State private var showAdvanced = false
@State private var manualHost = ""
@State private var manualPort = ""
@@ -17,7 +19,7 @@ struct LoginView: View {
colors: [
Color(red: 0.08, green: 0.38, blue: 0.78),
Color(red: 0.12, green: 0.62, blue: 0.96),
Color(red: 0.44, green: 0.84, blue: 0.96)
Color(red: 0.44, green: 0.84, blue: 0.96),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
@@ -76,32 +78,85 @@ struct LoginView: View {
Text("XMPP-аккаунт")
.font(.headline)
TextField("you@example.org", text: $jid)
.textFieldStyle(.roundedBorder)
#if os(iOS)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
#endif
.textContentType(.username)
.autocorrectionDisabled()
.textFieldStyle(.plain)
.disableAutocorrection(true)
.padding()
.glassEffect()
}
VStack(alignment: .leading, spacing: 7) {
Text("Пароль")
.font(.headline)
SecureField("Пароль", text: $password)
HStack(spacing: 8) {
Group {
if passwordVisible {
TextField("Пароль", text: $password)
.textContentType(.oneTimeCode)
.textFieldStyle(.plain)
} else {
SecureField("Пароль", text: $password)
.textContentType(.password)
.textFieldStyle(.plain)
}
}
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.textFieldStyle(.roundedBorder)
Button {
passwordVisible.toggle()
} label: {
Image(systemName: passwordVisible ? "eye.slash.fill" : "eye.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(passwordVisible ? "Скрыть пароль" : "Показать пароль")
}
.padding()
.glassEffect()
}
DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) {
VStack(spacing: 14) {
TextField("Отображаемое имя", text: $displayName)
.textFieldStyle(.roundedBorder)
TextField("Ресурс устройства", text: $resource)
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.padding()
.glassEffect()
TextField("Автоматически (Luma-XXXX)", text: $resource)
.textFieldStyle(.plain)
.padding()
.glassEffect()
Text(
"Оставьте пустым — Luma создаст уникальный ресурс для этого устройства, чтобы macOS и iOS не конфликтовали между собой."
)
.font(.caption)
.foregroundStyle(.secondary)
TextField("Сервер вручную (необязательно)", text: $manualHost)
.textFieldStyle(.roundedBorder)
TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort)
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.padding()
.glassEffect()
TextField(
directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort
)
.textFieldStyle(.plain)
.padding()
.glassEffect()
Toggle("Direct TLS", isOn: $directTLS)
Text("Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.toggleStyle(.switch)
Text(
"Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS."
)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.top, 14)
}
@@ -119,14 +174,17 @@ struct LoginView: View {
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.buttonStyle(.glassProminent)
.controlSize(.large)
.disabled(model.isAuthenticating)
Label("Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.", systemImage: "lock.shield.fill")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Label(
"Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.",
systemImage: "lock.shield.fill"
)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(26)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
@@ -144,6 +202,16 @@ struct LoginView: View {
usesDirectTLS: directTLS
)
await model.signIn(account: account, password: password)
// When LoginView is presented from Settings, close the sheet only
// after AppModel has completed a successful sign-in. On failure the
// model clears the active account, so the form remains available for
// correcting the credentials.
guard model.account?.normalizedJID == account.normalizedJID else { return }
dismiss()
}
}
#Preview {
LoginView(model: PreviewSupport.model)
}
-268
View File
@@ -1,268 +0,0 @@
import Foundation
import SwiftUI
struct MainChatView: View {
private enum SidebarMode: String, CaseIterable {
case chats
case contacts
var title: String {
switch self {
case .chats: return "Чаты"
case .contacts: return "Контакты"
}
}
}
@ObservedObject var model: AppModel
@State private var searchText = ""
@State private var sidebarMode: SidebarMode = .chats
@State private var showingNewChat = false
@State private var showingNewGroup = false
@State private var showingSettings = false
private var filteredConversations: [Conversation] {
guard !searchText.isEmpty else { return model.conversations }
return model.conversations.filter(matchesSearch)
}
private var filteredRosterContacts: [Conversation] {
model.conversations
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.id) }
.filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort)
}
private var filteredGroupContacts: [Conversation] {
model.conversations
.filter { $0.isGroup }
.filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort)
}
private var hasStoredContacts: Bool {
!model.rosterContactJIDs.isEmpty || model.conversations.contains { $0.isGroup }
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
ConnectionBanner(model: model)
Picker("Раздел", selection: $sidebarMode) {
Label("Чаты", systemImage: "bubble.left.and.bubble.right.fill")
.tag(SidebarMode.chats)
Label("Контакты", systemImage: "person.2.fill")
.tag(SidebarMode.contacts)
}
.pickerStyle(.segmented)
.padding(.horizontal, 12)
.padding(.vertical, 8)
switch sidebarMode {
case .chats:
chatList
case .contacts:
contactsList
}
}
.navigationTitle(sidebarMode.title)
.searchable(
text: $searchText,
prompt: sidebarMode == .chats ? "Поиск чатов" : "Поиск контактов"
)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button { showingSettings = true } label: {
Image(systemName: "gearshape")
}
Button { showingNewGroup = true } label: {
Image(systemName: "person.3.fill")
}
.accessibilityLabel("Новый групповой чат")
.help("Новый групповой чат")
Button { showingNewChat = true } label: {
Image(systemName: "square.and.pencil")
}
.accessibilityLabel("Новый личный чат")
.help("Новый личный чат")
}
}
} detail: {
if let conversation = model.selectedConversation {
ChatView(model: model, conversation: conversation)
.id(conversation.id)
} else {
WelcomeDetailView()
}
}
.navigationSplitViewStyle(.balanced)
.onChange(of: model.selectedConversationID) { _, id in
guard let id else { return }
model.selectConversation(id: id)
}
.sheet(isPresented: $showingNewChat) {
NewChatView(model: model)
}
.sheet(isPresented: $showingNewGroup) {
NewGroupView(model: model)
}
.sheet(isPresented: $showingSettings) {
SettingsView(model: model)
}
}
private var chatList: some View {
List(selection: $model.selectedConversationID) {
ForEach(filteredConversations) { conversation in
ConversationRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
isEncrypted: model.encryptionEnabled(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
}
}
.listStyle(.plain)
.overlay {
if filteredConversations.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет чатов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass",
description: Text(
searchText.isEmpty
? "Создайте личный или групповой чат."
: "Попробуйте другой JID или имя."
)
)
}
}
}
private var contactsList: some View {
List(selection: $model.selectedConversationID) {
if !filteredRosterContacts.isEmpty {
Section {
ForEach(filteredRosterContacts) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
} header: {
Label("Люди из roster", systemImage: "person.2.fill")
}
}
if !filteredGroupContacts.isEmpty {
Section {
ForEach(filteredGroupContacts) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
} header: {
Label("Групповые чаты", systemImage: "person.3.fill")
}
}
}
.listStyle(.plain)
.overlay {
if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет контактов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass",
description: Text(contactListDescription)
)
}
}
}
private var contactListDescription: String {
if !searchText.isEmpty {
return "Попробуйте другой JID или имя."
}
if !hasStoredContacts, model.connectionStatus != .connected {
return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты."
}
return "Добавьте человека в roster или создайте групповой чат."
}
private func matchesSearch(_ conversation: Conversation) -> Bool {
conversation.displayName.localizedCaseInsensitiveContains(searchText)
|| conversation.jid.localizedCaseInsensitiveContains(searchText)
}
private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
}
}
private struct ContactRow: View {
let conversation: Conversation
let imageData: Data?
var body: some View {
HStack(spacing: 12) {
AvatarView(conversation: conversation, imageData: imageData, size: 42)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(conversation.displayName)
.font(.headline)
.lineLimit(1)
if !conversation.isGroup, conversation.isOnline {
Circle()
.fill(Color.green)
.frame(width: 7, height: 7)
.accessibilityLabel("В сети")
}
}
if conversation.isGroup {
HStack(spacing: 4) {
Image(systemName: "person.3.fill")
Text(groupSubtitle)
}
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(conversation.jid)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.bold())
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
private var groupSubtitle: String {
guard conversation.occupantCount > 0 else { return conversation.jid }
return "\(conversation.occupantCount) участн. • \(conversation.jid)"
}
}
private struct WelcomeDetailView: View {
var body: some View {
ZStack {
Color.secondary.opacity(0.04).ignoresSafeArea()
ContentUnavailableView {
Label("Luma", systemImage: "bubble.left.and.bubble.right.fill")
} description: {
Text("Выберите чат или начните новый защищённый диалог.")
}
}
}
}
File diff suppressed because it is too large. Load diff
+48 -12
View File
@@ -8,18 +8,10 @@ struct NewChatView: View {
@State private var addToRoster = true
var body: some View {
#if os(iOS)
NavigationStack {
Form {
Section("Контакт") {
TextField("user@example.org", text: $jid)
.disableAutocorrection(true)
TextField("Имя (необязательно)", text: $name)
}
Section {
Toggle("Добавить в roster", isOn: $addToRoster)
} footer: {
Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.")
}
formcontent()
}
.navigationTitle("Новый чат")
.toolbar {
@@ -40,8 +32,52 @@ struct NewChatView: View {
}
}
}
#if os(macOS)
.frame(minWidth: 380, minHeight: 300)
#else
List {
formcontent()
HStack {
Button("Отмена") { dismiss() }
.buttonStyle(.glass)
.controlSize(.large)
.tint(.secondary)
Spacer()
Button("Начать") {
model.openConversation(
jid: jid,
name: name.isEmpty ? nil : name,
addToRoster: addToRoster
)
if model.errorMessage == nil { dismiss() }
}
.fontWeight(.semibold)
.disabled(jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.buttonStyle(.glassProminent)
.controlSize(.large)
}
}
.navigationTitle("Новый чат")
.frame(minWidth: 380, minHeight: 220)
#endif
}
@ViewBuilder
private func formcontent() -> some View {
Section("Контакт") {
TextField("user@example.org", text: $jid)
.disableAutocorrection(true)
#if os(iOS)
.keyboardType(.emailAddress)
#endif
TextField("Имя (необязательно)", text: $name)
}
Section {
Toggle("Добавить в roster", isOn: $addToRoster)
.toggleStyle(.switch)
} footer: {
Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.")
}
}
}
#Preview {
NewChatView(model: PreviewSupport.model)
}
+70 -38
View File
@@ -13,59 +13,87 @@ struct NewGroupView: View {
@State private var isJoining = false
var body: some View {
NavigationStack {
Form {
GroupBox {
VStack(alignment: .leading, spacing: 12) {
TextField("room@conference.example.org", text: $roomJID)
.disableAutocorrection(true)
TextField("Название (необязательно)", text: $name)
TextField("Ваш псевдоним", text: $nickname)
}
} label: {
Text("Комната")
.font(.headline)
#if os(iOS)
NavigationStack {
Form {
formcontent()
}
GroupBox {
VStack(alignment: .leading, spacing: 8) {
TextField("alice@example.org, bob@example.org", text: $invitees, axis: .vertical)
.lineLimit(2...5)
Text("Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию.")
.font(.footnote)
.foregroundStyle(.secondary)
.navigationTitle("Новая группа")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
.disabled(isJoining)
}
ToolbarItem(placement: .confirmationAction) {
Button("Создать / войти") { join() }
.fontWeight(.semibold)
.disabled(!canJoin || isJoining)
}
}
.onAppear {
if nickname.isEmpty {
nickname = model.suggestedGroupNickname
}
} label: {
Text("Пригласить")
.font(.headline)
}
Label(
"Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.",
systemImage: "lock.shield.fill"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
.navigationTitle("Новая группа")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
#else
List {
formcontent()
HStack {
Button("Отмена") { dismiss() }
.disabled(isJoining)
}
ToolbarItem(placement: .confirmationAction) {
.buttonStyle(.glass)
.tint(.secondary)
.controlSize(.large)
Spacer()
Button("Создать / войти") { join() }
.fontWeight(.semibold)
.disabled(!canJoin || isJoining)
.buttonStyle(.glassProminent)
.controlSize(.large)
}
}
.navigationTitle("Новая группа")
.onAppear {
if nickname.isEmpty { nickname = model.suggestedGroupNickname }
}
.frame(minWidth: 480, minHeight: 330)
#endif
}
@ViewBuilder
private func formcontent() -> some View {
Section("Комната") {
TextField("room@conference.example.org", text: $roomJID)
.disableAutocorrection(true)
#if os(iOS)
.keyboardType(.emailAddress)
#endif
TextField("Название (необязательно)", text: $name)
TextField("Ваш псевдоним", text: $nickname)
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 470)
#endif
Section("Пригласить") {
TextField(
"alice@example.org, bob@example.org",
text: $invitees,
axis: .vertical
)
.lineLimit(2...5)
#if os(iOS)
.keyboardType(.emailAddress)
#endif
Text(
"Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию."
)
.font(.footnote)
.foregroundStyle(.secondary)
}
Label(
"Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.",
systemImage: "lock.shield.fill"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
private var canJoin: Bool {
@@ -92,3 +120,7 @@ struct NewGroupView: View {
.filter { !$0.isEmpty }
}
}
#Preview {
NewGroupView(model: PreviewSupport.model)
}
+300
View File
@@ -0,0 +1,300 @@
import Foundation
import SwiftData
import SwiftUI
/// Shared fixtures for SwiftUI previews.
///
/// Building an `AppModel` wires up the XMPP service, the WebRTC call engine
/// and the keychain-backed stores, so previews share one instance instead of
/// rebuilding it per canvas (the model skips account bootstrap inside the
/// preview host). SwiftData screens share one stable in-memory container and
/// seeded context so `@Query`-backed lists render sample data without
/// touching the real archive or re-fetching on every body evaluation.
enum PreviewSupport {
/// Single shared model: no account, no network traffic, connection is
/// "disconnected" — every screen renders in its resting state.
static let model: AppModel = MainActor.assumeIsolated { AppModel() }
// MARK: - SwiftData
/// Shared in-memory container for `@Query`-backed previews. One stable
/// container/context pair keeps SwiftUI list diffing from seeing a
/// brand-new fetch result on every body evaluation.
static let container: ModelContainer = {
do {
return try ModelContainer(
for: Conversation.self, ChatMessage.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
} catch {
fatalError("Failed to create the preview container: \(error)")
}
}()
/// Seeded context: two direct chats, one group and a short Alice thread.
@MainActor
static let previewContext: ModelContext = {
let context = container.mainContext
let alice = conversation(
jid: "alice@example.org",
displayName: "Алиса",
lastMessage: "Договорились 👌",
lastActivity: Date().addingTimeInterval(-120),
unreadCount: 2,
isOnline: true
)
let bob = conversation(
jid: "bob@example.org",
displayName: "Боб",
lastMessage: "Файл: отчёт.pdf",
lastActivity: Date().addingTimeInterval(-3_600)
)
let team = conversation(
jid: "team@conference.example.org",
displayName: "Команда Luma",
lastMessage: "Иван: созвон в 15:00",
lastActivity: Date().addingTimeInterval(-86_400),
unreadCount: 5,
kind: .group,
isGroupJoined: true,
occupantCount: 7
)
context.insert(alice)
context.insert(bob)
context.insert(team)
let aliceJID = alice.jid
context.insert(
message(
conversationID: aliceJID,
senderJID: aliceJID,
body: "Привет! Как дела?",
timestamp: Date().addingTimeInterval(-600),
direction: .incoming,
delivery: .delivered
)
)
context.insert(
message(
conversationID: aliceJID,
senderJID: "me@example.org",
body: "Отлично! Созвонимся вечером?",
timestamp: Date().addingTimeInterval(-420),
direction: .outgoing,
delivery: .sent
)
)
context.insert(
message(
conversationID: aliceJID,
senderJID: aliceJID,
body: "Договорились 👌",
timestamp: Date().addingTimeInterval(-60),
direction: .incoming,
delivery: .delivered
)
)
return context
}()
// MARK: - Models
static func conversation(
jid: String = "alice@example.org",
displayName: String? = nil,
lastMessage: String = "",
lastActivity: Date = .distantPast,
unreadCount: Int = 0,
isOnline: Bool = false,
isPinned: Bool = false,
encryptionPreference: EncryptionPreference = .inheritGlobal,
kind: Conversation.Kind = .direct,
groupNickname: String? = nil,
isGroupJoined: Bool = false,
shouldAutojoin: Bool = false,
occupantCount: Int = 0,
invitedBy: String? = nil
) -> Conversation {
Conversation(
jid: jid,
displayName: displayName,
lastMessage: lastMessage,
lastActivity: lastActivity,
unreadCount: unreadCount,
isOnline: isOnline,
isPinned: isPinned,
encryptionPreference: encryptionPreference,
kind: kind,
groupNickname: groupNickname,
isGroupJoined: isGroupJoined,
shouldAutojoin: shouldAutojoin,
occupantCount: occupantCount,
invitedBy: invitedBy
)
}
static func message(
id: String = UUID().uuidString,
conversationID: String = "alice@example.org",
senderJID: String = "alice@example.org",
body: String = "Привет! Как дела?",
timestamp: Date = Date(),
direction: ChatMessage.Direction = .incoming,
delivery: ChatMessage.Delivery = .delivered,
security: ChatMessage.Security = .omemo,
kind: ChatMessage.Kind = .text,
remoteAttachmentURL: String? = nil,
localFilename: String? = nil,
mimeType: String? = nil,
duration: TimeInterval? = nil,
byteCount: Int? = nil,
senderDisplayName: String? = nil,
isGroupMessage: Bool = false
) -> ChatMessage {
ChatMessage(
id: id,
conversationID: conversationID,
senderJID: senderJID,
body: body,
timestamp: timestamp,
direction: direction,
delivery: delivery,
security: security,
kind: kind,
remoteAttachmentURL: remoteAttachmentURL,
localFilename: localFilename,
mimeType: mimeType,
duration: duration,
byteCount: byteCount,
senderDisplayName: senderDisplayName,
isGroupMessage: isGroupMessage
)
}
static func photoMessage(
body: String = "Фото с прогулки",
filename: String = "IMG_1234.jpg",
byteCount: Int = 2_420_112
) -> ChatMessage {
message(
body: body,
kind: .photo,
localFilename: filename,
mimeType: "image/jpeg",
byteCount: byteCount
)
}
static func videoMessage(
body: String = "Видео с концерта",
filename: String = "IMG_5678.mov",
duration: TimeInterval = 42,
byteCount: Int = 18_330_240
) -> ChatMessage {
message(
body: body,
kind: .video,
localFilename: filename,
mimeType: "video/quicktime",
duration: duration,
byteCount: byteCount
)
}
static func videoNoteMessage(
body: String = "",
duration: TimeInterval = 12
) -> ChatMessage {
message(
body: body,
kind: .videoNote,
mimeType: "video/mp4",
duration: duration,
byteCount: 1_840_000
)
}
static func voiceMessage(
body: String = "",
duration: TimeInterval = 9
) -> ChatMessage {
message(
body: body,
kind: .voice,
mimeType: "audio/m4a",
duration: duration,
byteCount: 180_000
)
}
static func callSnapshot(
peerJID: String = "alice@example.org",
direction: CallDirection = .incoming,
media: Set<CallMedia> = [.video],
phase: CallPhase = .ringing,
isMuted: Bool = false,
isCameraEnabled: Bool = true,
isSpeakerEnabled: Bool = true
) -> CallSnapshot {
CallSnapshot(
id: UUID(),
peerJID: peerJID,
direction: direction,
media: media,
phase: phase,
connectedAt: phase == .connected ? Date().addingTimeInterval(-75) : nil,
isMuted: isMuted,
isCameraEnabled: isCameraEnabled,
isSpeakerEnabled: isSpeakerEnabled,
hasLocalVideo: media.contains(.video),
hasRemoteVideo: media.contains(.video)
)
}
static func drafts() -> [AttachmentDraft] {
[
AttachmentDraft(
url: URL(fileURLWithPath: "/tmp/preview-photo.jpg"),
filename: "IMG_1234.jpg",
mimeType: "image/jpeg",
kind: .photo,
byteCount: 2_420_112
),
AttachmentDraft(
url: URL(fileURLWithPath: "/tmp/preview-video.mov"),
filename: "IMG_5678.mov",
mimeType: "video/quicktime",
kind: .video,
duration: 21,
byteCount: 18_330_240
),
]
}
// MARK: - Composed scenes
/// Populated chat screen: a conversation plus the seeded Alice thread so
/// the `@Query`-driven timeline renders bubbles instead of an empty
/// state.
@MainActor
static func chatPreview() -> some View {
let conversation = conversation(
jid: "alice@example.org",
displayName: "Алиса",
lastMessage: "Договорились 👌",
lastActivity: Date().addingTimeInterval(-60),
isOnline: true
)
return ChatView(model: model, conversation: conversation)
.environment(\.modelContext, previewContext)
}
/// Main screen backed by the seeded in-memory context so the chat list
/// shows conversations on both platforms.
@MainActor
static func mainPreview() -> some View {
MainTabView(model: model)
.environment(\.modelContext, previewContext)
}
}
+66 -15
View File
@@ -1,40 +1,87 @@
import QuickLook
import SwiftData
import SwiftUI
struct RootView: View {
@ObservedObject var model: AppModel
@State private var toastDismissTask: Task<Void, Never>?
var body: some View {
ZStack {
Group {
if model.account == nil {
LoginView(model: model)
} else {
MainChatView(model: model)
if model.isAppLocked {
// The lock covers every layer: chat list, media viewer and
// calls stay hidden until the passcode or biometrics succeed.
AppLockView(model: model)
} else {
Group {
if model.account == nil {
LoginView(model: model)
} else {
MainTabView(model: model)
.environment(\.modelContext, model.modelContext)
}
}
}
if let item = model.mediaViewerItem {
MediaViewer(item: item, onClose: model.closeMediaViewer)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
.zIndex(100)
}
if let item = model.mediaViewerItem {
MediaViewer(item: item, onClose: model.closeMediaViewer)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
.zIndex(100)
}
if let call = model.activeCall {
CallView(model: model, call: call)
.transition(.opacity.combined(with: .scale(scale: 1.015)))
.zIndex(200)
if let call = model.activeCall {
CallView(model: model, call: call)
.transition(.opacity.combined(with: .scale(scale: 1.015)))
.zIndex(200)
}
if let info = model.informationalMessage {
VStack {
toast(info)
Spacer()
}
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(300)
}
}
}
.animation(.easeInOut(duration: 0.22), value: model.account?.id)
.animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id)
.animation(.easeInOut(duration: 0.2), value: model.activeCall?.id)
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: model.informationalMessage)
.alert("Luma", isPresented: errorBinding) {
Button("OK") { model.clearError() }
} message: {
Text(model.errorMessage ?? "Неизвестная ошибка")
}
.quickLookPreview($model.previewURL)
.onChange(of: model.informationalMessage) { _, newValue in
guard newValue != nil else { return }
toastDismissTask?.cancel()
toastDismissTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 2_500_000_000)
guard !Task.isCancelled else { return }
model.clearInformationalMessage()
}
}
}
/// Compact Telegram-style confirmation toast shown for a couple of
/// seconds above every layer (lists, media viewer, calls).
private func toast(_ message: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
Text(message)
.font(.subheadline.weight(.medium))
.lineLimit(2)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(Color.primary.opacity(0.06)))
.shadow(color: .black.opacity(0.18), radius: 12, y: 4)
.padding(.top, 8)
.accessibilityIdentifier("info-toast")
}
private var errorBinding: Binding<Bool> {
@@ -44,3 +91,7 @@ struct RootView: View {
)
}
}
#Preview {
RootView(model: PreviewSupport.model)
}
+404
View File
@@ -0,0 +1,404 @@
import SwiftUI
/// "Server information" screen shown from Settings, mirroring Monal's
/// `ServerDetails`: a grouped list of headline + caption entries whose row
/// background is tinted green/orange/red according to the detected status.
struct ServerInfoView: View {
@ObservedObject var model: AppModel
@State private var isLoading = false
var body: some View {
#if os(iOS)
Form {
infocontent()
}
.navigationTitle(model.account?.domain ?? "Сервер")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await load() }
} label: {
Label("Обновить", systemImage: "arrow.clockwise")
}
.disabled(isLoading)
}
}
.task { await load() }
.listStyle(.grouped)
#else
List {
infocontent()
}
.navigationTitle(model.account?.domain ?? "Сервер")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await load() }
} label: {
Label("Обновить", systemImage: "arrow.clockwise")
}
.disabled(isLoading)
}
}
.task { await load() }
.listStyle(.inset)
.frame(minWidth: 480, minHeight: 420)
#endif
}
@ViewBuilder
private func infocontent() -> some View {
if let info = model.serverInformation {
Section("Это статистика вашего соединения.") {
statisticsEntries(info)
}
Section("Это программное обеспечение, работающее на вашем сервере.") {
softwareEntry(info)
}
Section {
connectionRows(info)
} header: {
Text("Параметры подключения к вашему серверу.")
} footer: {
Text(
"Luma автоматически выбирает самый безопасный вариант: при TLS договаривается о максимальной версии (1.3 > 1.2), а при входе — о самом стойком SASL-механизме, который поддерживает сервер."
)
}
Section("Современные возможности XMPP, обнаруженные на вашем сервере после входа.") {
ForEach(info.capabilities) { capability in
entry(
title: capability.title,
detail: capability.detail,
status: capability.status
)
}
}
Section("MUC-серверы, обнаруженные на вашем сервере.") {
mucEntries(info)
}
Section("STUN и TURN сервисы, объявленные вашим сервером.") {
stunTurnEntries(info)
}
Section("Методы аутентификации SASL, которые поддерживает ваш сервер.") {
saslEntries(info)
}
Section("Методы аутентификации SASL2 (RFC 9050), которые поддерживает ваш сервер.") {
sasl2Entries(info)
}
Section {
channelBindingEntries(info)
} header: {
Text("Привязка канала (channel binding), которую поддерживает ваш сервер.")
} footer: {
Text(
"Channel binding связывает SASL-обмен с TLS-каналом: MITM-ретрансляция не сможет подменить соединение. Зелёным отмечен тип, использованный в текущем соединении, оранжевым — типы, которые Luma не поддерживает."
)
}
} else if isLoading {
Section {
HStack(spacing: 12) {
ProgressView()
Text("Загрузка информации о сервере…")
.foregroundStyle(.secondary)
}
}
} else {
Section {
ContentUnavailableView(
"Нет данных о сервере",
systemImage: "server.rack",
description: Text(
model.connectionStatus == .connected
? "Сервер не ответил на запрос. Попробуйте обновить."
: "Подключитесь к серверу, чтобы увидеть его возможности."
)
)
}
}
}
// MARK: - Sections
private func softwareEntry(_ info: ServerInformation) -> some View {
let name = info.software.name ?? "Неизвестный сервер"
let version = info.software.version ?? "неизвестная версия"
let platform = info.software.os.map { ", работает на \($0)" } ?? ""
return VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(.headline)
Text("версия \(version)\(platform)")
.font(.caption)
.foregroundStyle(.secondary)
Link(
"Рекомендации для администраторов серверов",
destination: URL(
string: "https://github.com/monal-im/Monal/wiki/Considerations-for-XMPP-server-admins"
)!
)
.font(.caption)
}
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.gray.opacity(0.16))
}
private func connectionRows(_ info: ServerInformation) -> some View {
Group {
if let account = model.account {
if let host = account.manualHost {
let port = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222)
LabeledContent("Хост", value: "\(host):\(port)")
LabeledContent(
"Шифрование",
value: account.usesDirectTLS ? "Direct TLS" : "STARTTLS"
)
} else {
LabeledContent("Хост", value: "DNS SRV")
}
LabeledContent("Ресурс", value: account.effectiveResource)
}
if let tlsVersion = info.tlsVersion {
LabeledContent(
"Версия TLS",
value: info.tlsCipher.map { "\(tlsVersion) · \($0)" } ?? tlsVersion
)
} else {
LabeledContent("Версия TLS", value: "Неизвестно")
}
if let saslMechanism = info.saslMechanism {
LabeledContent("SASL-механизм", value: saslMechanism)
} else {
LabeledContent("SASL-механизм", value: "Неизвестно")
}
}
}
@ViewBuilder
private func mucEntries(_ info: ServerInformation) -> some View {
if info.conferenceServers.isEmpty {
entry(
title: "Нет",
detail: "Этот сервер не предоставляет MUC-серверы.",
status: .error
)
} else {
ForEach(info.conferenceServers) { server in
entry(
title: "Сервер: \(server.jid)",
detail: "\(server.name ?? "<неизвестное имя>") (тип «\(server.type)», категория «\(server.category)»)",
status: server.type == "text" ? .success : .normal
)
}
}
}
@ViewBuilder
private func stunTurnEntries(_ info: ServerInformation) -> some View {
if info.externalServices.isEmpty {
entry(
title: "Нет",
detail: "Этот сервер не предоставляет STUN / TURN сервисы.",
status: .error
)
} else {
ForEach(info.externalServices) { service in
let endpoint = "\(service.host):\(service.port.map(String.init) ?? "-")"
entry(
title: service.type.uppercased(),
detail: endpoint,
status: ["stun", "turn", "stuns", "turns"].contains(service.type.lowercased())
? .success
: .error
)
}
}
}
@ViewBuilder
private func statisticsEntries(_ info: ServerInformation) -> some View {
let stats = info.connectionStats
entry(
title: "Последний вход",
detail: formatDate(stats.lastLogin),
status: .normal
)
entry(
title: "Сессия Stream Management установлена",
detail: formatDate(stats.smacksSessionEstablished),
status: .normal
)
entry(
title: "Станз отправлено / не подтверждено / получено в этой сессии",
detail: "\(stats.sent) / \(stats.unacknowledged) / \(stats.received)",
status: .normal
)
}
@ViewBuilder
private func saslEntries(_ info: ServerInformation) -> some View {
if info.saslMethods.isEmpty {
entry(
title: "Нет",
detail: "Сервер не объявил методы SASL-аутентификации.",
status: .error
)
} else {
ForEach(info.saslMethods, id: \.self) { method in
entry(
title: "Метод: \(method)",
detail: method == info.usedSASLMechanism
? "Используется в текущем соединении. " + saslDescription(method)
: saslDescription(method),
status: method == info.usedSASLMechanism
? .success
: (SASLMechanismPreference.weakMechanisms.contains(method)
? .warning
: .normal)
)
}
}
}
@ViewBuilder
private func sasl2Entries(_ info: ServerInformation) -> some View {
if info.sasl2Methods.isEmpty {
entry(
title: "Нет",
detail: "Сервер не объявил методы аутентификации SASL2 (RFC 9050).",
status: .normal
)
} else {
ForEach(info.sasl2Methods, id: \.self) { method in
entry(
title: "Метод: \(method)",
detail: method == info.usedSASLMechanism
? "Используется в текущем соединении. " + sasl2Description(method)
: sasl2Description(method),
status: method == info.usedSASLMechanism
? .success
: (SASLMechanismPreference.weakMechanisms.contains(method)
? .warning
: .normal)
)
}
}
}
private func sasl2Description(_ method: String) -> String {
switch method {
case "PLAIN":
return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно."
case "OAUTHBEARER":
return "OAuth 2.0 bearer-токен: пароль не передаётся, но сам токен — bearer-секрет, не очень безопасно."
case "EXTERNAL":
return "Использует TLS-клиентские сертификаты для аутентификации."
case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"):
return "Salted Challenge Response Authentication Mechanism с channel-binding."
case let method where method.hasPrefix("SCRAM-"):
return "Salted Challenge Response Authentication Mechanism на указанном хэше."
default:
return "Неизвестный метод аутентификации."
}
}
/// Channel-binding types Luma can actually produce binding data for.
private static let supportedChannelBindingTypes: Set<String> = [
"tls-exporter", "tls-server-end-point",
]
@ViewBuilder
private func channelBindingEntries(_ info: ServerInformation) -> some View {
if info.channelBindingTypes.isEmpty {
entry(
title: "Нет",
detail: "Сервер не рекламирует channel-binding типы (XEP-0440): SASL-обмен не детектирует MITM-атаку на TLS-слой.",
status: .warning
)
} else {
ForEach(info.channelBindingTypes, id: \.self) { type in
let used = info.usedChannelBindingType == type
let supported = Self.supportedChannelBindingTypes.contains(type)
entry(
title: "Тип: \(type)",
detail: channelBindingDescription(type, used: used, supported: supported),
status: used ? .success : (supported ? .normal : .warning)
)
}
}
}
private func channelBindingDescription(
_ type: String,
used: Bool,
supported: Bool
) -> String {
if used {
return "Используется в текущем соединении."
}
if supported {
return "Luma поддерживает этот тип привязки."
}
return "Luma не поддерживает этот тип привязки."
}
private func saslDescription(_ method: String) -> String {
switch method {
case "PLAIN":
return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно."
case "EXTERNAL":
return "Использует TLS-клиентские сертификаты для аутентификации."
case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"):
return "Salted Challenge Response Authentication Mechanism с channel-binding."
case let method where method.hasPrefix("SCRAM-"):
return "Salted Challenge Response Authentication Mechanism на указанном хэше."
default:
return "Неизвестный метод аутентификации."
}
}
private func formatDate(_ date: Date?) -> String {
guard let date else { return "<неизвестно>" }
return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short)
}
// MARK: - Entry
private func entry(
title: String,
detail: String,
status: ServerInformation.CapabilityStatus
) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
// .frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(status.color)
}
private func load() async {
guard model.connectionStatus == .connected else { return }
isLoading = true
await model.refreshServerInformation()
isLoading = false
}
}
private extension ServerInformation.CapabilityStatus {
var color: Color {
switch self {
case .success: return Color.green.opacity(0.16)
case .normal: return Color.gray.opacity(0.16)
case .warning: return Color.orange.opacity(0.16)
case .error: return Color.red.opacity(0.16)
}
}
}
#Preview {
NavigationStack {
ServerInfoView(model: PreviewSupport.model)
}
}
+272 -124
View File
@@ -1,146 +1,112 @@
import LocalAuthentication
import PhotosUI
import SwiftUI
struct SettingsView: View {
@ObservedObject var model: AppModel
/// When embedded as the «Настройки» tab there is no sheet to dismiss,
/// so the confirmation toolbar button is hidden.
var presentedAsTab: Bool = false
@Environment(\.dismiss) private var dismiss
@State private var showingSignOutConfirmation = false
@State private var forgetHistory = false
@State private var avatarItem: PhotosPickerItem?
@State private var passcodeSheetMode: AppLockPasscodeSheet.Mode?
@State private var showingAddAccount = false
var body: some View {
NavigationStack {
Form {
if let account = model.account {
Section("Профиль") {
HStack(spacing: 14) {
AvatarView(
conversation: Conversation(
jid: account.normalizedJID,
displayName: account.displayName.isEmpty ? nil : account.displayName
),
imageData: model.avatarData(for: account.normalizedJID),
size: 64
)
VStack(alignment: .leading, spacing: 6) {
Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName)
.font(.headline)
PhotosPicker(selection: $avatarItem, matching: .images) {
Label("Обновить аватар", systemImage: "photo.badge.plus")
}
.disabled(model.isUpdatingAvatar)
#if os(iOS)
NavigationStack {
Form {
formcontent()
}
.sheet(isPresented: $showingAddAccount) { LoginView(model: model) }
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Настройки")
.font(.headline)
}
if !presentedAsTab {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
}
}
#else
formcontent().sheet(isPresented: $showingAddAccount) { LoginView(model: model) }
#endif
}
@ViewBuilder
private func formcontent() -> some View {
if let account = model.account {
Section("Аккаунты") {
ForEach(model.accounts) { configured in
Button {
Task { await model.switchAccount(to: configured) }
} label: {
HStack {
Image(systemName: configured.id == account.id ? "checkmark.circle.fill" : "person.circle")
.foregroundStyle(configured.id == account.id ? .green : .secondary)
VStack(alignment: .leading) {
Text(configured.displayName.isEmpty ? configured.normalizedJID : configured.displayName)
if !configured.displayName.isEmpty { Text(configured.normalizedJID).font(.caption).foregroundStyle(.secondary) }
}
Spacer()
if model.isUpdatingAvatar {
ProgressView()
}
}
}
Section("Аккаунт") {
LabeledContent("JID", value: account.normalizedJID)
LabeledContent("Ресурс", value: account.effectiveResource)
if let host = account.manualHost {
LabeledContent("Сервер", value: "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))")
} else {
LabeledContent("Обнаружение", value: "DNS SRV")
}
}
.buttonStyle(.plain)
}
Section("Соединение") {
HStack {
Label(connectionTitle, systemImage: connectionIcon)
.foregroundStyle(connectionColor)
Spacer()
if model.connectionStatus != .connected {
Button("Подключить") { Task { await model.reconnect() } }
}
}
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
}
Section("Шифрование") {
Toggle("OMEMO по умолчанию", isOn: Binding(
get: { model.globalEncryptionEnabled },
set: { model.setGlobalEncryptionEnabled($0) }
))
Text("Для отдельного чата режим можно изменить через значок замка в его панели.")
.font(.caption)
.foregroundStyle(.secondary)
if !model.globalEncryptionEnabled {
Label("Новые сообщения без отдельного правила отправляются открытым текстом.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
LabeledContent("Состояние", value: model.isOMEMOReady ? "Активно" : "Подготовка")
if let fingerprint = model.ownFingerprint {
VStack(alignment: .leading, spacing: 7) {
Text("Отпечаток этого устройства")
.font(.subheadline)
Text(fingerprint.chunked(every: 8).joined(separator: " "))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
Section("Уведомления") {
Label("Локальные уведомления включаются после входа", systemImage: "bell.badge")
Text("Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Приватность чата") {
Toggle("Показывать статус набора", isOn: Binding(
get: { model.typingIndicatorsEnabled },
set: { model.setTypingIndicatorsEnabled($0) }
))
Text("Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory)
Button(role: .destructive) {
showingSignOutConfirmation = true
} label: {
Label("Выйти из аккаунта", systemImage: "rectangle.portrait.and.arrow.right")
}
Button {
showingAddAccount = true
} label: {
Label("Добавить аккаунт", systemImage: "person.badge.plus")
}
}
.navigationTitle("Настройки")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
.confirmationDialog(
"Выйти из Luma?",
isPresented: $showingSignOutConfirmation,
titleVisibility: .visible
) {
Button("Выйти", role: .destructive) {
Task {
await model.signOut(forgetHistory: forgetHistory)
dismiss()
Section("Профиль") {
HStack(spacing: 14) {
AvatarView(
conversation: Conversation(
jid: account.normalizedJID,
displayName: account.displayName.isEmpty
? nil : account.displayName
),
imageData: model.avatarData(for: account.normalizedJID),
size: 64
)
VStack(alignment: .leading, spacing: 6) {
Text(
account.displayName.isEmpty
? account.normalizedJID : account.displayName
)
.font(.headline)
PhotosPicker(selection: $avatarItem, matching: .images)
{
Label(
"Обновить аватар",
systemImage: "photo.badge.plus"
)
}
.disabled(model.isUpdatingAvatar)
}
Spacer()
if model.isUpdatingAvatar {
ProgressView()
}
}
Button("Отмена", role: .cancel) {}
} message: {
Text(forgetHistory
? "Пароль и локальная история будут удалены. Серверная история MAM не изменится."
: "Пароль будет удалён из Keychain. Серверная история MAM не изменится.")
}
.onChange(of: avatarItem) { _, item in
guard let item else { return }
Task {
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
guard
let data = try await item.loadTransferable(
type: Data.self
)
else { return }
await model.updateOwnAvatar(from: data)
avatarItem = nil
} catch {
@@ -148,10 +114,187 @@ struct SettingsView: View {
}
}
}
Section("Аккаунт") {
LabeledContent("JID", value: account.normalizedJID)
LabeledContent("Ресурс", value: account.effectiveResource)
if let host = account.manualHost {
LabeledContent(
"Сервер",
value:
"\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))"
)
} else {
LabeledContent("Обнаружение", value: "DNS SRV")
}
}
}
Section("Соединение") {
HStack {
Label(connectionTitle, systemImage: connectionIcon)
.foregroundStyle(connectionColor)
Spacer()
if model.connectionStatus != .connected {
Button("Подключить") { Task { await model.reconnect() } }
}
}
LabeledContent(
"История MAM",
value: model.isArchiveSyncing ? "Синхронизация" : "Готово"
)
NavigationLink {
ServerInfoView(model: model)
} label: {
Label("Информация о сервере", systemImage: "server.rack")
}
}
Section("Шифрование") {
Toggle(
"OMEMO по умолчанию",
isOn: Binding(
get: { model.globalEncryptionEnabled },
set: { model.setGlobalEncryptionEnabled($0) }
)
)
.toggleStyle(.switch)
Text(
"Для отдельного чата режим можно изменить через значок замка в его панели."
)
.font(.caption)
.foregroundStyle(.secondary)
if !model.globalEncryptionEnabled {
Label(
"Новые сообщения без отдельного правила отправляются открытым текстом.",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
}
LabeledContent(
"Состояние",
value: model.isOMEMOReady ? "Активно" : "Подготовка"
)
if let fingerprint = model.ownFingerprint {
VStack(alignment: .leading, spacing: 7) {
Text("Отпечаток этого устройства")
.font(.subheadline)
Text(fingerprint.chunked(every: 8).joined(separator: " "))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
Section("Уведомления") {
Label(
"Локальные уведомления включаются после входа",
systemImage: "bell.badge"
)
Text(
"Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне."
)
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Блокировка приложения") {
Toggle(
"Заблокировать приложение",
isOn: Binding(
get: { model.appLockIsEnabled },
set: { enabled in
if enabled {
passcodeSheetMode = .setup
} else {
passcodeSheetMode = .verify(.disableLock)
}
}
)
)
.toggleStyle(.switch)
.sheet(item: $passcodeSheetMode) { mode in
AppLockPasscodeSheet(model: model, mode: mode)
}
if model.appLockIsEnabled {
Toggle(
"Вход по \(model.biometricUnlockName)",
isOn: Binding(
get: { model.appLockBiometricIsEnabled },
set: { enabled in
passcodeSheetMode = .verify(
enabled ? .enableBiometrics : .disableBiometrics
)
}
)
)
.toggleStyle(.switch)
.disabled(!model.biometricUnlockAvailable)
Button("Сменить пароль") {
passcodeSheetMode = .change
}
}
Text(
"При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain."
)
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Приватность чата") {
Toggle(
"Показывать статус набора",
isOn: Binding(
get: { model.typingIndicatorsEnabled },
set: { model.setTypingIndicatorsEnabled($0) }
)
)
.toggleStyle(.switch)
Text(
"Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора."
)
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory)
.toggleStyle(.switch)
Button(role: .destructive) {
showingSignOutConfirmation = true
} label: {
Label(
"Выйти из аккаунта",
systemImage: "rectangle.portrait.and.arrow.right"
)
}
#if os(macOS)
.buttonStyle(.glassProminent)
.controlSize(.large)
#endif
}
.confirmationDialog(
"Выйти из Luma?",
isPresented: $showingSignOutConfirmation,
titleVisibility: .visible
) {
Button("Выйти", role: .destructive) {
Task {
await model.signOut(forgetHistory: forgetHistory)
dismiss()
}
}
Button("Отмена", role: .cancel) {}
} message: {
Text(
forgetHistory
? "Пароль и локальная история будут удалены. Серверная история MAM не изменится."
: "Пароль будет удалён из Keychain. Серверная история MAM не изменится."
)
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 560)
#endif
}
private var connectionTitle: String {
@@ -179,16 +322,21 @@ struct SettingsView: View {
}
}
private extension String {
func chunked(every size: Int) -> [String] {
extension String {
fileprivate func chunked(every size: Int) -> [String] {
guard size > 0 else { return [self] }
var result: [String] = []
var cursor = startIndex
while cursor < endIndex {
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
let end =
index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
result.append(String(self[cursor..<end]))
cursor = end
}
return result
}
}
#Preview {
SettingsView(model: PreviewSupport.model, presentedAsTab: true)
}
+51 -22
View File
@@ -1,4 +1,5 @@
import Foundation
import os
struct CapturedCameraMedia: Sendable {
let url: URL
@@ -12,6 +13,13 @@ import UIKit
@MainActor
struct SystemPhotoCameraView: UIViewControllerRepresentable {
/// Called synchronously on the main thread inside
/// `didFinishPickingMediaWithInfo`, before any background preparation.
/// The host must flip its `isPresented` binding here so SwiftUI drives
/// the dismissal; letting UIImagePickerController dismiss itself leaves
/// the fullScreenCover state desynchronized and breaks the follow-up
/// preview presentation.
let onDismissRequest: () -> Void
let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
let onCancel: () -> Void
@@ -20,7 +28,11 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
}
func makeCoordinator() -> Coordinator {
Coordinator(onMedia: onMedia, onCancel: onCancel)
Coordinator(
onDismissRequest: onDismissRequest,
onMedia: onMedia,
onCancel: onCancel
)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
@@ -42,14 +54,17 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
) {}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private let onDismissRequest: () -> Void
private let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
private let onCancel: () -> Void
private var completed = false
init(
onDismissRequest: @escaping () -> Void,
onMedia: @escaping (Result<CapturedCameraMedia, Error>) -> Void,
onCancel: @escaping () -> Void
) {
self.onDismissRequest = onDismissRequest
self.onMedia = onMedia
self.onCancel = onCancel
}
@@ -60,6 +75,7 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
) {
guard !completed else { return }
completed = true
onDismissRequest()
let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) }
if mediaType?.conforms(to: .movie) == true,
@@ -100,29 +116,34 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
}
}
/// Claims the picker-owned temporary movie synchronously, while the
/// picker is still alive: UIImagePickerController can delete its
/// temporary file as soon as it is dismissed, so a background copy
/// races that cleanup and fails with an unreadable source. The move
/// is a same-volume rename and does not block the main thread.
private func prepareMovie(from sourceURL: URL) {
let completion = onMedia
DispatchQueue.global(qos: .userInitiated).async {
let result: Result<CapturedCameraMedia, Error>
do {
let fileExtension = sourceURL.pathExtension.isEmpty
? "mov"
: sourceURL.pathExtension
let destination = try Self.destinationURL(extension: fileExtension)
try FileManager.default.copyItem(at: sourceURL, to: destination)
let values = try destination.resourceValues(
forKeys: [.fileSizeKey, .isRegularFileKey]
)
guard values.isRegularFile == true,
(values.fileSize ?? 0) > 0 else {
try? FileManager.default.removeItem(at: destination)
throw SystemCameraError.emptyMovie
}
result = .success(CapturedCameraMedia(url: destination, kind: .video))
} catch {
result = .failure(error)
let logger = Logger(subsystem: "Luma", category: "video-preview")
do {
let sourceSize = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
logger.info("picker movie ready: source=\(sourceURL.lastPathComponent) size=\(sourceSize)")
let fileExtension = sourceURL.pathExtension.isEmpty
? "mov"
: sourceURL.pathExtension
let destination = try Self.destinationURL(extension: fileExtension)
try FileManager.default.moveItem(at: sourceURL, to: destination)
let values = try destination.resourceValues(
forKeys: [.fileSizeKey, .isRegularFileKey]
)
let movedSize = values.fileSize ?? 0
logger.info("picker movie moved: size=\(movedSize) regular=\(values.isRegularFile == true)")
guard values.isRegularFile == true, movedSize > 0 else {
try? FileManager.default.removeItem(at: destination)
throw SystemCameraError.emptyMovie
}
DispatchQueue.main.async { completion(result) }
onMedia(.success(CapturedCameraMedia(url: destination, kind: .video)))
} catch {
logger.error("picker movie preparation failed: \(error.localizedDescription)")
onMedia(.failure(error))
}
}
@@ -156,4 +177,12 @@ private enum SystemCameraError: LocalizedError {
}
}
}
#Preview {
SystemPhotoCameraView(
onDismissRequest: {},
onMedia: { _ in },
onCancel: {}
)
}
#endif
@@ -13,6 +13,7 @@ struct VideoNoteCaptureView: View {
@StateObject private var recorder = VideoNoteRecorder()
@State private var errorMessage: String?
@State private var isFinalizing = false
@State private var isSwitchingCamera = false
let onComplete: (VideoNoteRecorder.Recording) -> Void
@@ -32,6 +33,26 @@ struct VideoNoteCaptureView: View {
.frame(maxWidth: 420)
.aspectRatio(1, contentMode: .fit)
.overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1))
#if os(iOS)
.overlay(alignment: .bottomTrailing) {
if recorder.hasAlternateCamera, recorder.isPrepared {
Button(action: flipCamera) {
Image(
systemName:
"arrow.triangle.2.circlepath.camera.fill"
)
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(.white)
.padding(10)
.background(.ultraThinMaterial, in: Circle())
}
.buttonStyle(.plain)
.padding(14)
.disabled(isSwitchingCamera || isFinalizing)
.accessibilityLabel("Переключить камеру")
}
}
#endif
Text(recordingStatus)
.font(.system(.title3, design: .monospaced).weight(.semibold))
@@ -120,6 +141,19 @@ struct VideoNoteCaptureView: View {
#endif
}
private func flipCamera() {
guard !isSwitchingCamera, !isFinalizing else { return }
isSwitchingCamera = true
Task { @MainActor in
do {
try await recorder.switchCamera()
} catch {
errorMessage = error.localizedDescription
}
isSwitchingCamera = false
}
}
private func formatted(_ duration: TimeInterval) -> String {
let seconds = max(0, Int(duration.rounded(.down)))
return String(format: "%d:%02d", seconds / 60, seconds % 60)
@@ -190,3 +224,7 @@ struct CameraPreview: NSViewRepresentable {
}
}
#endif
#Preview {
VideoNoteCaptureView { _ in }
}
+65
View File
@@ -0,0 +1,65 @@
import Foundation
import Martin
/// Service messages that sync call-history cards between the user's own
/// devices. After a call ends the originating device sends a message to its
/// own bare JID carrying a `<call-history xmlns='https://luma.chat/call-history'>`
/// payload; Carbons deliver it to the other devices and MAM archives it.
/// Receivers parse the payload and upsert the same card, deduplicating by
/// the origin-id that matches the local card's clientID.
enum CallHistorySync {
static let namespace = "https://luma.chat/call-history"
struct Envelope: Equatable, Sendable {
let id: String
let peerJID: String
let direction: CallDirection
let isVideo: Bool
let startedAt: Date
let duration: TimeInterval?
let outcome: CallHistoryOutcome
}
static func envelope(from message: Message) -> Envelope? {
guard let element = message.element.findChild(
name: "call-history",
xmlns: namespace
) else {
return nil
}
let value = { (name: String) -> String? in
element.findChild(name: name)?.value
}
guard let rawPeer = value("with"), !rawPeer.isEmpty else { return nil }
let durationSeconds = value("duration").flatMap { TimeInterval($0) }
return Envelope(
id: message.originId ?? message.id ?? UUID().uuidString,
peerJID: rawPeer.lowercased(),
direction: value("direction") == "outgoing" ? .outgoing : .incoming,
isVideo: value("video") == "true",
startedAt: value("start").flatMap { startFormatter.date(from: $0) } ?? Date(),
duration: (durationSeconds ?? 0) > 0 ? durationSeconds : nil,
outcome: value("status").flatMap(CallHistoryOutcome.init(rawValue:)) ?? .failed
)
}
static func payloadElement(entry: CallHistoryEntry) -> Element {
let element = Element(name: "call-history", xmlns: namespace)
element.addChild(Element(name: "direction", cdata: entry.direction.rawValue))
element.addChild(Element(name: "status", cdata: entry.outcome.rawValue))
element.addChild(
Element(name: "duration", cdata: String(Int((entry.duration ?? 0).rounded())))
)
element.addChild(Element(name: "start", cdata: startFormatter.string(from: entry.startedAt)))
element.addChild(Element(name: "with", cdata: entry.peerJID.lowercased()))
element.addChild(Element(name: "video", cdata: entry.isVideo ? "true" : "false"))
return element
}
private static let startFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}
+273 -216
View File
@@ -4,15 +4,15 @@ import Martin
import WebRTC
#if os(iOS)
import UIKit
import UIKit
#elseif os(macOS)
import AppKit
import AppKit
#endif
/// Owns one active 1:1 Jingle RTP session and bridges Martin to WebRTC.
/// Incoming calls are available while the XMPP connection is alive; production
/// wake-up for a terminated iOS app still requires VoIP push infrastructure.
final class LumaCallEngine: NSObject {
final class LumaCallEngine: NSObject, @unchecked Sendable {
var snapshotHandler: (@MainActor (CallSnapshot?) -> Void)?
var historyHandler: (@MainActor (CallHistoryEntry) -> Void)?
var errorHandler: (@MainActor (String) -> Void)?
@@ -76,15 +76,16 @@ final class LumaCallEngine: NSObject {
media: media,
client: client
)
#if DEBUG
print(
"Luma Call: selected \(destination.jid.stringValue) via "
+ (destination.usesMessageInitiation ? "JMI" : "direct Jingle")
+ " for \(media.map(\.rawValue).sorted().joined(separator: "+"))."
)
#endif
#if DEBUG
print(
"Luma Call: selected \(destination.jid.stringValue) via "
+ (destination.usesMessageInitiation ? "JMI" : "direct Jingle")
+ " for \(media.map(\.rawValue).sorted().joined(separator: "+"))."
)
#endif
let sid = UUID().uuidString.lowercased()
let initiationType: JingleSessionInitiationType = destination.usesMessageInitiation
let initiationType: JingleSessionInitiationType =
destination.usesMessageInitiation
? .message
: .iq
let session = self.makeSession(
@@ -108,14 +109,16 @@ final class LumaCallEngine: NSObject {
if destination.usesMessageInitiation {
Task {
do {
try await session.initiate(descriptions: media
.sorted { $0.rawValue < $1.rawValue }
.map {
Jingle.MessageInitiationAction.Description(
xmlns: "urn:xmpp:jingle:apps:rtp:1",
media: $0.rawValue
)
})
try await session.initiate(
descriptions:
media
.sorted { $0.rawValue < $1.rawValue }
.map {
Jingle.MessageInitiationAction.Description(
xmlns: "urn:xmpp:jingle:apps:rtp:1",
media: $0.rawValue
)
})
completion(.success(()))
} catch {
self.queue.async {
@@ -150,8 +153,9 @@ final class LumaCallEngine: NSObject {
func answerCall(completion: @escaping (Result<Void, Error>) -> Void) {
queue.async {
guard let call = self.activeCall,
call.direction == .incoming,
call.phase == .ringing else {
call.direction == .incoming,
call.phase == .ringing
else {
completion(.failure(LumaCallError.noIncomingCall))
return
}
@@ -324,8 +328,9 @@ final class LumaCallEngine: NSObject {
let capabilities = client.module(.caps)
let candidates = presences.compactMap(\.from).filter { jid in
guard capabilities.isFeatureSupported(JingleModule.XMLNS, by: jid),
capabilities.isFeatureSupported(Jingle.Transport.ICEUDPTransport.XMLNS, by: jid),
capabilities.isFeatureSupported("urn:xmpp:jingle:apps:rtp:audio", by: jid) else {
capabilities.isFeatureSupported(Jingle.Transport.ICEUDPTransport.XMLNS, by: jid),
capabilities.isFeatureSupported("urn:xmpp:jingle:apps:rtp:audio", by: jid)
else {
return false
}
return !media.contains(.video)
@@ -375,9 +380,9 @@ final class LumaCallEngine: NSObject {
timeoutWorkItem?.cancel()
let item = DispatchWorkItem { [weak self] in
guard let self, self.activeCall?.id == callID else { return }
#if DEBUG
print("Luma Call: timeout during \(stage) after \(Int(delay)) seconds.")
#endif
#if DEBUG
print("Luma Call: timeout during \(stage) after \(Int(delay)) seconds.")
#endif
self.fail(self.activeCall, error: LumaCallError.timedOut)
}
timeoutWorkItem = item
@@ -387,9 +392,9 @@ final class LumaCallEngine: NSObject {
private func fail(_ call: LumaWebRTCCall?, error: Error) {
guard call == nil || activeCall === call else { return }
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
#if DEBUG
print("Luma Call: failure: \(text)")
#endif
#if DEBUG
print("Luma Call: failure: \(text)")
#endif
report(error)
let cause: CallTerminationCause
if let callError = error as? LumaCallError, case .timedOut = callError {
@@ -424,14 +429,14 @@ final class LumaCallEngine: NSObject {
publish(nil)
return
}
#if DEBUG
print(
"Luma Call: closing sid=\(call.session.sid) "
+ "direction=\(String(describing: call.direction)) "
+ "phase=\(String(describing: call.phase)) "
+ "peer=\(call.session.jid.stringValue) trigger=\(trigger)."
)
#endif
#if DEBUG
print(
"Luma Call: closing sid=\(call.session.sid) "
+ "direction=\(String(describing: call.direction)) "
+ "phase=\(String(describing: call.phase)) "
+ "peer=\(call.session.jid.stringValue) trigger=\(trigger)."
)
#endif
let endedAt = Date()
let outcome = CallHistoryPolicy.outcome(
direction: call.direction,
@@ -515,20 +520,22 @@ extension LumaCallEngine: JingleSessionManager {
case .proceed(let id):
guard let session = sessions[id],
let call = activeCall,
call.session === session,
call.direction == .outgoing,
call.phase == .ringing,
session.pinPeerResource(jid) else {
#if DEBUG
print("Luma Jingle: ignored duplicate or foreign proceed for session \(id).")
#endif
let call = activeCall,
call.session === session,
call.direction == .outgoing,
call.phase == .ringing,
session.pinPeerResource(jid)
else {
#if DEBUG
print(
"Luma Jingle: ignored duplicate or foreign proceed for session \(id).")
#endif
return
}
call.phase = .connecting
#if DEBUG
print("Luma Jingle: proceed from \(jid.stringValue) for session \(id).")
#endif
#if DEBUG
print("Luma Jingle: proceed from \(jid.stringValue) for session \(id).")
#endif
scheduleTimeout(
for: call.id,
after: 90,
@@ -551,7 +558,8 @@ extension LumaCallEngine: JingleSessionManager {
case .retract(let id):
guard let session = sessions[id],
session.matchesPeerEndpoint(jid) else { return }
session.matchesPeerEndpoint(jid)
else { return }
finishActiveCall(
cause: .remoteCancelled,
sendTermination: false,
@@ -560,16 +568,17 @@ extension LumaCallEngine: JingleSessionManager {
case .reject(let id):
guard let session = sessions[id],
let call = activeCall,
call.session === session,
session.matchesPeerEndpoint(jid) else { return }
let call = activeCall,
call.session === session,
session.matchesPeerEndpoint(jid)
else { return }
// A JMI proposal is delivered to every online resource. Once a
// resource has proceeded, a delayed reject from a sibling must
// not tear down the session that already owns the call.
guard call.phase == .ringing else {
#if DEBUG
print("Luma Jingle: ignored late reject after proceed for session \(id).")
#endif
#if DEBUG
print("Luma Jingle: ignored late reject after proceed for session \(id).")
#endif
return
}
finishActiveCall(
@@ -599,7 +608,8 @@ extension LumaCallEngine: JingleSessionManager {
bundle: [String]?
) throws {
try performSync {
let media = Set(contents.compactMap { CallMedia(rawValue: $0.description?.media ?? "") })
let media = Set(
contents.compactMap { CallMedia(rawValue: $0.description?.media ?? "") })
guard media.contains(.audio) else { throw XMPPError.feature_not_implemented }
if let existing = sessions[sid] {
@@ -616,7 +626,9 @@ extension LumaCallEngine: JingleSessionManager {
}
return
}
guard activeCall == nil else { throw XMPPError.resource_constraint("Другой звонок уже активен") }
guard activeCall == nil else {
throw XMPPError.resource_constraint("Другой звонок уже активен")
}
let session = makeSession(
context: context,
@@ -648,21 +660,22 @@ extension LumaCallEngine: JingleSessionManager {
) throws {
try performSync {
guard let session = sessions[sid],
session.pinPeerResource(jid) else {
session.pinPeerResource(jid)
else {
throw XMPPError.item_not_found
}
// JMI may initially address a bare JID. session-accept is the first
// IQ guaranteed to identify the resource that owns this Jingle
// session, so keep every later transport-info stanza on it.
#if DEBUG
let acceptedMedia = contents.map {
"\($0.name):\($0.description?.media ?? "unknown")"
}.joined(separator: ",")
print(
"Luma Jingle: session-accept from \(jid.stringValue) "
+ "sid=\(sid) contents=[\(acceptedMedia)]."
)
#endif
#if DEBUG
let acceptedMedia = contents.map {
"\($0.name):\($0.description?.media ?? "unknown")"
}.joined(separator: ",")
print(
"Luma Jingle: session-accept from \(jid.stringValue) "
+ "sid=\(sid) contents=[\(acceptedMedia)]."
)
#endif
session.accepted(contents: contents, bundle: bundle)
if let call = activeCall, call.session === session {
call.phase = .connecting
@@ -679,7 +692,8 @@ extension LumaCallEngine: JingleSessionManager {
func sessionTerminated(for context: Context, with jid: JID, sid: String) throws {
try performSync {
guard let session = sessions[sid],
session.matchesPeerEndpoint(jid) else {
session.matchesPeerEndpoint(jid)
else {
throw XMPPError.item_not_found
}
if activeCall?.session.sid == sid {
@@ -703,7 +717,8 @@ extension LumaCallEngine: JingleSessionManager {
) throws {
try performSync {
guard let session = sessions[sid],
session.pinPeerResource(jid) else {
session.pinPeerResource(jid)
else {
throw XMPPError.item_not_found
}
// Also learn the endpoint from an early candidate sent by the
@@ -728,7 +743,8 @@ extension LumaCallEngine: JingleSessionManager {
) throws {
try performSync {
guard let session = sessions[sid],
session.matchesPeerEndpoint(jid) else {
session.matchesPeerEndpoint(jid)
else {
throw XMPPError.item_not_found
}
session.contentModified(action: action, contents: contents, bundle: bundle)
@@ -743,7 +759,8 @@ extension LumaCallEngine: JingleSessionManager {
) throws {
try performSync {
guard let session = sessions[sid],
session.matchesPeerEndpoint(jid) else {
session.matchesPeerEndpoint(jid)
else {
throw XMPPError.item_not_found
}
session.sessionInfoReceived(info: info)
@@ -789,7 +806,8 @@ private final class LumaJingleSession: JingleSession {
terminate(reason: reason)
return
}
let action: Jingle.MessageInitiationAction = role == .initiator
let action: Jingle.MessageInitiationAction =
role == .initiator
? .retract(id: sid)
: .reject(id: sid)
owningContext?.module(.jingle).sendMessageInitiation(action: action, to: jid)
@@ -806,9 +824,9 @@ private final class LumaJingleSession: JingleSession {
if jid.resource != nil {
return peer == jid
}
#if DEBUG
print("Luma Jingle: pinned session \(sid) to resource \(peer.stringValue).")
#endif
#if DEBUG
print("Luma Jingle: pinned session \(sid) to resource \(peer.stringValue).")
#endif
accepted(by: peer)
return true
}
@@ -871,7 +889,7 @@ private final class LumaJingleSession: JingleSession {
// MARK: - Live WebRTC call
private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate, @unchecked Sendable {
let id = UUID()
let startedAt = Date()
unowned let engine: LumaCallEngine
@@ -901,7 +919,7 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
private var connectionFailureWorkItem: DispatchWorkItem?
private var isPreparingPeerConnection = false
private var prepareCallbacks: [(Result<Void, Error>) -> Void] = []
private let webRTCSID = String(UInt64.random(in: UInt64.min ... UInt64.max))
private let webRTCSID = String(UInt64.random(in: UInt64.min...UInt64.max))
init(
engine: LumaCallEngine,
@@ -1001,22 +1019,25 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
}
func configureSpeaker(_ enabled: Bool) throws {
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
try audioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
try audioSession.setActive(true)
#else
_ = enabled
#endif
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
try audioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
try audioSession.setActive(true)
#else
_ = enabled
#endif
}
func switchCamera() {
guard let capturer = videoCapturer else { return }
let currentPosition = RTCCameraVideoCapturer.captureDevices()
let currentPosition =
RTCCameraVideoCapturer.captureDevices()
.first(where: { $0.uniqueID == cameraDeviceID })?.position ?? .front
guard let device = RTCCameraVideoCapturer.captureDevices().first(where: {
$0.position != currentPosition && $0.position != .unspecified
}), let format = Self.preferredFormat(for: device) else { return }
guard
let device = RTCCameraVideoCapturer.captureDevices().first(where: {
$0.position != currentPosition && $0.position != .unspecified
}), let format = Self.preferredFormat(for: device)
else { return }
cameraDeviceID = device.uniqueID
capturer.startCapture(with: device, format: format, fps: Self.preferredFPS(for: format))
}
@@ -1032,12 +1053,12 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
localAudioTrack = nil
localVideoTrack = nil
remoteVideoTrack = nil
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
#endif
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
#endif
}
private func createPeerConnection(iceServers: [RTCIceServer]) throws {
@@ -1071,19 +1092,19 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
delegate: self
) {
createdPeerConnection = candidate
#if DEBUG
if index > 0 {
print("Luma WebRTC: RTCConfiguration recovered with \(attempt.name).")
}
#endif
#if DEBUG
if index > 0 {
print("Luma WebRTC: RTCConfiguration recovered with \(attempt.name).")
}
#endif
break
}
#if DEBUG
print(
"Luma WebRTC: RTCConfiguration rejected "
+ "\(attempt.name) (\(attempt.servers.count) ICE server(s))."
)
#endif
#if DEBUG
print(
"Luma WebRTC: RTCConfiguration rejected "
+ "\(attempt.name) (\(attempt.servers.count) ICE server(s))."
)
#endif
}
guard let peerConnection = createdPeerConnection else {
@@ -1091,22 +1112,22 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
}
self.peerConnection = peerConnection
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
let audioOptions: AVAudioSession.CategoryOptions = [
.allowBluetoothHFP,
.allowBluetoothA2DP
]
try audioSession.setCategory(
.playAndRecord,
mode: media.contains(.video) ? .videoChat : .voiceChat,
options: audioOptions
)
try audioSession.setActive(true)
if media.contains(.video) {
try audioSession.overrideOutputAudioPort(.speaker)
}
#endif
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
let audioOptions: AVAudioSession.CategoryOptions = [
.allowBluetoothHFP,
.allowBluetoothA2DP,
]
try audioSession.setCategory(
.playAndRecord,
mode: media.contains(.video) ? .videoChat : .voiceChat,
options: audioOptions
)
try audioSession.setActive(true)
if media.contains(.video) {
try audioSession.overrideOutputAudioPort(.speaker)
}
#endif
let audioConstraints = RTCMediaConstraints(
mandatoryConstraints: nil,
@@ -1192,9 +1213,11 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
guard let self else { return }
self.engine.performOnQueue {
if let error {
#if DEBUG
print("Luma WebRTC: setRemoteDescription failed: \(error.localizedDescription)")
#endif
#if DEBUG
print(
"Luma WebRTC: setRemoteDescription failed: \(error.localizedDescription)"
)
#endif
self.engine.callDidFail(self, error: error)
return
}
@@ -1209,7 +1232,8 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
// both the answer and the exact full JID of its owner.
self.sendLocalCandidates()
if peerConnection.signalingState == .haveRemoteOffer {
self.generateDescription(type: .answer, peerConnection: peerConnection) { result in
self.generateDescription(type: .answer, peerConnection: peerConnection) {
result in
switch result {
case .success(let local):
Task {
@@ -1266,11 +1290,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
let martinSDP = CallSDPNormalization.martinParseInput(
description.sdp
)
guard let (sdp, _) = SDP.parse(
sdpString: martinSDP,
creatorProvider: self.session.contentCreator(of:),
localRole: self.session.role
) else {
guard
let (sdp, _) = SDP.parse(
sdpString: martinSDP,
creatorProvider: self.session.contentCreator(of:),
localRole: self.session.role
)
else {
completion(.failure(LumaCallError.sdpGenerationFailed))
return
}
@@ -1280,14 +1306,14 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
completion(.failure(LumaCallError.incompatibleLocalDescription))
return
}
#if DEBUG
if fidStatus.total > 0 {
print(
"Luma SDP: verified \(fidStatus.total) FID group(s); "
+ "repaired \(repaired.repairedCount) source msid(s)."
)
}
#endif
#if DEBUG
if fidStatus.total > 0 {
print(
"Luma SDP: verified \(fidStatus.total) FID group(s); "
+ "repaired \(repaired.repairedCount) source msid(s)."
)
}
#endif
self.localSDP = repaired.sdp
completion(.success(repaired.sdp))
}
@@ -1307,30 +1333,33 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
// until the remote SDP arrives from that concrete full JID; otherwise
// Prosody can route transport-info to another resource, which correctly
// responds with item-not-found for the SID.
guard CallCandidateDispatchGate.canSend(
initialDescriptionWasSignaled: initialDescriptionWasSignaled,
hasLocalDescription: localSDP != nil,
hasRemoteDescription: remoteSDP != nil,
hasFullPeerJID: session.jid.resource != nil
), let localSDP else { return }
guard
CallCandidateDispatchGate.canSend(
initialDescriptionWasSignaled: initialDescriptionWasSignaled,
hasLocalDescription: localSDP != nil,
hasRemoteDescription: remoteSDP != nil,
hasFullPeerJID: session.jid.resource != nil
), let localSDP
else { return }
let candidates = localCandidates
localCandidates.removeAll()
#if DEBUG
if !candidates.isEmpty {
print(
"Luma Jingle: sending \(candidates.count) ICE candidate(s) "
+ "to \(session.jid.stringValue) sid=\(session.sid)."
)
}
#endif
#if DEBUG
if !candidates.isEmpty {
print(
"Luma Jingle: sending \(candidates.count) ICE candidate(s) "
+ "to \(session.jid.stringValue) sid=\(session.sid)."
)
}
#endif
for candidate in candidates {
let line = candidate.sdp.hasPrefix("a=") ? candidate.sdp : "a=\(candidate.sdp)"
guard let jingleCandidate = Jingle.Transport.ICEUDPTransport.Candidate(fromSDP: line),
let contentName = candidate.sdpMid,
let content = localSDP.contents.first(where: { $0.name == contentName }),
let transport = content.transports.first(where: {
$0 is Jingle.Transport.ICEUDPTransport
}) as? Jingle.Transport.ICEUDPTransport else { continue }
let contentName = candidate.sdpMid,
let content = localSDP.contents.first(where: { $0.name == contentName }),
let transport = content.transports.first(where: {
$0 is Jingle.Transport.ICEUDPTransport
}) as? Jingle.Transport.ICEUDPTransport
else { continue }
_ = session.transportInfo(
contentName: contentName,
transport: Jingle.Transport.ICEUDPTransport(
@@ -1356,11 +1385,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
sdpMid: contentName
),
completionHandler: { error in
#if DEBUG
if let error {
print("Luma WebRTC: remote ICE candidate rejected: \(error.localizedDescription)")
}
#endif
#if DEBUG
if let error {
print(
"Luma WebRTC: remote ICE candidate rejected: \(error.localizedDescription)"
)
}
#endif
}
)
}
@@ -1372,17 +1403,20 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
let expectedNames = localSDP.contents.map(\.name)
let receivedNames = sdp.contents.map(\.name)
guard let indices = CallSDPOrdering.answerIndices(
localOrder: expectedNames,
remoteOrder: receivedNames
) else {
guard
let indices = CallSDPOrdering.answerIndices(
localOrder: expectedNames,
remoteOrder: receivedNames
)
else {
throw LumaCallError.incompatibleRemoteDescription
}
let normalizedBundle: [String]?
if let bundle = sdp.bundle {
let bundleNames = Set(bundle)
guard bundleNames.count == bundle.count,
bundleNames.isSubset(of: Set(receivedNames)) else {
bundleNames.isSubset(of: Set(receivedNames))
else {
throw LumaCallError.incompatibleRemoteDescription
}
normalizedBundle = expectedNames.filter { bundleNames.contains($0) }
@@ -1394,9 +1428,9 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
}
let orderedContents = indices.map { sdp.contents[$0] }
#if DEBUG
print("Luma WebRTC: reordered session-accept contents to match the local offer.")
#endif
#if DEBUG
print("Luma WebRTC: reordered session-accept contents to match the local offer.")
#endif
return SDP(contents: orderedContents, bundle: normalizedBundle)
}
@@ -1438,7 +1472,9 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
// MARK: RTCPeerConnectionDelegate
func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}
func peerConnection(
_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState
) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
guard let track = stream.videoTracks.first else { return }
engine.performOnQueue {
@@ -1449,11 +1485,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
func peerConnection(
_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState
) {
engine.performOnQueue {
#if DEBUG
print("Luma WebRTC: ICE state changed to \(String(describing: newState)).")
#endif
#if DEBUG
print("Luma WebRTC: ICE state changed to \(String(describing: newState)).")
#endif
switch newState {
case .connected, .completed:
self.cancelScheduledConnectionFailure()
@@ -1475,16 +1513,21 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
}
}
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {}
func peerConnection(
_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState
) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) {
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate)
{
engine.performOnQueue {
self.localCandidates.append(candidate)
self.sendLocalCandidates()
}
}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {}
func peerConnection(
_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]
) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {}
func peerConnection(
@@ -1499,7 +1542,8 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
}
}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove rtpReceiver: RTCRtpReceiver) {
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove rtpReceiver: RTCRtpReceiver)
{
guard let track = rtpReceiver.track as? RTCVideoTrack else { return }
engine.performOnQueue {
guard self.remoteVideoTrack === track else { return }
@@ -1511,40 +1555,43 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
// MARK: - Queue helpers kept fileprivate for the live call object
private extension LumaCallEngine {
func performOnQueue(_ action: @escaping () -> Void) {
extension LumaCallEngine {
fileprivate func performOnQueue(_ action: @escaping () -> Void) {
queue.async(execute: action)
}
func performOnQueue(after delay: TimeInterval, execute item: DispatchWorkItem) {
fileprivate func performOnQueue(after delay: TimeInterval, execute item: DispatchWorkItem) {
queue.asyncAfter(deadline: .now() + delay, execute: item)
}
func performAttachedClient() -> XMPPClient? {
fileprivate func performAttachedClient() -> XMPPClient? {
performSync { client }
}
}
private extension ExternalServiceDiscoveryModule.Service {
var rtcIceServer: RTCIceServer? {
extension ExternalServiceDiscoveryModule.Service {
fileprivate var rtcIceServer: RTCIceServer? {
let normalizedType = type.lowercased()
let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard ["stun", "stuns", "turn", "turns"].contains(normalizedType) else { return nil }
guard !normalizedHost.isEmpty,
normalizedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
!normalizedHost.contains("/"),
!normalizedHost.contains("?"),
!normalizedHost.contains("#") else { return nil }
normalizedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
!normalizedHost.contains("/"),
!normalizedHost.contains("?"),
!normalizedHost.contains("#")
else { return nil }
guard !normalizedType.hasSuffix("s") || transport == nil || transport == .tcp else {
return nil
}
if normalizedType.hasPrefix("turn") {
guard let username, !username.isEmpty,
let password, !password.isEmpty else { return nil }
let password, !password.isEmpty
else { return nil }
}
if let expires, expires <= Date() { return nil }
let escapedHost = normalizedHost.contains(":") && !normalizedHost.hasPrefix("[")
let escapedHost =
normalizedHost.contains(":") && !normalizedHost.hasPrefix("[")
? "[\(normalizedHost)]"
: normalizedHost
var url = "\(normalizedType):\(escapedHost)"
@@ -1567,9 +1614,10 @@ private extension ExternalServiceDiscoveryModule.Service {
enum CallSDPOrdering {
static func answerIndices(localOrder: [String], remoteOrder: [String]) -> [Int]? {
guard localOrder.count == remoteOrder.count,
Set(localOrder).count == localOrder.count,
Set(remoteOrder).count == remoteOrder.count,
Set(localOrder) == Set(remoteOrder) else { return nil }
Set(localOrder).count == localOrder.count,
Set(remoteOrder).count == remoteOrder.count,
Set(localOrder) == Set(remoteOrder)
else { return nil }
let remoteIndices = Dictionary(
uniqueKeysWithValues: remoteOrder.enumerated().map { ($0.element, $0.offset) }
@@ -1583,17 +1631,19 @@ enum CallSDPOrdering {
}
enum CallSDPNormalization {
/// Martin 3.2.4 expects CRLF between lines and unconditionally drops the
/// final two characters of its input. Give it one, and only one, trailing
/// CRLF so the last character of an SSRC/MSID value is never discarded.
static func martinParseInput(_ webRTCSDP: String) -> String {
var normalized = webRTCSDP
let lfNormalized =
webRTCSDP
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
while normalized.hasSuffix("\n") {
normalized.removeLast()
var lines = lfNormalized.components(separatedBy: "\n")
while lines.last?.isEmpty == true {
lines.removeLast()
}
return normalized.replacingOccurrences(of: "\n", with: "\r\n") + "\r\n"
return lines.joined(separator: "\r\n") + "\r\n"
}
}
@@ -1632,9 +1682,11 @@ enum CallSDPCompatibility {
var currentMSIDs: [String: String] = [:]
for source in description.ssrcs {
guard let value = source.parameters.first(where: {
$0.key.lowercased() == "msid"
})?.value, !value.isEmpty else { continue }
guard
let value = source.parameters.first(where: {
$0.key.lowercased() == "msid"
})?.value, !value.isEmpty
else { continue }
currentMSIDs[source.ssrc] = value
}
let desiredMSIDs = canonicalFIDMSIDs(
@@ -1647,19 +1699,21 @@ enum CallSDPCompatibility {
var changed = false
let sources = description.ssrcs.map { source -> Jingle.RTP.Description.SSRC in
guard let desired = desiredMSIDs[source.ssrc],
currentMSIDs[source.ssrc] != desired else {
currentMSIDs[source.ssrc] != desired
else {
return source
}
changed = true
repairedCount += 1
let parameters = source.parameters.filter {
$0.key.lowercased() != "msid"
} + [
Jingle.RTP.Description.SSRC.Parameter(
key: "msid",
value: desired
)
]
let parameters =
source.parameters.filter {
$0.key.lowercased() != "msid"
} + [
Jingle.RTP.Description.SSRC.Parameter(
key: "msid",
value: desired
)
]
return Jingle.RTP.Description.SSRC(
ssrc: source.ssrc,
parameters: parameters
@@ -1756,11 +1810,14 @@ private enum LumaCallError: LocalizedError {
case .unsupportedByContact:
return "Устройства контакта не объявили поддержку совместимых Jingle-звонков."
case .mediaEngineUnavailable:
return "WebRTC не смог создать соединение даже без STUN/TURN. Очистите сборку и переустановите приложение."
return
"WebRTC не смог создать соединение даже без STUN/TURN. Очистите сборку и переустановите приложение."
case .incompatibleLocalDescription:
return "WebRTC сформировал несовместимое описание видеопотока. Повторите звонок после перезапуска приложения."
return
"WebRTC сформировал несовместимое описание видеопотока. Повторите звонок после перезапуска приложения."
case .incompatibleRemoteDescription:
return "Ответ на видеозвонок содержит другой набор аудио/видео потоков. Обновите клиент собеседника или повторите аудиозвонок."
return
"Ответ на видеозвонок содержит другой набор аудио/видео потоков. Обновите клиент собеседника или повторите аудиозвонок."
case .sdpGenerationFailed:
return "Не удалось согласовать параметры аудио/видео."
case .connectionLost:
@@ -0,0 +1,68 @@
import Foundation
import Martin
/// Counts XEP-0198 stanza statistics (sent / acknowledged / received) at the
/// stream level so the server-details screen can mirror Monal's connection
/// statistics. Registered as an `XmppStanzaFilter` *before* stream management
/// so it can also observe the server's `<a h='N'>` acknowledgements.
final class LumaConnectionStatsModule: XmppModuleBase, XmppModule, XmppStanzaFilter {
static let ID = "lumaConnectionStats"
static let smNamespace = "urn:xmpp:sm:3"
let criteria = Criteria.empty()
let features: [String] = []
private let lock = NSLock()
private var sent: UInt32 = 0
private var received: UInt32 = 0
private var acknowledged: UInt32 = 0
struct Snapshot: Equatable, Sendable {
let sent: UInt32
let acknowledged: UInt32
let received: UInt32
var unacknowledged: UInt32 {
sent > acknowledged ? sent - acknowledged : 0
}
}
var snapshot: Snapshot {
lock.lock()
defer { lock.unlock() }
return Snapshot(sent: sent, acknowledged: acknowledged, received: received)
}
func processIncoming(stanza: Stanza) -> Bool {
if stanza.xmlns == Self.smNamespace, stanza.name == "a" {
if let raw = stanza.attribute("h"), let value = UInt32(raw) {
lock.lock()
acknowledged = value
lock.unlock()
}
// Let StreamManagementModule consume the acknowledgement too.
return false
}
if Self.isCountable(stanza) {
lock.lock()
received += 1
lock.unlock()
}
return false
}
func processOutgoing(stanza: Stanza) {
guard Self.isCountable(stanza) else { return }
lock.lock()
sent += 1
lock.unlock()
}
func process(stanza: Stanza) throws {
// Criteria is empty, so this module is never selected by the dispatcher.
}
private static func isCountable(_ stanza: Stanza) -> Bool {
stanza.name == "iq" || stanza.name == "message" || stanza.name == "presence"
}
}
+872
View File
@@ -0,0 +1,872 @@
import Foundation
import Combine
import CommonCrypto
import CryptoKit
import os
@preconcurrency import Martin
import MartinOMEMO
extension XmppModuleIdentifier {
static var omemo2: XmppModuleIdentifier<LumaOMEMO2Module> {
LumaOMEMO2Module.IDENTIFIER
}
}
/// OMEMO 2 (XEP-0384 0.8.3, urn:xmpp:omemo:2) support built on the
/// MartinOMEMO signal stack. Wire format implemented here:
/// - payload: AES-256-CBC + HMAC-SHA-256 keyed via HKDF ("OMEMO Payload"),
/// plaintext is a Stanza Content Encryption `<envelope>` (XEP-0420);
/// - header: `<keys jid>` groups with `<key rid kex>` elements;
/// - device list node `urn:xmpp:omemo:2:devices` (`<devices>` element);
/// - bundle node `urn:xmpp:omemo:2:bundles` (one item per device id,
/// `<bundle>` with `<spk>/<spks>/<ik>/<prekeys><pk>`).
/// The Double Ratchet sessions are shared with the legacy OMEMO module
/// through the same SignalStorage.
final class LumaOMEMO2Module: AbstractPEPModule, XmppModule, @unchecked Sendable {
public static let ID = "omemo2"
public static let IDENTIFIER = XmppModuleIdentifier<LumaOMEMO2Module>()
public static let XMLNS = "urn:xmpp:omemo:2"
public static let DEVICES_LIST_NODE = "urn:xmpp:omemo:2:devices"
public static let BUNDLES_NODE = "urn:xmpp:omemo:2:bundles"
public static let SCE_XMLNS = "urn:xmpp:sce:0"
public static let HKDF_INFO = "OMEMO Payload"
public let id: String = ID
public let criteria = Criteria.empty()
public let features: [String] = [LumaOMEMO2Module.DEVICES_LIST_NODE + "+notify"]
public let signalContext: SignalContext
public let storage: SignalStorage
private let devicesQueue = DispatchQueue(label: "app.luma.omemo2.devices")
private var devices: [BareJID: [Int32]] = [:]
private var devicesFetchError: [BareJID: [Int32]] = [:]
/// True once our device id has been published to the OMEMO 2 device
/// list and our bundle is on the server. Drives the UI readiness flag.
@Published public private(set) var isReady: Bool = false
public override var isPepAvailable: Bool {
didSet {
if isPepAvailable {
publishBundleIfNeeded { [weak self] in
self?.publishDeviceListIfNeeded()
}
}
}
}
public init(signalContext: SignalContext, signalStorage: SignalStorage) {
self.signalContext = signalContext
self.storage = signalStorage
super.init()
}
public func process(stanza: Stanza) throws {
// Decryption is driven explicitly by XMPPService decode calls;
// PEP notifications arrive through onItemNotification.
}
// MARK: - Device list
public func devices(for jid: BareJID) -> [Int32]? {
let known = devicesQueue.sync { devices[jid] }
guard let known else { return nil }
guard let failed = devicesFetchError[jid] else { return known }
return known.filter { !failed.contains($0) }
}
/// Returns the cached OMEMO 2 device list for `jid`, fetching it from
/// PEP on demand when `fetching` is true and nothing is cached yet. Used
/// by the send path so the first message to an OMEMO 2-only contact does
/// not fall back to the legacy protocol.
public func deviceIDs(for jid: BareJID, fetching: Bool) async -> [Int32]? {
if let known = devices(for: jid) { return known }
guard fetching, let context else { return nil }
return await withCheckedContinuation { continuation in
context.module(.pubsub).retrieveItems(
from: jid,
for: Self.DEVICES_LIST_NODE,
limit: .lastItems(1)
) { [weak self] result in
guard let self,
case .success(let items) = result,
let listEl = items.items.first?.payload,
listEl.name == "devices",
listEl.xmlns == Self.XMLNS
else {
continuation.resume(returning: nil)
return
}
let known = listEl.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) })
let jidString = jid.stringValue
self.devicesQueue.async { self.devices[BareJID(jidString)] = known }
continuation.resume(returning: known)
}
}
}
public func isAvailable(for jid: BareJID) -> Bool {
!(devicesQueue.sync { devices[jid] }?.isEmpty ?? true)
|| !storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true).isEmpty
}
private func publishDeviceListIfNeeded() {
guard isPepAvailable, let context else { return }
let pepJid = context.userBareJid
context.module(.pubsub).retrieveItems(
from: pepJid,
for: Self.DEVICES_LIST_NODE,
limit: .lastItems(1)
) { [weak self] result in
switch result {
case .success(let items):
self?.processDeviceList(jid: pepJid, payload: items.items.first?.payload)
case .failure(let error):
guard error.error == .item_not_found || error.error == .internal_server_error() else { return }
self?.processDeviceList(jid: pepJid, payload: nil)
}
}
}
private func processDeviceList(jid: BareJID, payload input: Element?) {
guard let context else { return }
var list = input
if list?.name != "devices" || list?.xmlns != Self.XMLNS {
list = Element(name: "devices", xmlns: Self.XMLNS)
}
guard let list else { return }
let isOwn = jid == context.userBareJid
let ourID = String(storage.identityKeyStore.localRegistrationId())
var changed = false
if isOwn,
list.findChild(where: { $0.name == "device" && $0.getAttribute("id") == ourID }) == nil
{
list.addChild(Element(name: "device", attributes: ["id": ourID, "label": "Luma"]))
changed = true
}
if isOwn, changed {
let options = PubSubNodeConfig()
options.accessModel = .open
context.module(.pubsub).publishItem(
at: jid,
to: Self.DEVICES_LIST_NODE,
itemId: "current",
payload: list,
publishOptions: options
) { [weak self] result in
switch result {
case .success:
Logger(subsystem: "Luma", category: "omemo2")
.info("device list published jid=\(jid.stringValue)")
self?.isReady = true
case .failure(let error):
Logger(subsystem: "Luma", category: "omemo2")
.warning("device list publish failed: \(error.error)")
guard error.error == .conflict() else { return }
context.module(.pubsub).retrieveNodeConfiguration(
from: jid,
node: Self.DEVICES_LIST_NODE
) { result in
guard case .success(let form) = result else { return }
form.accessModel = .open
context.module(.pubsub).configureNode(
at: jid,
node: Self.DEVICES_LIST_NODE,
with: form
) { _ in
context.module(.pubsub).publishItem(
at: jid,
to: Self.DEVICES_LIST_NODE,
itemId: "current",
payload: list,
publishOptions: options
) { result in
if case .success = result { self?.isReady = true }
}
}
}
}
}
} else if isOwn {
isReady = true
}
// Track known devices and align identity states with the list.
let known = list.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) })
devicesQueue.async { [weak self] in self?.devices[jid] = known }
// Record which device IDs are OMEMO 2 so the encryption screen can
// badge them; legacy-only identities stay unmarked.
(storage as? LumaOMEMOStore)?.setOMEMO2DeviceIDs(Set(known), for: jid.stringValue)
let active = storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true)
active.filter { !known.contains($0) }.forEach {
_ = storage.identityKeyStore.setStatus(active: false, forIdentity: SignalAddress(name: jid.stringValue, deviceId: $0))
}
known.filter { !active.contains($0) }.forEach {
_ = storage.identityKeyStore.setStatus(active: true, forIdentity: SignalAddress(name: jid.stringValue, deviceId: $0))
}
}
override func onItemNotification(notification: PubSubModule.ItemNotification) {
guard notification.node == Self.DEVICES_LIST_NODE, let context else { return }
switch notification.action {
case .published(let item):
let from = notification.message.from?.bareJid ?? context.userBareJid
processDeviceList(jid: from, payload: item.payload)
default:
break
}
}
// MARK: - Bundle
func publishBundleIfNeeded(completionHandler: (() -> Void)?) {
guard isPepAvailable, let context else {
completionHandler?()
return
}
let deviceID = String(storage.identityKeyStore.localRegistrationId())
context.module(.pubsub).retrieveItems(
from: context.userBareJid,
for: Self.BUNDLES_NODE,
limit: .items(withIds: [deviceID])
) { [weak self] result in
switch result {
case .success(let items):
self?.publishBundle(current: items.items.first?.payload, completionHandler: completionHandler)
case .failure(let error):
guard error.error == .item_not_found || error.error == .internal_server_error() else {
completionHandler?()
return
}
self?.publishBundle(current: nil, completionHandler: completionHandler)
}
}
}
private func signedPreKey(regenerate: Bool = false) -> SignalSignedPreKey? {
let signedPreKeyId = storage.signedPreKeyStore.countSignedPreKeys()
var signedPreKey: SignalSignedPreKey?
if !regenerate, signedPreKeyId != 0,
let data = signalContext.storage.signedPreKeyStore.loadSignedPreKey(withId: UInt32(signedPreKeyId))
{
signedPreKey = SignalSignedPreKey(fromSerializedData: data)
}
if signedPreKey == nil {
guard let identityKeyPair = storage.identityKeyStore.keyPair() else { return nil }
signedPreKey = signalContext.generateSignedPreKey(
withIdentity: identityKeyPair,
signedPreKeyId: UInt32(signedPreKeyId + 1)
)
guard let signedPreKey, let serialized = signedPreKey.serializedData else { return nil }
guard signalContext.storage.signedPreKeyStore.storeSignedPreKey(serialized, withId: signedPreKey.preKeyId) else {
return nil
}
}
return signedPreKey
}
private func publishBundle(current input: Element?, completionHandler: (() -> Void)?) {
guard let identityKeyPair = storage.identityKeyStore.keyPair(),
let identityPublicKey = identityKeyPair.publicKey
else {
completionHandler?()
return
}
var flush = input == nil
if !flush {
flush = identityPublicKey.base64EncodedString() != input?.findChild(name: "ik")?.value
}
guard let signedPreKey = signedPreKey(regenerate: flush) else {
completionHandler?()
return
}
let signedPublicBase64 = signedPreKey.publicKeyData?.base64EncodedString() ?? ""
let signatureBase64 = signedPreKey.signature.base64EncodedString()
var changed = flush
|| signedPublicBase64 != input?.findChild(name: "spk")?.value
|| signatureBase64 != input?.findChild(name: "spks")?.value
let currentIDs = input?.findChild(name: "prekeys")?.mapChildren(transform: {
$0.getAttribute("id").flatMap(UInt32.init)
}) ?? []
var validKeys = currentIDs.compactMap { id -> SignalPreKey? in
guard let data = storage.preKeyStore.loadPreKey(withId: id) else { return nil }
return SignalPreKey(fromSerializedData: data)
}
let needKeys = 100 - validKeys.count
if needKeys > 0 {
changed = true
let start = storage.preKeyStore.currentPreKeyId() + 1
let newKeys = signalContext.generatePreKeys(withStartingPreKeyId: start, count: UInt32(needKeys))
validKeys += newKeys.filter { key in
guard let serialized = key.serializedData else { return false }
return storage.preKeyStore.storePreKey(serialized, withId: key.preKeyId)
}
}
if changed {
publishBundle(
signedPreKey: signedPreKey,
identityKey: identityPublicKey,
preKeys: validKeys,
completionHandler: completionHandler
)
} else {
completionHandler?()
}
}
private func publishBundle(
signedPreKey: SignalSignedPreKey,
identityKey: Data,
preKeys: [SignalPreKey],
completionHandler: (() -> Void)?
) {
guard let context, let signedPreKeyPublic = signedPreKey.publicKeyData else {
completionHandler?()
return
}
let bundleEl = Element(name: "bundle", xmlns: Self.XMLNS)
bundleEl.addChild(Element(
name: "spk",
cdata: signedPreKeyPublic.base64EncodedString(),
attributes: ["id": String(signedPreKey.preKeyId)]
))
bundleEl.addChild(Element(name: "spks", cdata: signedPreKey.signature.base64EncodedString()))
bundleEl.addChild(Element(name: "ik", cdata: identityKey.base64EncodedString()))
let pkElements = preKeys.map { preKey -> Element in
let publicData = preKey.serializedPublicKey?.base64EncodedString() ?? ""
return Element(name: "pk", cdata: publicData, attributes: ["id": String(preKey.preKeyId)])
}
bundleEl.addChild(Element(name: "prekeys", children: pkElements))
let options = PubSubNodeConfig()
options.accessModel = .open
options.maxItems = .max
let deviceID = String(storage.identityKeyStore.localRegistrationId())
context.module(.pubsub).publishItem(
at: nil,
to: Self.BUNDLES_NODE,
itemId: deviceID,
payload: bundleEl,
publishOptions: options
) { result in
switch result {
case .success:
Logger(subsystem: "Luma", category: "omemo2")
.info("bundle published deviceID=\(deviceID)")
case .failure(let error):
Logger(subsystem: "Luma", category: "omemo2")
.warning("bundle publish failed: \(error.error)")
}
completionHandler?()
}
}
// MARK: - Session building
func buildSession(forAddress address: SignalAddress, completionHandler: (() -> Void)? = nil) {
guard let context else {
completionHandler?()
return
}
let pepJid = BareJID(address.name)
context.module(.pubsub).retrieveItems(
from: pepJid,
for: Self.BUNDLES_NODE,
limit: .items(withIds: [String(address.deviceId)])
) { [weak self] result in
defer { completionHandler?() }
guard let self, case .success(let items) = result,
let bundle = OMEMO2Bundle(from: items.items.first?.payload)
else {
self?.markDeviceAsFailed(for: pepJid, andDeviceId: address.deviceId)
return
}
guard let preKey = bundle.preKeys.randomElement(),
let preKeyBundle = SignalPreKeyBundle(
registrationId: 0,
deviceId: address.deviceId,
preKeyId: preKey.id,
preKeyPublic: preKey.data,
signedPreKeyId: bundle.signedPreKeyId,
signedPreKeyPublic: bundle.signedPreKeyPublic,
signedPreKeySignature: bundle.signedPreKeySignature,
identityKey: bundle.identityKey
),
let builder = SignalSessionBuilder(withAddress: address, andContext: self.signalContext)
else {
self.markDeviceAsFailed(for: pepJid, andDeviceId: address.deviceId)
return
}
_ = builder.processPreKeyBundle(bundle: preKeyBundle)
}
}
private func markDeviceAsFailed(for jid: BareJID, andDeviceId deviceId: Int32) {
var failed = devicesFetchError[jid] ?? []
if !failed.contains(deviceId) {
failed.append(deviceId)
devicesFetchError[jid] = failed
}
}
// MARK: - Decode
public func decode(message: Message, from: BareJID, serverMsgId: String? = nil) -> DecryptionResult<Message, SignalError> {
guard context != nil else { return .failure(.unknown) }
guard let encryptedEl = message.firstChild(name: "encrypted", xmlns: Self.XMLNS),
let headerEl = encryptedEl.findChild(name: "header"),
let sid = UInt32(headerEl.getAttribute("sid") ?? "")
else {
return .failure(.notEncrypted)
}
let localDeviceID = String(storage.identityKeyStore.localRegistrationId())
let ownJID = context!.userBareJid.stringValue.lowercased()
// Collect candidate keys for our device: prefer the 0.8.3
// `<keys jid>` grouping, fall back to direct `<key>` children.
var keyElements: [Element] = []
for keysEl in headerEl.getChildren(where: { $0.name == "keys" }) {
guard keysEl.getAttribute("jid")?.lowercased() == ownJID else { continue }
keyElements.append(contentsOf: keysEl.getChildren(where: { $0.name == "key" }))
}
if keyElements.isEmpty {
keyElements = headerEl.getChildren(where: { $0.name == "key" })
}
let ours = keyElements.filter { $0.getAttribute("rid") == localDeviceID }
guard !ours.isEmpty else {
Logger(subsystem: "Luma", category: "omemo2")
.warning("message not encrypted for us: from=\(from.stringValue) sid=\(sid) ourRid=\(localDeviceID) keyCount=\(keyElements.count) hasPayload=\(encryptedEl.findChild(name: "payload") != nil)")
guard context!.userBareJid != from || sid != storage.identityKeyStore.localRegistrationId() else {
return .failure(.duplicateMessage)
}
guard encryptedEl.findChild(name: "payload") != nil else {
return .failure(.duplicateMessage)
}
return .failure(.invalidMessage)
}
let address = SignalAddress(name: from.stringValue, deviceId: Int32(bitPattern: sid))
var lastError: SignalError = .unknown
for keyEl in ours {
guard let base64 = keyEl.value, let keyData = Data(base64Encoded: base64),
let cipher = SignalSessionCipher(withAddress: address, andContext: signalContext)
else {
Logger(subsystem: "Luma", category: "omemo2")
.warning("unusable key element from=\(from.stringValue) sid=\(sid)")
continue
}
let kexRaw = keyEl.getAttribute("kex") ?? ""
let isKex = kexRaw == "true" || kexRaw == "1"
|| keyEl.getAttribute("prekey") == "true" || keyEl.getAttribute("prekey") == "1"
// Trust the kex attribute first; some clients mislabel the key
// element, so retry with the opposite interpretation before
// giving up on the message.
var elementError: SignalError = .unknown
for prekey in [isKex, !isKex] {
let result = cipher.decrypt(key: SignalSessionCipher.Key(
key: keyData,
deviceId: Int32(bitPattern: sid),
prekey: prekey
))
switch result {
case .success(let combined):
Logger(subsystem: "Luma", category: "omemo2")
.info("key decrypted from=\(from.stringValue) sid=\(sid) prekey=\(prekey)")
return finishDecode(
message: message,
encryptedEl: encryptedEl,
combined: combined,
address: address,
isKex: prekey
)
case .failure(let error):
elementError = error
}
}
lastError = elementError
Logger(subsystem: "Luma", category: "omemo2")
.warning("key decrypt failed: error=\(elementError) from=\(from.stringValue) sid=\(sid) kexAttr=\(kexRaw)")
}
Logger(subsystem: "Luma", category: "omemo2")
.warning("decode failed: error=\(lastError) from=\(from.stringValue) sid=\(sid)")
if (lastError == .noSession || lastError == .invalidMessage), serverMsgId != nil {
buildSession(forAddress: address)
}
return .failure(lastError)
}
private func finishDecode(
message: Message,
encryptedEl: Element,
combined: Data,
address: SignalAddress,
isKex: Bool
) -> DecryptionResult<Message, SignalError> {
message.removeChild(encryptedEl)
// A consumed prekey must leave our published bundle.
if isKex, storage.preKeyStore.flushDeletedPreKeys() {
publishBundleIfNeeded(completionHandler: nil)
}
guard let payloadValue = encryptedEl.findChild(name: "payload")?.value,
let ciphertext = Data(base64Encoded: payloadValue)
else {
// Empty message: session management, nothing to display.
return .successTransportKey(combined, iv: Data())
}
// combined = key (32) || truncated HMAC (16)
guard combined.count >= 48 else { return .failure(.invalidMac) }
let key = combined.subdata(in: 0..<32)
let expectedMAC = combined.subdata(in: 32..<48)
guard let derived = Self.derivePayloadKeys(from: key) else { return .failure(.invalidMac) }
let computedMAC = Data(HMAC<SHA256>.authenticationCode(
for: ciphertext,
using: SymmetricKey(data: derived.authKey)
).prefix(16))
guard Self.constantTimeEquals(computedMAC, expectedMAC) else {
Logger(subsystem: "Luma", category: "omemo2")
.warning("payload HMAC mismatch from=\(address.name) device=\(address.deviceId)")
return .failure(.invalidMac)
}
guard let plaintext = Self.aes256CBCDecrypt(ciphertext, key: derived.encryptionKey, iv: derived.iv),
let plaintextString = String(data: plaintext, encoding: .utf8),
let envelope = Element.from(string: plaintextString),
envelope.name == "envelope"
else {
Logger(subsystem: "Luma", category: "omemo2")
.warning("payload envelope parse failed from=\(address.name) device=\(address.deviceId)")
return .failure(.invalidMessage)
}
if let content = envelope.findChild(name: "content"),
let bodyEl = content.findChild(name: "body"),
let body = bodyEl.value, !body.isEmpty
{
message.body = body
}
Logger(subsystem: "Luma", category: "omemo2")
.info("decoded message from=\(address.name) device=\(address.deviceId) bodyLen=\(message.body?.count ?? 0)")
_ = storage.identityKeyStore.setStatus(active: true, forIdentity: address)
return .successMessage(
message,
fingerprint: storage.identityKeyStore.identityFingerprint(forAddress: address)
)
}
// MARK: - Encode
public func encode(
message: Message,
withStoreHint: Bool = true,
completionHandler: @escaping (EncryptionResult<Message, SignalError>) -> Void
) {
guard let jid = message.to?.bareJid else {
completionHandler(.failure(.noDestination))
return
}
encode(message: message, for: [jid], withStoreHint: withStoreHint, completionHandler: completionHandler)
}
public func addresses(
for jids: [BareJID],
completionHandler: @escaping (Result<[SignalAddress], SignalError>) -> Void
) {
guard let pubsub = context?.module(.pubsub) else {
completionHandler(.failure(.unknown))
return
}
let group = DispatchGroup()
var addresses: [SignalAddress] = []
for jid in jids {
if let known = devices(for: jid) {
let knownAddresses = known.map { SignalAddress(name: jid.stringValue, deviceId: $0) }
addresses.append(contentsOf: knownAddresses)
for address in knownAddresses where !storage.sessionStore.containsSessionRecord(forAddress: address) {
group.enter()
buildSession(forAddress: address) { group.leave() }
}
} else {
group.enter()
pubsub.retrieveItems(from: jid, for: Self.DEVICES_LIST_NODE, limit: .lastItems(1)) { [weak self] result in
defer { group.leave() }
guard let self, case .success(let items) = result,
let listEl = items.items.first?.payload,
listEl.name == "devices", listEl.xmlns == Self.XMLNS
else { return }
let known = listEl.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) })
self.devicesQueue.async { self.devices[jid] = known }
let knownAddresses = known.map { SignalAddress(name: jid.stringValue, deviceId: $0) }
addresses.append(contentsOf: knownAddresses)
for address in knownAddresses where !self.storage.sessionStore.containsSessionRecord(forAddress: address) {
group.enter()
self.buildSession(forAddress: address) { group.leave() }
}
}
}
}
group.notify(queue: .main) {
completionHandler(.success(addresses))
}
}
public func encode(
message: Message,
for jids: [BareJID],
withStoreHint: Bool = true,
completionHandler: @escaping (EncryptionResult<Message, SignalError>) -> Void
) {
addresses(for: jids) { result in
switch result {
case .failure(let error):
completionHandler(.failure(error))
case .success(let addresses):
if addresses.isEmpty {
completionHandler(.failure(.noSession))
} else {
self.encode(message: message, forAddresses: addresses, withStoreHint: withStoreHint, completionHandler: completionHandler)
}
}
}
}
public func encode(
message: Message,
forAddresses addresses: [SignalAddress],
roomJID: BareJID? = nil,
withStoreHint: Bool = true,
completionHandler: @escaping (EncryptionResult<Message, SignalError>) -> Void
) {
let result = encodeMessage(message: message, for: addresses, roomJID: roomJID)
switch result {
case .successMessage(let encoded, _):
if withStoreHint {
encoded.addChild(Element(name: "store", xmlns: "urn:xmpp:hints"))
}
default:
break
}
completionHandler(result)
}
private func encodeMessage(
message: Message,
for remoteAddresses: [SignalAddress],
roomJID: BareJID?
) -> EncryptionResult<Message, SignalError> {
guard let context else { return .failure(.unknown) }
let localAddresses = storage.sessionStore
.allDevices(for: context.userBareJid.stringValue, activeAndTrusted: true)
.map { SignalAddress(name: context.userBareJid.stringValue, deviceId: $0) }
let destinations = Set(remoteAddresses + localAddresses)
// Stanza Content Encryption envelope (XEP-0420) as the plaintext.
let envelopeXML = Self.envelopeXML(
body: message.body,
from: context.userBareJid.stringValue,
to: roomJID?.stringValue
)
guard let plaintext = envelopeXML.data(using: .utf8) else { return .failure(.unknown) }
var key = Data(count: 32)
key.withUnsafeMutableBytes { bytes in
_ = SecRandomCopyBytes(kSecRandomDefault, 32, bytes.baseAddress!)
}
guard let derived = Self.derivePayloadKeys(from: key) else { return .failure(.unknown) }
guard let ciphertext = Self.aes256CBCEncrypt(plaintext, key: derived.encryptionKey, iv: derived.iv) else {
return .failure(.unknown)
}
let mac = Data(HMAC<SHA256>.authenticationCode(
for: ciphertext,
using: SymmetricKey(data: derived.authKey)
).prefix(16))
var combinedKey = key
combinedKey.append(mac)
let encryptedEl = Element(name: "encrypted", xmlns: Self.XMLNS)
let header = Element(name: "header")
header.setAttribute("sid", value: String(storage.identityKeyStore.localRegistrationId()))
encryptedEl.addChild(header)
var keysByJID: [String: [Element]] = [:]
for address in destinations {
guard let cipher = SignalSessionCipher(withAddress: address, andContext: signalContext) else { continue }
switch cipher.encrypt(data: combinedKey) {
case .success(let output):
let keyEl = Element(name: "key", cdata: output.key.base64EncodedString())
keyEl.setAttribute("rid", value: String(output.deviceId))
if output.prekey { keyEl.setAttribute("kex", value: "true") }
keysByJID[address.name.lowercased(), default: []].append(keyEl)
case .failure:
break
}
}
for (jid, keys) in keysByJID {
let keysEl = Element(name: "keys")
keysEl.setAttribute("jid", value: jid)
keysEl.addChildren(keys)
header.addChild(keysEl)
}
encryptedEl.addChild(Element(name: "payload", cdata: ciphertext.base64EncodedString()))
message.body = nil
message.addChild(encryptedEl)
let fingerprint = storage.identityKeyStore.identityFingerprint(
forAddress: SignalAddress(
name: context.userBareJid.stringValue,
deviceId: Int32(bitPattern: storage.identityKeyStore.localRegistrationId())
)
)
return .successMessage(message, fingerprint: fingerprint)
}
// MARK: - Crypto helpers (internal for unit tests)
struct PayloadKeys: Equatable {
let encryptionKey: Data
let authKey: Data
let iv: Data
}
static func derivePayloadKeys(from key: Data) -> PayloadKeys? {
guard key.count == 32 else { return nil }
let salt = Data(count: 32) // 256 zero bits
let material = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: key),
salt: salt,
info: Data(HKDF_INFO.utf8),
outputByteCount: 80
)
let bytes = material.withUnsafeBytes { Data($0) }
return PayloadKeys(
encryptionKey: bytes.subdata(in: 0..<32),
authKey: bytes.subdata(in: 32..<64),
iv: bytes.subdata(in: 64..<80)
)
}
static func aes256CBCEncrypt(_ plaintext: Data, key: Data, iv: Data) -> Data? {
crypt(operation: CCOperation(kCCEncrypt), data: plaintext, key: key, iv: iv)
}
static func aes256CBCDecrypt(_ ciphertext: Data, key: Data, iv: Data) -> Data? {
crypt(operation: CCOperation(kCCDecrypt), data: ciphertext, key: key, iv: iv)
}
private static func crypt(operation: CCOperation, data: Data, key: Data, iv: Data) -> Data? {
guard key.count == kCCKeySizeAES256, iv.count == kCCBlockSizeAES128 else { return nil }
let bufferSize = data.count + kCCBlockSizeAES128
var buffer = Data(count: bufferSize)
var moved = 0
let status = key.withUnsafeBytes { keyBytes in
iv.withUnsafeBytes { ivBytes in
data.withUnsafeBytes { dataBytes in
buffer.withUnsafeMutableBytes { outBytes in
CCCrypt(
operation,
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyBytes.baseAddress, key.count,
ivBytes.baseAddress,
dataBytes.baseAddress, data.count,
outBytes.baseAddress, bufferSize,
&moved
)
}
}
}
}
guard status == kCCSuccess else { return nil }
buffer.removeSubrange(moved..<buffer.count)
return buffer
}
static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool {
guard lhs.count == rhs.count else { return false }
var difference: UInt8 = 0
for index in 0..<lhs.count {
difference |= lhs[index] ^ rhs[index]
}
return difference == 0
}
static func envelopeXML(body: String?, from fromJID: String, to toJID: String?) -> String {
var content = ""
if let body, !body.isEmpty {
content += "<body xmlns='jabber:client'>" + escapeXML(body) + "</body>"
}
let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
let rpadLength = Int.random(in: 12...96)
let rpad = String((0..<rpadLength).map { _ in alphabet.randomElement()! })
var result = "<envelope xmlns='" + Self.SCE_XMLNS + "'><content>" + content + "</content>"
result += "<rpad>" + rpad + "</rpad>"
result += "<from jid='" + escapeXML(fromJID) + "'/>"
if let toJID {
result += "<to jid='" + escapeXML(toJID) + "'/>"
}
result += "</envelope>"
return result
}
static func escapeXML(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
}
/// Parsed `urn:xmpp:omemo:2` bundle (`<spk>/<spks>/<ik>/<prekeys><pk>`).
struct OMEMO2Bundle {
let signedPreKeyId: UInt32
let signedPreKeyPublic: Data
let signedPreKeySignature: Data
let identityKey: Data
let preKeys: [(id: UInt32, data: Data)]
init?(from element: Element?) {
guard let element,
element.name == "bundle", element.xmlns == LumaOMEMO2Module.XMLNS,
let spk = element.findChild(name: "spk"),
let spkID = spk.getAttribute("id").flatMap(UInt32.init),
let spkValue = spk.value,
let spkData = Data(base64Encoded: spkValue),
let spks = element.findChild(name: "spks")?.value,
let spksData = Data(base64Encoded: spks),
let ik = element.findChild(name: "ik")?.value,
let ikData = Data(base64Encoded: ik),
let preKeysEl = element.findChild(name: "prekeys")
else { return nil }
let parsedPreKeys = preKeysEl.mapChildren(transform: { pk -> (UInt32, Data)? in
guard pk.name == "pk",
let id = pk.getAttribute("id").flatMap(UInt32.init),
let value = pk.value,
let data = Data(base64Encoded: value)
else { return nil }
return (id, data)
})
guard !parsedPreKeys.isEmpty else { return nil }
signedPreKeyId = spkID
signedPreKeyPublic = spkData
signedPreKeySignature = spksData
identityKey = ikData
preKeys = parsedPreKeys
}
}
+55 -3
View File
@@ -86,23 +86,71 @@ final class LumaOMEMOStore: SignalStorage {
}
func devices(for jid: String) -> [OMEMODevice] {
identityAdapter.identities(forName: jid.lowercased()).map { identity in
let key = jid.lowercased()
let published: Set<Int32> = Set(repository.read { $0.omemo2DeviceIDs?[key] ?? [] })
return identityAdapter.identities(forName: key).map { identity in
OMEMODevice(
jid: identity.address.name,
deviceID: identity.address.deviceId,
fingerprint: identity.fingerprint,
trust: Self.mapTrust(identity.status),
isActive: identity.status.isActive,
isOwn: identity.own
isOwn: identity.own,
isOMEMO2: published.contains(identity.address.deviceId)
)
}
.sorted { lhs, rhs in lhs.deviceID < rhs.deviceID }
}
/// Records the set of device IDs a JID currently publishes under
/// `urn:xmpp:omemo:2`. Drives the per-device protocol badge on the
/// encryption screen and persists so the label survives a relaunch.
func setOMEMO2DeviceIDs(_ deviceIDs: Set<Int32>, for jid: String) {
let key = jid.lowercased()
repository.mutate { state in
var all = state.omemo2DeviceIDs ?? [:]
if deviceIDs.isEmpty {
all.removeValue(forKey: key)
} else {
all[key] = deviceIDs.sorted()
}
state.omemo2DeviceIDs = all
}
}
func hasSession(for address: SignalAddress) -> Bool {
sessionAdapter.containsSessionRecord(forAddress: address)
}
var accountJID: String { repository.accountJID }
var localRegistrationID: UInt32 { identityAdapter.localRegistrationId() }
/// Removes a session record with our own current device. A poisoned
/// self-session (created by a buggy older build) makes encrypt-to-self
/// fail with "Bad MAC". Deleting it is safe, but the session is then
/// legitimately rebuilt from our own bundle and must never be purged
/// again: archived self-copies depend on its ratchet continuity.
func removeSessionWithOwnDevice() {
let registrationID = identityAdapter.localRegistrationId()
guard registrationID != 0 else { return }
let address = SignalAddress(
name: repository.accountJID,
deviceId: Int32(bitPattern: registrationID)
)
_ = sessionAdapter.deleteSessionRecord(forAddress: address)
}
/// One-time migration purge of the poisoned self-session, keyed per
/// account. Running it on every connect destroys the self-session ratchet
/// and turns every archived encrypt-to-self copy into "could not decrypt".
func removeSessionWithOwnDeviceOnce() {
let flagKey = "app.luma.chat.selfSessionPurged." + repository.accountJID
guard !UserDefaults.standard.bool(forKey: flagKey) else { return }
UserDefaults.standard.set(true, forKey: flagKey)
removeSessionWithOwnDevice()
}
func flushPendingPersistence() {
repository.flush()
}
@@ -544,6 +592,9 @@ private struct StoredOMEMOState: Codable {
var signedPreKeys: [String: Data]
var sessions: [String: Data]
var senderKeys: [String: Data]
// Lowercased JID -> device IDs currently published under urn:xmpp:omemo:2.
// Optional for backward compatibility with state written before OMEMO 2.
var omemo2DeviceIDs: [String: [Int32]]?
static let empty = StoredOMEMOState(
registrationID: 0,
@@ -552,7 +603,8 @@ private struct StoredOMEMOState: Codable {
preKeys: [:],
signedPreKeys: [:],
sessions: [:],
senderKeys: [:]
senderKeys: [:],
omemo2DeviceIDs: nil
)
}
-2
View File
@@ -1,8 +1,6 @@
import Foundation
import Martin
/// Martin 3.2.4 does not expose `DefaultRoomStore.init()` outside its module.
/// This mirrors its in-memory behavior so the app can register XEP-0045 MUC.
final class LumaRoomStore: RoomStore {
typealias Room = RoomBase
@@ -0,0 +1,26 @@
import Foundation
import Martin
/// Observes the raw SASL `<challenge/>` stanzas before `SaslModule` consumes
/// them, so Luma can detect the XEP-0474 downgrade-protection hash (`h=`
/// attribute in the SCRAM server-first message) regardless of which SCRAM
/// mechanism implementation actually runs the exchange. Registered before
/// `SaslModule` in the module list.
final class LumaSaslChallengeModule: XmppModuleBase, XmppModule {
static let ID = "lumaSaslChallenge"
static let saslXMLNS = "urn:ietf:params:xml:ns:xmpp-sasl"
let criteria = Criteria.name("challenge", xmlns: LumaSaslChallengeModule.saslXMLNS)
let features: [String] = []
private let onChallenge: (String) -> Void
init(onChallenge: @escaping (String) -> Void) {
self.onChallenge = onChallenge
super.init()
}
func process(stanza: Stanza) throws {
onChallenge(stanza.element.value ?? "")
}
}
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more