Commit 3f57451dba
Verified · cmc
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -331,6 +331,10 @@ private struct RouteDestinations: ViewModifier { | ||
| 331 | 331 | BuildSecretsView(client: client, repo: repo) |
| 332 | 332 | case .deployKeys(let repo): |
| 333 | 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 | 338 | case .wiki(let repo): |
| 335 | 339 | WikiView(client: client, repo: repo) |
| 336 | 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 | 27 | case webhookDeliveries(repo: String) |
| 28 | 28 | case buildSecrets(repo: String) |
| 29 | 29 | case deployKeys(repo: String) |
| 30 | case mirrors(repo: String) | |
| 31 | case pageDomains(repo: String) | |
| 30 | 32 | case profile(String) |
| 31 | 33 | case account |
| 32 | 34 | case tokens |
gitbay/Views/Repos/RepoSettingsView.swift +10
| @@ -561,6 +561,16 @@ struct RepoSettingsView: View { | ||
| 561 | 561 | .font(.gbSans(.subheadline)) |
| 562 | 562 | } |
| 563 | 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 | } | |