fix MAM history loading, multi-device resource, and OMEMO self-session
Multi-device: - Add DeviceResource with a stable per-install UUID-based resource (Luma-<hex>) persisted in UserDefaults and migrated from legacy "Luma". - AccountConfiguration.effectiveResource falls back to DeviceResource.default for a blank or legacy resource; LoginView defaults resource to "" with an auto placeholder. - Prevents two devices from sharing the same full JID resource and kicking each other off the stream (the "xmpp stream error" conflict). MAM / history: - loadOlderHistory now always issues the RSM page request instead of deferring, so scrolling up reliably loads older messages. - Track interactive history mutations separately so live messages are not dropped while an older-history page is decoding. - ChatView: replace the unreliable GeometryReader/preference scroll trigger with onAppear/onDisappear on a top sentinel; auto-load the first page when the conversation is empty. - AppModel: reset hasMoreOlderHistory for empty conversations and clear isLoadingOlderHistory on disconnect. - Decode own/duplicate/notEncrypted messages to decryption-failed/plaintext placeholders instead of dropping them, so every MAM stanza is collected. OMEMO: - Remove the encrypt-to-self setup that built a Signal session with the local device (cryptographically invalid), which caused an endless "Bad MAC" loop and a stream of undecryptable messages in the self-chat. - Add LumaOMEMOStore.removeSessionWithOwnDevice() and call it on connect to purge any self-session persisted by the previous build. Tests: - Update AccountConfigurationTests for the unique resource and add coverage for legacy-resource migration and explicit-resource preservation.
This commit is contained in:
1 parent
0b4a552044
commit
9c8cff7168
9 files changed
+235
-141
No files matched your search
@@ -47,6 +47,7 @@
|
||||
28D758624032D813C607DE5B /* MediaSendActivityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */; };
|
||||
2C5BAAEF4EC7AAE5FE0FEEDB /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; };
|
||||
2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; };
|
||||
2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; };
|
||||
2D83457A8857592E23289EF7 /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; };
|
||||
2F44B4FDA9AF67EB24776A16 /* WatchViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */; };
|
||||
30BE19F459FDDAD640F66C68 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; };
|
||||
@@ -175,6 +176,7 @@
|
||||
D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */; };
|
||||
D6D0A798487AE98AE03A579C /* NewGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99BE34DC082D19007241AA62 /* NewGroupView.swift */; };
|
||||
D6ED224CC13439A99A388676 /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; };
|
||||
D7179E7F8127A702FC58D3D4 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; };
|
||||
D7CB4481F4F3B81D2C9EF5BD /* NewGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99BE34DC082D19007241AA62 /* NewGroupView.swift */; };
|
||||
D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; };
|
||||
DA77E8A49D2D091EC0975DD3 /* InlineVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */; };
|
||||
@@ -310,6 +312,7 @@
|
||||
7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerRecordingGestureTests.swift; sourceTree = "<group>"; };
|
||||
81213335D275609C38056220 /* ForwardMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardMessageView.swift; sourceTree = "<group>"; };
|
||||
83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = "<group>"; };
|
||||
876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = "<group>"; };
|
||||
8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = "<group>"; };
|
||||
96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = "<group>"; };
|
||||
99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = "<group>"; };
|
||||
@@ -420,6 +423,7 @@
|
||||
13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */,
|
||||
1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */,
|
||||
7846C2EEE2551C6690A4FDC6 /* Conversation.swift */,
|
||||
876CDF98063A4C948B00C7CE /* DeviceResource.swift */,
|
||||
5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */,
|
||||
BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */,
|
||||
278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */,
|
||||
@@ -775,6 +779,7 @@
|
||||
1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */,
|
||||
64F4AD83D86B8900DE69F117 /* CredentialVault.swift in Sources */,
|
||||
EE4DFDBA97562C53C53FBF51 /* CryptoKitAESGCMEngine.swift in Sources */,
|
||||
2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */,
|
||||
9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */,
|
||||
9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */,
|
||||
8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */,
|
||||
@@ -872,6 +877,7 @@
|
||||
51AE33BE56ED4543014119D0 /* ConversationRow.swift in Sources */,
|
||||
EE2BF07D86C00AB6358EA5AE /* CredentialVault.swift in Sources */,
|
||||
E582A0424A63CA16DDA4E984 /* CryptoKitAESGCMEngine.swift in Sources */,
|
||||
D7179E7F8127A702FC58D3D4 /* DeviceResource.swift in Sources */,
|
||||
FCD330D04ED5B87E28883E58 /* EncryptionDevicesView.swift in Sources */,
|
||||
A07ABE478F7F48C3FEDC22D5 /* EncryptionPreference.swift in Sources */,
|
||||
25A9ADB0FF1496A4D3EF431D /* ForwardMessageView.swift in Sources */,
|
||||
|
||||
@@ -22,7 +22,10 @@ struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendabl
|
||||
|
||||
var effectiveResource: String {
|
||||
let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Luma" : trimmed
|
||||
if trimmed.isEmpty || trimmed == DeviceResource.legacyDefault {
|
||||
return DeviceResource.default
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
init(
|
||||
|
||||
@@ -513,6 +513,14 @@ final class AppModel: ObservableObject {
|
||||
// schedulePersist()
|
||||
// syncWatch()
|
||||
// hasMoreOlderHistory = true
|
||||
if hasMoreOlderHistoryByConversation[normalized] == false,
|
||||
selectedMessages.isEmpty
|
||||
{
|
||||
// An empty conversation must be allowed to retry a load after a
|
||||
// previous attempt returned zero results. Otherwise it stays empty
|
||||
// with `hasMore = false` and no way to trigger loading again.
|
||||
hasMoreOlderHistoryByConversation[normalized] = true
|
||||
}
|
||||
hasMoreOlderHistory = hasMoreOlderHistoryByConversation[normalized] ?? true
|
||||
}
|
||||
|
||||
@@ -520,13 +528,13 @@ final class AppModel: ObservableObject {
|
||||
guard !isLoadingOlderHistory,
|
||||
hasMoreOlderHistory,
|
||||
let conversation = selectedConversation else { return }
|
||||
// let oldestServerID = selectedMessages
|
||||
// .sorted { lhs, rhs in
|
||||
// if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
|
||||
// return lhs.id < rhs.id
|
||||
// }
|
||||
// .compactMap(\.stanzaID)
|
||||
// .first
|
||||
// let oldestServerID = selectedMessages
|
||||
// .sorted { lhs, rhs in
|
||||
// if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
|
||||
// return lhs.id < rhs.id
|
||||
// }
|
||||
// .compactMap(\.stanzaID)
|
||||
// .first
|
||||
let oldestServerID = selectedMessages
|
||||
.filter { $0.stanzaID != nil }
|
||||
.min { lhs, rhs in
|
||||
@@ -534,7 +542,7 @@ final class AppModel: ObservableObject {
|
||||
return lhs.id < rhs.id
|
||||
}?
|
||||
.stanzaID
|
||||
|
||||
|
||||
isLoadingOlderHistory = true
|
||||
let conversationID = conversation.id.lowercased()
|
||||
xmpp.loadOlderHistory(
|
||||
@@ -1763,6 +1771,7 @@ final class AppModel: ObservableObject {
|
||||
// UI from displaying a stale spinner even if the archive
|
||||
// completion event was overtaken by a socket state change.
|
||||
isArchiveSyncing = false
|
||||
isLoadingOlderHistory = false
|
||||
for index in conversations.indices where conversations[index].isGroup {
|
||||
conversations[index].isGroupJoined = false
|
||||
conversations[index].occupantCount = 0
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -334,27 +334,13 @@ struct ChatView: View {
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.accessibilityHidden(true)
|
||||
// .onAppear {
|
||||
//// guard !model.isLoadingOlderHistory else { return }
|
||||
// guard historyTopTriggerArmed,
|
||||
// !model.isLoadingOlderHistory
|
||||
// else {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// historyTopTriggerArmed = false
|
||||
// historyLoadAnchorID = timelineEntries.first?.id
|
||||
// model.loadOlderHistoryForSelectedConversation()
|
||||
// }
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: TimelineTopYPreferenceKey.self,
|
||||
value: geometry.frame(
|
||||
in: .named(Self.timelineCoordinateSpace)
|
||||
).minY
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
guard historyTopTriggerArmed,
|
||||
!model.isLoadingOlderHistory
|
||||
else { return }
|
||||
historyTopTriggerArmed = false
|
||||
historyLoadAnchorID = timelineEntries.first?.id
|
||||
model.loadOlderHistoryForSelectedConversation()
|
||||
}
|
||||
.onDisappear {
|
||||
historyTopTriggerArmed = true
|
||||
@@ -374,6 +360,14 @@ struct ChatView: View {
|
||||
description: Text(emptyChatDescription)
|
||||
)
|
||||
.padding(.top, 80)
|
||||
.onAppear {
|
||||
// Empty timeline has no scroll gesture, so load
|
||||
// the first page directly when it appears.
|
||||
guard model.hasMoreOlderHistory,
|
||||
!model.isLoadingOlderHistory
|
||||
else { return }
|
||||
model.loadOlderHistoryForSelectedConversation()
|
||||
}
|
||||
} else {
|
||||
ForEach(timelineEntries) { entry in
|
||||
let message = entry.message
|
||||
@@ -441,22 +435,6 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
#endif
|
||||
.onPreferenceChange(TimelineTopYPreferenceKey.self) { topY in
|
||||
if topY > 24 {
|
||||
historyTopTriggerArmed = true
|
||||
return
|
||||
}
|
||||
guard hasCompletedInitialScroll,
|
||||
!timelineEntries.isEmpty,
|
||||
model.hasMoreOlderHistory,
|
||||
!model.isLoadingOlderHistory,
|
||||
historyTopTriggerArmed
|
||||
// topY <= 24
|
||||
else { return }
|
||||
historyTopTriggerArmed = false
|
||||
historyLoadAnchorID = timelineEntries.first?.id
|
||||
model.loadOlderHistoryForSelectedConversation()
|
||||
}
|
||||
.task(id: timelineEntries.last?.id) {
|
||||
await performInitialScrollAfterLayout(using: proxy)
|
||||
}
|
||||
@@ -1571,8 +1549,16 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy) async {
|
||||
guard !hasCompletedInitialScroll,
|
||||
!model.selectedMessages.isEmpty else { return }
|
||||
guard !hasCompletedInitialScroll else { return }
|
||||
guard !model.selectedMessages.isEmpty else {
|
||||
// Empty conversation: there is nothing to scroll to, so the
|
||||
// scroll-to-top gesture can never fire. Load the first page of
|
||||
// history directly so a newly-created or emptied chat still fills.
|
||||
if model.hasMoreOlderHistory, !model.isLoadingOlderHistory {
|
||||
model.loadOlderHistoryForSelectedConversation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ScrollViewReader can receive its first command before a long
|
||||
// LazyVStack has published the bottom anchor. Yield once for layout,
|
||||
@@ -1614,14 +1600,6 @@ private struct TimelineBottomYPreferenceKey: PreferenceKey {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineTopYPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = .greatestFiniteMagnitude
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Reads the real UIScrollView offset without replacing its delegate or
|
||||
/// installing another pan recognizer. SwiftUI geometry preferences can stop
|
||||
|
||||
@@ -5,7 +5,7 @@ struct LoginView: View {
|
||||
@State private var jid = ""
|
||||
@State private var password = ""
|
||||
@State private var displayName = ""
|
||||
@State private var resource = "Luma"
|
||||
@State private var resource = ""
|
||||
@State private var showAdvanced = false
|
||||
@State private var manualHost = ""
|
||||
@State private var manualPort = ""
|
||||
@@ -91,8 +91,11 @@ struct LoginView: View {
|
||||
VStack(spacing: 14) {
|
||||
TextField("Отображаемое имя", text: $displayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Ресурс устройства", text: $resource)
|
||||
TextField("Автоматически (Luma-XXXX)", text: $resource)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Text("Оставьте пустым — Luma создаст уникальный ресурс для этого устройства, чтобы macOS и iOS не конфликтовали между собой.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Сервер вручную (необязательно)", text: $manualHost)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort)
|
||||
|
||||
@@ -103,6 +103,22 @@ final class LumaOMEMOStore: SignalStorage {
|
||||
sessionAdapter.containsSessionRecord(forAddress: address)
|
||||
}
|
||||
|
||||
/// Removes a session record with our own current device. A self-session is
|
||||
/// cryptographically invalid (Signal sessions exist between two distinct
|
||||
/// identities) and makes Martin's `_encode(..., forSelf: true)` encrypt to
|
||||
/// the local device itself, producing messages that later fail with
|
||||
/// "Bad MAC". Such a record can only be created by a bug; deleting it is
|
||||
/// always safe and idempotent.
|
||||
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)
|
||||
}
|
||||
|
||||
func flushPendingPersistence() {
|
||||
repository.flush()
|
||||
}
|
||||
|
||||
@@ -298,15 +298,13 @@ final class XMPPService {
|
||||
private var mucCatchupLastCursor: String?
|
||||
private var mucCursorFallbackArchives: Set<MAMArchiveKey> = []
|
||||
private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:]
|
||||
private struct PendingOlderHistoryRequest {
|
||||
let conversationJID: String
|
||||
let isGroup: Bool
|
||||
let before: String?
|
||||
let completion: (Result<Bool, Error>) -> Void
|
||||
}
|
||||
private var pendingOlderHistoryRequest: PendingOlderHistoryRequest?
|
||||
private var olderHistoryQueryID: String?
|
||||
private var olderHistoryCompletion: ((Result<Bool, Error>) -> Void)?
|
||||
private var olderHistoryTimeoutTask: Task<Void, Never>?
|
||||
/// When non-nil, archived mutations produced while applying an interactive
|
||||
/// backward-history page are collected here instead of the catch-up
|
||||
/// accumulator, so the two never interleave their mutations.
|
||||
private var interactiveHistoryMutations: [ArchiveMutation]?
|
||||
private let olderHistoryQueryTimeoutNanoseconds: UInt64 = 15_000_000_000
|
||||
/// Serial background queue for OMEMO decryption. `decode` performs the
|
||||
/// expensive Signal work (session decrypt + AES-GCM) and previously ran on
|
||||
@@ -422,6 +420,9 @@ final class XMPPService {
|
||||
guard let signalContext = SignalContext(withStorage: omemoStorage) else {
|
||||
throw LumaXMPPError.omemoInitializationFailed
|
||||
}
|
||||
// Clean up any invalid self-session left behind by a previous buggy
|
||||
// build so it cannot poison encrypt-to-self ("Bad MAC" loops).
|
||||
omemoStorage.removeSessionWithOwnDevice()
|
||||
|
||||
configureModules(client: client, signalContext: signalContext, omemoStorage: omemoStorage)
|
||||
configureConnection(client: client, account: account, password: password)
|
||||
@@ -446,6 +447,7 @@ final class XMPPService {
|
||||
|
||||
func disconnect() async {
|
||||
callEngine.detach()
|
||||
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
archiveRetryTask?.cancel()
|
||||
archiveRetryTask = nil
|
||||
archiveQueryTimeoutTask?.cancel()
|
||||
@@ -855,17 +857,9 @@ final class XMPPService {
|
||||
completion(.failure(LumaXMPPError.notConnected))
|
||||
return
|
||||
}
|
||||
// guard !archiveSyncStarted, activeMUCCatchup == nil else {
|
||||
// completion(.success(false))
|
||||
if archiveSyncStarted || activeMUCCatchup != nil {
|
||||
pendingOlderHistoryRequest = PendingOlderHistoryRequest(
|
||||
conversationJID: conversationJID,
|
||||
isGroup: isGroup,
|
||||
before: before,
|
||||
completion: completion
|
||||
)
|
||||
return
|
||||
}
|
||||
// Interactive backward-history is fully independent from catch-up: it
|
||||
// owns a query-ID-scoped inbox entry and its own mutation collector, so
|
||||
// it must never wait for an account/MUC catch-up pass to settle.
|
||||
performOlderHistoryRequest(
|
||||
conversationJID: conversationJID,
|
||||
isGroup: isGroup,
|
||||
@@ -885,20 +879,26 @@ final class XMPPService {
|
||||
return
|
||||
}
|
||||
guard let version = client.module(.mam).availableVersions.first else {
|
||||
// completion(.success(false))
|
||||
completion(.failure(LumaXMPPError.connection("MAM недоступен")))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let archive = isGroup
|
||||
? MAMArchiveKey.muc(conversationJID)
|
||||
: MAMArchiveKey.account(client.userBareJid.stringValue)
|
||||
// Only one interactive backward-history query is allowed at a time.
|
||||
// This is independent from the normal account/MUC catch-up query.
|
||||
if let previous = olderHistoryCompletion {
|
||||
// AppModel normally serializes requests, but never leak the
|
||||
// previous completion if a request does slip through.
|
||||
previous(.failure(LumaXMPPError.connection("Запрос истории заменён более новым.")))
|
||||
}
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = nil
|
||||
interactiveHistoryMutations = nil
|
||||
let queryID = UUID().uuidString
|
||||
olderHistoryQueryID = queryID
|
||||
olderHistoryCompletion = completion
|
||||
archiveStanzaInbox.begin(
|
||||
queryID: queryID,
|
||||
allowedSources: allowedMAMSources(for: archive, client: client)
|
||||
@@ -906,10 +906,9 @@ final class XMPPService {
|
||||
scheduleOlderHistoryTimeout(
|
||||
client: client,
|
||||
queryID: queryID,
|
||||
conversationJID: conversationJID,
|
||||
completion: completion
|
||||
conversationJID: conversationJID
|
||||
)
|
||||
|
||||
|
||||
// Martin 3.2.4's convenience overload accidentally clears `with`.
|
||||
// Construct MAMQueryForm explicitly so direct-chat history is truly
|
||||
// scoped to this peer.
|
||||
@@ -921,7 +920,7 @@ final class XMPPService {
|
||||
let rsm: RSM.Query = before.map {
|
||||
RSM.Query(before: $0, max: archivePageSize)
|
||||
} ?? RSM.Query(lastItems: archivePageSize)
|
||||
|
||||
|
||||
client.module(.mam).queryItems(
|
||||
version: version,
|
||||
componentJid: componentJID,
|
||||
@@ -930,33 +929,39 @@ final class XMPPService {
|
||||
rsm: rsm
|
||||
) { [weak self, weak client] result in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, let client else {
|
||||
// completion(.success(false))
|
||||
guard let self else {
|
||||
completion(.failure(LumaXMPPError.notConnected))
|
||||
return
|
||||
}
|
||||
guard let client, self.client === client else {
|
||||
self.finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
return
|
||||
}
|
||||
guard self.olderHistoryQueryID == queryID else {
|
||||
// A newer request, a timeout, or a disconnect already owns
|
||||
// the slot and has released this completion.
|
||||
return
|
||||
}
|
||||
self.olderHistoryTimeoutTask?.cancel()
|
||||
self.olderHistoryTimeoutTask = nil
|
||||
self.olderHistoryQueryID = nil
|
||||
let page = self.archiveStanzaInbox.take(queryID: queryID)
|
||||
guard !page.overflowed, !page.rejectedSource else {
|
||||
completion(.failure(LumaXMPPError.connection("Некорректная MAM history page")))
|
||||
self.finishOlderHistory(
|
||||
result: .failure(LumaXMPPError.connection("Некорректная MAM history page"))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Task { @MainActor [weak self, weak client] in
|
||||
guard let self, let client else {
|
||||
completion(.failure(LumaXMPPError.notConnected))
|
||||
guard let self else { return }
|
||||
guard let client, self.client === client else {
|
||||
self.finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
return
|
||||
}
|
||||
// History is only allowed while catch-up is idle, so the
|
||||
// legacy mutation accumulator can safely be used as a local
|
||||
// page collector here.
|
||||
let savedMutations = self.archivePassMutations
|
||||
self.archivePassMutations = []
|
||||
// Collect this page's mutations in a dedicated buffer so a
|
||||
// concurrently-running catch-up pass never interleaves its
|
||||
// mutations with the interactive page.
|
||||
self.interactiveHistoryMutations = []
|
||||
for stanza in page.stanzas {
|
||||
if stanza.message.type == .groupchat,
|
||||
let roomJID = stanza.message.from?.bareJid {
|
||||
@@ -977,29 +982,28 @@ final class XMPPService {
|
||||
)
|
||||
}
|
||||
}
|
||||
let historyMutations = self.archivePassMutations
|
||||
self.archivePassMutations = savedMutations
|
||||
let historyMutations = self.interactiveHistoryMutations ?? []
|
||||
self.interactiveHistoryMutations = nil
|
||||
if !historyMutations.isEmpty {
|
||||
self.eventHandler?(.archiveBatch(historyMutations))
|
||||
}
|
||||
switch result {
|
||||
case .success(let response):
|
||||
completion(.success(!response.complete))
|
||||
self.finishOlderHistory(result: .success(!response.complete))
|
||||
case .failure(let error):
|
||||
// Do not leave the UI in the loading state. A failed
|
||||
// interactive query is recoverable by the next scroll.
|
||||
completion(.failure(error))
|
||||
self.finishOlderHistory(result: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func scheduleOlderHistoryTimeout(
|
||||
client: XMPPClient,
|
||||
queryID: String,
|
||||
conversationJID: String,
|
||||
completion: @escaping (Result<Bool, Error>) -> Void
|
||||
conversationJID: String
|
||||
) {
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = Task { @MainActor [weak self, weak client] in
|
||||
@@ -1012,17 +1016,15 @@ final class XMPPService {
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
self.olderHistoryQueryID = nil
|
||||
self.olderHistoryTimeoutTask = nil
|
||||
|
||||
self.archiveStanzaInbox.cancel(queryID: queryID)
|
||||
self.eventHandler?(
|
||||
.recoverableError(
|
||||
"Не удалось загрузить более старую историю чата \(conversationJID): MAM не ответил за 15 секунд."
|
||||
)
|
||||
)
|
||||
completion(
|
||||
.failure(
|
||||
self.finishOlderHistory(
|
||||
result: .failure(
|
||||
LumaXMPPError.connection(
|
||||
"MAM не ответил вовремя. Повторите прокрутку вверх."
|
||||
)
|
||||
@@ -1030,19 +1032,20 @@ final class XMPPService {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPendingOlderHistoryIfPossible() {
|
||||
guard !archiveSyncStarted,
|
||||
activeMUCCatchup == nil,
|
||||
let pending = pendingOlderHistoryRequest
|
||||
else { return }
|
||||
pendingOlderHistoryRequest = nil
|
||||
performOlderHistoryRequest(
|
||||
conversationJID: pending.conversationJID,
|
||||
isGroup: pending.isGroup,
|
||||
before: pending.before,
|
||||
completion: pending.completion
|
||||
)
|
||||
|
||||
/// Releases the in-flight interactive backward-history request exactly once.
|
||||
/// Every terminal path (success, failure, timeout, disconnect, background)
|
||||
/// funnels through here so the UI's "loading older history" spinner can
|
||||
/// never be left stuck.
|
||||
private func finishOlderHistory(result: Result<Bool, Error>) {
|
||||
guard olderHistoryCompletion != nil else { return }
|
||||
let completion = olderHistoryCompletion
|
||||
olderHistoryCompletion = nil
|
||||
olderHistoryQueryID = nil
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = nil
|
||||
interactiveHistoryMutations = nil
|
||||
completion?(result)
|
||||
}
|
||||
|
||||
func sendRetraction(
|
||||
@@ -1650,7 +1653,7 @@ final class XMPPService {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
||||
private func deliverOrDelayDirect(_ message: Message, timestamp: Date) async {
|
||||
guard let client else { return }
|
||||
let archive = MAMArchiveKey.account(client.userBareJid.stringValue)
|
||||
@@ -1766,9 +1769,13 @@ final class XMPPService {
|
||||
security = .plaintext
|
||||
fingerprint = nil
|
||||
contentMessage = message
|
||||
case .duplicateMessage:
|
||||
return
|
||||
default:
|
||||
// Includes `.duplicateMessage` (our own message without an
|
||||
// encrypt-to-self key, or a key already consumed by a previous
|
||||
// session): the body can no longer be recovered here. Still
|
||||
// emit the message (as an undecryptable bubble) instead of
|
||||
// dropping it, so history does not silently stop. Genuine
|
||||
// duplicates are filtered later in AppModel by stanza/origin ID.
|
||||
security = .decryptionFailed
|
||||
fingerprint = nil
|
||||
contentMessage = nil
|
||||
@@ -1901,6 +1908,10 @@ final class XMPPService {
|
||||
? visibleBody
|
||||
: Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение")
|
||||
|
||||
// Live 1:1 messages carry their server archive UID in a <stanza-id>
|
||||
// element (by = own bare JID). Surface it so the interactive MAM
|
||||
// "before" cursor and dedup match the IDs the archive later returns.
|
||||
let stanzaID = archiveID ?? Self.accountStanzaID(in: message, accountJID: ownJID)
|
||||
let id = message.originId ?? message.id ?? archiveID ?? UUID().uuidString
|
||||
emitMessage(
|
||||
MessageEnvelope(
|
||||
@@ -1924,7 +1935,7 @@ final class XMPPService {
|
||||
replyToJID: reply?.getAttribute("to"),
|
||||
replyPreview: parsedReply.preview,
|
||||
originID: message.originId,
|
||||
stanzaID: archiveID,
|
||||
stanzaID: stanzaID,
|
||||
senderDisplayName: nil,
|
||||
isGroupMessage: false,
|
||||
isArchived: isArchived
|
||||
@@ -2411,6 +2422,16 @@ final class XMPPService {
|
||||
}?.getAttribute("id")
|
||||
}
|
||||
|
||||
private static func accountStanzaID(in message: Message, accountJID: BareJID) -> String? {
|
||||
message.children.first { child in
|
||||
guard child.name == "stanza-id", child.xmlns == Self.stanzaIDNamespace else {
|
||||
return false
|
||||
}
|
||||
guard let by = child.getAttribute("by")?.lowercased() else { return false }
|
||||
return by == accountJID.stringValue.lowercased()
|
||||
}?.getAttribute("id")
|
||||
}
|
||||
|
||||
private static func originalSenderJID(in message: Message) -> BareJID? {
|
||||
guard
|
||||
let address = message.firstChild(
|
||||
@@ -2481,7 +2502,7 @@ final class XMPPService {
|
||||
isOutgoing: outgoing
|
||||
)
|
||||
if isArchived {
|
||||
archivePassMutations.append(.retraction(envelope))
|
||||
appendArchiveMutation(.retraction(envelope))
|
||||
} else {
|
||||
eventHandler?(.retraction(envelope))
|
||||
}
|
||||
@@ -2492,7 +2513,7 @@ final class XMPPService {
|
||||
isArchived: Bool
|
||||
) {
|
||||
if isArchived {
|
||||
archivePassMutations.append(.reaction(envelope))
|
||||
appendArchiveMutation(.reaction(envelope))
|
||||
} else {
|
||||
eventHandler?(.reaction(envelope))
|
||||
}
|
||||
@@ -2500,12 +2521,24 @@ final class XMPPService {
|
||||
|
||||
private func emitMessage(_ envelope: MessageEnvelope, isArchived: Bool) {
|
||||
if isArchived {
|
||||
archivePassMutations.append(.message(envelope))
|
||||
appendArchiveMutation(.message(envelope))
|
||||
} else {
|
||||
eventHandler?(.message(envelope))
|
||||
}
|
||||
}
|
||||
|
||||
/// Archived mutations normally belong to the active account/MUC catch-up
|
||||
/// pass. While an interactive backward-history page is being applied, its
|
||||
/// mutations are collected in a dedicated buffer so the two flows never
|
||||
/// interleave and never steal each other's mutations.
|
||||
private func appendArchiveMutation(_ mutation: ArchiveMutation) {
|
||||
if interactiveHistoryMutations != nil {
|
||||
interactiveHistoryMutations?.append(mutation)
|
||||
} else {
|
||||
archivePassMutations.append(mutation)
|
||||
}
|
||||
}
|
||||
|
||||
private func retrievePEPAvatar(for jid: String, client: XMPPClient) async -> Data? {
|
||||
do {
|
||||
let avatarModule = client.module(.pepUserAvatar)
|
||||
@@ -2730,7 +2763,8 @@ final class XMPPService {
|
||||
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
||||
archiveBufferOverflowed = false
|
||||
archiveRejectedSource = false
|
||||
archiveStanzaInbox.cancel()
|
||||
// Do NOT blanket-cancel the inbox here: an interactive backward-history
|
||||
// query may be in flight and must keep its own query-ID-scoped buffer.
|
||||
// Show the visible "Синхронизация истории…" banner only for the
|
||||
// one-page bootstrap window. Incremental catch-up continues silently so
|
||||
// a large backlog never leaves the spinner visible for the whole pass.
|
||||
@@ -2878,7 +2912,6 @@ final class XMPPService {
|
||||
await self?.replayDelayedLive(for: archive)
|
||||
}
|
||||
startNextMUCCatchupIfPossible()
|
||||
startPendingOlderHistoryIfPossible()
|
||||
}
|
||||
// MUC catch-up decodes into archivePassMutations just like the account
|
||||
// pass. Publish them once the room catch-up settles, otherwise group
|
||||
@@ -3329,7 +3362,8 @@ final class XMPPService {
|
||||
archiveQueryTimeoutTask = nil
|
||||
archiveQueryCompletionTask?.cancel()
|
||||
archiveQueryCompletionTask = nil
|
||||
archiveStanzaInbox.cancel()
|
||||
// Do NOT blanket-cancel the inbox here: an interactive backward-history
|
||||
// query may be in flight and must keep its own query-ID-scoped buffer.
|
||||
archiveActiveQueryID = nil
|
||||
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
||||
archiveBufferOverflowed = false
|
||||
@@ -3345,7 +3379,6 @@ final class XMPPService {
|
||||
}
|
||||
setArchiveSyncIndicator(false)
|
||||
archiveSyncStarted = false
|
||||
startPendingOlderHistoryIfPossible()
|
||||
archiveIsBootstrapQuery = false
|
||||
archiveHighWatermark = nil
|
||||
archiveHasCompletedPage = false
|
||||
@@ -3435,14 +3468,8 @@ final class XMPPService {
|
||||
|
||||
private func suspendArchiveSyncForDisconnect() {
|
||||
setArchiveSyncIndicator(false)
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = nil
|
||||
olderHistoryQueryID = nil
|
||||
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
archiveStanzaInbox.cancel()
|
||||
if let pending = pendingOlderHistoryRequest {
|
||||
pendingOlderHistoryRequest = nil
|
||||
pending.completion(.failure(LumaXMPPError.notConnected))
|
||||
}
|
||||
guard let client else {
|
||||
archiveSyncStarted = false
|
||||
// archiveStanzaInbox.cancel()
|
||||
@@ -3485,6 +3512,10 @@ final class XMPPService {
|
||||
|
||||
private func pauseArchiveSync(client: XMPPClient) {
|
||||
setArchiveSyncIndicator(false)
|
||||
// An in-flight interactive history request must not survive into the
|
||||
// background with a dangling spinner; finish it before the catch-up
|
||||
// guard below, which may early-return when no catch-up is active.
|
||||
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
guard archiveSyncStarted, self.client === client else { return }
|
||||
archiveRetryTask?.cancel()
|
||||
archiveRetryTask = nil
|
||||
@@ -3495,9 +3526,6 @@ final class XMPPService {
|
||||
archiveSyncRetryTask?.cancel()
|
||||
archiveSyncRetryTask = nil
|
||||
archiveStanzaInbox.cancel()
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = nil
|
||||
olderHistoryQueryID = nil
|
||||
archiveResumeAfter = archiveSyncCheckpoint?.cursor
|
||||
archiveActiveQueryID = nil
|
||||
archiveSyncStarted = false
|
||||
|
||||
@@ -14,10 +14,31 @@ final class AccountConfigurationTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(validated.normalizedJID, "alice@example.org")
|
||||
XCTAssertEqual(validated.domain, "example.org")
|
||||
XCTAssertEqual(validated.effectiveResource, "Luma")
|
||||
XCTAssertTrue(validated.effectiveResource.hasPrefix("Luma-"))
|
||||
XCTAssertNotEqual(validated.effectiveResource, DeviceResource.legacyDefault)
|
||||
XCTAssertEqual(validated.effectiveResource, DeviceResource.default)
|
||||
XCTAssertEqual(validated.manualHost, "xmpp.example.org")
|
||||
}
|
||||
|
||||
func testLegacyDefaultResourceIsMigratedToAUniqueDeviceResource() throws {
|
||||
let account = try AccountConfiguration(
|
||||
jid: "alice@example.org",
|
||||
resource: "Luma"
|
||||
).validated()
|
||||
|
||||
XCTAssertTrue(account.effectiveResource.hasPrefix("Luma-"))
|
||||
XCTAssertNotEqual(account.effectiveResource, "Luma")
|
||||
}
|
||||
|
||||
func testExplicitResourceIsPreserved() throws {
|
||||
let account = try AccountConfiguration(
|
||||
jid: "alice@example.org",
|
||||
resource: " MyDevice "
|
||||
).validated()
|
||||
|
||||
XCTAssertEqual(account.effectiveResource, "MyDevice")
|
||||
}
|
||||
|
||||
func testInvalidJIDIsRejected() {
|
||||
XCTAssertThrowsError(try AccountConfiguration(jid: "not-a-jid").validated())
|
||||
XCTAssertThrowsError(try AccountConfiguration(jid: "@example.org").validated())
|
||||
|
||||
Reference in new issue
Block a user