import Foundation import Observation /// One row of `repo mirror list`. nonisolated struct Mirror: Decodable, Sendable, Hashable, Identifiable { let id: Int64 /// `push` or `pull`. let direction: String let url: String var username: String? = nil /// Waiting for the worker's next sync. let pending: Bool var lastSync: Date? = nil var lastError: String? = nil var isPull: Bool { direction == "pull" } enum CodingKeys: String, CodingKey { case id, direction, url, username, pending case lastSync = "last_sync" case lastError = "last_error" } } /// `repo mirror list`, `add`, `remove` and `sync`. @Observable @MainActor final class MirrorsViewModel { private(set) var state: LoadState<[Mirror]> = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath } func load() async { do { let mirrors = try await client.readList(["repo", "mirror", "list", repoPath], of: Mirror.self) state = mirrors.isEmpty ? .empty("No mirrors yet.") : .loaded(mirrors) } catch { state = .from(error) } } /// `repo mirror add --direction push|pull [--username ] /// [--token-stdin]`. The token travels on stdin, never in argv. func add(url: String, pull: Bool, username: String, token: String) async -> Bool { let url = url.trimmingCharacters(in: .whitespaces) guard !url.isEmpty else { return false } var argv = ["repo", "mirror", "add", repoPath, url, "--direction", pull ? "pull" : "push"] let username = username.trimmingCharacters(in: .whitespaces) if !username.isEmpty { argv += ["--username", username] } let token = token.trimmingCharacters(in: .whitespacesAndNewlines) if !token.isEmpty { argv.append("--token-stdin") } return await perform(argv, stdin: token.isEmpty ? nil : token + "\n") } /// Asks the worker to sync every mirror of the repository now. func sync() async -> Bool { await perform(["repo", "mirror", "sync", repoPath]) } func remove(id: Int64) async { await perform(["repo", "mirror", "remove", repoPath, String(id)]) } @discardableResult private func perform(_ argv: [String], stdin: String? = nil) async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() return true } catch let error as GitbayError { actionError = error.messageForTypedInput } catch { actionError = GitbayError.transport(error).userFacingMessage } // A refusal may mean the list changed: a claim that expired is // removed by the verify that finds it so. await load() return false } } /// One row of `repo domain list`. nonisolated struct PageDomain: Decodable, Sendable, Hashable, Identifiable { let domain: String /// `pending` until the DNS challenge is verified, `expired` once the /// claim lapsed unverified. let state: String var verifiedAt: Date? = nil var id: String { domain } var isVerified: Bool { state == "verified" } /// Waiting on its DNS record; an `expired` claim is neither. var isPending: Bool { state == "pending" } enum CodingKeys: String, CodingKey { case domain, state case verifiedAt = "verified_at" } } /// The TXT record that proves a claim, shown once when it is made. nonisolated struct DomainChallenge: Decodable, Sendable, Hashable { let domain: String let name: String let value: String enum CodingKeys: String, CodingKey { case domain case name = "challenge_name" case value = "challenge_value" } } /// `repo domain list`, `add`, `verify` and `remove`: custom domains that /// serve the repository's pages once a DNS TXT record proves the claim. @Observable @MainActor final class PageDomainsViewModel { private(set) var state: LoadState<[PageDomain]> = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath } func load() async { do { let domains = try await client.readList(["repo", "domain", "list", repoPath], of: PageDomain.self) state = domains.isEmpty ? .empty("No custom domains yet.") : .loaded(domains) } catch { state = .from(error) } } /// Claims a domain; the record to create comes back, and only now. func add(_ domain: String) async -> DomainChallenge? { let domain = domain.trimmingCharacters(in: .whitespaces) guard !domain.isEmpty else { return nil } working = true actionError = nil defer { working = false } do { let challenge = try await client.run(["repo", "domain", "add", repoPath, domain], as: DomainChallenge.self) await load() return challenge } catch let error as GitbayError { actionError = error.messageForTypedInput } catch { actionError = GitbayError.transport(error).userFacingMessage } return nil } func verify(_ domain: String) async -> Bool { await perform(["repo", "domain", "verify", repoPath, domain]) } func remove(_ domain: String) async { await perform(["repo", "domain", "remove", repoPath, domain]) } @discardableResult private func perform(_ argv: [String]) async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(argv) await load() return true } catch let error as GitbayError { actionError = error.messageForTypedInput } catch { actionError = GitbayError.transport(error).userFacingMessage } // A refusal may mean the list changed: a claim that expired is // removed by the verify that finds it so. await load() return false } }