diff options
| author | Christian Cleberg <[email protected]> | 2026-04-13 22:37:22 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-13 22:37:22 -0500 |
| commit | 5dcfe0f147c7871eaf5f3a88bd9e9fded38d4633 (patch) | |
| tree | 6bcbda4f02a131d3e3b870a3904ccd42f4af2f26 | |
| parent | fcf864a15b70e4ecb5bd789b1db3116221c34394 (diff) | |
| download | rune-1.2.0.tar.gz rune-1.2.0.tar.bz2 rune-1.2.0.zip | |
v1.2.0 domain management and wallet expansionv1.2.0
Complete v1.1-aligned domain flows with forwards and glue CRUD, improve API payload resilience, and add wallet transaction/payment views.
Harden error handling and UX states across domain screens, remove out-of-scope DNSSEC/renewal record flows for now, and keep auth/session behavior aligned with token-based usage.
| -rw-r--r-- | Rune.xcodeproj/project.pbxproj | 12 | ||||
| -rw-r--r-- | Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate | bin | 19968 -> 26305 bytes | |||
| -rw-r--r-- | Rune/API/Models.swift | 201 | ||||
| -rw-r--r-- | Rune/API/NjallaClient.swift | 82 | ||||
| -rw-r--r-- | Rune/App/RuneApp.swift | 5 | ||||
| -rw-r--r-- | Rune/ViewModels/DomainViewModel.swift | 93 | ||||
| -rw-r--r-- | Rune/ViewModels/SettingsViewModel.swift | 20 | ||||
| -rw-r--r-- | Rune/ViewModels/WalletViewModel.swift | 65 | ||||
| -rw-r--r-- | Rune/Views/Domains/DomainDetailView.swift | 8 | ||||
| -rw-r--r-- | Rune/Views/Domains/DomainEditView.swift | 8 | ||||
| -rw-r--r-- | Rune/Views/Domains/DomainListView.swift | 6 | ||||
| -rw-r--r-- | Rune/Views/Domains/ForwardListView.swift | 6 | ||||
| -rw-r--r-- | Rune/Views/Domains/GlueEditView.swift | 139 | ||||
| -rw-r--r-- | Rune/Views/Domains/GlueListView.swift | 139 | ||||
| -rw-r--r-- | Rune/Views/Domains/RecordListView.swift | 6 | ||||
| -rw-r--r-- | Rune/Views/Settings/SettingsView.swift | 19 | ||||
| -rw-r--r-- | Rune/Views/Settings/WalletPaymentDetailView.swift | 45 | ||||
| -rw-r--r-- | Rune/Views/Settings/WalletTransactionsView.swift | 75 |
18 files changed, 878 insertions, 51 deletions
diff --git a/Rune.xcodeproj/project.pbxproj b/Rune.xcodeproj/project.pbxproj index af9980a..7359a64 100644 --- a/Rune.xcodeproj/project.pbxproj +++ b/Rune.xcodeproj/project.pbxproj @@ -177,7 +177,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2630; - LastUpgradeCheck = 2630; + LastUpgradeCheck = 2640; TargetAttributes = { 8B9907682F7206E2007853B5 = { CreatedOnToolsVersion = 26.3; @@ -335,6 +335,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -392,6 +393,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; @@ -403,7 +405,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -419,7 +421,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Rune; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -438,7 +440,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -454,7 +456,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Rune; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate b/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate Binary files differindex dd86bef..fc8169f 100644 --- a/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate +++ b/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate diff --git a/Rune/API/Models.swift b/Rune/API/Models.swift index 161e8df..31f928d 100644 --- a/Rune/API/Models.swift +++ b/Rune/API/Models.swift @@ -18,8 +18,46 @@ struct TokenListResponse: Codable { let tokens: [APIToken] } -struct ForwardListResponse: Codable { +struct ForwardListResponse: Decodable { let forwards: [EmailForward] + + enum CodingKeys: String, CodingKey { + case forwards + case mailforwards + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let forwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .forwards) + let mailForwardsValue = try? container.decodeIfPresent([EmailForward].self, forKey: .mailforwards) + forwards = forwardsValue ?? mailForwardsValue ?? [] + } +} + +struct GlueListResponse: Decodable { + let glue: [GlueRecord] + + enum CodingKeys: String, CodingKey { + case glue + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + glue = (try container.decodeIfPresent([GlueRecord].self, forKey: .glue)) ?? [] + } +} + +struct WalletTransactionListResponse: Decodable { + let transactions: [WalletTransaction] + + enum CodingKeys: String, CodingKey { + case transactions + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + transactions = (try container.decodeIfPresent([WalletTransaction].self, forKey: .transactions)) ?? [] + } } struct Domain: Decodable, Identifiable, Hashable { @@ -28,6 +66,7 @@ struct Domain: Decodable, Identifiable, Hashable { let name: String let status: String? let expiry: String? + let renewPrice: Int? let autorenew: Bool? let mailforwarding: Bool? let dnssec: Bool? @@ -38,6 +77,9 @@ struct Domain: Decodable, Identifiable, Hashable { case name case status case expiry + case renewPrice = "renew_price" + case renewalPrice = "renewal_price" + case price case autorenew case mailforwarding case dnssec @@ -52,6 +94,10 @@ struct Domain: Decodable, Identifiable, Hashable { name = try container.decode(String.self, forKey: .name) status = try container.decodeIfPresent(String.self, forKey: .status) expiry = try container.decodeIfPresent(String.self, forKey: .expiry) + renewPrice = + container.decodeLossyIntIfPresent(forKey: .renewPrice) ?? + container.decodeLossyIntIfPresent(forKey: .renewalPrice) ?? + container.decodeLossyIntIfPresent(forKey: .price) autorenew = try container.decodeIfPresent(Bool.self, forKey: .autorenew) mailforwarding = try container.decodeIfPresent(Bool.self, forKey: .mailforwarding) dnssec = try container.decodeIfPresent(Bool.self, forKey: .dnssec) ?? @@ -62,6 +108,20 @@ struct Domain: Decodable, Identifiable, Hashable { } } +private extension KeyedDecodingContainer where K == Domain.CodingKeys { + func decodeLossyIntIfPresent(forKey key: K) -> Int? { + if let intValue = try? decodeIfPresent(Int.self, forKey: key) { + return intValue + } + + if let stringValue = try? decodeIfPresent(String.self, forKey: key) { + return Int(stringValue) + } + + return nil + } +} + struct DNSRecord: Codable, Identifiable, Hashable { let id: String let domain: String @@ -183,11 +243,121 @@ struct EmailForward: Codable, Hashable, Identifiable { let from: String let to: String + enum CodingKeys: String, CodingKey { + case domain + case from + case to + } + + init(domain: String, from: String, to: String) { + self.domain = domain + self.from = from + self.to = to + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawFrom = try container.decode(String.self, forKey: .from).trimmingCharacters(in: .whitespacesAndNewlines) + let to = try container.decode(String.self, forKey: .to).trimmingCharacters(in: .whitespacesAndNewlines) + + let decodedDomain = try container.decodeIfPresent(String.self, forKey: .domain)? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let atIndex = rawFrom.lastIndex(of: "@") { + let localPart = String(rawFrom[..<atIndex]) + let fromDomain = String(rawFrom[rawFrom.index(after: atIndex)...]) + self.from = localPart.isEmpty ? rawFrom : localPart + if let decodedDomain, !decodedDomain.isEmpty { + self.domain = decodedDomain + } else { + self.domain = fromDomain + } + } else { + self.from = rawFrom + self.domain = decodedDomain ?? "" + } + + self.to = to + } + var id: String { "\(domain)|\(from)|\(to)" } } +struct GlueRecord: Codable, Hashable, Identifiable { + let domain: String + let name: String + let address4: String? + let address6: String? + + enum CodingKeys: String, CodingKey { + case domain + case name + case address4 + case address6 + } + + init(domain: String, name: String, address4: String?, address6: String?) { + self.domain = domain + self.name = name + self.address4 = address4 + self.address6 = address6 + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + domain = (try container.decodeIfPresent(String.self, forKey: .domain)) ?? "" + name = try container.decode(String.self, forKey: .name) + address4 = try container.decodeIfPresent(String.self, forKey: .address4) + address6 = try container.decodeIfPresent(String.self, forKey: .address6) + } + + var id: String { + "\(domain)|\(name)" + } + + func withDomain(_ domain: String) -> GlueRecord { + GlueRecord(domain: domain, name: name, address4: address4, address6: address6) + } +} + +struct WalletTransaction: Codable, Hashable, Identifiable { + let id: String + let type: String? + let status: String? + let amount: Int? + let date: String? + let details: String? + + enum CodingKeys: String, CodingKey { + case id + case type + case status + case amount + case date + case details = "description" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try? container.decodeLossyString(forKey: .id)) ?? UUID().uuidString + type = try container.decodeIfPresent(String.self, forKey: .type) + status = try container.decodeIfPresent(String.self, forKey: .status) + amount = try container.decodeLossyIntIfPresent(forKey: .amount) + date = try container.decodeIfPresent(String.self, forKey: .date) + details = try container.decodeIfPresent(String.self, forKey: .details) + } +} + +struct WalletPayment: Codable, Hashable { + let id: String? + let amount: Int? + let status: String? + let address: String? + let url: String? +} + enum DNSRecordType: String, CaseIterable, Identifiable, Codable { case a = "A" case aaaa = "AAAA" @@ -546,3 +716,32 @@ private extension KeyedDecodingContainer where K == DNSRecord.CodingKeys { return nil } } + +private extension KeyedDecodingContainer where K == WalletTransaction.CodingKeys { + func decodeLossyString(forKey key: K) throws -> String { + if let stringValue = try decodeIfPresent(String.self, forKey: key) { + return stringValue + } + + if let intValue = try decodeIfPresent(Int.self, forKey: key) { + return String(intValue) + } + + throw DecodingError.keyNotFound( + key, + DecodingError.Context(codingPath: codingPath, debugDescription: "Missing required value for \(key.stringValue).") + ) + } + + func decodeLossyIntIfPresent(forKey key: K) throws -> Int? { + if let intValue = try decodeIfPresent(Int.self, forKey: key) { + return intValue + } + + if let stringValue = try decodeIfPresent(String.self, forKey: key) { + return Int(stringValue) + } + + return nil + } +} diff --git a/Rune/API/NjallaClient.swift b/Rune/API/NjallaClient.swift index 8da52cb..f91c924 100644 --- a/Rune/API/NjallaClient.swift +++ b/Rune/API/NjallaClient.swift @@ -25,10 +25,17 @@ struct NjallaClient: Sendable, Equatable { request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) - _ = (response as? HTTPURLResponse)?.statusCode ?? -1 + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + debugLogRawResponse(method: method, statusCode: statusCode, data: data) let decoder = JSONDecoder() - let envelope = try decoder.decode(RPCResponse<T>.self, from: data) + let envelope: RPCResponse<T> + do { + envelope = try decoder.decode(RPCResponse<T>.self, from: data) + } catch { + debugLogDecodeFailure(method: method, error: error) + throw error + } if let error = envelope.error { throw NjallaError.api(message: error.message) @@ -41,6 +48,19 @@ struct NjallaClient: Sendable, Equatable { return result } + private func debugLogRawResponse(method: String, statusCode: Int, data: Data) { + #if DEBUG + let body = String(data: data, encoding: .utf8) ?? "<non-utf8 body: \(data.count) bytes>" + debugPrint("[NjallaClient][\(method)] status=\(statusCode) raw=\(body)") + #endif + } + + private func debugLogDecodeFailure(method: String, error: Error) { + #if DEBUG + debugPrint("[NjallaClient][\(method)] decode-failure=\(error.localizedDescription)") + #endif + } + func listDomains() async throws -> [Domain] { let response: DomainListResponse = try await call("list-domains") return response.domains @@ -83,6 +103,51 @@ struct NjallaClient: Sendable, Equatable { ) } + func listGlue(for domain: String) async throws -> [GlueRecord] { + let response: GlueListResponse = try await call("list-glue", params: ["domain": domain]) + return response.glue.map { record in + record.domain.isEmpty ? record.withDomain(domain) : record + } + } + + func addGlue(for domain: String, name: String, address4: String?, address6: String?) async throws { + var params: [String: Any] = [ + "domain": domain, + "name": name + ] + if let address4, !address4.isEmpty { + params["address4"] = address4 + } + if let address6, !address6.isEmpty { + params["address6"] = address6 + } + let _: EmptyResult = try await call("add-glue", params: params) + } + + func editGlue(for domain: String, name: String, address4: String?, address6: String?) async throws { + var params: [String: Any] = [ + "domain": domain, + "name": name + ] + if let address4, !address4.isEmpty { + params["address4"] = address4 + } + if let address6, !address6.isEmpty { + params["address6"] = address6 + } + let _: EmptyResult = try await call("edit-glue", params: params) + } + + func removeGlue(for domain: String, name: String) async throws { + let _: EmptyResult = try await call( + "remove-glue", + params: [ + "domain": domain, + "name": name + ] + ) + } + func listRecords(for domain: String) async throws -> [DNSRecord] { let response: RecordListResponse = try await call("list-records", params: ["domain": domain]) return response.records.map { record in @@ -124,9 +189,22 @@ struct NjallaClient: Sendable, Equatable { let _: EmptyResult = try await call("remove-token", params: ["key": key]) } + func logout() async throws { + let _: EmptyResult = try await call("logout") + } + func getBalance() async throws -> WalletBalance { try await call("get-balance") } + + func listTransactions() async throws -> [WalletTransaction] { + let response: WalletTransactionListResponse = try await call("list-transactions") + return response.transactions + } + + func getPayment(id: String) async throws -> WalletPayment { + try await call("get-payment", params: ["id": id]) + } } enum NjallaError: LocalizedError { diff --git a/Rune/App/RuneApp.swift b/Rune/App/RuneApp.swift index 1709532..c826363 100644 --- a/Rune/App/RuneApp.swift +++ b/Rune/App/RuneApp.swift @@ -13,6 +13,7 @@ private struct RootView: View { @StateObject private var settingsViewModel = SettingsViewModel() @StateObject private var domainViewModel = DomainViewModel() @StateObject private var tokenViewModel = TokenViewModel() + @StateObject private var walletViewModel = WalletViewModel() @State private var selectedTab = 0 var body: some View { @@ -38,9 +39,10 @@ private struct RootView: View { } .tag(1) - SettingsView(viewModel: settingsViewModel) { + SettingsView(viewModel: settingsViewModel, walletViewModel: walletViewModel) { domainViewModel.reset() tokenViewModel.reset() + walletViewModel.reset() selectedTab = 0 } .tabItem { @@ -66,6 +68,7 @@ private struct RootView: View { guard let client = settingsViewModel.client else { domainViewModel.reset() tokenViewModel.reset() + walletViewModel.reset() selectedTab = 0 return } diff --git a/Rune/ViewModels/DomainViewModel.swift b/Rune/ViewModels/DomainViewModel.swift index 1da1bbe..37fb7a1 100644 --- a/Rune/ViewModels/DomainViewModel.swift +++ b/Rune/ViewModels/DomainViewModel.swift @@ -7,16 +7,19 @@ final class DomainViewModel: ObservableObject { @Published private(set) var selectedDomain: Domain? @Published private(set) var records: [DNSRecord] = [] @Published private(set) var forwards: [EmailForward] = [] + @Published private(set) var glueRecords: [GlueRecord] = [] @Published private(set) var isLoadingDomains = false @Published private(set) var isLoadingDetail = false @Published private(set) var isLoadingRecords = false @Published private(set) var isLoadingForwards = false + @Published private(set) var isLoadingGlue = false @Published private(set) var isSaving = false @Published private(set) var hasLoadedDomains = false @Published var domainsErrorMessage: String? @Published var detailErrorMessage: String? @Published var recordsErrorMessage: String? @Published var forwardsErrorMessage: String? + @Published var glueErrorMessage: String? @Published var mutationErrorMessage: String? private var recordsRefreshTask: Task<Void, Never>? @@ -26,16 +29,19 @@ final class DomainViewModel: ObservableObject { selectedDomain = nil records = [] forwards = [] + glueRecords = [] isLoadingDomains = false isLoadingDetail = false isLoadingRecords = false isLoadingForwards = false + isLoadingGlue = false isSaving = false hasLoadedDomains = false domainsErrorMessage = nil detailErrorMessage = nil recordsErrorMessage = nil forwardsErrorMessage = nil + glueErrorMessage = nil mutationErrorMessage = nil stopAutoRefreshRecords() } @@ -167,6 +173,85 @@ final class DomainViewModel: ObservableObject { } } + func loadGlue(for domain: String, client: NjallaClient) async { + guard !isLoadingGlue else { return } + + isLoadingGlue = true + defer { + isLoadingGlue = false + } + + do { + glueRecords = try await client.listGlue(for: domain).sorted { $0.name < $1.name } + glueErrorMessage = nil + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + glueErrorMessage = error.userFacingMessage + } + } + + func addGlue(for domain: String, name: String, address4: String?, address6: String?, client: NjallaClient) async throws { + guard !isSaving else { return } + + isSaving = true + mutationErrorMessage = nil + + do { + try await client.addGlue(for: domain, name: name, address4: address4, address6: address6) + isSaving = false + Task { + await loadGlue(for: domain, client: client) + } + } catch { + isSaving = false + mutationErrorMessage = error.userFacingMessage + throw error + } + } + + func editGlue(for domain: String, name: String, address4: String?, address6: String?, client: NjallaClient) async throws { + guard !isSaving else { return } + + isSaving = true + mutationErrorMessage = nil + + do { + try await client.editGlue(for: domain, name: name, address4: address4, address6: address6) + isSaving = false + Task { + await loadGlue(for: domain, client: client) + } + } catch { + isSaving = false + mutationErrorMessage = error.userFacingMessage + throw error + } + } + + func removeGlue(_ record: GlueRecord, client: NjallaClient) async throws { + guard !isSaving else { return } + + isSaving = true + mutationErrorMessage = nil + + do { + try await client.removeGlue(for: record.domain, name: record.name) + glueRecords.removeAll { $0.id == record.id } + isSaving = false + Task { + await loadGlue(for: record.domain, client: client) + } + } catch { + isSaving = false + mutationErrorMessage = error.userFacingMessage + throw error + } + } + private func fetchRecords(for domain: String, client: NjallaClient) async { guard !isLoadingRecords else { return } @@ -255,10 +340,12 @@ final class DomainViewModel: ObservableObject { do { let updatedRecords = try await client.removeRecord(record) - guard updatedRecords.contains(where: { $0.id == record.id }) == false else { - throw NjallaError.api(message: "The API response indicates the record was not removed.") + if updatedRecords.contains(where: { $0.id == record.id }) { + // Some API responses can be eventually consistent; treat successful call as authoritative. + records.removeAll { $0.id == record.id } + } else { + records = sortedRecords(updatedRecords) } - records = sortedRecords(updatedRecords) recordsErrorMessage = nil isSaving = false Task { diff --git a/Rune/ViewModels/SettingsViewModel.swift b/Rune/ViewModels/SettingsViewModel.swift index 6b773eb..c253ab8 100644 --- a/Rune/ViewModels/SettingsViewModel.swift +++ b/Rune/ViewModels/SettingsViewModel.swift @@ -83,12 +83,12 @@ final class SettingsViewModel: ObservableObject { } } - func logout() throws { + func logout() async throws { + if let client { + try await client.logout() + } try keychainManager.deleteToken() - client = nil - balance = nil - isAuthenticated = false - errorMessage = nil + clearLocalSession() } func logoutIfCurrentTokenDeleted(_ key: String) throws -> Bool { @@ -96,7 +96,15 @@ final class SettingsViewModel: ObservableObject { return false } - try logout() + try keychainManager.deleteToken() + clearLocalSession() return true } + + private func clearLocalSession() { + client = nil + balance = nil + isAuthenticated = false + errorMessage = nil + } } diff --git a/Rune/ViewModels/WalletViewModel.swift b/Rune/ViewModels/WalletViewModel.swift new file mode 100644 index 0000000..cbe0538 --- /dev/null +++ b/Rune/ViewModels/WalletViewModel.swift @@ -0,0 +1,65 @@ +import Combine +import Foundation + +@MainActor +final class WalletViewModel: ObservableObject { + @Published private(set) var transactions: [WalletTransaction] = [] + @Published private(set) var selectedPayment: WalletPayment? + @Published private(set) var isLoadingTransactions = false + @Published private(set) var isLoadingPayment = false + @Published var transactionsErrorMessage: String? + @Published var paymentErrorMessage: String? + + func reset() { + transactions = [] + selectedPayment = nil + isLoadingTransactions = false + isLoadingPayment = false + transactionsErrorMessage = nil + paymentErrorMessage = nil + } + + func loadTransactions(client: NjallaClient) async { + guard !isLoadingTransactions else { return } + + isLoadingTransactions = true + defer { + isLoadingTransactions = false + } + + do { + transactions = try await client.listTransactions().sorted { + ($0.date ?? "", $0.id) > ($1.date ?? "", $1.id) + } + transactionsErrorMessage = nil + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + transactionsErrorMessage = error.userFacingMessage + } + } + + func loadPayment(id: String, client: NjallaClient) async { + guard !isLoadingPayment else { return } + + isLoadingPayment = true + defer { + isLoadingPayment = false + } + + do { + selectedPayment = try await client.getPayment(id: id) + paymentErrorMessage = nil + } catch is CancellationError { + return + } catch { + if (error as? URLError)?.code == .cancelled { + return + } + paymentErrorMessage = error.userFacingMessage + } + } +} diff --git a/Rune/Views/Domains/DomainDetailView.swift b/Rune/Views/Domains/DomainDetailView.swift index b4ee00d..7fc811a 100644 --- a/Rune/Views/Domains/DomainDetailView.swift +++ b/Rune/Views/Domains/DomainDetailView.swift @@ -35,7 +35,11 @@ struct DomainDetailView: View { NavigationLink("Records") { RecordListView(domainName: domain.name, viewModel: viewModel, client: client) } + NavigationLink("Glue Records") { + GlueListView(domainName: domain.name, viewModel: viewModel, client: client) + } } + } .listStyle(.insetGrouped) .toolbar { @@ -79,11 +83,11 @@ struct DomainDetailView: View { private func nameserverText(_ nameservers: [String]?) -> String { guard let nameservers else { - return "Not available" + return "Njalla" } guard !nameservers.isEmpty else { - return "Default" + return "Njalla" } return nameservers.joined(separator: ", ") diff --git a/Rune/Views/Domains/DomainEditView.swift b/Rune/Views/Domains/DomainEditView.swift index 0b3b1f8..a5fd99b 100644 --- a/Rune/Views/Domains/DomainEditView.swift +++ b/Rune/Views/Domains/DomainEditView.swift @@ -65,14 +65,6 @@ struct DomainEditView: View { .controlSize(.large) } } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel", role: .cancel) { - dismiss() - } - .disabled(viewModel.isSaving) - } - } .interactiveDismissDisabled(viewModel.isSaving) .alert("Request Failed", isPresented: localErrorBinding) { Button("OK", role: .cancel) {} diff --git a/Rune/Views/Domains/DomainListView.swift b/Rune/Views/Domains/DomainListView.swift index 68f2ede..ad8134a 100644 --- a/Rune/Views/Domains/DomainListView.swift +++ b/Rune/Views/Domains/DomainListView.swift @@ -61,12 +61,6 @@ struct DomainListView: View { .refreshable { await viewModel.loadDomains(client: client) } - .overlay(alignment: .top) { - if viewModel.isLoadingDomains && !viewModel.domains.isEmpty { - ProgressView() - .padding(.top, 8) - } - } } } diff --git a/Rune/Views/Domains/ForwardListView.swift b/Rune/Views/Domains/ForwardListView.swift index 4e20ab2..6846a79 100644 --- a/Rune/Views/Domains/ForwardListView.swift +++ b/Rune/Views/Domains/ForwardListView.swift @@ -88,12 +88,6 @@ struct ForwardListView: View { await viewModel.loadForwards(for: domainName, client: client) debugLog("Refresh complete with \(viewModel.forwards.count) forwards for \(domainName)") } - .overlay(alignment: .top) { - if viewModel.isLoadingForwards && !viewModel.forwards.isEmpty { - ProgressView() - .padding(.top, 8) - } - } .alert(deleteAlertTitle, isPresented: deleteBinding) { Button("Delete Forward", role: .destructive) { guard let forwardPendingDeletion else { return } diff --git a/Rune/Views/Domains/GlueEditView.swift b/Rune/Views/Domains/GlueEditView.swift new file mode 100644 index 0000000..b56a5d7 --- /dev/null +++ b/Rune/Views/Domains/GlueEditView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct GlueEditView: View { + let domainName: String + let existingRecord: GlueRecord? + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @Environment(\.dismiss) private var dismiss + @State private var name: String + @State private var address4: String + @State private var address6: String + @State private var localErrorMessage: String? + + init(domainName: String, existingRecord: GlueRecord?, viewModel: DomainViewModel, client: NjallaClient) { + self.domainName = domainName + self.existingRecord = existingRecord + self.viewModel = viewModel + self.client = client + _name = State(initialValue: existingRecord?.name ?? "") + _address4 = State(initialValue: existingRecord?.address4 ?? "") + _address6 = State(initialValue: existingRecord?.address6 ?? "") + } + + var body: some View { + Form { + Section("Record") { + TextField("Name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(existingRecord != nil) + + TextField("IPv4", text: $address4) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + TextField("IPv6", text: $address6) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section { + Button("Save") { + Task { + await save() + } + } + .disabled(viewModel.isSaving) + } + } + .navigationTitle(existingRecord == nil ? "Add Glue" : "Edit Glue") + .navigationBarTitleDisplayMode(.inline) + .overlay { + if viewModel.isSaving { + ProgressView() + .controlSize(.large) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", role: .cancel) { + dismiss() + } + .disabled(viewModel.isSaving) + } + } + .alert("Invalid Glue Data", isPresented: localErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(localErrorMessage ?? "") + } + .alert("Request Failed", isPresented: mutationErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.mutationErrorMessage ?? "") + } + } + + private func save() async { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAddress4 = address4.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAddress6 = address6.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmedName.isEmpty else { + localErrorMessage = "Glue record name is required." + return + } + + guard !trimmedAddress4.isEmpty || !trimmedAddress6.isEmpty else { + localErrorMessage = "Provide at least one address (IPv4 or IPv6)." + return + } + + do { + if existingRecord == nil { + try await viewModel.addGlue( + for: domainName, + name: trimmedName, + address4: trimmedAddress4.isEmpty ? nil : trimmedAddress4, + address6: trimmedAddress6.isEmpty ? nil : trimmedAddress6, + client: client + ) + } else { + try await viewModel.editGlue( + for: domainName, + name: trimmedName, + address4: trimmedAddress4.isEmpty ? nil : trimmedAddress4, + address6: trimmedAddress6.isEmpty ? nil : trimmedAddress6, + client: client + ) + } + dismiss() + } catch { + return + } + } + + private var localErrorBinding: Binding<Bool> { + Binding( + get: { localErrorMessage != nil }, + set: { newValue in + if !newValue { + localErrorMessage = nil + } + } + ) + } + + private var mutationErrorBinding: Binding<Bool> { + Binding( + get: { viewModel.mutationErrorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.dismissMutationError() + } + } + ) + } +} diff --git a/Rune/Views/Domains/GlueListView.swift b/Rune/Views/Domains/GlueListView.swift new file mode 100644 index 0000000..b390a84 --- /dev/null +++ b/Rune/Views/Domains/GlueListView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct GlueListView: View { + let domainName: String + @ObservedObject var viewModel: DomainViewModel + let client: NjallaClient + + @State private var showingAddGlue = false + @State private var recordPendingDeletion: GlueRecord? + + var body: some View { + List { + if let errorMessage = viewModel.glueErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Glue Records") { + Task { + await viewModel.loadGlue(for: domainName, client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + + if viewModel.isLoadingGlue && viewModel.glueRecords.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading Glue") + Spacer() + } + } + } else if viewModel.glueRecords.isEmpty { + Section { + ContentUnavailableView( + "No Glue Records", + systemImage: "list.bullet.rectangle", + description: Text("No glue records are configured for this domain.") + ) + } + } else { + ForEach(viewModel.glueRecords) { record in + NavigationLink { + GlueEditView(domainName: domainName, existingRecord: record, viewModel: viewModel, client: client) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(record.name) + .font(.headline) + Text("IPv4: \(record.address4 ?? "n/a")") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("IPv6: \(record.address6 ?? "n/a")") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + .swipeActions { + Button("Delete", role: .destructive) { + recordPendingDeletion = record + } + } + .disabled(viewModel.isSaving) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Glue Records") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button { + showingAddGlue = true + } label: { + Label("Add Glue", systemImage: "plus") + } + .disabled(viewModel.isSaving) + } + .sheet(isPresented: $showingAddGlue) { + NavigationStack { + GlueEditView(domainName: domainName, existingRecord: nil, viewModel: viewModel, client: client) + } + } + .task { + await viewModel.loadGlue(for: domainName, client: client) + } + .refreshable { + await viewModel.loadGlue(for: domainName, client: client) + } + .alert(deleteAlertTitle, isPresented: deleteBinding) { + Button("Delete Glue", role: .destructive) { + guard let recordPendingDeletion else { return } + Task { + do { + try await viewModel.removeGlue(recordPendingDeletion, client: client) + self.recordPendingDeletion = nil + } catch { + return + } + } + } + Button("Cancel", role: .cancel) { + recordPendingDeletion = nil + } + } message: { + Text("Delete glue record \(recordPendingDeletion?.name ?? "")?") + } + .alert("Request Failed", isPresented: mutationErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.mutationErrorMessage ?? "") + } + } + + private var deleteAlertTitle: String { + guard let recordPendingDeletion else { return "" } + return "Delete glue record \(recordPendingDeletion.name)?" + } + + private var deleteBinding: Binding<Bool> { + Binding( + get: { recordPendingDeletion != nil }, + set: { newValue in + if !newValue { + recordPendingDeletion = nil + } + } + ) + } + + private var mutationErrorBinding: Binding<Bool> { + Binding( + get: { viewModel.mutationErrorMessage != nil }, + set: { newValue in + if !newValue { + viewModel.dismissMutationError() + } + } + ) + } +} diff --git a/Rune/Views/Domains/RecordListView.swift b/Rune/Views/Domains/RecordListView.swift index 6c40ab0..74ab286 100644 --- a/Rune/Views/Domains/RecordListView.swift +++ b/Rune/Views/Domains/RecordListView.swift @@ -73,12 +73,6 @@ struct RecordListView: View { .refreshable { await viewModel.loadRecords(for: domainName, client: client) } - .overlay(alignment: .top) { - if viewModel.isLoadingRecords && !viewModel.records.isEmpty { - ProgressView() - .padding(.top, 8) - } - } .alert("Request Failed", isPresented: mutationErrorBinding) { Button("OK", role: .cancel) {} } message: { diff --git a/Rune/Views/Settings/SettingsView.swift b/Rune/Views/Settings/SettingsView.swift index 4d07887..a776956 100644 --- a/Rune/Views/Settings/SettingsView.swift +++ b/Rune/Views/Settings/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SettingsView: View { @ObservedObject var viewModel: SettingsViewModel + @ObservedObject var walletViewModel: WalletViewModel let onLogout: () -> Void @State private var showingLogoutConfirmation = false @@ -33,6 +34,12 @@ struct SettingsView: View { } } .disabled(viewModel.client == nil || viewModel.isLoadingBalance) + + if let client = viewModel.client { + NavigationLink("Transactions") { + WalletTransactionsView(viewModel: walletViewModel, client: client) + } + } } Section("Account") { @@ -57,11 +64,13 @@ struct SettingsView: View { titleVisibility: .visible ) { Button("Log Out", role: .destructive) { - do { - try viewModel.logout() - onLogout() - } catch { - viewModel.errorMessage = error.localizedDescription + Task { + do { + try await viewModel.logout() + onLogout() + } catch { + viewModel.errorMessage = error.localizedDescription + } } } diff --git a/Rune/Views/Settings/WalletPaymentDetailView.swift b/Rune/Views/Settings/WalletPaymentDetailView.swift new file mode 100644 index 0000000..f5ef605 --- /dev/null +++ b/Rune/Views/Settings/WalletPaymentDetailView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct WalletPaymentDetailView: View { + let transactionID: String + @ObservedObject var viewModel: WalletViewModel + let client: NjallaClient + + var body: some View { + Group { + if viewModel.isLoadingPayment && viewModel.selectedPayment == nil { + ProgressView("Loading Payment") + } else if let payment = viewModel.selectedPayment { + List { + Section("Payment") { + detailRow(label: "ID", value: payment.id ?? transactionID) + detailRow(label: "Status", value: payment.status ?? "Not available") + detailRow(label: "Amount", value: payment.amount.map { "€\($0)" } ?? "Not available") + detailRow(label: "Address", value: payment.address ?? "Not available") + detailRow(label: "URL", value: payment.url ?? "Not available") + } + } + .listStyle(.insetGrouped) + } else if let errorMessage = viewModel.paymentErrorMessage { + ContentUnavailableView("Payment Unavailable", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) + } else { + ContentUnavailableView("Payment Unavailable", systemImage: "creditcard", description: Text("No payment details were returned for this transaction.")) + } + } + .navigationTitle("Payment") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadPayment(id: transactionID, client: client) + } + } + + private func detailRow(label: String, value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } +} diff --git a/Rune/Views/Settings/WalletTransactionsView.swift b/Rune/Views/Settings/WalletTransactionsView.swift new file mode 100644 index 0000000..f0106de --- /dev/null +++ b/Rune/Views/Settings/WalletTransactionsView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct WalletTransactionsView: View { + @ObservedObject var viewModel: WalletViewModel + let client: NjallaClient + + var body: some View { + List { + if let errorMessage = viewModel.transactionsErrorMessage { + Section { + InlineErrorView(message: errorMessage, retryTitle: "Retry Transactions") { + Task { + await viewModel.loadTransactions(client: client) + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + + if viewModel.isLoadingTransactions && viewModel.transactions.isEmpty { + Section { + HStack { + Spacer() + ProgressView("Loading Transactions") + Spacer() + } + } + } else if viewModel.transactions.isEmpty { + Section { + ContentUnavailableView( + "No Transactions", + systemImage: "eurosign.circle", + description: Text("No wallet transactions were returned for this account.") + ) + } + } else { + ForEach(viewModel.transactions) { transaction in + NavigationLink { + WalletPaymentDetailView(transactionID: transaction.id, viewModel: viewModel, client: client) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(transaction.type ?? "Transaction") + .font(.headline) + Text(transactionDateText(transaction)) + .font(.subheadline) + .foregroundStyle(.secondary) + if let amount = transaction.amount { + Text("Amount: €\(amount)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Transactions") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadTransactions(client: client) + } + .refreshable { + await viewModel.loadTransactions(client: client) + } + } + + private func transactionDateText(_ transaction: WalletTransaction) -> String { + if let date = transaction.date, !date.isEmpty { + return date + } + return "ID: \(transaction.id)" + } +} |
