gitbay/Account/AccountViewModel.swift

v1.1.0
gitbay-ios/gitbay/Account/AccountViewModel.swift history · blame · raw

203 lines · 6681 bytes

  1import Foundation
  2import Observation
  3
  4/// SSH keys, PGP keys, and email verification — the account rows the web
  5/// has. Tokens stay SSH-only by design and get no UI here.
  6@Observable
  7@MainActor
  8final class AccountViewModel {
  9
 10    nonisolated struct SSHKey: Decodable, Sendable, Hashable, Identifiable {
 11        let fingerprint: String
 12        let algo: String
 13        let scope: String
 14        var id: String { fingerprint }
 15    }
 16
 17    nonisolated struct PGPKey: Decodable, Sendable, Hashable, Identifiable {
 18        let fingerprint: String
 19        /// The server stores the UID list JSON-encoded inside the field.
 20        let emails: String?
 21        var id: String { fingerprint }
 22
 23        var emailList: [String] {
 24            guard let emails,
 25                  let data = try? JSONDecoder().decode([String].self, from: Data(emails.utf8)) else {
 26                return []
 27            }
 28            return data
 29        }
 30    }
 31
 32    /// One row of `email list` (v1.16.0).
 33    nonisolated struct EmailAddress: Decodable, Sendable, Hashable, Identifiable {
 34        let address: String
 35        let verified: Bool
 36        let verifiedBy: String?
 37        let primary: Bool
 38        var id: String { address }
 39
 40        enum CodingKeys: String, CodingKey {
 41            case address, verified, primary
 42            case verifiedBy = "verified_by"
 43        }
 44    }
 45
 46    nonisolated struct Loaded: Sendable, Hashable {
 47        let sshKeys: [SSHKey]
 48        let pgpKeys: [PGPKey]
 49        let orgs: [OrgMembership]
 50    }
 51
 52    private(set) var state: LoadState<Loaded> = .loading
 53    private(set) var actionError: String?
 54    /// Set after `email add` succeeds, so the screen can say a code is
 55    /// on its way.
 56    private(set) var notice: String?
 57    private(set) var working = false
 58    /// Whether activity mail is on (v1.16.0); nil until read, or on a
 59    /// server without the setting. Inbox rows are filed either way.
 60    private(set) var mailOn: Bool?
 61    /// The account's addresses; nil until read, or on a server without
 62    /// `email list`.
 63    private(set) var emails: [EmailAddress]?
 64
 65    private let client: GitbayClient
 66
 67    init(client: GitbayClient) {
 68        self.client = client
 69    }
 70
 71    func load() async {
 72        do {
 73            async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
 74            async let pgp = client.readList(["pgp", "list"], of: PGPKey.self)
 75            async let orgs = client.readList(["org", "list"], of: OrgMembership.self)
 76            state = .loaded(Loaded(
 77                sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs
 78            ))
 79        } catch {
 80            state = .from(error)
 81        }
 82    }
 83
 84    // MARK: - Notifications
 85
 86    nonisolated private struct MailSetting: Decodable, Sendable {
 87        let mail: Bool
 88    }
 89
 90    func loadMailPreference() async {
 91        mailOn = (try? await client.read(
 92            ["notifications", "settings", "show"], as: MailSetting.self))?.mail
 93    }
 94
 95    /// `notifications settings mail on|off`, then read the value back.
 96    func setMail(_ on: Bool) async {
 97        working = true
 98        actionError = nil
 99        defer { working = false }
100        do {
101            try await client.run(["notifications", "settings", "mail", on ? "on" : "off"])
102            await loadMailPreference()
103        } catch let error as GitbayError {
104            actionError = error.userFacingMessage
105        } catch {
106            actionError = GitbayError.transport(error).userFacingMessage
107        }
108    }
109
110    // MARK: - SSH keys
111
112    /// `keys add [--scope full|git]` — the authorized_keys line travels
113    /// as raw stdin; a public key is not a secret.
114    func addSSHKey(_ publicKey: String, scope: String) async {
115        await perform(["keys", "add", "--scope", scope],
116                      stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
117    }
118
119    func removeSSHKey(_ key: SSHKey) async {
120        await perform(["keys", "remove", key.fingerprint])
121    }
122
123    // MARK: - PGP keys
124
125    func addPGPKey(_ armored: String) async {
126        await perform(["pgp", "add"],
127                      stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines))
128    }
129
130    func removePGPKey(_ key: PGPKey) async {
131        await perform(["pgp", "remove", key.fingerprint])
132    }
133
134    // MARK: - Email
135
136    func addEmail(_ address: String) async {
137        await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
138        if actionError == nil {
139            notice = "A verification code is on its way to \(address)."
140            await loadEmails()
141        }
142    }
143
144    func verifyEmail(code: String) async {
145        await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
146        if actionError == nil {
147            notice = "Email verified."
148            await loadEmails()
149        }
150    }
151
152    func loadEmails() async {
153        emails = try? await client.readList(["email", "list"], of: EmailAddress.self)
154    }
155
156    /// `email remove`: the primary and the last verified address are
157    /// refused by the server, since identity resolves through them.
158    func removeEmail(_ address: String) async {
159        await performEmail(["email", "remove", address])
160    }
161
162    /// `email primary`: moves the primary to a verified address.
163    func setPrimaryEmail(_ address: String) async {
164        await performEmail(["email", "primary", address])
165    }
166
167    private func performEmail(_ argv: [String]) async {
168        working = true
169        actionError = nil
170        notice = nil
171        defer { working = false }
172        do {
173            try await client.run(argv)
174            await loadEmails()
175        } catch let error as GitbayError {
176            actionError = error.userFacingMessage
177        } catch {
178            actionError = GitbayError.transport(error).userFacingMessage
179        }
180    }
181
182    private func perform(_ argv: [String], stdin: String? = nil) async {
183        working = true
184        actionError = nil
185        notice = nil
186        defer { working = false }
187        do {
188            try await client.run(argv, stdin: stdin)
189            await load()
190        } catch let error as GitbayError {
191            // Exit 2 is normally an app bug and stays generic, but on
192            // this screen it validates pasted content ("not a valid
193            // public key…", duplicates) — written for the person.
194            if case .usage(let message) = error, !message.isEmpty {
195                actionError = message
196            } else {
197                actionError = error.userFacingMessage
198            }
199        } catch {
200            actionError = GitbayError.transport(error).userFacingMessage
201        }
202    }
203}