gitbay/Repos/RepoCredentials.swift

v1.14.1
gitbay-ios/gitbay/Repos/RepoCredentials.swift history · blame · raw

145 lines · 4660 bytes

  1import Foundation
  2import Observation
  3
  4/// `repo secret list`, `set` and `remove`. Names only: a value goes to
  5/// the server on stdin and never comes back.
  6@Observable
  7@MainActor
  8final class BuildSecretsViewModel {
  9
 10    private(set) var state: LoadState<[String]> = .loading
 11    private(set) var actionError: String?
 12    private(set) var working = false
 13
 14    private let client: GitbayClient
 15    let repoPath: String
 16
 17    init(client: GitbayClient, repoPath: String) {
 18        self.client = client
 19        self.repoPath = repoPath
 20    }
 21
 22    func load() async {
 23        do {
 24            let names = try await client.readList(["repo", "secret", "list", repoPath], of: String.self)
 25            state = names.isEmpty ? .empty("No build secrets yet.") : .loaded(names)
 26        } catch {
 27            state = .from(error)
 28        }
 29    }
 30
 31    /// Sets or replaces a secret. The name is the user's, so a refusal
 32    /// of it is shown as the server words it.
 33    func set(name: String, value: String) async -> Bool {
 34        let name = name.trimmingCharacters(in: .whitespaces)
 35        guard !name.isEmpty, !value.isEmpty else { return false }
 36        return await perform(["repo", "secret", "set", repoPath, name], stdin: value)
 37    }
 38
 39    func remove(_ name: String) async {
 40        await perform(["repo", "secret", "remove", repoPath, name])
 41    }
 42
 43    @discardableResult
 44    private func perform(_ argv: [String], stdin: String? = nil) async -> Bool {
 45        working = true
 46        actionError = nil
 47        defer { working = false }
 48        do {
 49            try await client.run(argv, stdin: stdin)
 50            await load()
 51            return true
 52        } catch let error as GitbayError {
 53            actionError = error.messageForTypedInput
 54        } catch {
 55            actionError = GitbayError.transport(error).userFacingMessage
 56        }
 57        // A refusal may mean the list changed elsewhere, e.g. a row
 58        // removed from another device.
 59        await load()
 60        return false
 61    }
 62}
 63
 64/// One row of `repo deploy-key list`.
 65nonisolated struct DeployKey: Decodable, Sendable, Hashable, Identifiable {
 66    let fingerprint: String
 67    let algo: String
 68    /// `ro` or `rw`.
 69    let mode: String
 70    /// The key's comment; empty when it had none.
 71    let label: String
 72    var lastUsedAt: Date? = nil
 73    var expiresAt: Date? = nil
 74    var id: String { fingerprint }
 75
 76    var canPush: Bool { mode == "rw" }
 77
 78    enum CodingKeys: String, CodingKey {
 79        case fingerprint, algo, mode, label
 80        case lastUsedAt = "last_used_at"
 81        case expiresAt = "expires_at"
 82    }
 83}
 84
 85/// `repo deploy-key list`, `add` and `remove`: keys bound to this one
 86/// repository, read-only unless added with `--rw`.
 87@Observable
 88@MainActor
 89final class DeployKeysViewModel {
 90
 91    private(set) var state: LoadState<[DeployKey]> = .loading
 92    private(set) var actionError: String?
 93    private(set) var working = false
 94
 95    private let client: GitbayClient
 96    let repoPath: String
 97
 98    init(client: GitbayClient, repoPath: String) {
 99        self.client = client
100        self.repoPath = repoPath
101    }
102
103    func load() async {
104        do {
105            let keys = try await client.readList(["repo", "deploy-key", "list", repoPath], of: DeployKey.self)
106            state = keys.isEmpty ? .empty("No deploy keys yet.") : .loaded(keys)
107        } catch {
108            state = .from(error)
109        }
110    }
111
112    /// `repo deploy-key add <repo> [--rw] [--ttl <d>] < key.pub`.
113    func add(publicKey: String, canPush: Bool, ttl: String?) async -> Bool {
114        let key = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
115        guard !key.isEmpty else { return false }
116        var argv = ["repo", "deploy-key", "add", repoPath]
117        if canPush { argv.append("--rw") }
118        if let ttl { argv += ["--ttl", ttl] }
119        return await perform(argv, stdin: key)
120    }
121
122    func remove(_ key: DeployKey) async {
123        await perform(["repo", "deploy-key", "remove", repoPath, key.fingerprint])
124    }
125
126    @discardableResult
127    private func perform(_ argv: [String], stdin: String? = nil) async -> Bool {
128        working = true
129        actionError = nil
130        defer { working = false }
131        do {
132            try await client.run(argv, stdin: stdin)
133            await load()
134            return true
135        } catch let error as GitbayError {
136            actionError = error.messageForTypedInput
137        } catch {
138            actionError = GitbayError.transport(error).userFacingMessage
139        }
140        // A refusal may mean the list changed elsewhere, e.g. a row
141        // removed from another device.
142        await load()
143        return false
144    }
145}