Commit 7d4c8f49e2

7d4c8f49e28dac1eb593a26cf99a2629ebfeaa51

parent: 3f57451dba

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 03:26 UTC

Mirrors and domains: expired claims, refresh after a refusal, a record that cannot be lost

An expired claim says so and offers no verify; a refused write reloads the list, so a claim the verify removed goes. The DNS record has copy buttons and its sheet closes only by Done. The sync and verify notices clear on the next action and no longer promise a live list.

Ref #44
Ref #45

Layout: unified · split

gitbay/Repos/RepoMirrorsDomains.swift +10 −1
@@ -84,6 +84,9 @@ final class MirrorsViewModel {
84 } catch { 84 } catch {
85 actionError = GitbayError.transport(error).userFacingMessage 85 actionError = GitbayError.transport(error).userFacingMessage
86 } 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()
87 return false 90 return false
88 } 91 }
89} 92}
@@ -91,12 +94,15 @@ final class MirrorsViewModel {
91/// One row of `repo domain list`. 94/// One row of `repo domain list`.
92nonisolated struct PageDomain: Decodable, Sendable, Hashable, Identifiable { 95nonisolated struct PageDomain: Decodable, Sendable, Hashable, Identifiable {
93 let domain: String 96 let domain: String
94 /// `pending` until the DNS challenge is verified. 97 /// `pending` until the DNS challenge is verified, `expired` once the
98 /// claim lapsed unverified.
95 let state: String 99 let state: String
96 var verifiedAt: Date? = nil 100 var verifiedAt: Date? = nil
97 var id: String { domain } 101 var id: String { domain }
98 102
99 var isVerified: Bool { state == "verified" } 103 var isVerified: Bool { state == "verified" }
104 /// Waiting on its DNS record; an `expired` claim is neither.
105 var isPending: Bool { state == "pending" }
100 106
101 enum CodingKeys: String, CodingKey { 107 enum CodingKeys: String, CodingKey {
102 case domain, state 108 case domain, state
@@ -185,6 +191,9 @@ final class PageDomainsViewModel {
185 } catch { 191 } catch {
186 actionError = GitbayError.transport(error).userFacingMessage 192 actionError = GitbayError.transport(error).userFacingMessage
187 } 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()
188 return false 197 return false
189 } 198 }
190} 199}
gitbay/Views/Repos/RepoMirrorDomainViews.swift +58 −22
@@ -18,14 +18,17 @@ struct MirrorsView: View {
18 if let error = model.actionError, !adding { 18 if let error = model.actionError, !adding {
19 Section { GBNotice(error, .gbWarn) } 19 Section { GBNotice(error, .gbWarn) }
20 } else if scheduled { 20 } else if scheduled {
21 Section { GBNotice("Sync scheduled. The list updates as each mirror finishes.", .gbOK) } 21 Section { GBNotice("Sync scheduled. Pull to refresh for each mirror's result.", .gbOK) }
22 } 22 }
23 Section { 23 Section {
24 ForEach(model.state.value ?? []) { mirror in 24 ForEach(model.state.value ?? []) { mirror in
25 row(mirror) 25 row(mirror)
26 .swipeActions { 26 .swipeActions {
27 Button("Remove", role: .destructive) { removing = mirror } 27 Button("Remove", role: .destructive) {
28 .disabled(model.working) 28 scheduled = false
29 removing = mirror
30 }
31 .disabled(model.working)
29 } 32 }
30 } 33 }
31 } footer: { 34 } footer: {
@@ -51,7 +54,10 @@ struct MirrorsView: View {
51 .navigationBarTitleDisplayMode(.inline) 54 .navigationBarTitleDisplayMode(.inline)
52 .toolbar { 55 .toolbar {
53 ToolbarItem(placement: .topBarTrailing) { 56 ToolbarItem(placement: .topBarTrailing) {
54 Button { adding = true } label: { Image(systemName: "plus") } 57 Button {
58 scheduled = false
59 adding = true
60 } label: { Image(systemName: "plus") }
55 .accessibilityLabel("Add Mirror") 61 .accessibilityLabel("Add Mirror")
56 .disabled(model.working) 62 .disabled(model.working)
57 .accessibilityIdentifier("mirror-add-button") 63 .accessibilityIdentifier("mirror-add-button")
@@ -115,6 +121,7 @@ private struct MirrorAddSheet: View {
115 @State private var pull = false 121 @State private var pull = false
116 @State private var username = "" 122 @State private var username = ""
117 @State private var token = "" 123 @State private var token = ""
124 @State private var attempted = false
118 125
119 var body: some View { 126 var body: some View {
120 NavigationStack { 127 NavigationStack {
@@ -146,7 +153,7 @@ private struct MirrorAddSheet: View {
146 } footer: { 153 } footer: {
147 Text("For a remote that needs credentials. The token is stored for syncs and not shown again.") 154 Text("For a remote that needs credentials. The token is stored for syncs and not shown again.")
148 } 155 }
149 if let error = model.actionError { 156 if attempted, let error = model.actionError {
150 Section { GBNotice(error, .gbWarn) } 157 Section { GBNotice(error, .gbWarn) }
151 } 158 }
152 } 159 }
@@ -162,6 +169,7 @@ private struct MirrorAddSheet: View {
162 } else { 169 } else {
163 Button("Add") { 170 Button("Add") {
164 Task { 171 Task {
172 attempted = true
165 guard await model.add(url: url, pull: pull, username: username, token: token) else { return } 173 guard await model.add(url: url, pull: pull, username: username, token: token) else { return }
166 // A secure field that still holds text as the sheet 174 // A secure field that still holds text as the sheet
167 // goes makes iOS offer to save it as a password. 175 // goes makes iOS offer to save it as a password.
@@ -207,7 +215,7 @@ struct PageDomainsView: View {
207 VStack(alignment: .leading, spacing: 3) { 215 VStack(alignment: .leading, spacing: 3) {
208 Text(claim.domain).font(.gbMono(.subheadline)) 216 Text(claim.domain).font(.gbMono(.subheadline))
209 HStack(spacing: 6) { 217 HStack(spacing: 6) {
210 GBChip(claim.isVerified ? "verified" : "pending", claim.isVerified ? Color.gbOK : Color.gbWarn) 218 GBChip(claim.state, claim.isVerified ? Color.gbOK : claim.isPending ? Color.gbWarn : Color.secondary)
211 if let at = claim.verifiedAt { 219 if let at = claim.verifiedAt {
212 Text("since \(at.formatted(date: .abbreviated, time: .omitted))") 220 Text("since \(at.formatted(date: .abbreviated, time: .omitted))")
213 .font(.gbSans(.caption2)) 221 .font(.gbSans(.caption2))
@@ -218,9 +226,12 @@ struct PageDomainsView: View {
218 .padding(.vertical, 2) 226 .padding(.vertical, 2)
219 .accessibilityElement(children: .combine) 227 .accessibilityElement(children: .combine)
220 .swipeActions { 228 .swipeActions {
221 Button("Remove", role: .destructive) { removing = claim } 229 Button("Remove", role: .destructive) {
222 .disabled(model.working) 230 verified = nil
223 if !claim.isVerified { 231 removing = claim
232 }
233 .disabled(model.working)
234 if claim.isPending {
224 Button("Verify") { verify(claim.domain) } 235 Button("Verify") { verify(claim.domain) }
225 .tint(.gbAccent) 236 .tint(.gbAccent)
226 .disabled(model.working) 237 .disabled(model.working)
@@ -229,7 +240,7 @@ struct PageDomainsView: View {
229 } 240 }
230 } footer: { 241 } footer: {
231 if model.state.value != nil { 242 if model.state.value != nil {
232 Text("A domain serves this repository's pages branch once a DNS TXT record proves the claim. Swipe a pending claim to verify it.") 243 Text("A domain serves this repository's pages branch once a DNS TXT record proves the claim. Swipe a pending claim to verify it; an expired one has to be claimed again.")
233 } 244 }
234 } 245 }
235 } 246 }
@@ -238,7 +249,10 @@ struct PageDomainsView: View {
238 .navigationBarTitleDisplayMode(.inline) 249 .navigationBarTitleDisplayMode(.inline)
239 .toolbar { 250 .toolbar {
240 ToolbarItem(placement: .topBarTrailing) { 251 ToolbarItem(placement: .topBarTrailing) {
241 Button { adding = true } label: { Image(systemName: "plus") } 252 Button {
253 verified = nil
254 adding = true
255 } label: { Image(systemName: "plus") }
242 .accessibilityLabel("Claim Domain") 256 .accessibilityLabel("Claim Domain")
243 .disabled(model.working) 257 .disabled(model.working)
244 .accessibilityIdentifier("domain-add-button") 258 .accessibilityIdentifier("domain-add-button")
@@ -283,23 +297,20 @@ private struct DomainClaimSheet: View {
283 297
284 @State private var domain = "" 298 @State private var domain = ""
285 @State private var challenge: DomainChallenge? 299 @State private var challenge: DomainChallenge?
300 @State private var attempted = false
286 301
287 var body: some View { 302 var body: some View {
288 NavigationStack { 303 NavigationStack {
289 Form { 304 Form {
290 if let challenge { 305 if let challenge {
291 Section { 306 Section {
292 LabeledContent("Name") { 307 record("Name", challenge.name, id: "domain-copy-name")
293 Text(challenge.name).font(.gbMono(.caption)).textSelection(.enabled)
294 }
295 LabeledContent("Type", value: "TXT") 308 LabeledContent("Type", value: "TXT")
296 LabeledContent("Value") { 309 record("Value", challenge.value, id: "domain-copy-value")
297 Text(challenge.value).font(.gbMono(.caption)).textSelection(.enabled)
298 }
299 } header: { 310 } header: {
300 Text("Create this DNS record") 311 Text("Create this DNS record")
301 } footer: { 312 } footer: {
302 Text("Then verify. An unverified claim expires after a few days; it can also be verified later from the list.") 313 Text("This record is shown only now. Create it, then verify, here or later from the list. An unverified claim expires after a few days.")
303 } 314 }
304 } else { 315 } else {
305 Section { 316 Section {
@@ -312,7 +323,7 @@ private struct DomainClaimSheet: View {
312 Text("A domain you control. Claiming it gives you a DNS record that proves it.") 323 Text("A domain you control. Claiming it gives you a DNS record that proves it.")
313 } 324 }
314 } 325 }
315 if let error = model.actionError { 326 if attempted, let error = model.actionError {
316 Section { GBNotice(error, .gbWarn) } 327 Section { GBNotice(error, .gbWarn) }
317 } 328 }
318 } 329 }
@@ -329,19 +340,44 @@ private struct DomainClaimSheet: View {
329 ProgressView() 340 ProgressView()
330 } else if let challenge { 341 } else if let challenge {
331 Button("Verify") { 342 Button("Verify") {
332 Task { if await model.verify(challenge.domain) { onDone(challenge.domain) } } 343 Task {
344 attempted = true
345 if await model.verify(challenge.domain) { onDone(challenge.domain) }
346 }
333 } 347 }
334 .accessibilityIdentifier("domain-verify") 348 .accessibilityIdentifier("domain-verify")
335 } else { 349 } else {
336 Button("Claim") { 350 Button("Claim") {
337 Task { challenge = await model.add(domain) } 351 Task {
352 attempted = true
353 challenge = await model.add(domain)
354 }
338 } 355 }
339 .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty) 356 .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty)
340 .accessibilityIdentifier("domain-submit") 357 .accessibilityIdentifier("domain-submit")
341 } 358 }
342 } 359 }
343 } 360 }
344 .interactiveDismissDisabled(model.working) 361 // The record cannot be fetched again, so it closes only by Done.
362 .interactiveDismissDisabled(model.working || challenge != nil)
363 }
364 }
365
366 private func record(_ label: String, _ text: String, id: String) -> some View {
367 HStack(alignment: .firstTextBaseline) {
368 VStack(alignment: .leading, spacing: 2) {
369 Text(label).font(.gbSans(.caption)).foregroundStyle(.secondary)
370 Text(text).font(.gbMono(.caption)).textSelection(.enabled)
371 }
372 Spacer()
373 Button {
374 UIPasteboard.general.string = text
375 } label: {
376 Image(systemName: "doc.on.doc")
377 }
378 .buttonStyle(.borderless)
379 .accessibilityLabel("Copy \(label)")
380 .accessibilityIdentifier(id)
345 } 381 }
346 } 382 }
347} 383}
gitbayTests/RepoMirrorDomainTests.swift +20 −3
@@ -29,7 +29,8 @@ private let mirrorsJSON = """
29private let domainsJSON = """ 29private let domainsJSON = """
30 {"protocol_version":1,"data":[\ 30 {"protocol_version":1,"data":[\
31 {"domain":"docs.example.test","state":"pending"},\ 31 {"domain":"docs.example.test","state":"pending"},\
32 {"domain":"www.example.test","state":"verified","verified_at":"2026-10-01T09:00:00Z"}\ 32 {"domain":"www.example.test","state":"verified","verified_at":"2026-10-01T09:00:00Z"},\
33 {"domain":"old.example.test","state":"expired"}\
33 ],"exit_code":0} 34 ],"exit_code":0}
34 """ 35 """
35 36
@@ -107,9 +108,10 @@ struct PageDomainsViewModelTests {
107 await model.load() 108 await model.load()
108 109
109 let domains = try #require(model.state.value) 110 let domains = try #require(model.state.value)
110 #expect(domains.map(\.domain) == ["docs.example.test", "www.example.test"]) 111 #expect(domains.map(\.domain) == ["docs.example.test", "www.example.test", "old.example.test"])
111 #expect(!domains[0].isVerified && domains[0].verifiedAt == nil) 112 #expect(domains[0].isPending && domains[0].verifiedAt == nil)
112 #expect(domains[1].isVerified && domains[1].verifiedAt != nil) 113 #expect(domains[1].isVerified && domains[1].verifiedAt != nil)
114 #expect(!domains[2].isPending && !domains[2].isVerified)
113 } 115 }
114 116
115 @Test func addReturnsTheDNSChallenge() async throws { 117 @Test func addReturnsTheDNSChallenge() async throws {
@@ -137,6 +139,21 @@ struct PageDomainsViewModelTests {
137 #expect(model.actionError == #"_gitbay-challenge.docs.example.test does not carry the expected record "x""#) 139 #expect(model.actionError == #"_gitbay-challenge.docs.example.test does not carry the expected record "x""#)
138 } 140 }
139 141
142 @Test func aRefusedVerifyRefreshesTheList() async throws {
143 let (client, stub) = try makeClient()
144 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"the claim on old.example.test expired; run repo domain add again","exit_code":2}"#, match: "cmd"))
145 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":[],"exit_code":0}"#, match: "argv=list"))
146 let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay")
147
148 #expect(await model.verify("old.example.test") == false)
149
150 #expect(model.actionError == "the claim on old.example.test expired; run repo domain add again")
151 guard case .empty = model.state else {
152 Issue.record("expected the removed claim gone, got \(model.state)")
153 return
154 }
155 }
156
140 @Test func verifyAndRemoveNameTheDomain() async throws { 157 @Test func verifyAndRemoveNameTheDomain() async throws {
141 let (client, stub) = try makeClient() 158 let (client, stub) = try makeClient()
142 for _ in 0..<2 { 159 for _ in 0..<2 {