gitbay/Repos/RepoMirrorsDomains.swift
199 lines · 6507 bytes
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}