gitbay/Account/AccountViewModel.swift

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

237 lines · 8216 bytes

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