fix MAM history loading, multi-device resource, and OMEMO self-session
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

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:
wt committed 2026-08-18 02:41:52 +07:00
1 parent 0b4a552044
commit 9c8cff7168
9 files changed
+235 -141

No files matched your search

+6
View File
@@ -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(
+17 -8
View File
@@ -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
}
}
+25 -47
View File
@@ -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 -2
View File
@@ -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)
+16
View File
@@ -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()
}
+110 -82
View File
@@ -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
+22 -1
View File
@@ -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())