From 9c8cff71688578b263ed5a70c51c26ffff6010a6 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Tue, 18 Aug 2026 02:41:52 +0700 Subject: [PATCH] fix MAM history loading, multi-device resource, and OMEMO self-session Multi-device: - Add DeviceResource with a stable per-install UUID-based resource (Luma-) 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. --- Luma.xcodeproj/project.pbxproj | 6 + .../Shared/Models/AccountConfiguration.swift | 5 +- Sources/Shared/Models/AppModel.swift | 25 ++- Sources/Shared/Models/DeviceResource.swift | 30 +++ Sources/Shared/UI/ChatView.swift | 72 +++---- Sources/Shared/UI/LoginView.swift | 7 +- Sources/Shared/XMPP/LumaOMEMOStore.swift | 16 ++ Sources/Shared/XMPP/XMPPService.swift | 192 ++++++++++-------- Tests/AccountConfigurationTests.swift | 23 ++- 9 files changed, 235 insertions(+), 141 deletions(-) create mode 100644 Sources/Shared/Models/DeviceResource.swift diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 54a358e..56aeb08 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -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 = ""; }; 81213335D275609C38056220 /* ForwardMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardMessageView.swift; sourceTree = ""; }; 83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = ""; }; + 876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = ""; }; 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = ""; }; 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = ""; }; 99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Sources/Shared/Models/AccountConfiguration.swift b/Sources/Shared/Models/AccountConfiguration.swift index d558660..429e575 100644 --- a/Sources/Shared/Models/AccountConfiguration.swift +++ b/Sources/Shared/Models/AccountConfiguration.swift @@ -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( diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index 1597c7e..b411e25 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -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 diff --git a/Sources/Shared/Models/DeviceResource.swift b/Sources/Shared/Models/DeviceResource.swift new file mode 100644 index 0000000..3cb7dcb --- /dev/null +++ b/Sources/Shared/Models/DeviceResource.swift @@ -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 + } +} diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index 9d2d532..457af34 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -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 diff --git a/Sources/Shared/UI/LoginView.swift b/Sources/Shared/UI/LoginView.swift index 2d1adb5..3fb1f50 100644 --- a/Sources/Shared/UI/LoginView.swift +++ b/Sources/Shared/UI/LoginView.swift @@ -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) diff --git a/Sources/Shared/XMPP/LumaOMEMOStore.swift b/Sources/Shared/XMPP/LumaOMEMOStore.swift index 155309d..6a2b4ea 100644 --- a/Sources/Shared/XMPP/LumaOMEMOStore.swift +++ b/Sources/Shared/XMPP/LumaOMEMOStore.swift @@ -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() } diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index c9e7db5..c001492 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -298,15 +298,13 @@ final class XMPPService { private var mucCatchupLastCursor: String? private var mucCursorFallbackArchives: Set = [] private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:] - private struct PendingOlderHistoryRequest { - let conversationJID: String - let isGroup: Bool - let before: String? - let completion: (Result) -> Void - } - private var pendingOlderHistoryRequest: PendingOlderHistoryRequest? private var olderHistoryQueryID: String? + private var olderHistoryCompletion: ((Result) -> Void)? private var olderHistoryTimeoutTask: Task? + /// 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) -> 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) { + 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 + // 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 diff --git a/Tests/AccountConfigurationTests.swift b/Tests/AccountConfigurationTests.swift index 42a7487..af3f951 100644 --- a/Tests/AccountConfigurationTests.swift +++ b/Tests/AccountConfigurationTests.swift @@ -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())