gitbay/Repos/RepoMirrorsDomains.swift

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/gitbay/Repos/RepoMirrorsDomains.swift history · blame · raw

199 lines · 6507 bytes

15 symbols in this file
  1import Foundation
  2import Observation
  3
  4/// One row of `repo mirror list`.
  5nonisolated struct Mirror: Decodable, Sendable, Hashable, Identifiable {
  6    let id: Int64
  7    /// `push` or `pull`.
  8    let direction: String
  9    let url: String
 10    var username: String? = nil
 11    /// Waiting for the worker's next sync.
 12    let pending: Bool
 13    var lastSync: Date? = nil
 14    var lastError: String? = nil
 15
 16    var isPull: Bool { direction == "pull" }
 17
 18    enum CodingKeys: String, CodingKey {
 19        case id, direction, url, username, pending
 20        case lastSync = "last_sync"
 21        case lastError = "last_error"
 22    }
 23}
 24
 25/// `repo mirror list`, `add`, `remove` and `sync`.
 26@Observable
 27@MainActor
 28final class MirrorsViewModel {
 29
 30    private(set) var state: LoadState<[Mirror]> = .loading
 31    private(set) var actionError: String?
 32    private(set) var working = false
 33
 34    private let client: GitbayClient
 35    let repoPath: String
 36
 37    init(client: GitbayClient, repoPath: String) {
 38        self.client = client
 39        self.repoPath = repoPath
 40    }
 41
 42    func load() async {
 43        do {
 44            let mirrors = try await client.readList(["repo", "mirror", "list", repoPath], of: Mirror.self)
 45            state = mirrors.isEmpty ? .empty("No mirrors yet.") : .loaded(mirrors)
 46        } catch {
 47            state = .from(error)
 48        }
 49    }
 50
 51    /// `repo mirror add <repo> <url> --direction push|pull [--username <u>]
 52    /// [--token-stdin]`. The token travels on stdin, never in argv.
 53    func add(url: String, pull: Bool, username: String, token: String) async -> Bool {
 54        let url = url.trimmingCharacters(in: .whitespaces)
 55        guard !url.isEmpty else { return false }
 56        var argv = ["repo", "mirror", "add", repoPath, url, "--direction", pull ? "pull" : "push"]
 57        let username = username.trimmingCharacters(in: .whitespaces)
 58        if !username.isEmpty { argv += ["--username", username] }
 59        let token = token.trimmingCharacters(in: .whitespacesAndNewlines)
 60        if !token.isEmpty { argv.append("--token-stdin") }
 61        return await perform(argv, stdin: token.isEmpty ? nil : token + "\n")
 62    }
 63
 64    /// Asks the worker to sync every mirror of the repository now.
 65    func sync() async -> Bool {
 66        await perform(["repo", "mirror", "sync", repoPath])
 67    }
 68
 69    func remove(id: Int64) async {
 70        await perform(["repo", "mirror", "remove", repoPath, String(id)])
 71    }
 72
 73    @discardableResult
 74    private func perform(_ argv: [String], stdin: String? = nil) async -> Bool {
 75        working = true
 76        actionError = nil
 77        defer { working = false }
 78        do {
 79            try await client.run(argv, stdin: stdin)
 80            await load()
 81            return true
 82        } catch let error as GitbayError {
 83            actionError = error.messageForTypedInput
 84        } catch {
 85            actionError = GitbayError.transport(error).userFacingMessage
 86        }
 87        // A refusal may mean the list changed: a claim that expired is
 88        // removed by the verify that finds it so.
 89        await load()
 90        return false
 91    }
 92}
 93
 94/// One row of `repo domain list`.
 95nonisolated struct PageDomain: Decodable, Sendable, Hashable, Identifiable {
 96    let domain: String
 97    /// `pending` until the DNS challenge is verified, `expired` once the
 98    /// claim lapsed unverified.
 99    let state: String
100    var verifiedAt: Date? = nil
101    var id: String { domain }
102
103    var isVerified: Bool { state == "verified" }
104    /// Waiting on its DNS record; an `expired` claim is neither.
105    var isPending: Bool { state == "pending" }
106
107    enum CodingKeys: String, CodingKey {
108        case domain, state
109        case verifiedAt = "verified_at"
110    }
111}
112
113/// The TXT record that proves a claim, shown once when it is made.
114nonisolated struct DomainChallenge: Decodable, Sendable, Hashable {
115    let domain: String
116    let name: String
117    let value: String
118
119    enum CodingKeys: String, CodingKey {
120        case domain
121        case name = "challenge_name"
122        case value = "challenge_value"
123    }
124}
125
126/// `repo domain list`, `add`, `verify` and `remove`: custom domains that
127/// serve the repository's pages once a DNS TXT record proves the claim.
128@Observable
129@MainActor
130final class PageDomainsViewModel {
131
132    private(set) var state: LoadState<[PageDomain]> = .loading
133    private(set) var actionError: String?
134    private(set) var working = false
135
136    private let client: GitbayClient
137    let repoPath: String
138
139    init(client: GitbayClient, repoPath: String) {
140        self.client = client
141        self.repoPath = repoPath
142    }
143
144    func load() async {
145        do {
146            let domains = try await client.readList(["repo", "domain", "list", repoPath], of: PageDomain.self)
147            state = domains.isEmpty ? .empty("No custom domains yet.") : .loaded(domains)
148        } catch {
149            state = .from(error)
150        }
151    }
152
153    /// Claims a domain; the record to create comes back, and only now.
154    func add(_ domain: String) async -> DomainChallenge? {
155        let domain = domain.trimmingCharacters(in: .whitespaces)
156        guard !domain.isEmpty else { return nil }
157        working = true
158        actionError = nil
159        defer { working = false }
160        do {
161            let challenge = try await client.run(["repo", "domain", "add", repoPath, domain], as: DomainChallenge.self)
162            await load()
163            return challenge
164        } catch let error as GitbayError {
165            actionError = error.messageForTypedInput
166        } catch {
167            actionError = GitbayError.transport(error).userFacingMessage
168        }
169        return nil
170    }
171
172    func verify(_ domain: String) async -> Bool {
173        await perform(["repo", "domain", "verify", repoPath, domain])
174    }
175
176    func remove(_ domain: String) async {
177        await perform(["repo", "domain", "remove", repoPath, domain])
178    }
179
180    @discardableResult
181    private func perform(_ argv: [String]) async -> Bool {
182        working = true
183        actionError = nil
184        defer { working = false }
185        do {
186            try await client.run(argv)
187            await load()
188            return true
189        } catch let error as GitbayError {
190            actionError = error.messageForTypedInput
191        } catch {
192            actionError = GitbayError.transport(error).userFacingMessage
193        }
194        // A refusal may mean the list changed: a claim that expired is
195        // removed by the verify that finds it so.
196        await load()
197        return false
198    }
199}