gitbay/Account/AccountViewModel.swift

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

219 lines · 7302 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    /// Whether the account watches every issue and merge request on the
 62    /// repositories it can write to (v1.19.0); nil until read, or on a
 63    /// server whose `show` has no `watch`.
 64    private(set) var watchOn: Bool?
 65    /// The account's addresses; nil until read, or on a server without
 66    /// `email list`.
 67    private(set) var emails: [EmailAddress]?
 68
 69    private let client: GitbayClient
 70
 71    init(client: GitbayClient) {
 72        self.client = client
 73    }
 74
 75    func load() async {
 76        do {
 77            async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
 78            async let pgp = client.readList(["pgp", "list"], of: PGPKey.self)
 79            async let orgs = client.readList(["org", "list"], of: OrgMembership.self)
 80            state = .loaded(Loaded(
 81                sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs
 82            ))
 83        } catch {
 84            state = .from(error)
 85        }
 86    }
 87
 88    // MARK: - Notifications
 89
 90    nonisolated private struct NotificationSettings: Decodable, Sendable {
 91        let mail: Bool
 92        let watch: Bool?
 93    }
 94
 95    func loadNotificationPreferences() async {
 96        let settings = try? await client.read(
 97            ["notifications", "settings", "show"], as: NotificationSettings.self)
 98        mailOn = settings?.mail
 99        watchOn = settings?.watch
100    }
101
102    /// `notifications settings mail on|off`, then read the value back.
103    func setMail(_ on: Bool) async {
104        await setPreference("mail", on)
105    }
106
107    /// `notifications settings watch on|off`, then read the value back.
108    func setWatch(_ on: Bool) async {
109        await setPreference("watch", on)
110    }
111
112    private func setPreference(_ name: String, _ on: Bool) async {
113        working = true
114        actionError = nil
115        defer { working = false }
116        do {
117            try await client.run(["notifications", "settings", name, on ? "on" : "off"])
118            await loadNotificationPreferences()
119        } catch let error as GitbayError {
120            actionError = error.userFacingMessage
121        } catch {
122            actionError = GitbayError.transport(error).userFacingMessage
123        }
124    }
125
126    // MARK: - SSH keys
127
128    /// `keys add [--scope full|git]` — the authorized_keys line travels
129    /// as raw stdin; a public key is not a secret.
130    func addSSHKey(_ publicKey: String, scope: String) async {
131        await perform(["keys", "add", "--scope", scope],
132                      stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
133    }
134
135    func removeSSHKey(_ key: SSHKey) async {
136        await perform(["keys", "remove", key.fingerprint])
137    }
138
139    // MARK: - PGP keys
140
141    func addPGPKey(_ armored: String) async {
142        await perform(["pgp", "add"],
143                      stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines))
144    }
145
146    func removePGPKey(_ key: PGPKey) async {
147        await perform(["pgp", "remove", key.fingerprint])
148    }
149
150    // MARK: - Email
151
152    func addEmail(_ address: String) async {
153        await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
154        if actionError == nil {
155            notice = "A verification code is on its way to \(address)."
156            await loadEmails()
157        }
158    }
159
160    func verifyEmail(code: String) async {
161        await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
162        if actionError == nil {
163            notice = "Email verified."
164            await loadEmails()
165        }
166    }
167
168    func loadEmails() async {
169        emails = try? await client.readList(["email", "list"], of: EmailAddress.self)
170    }
171
172    /// `email remove`: the primary and the last verified address are
173    /// refused by the server, since identity resolves through them.
174    func removeEmail(_ address: String) async {
175        await performEmail(["email", "remove", address])
176    }
177
178    /// `email primary`: moves the primary to a verified address.
179    func setPrimaryEmail(_ address: String) async {
180        await performEmail(["email", "primary", address])
181    }
182
183    private func performEmail(_ argv: [String]) async {
184        working = true
185        actionError = nil
186        notice = nil
187        defer { working = false }
188        do {
189            try await client.run(argv)
190            await loadEmails()
191        } catch let error as GitbayError {
192            actionError = error.userFacingMessage
193        } catch {
194            actionError = GitbayError.transport(error).userFacingMessage
195        }
196    }
197
198    private func perform(_ argv: [String], stdin: String? = nil) async {
199        working = true
200        actionError = nil
201        notice = nil
202        defer { working = false }
203        do {
204            try await client.run(argv, stdin: stdin)
205            await load()
206        } catch let error as GitbayError {
207            // Exit 2 is normally an app bug and stays generic, but on
208            // this screen it validates pasted content ("not a valid
209            // public key…", duplicates) — written for the person.
210            if case .usage(let message) = error, !message.isEmpty {
211                actionError = message
212            } else {
213                actionError = error.userFacingMessage
214            }
215        } catch {
216            actionError = GitbayError.transport(error).userFacingMessage
217        }
218    }
219}