import Foundation import Observation /// SSH keys, PGP keys, and email verification — the account rows the web /// has. Tokens stay SSH-only by design and get no UI here. @Observable @MainActor final class AccountViewModel { nonisolated struct SSHKey: Decodable, Sendable, Hashable, Identifiable { let fingerprint: String let algo: String let scope: String var id: String { fingerprint } } nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable { let fingerprint: String /// The server stores the UID list JSON-encoded inside the field. let emails: String? var id: String { fingerprint } var emailList: [String] { guard let emails, let data = try? JSONDecoder().decode([String].self, from: Data(emails.utf8)) else { return [] } return data } } nonisolated struct Loaded: Sendable, Hashable { let sshKeys: [SSHKey] let pgpKeys: [PGPKey] let orgs: [OrgMembership] } private(set) var state: LoadState = .loading private(set) var actionError: String? /// Set after `email add` succeeds, so the screen can say a code is /// on its way. private(set) var notice: String? private(set) var working = false private let client: GitbayClient init(client: GitbayClient) { self.client = client } func load() async { do { async let ssh = client.readList(["keys", "list"], of: SSHKey.self) async let pgp = client.readList(["pgp", "list"], of: PGPKey.self) async let orgs = client.readList(["org", "list"], of: OrgMembership.self) state = .loaded(Loaded( sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs )) } catch { state = .from(error) } } // MARK: - SSH keys /// `keys add [--scope full|git]` — the authorized_keys line travels /// as raw stdin; a public key is not a secret. func addSSHKey(_ publicKey: String, scope: String) async { await perform(["keys", "add", "--scope", scope], stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines)) } func removeSSHKey(_ key: SSHKey) async { await perform(["keys", "remove", key.fingerprint]) } // MARK: - PGP keys func addPGPKey(_ armored: String) async { await perform(["pgp", "add"], stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines)) } func removePGPKey(_ key: PGPKey) async { await perform(["pgp", "remove", key.fingerprint]) } // MARK: - Email func addEmail(_ address: String) async { await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)]) if actionError == nil { notice = "A verification code is on its way to \(address)." } } func verifyEmail(code: String) async { await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)]) if actionError == nil { notice = "Email verified." } } private func perform(_ argv: [String], stdin: String? = nil) async { working = true actionError = nil notice = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() } catch let error as GitbayError { // Exit 2 is normally an app bug and stays generic, but on // this screen it validates pasted content ("not a valid // public key…", duplicates) — written for the person. if case .usage(let message) = error, !message.isEmpty { actionError = message } else { actionError = error.userFacingMessage } } catch { actionError = GitbayError.transport(error).userFacingMessage } } }