Commit 3f57451dba
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -331,6 +331,10 @@ private struct RouteDestinations: ViewModifier { | |||
| 331 | BuildSecretsView(client: client, repo: repo) | 331 | BuildSecretsView(client: client, repo: repo) |
| 332 | case .deployKeys(let repo): | 332 | case .deployKeys(let repo): |
| 333 | DeployKeysView(client: client, repo: repo) | 333 | DeployKeysView(client: client, repo: repo) |
| 334 | case .mirrors(let repo): | ||
| 335 | MirrorsView(client: client, repo: repo) | ||
| 336 | case .pageDomains(let repo): | ||
| 337 | PageDomainsView(client: client, repo: repo) | ||
| 334 | case .wiki(let repo): | 338 | case .wiki(let repo): |
| 335 | WikiView(client: client, repo: repo) | 339 | WikiView(client: client, repo: repo) |
| 336 | case .wikiPage(let repo, let page): | 340 | case .wikiPage(let repo, let page): |
gitbay/Repos/RepoMirrorsDomains.swift added +190
| @@ -0,0 +1,190 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// One row of `repo mirror list`. | ||
| 5 | nonisolated 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 | ||
| 28 | final 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 | return false | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | /// One row of `repo domain list`. | ||
| 92 | nonisolated struct PageDomain: Decodable, Sendable, Hashable, Identifiable { | ||
| 93 | let domain: String | ||
| 94 | /// `pending` until the DNS challenge is verified. | ||
| 95 | let state: String | ||
| 96 | var verifiedAt: Date? = nil | ||
| 97 | var id: String { domain } | ||
| 98 | |||
| 99 | var isVerified: Bool { state == "verified" } | ||
| 100 | |||
| 101 | enum CodingKeys: String, CodingKey { | ||
| 102 | case domain, state | ||
| 103 | case verifiedAt = "verified_at" | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | /// The TXT record that proves a claim, shown once when it is made. | ||
| 108 | nonisolated struct DomainChallenge: Decodable, Sendable, Hashable { | ||
| 109 | let domain: String | ||
| 110 | let name: String | ||
| 111 | let value: String | ||
| 112 | |||
| 113 | enum CodingKeys: String, CodingKey { | ||
| 114 | case domain | ||
| 115 | case name = "challenge_name" | ||
| 116 | case value = "challenge_value" | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | /// `repo domain list`, `add`, `verify` and `remove`: custom domains that | ||
| 121 | /// serve the repository's pages once a DNS TXT record proves the claim. | ||
| 122 | @Observable | ||
| 123 | @MainActor | ||
| 124 | final class PageDomainsViewModel { | ||
| 125 | |||
| 126 | private(set) var state: LoadState<[PageDomain]> = .loading | ||
| 127 | private(set) var actionError: String? | ||
| 128 | private(set) var working = false | ||
| 129 | |||
| 130 | private let client: GitbayClient | ||
| 131 | let repoPath: String | ||
| 132 | |||
| 133 | init(client: GitbayClient, repoPath: String) { | ||
| 134 | self.client = client | ||
| 135 | self.repoPath = repoPath | ||
| 136 | } | ||
| 137 | |||
| 138 | func load() async { | ||
| 139 | do { | ||
| 140 | let domains = try await client.readList(["repo", "domain", "list", repoPath], of: PageDomain.self) | ||
| 141 | state = domains.isEmpty ? .empty("No custom domains yet.") : .loaded(domains) | ||
| 142 | } catch { | ||
| 143 | state = .from(error) | ||
| 144 | } | ||
| 145 | } | ||
| 146 | |||
| 147 | /// Claims a domain; the record to create comes back, and only now. | ||
| 148 | func add(_ domain: String) async -> DomainChallenge? { | ||
| 149 | let domain = domain.trimmingCharacters(in: .whitespaces) | ||
| 150 | guard !domain.isEmpty else { return nil } | ||
| 151 | working = true | ||
| 152 | actionError = nil | ||
| 153 | defer { working = false } | ||
| 154 | do { | ||
| 155 | let challenge = try await client.run(["repo", "domain", "add", repoPath, domain], as: DomainChallenge.self) | ||
| 156 | await load() | ||
| 157 | return challenge | ||
| 158 | } catch let error as GitbayError { | ||
| 159 | actionError = error.messageForTypedInput | ||
| 160 | } catch { | ||
| 161 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 162 | } | ||
| 163 | return nil | ||
| 164 | } | ||
| 165 | |||
| 166 | func verify(_ domain: String) async -> Bool { | ||
| 167 | await perform(["repo", "domain", "verify", repoPath, domain]) | ||
| 168 | } | ||
| 169 | |||
| 170 | func remove(_ domain: String) async { | ||
| 171 | await perform(["repo", "domain", "remove", repoPath, domain]) | ||
| 172 | } | ||
| 173 | |||
| 174 | @discardableResult | ||
| 175 | private func perform(_ argv: [String]) async -> Bool { | ||
| 176 | working = true | ||
| 177 | actionError = nil | ||
| 178 | defer { working = false } | ||
| 179 | do { | ||
| 180 | try await client.run(argv) | ||
| 181 | await load() | ||
| 182 | return true | ||
| 183 | } catch let error as GitbayError { | ||
| 184 | actionError = error.messageForTypedInput | ||
| 185 | } catch { | ||
| 186 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 187 | } | ||
| 188 | return false | ||
| 189 | } | ||
| 190 | } | ||
gitbay/Views/Repos/RepoMirrorDomainViews.swift added +347
| @@ -0,0 +1,347 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// A repository's mirrors: where each pushes to or pulls from, how its | ||
| 4 | /// last sync went, a way to sync now, add and remove. | ||
| 5 | struct MirrorsView: View { | ||
| 6 | |||
| 7 | @State private var model: MirrorsViewModel | ||
| 8 | @State private var adding = false | ||
| 9 | @State private var removing: Mirror? | ||
| 10 | @State private var scheduled = false | ||
| 11 | |||
| 12 | init(client: GitbayClient, repo: String) { | ||
| 13 | _model = State(initialValue: MirrorsViewModel(client: client, repoPath: repo)) | ||
| 14 | } | ||
| 15 | |||
| 16 | var body: some View { | ||
| 17 | List { | ||
| 18 | if let error = model.actionError, !adding { | ||
| 19 | Section { GBNotice(error, .gbWarn) } | ||
| 20 | } else if scheduled { | ||
| 21 | Section { GBNotice("Sync scheduled. The list updates as each mirror finishes.", .gbOK) } | ||
| 22 | } | ||
| 23 | Section { | ||
| 24 | ForEach(model.state.value ?? []) { mirror in | ||
| 25 | row(mirror) | ||
| 26 | .swipeActions { | ||
| 27 | Button("Remove", role: .destructive) { removing = mirror } | ||
| 28 | .disabled(model.working) | ||
| 29 | } | ||
| 30 | } | ||
| 31 | } footer: { | ||
| 32 | if model.state.value != nil { | ||
| 33 | Text("A push mirror sends every push here to the remote. A pull mirror fetches from it, and pushes here are refused.") | ||
| 34 | } | ||
| 35 | } | ||
| 36 | if model.state.value != nil { | ||
| 37 | Section { | ||
| 38 | Button { | ||
| 39 | Task { scheduled = await model.sync() } | ||
| 40 | } label: { | ||
| 41 | Label("Sync now", systemImage: "arrow.triangle.2.circlepath") | ||
| 42 | .font(.gbSans(.subheadline)) | ||
| 43 | } | ||
| 44 | .disabled(model.working) | ||
| 45 | .accessibilityIdentifier("mirror-sync-button") | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | ||
| 49 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 50 | .navigationTitle("Mirrors") | ||
| 51 | .navigationBarTitleDisplayMode(.inline) | ||
| 52 | .toolbar { | ||
| 53 | ToolbarItem(placement: .topBarTrailing) { | ||
| 54 | Button { adding = true } label: { Image(systemName: "plus") } | ||
| 55 | .accessibilityLabel("Add Mirror") | ||
| 56 | .disabled(model.working) | ||
| 57 | .accessibilityIdentifier("mirror-add-button") | ||
| 58 | } | ||
| 59 | } | ||
| 60 | .sheet(isPresented: $adding) { | ||
| 61 | MirrorAddSheet(model: model) { adding = false } | ||
| 62 | } | ||
| 63 | .confirmationDialog( | ||
| 64 | "Remove the mirror \(removing?.isPull == true ? "from" : "to") \(removing?.url ?? "")?", | ||
| 65 | isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), | ||
| 66 | titleVisibility: .visible | ||
| 67 | ) { | ||
| 68 | Button("Remove", role: .destructive) { | ||
| 69 | if let mirror = removing { Task { await model.remove(id: mirror.id) } } | ||
| 70 | removing = nil | ||
| 71 | } | ||
| 72 | Button("Cancel", role: .cancel) {} | ||
| 73 | } | ||
| 74 | .task { await model.load() } | ||
| 75 | .refreshable { | ||
| 76 | scheduled = false | ||
| 77 | await model.load() | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | private func row(_ mirror: Mirror) -> some View { | ||
| 82 | VStack(alignment: .leading, spacing: 3) { | ||
| 83 | Text(mirror.url) | ||
| 84 | .font(.gbMono(.caption)) | ||
| 85 | .lineLimit(2) | ||
| 86 | HStack(spacing: 6) { | ||
| 87 | GBChip(mirror.isPull ? "pull" : "push", .secondary) | ||
| 88 | if let username = mirror.username { | ||
| 89 | Text("as \(username)") | ||
| 90 | } | ||
| 91 | if mirror.pending { | ||
| 92 | Text("sync pending") | ||
| 93 | } else if let synced = mirror.lastSync { | ||
| 94 | Text("synced \(synced.formatted(.relative(presentation: .named)))") | ||
| 95 | } | ||
| 96 | } | ||
| 97 | .font(.gbSans(.caption2)) | ||
| 98 | .foregroundStyle(.secondary) | ||
| 99 | if let error = mirror.lastError, !error.isEmpty { | ||
| 100 | Text(error) | ||
| 101 | .font(.gbSans(.caption2)) | ||
| 102 | .foregroundStyle(Color.gbBad) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | .padding(.vertical, 2) | ||
| 106 | .accessibilityElement(children: .combine) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | private struct MirrorAddSheet: View { | ||
| 111 | let model: MirrorsViewModel | ||
| 112 | let onDone: () -> Void | ||
| 113 | |||
| 114 | @State private var url = "" | ||
| 115 | @State private var pull = false | ||
| 116 | @State private var username = "" | ||
| 117 | @State private var token = "" | ||
| 118 | |||
| 119 | var body: some View { | ||
| 120 | NavigationStack { | ||
| 121 | Form { | ||
| 122 | Section { | ||
| 123 | TextField("https://…", text: $url) | ||
| 124 | .autocorrectionDisabled() | ||
| 125 | .textInputAutocapitalization(.never) | ||
| 126 | .keyboardType(.URL) | ||
| 127 | .accessibilityIdentifier("mirror-url") | ||
| 128 | Picker("Direction", selection: $pull) { | ||
| 129 | Text("Push").tag(false) | ||
| 130 | Text("Pull").tag(true) | ||
| 131 | } | ||
| 132 | .pickerStyle(.segmented) | ||
| 133 | .accessibilityIdentifier("mirror-direction") | ||
| 134 | } footer: { | ||
| 135 | Text(pull | ||
| 136 | ? "Pulls from the remote. Pushes to this repository are refused while the mirror exists." | ||
| 137 | : "Pushes here are sent on to the remote.") | ||
| 138 | } | ||
| 139 | Section { | ||
| 140 | TextField("Username (optional)", text: $username) | ||
| 141 | .autocorrectionDisabled() | ||
| 142 | .textInputAutocapitalization(.never) | ||
| 143 | SecureField("Token (optional)", text: $token) | ||
| 144 | .autocorrectionDisabled() | ||
| 145 | .textInputAutocapitalization(.never) | ||
| 146 | } footer: { | ||
| 147 | Text("For a remote that needs credentials. The token is stored for syncs and not shown again.") | ||
| 148 | } | ||
| 149 | if let error = model.actionError { | ||
| 150 | Section { GBNotice(error, .gbWarn) } | ||
| 151 | } | ||
| 152 | } | ||
| 153 | .navigationTitle("Add Mirror") | ||
| 154 | .navigationBarTitleDisplayMode(.inline) | ||
| 155 | .toolbar { | ||
| 156 | ToolbarItem(placement: .cancellationAction) { | ||
| 157 | Button("Cancel") { onDone() }.disabled(model.working) | ||
| 158 | } | ||
| 159 | ToolbarItem(placement: .confirmationAction) { | ||
| 160 | if model.working { | ||
| 161 | ProgressView() | ||
| 162 | } else { | ||
| 163 | Button("Add") { | ||
| 164 | Task { | ||
| 165 | 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 | ||
| 167 | // goes makes iOS offer to save it as a password. | ||
| 168 | token = "" | ||
| 169 | await Task.yield() | ||
| 170 | onDone() | ||
| 171 | } | ||
| 172 | } | ||
| 173 | .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty) | ||
| 174 | .accessibilityIdentifier("mirror-submit") | ||
| 175 | } | ||
| 176 | } | ||
| 177 | } | ||
| 178 | .interactiveDismissDisabled(model.working) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | /// A repository's custom pages domains: each claim, verified or waiting | ||
| 184 | /// on its DNS record, with verify and remove. | ||
| 185 | struct PageDomainsView: View { | ||
| 186 | |||
| 187 | @State private var model: PageDomainsViewModel | ||
| 188 | @State private var adding = false | ||
| 189 | @State private var removing: PageDomain? | ||
| 190 | @State private var verified: String? | ||
| 191 | |||
| 192 | init(client: GitbayClient, repo: String) { | ||
| 193 | _model = State(initialValue: PageDomainsViewModel(client: client, repoPath: repo)) | ||
| 194 | } | ||
| 195 | |||
| 196 | var body: some View { | ||
| 197 | List { | ||
| 198 | if let error = model.actionError, !adding { | ||
| 199 | Section { GBNotice(error, .gbWarn) } | ||
| 200 | } else if let verified { | ||
| 201 | Section { | ||
| 202 | GBNotice("\(verified) is verified. Point its A and AAAA records at this server.", .gbOK) | ||
| 203 | } | ||
| 204 | } | ||
| 205 | Section { | ||
| 206 | ForEach(model.state.value ?? []) { claim in | ||
| 207 | VStack(alignment: .leading, spacing: 3) { | ||
| 208 | Text(claim.domain).font(.gbMono(.subheadline)) | ||
| 209 | HStack(spacing: 6) { | ||
| 210 | GBChip(claim.isVerified ? "verified" : "pending", claim.isVerified ? Color.gbOK : Color.gbWarn) | ||
| 211 | if let at = claim.verifiedAt { | ||
| 212 | Text("since \(at.formatted(date: .abbreviated, time: .omitted))") | ||
| 213 | .font(.gbSans(.caption2)) | ||
| 214 | .foregroundStyle(.secondary) | ||
| 215 | } | ||
| 216 | } | ||
| 217 | } | ||
| 218 | .padding(.vertical, 2) | ||
| 219 | .accessibilityElement(children: .combine) | ||
| 220 | .swipeActions { | ||
| 221 | Button("Remove", role: .destructive) { removing = claim } | ||
| 222 | .disabled(model.working) | ||
| 223 | if !claim.isVerified { | ||
| 224 | Button("Verify") { verify(claim.domain) } | ||
| 225 | .tint(.gbAccent) | ||
| 226 | .disabled(model.working) | ||
| 227 | } | ||
| 228 | } | ||
| 229 | } | ||
| 230 | } footer: { | ||
| 231 | 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.") | ||
| 233 | } | ||
| 234 | } | ||
| 235 | } | ||
| 236 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 237 | .navigationTitle("Custom Domains") | ||
| 238 | .navigationBarTitleDisplayMode(.inline) | ||
| 239 | .toolbar { | ||
| 240 | ToolbarItem(placement: .topBarTrailing) { | ||
| 241 | Button { adding = true } label: { Image(systemName: "plus") } | ||
| 242 | .accessibilityLabel("Claim Domain") | ||
| 243 | .disabled(model.working) | ||
| 244 | .accessibilityIdentifier("domain-add-button") | ||
| 245 | } | ||
| 246 | } | ||
| 247 | .sheet(isPresented: $adding) { | ||
| 248 | DomainClaimSheet(model: model) { domain in | ||
| 249 | adding = false | ||
| 250 | if let domain { verified = domain } | ||
| 251 | } | ||
| 252 | } | ||
| 253 | .confirmationDialog( | ||
| 254 | "Remove \(removing?.domain ?? "")? It stops serving this repository's pages.", | ||
| 255 | isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), | ||
| 256 | titleVisibility: .visible | ||
| 257 | ) { | ||
| 258 | Button("Remove", role: .destructive) { | ||
| 259 | if let claim = removing { Task { await model.remove(claim.domain) } } | ||
| 260 | removing = nil | ||
| 261 | } | ||
| 262 | Button("Cancel", role: .cancel) {} | ||
| 263 | } | ||
| 264 | .task { await model.load() } | ||
| 265 | .refreshable { | ||
| 266 | verified = nil | ||
| 267 | await model.load() | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 271 | private func verify(_ domain: String) { | ||
| 272 | verified = nil | ||
| 273 | Task { if await model.verify(domain) { verified = domain } } | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | /// Claims a domain, then shows the TXT record to create and a way to | ||
| 278 | /// verify it once it is in place. The record is shown only here. | ||
| 279 | private struct DomainClaimSheet: View { | ||
| 280 | let model: PageDomainsViewModel | ||
| 281 | /// The domain when it was verified from here, else nil. | ||
| 282 | let onDone: (String?) -> Void | ||
| 283 | |||
| 284 | @State private var domain = "" | ||
| 285 | @State private var challenge: DomainChallenge? | ||
| 286 | |||
| 287 | var body: some View { | ||
| 288 | NavigationStack { | ||
| 289 | Form { | ||
| 290 | if let challenge { | ||
| 291 | Section { | ||
| 292 | LabeledContent("Name") { | ||
| 293 | Text(challenge.name).font(.gbMono(.caption)).textSelection(.enabled) | ||
| 294 | } | ||
| 295 | LabeledContent("Type", value: "TXT") | ||
| 296 | LabeledContent("Value") { | ||
| 297 | Text(challenge.value).font(.gbMono(.caption)).textSelection(.enabled) | ||
| 298 | } | ||
| 299 | } header: { | ||
| 300 | Text("Create this DNS record") | ||
| 301 | } footer: { | ||
| 302 | Text("Then verify. An unverified claim expires after a few days; it can also be verified later from the list.") | ||
| 303 | } | ||
| 304 | } else { | ||
| 305 | Section { | ||
| 306 | TextField("docs.example.com", text: $domain) | ||
| 307 | .autocorrectionDisabled() | ||
| 308 | .textInputAutocapitalization(.never) | ||
| 309 | .keyboardType(.URL) | ||
| 310 | .accessibilityIdentifier("domain-name") | ||
| 311 | } footer: { | ||
| 312 | Text("A domain you control. Claiming it gives you a DNS record that proves it.") | ||
| 313 | } | ||
| 314 | } | ||
| 315 | if let error = model.actionError { | ||
| 316 | Section { GBNotice(error, .gbWarn) } | ||
| 317 | } | ||
| 318 | } | ||
| 319 | .navigationTitle(challenge == nil ? "Claim Domain" : challenge!.domain) | ||
| 320 | .navigationBarTitleDisplayMode(.inline) | ||
| 321 | .toolbar { | ||
| 322 | ToolbarItem(placement: .cancellationAction) { | ||
| 323 | Button(challenge == nil ? "Cancel" : "Done") { onDone(nil) } | ||
| 324 | .disabled(model.working) | ||
| 325 | .accessibilityIdentifier("domain-done") | ||
| 326 | } | ||
| 327 | ToolbarItem(placement: .confirmationAction) { | ||
| 328 | if model.working { | ||
| 329 | ProgressView() | ||
| 330 | } else if let challenge { | ||
| 331 | Button("Verify") { | ||
| 332 | Task { if await model.verify(challenge.domain) { onDone(challenge.domain) } } | ||
| 333 | } | ||
| 334 | .accessibilityIdentifier("domain-verify") | ||
| 335 | } else { | ||
| 336 | Button("Claim") { | ||
| 337 | Task { challenge = await model.add(domain) } | ||
| 338 | } | ||
| 339 | .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty) | ||
| 340 | .accessibilityIdentifier("domain-submit") | ||
| 341 | } | ||
| 342 | } | ||
| 343 | } | ||
| 344 | .interactiveDismissDisabled(model.working) | ||
| 345 | } | ||
| 346 | } | ||
| 347 | } | ||
gitbay/Views/Repos/RepoRoute.swift +2
| @@ -27,6 +27,8 @@ nonisolated enum RepoRoute: Hashable { | |||
| 27 | case webhookDeliveries(repo: String) | 27 | case webhookDeliveries(repo: String) |
| 28 | case buildSecrets(repo: String) | 28 | case buildSecrets(repo: String) |
| 29 | case deployKeys(repo: String) | 29 | case deployKeys(repo: String) |
| 30 | case mirrors(repo: String) | ||
| 31 | case pageDomains(repo: String) | ||
| 30 | case profile(String) | 32 | case profile(String) |
| 31 | case account | 33 | case account |
| 32 | case tokens | 34 | case tokens |
gitbay/Views/Repos/RepoSettingsView.swift +10
| @@ -561,6 +561,16 @@ struct RepoSettingsView: View { | |||
| 561 | .font(.gbSans(.subheadline)) | 561 | .font(.gbSans(.subheadline)) |
| 562 | } | 562 | } |
| 563 | .accessibilityIdentifier("settings-deploy-keys-link") | 563 | .accessibilityIdentifier("settings-deploy-keys-link") |
| 564 | NavigationLink(value: RepoRoute.mirrors(repo: model.repoPath)) { | ||
| 565 | Label("Mirrors", systemImage: "arrow.triangle.branch") | ||
| 566 | .font(.gbSans(.subheadline)) | ||
| 567 | } | ||
| 568 | .accessibilityIdentifier("settings-mirrors-link") | ||
| 569 | NavigationLink(value: RepoRoute.pageDomains(repo: model.repoPath)) { | ||
| 570 | Label("Custom domains", systemImage: "globe") | ||
| 571 | .font(.gbSans(.subheadline)) | ||
| 572 | } | ||
| 573 | .accessibilityIdentifier("settings-domains-link") | ||
| 564 | } | 574 | } |
| 565 | } | 575 | } |
| 566 | 576 | ||
gitbayTests/RepoMirrorDomainTests.swift added +156
| @@ -0,0 +1,156 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 6 | let box = StubProtocol.box() | ||
| 7 | let client = GitbayClient( | ||
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 9 | token: "test-token", | ||
| 10 | session: box.session() | ||
| 11 | ) | ||
| 12 | return (client, box) | ||
| 13 | } | ||
| 14 | |||
| 15 | private func writes(_ stub: StubProtocol.Box) throws -> [[String: Any]] { | ||
| 16 | try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map { | ||
| 17 | try #require(try JSONSerialization.jsonObject(with: $0.body) as? [String: Any]) | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 22 | private let mirrorsJSON = """ | ||
| 23 | {"protocol_version":1,"data":[\ | ||
| 24 | {"id":1,"direction":"push","url":"https://github.com/krz/gitbay.git","username":"bot","pending":true},\ | ||
| 25 | {"id":2,"direction":"pull","url":"https://example.test/up.git","pending":false,\ | ||
| 26 | "last_sync":"2026-10-15T10:00:00.123Z","last_error":"authentication failed"}\ | ||
| 27 | ],"exit_code":0} | ||
| 28 | """ | ||
| 29 | private let domainsJSON = """ | ||
| 30 | {"protocol_version":1,"data":[\ | ||
| 31 | {"domain":"docs.example.test","state":"pending"},\ | ||
| 32 | {"domain":"www.example.test","state":"verified","verified_at":"2026-10-01T09:00:00Z"}\ | ||
| 33 | ],"exit_code":0} | ||
| 34 | """ | ||
| 35 | |||
| 36 | @MainActor | ||
| 37 | struct MirrorsViewModelTests { | ||
| 38 | |||
| 39 | @Test func listsMirrorsWithTheirSyncState() async throws { | ||
| 40 | let (client, stub) = try makeClient() | ||
| 41 | stub.enqueue(.init(status: 200, json: mirrorsJSON)) | ||
| 42 | let model = MirrorsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 43 | await model.load() | ||
| 44 | |||
| 45 | let mirrors = try #require(model.state.value) | ||
| 46 | #expect(mirrors.map(\.id) == [1, 2]) | ||
| 47 | #expect(mirrors[0].pending && mirrors[0].lastSync == nil && mirrors[0].username == "bot") | ||
| 48 | #expect(mirrors[1].isPull && mirrors[1].lastSync != nil && mirrors[1].lastError == "authentication failed") | ||
| 49 | #expect(stub.seen.first?.url.query() == "argv=repo&argv=mirror&argv=list&argv=krz/gitbay") | ||
| 50 | } | ||
| 51 | |||
| 52 | @Test func addSendsTheTokenOnStdinOnlyWhenGiven() async throws { | ||
| 53 | let (client, stub) = try makeClient() | ||
| 54 | for _ in 0..<2 { | ||
| 55 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"id":3},"exit_code":0}"#, match: "cmd")) | ||
| 56 | stub.enqueue(.init(status: 200, json: mirrorsJSON, match: "argv=list")) | ||
| 57 | } | ||
| 58 | let model = MirrorsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 59 | |||
| 60 | #expect(await model.add(url: " https://example.test/a.git ", pull: false, username: "", token: "")) | ||
| 61 | #expect(await model.add(url: "https://example.test/b.git", pull: true, username: "bot", token: "t0k")) | ||
| 62 | |||
| 63 | let sent = try writes(stub) | ||
| 64 | #expect(sent.map { $0["argv"] as? [String] } == [ | ||
| 65 | ["repo", "mirror", "add", "krz/gitbay", "https://example.test/a.git", "--direction", "push"], | ||
| 66 | ["repo", "mirror", "add", "krz/gitbay", "https://example.test/b.git", "--direction", "pull", | ||
| 67 | "--username", "bot", "--token-stdin"], | ||
| 68 | ]) | ||
| 69 | #expect(sent[0]["stdin"] == nil) | ||
| 70 | #expect(sent[1]["stdin"] as? String == "t0k\n") | ||
| 71 | } | ||
| 72 | |||
| 73 | @Test func aRefusedURLSaysWhy() async throws { | ||
| 74 | let (client, stub) = try makeClient() | ||
| 75 | stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"that mirror already exists","exit_code":2}"#)) | ||
| 76 | let model = MirrorsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 77 | |||
| 78 | #expect(await model.add(url: "https://example.test/a.git", pull: false, username: "", token: "") == false) | ||
| 79 | #expect(model.actionError == "that mirror already exists") | ||
| 80 | } | ||
| 81 | |||
| 82 | @Test func syncAndRemove() async throws { | ||
| 83 | let (client, stub) = try makeClient() | ||
| 84 | for _ in 0..<2 { | ||
| 85 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 86 | stub.enqueue(.init(status: 200, json: mirrorsJSON, match: "argv=list")) | ||
| 87 | } | ||
| 88 | let model = MirrorsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 89 | |||
| 90 | #expect(await model.sync()) | ||
| 91 | await model.remove(id: 2) | ||
| 92 | |||
| 93 | #expect(try writes(stub).map { $0["argv"] as? [String] } == [ | ||
| 94 | ["repo", "mirror", "sync", "krz/gitbay"], | ||
| 95 | ["repo", "mirror", "remove", "krz/gitbay", "2"], | ||
| 96 | ]) | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | @MainActor | ||
| 101 | struct PageDomainsViewModelTests { | ||
| 102 | |||
| 103 | @Test func listsClaimsWithTheirState() async throws { | ||
| 104 | let (client, stub) = try makeClient() | ||
| 105 | stub.enqueue(.init(status: 200, json: domainsJSON)) | ||
| 106 | let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 107 | await model.load() | ||
| 108 | |||
| 109 | 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) | ||
| 112 | #expect(domains[1].isVerified && domains[1].verifiedAt != nil) | ||
| 113 | } | ||
| 114 | |||
| 115 | @Test func addReturnsTheDNSChallenge() async throws { | ||
| 116 | let (client, stub) = try makeClient() | ||
| 117 | stub.enqueue(.init(status: 200, json: """ | ||
| 118 | {"protocol_version":1,"data":{"challenge_name":"_gitbay-challenge.docs.example.test",\ | ||
| 119 | "challenge_value":"gitbay-domain-verify=abc","domain":"docs.example.test","state":"pending"},"exit_code":0} | ||
| 120 | """, match: "cmd")) | ||
| 121 | stub.enqueue(.init(status: 200, json: domainsJSON, match: "argv=list")) | ||
| 122 | let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 123 | |||
| 124 | let challenge = try #require(await model.add(" Docs.Example.Test ")) | ||
| 125 | |||
| 126 | #expect(challenge.name == "_gitbay-challenge.docs.example.test") | ||
| 127 | #expect(challenge.value == "gitbay-domain-verify=abc") | ||
| 128 | #expect(try writes(stub).first?["argv"] as? [String] == ["repo", "domain", "add", "krz/gitbay", "Docs.Example.Test"]) | ||
| 129 | } | ||
| 130 | |||
| 131 | @Test func verifyReportsAMissingRecordVerbatim() async throws { | ||
| 132 | let (client, stub) = try makeClient() | ||
| 133 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"_gitbay-challenge.docs.example.test does not carry the expected record \"x\"","exit_code":4}"#)) | ||
| 134 | let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 135 | |||
| 136 | #expect(await model.verify("docs.example.test") == false) | ||
| 137 | #expect(model.actionError == #"_gitbay-challenge.docs.example.test does not carry the expected record "x""#) | ||
| 138 | } | ||
| 139 | |||
| 140 | @Test func verifyAndRemoveNameTheDomain() async throws { | ||
| 141 | let (client, stub) = try makeClient() | ||
| 142 | for _ in 0..<2 { | ||
| 143 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"domain":"d","state":"verified"},"exit_code":0}"#, match: "cmd")) | ||
| 144 | stub.enqueue(.init(status: 200, json: domainsJSON, match: "argv=list")) | ||
| 145 | } | ||
| 146 | let model = PageDomainsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 147 | |||
| 148 | #expect(await model.verify("docs.example.test")) | ||
| 149 | await model.remove("www.example.test") | ||
| 150 | |||
| 151 | #expect(try writes(stub).map { $0["argv"] as? [String] } == [ | ||
| 152 | ["repo", "domain", "verify", "krz/gitbay", "docs.example.test"], | ||
| 153 | ["repo", "domain", "remove", "krz/gitbay", "www.example.test"], | ||
| 154 | ]) | ||
| 155 | } | ||
| 156 | } | ||