diff --git a/Yattee/Services/CloudKit/AccountSyncCoordinator.swift b/Yattee/Services/CloudKit/AccountSyncCoordinator.swift index ded2929d..dd60185e 100644 --- a/Yattee/Services/CloudKit/AccountSyncCoordinator.swift +++ b/Yattee/Services/CloudKit/AccountSyncCoordinator.swift @@ -612,7 +612,11 @@ final class AccountSyncCoordinator { return mapper.toCKRecord(accountDeletionID: accountID) case .dataContext: - guard accountsManager.canUploadContext else { return nil } + // The pending flag, not just the ordering: correcting away from a + // deleted account clears it deliberately, and sending that + // automatic fallback would publish it under the deleted choice's + // timestamp — over a deliberate choice made elsewhere. + guard accountsManager.pendingContextUpload, accountsManager.canUploadContext else { return nil } return mapper.toCKRecord(dataContext: accountsManager.storedContext) case nil: @@ -623,13 +627,13 @@ final class AccountSyncCoordinator { /// Clears outbox entries the server has confirmed, and releases whatever was /// waiting on them. func didSave(records: [CKRecord]) { - var didConfirmAccount = false + var didSaveAccountRecord = false for record in records { switch AccountRecordName.parse(record.recordID.recordName) { case let .account(id): accountsManager.confirmUpload(id, updatedAt: record["updatedAt"] as? Date) - didConfirmAccount = true + didSaveAccountRecord = true case let .legacyMigration(instanceID): accountsManager.confirmMarkerUpload(instanceID: instanceID) @@ -644,14 +648,17 @@ final class AccountSyncCoordinator { if let changedAt = record["changedAt"] as? Date { accountsManager.confirmContextUpload(changedAt: changedAt) } + didSaveAccountRecord = true case nil: break } } - // A marker or a context held behind one of these accounts can go now. - if didConfirmAccount { + // Whatever was held behind these can go now — a marker waiting on its + // account, or a context the user changed while the previous one was + // still in flight, which confirmation deliberately left dirty. + if didSaveAccountRecord { enqueueDirtyRecords() } } diff --git a/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift b/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift index b7deff17..812ed57c 100644 --- a/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift @@ -27,7 +27,13 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { /// Records the outcome of a server call against the account. The /// classification lives in one place so the feed cache, which talks to the /// same servers without going through a provider, reaches the same verdict. - private func recordOutcome(_ error: Error?) { + private func recordOutcome(_ error: Error?, credential: String) { + // Only for the secret the request actually used. Signing in again — or + // a switch of Apple account — replaces it under the same key while a + // request is still out, and a late 401 would otherwise reject the + // replacement, or a late success clear a rejection it never earned. + guard credentialsManager.credential(for: account) == credential else { return } + accountsManager.recordServerOutcome(error, for: account) } private let settingsManager: SettingsManager @@ -63,7 +69,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { let (instance, sid) = try await getAuthenticatedInstance() // Fetch subscriptions from Invidious - let subscriptions = try await tracking { + let subscriptions = try await tracking(credential: sid) { try await invidiousAPI.subscriptions(instance: instance, sid: sid) } @@ -81,7 +87,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { let (instance, sid) = try await getAuthenticatedInstance() // Subscribe on Invidious server - try await tracking { + try await tracking(credential: sid) { try await invidiousAPI.subscribe(to: channel.id.channelID, instance: instance, sid: sid) } @@ -100,7 +106,7 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { // Unsubscribe on Invidious server. Tracked like every other // authenticated call: a rejected credential noticed here must mark the // account, or it keeps reporting itself as signed in. - try await tracking { + try await tracking(credential: sid) { try await invidiousAPI.unsubscribe(from: channelID, instance: instance, sid: sid) } @@ -137,13 +143,13 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { /// Gets the authenticated Invidious instance and session ID from account settings. /// Runs a server call and records what it implies about the credential. - private func tracking(_ operation: () async throws -> T) async rethrows -> T { + private func tracking(credential: String, _ operation: () async throws -> T) async rethrows -> T { do { let value = try await operation() - recordOutcome(nil) + recordOutcome(nil, credential: credential) return value } catch { - recordOutcome(error) + recordOutcome(error, credential: credential) throw error } } diff --git a/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift b/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift index 3dcfe252..01128e4d 100644 --- a/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift @@ -27,7 +27,13 @@ final class PipedSubscriptionProvider: SubscriptionProvider { /// Records the outcome of a server call against the account. The /// classification lives in one place so the feed cache, which talks to the /// same servers without going through a provider, reaches the same verdict. - private func recordOutcome(_ error: Error?) { + private func recordOutcome(_ error: Error?, credential: String) { + // Only for the secret the request actually used. Signing in again — or + // a switch of Apple account — replaces it under the same key while a + // request is still out, and a late 401 would otherwise reject the + // replacement, or a late success clear a rejection it never earned. + guard credentialsManager.credential(for: account) == credential else { return } + accountsManager.recordServerOutcome(error, for: account) } private let settingsManager: SettingsManager @@ -63,7 +69,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider { let (instance, authToken) = try await getAuthenticatedInstance() // Fetch subscriptions from Piped - let subscriptions = try await tracking { + let subscriptions = try await tracking(credential: authToken) { try await pipedAPI.subscriptions(instance: instance, authToken: authToken) } @@ -83,7 +89,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider { // Subscribe on Piped server. Tracked like every other authenticated // call: a rejected credential noticed here must mark the account, or it // keeps reporting itself as signed in. - try await tracking { + try await tracking(credential: authToken) { try await pipedAPI.subscribe(channelID: channel.id.channelID, instance: instance, authToken: authToken) } @@ -100,7 +106,7 @@ final class PipedSubscriptionProvider: SubscriptionProvider { let (instance, authToken) = try await getAuthenticatedInstance() // Unsubscribe on Piped server - try await tracking { + try await tracking(credential: authToken) { try await pipedAPI.unsubscribe(channelID: channelID, instance: instance, authToken: authToken) } @@ -137,13 +143,13 @@ final class PipedSubscriptionProvider: SubscriptionProvider { /// Gets the authenticated Piped instance and auth token from account settings. /// Runs a server call and records what it implies about the credential. - private func tracking(_ operation: () async throws -> T) async rethrows -> T { + private func tracking(credential: String, _ operation: () async throws -> T) async rethrows -> T { do { let value = try await operation() - recordOutcome(nil) + recordOutcome(nil, credential: credential) return value } catch { - recordOutcome(error) + recordOutcome(error, credential: credential) throw error } }