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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- issue-*
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-ios:
|
||||||
name: Build and Test SwiftUI App
|
name: Build Unsigned iOS IPA
|
||||||
runs-on: macos-26 # Uses Apple Silicon M1/M2 runners
|
runs-on: macos-26
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Step 1: Check out the repository code
|
- name: Checkout repository
|
||||||
- name: Checkout Repository
|
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Xcode version
|
||||||
# Step 2: Select the correct Xcode version
|
|
||||||
- name: Set Xcode Version
|
|
||||||
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
|
||||||
|
- name: Build Xcode Workspace
|
||||||
# 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
|
|
||||||
run: |
|
run: |
|
||||||
xcodebuild test \
|
xcodebuild build \
|
||||||
-project Luma.xcodeproj \
|
-project Luma.xcodeproj \
|
||||||
-scheme Luma \
|
-scheme Luma \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
|
-configuration Release \
|
||||||
-quiet
|
-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>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Luma использует камеру для фотографий, видеосообщений и видеозвонков.</string>
|
<string>Luma использует камеру для фотографий, видеосообщений и видеозвонков.</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Luma использует Face ID для разблокировки приложения.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Luma использует локальную сеть для прямого соединения аудио- и видеозвонков.</string>
|
<string>Luma использует локальную сеть для прямого соединения аудио- и видеозвонков.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Luma использует геопозицию только когда вы выбираете отправку точки в чате.</string>
|
<string>Luma использует геопозицию только когда вы выбираете отправку точки в чате.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Luma использует микрофон для голосовых сообщений и звонков.</string>
|
<string>Luma использует микрофон для голосовых сообщений и звонков.</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>Luma сохраняет фотографии и видео в вашу медиатеку.</string>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
@@ -40,6 +44,19 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<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>
|
<key>UISupportsDocumentBrowser</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
<string>public.app-category.social-networking</string>
|
<string>public.app-category.social-networking</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Luma использует камеру для фотографий, видеосообщений и видеозвонков.</string>
|
<string>Luma использует камеру для фотографий, видеосообщений и видеозвонков.</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Luma использует Touch ID для разблокировки приложения.</string>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSLocationUsageDescription</key>
|
<key>NSLocationUsageDescription</key>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.files.user-selected.read-only</key>
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
|||||||
@@ -27,6 +27,6 @@
|
|||||||
<key>WKApplication</key>
|
<key>WKApplication</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>WKCompanionAppBundleIdentifier</key>
|
<key>WKCompanionAppBundleIdentifier</key>
|
||||||
<string>app.luma.chat</string>
|
<string>$(COMPANION_BUNDLE_IDENTIFIER)</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
+70
-14
@@ -9,7 +9,9 @@
|
|||||||
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
|
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
|
||||||
AES-GCM выполняется CryptoKit.
|
AES-GCM выполняется CryptoKit.
|
||||||
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
|
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
|
||||||
сообщения и сохраняет snapshot локально.
|
сообщения и сохраняет их в per-account SwiftData-хранилище (`ArchiveStore`,
|
||||||
|
отдельный `ModelContainer` на аккаунт); список чатов, лента и список
|
||||||
|
получателей при пересылке читают данные напрямую через `@Query`.
|
||||||
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
|
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
|
||||||
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
|
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
|
||||||
текстовые ответы возвращаются немедленно или через гарантированную очередь,
|
текстовые ответы возвращаются немедленно или через гарантированную очередь,
|
||||||
@@ -35,11 +37,27 @@ XEP-0045-комнатами в отдельном разделе «Группо
|
|||||||
собственных дополнительных устройств; при выключенном отправляется обычный
|
собственных дополнительных устройств; при выключенном отправляется обычный
|
||||||
`<body>` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический
|
`<body>` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический
|
||||||
fallback на plaintext. UI сразу показывает optimistic message и обновляет
|
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. Сервис определяет
|
Входящая stanza может прийти напрямую, через carbons или MAM. Сервис определяет
|
||||||
peer, расшифровывает payload, использует `origin-id`/stanza id для дедупликации и
|
peer, расшифровывает payload, использует `origin-id`/stanza id для дедупликации и
|
||||||
передаёт value-type envelope в `AppModel`.
|
передаёт value-type envelope в `AppModel`. Дедупликация выполняется по
|
||||||
|
хеш-индексам (`origin-id`, `stanza-id`, `id`), поэтому стоимость применения пачки
|
||||||
|
не растёт квадратично с размером архива.
|
||||||
|
|
||||||
Свежая установка начинает MAM с ограниченной последней страницы RSM, а не с
|
Свежая установка начинает MAM с ограниченной последней страницы RSM, а не с
|
||||||
самого старого сообщения. Инкрементальные проходы используют сохранённый
|
самого старого сообщения. Инкрементальные проходы используют сохранённый
|
||||||
@@ -48,14 +66,18 @@ peer, расшифровывает payload, использует `origin-id`/sta
|
|||||||
Каждая stanza проходит проверку `queryid` и источника личного архива. Martin
|
Каждая stanza проходит проверку `queryid` и источника личного архива. Martin
|
||||||
публикует MAM-результаты на parser queue: ограниченный inbox принимает там всю
|
публикует MAM-результаты на parser queue: ограниченный inbox принимает там всю
|
||||||
страницу и делает единственный hand-off на main actor после финального IQ.
|
страницу и делает единственный hand-off на main actor после финального IQ.
|
||||||
Дешифрование выполняется по одной stanza с уступкой event loop, а декодированные
|
Расшифровка OMEMO выполняется на отдельной серийной фоновой очереди, поэтому
|
||||||
изменения становятся видимыми только одной атомарной пачкой вместе с новой
|
Signal-криптография не блокирует главный поток; построение envelope и публикация
|
||||||
контрольной точкой. Один foreground-проход имеет конечный бюджет страниц и
|
остаются на main actor. Видимый индикатор «Синхронизация истории…» показывается
|
||||||
времени; ошибка страницы не перезапускает весь архив сама. При уходе приложения
|
только на первичной странице, а инкрементальная догрузка backlog продолжается в
|
||||||
с экрана и на время записи/отправки видеосообщения активный MAM-запрос
|
фоне молча, публикуя декодированные изменения атомарными пачками по мере
|
||||||
закрывается, чтобы дешифрование истории не конкурировало с камерой и upload.
|
готовности страниц. MUC-архив проходит тем же путём и флашит декодированные
|
||||||
Только после commit `AppModel` обновляет SwiftUI, локальный snapshot и Apple
|
групповые сообщения по завершении catch-up комнаты. Ошибка отдельной страницы
|
||||||
Watch; незавершённый проход при timeout/disconnect отбрасывается целиком.
|
автоматически повторяется ограниченное число раз. При уходе приложения с экрана
|
||||||
|
и на время записи/отправки видеосообщения активный MAM-запрос закрывается, чтобы
|
||||||
|
дешифрование истории не конкурировало с камерой и upload. Только после commit
|
||||||
|
`AppModel` обновляет SwiftUI, SwiftData-хранилище и Apple Watch; незавершённый
|
||||||
|
проход при timeout/disconnect отбрасывается целиком.
|
||||||
|
|
||||||
Редактирование реализовано стандартным XEP-0308: исправление получает новый id и
|
Редактирование реализовано стандартным XEP-0308: исправление получает новый id и
|
||||||
`<replace>` с id исходного сообщения. `AppModel` проверяет совпадение bare JID
|
`<replace>` с id исходного сообщения. `AppModel` проверяет совпадение bare JID
|
||||||
@@ -89,13 +111,19 @@ stanza отдельно сохраняет назначенный комнато
|
|||||||
декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются
|
декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются
|
||||||
межклиентно совместимыми.
|
межклиентно совместимыми.
|
||||||
|
|
||||||
|
Выход из комнаты оставляет чат в списке как «Не подключено». Удаление чата
|
||||||
|
убирает его и локальную историю с устройства (и предварительно выходит из
|
||||||
|
комнаты на сервере, если участник в ней); подавление на время сессии не даёт
|
||||||
|
событиям комнаты создать удалённый чат заново.
|
||||||
|
|
||||||
XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное
|
XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное
|
||||||
сообщение. Перед применением `AppModel` проверяет conversation и JID автора,
|
сообщение. Перед применением `AppModel` проверяет conversation и JID автора,
|
||||||
после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее
|
после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее
|
||||||
редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM,
|
редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM,
|
||||||
временно удерживается в ограниченной очереди. Локальное удаление физически
|
временно удерживается в ограниченной очереди. Локальное удаление физически
|
||||||
убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы
|
удаляет строку из SwiftData-хранилища (при открытии хранилище также вычищает
|
||||||
последующая MAM-синхронизация не добавила её обратно.
|
строки, помеченные удалёнными старыми сборками) и сохраняет отдельный набор
|
||||||
|
подавленных id, чтобы последующая MAM-синхронизация не добавила её обратно.
|
||||||
|
|
||||||
Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не
|
Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не
|
||||||
переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и
|
переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и
|
||||||
@@ -182,6 +210,33 @@ trickle ICE candidates накапливаются до успешного нач
|
|||||||
успешным и получает длительность. Удаление `activeCall` до закрытия Jingle-сессии
|
успешным и получает длительность. Удаление `activeCall` до закрытия Jingle-сессии
|
||||||
гарантирует, что ответный callback не создаст дубликат карточки.
|
гарантирует, что ответный 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 завершённого
|
Текущая реализация принимает вызов при живом XMPP-соединении. Wake-up завершённого
|
||||||
iOS-приложения намеренно не имитируется: production-путь требует VoIP push,
|
iOS-приложения намеренно не имитируется: production-путь требует VoIP push,
|
||||||
PushKit/CallKit и серверной APNs-инфраструктуры.
|
PushKit/CallKit и серверной APNs-инфраструктуры.
|
||||||
@@ -199,6 +254,7 @@ PushKit/CallKit и серверной APNs-инфраструктуры.
|
|||||||
- APNs provider + XEP-0357 registration;
|
- APNs provider + XEP-0357 registration;
|
||||||
- современный `urn:xmpp:omemo:2` adapter и миграция устройств;
|
- современный `urn:xmpp:omemo:2` adapter и миграция устройств;
|
||||||
- MIX и реакции;
|
- MIX и реакции;
|
||||||
- SQLite/SQLCipher вместо JSON snapshot;
|
- шифрование базы SQLCipher поверх SwiftData SQLite (история уже хранится
|
||||||
|
в SwiftData, а не в JSON snapshot);
|
||||||
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
|
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
|
||||||
- share extension и notification service extension.
|
- share extension и notification service extension.
|
||||||
+6
-6
@@ -105,18 +105,18 @@ participant читать member list. Для уже существующей к
|
|||||||
Для `upload.example.org` нужен корректный HTTPS URL. Если компонент не является
|
Для `upload.example.org` нужен корректный HTTPS URL. Если компонент не является
|
||||||
прямым поддоменом VirtualHost, добавьте его в `disco_items`. При reverse proxy
|
прямым поддоменом VirtualHost, добавьте его в `disco_items`. При reverse proxy
|
||||||
задайте `http_host`/`http_external_url` согласно вашей топологии. Например, для
|
задайте `http_host`/`http_external_url` согласно вашей топологии. Например, для
|
||||||
`wthome.ru` за HTTPS reverse proxy:
|
`example.com` за HTTPS reverse proxy:
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
Component "upload.wthome.ru" "http_file_share"
|
Component "upload.example.com" "http_file_share"
|
||||||
http_file_share_size_limit = 1024 * 1024 * 1024
|
http_file_share_size_limit = 1024 * 1024 * 1024
|
||||||
http_host = "upload.wthome.ru"
|
http_host = "upload.example.com"
|
||||||
http_external_url = "https://upload.wthome.ru/"
|
http_external_url = "https://upload.example.com/"
|
||||||
trusted_proxies = { "127.0.0.1", "::1" }
|
trusted_proxies = { "127.0.0.1", "::1" }
|
||||||
modules_disabled = { "s2s" }
|
modules_disabled = { "s2s" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Reverse proxy должен передавать `Host: upload.wthome.ru` и
|
Reverse proxy должен передавать `Host: upload.example.com` и
|
||||||
`X-Forwarded-Proto: https`. Иначе Prosody может выдать клиенту `http://` URL или
|
`X-Forwarded-Proto: https`. Иначе Prosody может выдать клиенту `http://` URL или
|
||||||
маршрут, возвращающий 404; Luma намеренно не понижает upload до HTTP.
|
маршрут, возвращающий 404; Luma намеренно не понижает upload до HTTP.
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ Reverse proxy должен передавать `Host: upload.wthome.ru` и
|
|||||||
prosodyctl check config
|
prosodyctl check config
|
||||||
prosodyctl check certs
|
prosodyctl check certs
|
||||||
prosodyctl shell module info http_file_share
|
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,
|
Начиная с 0.3.0 Luma различает ошибки discovery, получения upload slot,
|
||||||
|
|||||||
+50
-11
@@ -1,12 +1,39 @@
|
|||||||
# Безопасность и модель угроз MVP
|
# Безопасность и модель угроз MVP
|
||||||
|
|
||||||
- Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или
|
- Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или
|
||||||
snapshot истории.
|
локальное хранилище истории.
|
||||||
- Проверка сертификата не отключается. Ручной host меняет маршрут подключения,
|
- Проверка сертификата не отключается. Ручной host меняет маршрут подключения,
|
||||||
но XMPP domain/JID остаётся исходной идентичностью.
|
но XMPP domain/JID остаётся исходной идентичностью.
|
||||||
- TLS trust оценивается системным Apple Security framework как сертификат
|
- TLS trust оценивается системным Apple Security framework как сертификат
|
||||||
сервера (`SecPolicyCreateSSL(true, ...)`) для домена из JID; hostname и цепочка
|
сервера (`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 включено глобально или для конкретного чата, исходящие сообщения
|
- Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения
|
||||||
не откатываются на plaintext при ошибке: ошибка показывается пользователю.
|
не откатываются на plaintext при ошибке: ошибка показывается пользователю.
|
||||||
- Пользователь может осознанно отключить OMEMO глобально или для отдельного
|
- Пользователь может осознанно отключить OMEMO глобально или для отдельного
|
||||||
@@ -49,22 +76,34 @@
|
|||||||
STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже
|
STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже
|
||||||
зашифрованный медиапоток. Если Prosody не публикует собственный сервис через
|
зашифрованный медиапоток. Если Prosody не публикует собственный сервис через
|
||||||
XEP-0215, резервный публичный STUN получает запрос на определение адреса.
|
XEP-0215, резервный публичный STUN получает запрос на определение адреса.
|
||||||
- На iOS файлы OMEMO-состояния и локальной истории получают data-protection
|
- На iOS файл OMEMO-состояния и база локальной истории (SwiftData
|
||||||
`completeUntilFirstUserAuthentication`.
|
`.store`) получают data-protection `completeUntilFirstUserAuthentication`.
|
||||||
|
|
||||||
|
- Архивные эхо собственных служебных станз (receipts, chat markers,
|
||||||
|
повторно доставленные копии) и собственные сообщения, которые невозможно
|
||||||
|
расшифровать обратно (нет encrypt-to-self ключа или self-сессия устарела),
|
||||||
|
не превращаются в пузыри «Не удалось расшифровать сообщение»: они
|
||||||
|
отбрасываются, как это делают другие клиенты, чтобы не засорять историю
|
||||||
|
фантомами, которых нет в виде сообщений на сервере.
|
||||||
|
- Сохранение медиа выполняется только по явному действию пользователя:
|
||||||
|
фото/видео/видеосообщения — в медиатеку, файлы и голосовые — в «Файлы»
|
||||||
|
(Загрузки). Расшифрованное содержимое при этом покидает sandbox приложения.
|
||||||
|
|
||||||
## Известные ограничения
|
## Известные ограничения
|
||||||
|
|
||||||
- На macOS история и OMEMO state лежат в sandbox Application Support без
|
- На macOS история (SwiftData SQLite в Application Support) и OMEMO state
|
||||||
дополнительного SQLCipher-слоя; защиту обеспечивает учётная запись и FileVault
|
лежат в sandbox без дополнительного SQLCipher-слоя; защиту обеспечивает
|
||||||
пользователя. Для повышенной модели угроз нужна зашифрованная база.
|
учётная запись и FileVault пользователя. Для повышенной модели угроз нужна
|
||||||
|
зашифрованная база.
|
||||||
- Локальная история содержит уже расшифрованные тексты, как и у большинства
|
- Локальная история содержит уже расшифрованные тексты, как и у большинства
|
||||||
клиентов. Полное удаление требует выхода с опцией удаления истории.
|
клиентов. Полное удаление требует выхода с опцией удаления истории.
|
||||||
- «Удалить у меня» удаляет запись только из локального snapshot этого устройства
|
- «Удалить у меня» удаляет запись только из локального SwiftData-хранилища
|
||||||
и запоминает id против повторной MAM-загрузки; оно не удаляет серверный архив,
|
этого устройства и запоминает id против повторной MAM-загрузки; оно не
|
||||||
другие собственные устройства или копию собеседника.
|
удаляет серверный архив, другие собственные устройства или копию собеседника.
|
||||||
- Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого
|
- Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого
|
||||||
JID, а переопределение чата — в локальном snapshot. Эти значения не являются
|
JID, а переопределение чата — в локальном SwiftData-хранилище. Эти значения
|
||||||
секретами, но прямо влияют на конфиденциальность следующей отправки.
|
не являются секретами, но прямо влияют на конфиденциальность следующей
|
||||||
|
отправки.
|
||||||
- Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется
|
- Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется
|
||||||
настройками PEP и roster на сервере; не используйте аватар как секретное фото.
|
настройками PEP и roster на сервере; не используйте аватар как секретное фото.
|
||||||
- Используемая реализация MartinOMEMO поддерживает распространённый legacy
|
- Используемая реализация MartinOMEMO поддерживает распространённый legacy
|
||||||
|
|||||||
+485
-78
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "a9b7decfad90e115a57d2c941faf30bf1f041c5fcd0d87ea51c92b76dfac7ca8",
|
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "libsignal",
|
"identity" : "libsignal",
|
||||||
@@ -20,12 +19,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "martinomemo",
|
"identity" : "openssl",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/tigase/MartinOMEMO.git",
|
"location" : "https://github.com/krzyzanowskim/OpenSSL.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "3c162154d646aa258c9a86c0a07655a536e55a94",
|
"revision" : "4b1188ba947ddc23d03b13096b9e44b804e146c9",
|
||||||
"version" : "2.2.3"
|
"version" : "3.6.3000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -47,5 +46,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 3
|
"version" : 2
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2660"
|
LastUpgradeVersion = "1600"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@@ -62,6 +62,16 @@
|
|||||||
ReferencedContainer = "container:Luma.xcodeproj">
|
ReferencedContainer = "container:Luma.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A086B6D25E7969CB643F1C8A"
|
||||||
|
BuildableName = "LumaUITests.xctest"
|
||||||
|
BlueprintName = "LumaUITests"
|
||||||
|
ReferencedContainer = "container:Luma.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2660"
|
LastUpgradeVersion = "1600"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2660"
|
LastUpgradeVersion = "1600"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@@ -55,7 +55,6 @@
|
|||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BAB78463D7DE07A8F7B3D27D"
|
BlueprintIdentifier = "BAB78463D7DE07A8F7B3D27D"
|
||||||
BuildableName = "LumaWatch.app"
|
BuildableName = "LumaWatch.app"
|
||||||
BlueprintName = "LumaWatch"
|
|
||||||
ReferencedContainer = "container:Luma.xcodeproj">
|
ReferencedContainer = "container:Luma.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
@@ -72,7 +71,6 @@
|
|||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "BAB78463D7DE07A8F7B3D27D"
|
BlueprintIdentifier = "BAB78463D7DE07A8F7B3D27D"
|
||||||
BuildableName = "LumaWatch.app"
|
BuildableName = "LumaWatch.app"
|
||||||
BlueprintName = "LumaWatch"
|
|
||||||
ReferencedContainer = "container:Luma.xcodeproj">
|
ReferencedContainer = "container:Luma.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
.PHONY: project open verify clean
|
.PHONY: project open verify clean reset
|
||||||
|
|
||||||
project:
|
project:
|
||||||
@command -v xcodegen >/dev/null || (echo "Install XcodeGen first: brew install xcodegen" && exit 1)
|
@command -v xcodegen >/dev/null || (echo "Install XcodeGen first: brew install xcodegen" && exit 1)
|
||||||
xcodegen generate
|
xcodegen generate
|
||||||
|
@python3 Scripts/patch-xcodeproj.py
|
||||||
|
|
||||||
open: project
|
open: project
|
||||||
open Luma.xcodeproj
|
open Luma.xcodeproj
|
||||||
@@ -13,3 +14,12 @@ verify:
|
|||||||
clean:
|
clean:
|
||||||
rm -rf Luma.xcodeproj DerivedData .build
|
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 явно подключён ко всем
|
iPhone, iPad, Mac и Apple Watch; один и тот же catalog явно подключён ко всем
|
||||||
app-target’ам, а имя закреплено в build settings и Info.plist;
|
app-target’ам, а имя закреплено в build settings и Info.plist;
|
||||||
- адаптивный SwiftUI-интерфейс для iPhone, iPad и Mac;
|
- адаптивный SwiftUI-интерфейс для iPhone, iPad и Mac;
|
||||||
|
- поддержка портретной и альбомной ориентации на iPhone и всех четырёх ориентаций на iPad;
|
||||||
- companion-приложение watchOS: последние чаты, диктовка/ответы, а также запись
|
- companion-приложение watchOS: последние чаты, диктовка/ответы, а также запись
|
||||||
голосового до 60 секунд с отменой и предпрослушиванием; `.m4a` передаётся на
|
голосового до 60 секунд с отменой и предпрослушиванием; `.m4a` передаётся на
|
||||||
iPhone фоновой файловой очередью WatchConnectivity, после чего iPhone
|
iPhone фоновой файловой очередью WatchConnectivity, после чего iPhone
|
||||||
@@ -145,14 +146,18 @@ OMEMO, и для XEP-0084 аватаров. Для медиа и вложени
|
|||||||
XMPP-сокет. Для production нужны Apple Developer credentials, APNs provider,
|
XMPP-сокет. Для production нужны Apple Developer credentials, APNs provider,
|
||||||
XEP-0357 push-компонент и регистрация приложения в этом компоненте.
|
XEP-0357 push-компонент и регистрация приложения в этом компоненте.
|
||||||
- При первом запуске Luma запрашивает ограниченную последнюю страницу
|
- При первом запуске Luma запрашивает ограниченную последнюю страницу
|
||||||
серверного MAM-архива, чтобы огромная история не блокировала интерфейс.
|
серверного MAM-архива, чтобы огромная история не блокировала интерфейс, и
|
||||||
|
показывает индикатор «Синхронизация истории…» только на этом первичном этапе.
|
||||||
После успешного прохода сохраняются серверный UID последней записи и время;
|
После успешного прохода сохраняются серверный UID последней записи и время;
|
||||||
следующие подключения продолжают RSM строго после UID. Временное перекрытие
|
следующие подключения продолжают RSM строго после UID. Временное перекрытие
|
||||||
используется только для старых snapshots без UID или один раз, если сервер
|
используется только для старых snapshots без UID или один раз, если сервер
|
||||||
удалил сохранённый UID. Сырые stanza собираются вне main queue, расшифровываются
|
удалил сохранённый UID. Сырые stanza собираются вне main queue, а расшифровка
|
||||||
по одной с уступкой UI, а готовый проход публикуется одной пачкой. Один
|
OMEMO выполняется на отдельной фоновой очереди — главный поток освобождается на
|
||||||
foreground-проход также ограничен по страницам и времени; после ошибки он не
|
время Signal-криптографии, поэтому большая история не лагает интерфейс.
|
||||||
перезапускает сам себя бесконечно.
|
Инкрементальная догрузка backlog продолжается в фоне без вечного спиннера, а
|
||||||
|
сообщения применяются атомарными пачками с дедупликацией по `origin-id`/stanza
|
||||||
|
id через хеш-индексы. После сбоя отдельной страницы синхронизация автоматически
|
||||||
|
повторяется ограниченное число раз, не дожидаясь повторного открытия приложения.
|
||||||
- Клиент не вводит собственного ограничения размера вложения: фактический
|
- Клиент не вводит собственного ограничения размера вложения: фактический
|
||||||
предел сообщает XEP-0363 upload-компонент сервера. Видеосообщение ограничено
|
предел сообщает XEP-0363 upload-компонент сервера. Видеосообщение ограничено
|
||||||
60 секундами.
|
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" : [
|
"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-20@2x.png",
|
||||||
{ "filename" : "AppIcon-iPhone-29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },
|
"idiom" : "iphone",
|
||||||
{ "filename" : "AppIcon-iPhone-29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },
|
"scale" : "2x",
|
||||||
{ "filename" : "AppIcon-iPhone-40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },
|
"size" : "20x20"
|
||||||
{ "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-iPhone-20@3x.png",
|
||||||
{ "filename" : "AppIcon-iPad-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" },
|
"idiom" : "iphone",
|
||||||
{ "filename" : "AppIcon-iPad-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" },
|
"scale" : "3x",
|
||||||
{ "filename" : "AppIcon-iPad-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" },
|
"size" : "20x20"
|
||||||
{ "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-iPhone-29@2x.png",
|
||||||
{ "filename" : "AppIcon-iPad-76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },
|
"idiom" : "iphone",
|
||||||
{ "filename" : "AppIcon-iPad-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
|
"scale" : "2x",
|
||||||
{ "filename" : "AppIcon-iPad-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
|
"size" : "29x29"
|
||||||
{ "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-iPhone-29@3x.png",
|
||||||
{ "filename" : "AppIcon-Mac-32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
|
"idiom" : "iphone",
|
||||||
{ "filename" : "AppIcon-Mac-32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
|
"scale" : "3x",
|
||||||
{ "filename" : "AppIcon-Mac-128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
|
"size" : "29x29"
|
||||||
{ "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-iPhone-40@2x.png",
|
||||||
{ "filename" : "AppIcon-Mac-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
|
"idiom" : "iphone",
|
||||||
{ "filename" : "AppIcon-Mac-512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" },
|
"scale" : "2x",
|
||||||
{ "filename" : "AppIcon-Watch-Notification-38mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" },
|
"size" : "40x40"
|
||||||
{ "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-iPhone-40@3x.png",
|
||||||
{ "filename" : "AppIcon-Watch-Launcher-38mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" },
|
"idiom" : "iphone",
|
||||||
{ "filename" : "AppIcon-Watch-Launcher-40mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" },
|
"scale" : "3x",
|
||||||
{ "filename" : "AppIcon-Watch-Launcher-41mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" },
|
"size" : "40x40"
|
||||||
{ "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-iPhone-60@2x.png",
|
||||||
{ "filename" : "AppIcon-Watch-QuickLook-38mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" },
|
"idiom" : "iphone",
|
||||||
{ "filename" : "AppIcon-Watch-QuickLook-42mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" },
|
"scale" : "2x",
|
||||||
{ "filename" : "AppIcon-Watch-QuickLook-44mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" },
|
"size" : "60x60"
|
||||||
{ "filename" : "AppIcon-WatchStore-1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" }
|
},
|
||||||
|
{
|
||||||
|
"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" : {
|
"info" : {
|
||||||
"author" : "xcode",
|
"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
|
fi
|
||||||
|
|
||||||
xcodegen generate
|
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
|
open Luma.xcodeproj
|
||||||
+284
-12
@@ -18,12 +18,14 @@ required=(
|
|||||||
Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift
|
Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift
|
||||||
Sources/Shared/Models/ArchiveSyncWorkBudget.swift
|
Sources/Shared/Models/ArchiveSyncWorkBudget.swift
|
||||||
Sources/Shared/Models/ArchiveMessageBatchPolicy.swift
|
Sources/Shared/Models/ArchiveMessageBatchPolicy.swift
|
||||||
|
Sources/Shared/Models/OlderHistoryScrollPolicy.swift
|
||||||
Sources/Shared/Models/ChatScrollPositionPolicy.swift
|
Sources/Shared/Models/ChatScrollPositionPolicy.swift
|
||||||
Sources/Shared/Models/ChatTimelineEntry.swift
|
Sources/Shared/Models/ChatTimelineEntry.swift
|
||||||
Sources/Shared/Models/ChatTypingPolicy.swift
|
Sources/Shared/Models/ChatTypingPolicy.swift
|
||||||
Sources/Shared/Models/MessageReaction.swift
|
Sources/Shared/Models/MessageReaction.swift
|
||||||
Sources/Shared/Models/MediaPickerSelectionPolicy.swift
|
Sources/Shared/Models/MediaPickerSelectionPolicy.swift
|
||||||
Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift
|
Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift
|
||||||
|
Sources/Shared/Models/VideoNoteRotationPolicy.swift
|
||||||
Sources/Shared/Models/VideoNoteRecordingLifecycle.swift
|
Sources/Shared/Models/VideoNoteRecordingLifecycle.swift
|
||||||
Sources/Shared/Models/MessageReplySwipePolicy.swift
|
Sources/Shared/Models/MessageReplySwipePolicy.swift
|
||||||
Sources/Shared/Models/MessageReplyFallback.swift
|
Sources/Shared/Models/MessageReplyFallback.swift
|
||||||
@@ -66,6 +68,7 @@ required=(
|
|||||||
Tests/MediaViewerDismissGestureTests.swift
|
Tests/MediaViewerDismissGestureTests.swift
|
||||||
Tests/ArchiveSyncCheckpointTests.swift
|
Tests/ArchiveSyncCheckpointTests.swift
|
||||||
Tests/ArchiveSyncPaginationTests.swift
|
Tests/ArchiveSyncPaginationTests.swift
|
||||||
|
Tests/OlderHistoryScrollPolicyTests.swift
|
||||||
Tests/ArchiveSyncRecoveryPolicyTests.swift
|
Tests/ArchiveSyncRecoveryPolicyTests.swift
|
||||||
Tests/ArchiveSyncWorkBudgetTests.swift
|
Tests/ArchiveSyncWorkBudgetTests.swift
|
||||||
Tests/ArchiveMessageBatchPolicyTests.swift
|
Tests/ArchiveMessageBatchPolicyTests.swift
|
||||||
@@ -77,9 +80,34 @@ required=(
|
|||||||
Tests/MediaPickerSelectionPolicyTests.swift
|
Tests/MediaPickerSelectionPolicyTests.swift
|
||||||
Tests/VideoNoteRecordingCompletionPolicyTests.swift
|
Tests/VideoNoteRecordingCompletionPolicyTests.swift
|
||||||
Tests/VideoNoteRecordingLifecycleTests.swift
|
Tests/VideoNoteRecordingLifecycleTests.swift
|
||||||
|
Tests/VideoNoteRotationPolicyTests.swift
|
||||||
Tests/VideoNoteStopPolicyTests.swift
|
Tests/VideoNoteStopPolicyTests.swift
|
||||||
Tests/MessageReplySwipeTests.swift
|
Tests/MessageReplySwipeTests.swift
|
||||||
Tests/WatchVoiceMessageTests.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
|
Brand/LumaIcon-1024.png
|
||||||
Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
|
Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
|
||||||
Regenerate-Luma-Project.command
|
Regenerate-Luma-Project.command
|
||||||
@@ -141,7 +169,7 @@ test "$(grep -c -- '- package: WebRTC' project.yml)" -eq 2 || {
|
|||||||
exit 1
|
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"
|
echo "Legacy bubble tails or the old client attachment limit are still present"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -225,6 +253,10 @@ grep -q 'archiveWorkBudget.recordCompletedPage' Sources/Shared/XMPP/XMPPService.
|
|||||||
echo "Incremental MAM passes must have a finite foreground work budget"
|
echo "Incremental MAM passes must have a finite foreground work budget"
|
||||||
exit 1
|
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
|
if grep -q 'scheduleArchiveRestart\|restartDelayNanoseconds' Sources/Shared/XMPP/XMPPService.swift; then
|
||||||
echo "Failed MAM queries must not restart themselves indefinitely"
|
echo "Failed MAM queries must not restart themselves indefinitely"
|
||||||
exit 1
|
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"
|
echo "MAM mutations must be applied atomically before publishing SwiftUI state"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
grep -q 'decodeSliceSize = 1' Sources/Shared/Models/ArchiveMessageBatchPolicy.swift || {
|
grep -q 'decodeSliceSize = 8' Sources/Shared/Models/ArchiveMessageBatchPolicy.swift || {
|
||||||
echo "MAM decoding must remain limited to one stanza per main-actor slice"
|
echo "MAM decoding must stay batched in small per-slice groups"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ChatArchive.swift || {
|
grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ArchiveStore.swift || {
|
||||||
echo "The durable archive snapshot must persist the XEP-0313 UID cursor"
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
|
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"
|
echo "Ambiguous photo/video providers must retain a fallback representation"
|
||||||
exit 1
|
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"
|
echo "PhotosPicker images need a data fallback when no file representation is available"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -358,7 +622,7 @@ grep -q 'scrollDismissesKeyboard(.interactively)' Sources/Shared/UI/ChatView.swi
|
|||||||
echo "Interactive keyboard dismissal is missing"
|
echo "Interactive keyboard dismissal is missing"
|
||||||
exit 1
|
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"
|
echo "Chat rows and day boundaries must use the cached stable timeline"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -429,6 +693,10 @@ if grep -q 'Режим видеосообщения\|composerCaptureMode.*video'
|
|||||||
echo "Video-note recording must not reuse the inline voice-message gesture"
|
echo "Video-note recording must not reuse the inline voice-message gesture"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 || {
|
grep -q 'resetCaptureGraph()' Sources/Shared/Services/VideoNoteRecorder.swift || {
|
||||||
echo "Reusable video-note capture cleanup is missing"
|
echo "Reusable video-note capture cleanup is missing"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -537,8 +805,12 @@ grep -q 'urn:xmpp:reactions:0' Sources/Shared/XMPP/XMPPService.swift || {
|
|||||||
echo "XEP-0444 capability advertisement is missing"
|
echo "XEP-0444 capability advertisement is missing"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
grep -q 'MessageReactionPolicy.quickChoices' Sources/Shared/UI/Components/MessageBubble.swift || {
|
grep -q 'onReactPicker' Sources/Shared/UI/Components/MessageBubble.swift || {
|
||||||
echo "Message reaction UI is missing"
|
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
|
exit 1
|
||||||
}
|
}
|
||||||
grep -q 'ChatStateNotificationsModule' Sources/Shared/XMPP/XMPPService.swift || {
|
grep -q 'ChatStateNotificationsModule' Sources/Shared/XMPP/XMPPService.swift || {
|
||||||
@@ -554,8 +826,8 @@ grep -q 'snapshotQueue.async' Sources/Shared/Services/PhoneWatchBridge.swift ||
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
awk '
|
awk '
|
||||||
/^#if os\(iOS\)$/ { ios_only = 1; next }
|
/^[[:space:]]*#if os\(iOS\)$/ { ios_only = 1; next }
|
||||||
/^#endif$/ && ios_only { ios_only = 0; next }
|
/^[[:space:]]*#endif$/ && ios_only { ios_only = 0; next }
|
||||||
/availableVideoCodecTypes/ {
|
/availableVideoCodecTypes/ {
|
||||||
found = 1
|
found = 1
|
||||||
if (!ios_only) escaped = 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"
|
echo "Failed media drafts must remain available for retry"
|
||||||
exit 1
|
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"
|
echo "Prosody roster contacts screen is missing"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,20 @@ struct LumaApp: App {
|
|||||||
RootView(model: model)
|
RootView(model: model)
|
||||||
.tint(Color(red: 0.14, green: 0.56, blue: 0.96))
|
.tint(Color(red: 0.14, green: 0.56, blue: 0.96))
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.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)
|
#if os(macOS)
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendabl
|
|||||||
|
|
||||||
var effectiveResource: String {
|
var effectiveResource: String {
|
||||||
let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return trimmed.isEmpty ? "Luma" : trimmed
|
if trimmed.isEmpty || trimmed == DeviceResource.legacyDefault {
|
||||||
|
return DeviceResource.default
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
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
|
/// still published atomically; these values only bound network and decryption
|
||||||
/// work performed before that publication.
|
/// work performed before that publication.
|
||||||
enum ArchiveMessageBatchPolicy {
|
enum ArchiveMessageBatchPolicy {
|
||||||
static let pageSize = 24
|
static let pageSize = 40
|
||||||
static let bootstrapMessageLimit = 40
|
static let bootstrapMessageLimit = 40
|
||||||
static let decodeSliceSize = 1
|
/// Decrypt this many archived stanzas per main-actor slice before yielding.
|
||||||
static let maximumBufferedStanzas = 48
|
/// One-by-one decryption turns a large MAM catch-up into hours of serialized
|
||||||
/// Leave roughly half of a 60 Hz frame between archived decryptions. This
|
/// work; a small batch keeps frames mostly intact while cutting the number
|
||||||
/// keeps UIScrollView touch handling responsive even when OMEMO work is
|
/// of run-loop hand-offs by the same factor.
|
||||||
/// unusually expensive on older devices.
|
static let decodeSliceSize = 8
|
||||||
static let interSliceDelayNanoseconds: UInt64 = 8_000_000
|
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
|
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
|
/// Durable MAM position. The archive UID is the primary synchronization
|
||||||
/// anchor; the timestamp is retained as a compatibility fallback for legacy
|
/// anchor; the timestamp is retained as a compatibility fallback for legacy
|
||||||
/// snapshots and servers that have expired an old UID.
|
/// snapshots and servers that have expired an old UID.
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import Foundation
|
|||||||
enum ArchiveSyncRecoveryPolicy {
|
enum ArchiveSyncRecoveryPolicy {
|
||||||
static let incrementalOverlap: TimeInterval = 60
|
static let incrementalOverlap: TimeInterval = 60
|
||||||
static let interPageDelayNanoseconds: UInt64 = 100_000_000
|
static let interPageDelayNanoseconds: UInt64 = 100_000_000
|
||||||
static let queryTimeoutNanoseconds: UInt64 = 12_000_000_000
|
static let queryTimeoutNanoseconds: UInt64 = 20_000_000_000
|
||||||
static let pageApplyTimeoutNanoseconds: UInt64 = 8_000_000_000
|
static let pageApplyTimeoutNanoseconds: UInt64 = 20_000_000_000
|
||||||
static let pageRetryLimit = 1
|
static let pageRetryLimit = 1
|
||||||
static let resumeAfterCaptureDelayNanoseconds: UInt64 = 3_000_000_000
|
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 Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
@Model
|
||||||
enum Direction: String, Codable, Sendable {
|
final class ChatMessage {
|
||||||
|
enum Direction: String, Codable {
|
||||||
case incoming
|
case incoming
|
||||||
case outgoing
|
case outgoing
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Delivery: String, Codable, Sendable {
|
enum Delivery: String, Codable {
|
||||||
case sending
|
case sending
|
||||||
case sent
|
case sent
|
||||||
case delivered
|
case delivered
|
||||||
case failed
|
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 omemo
|
||||||
case plaintext
|
case plaintext
|
||||||
case decryptionFailed
|
case decryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Kind: String, Codable, Sendable {
|
enum Kind: String, Codable {
|
||||||
case text
|
case text
|
||||||
case attachment
|
case attachment
|
||||||
case photo
|
case photo
|
||||||
@@ -40,7 +64,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let id: String
|
@Attribute(.unique) var clientID: String
|
||||||
var conversationID: String
|
var conversationID: String
|
||||||
var senderJID: String
|
var senderJID: String
|
||||||
var body: String
|
var body: String
|
||||||
@@ -61,12 +85,15 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
|||||||
var replyPreview: String?
|
var replyPreview: String?
|
||||||
var forwardedFrom: String?
|
var forwardedFrom: String?
|
||||||
var retractedAt: Date?
|
var retractedAt: Date?
|
||||||
|
var originID: String?
|
||||||
var stanzaID: String?
|
var stanzaID: String?
|
||||||
var senderDisplayName: String?
|
var senderDisplayName: String?
|
||||||
var isGroupMessage: Bool
|
var isGroupMessage: Bool
|
||||||
var callHistory: CallHistoryMetadata?
|
var callHistory: CallHistoryMetadata?
|
||||||
var reactions: [MessageReaction]
|
var reactions: [MessageReaction]
|
||||||
|
|
||||||
|
var conversation: Conversation?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
conversationID: String,
|
conversationID: String,
|
||||||
@@ -89,13 +116,14 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
|||||||
replyPreview: String? = nil,
|
replyPreview: String? = nil,
|
||||||
forwardedFrom: String? = nil,
|
forwardedFrom: String? = nil,
|
||||||
retractedAt: Date? = nil,
|
retractedAt: Date? = nil,
|
||||||
|
originID: String? = nil,
|
||||||
stanzaID: String? = nil,
|
stanzaID: String? = nil,
|
||||||
senderDisplayName: String? = nil,
|
senderDisplayName: String? = nil,
|
||||||
isGroupMessage: Bool = false,
|
isGroupMessage: Bool = false,
|
||||||
callHistory: CallHistoryMetadata? = nil,
|
callHistory: CallHistoryMetadata? = nil,
|
||||||
reactions: [MessageReaction] = []
|
reactions: [MessageReaction] = []
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.clientID = id
|
||||||
self.conversationID = conversationID.lowercased()
|
self.conversationID = conversationID.lowercased()
|
||||||
self.senderJID = Self.normalizedSenderJID(senderJID)
|
self.senderJID = Self.normalizedSenderJID(senderJID)
|
||||||
self.body = body
|
self.body = body
|
||||||
@@ -116,6 +144,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.replyPreview = replyPreview
|
self.replyPreview = replyPreview
|
||||||
self.forwardedFrom = forwardedFrom
|
self.forwardedFrom = forwardedFrom
|
||||||
self.retractedAt = retractedAt
|
self.retractedAt = retractedAt
|
||||||
|
self.originID = originID ?? (direction == .outgoing ? id : nil)
|
||||||
self.stanzaID = stanzaID
|
self.stanzaID = stanzaID
|
||||||
self.senderDisplayName = senderDisplayName
|
self.senderDisplayName = senderDisplayName
|
||||||
self.isGroupMessage = isGroupMessage
|
self.isGroupMessage = isGroupMessage
|
||||||
@@ -123,71 +152,12 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.reactions = reactions
|
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 {
|
var canBeEdited: Bool {
|
||||||
!isRetracted
|
!isRetracted
|
||||||
&& !isGroupMessage
|
&& !isGroupMessage
|
||||||
&& direction == .outgoing
|
&& direction == .outgoing
|
||||||
&& kind == .text
|
&& kind == .text
|
||||||
&& (delivery == .sent || delivery == .delivered)
|
&& (delivery == .sent || delivery == .delivered || delivery == .read)
|
||||||
}
|
}
|
||||||
|
|
||||||
var isRetracted: Bool {
|
var isRetracted: Bool {
|
||||||
@@ -209,17 +179,18 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
|||||||
&& !isGroupMessage
|
&& !isGroupMessage
|
||||||
&& !isRetracted
|
&& !isRetracted
|
||||||
&& kind != .system
|
&& kind != .system
|
||||||
&& (delivery == .sent || delivery == .delivered)
|
&& (delivery == .sent || delivery == .delivered || delivery == .read)
|
||||||
}
|
}
|
||||||
|
|
||||||
var replyIdentifier: String? {
|
var replyIdentifier: String? {
|
||||||
isGroupMessage ? stanzaID : id
|
isGroupMessage ? stanzaID : clientID
|
||||||
}
|
}
|
||||||
|
|
||||||
var quotePreview: String {
|
var quotePreview: String {
|
||||||
if isRetracted { return "Сообщение удалено" }
|
if isRetracted { return "Сообщение удалено" }
|
||||||
let value = kind == .text ? body : previewText
|
let value = kind == .text ? body : previewText
|
||||||
let normalized = value
|
let normalized =
|
||||||
|
value
|
||||||
.replacingOccurrences(of: "\n", with: " ")
|
.replacingOccurrences(of: "\n", with: " ")
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard normalized.count > 140 else { return normalized }
|
guard normalized.count > 140 else { return normalized }
|
||||||
@@ -316,4 +287,15 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
|
|||||||
let bare = value[..<slash].lowercased()
|
let bare = value[..<slash].lowercased()
|
||||||
return bare + String(value[slash...])
|
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
|
/// A cached presentation row for the selected chat. Building day boundaries
|
||||||
/// once per message mutation avoids walking the full history during every
|
/// once per message mutation avoids walking the full history during every
|
||||||
/// scroll-position or composer-state update.
|
/// scroll-position or composer-state update.
|
||||||
struct ChatTimelineEntry: Identifiable, Hashable, Sendable {
|
struct ChatTimelineEntry: Identifiable, Hashable {
|
||||||
let message: ChatMessage
|
let message: ChatMessage
|
||||||
let startsNewDay: Bool
|
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(
|
static func make(
|
||||||
from messages: [ChatMessage],
|
from messages: [ChatMessage],
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
@Model
|
||||||
enum Kind: String, Codable, Sendable {
|
final class Conversation {
|
||||||
|
enum Kind: String, Codable {
|
||||||
case direct
|
case direct
|
||||||
case group
|
case group
|
||||||
}
|
}
|
||||||
|
|
||||||
let id: String
|
@Attribute(.unique) var jid: String
|
||||||
var jid: String
|
|
||||||
var displayName: String
|
var displayName: String
|
||||||
var lastMessage: String
|
var lastMessage: String
|
||||||
var lastActivity: Date
|
var lastActivity: Date
|
||||||
@@ -22,6 +23,9 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
|||||||
var occupantCount: Int
|
var occupantCount: Int
|
||||||
var invitedBy: String?
|
var invitedBy: String?
|
||||||
|
|
||||||
|
@Relationship(deleteRule: .cascade, inverse: \ChatMessage.conversation)
|
||||||
|
var messages: [ChatMessage] = []
|
||||||
|
|
||||||
init(
|
init(
|
||||||
jid: String,
|
jid: String,
|
||||||
displayName: String? = nil,
|
displayName: String? = nil,
|
||||||
@@ -39,7 +43,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
|||||||
invitedBy: String? = nil
|
invitedBy: String? = nil
|
||||||
) {
|
) {
|
||||||
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
self.id = normalized
|
|
||||||
self.jid = normalized
|
self.jid = normalized
|
||||||
self.displayName = displayName?.nilIfBlank ?? normalized
|
self.displayName = displayName?.nilIfBlank ?? normalized
|
||||||
self.lastMessage = lastMessage
|
self.lastMessage = lastMessage
|
||||||
@@ -56,46 +59,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.invitedBy = invitedBy?.nilIfBlank
|
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 isGroup: Bool { kind == .group }
|
||||||
|
|
||||||
var initials: String {
|
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 {
|
private extension String {
|
||||||
var nilIfBlank: String? {
|
var nilIfBlank: String? {
|
||||||
trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
|
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 {
|
extension ChatMessage {
|
||||||
var reactionIdentifier: String? {
|
var reactionIdentifier: String? {
|
||||||
isGroupMessage ? stanzaID : id
|
isGroupMessage ? stanzaID : clientID
|
||||||
}
|
}
|
||||||
|
|
||||||
var canBeReactedTo: Bool {
|
var canBeReactedTo: Bool {
|
||||||
|
|||||||
@@ -2,30 +2,62 @@ import CoreGraphics
|
|||||||
|
|
||||||
struct MessageReplySwipePolicy {
|
struct MessageReplySwipePolicy {
|
||||||
static let triggerDistance: CGFloat = 64
|
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 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 {
|
/// Whether this translation can lock the gesture as a horizontal swipe.
|
||||||
guard isHorizontal(translation) else { return 0 }
|
/// Both directions are accepted, and the lock is only available early:
|
||||||
let distance = min(abs(translation.width) * 0.72, maximumOffset)
|
/// 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
|
return translation.width < 0 ? -distance : distance
|
||||||
}
|
}
|
||||||
|
|
||||||
static func shouldReply(
|
static func shouldReply(
|
||||||
|
locked: Bool,
|
||||||
translation: CGSize,
|
translation: CGSize,
|
||||||
predictedEndTranslation: CGSize
|
predictedEndTranslation: CGSize
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
|
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
|
||||||
? predictedEndTranslation
|
? predictedEndTranslation
|
||||||
: translation
|
: 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 {
|
static func progress(for offset: CGFloat) -> CGFloat {
|
||||||
min(1, abs(offset) / triggerDistance)
|
min(1, abs(offset) / triggerDistance)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isHorizontal(_ translation: CGSize) -> Bool {
|
private static func isHorizontal(_ translation: CGSize, dominance: CGFloat) -> Bool {
|
||||||
abs(translation.width) > abs(translation.height) * horizontalDominance
|
abs(translation.width) > abs(translation.height) * dominance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,8 +14,14 @@ struct OMEMODevice: Identifiable, Hashable, Sendable {
|
|||||||
let trust: Trust
|
let trust: Trust
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let isOwn: 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 {
|
var formattedFingerprint: String {
|
||||||
fingerprint.chunked(every: 8).joined(separator: " ")
|
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 {
|
final class AccountPreferences {
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let accountKey = "luma.active-account.v1"
|
private let accountKey = "luma.active-account.v1"
|
||||||
|
private let accountsKey = "luma.accounts.v1"
|
||||||
private let encryptionSettingsKey = "luma.encryption-settings.v1"
|
private let encryptionSettingsKey = "luma.encryption-settings.v1"
|
||||||
private let chatStateSettingsKey = "luma.chat-state-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)
|
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 {
|
func save(_ account: AccountConfiguration) throws {
|
||||||
let data = try JSONEncoder().encode(account)
|
let data = try JSONEncoder().encode(account)
|
||||||
defaults.set(data, forKey: accountKey)
|
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,
|
for message: ChatMessage,
|
||||||
data: Data
|
data: Data
|
||||||
) -> ChatMediaPlatformImage? {
|
) -> 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) {
|
if let cached = cache.object(forKey: cacheKey) {
|
||||||
return cached
|
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 AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
import os
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
actor MediaPreviewProcessor {
|
actor MediaPreviewProcessor {
|
||||||
@@ -44,6 +45,12 @@ actor MediaPreviewProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
|
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(
|
return Analysis(
|
||||||
duration: duration,
|
duration: duration,
|
||||||
thumbnailData: thumbnailData,
|
thumbnailData: thumbnailData,
|
||||||
@@ -94,6 +101,8 @@ actor MediaPreviewProcessor {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
Logger(subsystem: "Luma", category: "video-preview")
|
||||||
|
.error("image generator failed at \(second)s: \(error.localizedDescription)")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ struct PhoneWatchSnapshot: Codable {
|
|||||||
let chats: [Chat]
|
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 {
|
struct WatchVoiceMessage: Sendable {
|
||||||
let transferID: String
|
let transferID: String
|
||||||
let jid: String
|
let jid: String
|
||||||
@@ -32,9 +50,9 @@ struct WatchVoiceMessage: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) && canImport(WatchConnectivity)
|
#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 onReply: ((String, String) -> Void)?
|
||||||
var onVoiceMessage: ((WatchVoiceMessage) -> Void)? {
|
var onVoiceMessage: ((WatchVoiceMessage) -> Void)? {
|
||||||
didSet { deliverPendingVoiceMessages() }
|
didSet { deliverPendingVoiceMessages() }
|
||||||
@@ -49,25 +67,43 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
|||||||
private var deliveredVoiceTransferIDs: Set<String> = []
|
private var deliveredVoiceTransferIDs: Set<String> = []
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
session = WCSession.isSupported() ? .default : nil
|
session =
|
||||||
|
!RuntimeEnvironment.isRunningTests && WCSession.isSupported()
|
||||||
|
? .default
|
||||||
|
: nil
|
||||||
super.init()
|
super.init()
|
||||||
pendingVoiceMessages = Self.loadPersistedVoiceMessages()
|
pendingVoiceMessages = Self.loadPersistedVoiceMessages()
|
||||||
session?.delegate = self
|
session?.delegate = self
|
||||||
session?.activate()
|
session?.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
func update(conversations: [Conversation], messages: [ChatMessage]) {
|
func update(conversations: [Conversation], messages: [ChatMessage]) {
|
||||||
guard let session else { return }
|
guard let session else { return }
|
||||||
// Copy-on-write snapshots are cheap here; the filtering, sorting and
|
// SwiftData models must never be read off the main actor: snapshot
|
||||||
// JSON encoding are not. Keep that work away from SwiftUI's main run
|
// the fields the watch needs here, then hand plain values to the
|
||||||
// loop, especially immediately after a MAM page is committed.
|
// 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
|
snapshotQueue.async { [weak self, weak session] in
|
||||||
guard let self,
|
guard let self,
|
||||||
let session,
|
let session,
|
||||||
let data = Self.makeSnapshotData(
|
let data = Self.makeSnapshotData(chats: chats, entries: entries),
|
||||||
conversations: conversations,
|
|
||||||
messages: messages
|
|
||||||
),
|
|
||||||
data != self.latestSnapshot else { return }
|
data != self.latestSnapshot else { return }
|
||||||
self.latestSnapshot = data
|
self.latestSnapshot = data
|
||||||
self.publishLatestSnapshot(using: session)
|
self.publishLatestSnapshot(using: session)
|
||||||
@@ -75,57 +111,56 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func makeSnapshotData(
|
private static func makeSnapshotData(
|
||||||
conversations: [Conversation],
|
chats: [PhoneWatchChatData],
|
||||||
messages: [ChatMessage]
|
entries: [PhoneWatchMessageData]
|
||||||
) -> Data? {
|
) -> Data? {
|
||||||
let visibleConversations = Array(conversations.prefix(20))
|
let visibleChats = Array(chats.prefix(20))
|
||||||
let visibleConversationIDs = Set(visibleConversations.map(\.id))
|
let visibleChatIDs = Set(visibleChats.map(\.jid))
|
||||||
var recentMessagesByConversation: [String: [ChatMessage]] = [:]
|
var recentMessagesByChat: [String: [PhoneWatchMessageData]] = [:]
|
||||||
|
|
||||||
// Keep a bounded, chronologically sorted window. MAM can append an old
|
// Keep a bounded, chronologically sorted window. MAM can append an old
|
||||||
// overlapping stanza after a newer live message, so array order alone
|
// overlapping stanza after a newer live message, so array order alone
|
||||||
// cannot identify the latest messages reliably.
|
// cannot identify the latest messages reliably.
|
||||||
for message in messages where visibleConversationIDs.contains(message.conversationID) {
|
for entry in entries where visibleChatIDs.contains(entry.conversationID) {
|
||||||
var recent = recentMessagesByConversation[message.conversationID] ?? []
|
var recent = recentMessagesByChat[entry.conversationID] ?? []
|
||||||
let insertionIndex = Self.insertionIndex(for: message, in: recent)
|
let insertionIndex = Self.insertionIndex(for: entry, in: recent)
|
||||||
recent.insert(message, at: insertionIndex)
|
recent.insert(entry, at: insertionIndex)
|
||||||
if recent.count > 20 {
|
if recent.count > 20 {
|
||||||
recent.removeFirst(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(
|
PhoneWatchSnapshot.Chat(
|
||||||
jid: conversation.jid,
|
jid: chat.jid,
|
||||||
name: conversation.displayName,
|
name: chat.name,
|
||||||
unread: conversation.unreadCount,
|
unread: chat.unread,
|
||||||
messages: (recentMessagesByConversation[conversation.id] ?? [])
|
messages: (recentMessagesByChat[chat.jid] ?? []).map { entry in
|
||||||
.map {
|
PhoneWatchSnapshot.Message(
|
||||||
PhoneWatchSnapshot.Message(
|
id: entry.id,
|
||||||
id: $0.id,
|
body: entry.body,
|
||||||
body: $0.previewText,
|
timestamp: entry.timestamp,
|
||||||
timestamp: $0.timestamp,
|
outgoing: entry.outgoing,
|
||||||
outgoing: $0.direction == .outgoing,
|
encrypted: entry.encrypted
|
||||||
encrypted: $0.security == .omemo
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats))
|
return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: snapshotChats))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func insertionIndex(
|
private static func insertionIndex(
|
||||||
for message: ChatMessage,
|
for entry: PhoneWatchMessageData,
|
||||||
in sortedMessages: [ChatMessage]
|
in sorted: [PhoneWatchMessageData]
|
||||||
) -> Int {
|
) -> Int {
|
||||||
var lowerBound = 0
|
var lowerBound = 0
|
||||||
var upperBound = sortedMessages.count
|
var upperBound = sorted.count
|
||||||
while lowerBound < upperBound {
|
while lowerBound < upperBound {
|
||||||
let middle = lowerBound + (upperBound - lowerBound) / 2
|
let middle = lowerBound + (upperBound - lowerBound) / 2
|
||||||
let existing = sortedMessages[middle]
|
let existing = sorted[middle]
|
||||||
let existingComesFirst = existing.timestamp < message.timestamp
|
let existingComesFirst = existing.timestamp < entry.timestamp
|
||||||
|| (existing.timestamp == message.timestamp && existing.id < message.id)
|
|| (existing.timestamp == entry.timestamp && existing.id < entry.id)
|
||||||
if existingComesFirst {
|
if existingComesFirst {
|
||||||
lowerBound = middle + 1
|
lowerBound = middle + 1
|
||||||
} else {
|
} else {
|
||||||
@@ -150,6 +185,7 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
|||||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||||
|
|
||||||
func sessionDidDeactivate(_ session: WCSession) {
|
func sessionDidDeactivate(_ session: WCSession) {
|
||||||
|
guard !RuntimeEnvironment.isRunningTests else { return }
|
||||||
session.activate()
|
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 Foundation
|
||||||
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -239,7 +240,13 @@ struct AttachmentPreviewView: View {
|
|||||||
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
|
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
|
||||||
for draft in drafts where thumbnailImages[draft.id] == nil {
|
for draft in drafts where thumbnailImages[draft.id] == nil {
|
||||||
guard let data = draft.thumbnailData,
|
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
|
thumbnailImages[draft.id] = image
|
||||||
await Task.yield()
|
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
|
let call: CallSnapshot
|
||||||
|
|
||||||
private var conversation: Conversation {
|
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))
|
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,3 +241,18 @@ private struct CallControlButton: View {
|
|||||||
.accessibilityLabel(title)
|
.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())
|
.font(.caption2.monospacedDigit())
|
||||||
.foregroundStyle(secondaryText)
|
.foregroundStyle(secondaryText)
|
||||||
Spacer()
|
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) {
|
Button(action: playback.cycleVoiceRate) {
|
||||||
Text(rateText)
|
Text(rateText)
|
||||||
.font(.caption2.monospacedDigit().weight(.bold))
|
.font(.caption2.monospacedDigit().weight(.bold))
|
||||||
@@ -95,6 +105,13 @@ struct AudioMessagePlayer: View {
|
|||||||
countStyle: .file
|
countStyle: .file
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(action: saveToFiles) {
|
||||||
|
Image(systemName: "square.and.arrow.down")
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Сохранить в Файлы")
|
||||||
}
|
}
|
||||||
.font(.caption2.monospacedDigit())
|
.font(.caption2.monospacedDigit())
|
||||||
.foregroundStyle(secondaryText)
|
.foregroundStyle(secondaryText)
|
||||||
@@ -129,7 +146,7 @@ struct AudioMessagePlayer: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var isCurrent: Bool {
|
private var isCurrent: Bool {
|
||||||
playback.currentMessageID == message.id
|
playback.currentMessageID == message.clientID
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPlaying: Bool {
|
private var isPlaying: Bool {
|
||||||
@@ -185,6 +202,10 @@ struct AudioMessagePlayer: View {
|
|||||||
message.direction == .outgoing ? .white.opacity(0.78) : .secondary
|
message.direction == .outgoing ? .white.opacity(0.78) : .secondary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func saveToFiles() {
|
||||||
|
Task { await MediaDownloadService.save(message, model: model) }
|
||||||
|
}
|
||||||
|
|
||||||
private func togglePlayback() {
|
private func togglePlayback() {
|
||||||
guard let localURL else {
|
guard let localURL else {
|
||||||
Task {
|
Task {
|
||||||
@@ -194,7 +215,7 @@ struct AudioMessagePlayer: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
playback.toggle(
|
playback.toggle(
|
||||||
messageID: message.id,
|
messageID: message.clientID,
|
||||||
url: downloadedURL,
|
url: downloadedURL,
|
||||||
durationHint: message.duration,
|
durationHint: message.duration,
|
||||||
isVoice: message.kind == .voice
|
isVoice: message.kind == .voice
|
||||||
@@ -203,7 +224,7 @@ struct AudioMessagePlayer: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
playback.toggle(
|
playback.toggle(
|
||||||
messageID: message.id,
|
messageID: message.clientID,
|
||||||
url: localURL,
|
url: localURL,
|
||||||
durationHint: message.duration,
|
durationHint: message.duration,
|
||||||
isVoice: message.kind == .voice
|
isVoice: message.kind == .voice
|
||||||
@@ -213,7 +234,7 @@ struct AudioMessagePlayer: View {
|
|||||||
private func seek(_ fraction: Double) {
|
private func seek(_ fraction: Double) {
|
||||||
guard let localURL else { return }
|
guard let localURL else { return }
|
||||||
playback.seek(
|
playback.seek(
|
||||||
messageID: message.id,
|
messageID: message.clientID,
|
||||||
url: localURL,
|
url: localURL,
|
||||||
durationHint: message.duration,
|
durationHint: message.duration,
|
||||||
isVoice: message.kind == .voice,
|
isVoice: message.kind == .voice,
|
||||||
@@ -305,3 +326,28 @@ enum MediaTimeFormatter {
|
|||||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
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
|
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 {
|
struct ConnectionBanner: View {
|
||||||
@ObservedObject var model: AppModel
|
@ObservedObject var model: AppModel
|
||||||
|
var text: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch model.connectionStatus {
|
switch model.connectionStatus {
|
||||||
case .connected:
|
case .connected:
|
||||||
if model.isArchiveSyncing || !model.isOMEMOReady {
|
if model.isArchiveSyncing || !model.isOMEMOReady {
|
||||||
banner(
|
banner(
|
||||||
icon: model.isArchiveSyncing ? "arrow.triangle.2.circlepath" : "lock.rotation",
|
text: "Обновление",
|
||||||
text: model.isArchiveSyncing ? "Синхронизация истории…" : "Публикация ключей OMEMO…",
|
color: .clear,
|
||||||
color: .blue,
|
|
||||||
progress: true
|
progress: true
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Text(text)
|
||||||
|
.font(.headline)
|
||||||
}
|
}
|
||||||
case .connecting:
|
case .connecting:
|
||||||
banner(icon: "antenna.radiowaves.left.and.right", text: "Подключение…", color: .orange, progress: true)
|
banner(text: "Подключение", color: .clear, progress: true)
|
||||||
case .disconnected(let reason):
|
case .disconnected:
|
||||||
HStack(spacing: 8) {
|
banner(text: "Ожидание сети", color: .clear, progress: true)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
HStack(spacing: 8) {
|
||||||
if progress {
|
if progress {
|
||||||
ProgressView().controlSize(.mini).tint(.white)
|
ProgressView().controlSize(.mini).tint(.white)
|
||||||
} else {
|
|
||||||
Image(systemName: icon)
|
|
||||||
}
|
}
|
||||||
Text(text)
|
Text(text)
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.white)
|
.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)
|
.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
|
#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() {
|
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.name = "Геопозиция из Luma"
|
||||||
item.openInMaps()
|
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 isTrackingDismissGesture = false
|
||||||
@State private var isCompletingDismissal = false
|
@State private var isCompletingDismissal = false
|
||||||
@State private var isPhotoZoomed = false
|
@State private var isPhotoZoomed = false
|
||||||
|
@State private var saveErrorMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
@@ -58,13 +59,24 @@ struct MediaViewer: View {
|
|||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.title)
|
// Text(item.title)
|
||||||
.font(.headline)
|
// .font(.headline)
|
||||||
.foregroundStyle(.white)
|
// .foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
// .lineLimit(1)
|
||||||
|
|
||||||
Spacer()
|
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(.horizontal, 16)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
@@ -77,8 +89,6 @@ struct MediaViewer: View {
|
|||||||
)
|
)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.offset(y: dragOffset)
|
.offset(y: dragOffset)
|
||||||
@@ -96,6 +106,11 @@ struct MediaViewer: View {
|
|||||||
.accessibilityAction(.escape) {
|
.accessibilityAction(.escape) {
|
||||||
closeImmediately()
|
closeImmediately()
|
||||||
}
|
}
|
||||||
|
.alert("Ошибка сохранения", isPresented: saveErrorBinding) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(saveErrorMessage ?? "Не удалось сохранить медиа.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onExitCommand {
|
.onExitCommand {
|
||||||
@@ -183,6 +198,27 @@ struct MediaViewer: View {
|
|||||||
onClose()
|
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 {
|
private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat {
|
||||||
min(1, abs(dragOffset) / max(1, containerHeight * 0.62))
|
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
|
import SwiftUI
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -23,9 +23,12 @@ struct MessageBubble: View {
|
|||||||
let onDelete: () -> Void
|
let onDelete: () -> Void
|
||||||
let onReplyTap: (String) -> Void
|
let onReplyTap: (String) -> Void
|
||||||
let onReact: (String) -> Void
|
let onReact: (String) -> Void
|
||||||
|
let onReactPicker: () -> Void
|
||||||
|
|
||||||
@State private var replySwipeOffset: CGFloat = 0
|
@State private var replySwipeOffset: CGFloat = 0
|
||||||
@State private var replySwipeArmed = false
|
@State private var replySwipeArmed = false
|
||||||
|
@State private var replySwipeLocked = false
|
||||||
|
@State private var scrollOwnsGesture = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -34,8 +37,8 @@ struct MessageBubble: View {
|
|||||||
.offset(x: replySwipeOffset)
|
.offset(x: replySwipeOffset)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.accessibilityIdentifier("bubble-\(message.clientID)")
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
guard isSelectionMode else { return }
|
guard isSelectionMode else { return }
|
||||||
onToggleSelection()
|
onToggleSelection()
|
||||||
@@ -44,39 +47,59 @@ struct MessageBubble: View {
|
|||||||
.vertical,
|
.vertical,
|
||||||
message.kind == .photo || message.kind == .video ? 7 : 2
|
message.kind == .photo || message.kind == .video ? 7 : 2
|
||||||
)
|
)
|
||||||
.simultaneousGesture(
|
|
||||||
replySwipeGesture,
|
|
||||||
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
|
|
||||||
)
|
|
||||||
.onChange(of: isSelectionMode) { _, selecting in
|
.onChange(of: isSelectionMode) { _, selecting in
|
||||||
guard selecting else { return }
|
guard selecting else { return }
|
||||||
replySwipeOffset = 0
|
replySwipeOffset = 0
|
||||||
replySwipeArmed = false
|
replySwipeArmed = false
|
||||||
|
replySwipeLocked = false
|
||||||
|
scrollOwnsGesture = false
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if !isSelectionMode {
|
if !isSelectionMode {
|
||||||
if message.canBeReactedTo {
|
if message.canBeReactedTo {
|
||||||
Menu {
|
Button(action: onReactPicker) {
|
||||||
ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in
|
|
||||||
Button(emoji) { onReact(emoji) }
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Реакция", systemImage: "face.smiling")
|
Label("Реакция", systemImage: "face.smiling")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if message.canBeRepliedTo {
|
if message.canBeRepliedTo {
|
||||||
Button(action: onReply) {
|
Button(action: onReply) {
|
||||||
Label("Ответить", systemImage: "arrowshape.turn.up.left")
|
Label(
|
||||||
|
"Ответить",
|
||||||
|
systemImage: "arrowshape.turn.up.left"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if message.canBeForwarded {
|
if message.canBeForwarded {
|
||||||
Button(action: onForward) {
|
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) {
|
if model.canRetryMediaMessage(message) {
|
||||||
Button(action: onRetry) {
|
Button(action: onRetry) {
|
||||||
Label("Повторить отправку", systemImage: "arrow.clockwise")
|
Label(
|
||||||
|
"Повторить отправку",
|
||||||
|
systemImage: "arrow.clockwise"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if message.canBeEdited {
|
if message.canBeEdited {
|
||||||
@@ -99,7 +122,9 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text(selectionAccessibilityLabel))
|
.accessibilityLabel(Text(selectionAccessibilityLabel))
|
||||||
.accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) {
|
.accessibilityAction(
|
||||||
|
named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")
|
||||||
|
) {
|
||||||
if isSelectionMode {
|
if isSelectionMode {
|
||||||
onToggleSelection()
|
onToggleSelection()
|
||||||
} else {
|
} 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 {
|
private var messageContainer: some View {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
if isSelectionMode {
|
if isSelectionMode {
|
||||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
Image(
|
||||||
.font(.system(size: 23, weight: .semibold))
|
systemName: isSelected ? "checkmark.circle.fill" : "circle"
|
||||||
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
)
|
||||||
.frame(width: 34)
|
.font(.system(size: 23, weight: .semibold))
|
||||||
.padding(.leading, 7)
|
.foregroundStyle(
|
||||||
.accessibilityHidden(true)
|
isSelected ? Color.accentColor : Color.secondary
|
||||||
|
)
|
||||||
|
.frame(width: 34)
|
||||||
|
.padding(.leading, 7)
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -145,30 +183,65 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var replySwipeIcon: some View {
|
private var replySwipeIcon: some View {
|
||||||
Image(systemName: replySwipeArmed
|
Image(
|
||||||
? "arrowshape.turn.up.left.fill"
|
systemName: replySwipeArmed
|
||||||
: "arrowshape.turn.up.left")
|
? "arrowshape.turn.up.left.fill"
|
||||||
.font(.system(size: 19, weight: .semibold))
|
: "arrowshape.turn.up.left"
|
||||||
.foregroundStyle(Color.accentColor)
|
)
|
||||||
.scaleEffect(
|
.font(.system(size: 19, weight: .semibold))
|
||||||
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
.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 {
|
private var replySwipeGesture: some Gesture {
|
||||||
DragGesture(minimumDistance: 20)
|
DragGesture(minimumDistance: 20)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let offset = MessageReplySwipePolicy.offset(for: value.translation)
|
// Arbitration: once the touch is clearly vertical the scroll
|
||||||
guard offset != 0 || replySwipeOffset != 0 else {
|
// view owns it for good, so a later horizontal arc can never
|
||||||
// A vertical timeline pan must not publish per-cell state
|
// move bubbles mid-scroll. A reply swipe locks in only while
|
||||||
// changes or compete with UIScrollView's own recognizer.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
replySwipeOffset = offset
|
replySwipeOffset = offset
|
||||||
let armed = MessageReplySwipePolicy.shouldReply(
|
let armed = MessageReplySwipePolicy.shouldReply(
|
||||||
|
locked: replySwipeLocked,
|
||||||
translation: value.translation,
|
translation: value.translation,
|
||||||
predictedEndTranslation: value.translation
|
predictedEndTranslation: value.translation
|
||||||
)
|
)
|
||||||
@@ -178,12 +251,23 @@ struct MessageBubble: View {
|
|||||||
replySwipeArmed = armed
|
replySwipeArmed = armed
|
||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let shouldReply = MessageReplySwipePolicy.shouldReply(
|
// The lock is a hint, not a requirement: a fast flick may
|
||||||
translation: value.translation,
|
// deliver no intermediate frame that passes canLock, yet its
|
||||||
predictedEndTranslation: value.predictedEndTranslation
|
// 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 {
|
if replySwipeOffset != 0 || replySwipeArmed {
|
||||||
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
|
withAnimation(
|
||||||
|
.spring(response: 0.24, dampingFraction: 0.82)
|
||||||
|
) {
|
||||||
replySwipeOffset = 0
|
replySwipeOffset = 0
|
||||||
replySwipeArmed = false
|
replySwipeArmed = false
|
||||||
}
|
}
|
||||||
@@ -195,28 +279,47 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func replySwipeFeedback() {
|
private func replySwipeFeedback() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectionAccessibilityLabel: String {
|
private var selectionAccessibilityLabel: String {
|
||||||
guard isSelectionMode else { return message.previewText }
|
guard isSelectionMode else { return message.previewText }
|
||||||
return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")"
|
return
|
||||||
|
"\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var messageRow: some View {
|
private var messageRow: some View {
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
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 {
|
if message.isGroupMessage, message.direction == .incoming {
|
||||||
Text(message.senderDisplayName ?? model.displayName(for: message.senderJID))
|
Text(
|
||||||
.font(.caption.weight(.semibold))
|
message.senderDisplayName
|
||||||
.foregroundStyle(senderColor)
|
?? model.displayName(for: message.senderJID)
|
||||||
.padding(.horizontal, 8)
|
)
|
||||||
|
.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
|
messageContent
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.simultaneousGesture(
|
||||||
|
replySwipeGesture,
|
||||||
|
including: !isSelectionMode && message.canBeRepliedTo
|
||||||
|
? .gesture : .none
|
||||||
|
)
|
||||||
|
|
||||||
if !reactionSummaries.isEmpty {
|
if !reactionSummaries.isEmpty {
|
||||||
reactionStrip
|
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)
|
.padding(.horizontal, 12)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
guard let targetID = message.replyToID,
|
guard let targetID = message.replyToID,
|
||||||
message.kind == .text || message.kind == .system else { return }
|
message.kind == .text || message.kind == .system
|
||||||
|
else { return }
|
||||||
onReplyTap(targetID)
|
onReplyTap(targetID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,8 +355,13 @@ struct MessageBubble: View {
|
|||||||
if message.security != .omemo || message.delivery == .failed {
|
if message.security != .omemo || message.delivery == .failed {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if message.security != .omemo {
|
if message.security != .omemo {
|
||||||
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
|
Image(
|
||||||
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
systemName: message.security == .plaintext
|
||||||
|
? "lock.open" : "exclamationmark.shield"
|
||||||
|
)
|
||||||
|
.foregroundStyle(
|
||||||
|
message.security == .plaintext ? .orange : .red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if message.delivery == .failed {
|
if message.delivery == .failed {
|
||||||
Image(systemName: "exclamationmark.circle.fill")
|
Image(systemName: "exclamationmark.circle.fill")
|
||||||
@@ -265,8 +376,13 @@ struct MessageBubble: View {
|
|||||||
private var standardMetadata: some View {
|
private var standardMetadata: some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if message.callHistory == nil, message.security != .omemo {
|
if message.callHistory == nil, message.security != .omemo {
|
||||||
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
|
Image(
|
||||||
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
systemName: message.security == .plaintext
|
||||||
|
? "lock.open" : "exclamationmark.shield"
|
||||||
|
)
|
||||||
|
.foregroundStyle(
|
||||||
|
message.security == .plaintext ? .orange : .red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if message.editedAt != nil {
|
if message.editedAt != nil {
|
||||||
Text("изм.")
|
Text("изм.")
|
||||||
@@ -274,7 +390,10 @@ struct MessageBubble: View {
|
|||||||
Text(message.timestamp, format: .dateTime.hour().minute())
|
Text(message.timestamp, format: .dateTime.hour().minute())
|
||||||
if message.direction == .outgoing, message.callHistory == nil {
|
if message.direction == .outgoing, message.callHistory == nil {
|
||||||
Image(systemName: deliveryIcon)
|
Image(systemName: deliveryIcon)
|
||||||
.foregroundStyle(message.delivery == .failed ? .red : .secondary)
|
.foregroundStyle(
|
||||||
|
message.delivery == .failed ? .red : .secondary
|
||||||
|
)
|
||||||
|
Text(deliveryText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
@@ -293,7 +412,10 @@ struct MessageBubble: View {
|
|||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.padding(.horizontal, 13)
|
.padding(.horizontal, 13)
|
||||||
.padding(.vertical, 9)
|
.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)
|
.background(bubbleBackground)
|
||||||
.clipShape(messageBubbleShape)
|
.clipShape(messageBubbleShape)
|
||||||
} else {
|
} else {
|
||||||
@@ -340,7 +462,10 @@ struct MessageBubble: View {
|
|||||||
.lumaTextSelection()
|
.lumaTextSelection()
|
||||||
.padding(.horizontal, 13)
|
.padding(.horizontal, 13)
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
.foregroundStyle(
|
||||||
|
message.direction == .outgoing
|
||||||
|
? Color.white : Color.primary
|
||||||
|
)
|
||||||
.background(bubbleBackground)
|
.background(bubbleBackground)
|
||||||
.clipShape(messageBubbleShape)
|
.clipShape(messageBubbleShape)
|
||||||
}
|
}
|
||||||
@@ -351,7 +476,9 @@ struct MessageBubble: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 13)
|
.padding(.horizontal, 13)
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
.foregroundStyle(
|
||||||
|
message.direction == .outgoing ? Color.white : Color.primary
|
||||||
|
)
|
||||||
.background(bubbleBackground)
|
.background(bubbleBackground)
|
||||||
.clipShape(messageBubbleShape)
|
.clipShape(messageBubbleShape)
|
||||||
case .text, .system:
|
case .text, .system:
|
||||||
@@ -362,27 +489,36 @@ struct MessageBubble: View {
|
|||||||
.lumaTextSelection()
|
.lumaTextSelection()
|
||||||
.padding(.horizontal, 13)
|
.padding(.horizontal, 13)
|
||||||
.padding(.vertical, 9)
|
.padding(.vertical, 9)
|
||||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
.foregroundStyle(
|
||||||
|
message.direction == .outgoing
|
||||||
|
? Color.white : Color.primary
|
||||||
|
)
|
||||||
.background(bubbleBackground)
|
.background(bubbleBackground)
|
||||||
.clipShape(messageBubbleShape)
|
.clipShape(messageBubbleShape)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func callHistoryContent(_ history: CallHistoryMetadata) -> some View {
|
private func callHistoryContent(_ history: CallHistoryMetadata) -> some View
|
||||||
|
{
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(callAccentColor(for: history).opacity(
|
.fill(
|
||||||
message.direction == .outgoing ? 0.28 : 0.14
|
callAccentColor(for: history).opacity(
|
||||||
))
|
message.direction == .outgoing ? 0.28 : 0.14
|
||||||
|
)
|
||||||
|
)
|
||||||
Image(systemName: history.isVideo ? "video.fill" : "phone.fill")
|
Image(systemName: history.isVideo ? "video.fill" : "phone.fill")
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
Image(systemName: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
|
Image(
|
||||||
.font(.system(size: 8, weight: .heavy))
|
systemName: message.direction == .outgoing
|
||||||
.padding(3)
|
? "arrow.up.right" : "arrow.down.left"
|
||||||
.background(Circle().fill(callDirectionBadgeBackground))
|
)
|
||||||
.offset(x: 14, y: 14)
|
.font(.system(size: 8, weight: .heavy))
|
||||||
|
.padding(3)
|
||||||
|
.background(Circle().fill(callDirectionBadgeBackground))
|
||||||
|
.offset(x: 14, y: 14)
|
||||||
}
|
}
|
||||||
.frame(width: 42, height: 42)
|
.frame(width: 42, height: 42)
|
||||||
.foregroundStyle(callIconForeground(for: history))
|
.foregroundStyle(callIconForeground(for: history))
|
||||||
@@ -402,7 +538,9 @@ struct MessageBubble: View {
|
|||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.frame(maxWidth: 300, alignment: .leading)
|
.frame(maxWidth: 300, alignment: .leading)
|
||||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
.foregroundStyle(
|
||||||
|
message.direction == .outgoing ? Color.white : Color.primary
|
||||||
|
)
|
||||||
.background(bubbleBackground)
|
.background(bubbleBackground)
|
||||||
.clipShape(messageBubbleShape)
|
.clipShape(messageBubbleShape)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
@@ -463,7 +601,9 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("\(summary.emoji), реакций: \(summary.count)")
|
.accessibilityLabel(
|
||||||
|
"\(summary.emoji), реакций: \(summary.count)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
@@ -491,9 +631,10 @@ struct MessageBubble: View {
|
|||||||
.padding(.bottom, -2)
|
.padding(.bottom, -2)
|
||||||
.task(id: replyTarget?.remoteAttachmentURL) {
|
.task(id: replyTarget?.remoteAttachmentURL) {
|
||||||
if let replyTarget,
|
if let replyTarget,
|
||||||
replyTarget.kind == .photo
|
replyTarget.kind == .photo
|
||||||
|| replyTarget.kind == .video
|
|| replyTarget.kind == .video
|
||||||
|| replyTarget.kind == .videoNote {
|
|| replyTarget.kind == .videoNote
|
||||||
|
{
|
||||||
await model.prepareMediaPreview(replyTarget)
|
await model.prepareMediaPreview(replyTarget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,7 +647,8 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VStack(
|
VStack(
|
||||||
alignment: compactTargetDirection == .outgoing ? .trailing : .leading,
|
alignment: compactTargetDirection == .outgoing
|
||||||
|
? .trailing : .leading,
|
||||||
spacing: -1
|
spacing: -1
|
||||||
) {
|
) {
|
||||||
compactSourceBubble
|
compactSourceBubble
|
||||||
@@ -550,7 +692,9 @@ struct MessageBubble: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var compactReplyThumbnail: some View {
|
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 {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||||
.fill(Color.primary.opacity(0.08))
|
.fill(Color.primary.opacity(0.08))
|
||||||
@@ -564,25 +708,31 @@ struct MessageBubble: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func replyThumbnailImage(for target: ChatMessage) -> some View {
|
private func replyThumbnailImage(for target: ChatMessage) -> some View {
|
||||||
if let data = model.mediaThumbnail(for: target) {
|
if let data = model.mediaThumbnail(for: target) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
if let image = ChatMediaImageCache.image(
|
||||||
Image(uiImage: image)
|
for: target,
|
||||||
.resizable()
|
data: data
|
||||||
.scaledToFill()
|
) {
|
||||||
} else {
|
Image(uiImage: image)
|
||||||
Image(systemName: replyMediaIcon(for: target.kind))
|
.resizable()
|
||||||
.foregroundStyle(compactReplyColor)
|
.scaledToFill()
|
||||||
}
|
} else {
|
||||||
#elseif os(macOS)
|
Image(systemName: replyMediaIcon(for: target.kind))
|
||||||
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
.foregroundStyle(compactReplyColor)
|
||||||
Image(nsImage: image)
|
}
|
||||||
.resizable()
|
#elseif os(macOS)
|
||||||
.scaledToFill()
|
if let image = ChatMediaImageCache.image(
|
||||||
} else {
|
for: target,
|
||||||
Image(systemName: replyMediaIcon(for: target.kind))
|
data: data
|
||||||
.foregroundStyle(compactReplyColor)
|
) {
|
||||||
}
|
Image(nsImage: image)
|
||||||
#endif
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else {
|
||||||
|
Image(systemName: replyMediaIcon(for: target.kind))
|
||||||
|
.foregroundStyle(compactReplyColor)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: replyMediaIcon(for: target.kind))
|
Image(systemName: replyMediaIcon(for: target.kind))
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 12, weight: .semibold))
|
||||||
@@ -591,7 +741,9 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var replyTarget: ChatMessage? {
|
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 {
|
private var compactTargetDirection: ChatMessage.Direction {
|
||||||
@@ -606,15 +758,23 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var replyText: String {
|
private var replyText: String {
|
||||||
replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно"
|
replyTarget?.quotePreview ?? message.replyPreview
|
||||||
|
?? "Исходное сообщение недоступно"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mediaContent: some View {
|
private var mediaContent: some View {
|
||||||
HStack(spacing: 11) {
|
HStack(spacing: 11) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12))
|
.fill(
|
||||||
.frame(width: message.kind == .videoNote ? 58 : 38, height: message.kind == .videoNote ? 58 : 38)
|
.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)
|
Image(systemName: mediaIcon)
|
||||||
.font(.system(size: message.kind == .videoNote ? 24 : 17))
|
.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))
|
parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60))
|
||||||
}
|
}
|
||||||
if let byteCount = message.byteCount {
|
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 {
|
private var mediaIcon: String {
|
||||||
@@ -687,7 +853,10 @@ struct MessageBubble: View {
|
|||||||
private var bubbleBackground: some View {
|
private var bubbleBackground: some View {
|
||||||
if message.direction == .outgoing {
|
if message.direction == .outgoing {
|
||||||
LinearGradient(
|
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,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
)
|
)
|
||||||
@@ -696,16 +865,27 @@ struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var messageBubbleShape: RoundedRectangle {
|
private var messageBubbleShape: ChatBubbleShape {
|
||||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
ChatBubbleShape(isOutgoing: message.direction == .outgoing)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var deliveryIcon: String {
|
private var deliveryIcon: String {
|
||||||
switch message.delivery {
|
switch message.delivery {
|
||||||
case .sending: return "clock"
|
case .sending: return "clock"
|
||||||
case .sent: return "checkmark"
|
case .sent: return "checkmark"
|
||||||
case .delivered: return "checkmark.circle.fill"
|
case .delivered: return "checkmark.circle.dotted"
|
||||||
case .failed: return "exclamationmark.circle.fill"
|
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
|
@ViewBuilder
|
||||||
func lumaTextSelection() -> some View {
|
fileprivate func lumaTextSelection() -> some View {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
textSelection(.enabled)
|
textSelection(.enabled)
|
||||||
#else
|
#else
|
||||||
self
|
self
|
||||||
#endif
|
#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() {
|
private func openViewer() {
|
||||||
Task {
|
Task {
|
||||||
await model.presentMediaViewer(message)
|
await model.presentMediaViewer(message)
|
||||||
if model.mediaViewerItem?.id != message.id,
|
if model.mediaViewerItem?.id != message.clientID,
|
||||||
message.remoteAttachmentURL == nil {
|
message.remoteAttachmentURL == nil {
|
||||||
onFallbackTap()
|
onFallbackTap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
PhotoAttachmentPreview(
|
||||||
|
model: PreviewSupport.model,
|
||||||
|
message: PreviewSupport.photoMessage(),
|
||||||
|
onFallbackTap: {}
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@@ -97,3 +97,9 @@ struct RTCVideoRendererView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
RTCVideoRendererView(track: nil)
|
||||||
|
.frame(width: 280, height: 320)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
@@ -71,12 +71,13 @@ struct ReplyThreadOverlay: View {
|
|||||||
|
|
||||||
Button(action: onDismiss) {
|
Button(action: onDismiss) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 13, weight: .bold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(0.82))
|
.foregroundStyle(.white.opacity(0.82))
|
||||||
.frame(width: 32, height: 32)
|
.padding(7)
|
||||||
.background(.white.opacity(0.12), in: Circle())
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.glass)
|
||||||
|
.buttonBorderShape(.circle)
|
||||||
|
.background(.clear)
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
.accessibilityLabel("Закрыть ветку ответов")
|
.accessibilityLabel("Закрыть ветку ответов")
|
||||||
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
|
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
|
||||||
@@ -112,10 +113,10 @@ struct ReplyThreadOverlay: View {
|
|||||||
model: model,
|
model: model,
|
||||||
message: reply,
|
message: reply,
|
||||||
maximumBubbleWidth: maximumBubbleWidth,
|
maximumBubbleWidth: maximumBubbleWidth,
|
||||||
isSelected: reply.id == selectedReplyID,
|
isSelected: reply.clientID == selectedReplyID,
|
||||||
showsDeliveryStatus: reply.id == selectedReplyID
|
showsDeliveryStatus: reply.clientID == selectedReplyID
|
||||||
)
|
)
|
||||||
.id(reply.id)
|
.id(reply.clientID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
|
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
|
||||||
@@ -338,6 +339,7 @@ private struct ReplyThreadMessageRow: View {
|
|||||||
case .sent: state = "Отправлено"
|
case .sent: state = "Отправлено"
|
||||||
case .delivered: state = "Доставлено"
|
case .delivered: state = "Доставлено"
|
||||||
case .failed: state = "Не отправлено"
|
case .failed: state = "Не отправлено"
|
||||||
|
case .read: state = "Прочитано"
|
||||||
}
|
}
|
||||||
let value = Calendar.current.isDateInToday(message.timestamp)
|
let value = Calendar.current.isDateInToday(message.timestamp)
|
||||||
? message.timestamp.formatted(date: .omitted, time: .shortened)
|
? message.timestamp.formatted(date: .omitted, time: .shortened)
|
||||||
@@ -345,3 +347,27 @@ private struct ReplyThreadMessageRow: View {
|
|||||||
return "\(state) \(value)"
|
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() {
|
private func openViewer() {
|
||||||
Task {
|
Task {
|
||||||
await model.presentMediaViewer(message)
|
await model.presentMediaViewer(message)
|
||||||
if model.mediaViewerItem?.id != message.id,
|
if model.mediaViewerItem?.id != message.clientID,
|
||||||
message.remoteAttachmentURL == nil {
|
message.remoteAttachmentURL == nil {
|
||||||
onFallbackTap()
|
onFallbackTap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VideoAttachmentPreview(
|
||||||
|
model: PreviewSupport.model,
|
||||||
|
message: PreviewSupport.videoMessage(),
|
||||||
|
onFallbackTap: {}
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ struct VideoNotePreview: View {
|
|||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
// Spacer()
|
||||||
Text(durationText)
|
Text(durationText)
|
||||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
@@ -82,7 +82,7 @@ struct VideoNotePreview: View {
|
|||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
|
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
|
||||||
guard let activeID = notification.object as? String,
|
guard let activeID = notification.object as? String,
|
||||||
activeID != message.id else { return }
|
activeID != message.clientID else { return }
|
||||||
player?.pause()
|
player?.pause()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
}
|
}
|
||||||
@@ -146,10 +146,19 @@ struct VideoNotePreview: View {
|
|||||||
model.audioPlayback.stop()
|
model.audioPlayback.stop()
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: .lumaExclusiveMediaPlayback,
|
name: .lumaExclusiveMediaPlayback,
|
||||||
object: message.id
|
object: message.clientID
|
||||||
)
|
)
|
||||||
player.play()
|
player.play()
|
||||||
}
|
}
|
||||||
isPlaying.toggle()
|
isPlaying.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VideoNotePreview(
|
||||||
|
model: PreviewSupport.model,
|
||||||
|
message: PreviewSupport.videoNoteMessage(),
|
||||||
|
onFallbackTap: {}
|
||||||
|
)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
@@ -16,19 +16,21 @@ struct EncryptionDevicesView: View {
|
|||||||
)
|
)
|
||||||
.foregroundStyle(statusColor)
|
.foregroundStyle(statusColor)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное.")
|
Text(
|
||||||
|
"Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if devices.isEmpty {
|
Section("Устройства контакта") {
|
||||||
Section("Устройства контакта") {
|
if devices.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Ключи ещё не получены",
|
"Ключи ещё не получены",
|
||||||
systemImage: "key.horizontal",
|
systemImage: "key.horizontal",
|
||||||
description: Text("Они появятся после первой попытки отправки или получения OMEMO-сообщения.")
|
description: Text(
|
||||||
|
"Они появятся после первой попытки отправки или получения OMEMO-сообщения."
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
} else {
|
|
||||||
Section("Устройства контакта") {
|
|
||||||
ForEach(devices) { device in
|
ForEach(devices) { device in
|
||||||
DeviceRow(device: device) {
|
DeviceRow(device: device) {
|
||||||
model.setDeviceVerified(
|
model.setDeviceVerified(
|
||||||
@@ -58,9 +60,9 @@ struct EncryptionDevicesView: View {
|
|||||||
}
|
}
|
||||||
.onAppear(perform: reload)
|
.onAppear(perform: reload)
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 480, minHeight: 500)
|
.frame(minWidth: 480, minHeight: 500)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reload() {
|
private func reload() {
|
||||||
@@ -68,7 +70,9 @@ struct EncryptionDevicesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var statusTitle: String {
|
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 подготавливается"
|
return model.isOMEMOReady ? "OMEMO активно" : "OMEMO подготавливается"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,8 +93,20 @@ private struct DeviceRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
Label("Устройство \(device.deviceID)", systemImage: device.isActive ? "iphone" : "iphone.slash")
|
Label(
|
||||||
.font(.headline)
|
"Устройство \(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()
|
Spacer()
|
||||||
Button(action: toggleVerification) {
|
Button(action: toggleVerification) {
|
||||||
Label(trustTitle, systemImage: trustIcon)
|
Label(trustTitle, systemImage: trustIcon)
|
||||||
@@ -134,8 +150,8 @@ private struct DeviceRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension String {
|
extension String {
|
||||||
func chunked(every size: Int) -> [String] {
|
fileprivate func chunked(every size: Int) -> [String] {
|
||||||
guard size > 0 else { return [self] }
|
guard size > 0 else { return [self] }
|
||||||
var result: [String] = []
|
var result: [String] = []
|
||||||
var cursor = startIndex
|
var cursor = startIndex
|
||||||
@@ -147,3 +163,10 @@ private extension String {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
EncryptionDevicesView(
|
||||||
|
model: PreviewSupport.model,
|
||||||
|
conversation: PreviewSupport.conversation(displayName: "Алиса")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -10,6 +11,9 @@ struct ForwardMessageView: View {
|
|||||||
@State private var destinationJID = ""
|
@State private var destinationJID = ""
|
||||||
@State private var isForwarding = false
|
@State private var isForwarding = false
|
||||||
|
|
||||||
|
/// SwiftData-backed destination list, ordered like the main chat list.
|
||||||
|
@Query private var conversations: [Conversation]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
model: AppModel,
|
model: AppModel,
|
||||||
messages: [ChatMessage],
|
messages: [ChatMessage],
|
||||||
@@ -18,6 +22,11 @@ struct ForwardMessageView: View {
|
|||||||
_model = ObservedObject(wrappedValue: model)
|
_model = ObservedObject(wrappedValue: model)
|
||||||
self.messages = messages.filter(\.canBeForwarded)
|
self.messages = messages.filter(\.canBeForwarded)
|
||||||
self.onComplete = onComplete
|
self.onComplete = onComplete
|
||||||
|
_conversations = Query(
|
||||||
|
sort: [
|
||||||
|
SortDescriptor(\Conversation.lastActivity, order: .reverse),
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -39,9 +48,9 @@ struct ForwardMessageView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !model.conversations.isEmpty {
|
if !orderedConversations.isEmpty {
|
||||||
Section("Чаты") {
|
Section("Чаты") {
|
||||||
ForEach(model.conversations) { conversation in
|
ForEach(orderedConversations) { conversation in
|
||||||
Button {
|
Button {
|
||||||
forward(to: conversation.jid)
|
forward(to: conversation.jid)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -105,6 +114,14 @@ struct ForwardMessageView: View {
|
|||||||
#endif
|
#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) {
|
private func forward(to jid: String) {
|
||||||
guard !isForwarding, !messages.isEmpty else { return }
|
guard !isForwarding, !messages.isEmpty else { return }
|
||||||
isForwarding = true
|
isForwarding = true
|
||||||
@@ -135,3 +152,19 @@ struct ForwardMessageView: View {
|
|||||||
return "\(count) \(word)"
|
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 invitees = ""
|
||||||
@State private var isJoining = false
|
@State private var isJoining = false
|
||||||
@State private var isInviting = false
|
@State private var isInviting = false
|
||||||
|
@State private var confirmingDeletion = false
|
||||||
|
|
||||||
init(model: AppModel, conversation: Conversation) {
|
init(model: AppModel, conversation: Conversation) {
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -68,6 +69,16 @@ struct GroupInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
confirmingDeletion = true
|
||||||
|
} label: {
|
||||||
|
Label("Удалить чат", systemImage: "trash")
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Чат и его история будут удалены с этого устройства.")
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Label(
|
Label(
|
||||||
model.encryptionEnabled(for: conversation.jid)
|
model.encryptionEnabled(for: conversation.jid)
|
||||||
@@ -85,6 +96,15 @@ struct GroupInfoView: View {
|
|||||||
Button("Готово") { dismiss() }
|
Button("Готово") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert("Удалить групповой чат?", isPresented: $confirmingDeletion) {
|
||||||
|
Button("Удалить", role: .destructive) {
|
||||||
|
model.deleteGroupChat(jid: conversation.jid)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Button("Отмена", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 480, minHeight: 500)
|
.frame(minWidth: 480, minHeight: 500)
|
||||||
@@ -92,7 +112,7 @@ struct GroupInfoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var liveConversation: Conversation {
|
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] {
|
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()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
LocationPickerView { _ in }
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import SwiftUI
|
|||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@ObservedObject var model: AppModel
|
@ObservedObject var model: AppModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var jid = ""
|
@State private var jid = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
|
@State private var passwordVisible = false
|
||||||
@State private var displayName = ""
|
@State private var displayName = ""
|
||||||
@State private var resource = "Luma"
|
@State private var resource = ""
|
||||||
@State private var showAdvanced = false
|
@State private var showAdvanced = false
|
||||||
@State private var manualHost = ""
|
@State private var manualHost = ""
|
||||||
@State private var manualPort = ""
|
@State private var manualPort = ""
|
||||||
@@ -17,7 +19,7 @@ struct LoginView: View {
|
|||||||
colors: [
|
colors: [
|
||||||
Color(red: 0.08, green: 0.38, blue: 0.78),
|
Color(red: 0.08, green: 0.38, blue: 0.78),
|
||||||
Color(red: 0.12, green: 0.62, blue: 0.96),
|
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,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
@@ -76,32 +78,85 @@ struct LoginView: View {
|
|||||||
Text("XMPP-аккаунт")
|
Text("XMPP-аккаунт")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
TextField("you@example.org", text: $jid)
|
TextField("you@example.org", text: $jid)
|
||||||
.textFieldStyle(.roundedBorder)
|
#if os(iOS)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#endif
|
||||||
|
.textContentType(.username)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textFieldStyle(.plain)
|
||||||
.disableAutocorrection(true)
|
.disableAutocorrection(true)
|
||||||
|
.padding()
|
||||||
|
.glassEffect()
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
Text("Пароль")
|
Text("Пароль")
|
||||||
.font(.headline)
|
.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)
|
.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) {
|
DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
TextField("Отображаемое имя", text: $displayName)
|
TextField("Отображаемое имя", text: $displayName)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.plain)
|
||||||
TextField("Ресурс устройства", text: $resource)
|
.padding()
|
||||||
.textFieldStyle(.roundedBorder)
|
.glassEffect()
|
||||||
|
TextField("Автоматически (Luma-XXXX)", text: $resource)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding()
|
||||||
|
.glassEffect()
|
||||||
|
Text(
|
||||||
|
"Оставьте пустым — Luma создаст уникальный ресурс для этого устройства, чтобы macOS и iOS не конфликтовали между собой."
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
TextField("Сервер вручную (необязательно)", text: $manualHost)
|
TextField("Сервер вручную (необязательно)", text: $manualHost)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.plain)
|
||||||
TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort)
|
.padding()
|
||||||
.textFieldStyle(.roundedBorder)
|
.glassEffect()
|
||||||
|
TextField(
|
||||||
|
directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort
|
||||||
|
)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.padding()
|
||||||
|
.glassEffect()
|
||||||
Toggle("Direct TLS", isOn: $directTLS)
|
Toggle("Direct TLS", isOn: $directTLS)
|
||||||
Text("Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS.")
|
.toggleStyle(.switch)
|
||||||
.font(.caption)
|
Text(
|
||||||
.foregroundStyle(.secondary)
|
"Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS."
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
}
|
}
|
||||||
@@ -119,14 +174,17 @@ struct LoginView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 34)
|
.frame(height: 34)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.glassProminent)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
.disabled(model.isAuthenticating)
|
.disabled(model.isAuthenticating)
|
||||||
|
|
||||||
Label("Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.", systemImage: "lock.shield.fill")
|
Label(
|
||||||
.font(.caption)
|
"Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.",
|
||||||
.foregroundStyle(.secondary)
|
systemImage: "lock.shield.fill"
|
||||||
.multilineTextAlignment(.center)
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.padding(26)
|
.padding(26)
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||||
@@ -144,6 +202,16 @@ struct LoginView: View {
|
|||||||
usesDirectTLS: directTLS
|
usesDirectTLS: directTLS
|
||||||
)
|
)
|
||||||
await model.signIn(account: account, password: password)
|
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
|
@State private var addToRoster = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if os(iOS)
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section("Контакт") {
|
formcontent()
|
||||||
TextField("user@example.org", text: $jid)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
TextField("Имя (необязательно)", text: $name)
|
|
||||||
}
|
|
||||||
Section {
|
|
||||||
Toggle("Добавить в roster", isOn: $addToRoster)
|
|
||||||
} footer: {
|
|
||||||
Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Новый чат")
|
.navigationTitle("Новый чат")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -40,8 +32,52 @@ struct NewChatView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#else
|
||||||
.frame(minWidth: 380, minHeight: 300)
|
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
|
#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
|
@State private var isJoining = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
#if os(iOS)
|
||||||
Form {
|
NavigationStack {
|
||||||
GroupBox {
|
Form {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
formcontent()
|
||||||
TextField("room@conference.example.org", text: $roomJID)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
TextField("Название (необязательно)", text: $name)
|
|
||||||
TextField("Ваш псевдоним", text: $nickname)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text("Комната")
|
|
||||||
.font(.headline)
|
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Новая группа")
|
||||||
GroupBox {
|
.toolbar {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
TextField("alice@example.org, bob@example.org", text: $invitees, axis: .vertical)
|
Button("Отмена") { dismiss() }
|
||||||
.lineLimit(2...5)
|
.disabled(isJoining)
|
||||||
Text("Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию.")
|
}
|
||||||
.font(.footnote)
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
.foregroundStyle(.secondary)
|
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("Новая группа")
|
#else
|
||||||
.toolbar {
|
List {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
formcontent()
|
||||||
|
HStack {
|
||||||
Button("Отмена") { dismiss() }
|
Button("Отмена") { dismiss() }
|
||||||
.disabled(isJoining)
|
.disabled(isJoining)
|
||||||
}
|
.buttonStyle(.glass)
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
.tint(.secondary)
|
||||||
|
.controlSize(.large)
|
||||||
|
Spacer()
|
||||||
Button("Создать / войти") { join() }
|
Button("Создать / войти") { join() }
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.disabled(!canJoin || isJoining)
|
.disabled(!canJoin || isJoining)
|
||||||
|
.buttonStyle(.glassProminent)
|
||||||
|
.controlSize(.large)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationTitle("Новая группа")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if nickname.isEmpty { nickname = model.suggestedGroupNickname }
|
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)
|
Section("Пригласить") {
|
||||||
.frame(minWidth: 480, minHeight: 470)
|
TextField(
|
||||||
#endif
|
"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 {
|
private var canJoin: Bool {
|
||||||
@@ -92,3 +120,7 @@ struct NewGroupView: View {
|
|||||||
.filter { !$0.isEmpty }
|
.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 QuickLook
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@ObservedObject var model: AppModel
|
@ObservedObject var model: AppModel
|
||||||
|
@State private var toastDismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Group {
|
if model.isAppLocked {
|
||||||
if model.account == nil {
|
// The lock covers every layer: chat list, media viewer and
|
||||||
LoginView(model: model)
|
// calls stay hidden until the passcode or biometrics succeed.
|
||||||
} else {
|
AppLockView(model: model)
|
||||||
MainChatView(model: model)
|
} else {
|
||||||
|
Group {
|
||||||
|
if model.account == nil {
|
||||||
|
LoginView(model: model)
|
||||||
|
} else {
|
||||||
|
MainTabView(model: model)
|
||||||
|
.environment(\.modelContext, model.modelContext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let item = model.mediaViewerItem {
|
if let item = model.mediaViewerItem {
|
||||||
MediaViewer(item: item, onClose: model.closeMediaViewer)
|
MediaViewer(item: item, onClose: model.closeMediaViewer)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||||
.zIndex(100)
|
.zIndex(100)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let call = model.activeCall {
|
if let call = model.activeCall {
|
||||||
CallView(model: model, call: call)
|
CallView(model: model, call: call)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 1.015)))
|
.transition(.opacity.combined(with: .scale(scale: 1.015)))
|
||||||
.zIndex(200)
|
.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.22), value: model.account?.id)
|
||||||
.animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id)
|
.animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id)
|
||||||
.animation(.easeInOut(duration: 0.2), value: model.activeCall?.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) {
|
.alert("Luma", isPresented: errorBinding) {
|
||||||
Button("OK") { model.clearError() }
|
Button("OK") { model.clearError() }
|
||||||
} message: {
|
} message: {
|
||||||
Text(model.errorMessage ?? "Неизвестная ошибка")
|
Text(model.errorMessage ?? "Неизвестная ошибка")
|
||||||
}
|
}
|
||||||
.quickLookPreview($model.previewURL)
|
.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> {
|
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 PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var model: AppModel
|
@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
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showingSignOutConfirmation = false
|
@State private var showingSignOutConfirmation = false
|
||||||
@State private var forgetHistory = false
|
@State private var forgetHistory = false
|
||||||
@State private var avatarItem: PhotosPickerItem?
|
@State private var avatarItem: PhotosPickerItem?
|
||||||
|
@State private var passcodeSheetMode: AppLockPasscodeSheet.Mode?
|
||||||
|
@State private var showingAddAccount = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
#if os(iOS)
|
||||||
Form {
|
NavigationStack {
|
||||||
if let account = model.account {
|
Form {
|
||||||
Section("Профиль") {
|
formcontent()
|
||||||
HStack(spacing: 14) {
|
}
|
||||||
AvatarView(
|
.sheet(isPresented: $showingAddAccount) { LoginView(model: model) }
|
||||||
conversation: Conversation(
|
.navigationTitle("")
|
||||||
jid: account.normalizedJID,
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
displayName: account.displayName.isEmpty ? nil : account.displayName
|
.toolbar {
|
||||||
),
|
ToolbarItem(placement: .principal) {
|
||||||
imageData: model.avatarData(for: account.normalizedJID),
|
Text("Настройки")
|
||||||
size: 64
|
.font(.headline)
|
||||||
)
|
}
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
if !presentedAsTab {
|
||||||
Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName)
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
.font(.headline)
|
Button("Готово") { dismiss() }
|
||||||
PhotosPicker(selection: $avatarItem, matching: .images) {
|
}
|
||||||
Label("Обновить аватар", systemImage: "photo.badge.plus")
|
}
|
||||||
}
|
}
|
||||||
.disabled(model.isUpdatingAvatar)
|
}
|
||||||
|
#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()
|
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)
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
Section("Соединение") {
|
showingAddAccount = true
|
||||||
HStack {
|
} label: {
|
||||||
Label(connectionTitle, systemImage: connectionIcon)
|
Label("Добавить аккаунт", systemImage: "person.badge.plus")
|
||||||
.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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Настройки")
|
Section("Профиль") {
|
||||||
.toolbar {
|
HStack(spacing: 14) {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
AvatarView(
|
||||||
Button("Готово") { dismiss() }
|
conversation: Conversation(
|
||||||
}
|
jid: account.normalizedJID,
|
||||||
}
|
displayName: account.displayName.isEmpty
|
||||||
.confirmationDialog(
|
? nil : account.displayName
|
||||||
"Выйти из Luma?",
|
),
|
||||||
isPresented: $showingSignOutConfirmation,
|
imageData: model.avatarData(for: account.normalizedJID),
|
||||||
titleVisibility: .visible
|
size: 64
|
||||||
) {
|
)
|
||||||
Button("Выйти", role: .destructive) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Task {
|
Text(
|
||||||
await model.signOut(forgetHistory: forgetHistory)
|
account.displayName.isEmpty
|
||||||
dismiss()
|
? 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
|
.onChange(of: avatarItem) { _, item in
|
||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
Task {
|
Task {
|
||||||
do {
|
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)
|
await model.updateOwnAvatar(from: data)
|
||||||
avatarItem = nil
|
avatarItem = nil
|
||||||
} catch {
|
} 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 {
|
private var connectionTitle: String {
|
||||||
@@ -179,16 +322,21 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension String {
|
extension String {
|
||||||
func chunked(every size: Int) -> [String] {
|
fileprivate func chunked(every size: Int) -> [String] {
|
||||||
guard size > 0 else { return [self] }
|
guard size > 0 else { return [self] }
|
||||||
var result: [String] = []
|
var result: [String] = []
|
||||||
var cursor = startIndex
|
var cursor = startIndex
|
||||||
while cursor < endIndex {
|
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]))
|
result.append(String(self[cursor..<end]))
|
||||||
cursor = end
|
cursor = end
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SettingsView(model: PreviewSupport.model, presentedAsTab: true)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
struct CapturedCameraMedia: Sendable {
|
struct CapturedCameraMedia: Sendable {
|
||||||
let url: URL
|
let url: URL
|
||||||
@@ -12,6 +13,13 @@ import UIKit
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
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 onMedia: (Result<CapturedCameraMedia, Error>) -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
|
|
||||||
@@ -20,7 +28,11 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(onMedia: onMedia, onCancel: onCancel)
|
Coordinator(
|
||||||
|
onDismissRequest: onDismissRequest,
|
||||||
|
onMedia: onMedia,
|
||||||
|
onCancel: onCancel
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
@@ -42,14 +54,17 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
private let onDismissRequest: () -> Void
|
||||||
private let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
|
private let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
|
||||||
private let onCancel: () -> Void
|
private let onCancel: () -> Void
|
||||||
private var completed = false
|
private var completed = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
|
onDismissRequest: @escaping () -> Void,
|
||||||
onMedia: @escaping (Result<CapturedCameraMedia, Error>) -> Void,
|
onMedia: @escaping (Result<CapturedCameraMedia, Error>) -> Void,
|
||||||
onCancel: @escaping () -> Void
|
onCancel: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
|
self.onDismissRequest = onDismissRequest
|
||||||
self.onMedia = onMedia
|
self.onMedia = onMedia
|
||||||
self.onCancel = onCancel
|
self.onCancel = onCancel
|
||||||
}
|
}
|
||||||
@@ -60,6 +75,7 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
|||||||
) {
|
) {
|
||||||
guard !completed else { return }
|
guard !completed else { return }
|
||||||
completed = true
|
completed = true
|
||||||
|
onDismissRequest()
|
||||||
|
|
||||||
let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) }
|
let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) }
|
||||||
if mediaType?.conforms(to: .movie) == true,
|
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) {
|
private func prepareMovie(from sourceURL: URL) {
|
||||||
let completion = onMedia
|
let logger = Logger(subsystem: "Luma", category: "video-preview")
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
do {
|
||||||
let result: Result<CapturedCameraMedia, Error>
|
let sourceSize = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1
|
||||||
do {
|
logger.info("picker movie ready: source=\(sourceURL.lastPathComponent) size=\(sourceSize)")
|
||||||
let fileExtension = sourceURL.pathExtension.isEmpty
|
let fileExtension = sourceURL.pathExtension.isEmpty
|
||||||
? "mov"
|
? "mov"
|
||||||
: sourceURL.pathExtension
|
: sourceURL.pathExtension
|
||||||
let destination = try Self.destinationURL(extension: fileExtension)
|
let destination = try Self.destinationURL(extension: fileExtension)
|
||||||
try FileManager.default.copyItem(at: sourceURL, to: destination)
|
try FileManager.default.moveItem(at: sourceURL, to: destination)
|
||||||
let values = try destination.resourceValues(
|
let values = try destination.resourceValues(
|
||||||
forKeys: [.fileSizeKey, .isRegularFileKey]
|
forKeys: [.fileSizeKey, .isRegularFileKey]
|
||||||
)
|
)
|
||||||
guard values.isRegularFile == true,
|
let movedSize = values.fileSize ?? 0
|
||||||
(values.fileSize ?? 0) > 0 else {
|
logger.info("picker movie moved: size=\(movedSize) regular=\(values.isRegularFile == true)")
|
||||||
try? FileManager.default.removeItem(at: destination)
|
guard values.isRegularFile == true, movedSize > 0 else {
|
||||||
throw SystemCameraError.emptyMovie
|
try? FileManager.default.removeItem(at: destination)
|
||||||
}
|
throw SystemCameraError.emptyMovie
|
||||||
result = .success(CapturedCameraMedia(url: destination, kind: .video))
|
|
||||||
} catch {
|
|
||||||
result = .failure(error)
|
|
||||||
}
|
}
|
||||||
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
|
#endif
|
||||||
@@ -13,6 +13,7 @@ struct VideoNoteCaptureView: View {
|
|||||||
@StateObject private var recorder = VideoNoteRecorder()
|
@StateObject private var recorder = VideoNoteRecorder()
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var isFinalizing = false
|
@State private var isFinalizing = false
|
||||||
|
@State private var isSwitchingCamera = false
|
||||||
|
|
||||||
let onComplete: (VideoNoteRecorder.Recording) -> Void
|
let onComplete: (VideoNoteRecorder.Recording) -> Void
|
||||||
|
|
||||||
@@ -32,6 +33,26 @@ struct VideoNoteCaptureView: View {
|
|||||||
.frame(maxWidth: 420)
|
.frame(maxWidth: 420)
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
.overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1))
|
.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)
|
Text(recordingStatus)
|
||||||
.font(.system(.title3, design: .monospaced).weight(.semibold))
|
.font(.system(.title3, design: .monospaced).weight(.semibold))
|
||||||
@@ -120,6 +141,19 @@ struct VideoNoteCaptureView: View {
|
|||||||
#endif
|
#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 {
|
private func formatted(_ duration: TimeInterval) -> String {
|
||||||
let seconds = max(0, Int(duration.rounded(.down)))
|
let seconds = max(0, Int(duration.rounded(.down)))
|
||||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||||
@@ -190,3 +224,7 @@ struct CameraPreview: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#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
|
import WebRTC
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Owns one active 1:1 Jingle RTP session and bridges Martin to WebRTC.
|
/// Owns one active 1:1 Jingle RTP session and bridges Martin to WebRTC.
|
||||||
/// Incoming calls are available while the XMPP connection is alive; production
|
/// Incoming calls are available while the XMPP connection is alive; production
|
||||||
/// wake-up for a terminated iOS app still requires VoIP push infrastructure.
|
/// 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 snapshotHandler: (@MainActor (CallSnapshot?) -> Void)?
|
||||||
var historyHandler: (@MainActor (CallHistoryEntry) -> Void)?
|
var historyHandler: (@MainActor (CallHistoryEntry) -> Void)?
|
||||||
var errorHandler: (@MainActor (String) -> Void)?
|
var errorHandler: (@MainActor (String) -> Void)?
|
||||||
@@ -76,15 +76,16 @@ final class LumaCallEngine: NSObject {
|
|||||||
media: media,
|
media: media,
|
||||||
client: client
|
client: client
|
||||||
)
|
)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print(
|
print(
|
||||||
"Luma Call: selected \(destination.jid.stringValue) via "
|
"Luma Call: selected \(destination.jid.stringValue) via "
|
||||||
+ (destination.usesMessageInitiation ? "JMI" : "direct Jingle")
|
+ (destination.usesMessageInitiation ? "JMI" : "direct Jingle")
|
||||||
+ " for \(media.map(\.rawValue).sorted().joined(separator: "+"))."
|
+ " for \(media.map(\.rawValue).sorted().joined(separator: "+"))."
|
||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
let sid = UUID().uuidString.lowercased()
|
let sid = UUID().uuidString.lowercased()
|
||||||
let initiationType: JingleSessionInitiationType = destination.usesMessageInitiation
|
let initiationType: JingleSessionInitiationType =
|
||||||
|
destination.usesMessageInitiation
|
||||||
? .message
|
? .message
|
||||||
: .iq
|
: .iq
|
||||||
let session = self.makeSession(
|
let session = self.makeSession(
|
||||||
@@ -108,14 +109,16 @@ final class LumaCallEngine: NSObject {
|
|||||||
if destination.usesMessageInitiation {
|
if destination.usesMessageInitiation {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await session.initiate(descriptions: media
|
try await session.initiate(
|
||||||
.sorted { $0.rawValue < $1.rawValue }
|
descriptions:
|
||||||
.map {
|
media
|
||||||
Jingle.MessageInitiationAction.Description(
|
.sorted { $0.rawValue < $1.rawValue }
|
||||||
xmlns: "urn:xmpp:jingle:apps:rtp:1",
|
.map {
|
||||||
media: $0.rawValue
|
Jingle.MessageInitiationAction.Description(
|
||||||
)
|
xmlns: "urn:xmpp:jingle:apps:rtp:1",
|
||||||
})
|
media: $0.rawValue
|
||||||
|
)
|
||||||
|
})
|
||||||
completion(.success(()))
|
completion(.success(()))
|
||||||
} catch {
|
} catch {
|
||||||
self.queue.async {
|
self.queue.async {
|
||||||
@@ -150,8 +153,9 @@ final class LumaCallEngine: NSObject {
|
|||||||
func answerCall(completion: @escaping (Result<Void, Error>) -> Void) {
|
func answerCall(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
queue.async {
|
queue.async {
|
||||||
guard let call = self.activeCall,
|
guard let call = self.activeCall,
|
||||||
call.direction == .incoming,
|
call.direction == .incoming,
|
||||||
call.phase == .ringing else {
|
call.phase == .ringing
|
||||||
|
else {
|
||||||
completion(.failure(LumaCallError.noIncomingCall))
|
completion(.failure(LumaCallError.noIncomingCall))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -324,8 +328,9 @@ final class LumaCallEngine: NSObject {
|
|||||||
let capabilities = client.module(.caps)
|
let capabilities = client.module(.caps)
|
||||||
let candidates = presences.compactMap(\.from).filter { jid in
|
let candidates = presences.compactMap(\.from).filter { jid in
|
||||||
guard capabilities.isFeatureSupported(JingleModule.XMLNS, by: jid),
|
guard capabilities.isFeatureSupported(JingleModule.XMLNS, by: jid),
|
||||||
capabilities.isFeatureSupported(Jingle.Transport.ICEUDPTransport.XMLNS, by: jid),
|
capabilities.isFeatureSupported(Jingle.Transport.ICEUDPTransport.XMLNS, by: jid),
|
||||||
capabilities.isFeatureSupported("urn:xmpp:jingle:apps:rtp:audio", by: jid) else {
|
capabilities.isFeatureSupported("urn:xmpp:jingle:apps:rtp:audio", by: jid)
|
||||||
|
else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return !media.contains(.video)
|
return !media.contains(.video)
|
||||||
@@ -375,9 +380,9 @@ final class LumaCallEngine: NSObject {
|
|||||||
timeoutWorkItem?.cancel()
|
timeoutWorkItem?.cancel()
|
||||||
let item = DispatchWorkItem { [weak self] in
|
let item = DispatchWorkItem { [weak self] in
|
||||||
guard let self, self.activeCall?.id == callID else { return }
|
guard let self, self.activeCall?.id == callID else { return }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Luma Call: timeout during \(stage) after \(Int(delay)) seconds.")
|
print("Luma Call: timeout during \(stage) after \(Int(delay)) seconds.")
|
||||||
#endif
|
#endif
|
||||||
self.fail(self.activeCall, error: LumaCallError.timedOut)
|
self.fail(self.activeCall, error: LumaCallError.timedOut)
|
||||||
}
|
}
|
||||||
timeoutWorkItem = item
|
timeoutWorkItem = item
|
||||||
@@ -387,9 +392,9 @@ final class LumaCallEngine: NSObject {
|
|||||||
private func fail(_ call: LumaWebRTCCall?, error: Error) {
|
private func fail(_ call: LumaWebRTCCall?, error: Error) {
|
||||||
guard call == nil || activeCall === call else { return }
|
guard call == nil || activeCall === call else { return }
|
||||||
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Luma Call: failure: \(text)")
|
print("Luma Call: failure: \(text)")
|
||||||
#endif
|
#endif
|
||||||
report(error)
|
report(error)
|
||||||
let cause: CallTerminationCause
|
let cause: CallTerminationCause
|
||||||
if let callError = error as? LumaCallError, case .timedOut = callError {
|
if let callError = error as? LumaCallError, case .timedOut = callError {
|
||||||
@@ -424,14 +429,14 @@ final class LumaCallEngine: NSObject {
|
|||||||
publish(nil)
|
publish(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print(
|
print(
|
||||||
"Luma Call: closing sid=\(call.session.sid) "
|
"Luma Call: closing sid=\(call.session.sid) "
|
||||||
+ "direction=\(String(describing: call.direction)) "
|
+ "direction=\(String(describing: call.direction)) "
|
||||||
+ "phase=\(String(describing: call.phase)) "
|
+ "phase=\(String(describing: call.phase)) "
|
||||||
+ "peer=\(call.session.jid.stringValue) trigger=\(trigger)."
|
+ "peer=\(call.session.jid.stringValue) trigger=\(trigger)."
|
||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
let endedAt = Date()
|
let endedAt = Date()
|
||||||
let outcome = CallHistoryPolicy.outcome(
|
let outcome = CallHistoryPolicy.outcome(
|
||||||
direction: call.direction,
|
direction: call.direction,
|
||||||
@@ -515,20 +520,22 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
|
|
||||||
case .proceed(let id):
|
case .proceed(let id):
|
||||||
guard let session = sessions[id],
|
guard let session = sessions[id],
|
||||||
let call = activeCall,
|
let call = activeCall,
|
||||||
call.session === session,
|
call.session === session,
|
||||||
call.direction == .outgoing,
|
call.direction == .outgoing,
|
||||||
call.phase == .ringing,
|
call.phase == .ringing,
|
||||||
session.pinPeerResource(jid) else {
|
session.pinPeerResource(jid)
|
||||||
#if DEBUG
|
else {
|
||||||
print("Luma Jingle: ignored duplicate or foreign proceed for session \(id).")
|
#if DEBUG
|
||||||
#endif
|
print(
|
||||||
|
"Luma Jingle: ignored duplicate or foreign proceed for session \(id).")
|
||||||
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
call.phase = .connecting
|
call.phase = .connecting
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Luma Jingle: proceed from \(jid.stringValue) for session \(id).")
|
print("Luma Jingle: proceed from \(jid.stringValue) for session \(id).")
|
||||||
#endif
|
#endif
|
||||||
scheduleTimeout(
|
scheduleTimeout(
|
||||||
for: call.id,
|
for: call.id,
|
||||||
after: 90,
|
after: 90,
|
||||||
@@ -551,7 +558,8 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
|
|
||||||
case .retract(let id):
|
case .retract(let id):
|
||||||
guard let session = sessions[id],
|
guard let session = sessions[id],
|
||||||
session.matchesPeerEndpoint(jid) else { return }
|
session.matchesPeerEndpoint(jid)
|
||||||
|
else { return }
|
||||||
finishActiveCall(
|
finishActiveCall(
|
||||||
cause: .remoteCancelled,
|
cause: .remoteCancelled,
|
||||||
sendTermination: false,
|
sendTermination: false,
|
||||||
@@ -560,16 +568,17 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
|
|
||||||
case .reject(let id):
|
case .reject(let id):
|
||||||
guard let session = sessions[id],
|
guard let session = sessions[id],
|
||||||
let call = activeCall,
|
let call = activeCall,
|
||||||
call.session === session,
|
call.session === session,
|
||||||
session.matchesPeerEndpoint(jid) else { return }
|
session.matchesPeerEndpoint(jid)
|
||||||
|
else { return }
|
||||||
// A JMI proposal is delivered to every online resource. Once a
|
// A JMI proposal is delivered to every online resource. Once a
|
||||||
// resource has proceeded, a delayed reject from a sibling must
|
// resource has proceeded, a delayed reject from a sibling must
|
||||||
// not tear down the session that already owns the call.
|
// not tear down the session that already owns the call.
|
||||||
guard call.phase == .ringing else {
|
guard call.phase == .ringing else {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Luma Jingle: ignored late reject after proceed for session \(id).")
|
print("Luma Jingle: ignored late reject after proceed for session \(id).")
|
||||||
#endif
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
finishActiveCall(
|
finishActiveCall(
|
||||||
@@ -599,7 +608,8 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
bundle: [String]?
|
bundle: [String]?
|
||||||
) throws {
|
) throws {
|
||||||
try performSync {
|
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 }
|
guard media.contains(.audio) else { throw XMPPError.feature_not_implemented }
|
||||||
|
|
||||||
if let existing = sessions[sid] {
|
if let existing = sessions[sid] {
|
||||||
@@ -616,7 +626,9 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard activeCall == nil else { throw XMPPError.resource_constraint("Другой звонок уже активен") }
|
guard activeCall == nil else {
|
||||||
|
throw XMPPError.resource_constraint("Другой звонок уже активен")
|
||||||
|
}
|
||||||
|
|
||||||
let session = makeSession(
|
let session = makeSession(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -648,21 +660,22 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
) throws {
|
) throws {
|
||||||
try performSync {
|
try performSync {
|
||||||
guard let session = sessions[sid],
|
guard let session = sessions[sid],
|
||||||
session.pinPeerResource(jid) else {
|
session.pinPeerResource(jid)
|
||||||
|
else {
|
||||||
throw XMPPError.item_not_found
|
throw XMPPError.item_not_found
|
||||||
}
|
}
|
||||||
// JMI may initially address a bare JID. session-accept is the first
|
// JMI may initially address a bare JID. session-accept is the first
|
||||||
// IQ guaranteed to identify the resource that owns this Jingle
|
// IQ guaranteed to identify the resource that owns this Jingle
|
||||||
// session, so keep every later transport-info stanza on it.
|
// session, so keep every later transport-info stanza on it.
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let acceptedMedia = contents.map {
|
let acceptedMedia = contents.map {
|
||||||
"\($0.name):\($0.description?.media ?? "unknown")"
|
"\($0.name):\($0.description?.media ?? "unknown")"
|
||||||
}.joined(separator: ",")
|
}.joined(separator: ",")
|
||||||
print(
|
print(
|
||||||
"Luma Jingle: session-accept from \(jid.stringValue) "
|
"Luma Jingle: session-accept from \(jid.stringValue) "
|
||||||
+ "sid=\(sid) contents=[\(acceptedMedia)]."
|
+ "sid=\(sid) contents=[\(acceptedMedia)]."
|
||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
session.accepted(contents: contents, bundle: bundle)
|
session.accepted(contents: contents, bundle: bundle)
|
||||||
if let call = activeCall, call.session === session {
|
if let call = activeCall, call.session === session {
|
||||||
call.phase = .connecting
|
call.phase = .connecting
|
||||||
@@ -679,7 +692,8 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
func sessionTerminated(for context: Context, with jid: JID, sid: String) throws {
|
func sessionTerminated(for context: Context, with jid: JID, sid: String) throws {
|
||||||
try performSync {
|
try performSync {
|
||||||
guard let session = sessions[sid],
|
guard let session = sessions[sid],
|
||||||
session.matchesPeerEndpoint(jid) else {
|
session.matchesPeerEndpoint(jid)
|
||||||
|
else {
|
||||||
throw XMPPError.item_not_found
|
throw XMPPError.item_not_found
|
||||||
}
|
}
|
||||||
if activeCall?.session.sid == sid {
|
if activeCall?.session.sid == sid {
|
||||||
@@ -703,7 +717,8 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
) throws {
|
) throws {
|
||||||
try performSync {
|
try performSync {
|
||||||
guard let session = sessions[sid],
|
guard let session = sessions[sid],
|
||||||
session.pinPeerResource(jid) else {
|
session.pinPeerResource(jid)
|
||||||
|
else {
|
||||||
throw XMPPError.item_not_found
|
throw XMPPError.item_not_found
|
||||||
}
|
}
|
||||||
// Also learn the endpoint from an early candidate sent by the
|
// Also learn the endpoint from an early candidate sent by the
|
||||||
@@ -728,7 +743,8 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
) throws {
|
) throws {
|
||||||
try performSync {
|
try performSync {
|
||||||
guard let session = sessions[sid],
|
guard let session = sessions[sid],
|
||||||
session.matchesPeerEndpoint(jid) else {
|
session.matchesPeerEndpoint(jid)
|
||||||
|
else {
|
||||||
throw XMPPError.item_not_found
|
throw XMPPError.item_not_found
|
||||||
}
|
}
|
||||||
session.contentModified(action: action, contents: contents, bundle: bundle)
|
session.contentModified(action: action, contents: contents, bundle: bundle)
|
||||||
@@ -743,7 +759,8 @@ extension LumaCallEngine: JingleSessionManager {
|
|||||||
) throws {
|
) throws {
|
||||||
try performSync {
|
try performSync {
|
||||||
guard let session = sessions[sid],
|
guard let session = sessions[sid],
|
||||||
session.matchesPeerEndpoint(jid) else {
|
session.matchesPeerEndpoint(jid)
|
||||||
|
else {
|
||||||
throw XMPPError.item_not_found
|
throw XMPPError.item_not_found
|
||||||
}
|
}
|
||||||
session.sessionInfoReceived(info: info)
|
session.sessionInfoReceived(info: info)
|
||||||
@@ -789,7 +806,8 @@ private final class LumaJingleSession: JingleSession {
|
|||||||
terminate(reason: reason)
|
terminate(reason: reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let action: Jingle.MessageInitiationAction = role == .initiator
|
let action: Jingle.MessageInitiationAction =
|
||||||
|
role == .initiator
|
||||||
? .retract(id: sid)
|
? .retract(id: sid)
|
||||||
: .reject(id: sid)
|
: .reject(id: sid)
|
||||||
owningContext?.module(.jingle).sendMessageInitiation(action: action, to: jid)
|
owningContext?.module(.jingle).sendMessageInitiation(action: action, to: jid)
|
||||||
@@ -806,9 +824,9 @@ private final class LumaJingleSession: JingleSession {
|
|||||||
if jid.resource != nil {
|
if jid.resource != nil {
|
||||||
return peer == jid
|
return peer == jid
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Luma Jingle: pinned session \(sid) to resource \(peer.stringValue).")
|
print("Luma Jingle: pinned session \(sid) to resource \(peer.stringValue).")
|
||||||
#endif
|
#endif
|
||||||
accepted(by: peer)
|
accepted(by: peer)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -871,7 +889,7 @@ private final class LumaJingleSession: JingleSession {
|
|||||||
|
|
||||||
// MARK: - Live WebRTC call
|
// MARK: - Live WebRTC call
|
||||||
|
|
||||||
private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate, @unchecked Sendable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let startedAt = Date()
|
let startedAt = Date()
|
||||||
unowned let engine: LumaCallEngine
|
unowned let engine: LumaCallEngine
|
||||||
@@ -901,7 +919,7 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
private var connectionFailureWorkItem: DispatchWorkItem?
|
private var connectionFailureWorkItem: DispatchWorkItem?
|
||||||
private var isPreparingPeerConnection = false
|
private var isPreparingPeerConnection = false
|
||||||
private var prepareCallbacks: [(Result<Void, Error>) -> Void] = []
|
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(
|
init(
|
||||||
engine: LumaCallEngine,
|
engine: LumaCallEngine,
|
||||||
@@ -1001,22 +1019,25 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configureSpeaker(_ enabled: Bool) throws {
|
func configureSpeaker(_ enabled: Bool) throws {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let audioSession = AVAudioSession.sharedInstance()
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
try audioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
|
try audioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
|
||||||
try audioSession.setActive(true)
|
try audioSession.setActive(true)
|
||||||
#else
|
#else
|
||||||
_ = enabled
|
_ = enabled
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchCamera() {
|
func switchCamera() {
|
||||||
guard let capturer = videoCapturer else { return }
|
guard let capturer = videoCapturer else { return }
|
||||||
let currentPosition = RTCCameraVideoCapturer.captureDevices()
|
let currentPosition =
|
||||||
|
RTCCameraVideoCapturer.captureDevices()
|
||||||
.first(where: { $0.uniqueID == cameraDeviceID })?.position ?? .front
|
.first(where: { $0.uniqueID == cameraDeviceID })?.position ?? .front
|
||||||
guard let device = RTCCameraVideoCapturer.captureDevices().first(where: {
|
guard
|
||||||
$0.position != currentPosition && $0.position != .unspecified
|
let device = RTCCameraVideoCapturer.captureDevices().first(where: {
|
||||||
}), let format = Self.preferredFormat(for: device) else { return }
|
$0.position != currentPosition && $0.position != .unspecified
|
||||||
|
}), let format = Self.preferredFormat(for: device)
|
||||||
|
else { return }
|
||||||
cameraDeviceID = device.uniqueID
|
cameraDeviceID = device.uniqueID
|
||||||
capturer.startCapture(with: device, format: format, fps: Self.preferredFPS(for: format))
|
capturer.startCapture(with: device, format: format, fps: Self.preferredFPS(for: format))
|
||||||
}
|
}
|
||||||
@@ -1032,12 +1053,12 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
localAudioTrack = nil
|
localAudioTrack = nil
|
||||||
localVideoTrack = nil
|
localVideoTrack = nil
|
||||||
remoteVideoTrack = nil
|
remoteVideoTrack = nil
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
try? AVAudioSession.sharedInstance().setActive(
|
try? AVAudioSession.sharedInstance().setActive(
|
||||||
false,
|
false,
|
||||||
options: .notifyOthersOnDeactivation
|
options: .notifyOthersOnDeactivation
|
||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createPeerConnection(iceServers: [RTCIceServer]) throws {
|
private func createPeerConnection(iceServers: [RTCIceServer]) throws {
|
||||||
@@ -1071,19 +1092,19 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
delegate: self
|
delegate: self
|
||||||
) {
|
) {
|
||||||
createdPeerConnection = candidate
|
createdPeerConnection = candidate
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
print("Luma WebRTC: RTCConfiguration recovered with \(attempt.name).")
|
print("Luma WebRTC: RTCConfiguration recovered with \(attempt.name).")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print(
|
print(
|
||||||
"Luma WebRTC: RTCConfiguration rejected "
|
"Luma WebRTC: RTCConfiguration rejected "
|
||||||
+ "\(attempt.name) (\(attempt.servers.count) ICE server(s))."
|
+ "\(attempt.name) (\(attempt.servers.count) ICE server(s))."
|
||||||
)
|
)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let peerConnection = createdPeerConnection else {
|
guard let peerConnection = createdPeerConnection else {
|
||||||
@@ -1091,22 +1112,22 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
}
|
}
|
||||||
self.peerConnection = peerConnection
|
self.peerConnection = peerConnection
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let audioSession = AVAudioSession.sharedInstance()
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
let audioOptions: AVAudioSession.CategoryOptions = [
|
let audioOptions: AVAudioSession.CategoryOptions = [
|
||||||
.allowBluetoothHFP,
|
.allowBluetoothHFP,
|
||||||
.allowBluetoothA2DP
|
.allowBluetoothA2DP,
|
||||||
]
|
]
|
||||||
try audioSession.setCategory(
|
try audioSession.setCategory(
|
||||||
.playAndRecord,
|
.playAndRecord,
|
||||||
mode: media.contains(.video) ? .videoChat : .voiceChat,
|
mode: media.contains(.video) ? .videoChat : .voiceChat,
|
||||||
options: audioOptions
|
options: audioOptions
|
||||||
)
|
)
|
||||||
try audioSession.setActive(true)
|
try audioSession.setActive(true)
|
||||||
if media.contains(.video) {
|
if media.contains(.video) {
|
||||||
try audioSession.overrideOutputAudioPort(.speaker)
|
try audioSession.overrideOutputAudioPort(.speaker)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
let audioConstraints = RTCMediaConstraints(
|
let audioConstraints = RTCMediaConstraints(
|
||||||
mandatoryConstraints: nil,
|
mandatoryConstraints: nil,
|
||||||
@@ -1192,9 +1213,11 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.engine.performOnQueue {
|
self.engine.performOnQueue {
|
||||||
if let error {
|
if let error {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Luma WebRTC: setRemoteDescription failed: \(error.localizedDescription)")
|
print(
|
||||||
#endif
|
"Luma WebRTC: setRemoteDescription failed: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
self.engine.callDidFail(self, error: error)
|
self.engine.callDidFail(self, error: error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1209,7 +1232,8 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
// both the answer and the exact full JID of its owner.
|
// both the answer and the exact full JID of its owner.
|
||||||
self.sendLocalCandidates()
|
self.sendLocalCandidates()
|
||||||
if peerConnection.signalingState == .haveRemoteOffer {
|
if peerConnection.signalingState == .haveRemoteOffer {
|
||||||
self.generateDescription(type: .answer, peerConnection: peerConnection) { result in
|
self.generateDescription(type: .answer, peerConnection: peerConnection) {
|
||||||
|
result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let local):
|
case .success(let local):
|
||||||
Task {
|
Task {
|
||||||
@@ -1266,11 +1290,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
let martinSDP = CallSDPNormalization.martinParseInput(
|
let martinSDP = CallSDPNormalization.martinParseInput(
|
||||||
description.sdp
|
description.sdp
|
||||||
)
|
)
|
||||||
guard let (sdp, _) = SDP.parse(
|
guard
|
||||||
sdpString: martinSDP,
|
let (sdp, _) = SDP.parse(
|
||||||
creatorProvider: self.session.contentCreator(of:),
|
sdpString: martinSDP,
|
||||||
localRole: self.session.role
|
creatorProvider: self.session.contentCreator(of:),
|
||||||
) else {
|
localRole: self.session.role
|
||||||
|
)
|
||||||
|
else {
|
||||||
completion(.failure(LumaCallError.sdpGenerationFailed))
|
completion(.failure(LumaCallError.sdpGenerationFailed))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1280,14 +1306,14 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
completion(.failure(LumaCallError.incompatibleLocalDescription))
|
completion(.failure(LumaCallError.incompatibleLocalDescription))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if fidStatus.total > 0 {
|
if fidStatus.total > 0 {
|
||||||
print(
|
print(
|
||||||
"Luma SDP: verified \(fidStatus.total) FID group(s); "
|
"Luma SDP: verified \(fidStatus.total) FID group(s); "
|
||||||
+ "repaired \(repaired.repairedCount) source msid(s)."
|
+ "repaired \(repaired.repairedCount) source msid(s)."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
self.localSDP = repaired.sdp
|
self.localSDP = repaired.sdp
|
||||||
completion(.success(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
|
// until the remote SDP arrives from that concrete full JID; otherwise
|
||||||
// Prosody can route transport-info to another resource, which correctly
|
// Prosody can route transport-info to another resource, which correctly
|
||||||
// responds with item-not-found for the SID.
|
// responds with item-not-found for the SID.
|
||||||
guard CallCandidateDispatchGate.canSend(
|
guard
|
||||||
initialDescriptionWasSignaled: initialDescriptionWasSignaled,
|
CallCandidateDispatchGate.canSend(
|
||||||
hasLocalDescription: localSDP != nil,
|
initialDescriptionWasSignaled: initialDescriptionWasSignaled,
|
||||||
hasRemoteDescription: remoteSDP != nil,
|
hasLocalDescription: localSDP != nil,
|
||||||
hasFullPeerJID: session.jid.resource != nil
|
hasRemoteDescription: remoteSDP != nil,
|
||||||
), let localSDP else { return }
|
hasFullPeerJID: session.jid.resource != nil
|
||||||
|
), let localSDP
|
||||||
|
else { return }
|
||||||
let candidates = localCandidates
|
let candidates = localCandidates
|
||||||
localCandidates.removeAll()
|
localCandidates.removeAll()
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if !candidates.isEmpty {
|
if !candidates.isEmpty {
|
||||||
print(
|
print(
|
||||||
"Luma Jingle: sending \(candidates.count) ICE candidate(s) "
|
"Luma Jingle: sending \(candidates.count) ICE candidate(s) "
|
||||||
+ "to \(session.jid.stringValue) sid=\(session.sid)."
|
+ "to \(session.jid.stringValue) sid=\(session.sid)."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
let line = candidate.sdp.hasPrefix("a=") ? candidate.sdp : "a=\(candidate.sdp)"
|
let line = candidate.sdp.hasPrefix("a=") ? candidate.sdp : "a=\(candidate.sdp)"
|
||||||
guard let jingleCandidate = Jingle.Transport.ICEUDPTransport.Candidate(fromSDP: line),
|
guard let jingleCandidate = Jingle.Transport.ICEUDPTransport.Candidate(fromSDP: line),
|
||||||
let contentName = candidate.sdpMid,
|
let contentName = candidate.sdpMid,
|
||||||
let content = localSDP.contents.first(where: { $0.name == contentName }),
|
let content = localSDP.contents.first(where: { $0.name == contentName }),
|
||||||
let transport = content.transports.first(where: {
|
let transport = content.transports.first(where: {
|
||||||
$0 is Jingle.Transport.ICEUDPTransport
|
$0 is Jingle.Transport.ICEUDPTransport
|
||||||
}) as? Jingle.Transport.ICEUDPTransport else { continue }
|
}) as? Jingle.Transport.ICEUDPTransport
|
||||||
|
else { continue }
|
||||||
_ = session.transportInfo(
|
_ = session.transportInfo(
|
||||||
contentName: contentName,
|
contentName: contentName,
|
||||||
transport: Jingle.Transport.ICEUDPTransport(
|
transport: Jingle.Transport.ICEUDPTransport(
|
||||||
@@ -1356,11 +1385,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
sdpMid: contentName
|
sdpMid: contentName
|
||||||
),
|
),
|
||||||
completionHandler: { error in
|
completionHandler: { error in
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let error {
|
if let error {
|
||||||
print("Luma WebRTC: remote ICE candidate rejected: \(error.localizedDescription)")
|
print(
|
||||||
}
|
"Luma WebRTC: remote ICE candidate rejected: \(error.localizedDescription)"
|
||||||
#endif
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1372,17 +1403,20 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
|
|
||||||
let expectedNames = localSDP.contents.map(\.name)
|
let expectedNames = localSDP.contents.map(\.name)
|
||||||
let receivedNames = sdp.contents.map(\.name)
|
let receivedNames = sdp.contents.map(\.name)
|
||||||
guard let indices = CallSDPOrdering.answerIndices(
|
guard
|
||||||
localOrder: expectedNames,
|
let indices = CallSDPOrdering.answerIndices(
|
||||||
remoteOrder: receivedNames
|
localOrder: expectedNames,
|
||||||
) else {
|
remoteOrder: receivedNames
|
||||||
|
)
|
||||||
|
else {
|
||||||
throw LumaCallError.incompatibleRemoteDescription
|
throw LumaCallError.incompatibleRemoteDescription
|
||||||
}
|
}
|
||||||
let normalizedBundle: [String]?
|
let normalizedBundle: [String]?
|
||||||
if let bundle = sdp.bundle {
|
if let bundle = sdp.bundle {
|
||||||
let bundleNames = Set(bundle)
|
let bundleNames = Set(bundle)
|
||||||
guard bundleNames.count == bundle.count,
|
guard bundleNames.count == bundle.count,
|
||||||
bundleNames.isSubset(of: Set(receivedNames)) else {
|
bundleNames.isSubset(of: Set(receivedNames))
|
||||||
|
else {
|
||||||
throw LumaCallError.incompatibleRemoteDescription
|
throw LumaCallError.incompatibleRemoteDescription
|
||||||
}
|
}
|
||||||
normalizedBundle = expectedNames.filter { bundleNames.contains($0) }
|
normalizedBundle = expectedNames.filter { bundleNames.contains($0) }
|
||||||
@@ -1394,9 +1428,9 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let orderedContents = indices.map { sdp.contents[$0] }
|
let orderedContents = indices.map { sdp.contents[$0] }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Luma WebRTC: reordered session-accept contents to match the local offer.")
|
print("Luma WebRTC: reordered session-accept contents to match the local offer.")
|
||||||
#endif
|
#endif
|
||||||
return SDP(contents: orderedContents, bundle: normalizedBundle)
|
return SDP(contents: orderedContents, bundle: normalizedBundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1438,7 +1472,9 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
|
|
||||||
// MARK: RTCPeerConnectionDelegate
|
// MARK: RTCPeerConnectionDelegate
|
||||||
|
|
||||||
func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {}
|
func peerConnection(
|
||||||
|
_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState
|
||||||
|
) {}
|
||||||
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
|
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
|
||||||
guard let track = stream.videoTracks.first else { return }
|
guard let track = stream.videoTracks.first else { return }
|
||||||
engine.performOnQueue {
|
engine.performOnQueue {
|
||||||
@@ -1449,11 +1485,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate {
|
|||||||
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
|
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
|
||||||
func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
|
func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
|
||||||
|
|
||||||
func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {
|
func peerConnection(
|
||||||
|
_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState
|
||||||
|
) {
|
||||||
engine.performOnQueue {
|
engine.performOnQueue {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("Luma WebRTC: ICE state changed to \(String(describing: newState)).")
|
print("Luma WebRTC: ICE state changed to \(String(describing: newState)).")
|
||||||
#endif
|
#endif
|
||||||
switch newState {
|
switch newState {
|
||||||
case .connected, .completed:
|
case .connected, .completed:
|
||||||
self.cancelScheduledConnectionFailure()
|
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 {
|
engine.performOnQueue {
|
||||||
self.localCandidates.append(candidate)
|
self.localCandidates.append(candidate)
|
||||||
self.sendLocalCandidates()
|
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(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {}
|
||||||
|
|
||||||
func peerConnection(
|
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 }
|
guard let track = rtpReceiver.track as? RTCVideoTrack else { return }
|
||||||
engine.performOnQueue {
|
engine.performOnQueue {
|
||||||
guard self.remoteVideoTrack === track else { return }
|
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
|
// MARK: - Queue helpers kept fileprivate for the live call object
|
||||||
|
|
||||||
private extension LumaCallEngine {
|
extension LumaCallEngine {
|
||||||
func performOnQueue(_ action: @escaping () -> Void) {
|
fileprivate func performOnQueue(_ action: @escaping () -> Void) {
|
||||||
queue.async(execute: action)
|
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)
|
queue.asyncAfter(deadline: .now() + delay, execute: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func performAttachedClient() -> XMPPClient? {
|
fileprivate func performAttachedClient() -> XMPPClient? {
|
||||||
performSync { client }
|
performSync { client }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ExternalServiceDiscoveryModule.Service {
|
extension ExternalServiceDiscoveryModule.Service {
|
||||||
var rtcIceServer: RTCIceServer? {
|
fileprivate var rtcIceServer: RTCIceServer? {
|
||||||
let normalizedType = type.lowercased()
|
let normalizedType = type.lowercased()
|
||||||
let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard ["stun", "stuns", "turn", "turns"].contains(normalizedType) else { return nil }
|
guard ["stun", "stuns", "turn", "turns"].contains(normalizedType) else { return nil }
|
||||||
guard !normalizedHost.isEmpty,
|
guard !normalizedHost.isEmpty,
|
||||||
normalizedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
|
normalizedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
|
||||||
!normalizedHost.contains("/"),
|
!normalizedHost.contains("/"),
|
||||||
!normalizedHost.contains("?"),
|
!normalizedHost.contains("?"),
|
||||||
!normalizedHost.contains("#") else { return nil }
|
!normalizedHost.contains("#")
|
||||||
|
else { return nil }
|
||||||
guard !normalizedType.hasSuffix("s") || transport == nil || transport == .tcp else {
|
guard !normalizedType.hasSuffix("s") || transport == nil || transport == .tcp else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if normalizedType.hasPrefix("turn") {
|
if normalizedType.hasPrefix("turn") {
|
||||||
guard let username, !username.isEmpty,
|
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 }
|
if let expires, expires <= Date() { return nil }
|
||||||
|
|
||||||
let escapedHost = normalizedHost.contains(":") && !normalizedHost.hasPrefix("[")
|
let escapedHost =
|
||||||
|
normalizedHost.contains(":") && !normalizedHost.hasPrefix("[")
|
||||||
? "[\(normalizedHost)]"
|
? "[\(normalizedHost)]"
|
||||||
: normalizedHost
|
: normalizedHost
|
||||||
var url = "\(normalizedType):\(escapedHost)"
|
var url = "\(normalizedType):\(escapedHost)"
|
||||||
@@ -1567,9 +1614,10 @@ private extension ExternalServiceDiscoveryModule.Service {
|
|||||||
enum CallSDPOrdering {
|
enum CallSDPOrdering {
|
||||||
static func answerIndices(localOrder: [String], remoteOrder: [String]) -> [Int]? {
|
static func answerIndices(localOrder: [String], remoteOrder: [String]) -> [Int]? {
|
||||||
guard localOrder.count == remoteOrder.count,
|
guard localOrder.count == remoteOrder.count,
|
||||||
Set(localOrder).count == localOrder.count,
|
Set(localOrder).count == localOrder.count,
|
||||||
Set(remoteOrder).count == remoteOrder.count,
|
Set(remoteOrder).count == remoteOrder.count,
|
||||||
Set(localOrder) == Set(remoteOrder) else { return nil }
|
Set(localOrder) == Set(remoteOrder)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
let remoteIndices = Dictionary(
|
let remoteIndices = Dictionary(
|
||||||
uniqueKeysWithValues: remoteOrder.enumerated().map { ($0.element, $0.offset) }
|
uniqueKeysWithValues: remoteOrder.enumerated().map { ($0.element, $0.offset) }
|
||||||
@@ -1583,17 +1631,19 @@ enum CallSDPOrdering {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum CallSDPNormalization {
|
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 {
|
static func martinParseInput(_ webRTCSDP: String) -> String {
|
||||||
var normalized = webRTCSDP
|
let lfNormalized =
|
||||||
|
webRTCSDP
|
||||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
.replacingOccurrences(of: "\r", 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] = [:]
|
var currentMSIDs: [String: String] = [:]
|
||||||
for source in description.ssrcs {
|
for source in description.ssrcs {
|
||||||
guard let value = source.parameters.first(where: {
|
guard
|
||||||
$0.key.lowercased() == "msid"
|
let value = source.parameters.first(where: {
|
||||||
})?.value, !value.isEmpty else { continue }
|
$0.key.lowercased() == "msid"
|
||||||
|
})?.value, !value.isEmpty
|
||||||
|
else { continue }
|
||||||
currentMSIDs[source.ssrc] = value
|
currentMSIDs[source.ssrc] = value
|
||||||
}
|
}
|
||||||
let desiredMSIDs = canonicalFIDMSIDs(
|
let desiredMSIDs = canonicalFIDMSIDs(
|
||||||
@@ -1647,19 +1699,21 @@ enum CallSDPCompatibility {
|
|||||||
var changed = false
|
var changed = false
|
||||||
let sources = description.ssrcs.map { source -> Jingle.RTP.Description.SSRC in
|
let sources = description.ssrcs.map { source -> Jingle.RTP.Description.SSRC in
|
||||||
guard let desired = desiredMSIDs[source.ssrc],
|
guard let desired = desiredMSIDs[source.ssrc],
|
||||||
currentMSIDs[source.ssrc] != desired else {
|
currentMSIDs[source.ssrc] != desired
|
||||||
|
else {
|
||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
changed = true
|
changed = true
|
||||||
repairedCount += 1
|
repairedCount += 1
|
||||||
let parameters = source.parameters.filter {
|
let parameters =
|
||||||
$0.key.lowercased() != "msid"
|
source.parameters.filter {
|
||||||
} + [
|
$0.key.lowercased() != "msid"
|
||||||
Jingle.RTP.Description.SSRC.Parameter(
|
} + [
|
||||||
key: "msid",
|
Jingle.RTP.Description.SSRC.Parameter(
|
||||||
value: desired
|
key: "msid",
|
||||||
)
|
value: desired
|
||||||
]
|
)
|
||||||
|
]
|
||||||
return Jingle.RTP.Description.SSRC(
|
return Jingle.RTP.Description.SSRC(
|
||||||
ssrc: source.ssrc,
|
ssrc: source.ssrc,
|
||||||
parameters: parameters
|
parameters: parameters
|
||||||
@@ -1756,11 +1810,14 @@ private enum LumaCallError: LocalizedError {
|
|||||||
case .unsupportedByContact:
|
case .unsupportedByContact:
|
||||||
return "Устройства контакта не объявили поддержку совместимых Jingle-звонков."
|
return "Устройства контакта не объявили поддержку совместимых Jingle-звонков."
|
||||||
case .mediaEngineUnavailable:
|
case .mediaEngineUnavailable:
|
||||||
return "WebRTC не смог создать соединение даже без STUN/TURN. Очистите сборку и переустановите приложение."
|
return
|
||||||
|
"WebRTC не смог создать соединение даже без STUN/TURN. Очистите сборку и переустановите приложение."
|
||||||
case .incompatibleLocalDescription:
|
case .incompatibleLocalDescription:
|
||||||
return "WebRTC сформировал несовместимое описание видеопотока. Повторите звонок после перезапуска приложения."
|
return
|
||||||
|
"WebRTC сформировал несовместимое описание видеопотока. Повторите звонок после перезапуска приложения."
|
||||||
case .incompatibleRemoteDescription:
|
case .incompatibleRemoteDescription:
|
||||||
return "Ответ на видеозвонок содержит другой набор аудио/видео потоков. Обновите клиент собеседника или повторите аудиозвонок."
|
return
|
||||||
|
"Ответ на видеозвонок содержит другой набор аудио/видео потоков. Обновите клиент собеседника или повторите аудиозвонок."
|
||||||
case .sdpGenerationFailed:
|
case .sdpGenerationFailed:
|
||||||
return "Не удалось согласовать параметры аудио/видео."
|
return "Не удалось согласовать параметры аудио/видео."
|
||||||
case .connectionLost:
|
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] {
|
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(
|
OMEMODevice(
|
||||||
jid: identity.address.name,
|
jid: identity.address.name,
|
||||||
deviceID: identity.address.deviceId,
|
deviceID: identity.address.deviceId,
|
||||||
fingerprint: identity.fingerprint,
|
fingerprint: identity.fingerprint,
|
||||||
trust: Self.mapTrust(identity.status),
|
trust: Self.mapTrust(identity.status),
|
||||||
isActive: identity.status.isActive,
|
isActive: identity.status.isActive,
|
||||||
isOwn: identity.own
|
isOwn: identity.own,
|
||||||
|
isOMEMO2: published.contains(identity.address.deviceId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sorted { lhs, rhs in lhs.deviceID < rhs.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 {
|
func hasSession(for address: SignalAddress) -> Bool {
|
||||||
sessionAdapter.containsSessionRecord(forAddress: address)
|
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() {
|
func flushPendingPersistence() {
|
||||||
repository.flush()
|
repository.flush()
|
||||||
}
|
}
|
||||||
@@ -544,6 +592,9 @@ private struct StoredOMEMOState: Codable {
|
|||||||
var signedPreKeys: [String: Data]
|
var signedPreKeys: [String: Data]
|
||||||
var sessions: [String: Data]
|
var sessions: [String: Data]
|
||||||
var senderKeys: [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(
|
static let empty = StoredOMEMOState(
|
||||||
registrationID: 0,
|
registrationID: 0,
|
||||||
@@ -552,7 +603,8 @@ private struct StoredOMEMOState: Codable {
|
|||||||
preKeys: [:],
|
preKeys: [:],
|
||||||
signedPreKeys: [:],
|
signedPreKeys: [:],
|
||||||
sessions: [:],
|
sessions: [:],
|
||||||
senderKeys: [:]
|
senderKeys: [:],
|
||||||
|
omemo2DeviceIDs: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Martin
|
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 {
|
final class LumaRoomStore: RoomStore {
|
||||||
typealias Room = RoomBase
|
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