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 {
8484 } catch {
8585 actionError = GitbayError.transport(error).userFacingMessage
8686 }
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()
8790 return false
8891 }
8992}
@@ -91,12 +94,15 @@ final class MirrorsViewModel {
9194/// One row of `repo domain list`.
9295nonisolated struct PageDomain: Decodable, Sendable, Hashable, Identifiable {
9396 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.
9599 let state: String
96100 var verifiedAt: Date? = nil
97101 var id: String { domain }
98102
99103 var isVerified: Bool { state == "verified" }
104 /// Waiting on its DNS record; an `expired` claim is neither.
105 var isPending: Bool { state == "pending" }
100106
101107 enum CodingKeys: String, CodingKey {
102108 case domain, state
@@ -185,6 +191,9 @@ final class PageDomainsViewModel {
185191 } catch {
186192 actionError = GitbayError.transport(error).userFacingMessage
187193 }
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()
188197 return false
189198 }
190199}
gitbay/Views/Repos/RepoMirrorDomainViews.swift +58 −22
@@ -18,14 +18,17 @@ struct MirrorsView: View {
1818 if let error = model.actionError, !adding {
1919 Section { GBNotice(error, .gbWarn) }
2020 } 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) }
2222 }
2323 Section {
2424 ForEach(model.state.value ?? []) { mirror in
2525 row(mirror)
2626 .swipeActions {
27 Button("Remove", role: .destructive) { removing = mirror }
28 .disabled(model.working)
27 Button("Remove", role: .destructive) {
28 scheduled = false
29 removing = mirror
30 }
31 .disabled(model.working)
2932 }
3033 }
3134 } footer: {
@@ -51,7 +54,10 @@ struct MirrorsView: View {
5154 .navigationBarTitleDisplayMode(.inline)
5255 .toolbar {
5356 ToolbarItem(placement: .topBarTrailing) {
54 Button { adding = true } label: { Image(systemName: "plus") }
57 Button {
58 scheduled = false
59 adding = true
60 } label: { Image(systemName: "plus") }
5561 .accessibilityLabel("Add Mirror")
5662 .disabled(model.working)
5763 .accessibilityIdentifier("mirror-add-button")
@@ -115,6 +121,7 @@ private struct MirrorAddSheet: View {
115121 @State private var pull = false
116122 @State private var username = ""
117123 @State private var token = ""
124 @State private var attempted = false
118125
119126 var body: some View {
120127 NavigationStack {
@@ -146,7 +153,7 @@ private struct MirrorAddSheet: View {
146153 } footer: {
147154 Text("For a remote that needs credentials. The token is stored for syncs and not shown again.")
148155 }
149 if let error = model.actionError {
156 if attempted, let error = model.actionError {
150157 Section { GBNotice(error, .gbWarn) }
151158 }
152159 }
@@ -162,6 +169,7 @@ private struct MirrorAddSheet: View {
162169 } else {
163170 Button("Add") {
164171 Task {
172 attempted = true
165173 guard await model.add(url: url, pull: pull, username: username, token: token) else { return }
166174 // A secure field that still holds text as the sheet
167175 // goes makes iOS offer to save it as a password.
@@ -207,7 +215,7 @@ struct PageDomainsView: View {
207215 VStack(alignment: .leading, spacing: 3) {
208216 Text(claim.domain).font(.gbMono(.subheadline))
209217 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)
211219 if let at = claim.verifiedAt {
212220 Text("since \(at.formatted(date: .abbreviated, time: .omitted))")
213221 .font(.gbSans(.caption2))
@@ -218,9 +226,12 @@ struct PageDomainsView: View {
218226 .padding(.vertical, 2)
219227 .accessibilityElement(children: .combine)
220228 .swipeActions {
221 Button("Remove", role: .destructive) { removing = claim }
222 .disabled(model.working)
223 if !claim.isVerified {
229 Button("Remove", role: .destructive) {
230 verified = nil
231 removing = claim
232 }
233 .disabled(model.working)
234 if claim.isPending {
224235 Button("Verify") { verify(claim.domain) }
225236 .tint(.gbAccent)
226237 .disabled(model.working)
@@ -229,7 +240,7 @@ struct PageDomainsView: View {
229240 }
230241 } footer: {
231242 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.")
233244 }
234245 }
235246 }
@@ -238,7 +249,10 @@ struct PageDomainsView: View {
238249 .navigationBarTitleDisplayMode(.inline)
239250 .toolbar {
240251 ToolbarItem(placement: .topBarTrailing) {
241 Button { adding = true } label: { Image(systemName: "plus") }
252 Button {
253 verified = nil
254 adding = true
255 } label: { Image(systemName: "plus") }
242256 .accessibilityLabel("Claim Domain")
243257 .disabled(model.working)
244258 .accessibilityIdentifier("domain-add-button")
@@ -283,23 +297,20 @@ private struct DomainClaimSheet: View {
283297
284298 @State private var domain = ""
285299 @State private var challenge: DomainChallenge?
300 @State private var attempted = false
286301
287302 var body: some View {
288303 NavigationStack {
289304 Form {
290305 if let challenge {
291306 Section {
292 LabeledContent("Name") {
293 Text(challenge.name).font(.gbMono(.caption)).textSelection(.enabled)
294 }
307 record("Name", challenge.name, id: "domain-copy-name")
295308 LabeledContent("Type", value: "TXT")
296 LabeledContent("Value") {
297 Text(challenge.value).font(.gbMono(.caption)).textSelection(.enabled)
298 }
309 record("Value", challenge.value, id: "domain-copy-value")
299310 } header: {
300311 Text("Create this DNS record")
301312 } 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.")
303314 }
304315 } else {
305316 Section {
@@ -312,7 +323,7 @@ private struct DomainClaimSheet: View {
312323 Text("A domain you control. Claiming it gives you a DNS record that proves it.")
313324 }
314325 }
315 if let error = model.actionError {
326 if attempted, let error = model.actionError {
316327 Section { GBNotice(error, .gbWarn) }
317328 }
318329 }
@@ -329,19 +340,44 @@ private struct DomainClaimSheet: View {
329340 ProgressView()
330341 } else if let challenge {
331342 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 }
333347 }
334348 .accessibilityIdentifier("domain-verify")
335349 } else {
336350 Button("Claim") {
337 Task { challenge = await model.add(domain) }
351 Task {
352 attempted = true
353 challenge = await model.add(domain)
354 }
338355 }
339356 .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty)
340357 .accessibilityIdentifier("domain-submit")
341358 }
342359 }
343360 }
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)
345381 }
346382 }
347383}
gitbayTests/RepoMirrorDomainTests.swift +20 −3
@@ -29,7 +29,8 @@ private let mirrorsJSON = """
2929private let domainsJSON = """
3030 {"protocol_version":1,"data":[\
3131 {"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"}\
3334 ],"exit_code":0}
3435 """
3536
@@ -107,9 +108,10 @@ struct PageDomainsViewModelTests {
107108 await model.load()
108109
109110 let domains = try #require(model.state.value)
110 #expect(domains.map(\.domain) == ["docs.example.test", "www.example.test"])
111 #expect(!domains[0].isVerified && domains[0].verifiedAt == nil)
111 #expect(domains.map(\.domain) == ["docs.example.test", "www.example.test", "old.example.test"])
112 #expect(domains[0].isPending && domains[0].verifiedAt == nil)
112113 #expect(domains[1].isVerified && domains[1].verifiedAt != nil)
114 #expect(!domains[2].isPending && !domains[2].isVerified)
113115 }
114116
115117 @Test func addReturnsTheDNSChallenge() async throws {
@@ -137,6 +139,21 @@ struct PageDomainsViewModelTests {
137139 #expect(model.actionError == #"_gitbay-challenge.docs.example.test does not carry the expected record "x""#)
138140 }
139141
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
140157 @Test func verifyAndRemoveNameTheDomain() async throws {
141158 let (client, stub) = try makeClient()
142159 for _ in 0..<2 {