Files
yattee/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift
Arkadiusz Fal 8ee0e38fbd Tie verdicts and context uploads to what asked for them
A server outcome is recorded 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 — so a late 401 could reject
the replacement, and a late success could clear a rejection it never
earned.

The context record is built only while an upload is actually owed.
Correcting away from a deleted account clears that flag deliberately;
without checking it, the engine could still materialize the automatic
fallback and publish it under the deleted choice's timestamp, over a
deliberate choice made elsewhere.

Confirming a context upload re-runs the outbox like an account
confirmation does. Choosing a different context while the previous one
was in flight leaves the new one dirty by design, and nothing was left
to send it.
2026-08-28 23:39:00 +02:00

172 lines
6.5 KiB
Swift

//
// PipedSubscriptionProvider.swift
// Yattee
//
// Piped subscription provider that manages subscriptions on a Piped instance.
// Uses in-memory cache only - does NOT persist to SwiftData to avoid corrupting local subscriptions.
//
import Foundation
/// Piped subscription provider that syncs with a Piped instance account.
/// Subscriptions are managed on the Piped server and kept in memory only.
/// This provider does NOT write to SwiftData to preserve local subscriptions.
@MainActor
final class PipedSubscriptionProvider: SubscriptionProvider {
// MARK: - Properties
/// The account this provider is bound to. One provider per account, so two
/// logins on the same instance never share a subscription cache.
let account: Account
var context: DataContext { .account(account.id) }
private let pipedAPI: PipedAPI
private let credentialsManager: PipedCredentialsManager
private let accountsManager: AccountsManager
/// 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?, 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
/// In-memory cache of subscribed channel IDs for fast lookup.
private var cachedChannelIDs: Set<String> = []
/// In-memory cache of full channel data for display.
private var cachedChannels: [Channel] = []
/// Whether the cache has been populated from the server.
private var cachePopulated = false
// MARK: - Initialization
init(
account: Account,
pipedAPI: PipedAPI,
credentialsManager: PipedCredentialsManager,
accountsManager: AccountsManager,
settingsManager: SettingsManager
) {
self.account = account
self.pipedAPI = pipedAPI
self.credentialsManager = credentialsManager
self.accountsManager = accountsManager
self.settingsManager = settingsManager
}
// MARK: - SubscriptionProvider
func fetchSubscriptions() async throws -> [Channel] {
let (instance, authToken) = try await getAuthenticatedInstance()
// Fetch subscriptions from Piped
let subscriptions = try await tracking(credential: authToken) {
try await pipedAPI.subscriptions(instance: instance, authToken: authToken)
}
// Update in-memory cache
cachedChannelIDs = Set(subscriptions.map(\.channelId))
cachedChannels = subscriptions.map { $0.toChannel() }
cachePopulated = true
LoggingService.shared.info("Fetched \(subscriptions.count) Piped subscriptions", category: .api)
return cachedChannels
}
func subscribe(to channel: Channel) async throws {
let (instance, authToken) = try await getAuthenticatedInstance()
// 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(credential: authToken) {
try await pipedAPI.subscribe(channelID: channel.id.channelID, instance: instance, authToken: authToken)
}
// Update in-memory cache only
cachedChannelIDs.insert(channel.id.channelID)
if !cachedChannels.contains(where: { $0.id.channelID == channel.id.channelID }) {
cachedChannels.append(channel)
}
LoggingService.shared.info("Subscribed to \(channel.name) on Piped", category: .api)
}
func unsubscribe(from channelID: String) async throws {
let (instance, authToken) = try await getAuthenticatedInstance()
// Unsubscribe on Piped server
try await tracking(credential: authToken) {
try await pipedAPI.unsubscribe(channelID: channelID, instance: instance, authToken: authToken)
}
// Update in-memory cache only
cachedChannelIDs.remove(channelID)
cachedChannels.removeAll { $0.id.channelID == channelID }
LoggingService.shared.info("Unsubscribed from \(channelID) on Piped", category: .api)
}
func isSubscribed(to channelID: String) async -> Bool {
// If cache isn't populated yet, try to refresh it
if !cachePopulated {
do {
_ = try await fetchSubscriptions()
} catch {
// Can't determine subscription status without server connection
return false
}
}
return cachedChannelIDs.contains(channelID)
}
func refreshCache() async throws {
// Simply fetch subscriptions again - this updates in-memory cache
_ = try await fetchSubscriptions()
}
var cachedSubscriptionCount: Int? {
cachePopulated ? cachedChannels.count : nil
}
// MARK: - Private Helpers
/// 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<T>(credential: String, _ operation: () async throws -> T) async rethrows -> T {
do {
let value = try await operation()
recordOutcome(nil, credential: credential)
return value
} catch {
recordOutcome(error, credential: credential)
throw error
}
}
private func getAuthenticatedInstance() async throws -> (Instance, String) {
// The account names its instance directly - no falling back to
// "some enabled Piped instance", which could silently send one
// account's request to a server it has no credential for.
guard let instance = accountsManager.instance(for: account), instance.isEnabled else {
throw SubscriptionProviderError.instanceNotConfigured
}
guard let authToken = credentialsManager.credential(for: account) else {
throw SubscriptionProviderError.notAuthenticated
}
return (instance, authToken)
}
}