gitbay/Account/AccountViewModel.swift

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

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