Files
yattee/Yattee/Models/Account.swift
Arkadiusz Fal 794b2b0181 Let accounts be set inactive without signing them out
An inactive account keeps its credential and syncs like a rename, but
drops out of every picker and cannot be used until activated again.
Deactivating the selected account switches to the local context as a
published choice, so other devices follow instead of falling back.
2026-09-19 23:58:24 +02:00

110 lines
4.5 KiB
Swift

//
// Account.swift
// Yattee
//
// A single sign-in on a backend instance. One instance may hold several.
//
import Foundation
/// A user's login on an instance.
///
/// Yattee 2 collapsed v1's multi-account model into "one instance = at most one
/// credential" (`Instance.usesAccountLogin` plus a Keychain item keyed by the
/// instance's UUID). `Account` restores multi-login: the instance describes the
/// *server*, the account describes *who is signed in to it*.
///
/// The login identifier (username/email) is deliberately **not** stored here —
/// it lives in the Keychain beside the credential, so synced metadata never
/// carries account identifiers. Only `displayName` is a synced label.
///
/// `type` is not stored either: it is derived by resolving `instanceID` through
/// `InstancesManager`, so an instance that changes type can never disagree with
/// its accounts.
struct Account: Identifiable, Codable, Hashable, Sendable {
let id: UUID
let instanceID: UUID
/// User-set label. The only name that syncs.
var displayName: String?
/// Whether the user wants this account in use at all.
///
/// Deactivating is deliberately not a credential state: the login stays in
/// the Keychain untouched, and reactivating restores whatever it really
/// was. While inactive the account cannot be switched to and the selection
/// falls back to local — the account-level twin of a switched-off source.
var isActive: Bool
let dateAdded: Date
/// Bumped on every user edit — supplies the ordering for conflict
/// resolution, which never relies on CloudKit modification dates.
var updatedAt: Date
init(
id: UUID = UUID(),
instanceID: UUID,
displayName: String? = nil,
isActive: Bool = true,
dateAdded: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.instanceID = instanceID
self.displayName = displayName
self.isActive = isActive
self.dateAdded = dateAdded
self.updatedAt = updatedAt
}
/// Decoded by hand for one reason: accounts persisted before `isActive`
/// existed carry no such key, and they are all active — that was the only
/// state there was.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
instanceID = try container.decode(UUID.self, forKey: .instanceID)
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
isActive = try container.decodeIfPresent(Bool.self, forKey: .isActive) ?? true
dateAdded = try container.decode(Date.self, forKey: .dateAdded)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
}
/// The account synthesized for an instance that was signed in before
/// multi-account support existed.
///
/// The account's `id` is deliberately the **instance's** UUID: at most one
/// credential ever existed per instance, so reusing the UUID makes the
/// migration deterministic across devices — two devices migrating
/// independently produce the identical record name, which merges per-record
/// instead of duplicating.
static func migrated(from instanceID: UUID, dateAdded: Date = Date()) -> Account {
Account(
id: instanceID,
instanceID: instanceID,
displayName: nil,
dateAdded: dateAdded,
// Epoch-old: any explicit user edit on any device outranks a
// migration write regardless of arrival order.
updatedAt: Date(timeIntervalSince1970: 0)
)
}
/// Whether this account was produced by the legacy migration rather than an
/// explicit sign-in.
var isMigrated: Bool { id == instanceID }
/// Whether `other` should replace this copy.
///
/// Ordered by the app-managed `updatedAt`, with the label settling an exact
/// tie. The tie is reachable — two devices can migrate the same account in
/// the same instant, and every migrated account is stamped at the epoch — and
/// without a tie-break each device would keep its own label and consider the
/// matter closed, which is a divergence neither would ever notice.
func isOutranked(by other: Account) -> Bool {
if other.updatedAt != updatedAt { return other.updatedAt > updatedAt }
return (other.displayName ?? "") > (displayName ?? "")
}
}