import Foundation import Observation /// Which accounts exist, which one is active, and the client for it. /// /// Signing in validates the pasted token with `whoami` before anything is /// stored, so a stored account is always one that worked at least once. A /// revoked token later comes back as 401 on some request; `expire` turns /// that into a clean sign-out with a plain sentence on the sign-in screen. @Observable @MainActor final class SessionStore { private(set) var accounts: [Account] private(set) var current: Account? private(set) var client: GitbayClient? /// Why the user is looking at the sign-in screen, when there is a /// reason beyond never having signed in. private(set) var signedOutMessage: String? private let store: any TokenStore private let makeClient: @Sendable (GitbayInstance, String) -> GitbayClient /// The active account survives relaunch in UserDefaults. An account id /// is not a secret — the token it points at stays in the Keychain. private let defaults: UserDefaults private var currentAccountID: String { get { defaults.string(forKey: "currentAccountID") ?? "" } set { defaults.set(newValue, forKey: "currentAccountID") } } init( store: any TokenStore = KeychainTokenStore(), defaults: UserDefaults = .standard, makeClient: @escaping @Sendable (GitbayInstance, String) -> GitbayClient = { GitbayClient(instance: $0, token: $1) } ) { self.store = store self.makeClient = makeClient self.defaults = defaults self.accounts = store.loadAccounts() restore() } private func restore() { let candidate = accounts.first { $0.id == currentAccountID } ?? accounts.first guard let candidate, let token = store.token(for: candidate.id) else { current = nil client = nil return } activate(candidate, token: token) } /// Validate a pasted token against an instance and store the account. /// Throws `GitbayError` with a user-facing message on every failure. func signIn(instanceURL: String, token: String) async throws { let instance = try GitbayInstance(url: instanceURL) let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw GitbayError.unauthorized("empty token") } let candidate = makeClient(instance, trimmed) let who = try await candidate.read(["whoami"], as: WhoamiResponse.self) let account = Account(instance: instance, username: who.username) try store.saveToken(trimmed, for: account.id) if !accounts.contains(account) { accounts.append(account) try store.saveAccounts(accounts) } current = account client = candidate currentAccountID = account.id signedOutMessage = nil watchForRevocation(candidate, account: account) } /// Switch to another stored account. Returns false if its token is /// gone from the Keychain, in which case the account is dropped too. @discardableResult func activate(_ account: Account) -> Bool { guard let token = store.token(for: account.id) else { remove(account) return false } activate(account, token: token) return true } private func activate(_ account: Account, token: String) { current = account let client = makeClient(account.instance, token) self.client = client currentAccountID = account.id watchForRevocation(client, account: account) } /// Any 401 from this account's client — some screen, any time — /// becomes a clean sign-out. Attached only after a token has worked /// once, so sign-in's own failures stay on the sign-in screen. private func watchForRevocation(_ client: GitbayClient, account: Account) { client.onUnauthorized { [weak self] in Task { @MainActor [weak self] in guard let self, self.accounts.contains(account) else { return } self.expire(account) } } } /// Forget an account: token out of the Keychain, account out of the /// list, and if it was active, over to the next one or signed out. func remove(_ account: Account) { store.deleteToken(for: account.id) accounts.removeAll { $0.id == account.id } try? store.saveAccounts(accounts) if current?.id == account.id { current = nil client = nil currentAccountID = "" restore() } } /// The server said 401: the token is expired or revoked. Sign the /// account out cleanly and say why — never crash, never loop. func expire(_ account: Account) { remove(account) if current == nil { signedOutMessage = "The token for \(account.label) is no longer valid. Mint a new one and sign in again." } } /// Route an error from any screen: a 401 expires the current account; /// everything else is the caller's to show. func handle(_ error: any Error) { guard let error = error as? GitbayError, error.requiresReauthentication, let current else { return } expire(current) } nonisolated private struct WhoamiResponse: Decodable, Sendable { let username: String } }