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) await didAddAccount?(account) } /// A client for any signed-in account, active or not. Push /// registration talks to every account on the device, and `store` /// and `makeClient` are private. Vending does not change `current`. func client(for account: Account) -> GitbayClient? { guard let token = store.token(for: account.id) else { return nil } return makeClient(account.instance, token) } /// Runs before an account is removed, while its token still exists. /// `gitbayApp` points this at push deregistration; this type knows /// nothing about push beyond that something wants to run first. var willRemoveAccount: ((Account) async -> Void)? /// Runs after an account signs in, with its token stored. The /// counterpart to `willRemoveAccount`: an account added while the /// app runs has no push device row until something registers it, /// and nothing else would until the next cold launch. var didAddAccount: ((Account) async -> Void)? /// 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 { // Bypasses willRemoveAccount deliberately: store.token(for:) // just returned nil, so there is no credential left to // deregister a device with. removeAccountState(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) async { await willRemoveAccount?(account) removeAccountState(account) } /// The state change behind `remove`, minus the hook. `remove` is the /// only path that awaits `willRemoveAccount` — this does NOT, on /// purpose, and every other caller of this method is an intentional /// exemption from that hook, commented at the call site with why a /// deregistration attempt there could not succeed anyway. Do not /// call this from a new site without asking the same question: does /// the account's token still work here? If it might, route through /// `remove` instead so the hook gets a chance to run. private func removeAccountState(_ 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. /// /// Bypasses willRemoveAccount deliberately: expire only runs after /// the server has already rejected this token with a 401 (via /// watchForRevocation or handle's requiresReauthentication check), /// so a deregistration call carrying that same token would be /// rejected the same way. The device row this account's push /// registration left on the server survives this and is not /// cleaned up here — the user clears it from the web settings page, /// same as any other stale device. func expire(_ account: Account) { removeAccountState(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 } }