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 } } /// One row of `email list` (v1.16.0). nonisolated struct EmailAddress: Decodable, Sendable, Hashable, Identifiable { let address: String let verified: Bool let verifiedBy: String? let primary: Bool var id: String { address } enum CodingKeys: String, CodingKey { case address, verified, primary case verifiedBy = "verified_by" } } 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 /// Whether activity mail is on (v1.16.0); nil until read, or on a /// server without the setting. Inbox rows are filed either way. private(set) var mailOn: Bool? /// Whether the account watches every issue and merge request on the /// repositories it can write to (v1.19.0); nil until read, or on a /// server whose `show` has no `watch`. private(set) var watchOn: Bool? /// The account's addresses; nil until read, or on a server without /// `email list`. private(set) var emails: [EmailAddress]? 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: - Notifications nonisolated private struct NotificationSettings: Decodable, Sendable { let mail: Bool let watch: Bool? } func loadNotificationPreferences() async { let settings = try? await client.read( ["notifications", "settings", "show"], as: NotificationSettings.self) mailOn = settings?.mail watchOn = settings?.watch } /// `notifications settings mail on|off`, then read the value back. func setMail(_ on: Bool) async { await setPreference("mail", on) } /// `notifications settings watch on|off`, then read the value back. func setWatch(_ on: Bool) async { await setPreference("watch", on) } private func setPreference(_ name: String, _ on: Bool) async { working = true actionError = nil defer { working = false } do { try await client.run(["notifications", "settings", name, on ? "on" : "off"]) await loadNotificationPreferences() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } // 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)." await loadEmails() } } func verifyEmail(code: String) async { await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)]) if actionError == nil { notice = "Email verified." await loadEmails() } } func loadEmails() async { emails = try? await client.readList(["email", "list"], of: EmailAddress.self) } /// `email remove`: the primary and the last verified address are /// refused by the server, since identity resolves through them. func removeEmail(_ address: String) async { await performEmail(["email", "remove", address]) } /// `email primary`: moves the primary to a verified address. func setPrimaryEmail(_ address: String) async { await performEmail(["email", "primary", address]) } private func performEmail(_ argv: [String]) async { working = true actionError = nil notice = nil defer { working = false } do { try await client.run(argv) await loadEmails() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } 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 } } }