mirror of
https://github.com/yattee/yattee.git
synced 2026-10-31 00:47:17 +00:00
Add Account model, AccountsManager and multi-login UI
Yattee 2 could hold at most one login per instance: a Keychain item keyed by the instance UUID and a Bool on the instance. This adds the account layer the rest of the revamp builds on. DataContext (.local or .account) replaces SubscriptionAccount as the thing that selects a data set. The legacy value maps onto it as an identity mapping, because migrated accounts deliberately reuse their instance UUID. Contexts carry an app-managed changedAt, and a migrated one is stamped at the epoch so any explicit choice on any device outranks it whatever order they arrive in. AccountCapabilities makes what a backend supports a single switch in InstanceType.accountCapabilities, verified against each API surface - Invidious has history and playlist privacy, Piped has neither. Call sites ask for capabilities instead of switching on instance type, which is the seam the planned per-user Yattee Server accounts land on. AccountsManager owns accounts and the selected context, stored per identity scope so a switch of Apple Account cannot show or upload the previous one's list. Legacy single-logins become accounts through a repeatable reconciliation rather than a one-shot at launch, because instances arrive over iCloud whenever they arrive; a permanent marker set is what makes re-running safe, since usesAccountLogin stays true on the instance forever and would otherwise recreate a deliberately deleted account. A dirty-flag outbox records what still needs uploading so an offline change survives a crash. The legacy subscriptionAccount selection is migrated one way, reading local defaults first and then the raw KVS value - the previous release published that key to iCloud but never read it back, so on a fresh install it exists only there. DataContextValidator replaces SubscriptionAccountValidator, which corrected any selection it could not immediately verify. That was safe when a context was just "an instance I am logged into", but with credentials arriving asynchronously it would discard the user's choice during a slow Keychain sync. Validity is now tri-state: anything that might still resolve keeps the selection and falls back to local for reads only, with server mutations disabled rather than silently retargeted at local data; only an actual deletion corrects the stored context. EditSourceView's single log in/out pair becomes a list of accounts with per-row status, sign-in-again and sign-out-and-remove, plus the explicit "use saved login from this device" action - the one-tap adoption that replaced automatic adoption after the Keychain provenance measurement. On tvOS a credential-less account offers a local sign-in instead, since Apple never syncs app Keychain items there and the state will not resolve on its own. InstanceLoginView now returns the identifier alongside the credential so the caller can file it in the Keychain beside the secret, keeping login identifiers out of synced metadata.
This commit is contained in:
87
Yattee/Models/AccountCapabilities.swift
Normal file
87
Yattee/Models/AccountCapabilities.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// AccountCapabilities.swift
|
||||
// Yattee
|
||||
//
|
||||
// What a given backend lets a signed-in account do.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// What server-side features an account supports.
|
||||
///
|
||||
/// Call sites ask for capabilities rather than switching on `instance.type`, so
|
||||
/// adding a backend — the planned per-user Yattee Server accounts, say — means
|
||||
/// adding one row in ``InstanceType/accountCapabilities`` instead of hunting
|
||||
/// down every `case .invidious` in the app.
|
||||
struct AccountCapabilities: Hashable, Sendable {
|
||||
var supportsSubscriptions: Bool
|
||||
var supportsFeed: Bool
|
||||
var supportsPlaylistReads: Bool
|
||||
var supportsPlaylistWrites: Bool
|
||||
var supportsWatchHistory: Bool
|
||||
/// Whether playlists carry a visibility setting (public/unlisted/private).
|
||||
var supportsPlaylistPrivacy: Bool
|
||||
|
||||
static let none = AccountCapabilities(
|
||||
supportsSubscriptions: false,
|
||||
supportsFeed: false,
|
||||
supportsPlaylistReads: false,
|
||||
supportsPlaylistWrites: false,
|
||||
supportsWatchHistory: false,
|
||||
supportsPlaylistPrivacy: false
|
||||
)
|
||||
|
||||
/// Whether this account can act as a `DataContext` at all. An account with
|
||||
/// nothing to offer is never listed as a switch target.
|
||||
var isSelectableContext: Bool {
|
||||
supportsSubscriptions || supportsPlaylistReads
|
||||
}
|
||||
}
|
||||
|
||||
extension InstanceType {
|
||||
/// **The** capability switch. Verified against each backend's API surface.
|
||||
///
|
||||
/// Adding a case here is the whole job of supporting a new account type;
|
||||
/// nothing else in the app is allowed to decide what an account can do.
|
||||
/// (Routing to the right API client, picking a credential manager and
|
||||
/// choosing a presentation still switch on `type` legitimately — those are
|
||||
/// about *how* to talk to a server, not what it supports.)
|
||||
var accountCapabilities: AccountCapabilities {
|
||||
switch self {
|
||||
case .invidious:
|
||||
// Full CRUD with privacy; removal by a stable per-entry indexId
|
||||
// token; history is a boolean watched marker.
|
||||
AccountCapabilities(
|
||||
supportsSubscriptions: true,
|
||||
supportsFeed: true,
|
||||
supportsPlaylistReads: true,
|
||||
supportsPlaylistWrites: true,
|
||||
supportsWatchHistory: true,
|
||||
supportsPlaylistPrivacy: true
|
||||
)
|
||||
|
||||
case .piped:
|
||||
// Full CRUD but no privacy setting, removal is positional, and
|
||||
// there is no history endpoint at all.
|
||||
AccountCapabilities(
|
||||
supportsSubscriptions: true,
|
||||
supportsFeed: true,
|
||||
supportsPlaylistReads: true,
|
||||
supportsPlaylistWrites: true,
|
||||
supportsWatchHistory: false,
|
||||
supportsPlaylistPrivacy: false
|
||||
)
|
||||
|
||||
case .peertube:
|
||||
// Yattee has no PeerTube account concept — instances are browsed
|
||||
// anonymously.
|
||||
.none
|
||||
|
||||
case .yatteeServer:
|
||||
// Stateless today: the client posts its own subscription list and
|
||||
// gets a merged feed back, with HTTP Basic as the only auth. Per-user
|
||||
// accounts are planned server-side; this row is where they land.
|
||||
.none
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Yattee/Models/DataContext.swift
Normal file
76
Yattee/Models/DataContext.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// DataContext.swift
|
||||
// Yattee
|
||||
//
|
||||
// Which data set the Subscriptions, Feed and Playlists views are showing.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The globally selected source of user data.
|
||||
///
|
||||
/// Replaces `SubscriptionAccount`, which could only ever describe *subscriptions*
|
||||
/// and identified an account by the instance it lived on — a model that cannot
|
||||
/// express two logins on the same instance.
|
||||
enum DataContext: Codable, Hashable, Sendable {
|
||||
/// The device's own data, synced through iCloud.
|
||||
case local
|
||||
/// A specific `Account.id`.
|
||||
case account(UUID)
|
||||
|
||||
var accountID: UUID? {
|
||||
if case let .account(id) = self { return id }
|
||||
return nil
|
||||
}
|
||||
|
||||
var isLocal: Bool { self == .local }
|
||||
}
|
||||
|
||||
/// A context together with the moment it was chosen.
|
||||
///
|
||||
/// The timestamp is app-managed and is the only ordering used when two devices
|
||||
/// pick different contexts — CloudKit's own modification dates are never
|
||||
/// consulted, because they record when a *record* was written rather than when
|
||||
/// the user made a choice.
|
||||
struct StoredDataContext: Codable, Hashable, Sendable {
|
||||
var context: DataContext
|
||||
var changedAt: Date
|
||||
|
||||
init(context: DataContext, changedAt: Date = Date()) {
|
||||
self.context = context
|
||||
self.changedAt = changedAt
|
||||
}
|
||||
|
||||
/// A context produced by migrating the legacy `subscriptionAccount` value.
|
||||
///
|
||||
/// Stamped at the epoch so that any explicit choice the user has made on any
|
||||
/// device outranks it regardless of which arrives first. A migration is a
|
||||
/// guess at intent; an explicit choice is intent.
|
||||
static func migrated(_ context: DataContext) -> StoredDataContext {
|
||||
StoredDataContext(context: context, changedAt: Date(timeIntervalSince1970: 0))
|
||||
}
|
||||
|
||||
var isMigrated: Bool { changedAt == Date(timeIntervalSince1970: 0) }
|
||||
}
|
||||
|
||||
// MARK: - Legacy migration
|
||||
|
||||
extension DataContext {
|
||||
/// Maps the pre-revamp `SubscriptionAccount` onto a context.
|
||||
///
|
||||
/// The instance UUID becomes the account UUID directly: migrated accounts
|
||||
/// deliberately reuse their instance's UUID (see `Account.migrated(from:)`),
|
||||
/// so this is an identity mapping rather than a lookup that could fail.
|
||||
init(legacy: SubscriptionAccount) {
|
||||
switch legacy.type {
|
||||
case .local:
|
||||
self = .local
|
||||
case .invidious, .piped:
|
||||
if let instanceID = legacy.instanceID {
|
||||
self = .account(instanceID)
|
||||
} else {
|
||||
self = .local
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user