a native ios client for gitbay

client ios swift

https://gitbay.org

gitbay/Account/AccountViewModel.swift

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

125 lines · 4050 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    nonisolated struct Loaded: Sendable, Hashable {
 33        let sshKeys: [SSHKey]
 34        let pgpKeys: [PGPKey]
 35        let orgs: [OrgMembership]
 36    }
 37
 38    private(set) var state: LoadState<Loaded> = .loading
 39    private(set) var actionError: String?
 40    /// Set after `email add` succeeds, so the screen can say a code is
 41    /// on its way.
 42    private(set) var notice: String?
 43    private(set) var working = false
 44
 45    private let client: GitbayClient
 46
 47    init(client: GitbayClient) {
 48        self.client = client
 49    }
 50
 51    func load() async {
 52        do {
 53            async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
 54            async let pgp = client.readList(["pgp", "list"], of: PGPKey.self)
 55            async let orgs = client.readList(["org", "list"], of: OrgMembership.self)
 56            state = .loaded(Loaded(
 57                sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs
 58            ))
 59        } catch {
 60            state = .from(error)
 61        }
 62    }
 63
 64    // MARK: - SSH keys
 65
 66    /// `keys add [--scope full|git]`  the authorized_keys line travels
 67    /// as raw stdin; a public key is not a secret.
 68    func addSSHKey(_ publicKey: String, scope: String) async {
 69        await perform(["keys", "add", "--scope", scope],
 70                      stdin: publicKey.trimmingCharacters(in: .whitespacesAndNewlines))
 71    }
 72
 73    func removeSSHKey(_ key: SSHKey) async {
 74        await perform(["keys", "remove", key.fingerprint])
 75    }
 76
 77    // MARK: - PGP keys
 78
 79    func addPGPKey(_ armored: String) async {
 80        await perform(["pgp", "add"],
 81                      stdin: armored.trimmingCharacters(in: .whitespacesAndNewlines))
 82    }
 83
 84    func removePGPKey(_ key: PGPKey) async {
 85        await perform(["pgp", "remove", key.fingerprint])
 86    }
 87
 88    // MARK: - Email
 89
 90    func addEmail(_ address: String) async {
 91        await perform(["email", "add", address.trimmingCharacters(in: .whitespaces)])
 92        if actionError == nil {
 93            notice = "A verification code is on its way to \(address)."
 94        }
 95    }
 96
 97    func verifyEmail(code: String) async {
 98        await perform(["email", "verify", code.trimmingCharacters(in: .whitespaces)])
 99        if actionError == nil {
100            notice = "Email verified."
101        }
102    }
103
104    private func perform(_ argv: [String], stdin: String? = nil) async {
105        working = true
106        actionError = nil
107        notice = nil
108        defer { working = false }
109        do {
110            try await client.run(argv, stdin: stdin)
111            await load()
112        } catch let error as GitbayError {
113            // Exit 2 is normally an app bug and stays generic, but on
114            // this screen it validates pasted content ("not a valid
115            // public key", duplicates)  written for the person.
116            if case .usage(let message) = error, !message.isEmpty {
117                actionError = message
118            } else {
119                actionError = error.userFacingMessage
120            }
121        } catch {
122            actionError = GitbayError.transport(error).userFacingMessage
123        }
124    }
125}