summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Rune.xcodeproj/project.pbxproj12
-rw-r--r--Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstatebin19968 -> 26305 bytes
-rw-r--r--Rune/API/Models.swift201
-rw-r--r--Rune/API/NjallaClient.swift82
-rw-r--r--Rune/App/RuneApp.swift5
-rw-r--r--Rune/ViewModels/DomainViewModel.swift93
-rw-r--r--Rune/ViewModels/SettingsViewModel.swift20
-rw-r--r--Rune/ViewModels/WalletViewModel.swift65
-rw-r--r--Rune/Views/Domains/DomainDetailView.swift8
-rw-r--r--Rune/Views/Domains/DomainEditView.swift8
-rw-r--r--Rune/Views/Domains/DomainListView.swift6
-rw-r--r--Rune/Views/Domains/ForwardListView.swift6
-rw-r--r--Rune/Views/Domains/GlueEditView.swift139
-rw-r--r--Rune/Views/Domains/GlueListView.swift139
-rw-r--r--Rune/Views/Domains/RecordListView.swift6
-rw-r--r--Rune/Views/Settings/SettingsView.swift19
-rw-r--r--Rune/Views/Settings/WalletPaymentDetailView.swift45
-rw-r--r--Rune/Views/Settings/WalletTransactionsView.swift75
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
index dd86bef..fc8169f 100644
--- a/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate
+++ b/Rune.xcodeproj/project.xcworkspace/xcuserdata/cmc.xcuserdatad/UserInterfaceState.xcuserstate
Binary files differ
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)"
+ }
+}