Merge pull request 'Develop' (#19) from develop into main
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
commit
8ecd38499d
165 files changed
+21368
-3264
No files matched your search
+32
-24
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+485
-78
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>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 секундами.
|
||||
|
||||
BIN
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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1296
-332
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
+1266
-588
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)!
|
||||
)
|
||||
}
|
||||
@@ -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: {}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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: "Алиса")
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user