summaryrefslogtreecommitdiff
path: root/Rune
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-24 21:48:23 -0500
committerChristian Cleberg <[email protected]>2026-03-24 21:48:23 -0500
commitcf587aa0573ac4f34e1effe70108a9eca82093ac (patch)
tree833e6f4961522e35b08af01b5f012983f8e34678 /Rune
downloadrune-cf587aa0573ac4f34e1effe70108a9eca82093ac.tar.gz
rune-cf587aa0573ac4f34e1effe70108a9eca82093ac.tar.bz2
rune-cf587aa0573ac4f34e1effe70108a9eca82093ac.zip
v1.0v1.0.0
Diffstat (limited to 'Rune')
-rw-r--r--Rune/API/Models.swift385
-rw-r--r--Rune/API/NjallaClient.swift129
-rw-r--r--Rune/App/RuneApp.swift76
-rw-r--r--Rune/Assets.xcassets/AccentColor.colorset/Contents.json11
-rw-r--r--Rune/Assets.xcassets/AppIcon.appiconset/Contents.json35
-rw-r--r--Rune/Assets.xcassets/Contents.json6
-rw-r--r--Rune/Keychain/KeychainManager.swift91
-rw-r--r--Rune/ViewModels/DomainViewModel.swift143
-rw-r--r--Rune/ViewModels/SettingsViewModel.swift102
-rw-r--r--Rune/ViewModels/TokenViewModel.swift91
-rw-r--r--Rune/Views/Domains/DomainDetailView.swift114
-rw-r--r--Rune/Views/Domains/DomainEditView.swift99
-rw-r--r--Rune/Views/Domains/DomainListView.swift86
-rw-r--r--Rune/Views/Domains/RecordAddView.swift144
-rw-r--r--Rune/Views/Domains/RecordEditView.swift105
-rw-r--r--Rune/Views/Domains/RecordListView.swift81
-rw-r--r--Rune/Views/Onboarding/OnboardingView.swift60
-rw-r--r--Rune/Views/Settings/SettingsView.swift73
-rw-r--r--Rune/Views/Tokens/TokenAddView.swift137
-rw-r--r--Rune/Views/Tokens/TokenListView.swift143
20 files changed, 2111 insertions, 0 deletions
diff --git a/Rune/API/Models.swift b/Rune/API/Models.swift
new file mode 100644
index 0000000..f0209b4
--- /dev/null
+++ b/Rune/API/Models.swift
@@ -0,0 +1,385 @@
+import Foundation
+
+struct EmptyResult: Codable {}
+
+struct WalletBalance: Codable, Equatable {
+ let balance: Int
+}
+
+struct DomainListResponse: Codable {
+ let domains: [Domain]
+}
+
+struct RecordListResponse: Codable {
+ let records: [DNSRecord]
+}
+
+struct TokenListResponse: Codable {
+ let tokens: [APIToken]
+}
+
+struct Domain: Codable, Identifiable, Hashable {
+ var id: String { name }
+
+ let name: String
+ let status: String?
+ let expiry: String?
+ let autorenew: Bool?
+ let mailforwarding: Bool?
+ let dnssec: Bool?
+ let lock: Bool?
+ let nameservers: [String]?
+}
+
+struct DNSRecord: Codable, Identifiable, Hashable {
+ let id: String
+ let domain: String
+ let type: String
+ let name: String
+ let content: String?
+ let ttl: Int?
+ let prio: Int?
+ let weight: Int?
+ let port: Int?
+ let target: String?
+ let sshAlgorithm: Int?
+ let sshType: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case domain
+ case type
+ case name
+ case content
+ case ttl
+ case prio
+ case weight
+ case port
+ case target
+ case sshAlgorithm = "ssh_algorithm"
+ case sshType = "ssh_type"
+ }
+
+ init(
+ id: String,
+ domain: String,
+ type: String,
+ name: String,
+ content: String?,
+ ttl: Int?,
+ prio: Int?,
+ weight: Int?,
+ port: Int?,
+ target: String?,
+ sshAlgorithm: Int?,
+ sshType: Int?
+ ) {
+ self.id = id
+ self.domain = domain
+ self.type = type
+ self.name = name
+ self.content = content
+ self.ttl = ttl
+ self.prio = prio
+ self.weight = weight
+ self.port = port
+ self.target = target
+ self.sshAlgorithm = sshAlgorithm
+ self.sshType = sshType
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ id = try container.decodeLossyString(forKey: .id)
+ domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? ""
+ type = try container.decode(String.self, forKey: .type)
+ name = try container.decode(String.self, forKey: .name)
+ content = try container.decodeIfPresent(String.self, forKey: .content)
+ ttl = try container.decodeLossyIntIfPresent(forKey: .ttl)
+ prio = try container.decodeLossyIntIfPresent(forKey: .prio)
+ weight = try container.decodeLossyIntIfPresent(forKey: .weight)
+ port = try container.decodeLossyIntIfPresent(forKey: .port)
+ target = try container.decodeIfPresent(String.self, forKey: .target)
+ sshAlgorithm = try container.decodeLossyIntIfPresent(forKey: .sshAlgorithm)
+ sshType = try container.decodeLossyIntIfPresent(forKey: .sshType)
+ }
+
+ func withDomain(_ domain: String) -> DNSRecord {
+ DNSRecord(
+ id: id,
+ domain: domain,
+ type: type,
+ name: name,
+ content: content,
+ ttl: ttl,
+ prio: prio,
+ weight: weight,
+ port: port,
+ target: target,
+ sshAlgorithm: sshAlgorithm,
+ sshType: sshType
+ )
+ }
+}
+
+struct APIToken: Codable, Identifiable, Hashable {
+ var id: String { key }
+
+ let key: String
+ let comment: String?
+ let from: [String]?
+ let allowedDomains: [String]?
+ let allowedServers: [String]?
+ let allowedMethods: [String]?
+ let allowedPrefixes: [String]?
+ let allowedTypes: [String]?
+
+ enum CodingKeys: String, CodingKey {
+ case key
+ case comment
+ case from
+ case allowedDomains = "allowed_domains"
+ case allowedServers = "allowed_servers"
+ case allowedMethods = "allowed_methods"
+ case allowedPrefixes = "allowed_prefixes"
+ case allowedTypes = "allowed_types"
+ }
+}
+
+enum DNSRecordType: String, CaseIterable, Identifiable, Codable {
+ case a = "A"
+ case aaaa = "AAAA"
+ case aname = "ANAME"
+ case caa = "CAA"
+ case cname = "CNAME"
+ case ds = "DS"
+ case dynamic = "Dynamic"
+ case https = "HTTPS"
+ case mx = "MX"
+ case naptr = "NAPTR"
+ case ns = "NS"
+ case ptr = "PTR"
+ case srv = "SRV"
+ case sshfp = "SSHFP"
+ case svcb = "SVCB"
+ case tlsa = "TLSA"
+ case txt = "TXT"
+
+ var id: String { rawValue }
+
+ var usesContent: Bool {
+ switch self {
+ case .dynamic, .https, .svcb:
+ return false
+ default:
+ return true
+ }
+ }
+
+ var usesTTL: Bool {
+ usesContent
+ }
+
+ var usesPriority: Bool {
+ switch self {
+ case .https, .mx, .srv, .svcb:
+ return true
+ default:
+ return false
+ }
+ }
+
+ var usesWeight: Bool {
+ self == .srv
+ }
+
+ var usesPort: Bool {
+ self == .srv
+ }
+
+ var usesTarget: Bool {
+ self == .https || self == .svcb
+ }
+
+ var usesSSHFields: Bool {
+ self == .sshfp
+ }
+}
+
+struct DNSRecordDraft: Equatable {
+ var type: DNSRecordType = .a
+ var name = ""
+ var content = ""
+ var ttl = ""
+ var prio = ""
+ var weight = ""
+ var port = ""
+ var target = ""
+ var sshAlgorithm = ""
+ var sshType = ""
+
+ init() {}
+
+ init(record: DNSRecord) {
+ type = DNSRecordType(rawValue: record.type) ?? .a
+ name = record.name
+ content = record.content ?? ""
+ ttl = record.ttl.map(String.init) ?? ""
+ prio = record.prio.map(String.init) ?? ""
+ weight = record.weight.map(String.init) ?? ""
+ port = record.port.map(String.init) ?? ""
+ target = record.target ?? ""
+ sshAlgorithm = record.sshAlgorithm.map(String.init) ?? ""
+ sshType = record.sshType.map(String.init) ?? ""
+ }
+
+ mutating func resetTypeSpecificFields() {
+ content = ""
+ ttl = ""
+ prio = ""
+ weight = ""
+ port = ""
+ target = ""
+ sshAlgorithm = ""
+ sshType = ""
+ }
+
+ var trimmedName: String {
+ name.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var canSubmit: Bool {
+ !trimmedName.isEmpty
+ }
+
+ func params(domain: String, id: String? = nil) -> [String: Any] {
+ var params: [String: Any] = [
+ "domain": domain,
+ "type": type.rawValue,
+ "name": trimmedName
+ ]
+
+ if let id {
+ params["id"] = id
+ }
+
+ if type.usesContent {
+ params["content"] = content.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ if type.usesTTL, let value = Int(ttl.trimmingCharacters(in: .whitespacesAndNewlines)) {
+ params["ttl"] = value
+ }
+
+ if type.usesPriority, let value = Int(prio.trimmingCharacters(in: .whitespacesAndNewlines)) {
+ params["prio"] = value
+ }
+
+ if type.usesWeight, let value = Int(weight.trimmingCharacters(in: .whitespacesAndNewlines)) {
+ params["weight"] = value
+ }
+
+ if type.usesPort, let value = Int(port.trimmingCharacters(in: .whitespacesAndNewlines)) {
+ params["port"] = value
+ }
+
+ if type.usesTarget {
+ params["target"] = target.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ if type.usesSSHFields {
+ if let value = Int(sshAlgorithm.trimmingCharacters(in: .whitespacesAndNewlines)) {
+ params["ssh_algorithm"] = value
+ }
+ if let value = Int(sshType.trimmingCharacters(in: .whitespacesAndNewlines)) {
+ params["ssh_type"] = value
+ }
+ }
+
+ return params
+ }
+}
+
+struct DomainUpdateRequest {
+ var autorenew: Bool
+ var mailforwarding: Bool
+ var dnssec: Bool
+ var lock: Bool
+ var nameservers: [String]
+
+ var params: [String: Any] {
+ [
+ "autorenew": autorenew,
+ "mailforwarding": mailforwarding,
+ "dnssec": dnssec,
+ "lock": lock,
+ "nameservers": nameservers
+ ]
+ }
+}
+
+struct TokenCreateRequest {
+ var comment: String
+ var from: [String]
+ var allowedMethods: [String]
+
+ var params: [String: Any] {
+ var params: [String: Any] = [:]
+
+ let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedComment.isEmpty {
+ params["comment"] = trimmedComment
+ }
+
+ if !from.isEmpty {
+ params["from"] = from
+ }
+
+ if !allowedMethods.isEmpty {
+ params["allowed_methods"] = allowedMethods
+ }
+
+ return params
+ }
+}
+
+extension String {
+ func formattedExpiry() -> String {
+ let parser = ISO8601DateFormatter()
+ guard let date = parser.date(from: self) else { return self }
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .none
+ return formatter.string(from: date)
+ }
+}
+
+private extension KeyedDecodingContainer where K == DNSRecord.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
new file mode 100644
index 0000000..9158e20
--- /dev/null
+++ b/Rune/API/NjallaClient.swift
@@ -0,0 +1,129 @@
+import Foundation
+
+struct NjallaClient: Sendable, Equatable {
+ private let token: String
+ private let endpoint = URL(string: "https://njal.la/api/1/")!
+
+ init(token: String) {
+ self.token = token
+ }
+
+ func call<T: Decodable>(_ method: String, params: [String: Any] = [:]) async throws -> T {
+ var request = URLRequest(url: endpoint)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue("Njalla \(token)", forHTTPHeaderField: "Authorization")
+
+ let body: [String: Any] = [
+ "jsonrpc": "2.0",
+ "method": method,
+ "params": params,
+ "id": "1"
+ ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
+
+ #if DEBUG
+ debugPrint("Njalla method:", method)
+ debugPrint("Njalla status:", statusCode)
+ #endif
+
+ let decoder = JSONDecoder()
+ let envelope = try decoder.decode(RPCResponse<T>.self, from: data)
+
+ if let error = envelope.error {
+ throw NjallaError.api(message: error.message)
+ }
+
+ guard let result = envelope.result else {
+ throw NjallaError.missingResult
+ }
+
+ return result
+ }
+
+ func listDomains() async throws -> [Domain] {
+ let response: DomainListResponse = try await call("list-domains")
+ return response.domains
+ }
+
+ func getDomain(named domain: String) async throws -> Domain {
+ try await call("get-domain", params: ["domain": domain])
+ }
+
+ func editDomain(named domain: String, request: DomainUpdateRequest) async throws -> Domain {
+ var params = request.params
+ params["domain"] = domain
+ return try await call("edit-domain", params: params)
+ }
+
+ 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
+ record.domain.isEmpty ? record.withDomain(domain) : record
+ }
+ }
+
+ func addRecord(for domain: String, draft: DNSRecordDraft) async throws -> DNSRecord {
+ try await call("add-record", params: draft.params(domain: domain))
+ }
+
+ func editRecord(for domain: String, id: String, draft: DNSRecordDraft) async throws -> DNSRecord {
+ try await call("edit-record", params: draft.params(domain: domain, id: id))
+ }
+
+ func removeRecord(_ record: DNSRecord) async throws {
+ let params: [String: Any] = [
+ "domain": record.domain,
+ "id": record.id,
+ "name": record.name,
+ "type": record.type
+ ]
+ let _: RecordListResponse = try await call("remove-record", params: params)
+ }
+
+ func listTokens() async throws -> [APIToken] {
+ let response: TokenListResponse = try await call("list-tokens")
+ return response.tokens
+ }
+
+ func addToken(request: TokenCreateRequest) async throws {
+ let _: EmptyResult = try await call("add-token", params: request.params)
+ }
+
+ func removeToken(key: String) async throws {
+ let _: EmptyResult = try await call("remove-token", params: ["key": key])
+ }
+
+ func getBalance() async throws -> WalletBalance {
+ try await call("get-balance")
+ }
+}
+
+enum NjallaError: LocalizedError {
+ case api(message: String)
+ case missingResult
+
+ var errorDescription: String? {
+ switch self {
+ case .api(let message):
+ return message
+ case .missingResult:
+ return "The API response did not include a result."
+ }
+ }
+}
+
+private struct RPCResponse<Result: Decodable>: Decodable {
+ let result: Result?
+ let error: RPCError?
+}
+
+private struct RPCError: Decodable {
+ let code: Int
+ let message: String
+}
diff --git a/Rune/App/RuneApp.swift b/Rune/App/RuneApp.swift
new file mode 100644
index 0000000..8c63dd6
--- /dev/null
+++ b/Rune/App/RuneApp.swift
@@ -0,0 +1,76 @@
+import SwiftUI
+
+@main
+struct RuneApp: App {
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ }
+ }
+}
+
+private struct RootView: View {
+ @StateObject private var settingsViewModel = SettingsViewModel()
+ @StateObject private var domainViewModel = DomainViewModel()
+ @StateObject private var tokenViewModel = TokenViewModel()
+ @State private var selectedTab = 0
+
+ var body: some View {
+ TabView(selection: $selectedTab) {
+ DomainListView(viewModel: domainViewModel, client: settingsViewModel.client)
+ .tabItem {
+ Label("Domains", systemImage: "globe")
+ }
+ .tag(0)
+
+ TokenListView(viewModel: tokenViewModel, client: settingsViewModel.client) { deletedTokenKey in
+ do {
+ let didLogout = try settingsViewModel.logoutIfCurrentTokenDeleted(deletedTokenKey)
+ if didLogout {
+ selectedTab = 0
+ }
+ } catch {
+ settingsViewModel.errorMessage = error.localizedDescription
+ }
+ }
+ .tabItem {
+ Label("Tokens", systemImage: "key.horizontal")
+ }
+ .tag(1)
+
+ SettingsView(viewModel: settingsViewModel) {
+ domainViewModel.reset()
+ tokenViewModel.reset()
+ selectedTab = 0
+ }
+ .tabItem {
+ Label("Settings", systemImage: "gearshape")
+ }
+ .tag(2)
+ }
+ .task {
+ await settingsViewModel.bootstrap()
+ }
+ .task(id: settingsViewModel.isAuthenticated) {
+ guard let client = settingsViewModel.client else {
+ domainViewModel.reset()
+ tokenViewModel.reset()
+ selectedTab = 0
+ return
+ }
+
+ await domainViewModel.loadDomains(client: client)
+ await tokenViewModel.loadTokens(client: client)
+ }
+ .fullScreenCover(isPresented: onboardingBinding) {
+ OnboardingView(viewModel: settingsViewModel)
+ }
+ }
+
+ private var onboardingBinding: Binding<Bool> {
+ Binding(
+ get: { settingsViewModel.requiresOnboarding },
+ set: { _ in }
+ )
+ }
+}
diff --git a/Rune/Assets.xcassets/AccentColor.colorset/Contents.json b/Rune/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/Rune/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Rune/Assets.xcassets/AppIcon.appiconset/Contents.json b/Rune/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..2305880
--- /dev/null
+++ b/Rune/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Rune/Assets.xcassets/Contents.json b/Rune/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Rune/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Rune/Keychain/KeychainManager.swift b/Rune/Keychain/KeychainManager.swift
new file mode 100644
index 0000000..bade4f8
--- /dev/null
+++ b/Rune/Keychain/KeychainManager.swift
@@ -0,0 +1,91 @@
+import Foundation
+import Security
+
+struct KeychainManager {
+ private let service = "net.cleberg.rune"
+ private let account = "api-token"
+
+ func saveToken(_ token: String) throws {
+ let encodedToken = Data(token.utf8)
+ var query = baseQuery()
+ query[kSecValueData as String] = encodedToken
+ query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+
+ let status = SecItemAdd(query as CFDictionary, nil)
+ if status == errSecDuplicateItem {
+ try updateToken(token)
+ return
+ }
+
+ guard status == errSecSuccess else {
+ throw KeychainError.unhandled(status)
+ }
+ }
+
+ func readToken() throws -> String? {
+ var query = baseQuery()
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ if status == errSecItemNotFound {
+ return nil
+ }
+
+ guard status == errSecSuccess else {
+ throw KeychainError.unhandled(status)
+ }
+
+ guard
+ let data = result as? Data,
+ let token = String(data: data, encoding: .utf8)
+ else {
+ throw KeychainError.invalidData
+ }
+
+ return token
+ }
+
+ func deleteToken() throws {
+ let status = SecItemDelete(baseQuery() as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw KeychainError.unhandled(status)
+ }
+ }
+
+ private func updateToken(_ token: String) throws {
+ let attributes: [String: Any] = [
+ kSecValueData as String: Data(token.utf8),
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ ]
+
+ let status = SecItemUpdate(baseQuery() as CFDictionary, attributes as CFDictionary)
+ guard status == errSecSuccess else {
+ throw KeychainError.unhandled(status)
+ }
+ }
+
+ private func baseQuery() -> [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ }
+}
+
+enum KeychainError: LocalizedError {
+ case invalidData
+ case unhandled(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidData:
+ return "Stored token data is invalid."
+ case .unhandled(let status):
+ return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain error: \(status)"
+ }
+ }
+}
diff --git a/Rune/ViewModels/DomainViewModel.swift b/Rune/ViewModels/DomainViewModel.swift
new file mode 100644
index 0000000..efad5b9
--- /dev/null
+++ b/Rune/ViewModels/DomainViewModel.swift
@@ -0,0 +1,143 @@
+import Combine
+import Foundation
+
+@MainActor
+final class DomainViewModel: ObservableObject {
+ @Published private(set) var domains: [Domain] = []
+ @Published private(set) var selectedDomain: Domain?
+ @Published private(set) var records: [DNSRecord] = []
+ @Published private(set) var isLoadingDomains = false
+ @Published private(set) var isLoadingDetail = false
+ @Published private(set) var isLoadingRecords = false
+ @Published private(set) var isSaving = false
+ @Published var errorMessage: String?
+
+ func reset() {
+ domains = []
+ selectedDomain = nil
+ records = []
+ isLoadingDomains = false
+ isLoadingDetail = false
+ isLoadingRecords = false
+ isSaving = false
+ errorMessage = nil
+ }
+
+ func loadDomains(client: NjallaClient) async {
+ isLoadingDomains = true
+ defer {
+ isLoadingDomains = false
+ }
+
+ do {
+ domains = try await client.listDomains().sorted {
+ $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+ }
+ errorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func loadDomainDetail(named name: String, client: NjallaClient) async {
+ isLoadingDetail = true
+ defer {
+ isLoadingDetail = false
+ }
+
+ do {
+ let domain = try await client.getDomain(named: name)
+ selectedDomain = domain
+ if let index = domains.firstIndex(where: { $0.name == name }) {
+ domains[index] = domain
+ }
+ errorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func updateDomain(named name: String, request: DomainUpdateRequest, client: NjallaClient) async throws {
+ isSaving = true
+ defer {
+ isSaving = false
+ }
+
+ let updated = try await client.editDomain(named: name, request: request)
+ selectedDomain = updated
+ if let index = domains.firstIndex(where: { $0.name == updated.name }) {
+ domains[index] = updated
+ }
+ errorMessage = nil
+ }
+
+ func loadRecords(for domain: String, client: NjallaClient) async {
+ isLoadingRecords = true
+ defer {
+ isLoadingRecords = false
+ }
+
+ do {
+ records = try await client.listRecords(for: domain).sorted {
+ ($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id)
+ }
+ errorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func addRecord(for domain: String, draft: DNSRecordDraft, client: NjallaClient) async throws {
+ isSaving = true
+ defer {
+ isSaving = false
+ }
+
+ _ = try await client.addRecord(for: domain, draft: draft)
+ try await reloadRecords(for: domain, client: client)
+ errorMessage = nil
+ }
+
+ func editRecord(for domain: String, recordID: String, draft: DNSRecordDraft, client: NjallaClient) async throws {
+ isSaving = true
+ defer {
+ isSaving = false
+ }
+
+ _ = try await client.editRecord(for: domain, id: recordID, draft: draft)
+ try await reloadRecords(for: domain, client: client)
+ errorMessage = nil
+ }
+
+ func removeRecord(_ record: DNSRecord, client: NjallaClient) async throws {
+ isSaving = true
+ defer {
+ isSaving = false
+ }
+
+ try await client.removeRecord(record)
+ try await reloadRecords(for: record.domain, client: client)
+ errorMessage = nil
+ }
+
+ private func reloadRecords(for domain: String, client: NjallaClient) async throws {
+ records = try await client.listRecords(for: domain).sorted {
+ ($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id)
+ }
+ }
+}
diff --git a/Rune/ViewModels/SettingsViewModel.swift b/Rune/ViewModels/SettingsViewModel.swift
new file mode 100644
index 0000000..6b773eb
--- /dev/null
+++ b/Rune/ViewModels/SettingsViewModel.swift
@@ -0,0 +1,102 @@
+import Combine
+import Foundation
+
+@MainActor
+final class SettingsViewModel: ObservableObject {
+ @Published private(set) var client: NjallaClient?
+ @Published private(set) var balance: WalletBalance?
+ @Published private(set) var isAuthenticated = false
+ @Published private(set) var isBootstrapped = false
+ @Published private(set) var isLoadingBalance = false
+ @Published var errorMessage: String?
+
+ private let keychainManager = KeychainManager()
+
+ var requiresOnboarding: Bool {
+ isBootstrapped && !isAuthenticated
+ }
+
+ func bootstrap() async {
+ guard !isBootstrapped else { return }
+
+ defer {
+ isBootstrapped = true
+ }
+
+ do {
+ guard let token = try keychainManager.readToken(), !token.isEmpty else {
+ isAuthenticated = false
+ client = nil
+ balance = nil
+ return
+ }
+
+ let client = NjallaClient(token: token)
+ self.client = client
+ isAuthenticated = true
+ try await refreshBalance()
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ errorMessage = error.localizedDescription
+ client = nil
+ balance = nil
+ isAuthenticated = false
+ }
+ }
+
+ func login(token: String) async throws {
+ let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
+ let client = NjallaClient(token: trimmedToken)
+ let balance = try await client.getBalance()
+ try keychainManager.saveToken(trimmedToken)
+
+ self.client = client
+ self.balance = balance
+ isAuthenticated = true
+ isBootstrapped = true
+ errorMessage = nil
+ }
+
+ func refreshBalance() async throws {
+ guard let client else { return }
+
+ isLoadingBalance = true
+ defer {
+ isLoadingBalance = false
+ }
+
+ do {
+ balance = try await client.getBalance()
+ errorMessage = nil
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ throw error
+ }
+ errorMessage = error.localizedDescription
+ throw error
+ }
+ }
+
+ func logout() throws {
+ try keychainManager.deleteToken()
+ client = nil
+ balance = nil
+ isAuthenticated = false
+ errorMessage = nil
+ }
+
+ func logoutIfCurrentTokenDeleted(_ key: String) throws -> Bool {
+ guard let storedToken = try keychainManager.readToken(), storedToken == key else {
+ return false
+ }
+
+ try logout()
+ return true
+ }
+}
diff --git a/Rune/ViewModels/TokenViewModel.swift b/Rune/ViewModels/TokenViewModel.swift
new file mode 100644
index 0000000..40495b4
--- /dev/null
+++ b/Rune/ViewModels/TokenViewModel.swift
@@ -0,0 +1,91 @@
+import Combine
+import Foundation
+
+@MainActor
+final class TokenViewModel: ObservableObject {
+ @Published private(set) var tokens: [APIToken] = []
+ @Published private(set) var isLoading = false
+ @Published private(set) var isSaving = false
+ @Published var errorMessage: String?
+
+ func reset() {
+ tokens = []
+ isLoading = false
+ isSaving = false
+ errorMessage = nil
+ }
+
+ func loadTokens(client: NjallaClient) async {
+ isLoading = true
+ defer {
+ isLoading = false
+ }
+
+ do {
+ tokens = try await client.listTokens().sorted {
+ tokenLabel(for: $0).localizedCaseInsensitiveCompare(tokenLabel(for: $1)) == .orderedAscending
+ }
+ errorMessage = nil
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ func addToken(request: TokenCreateRequest, client: NjallaClient) async -> Bool {
+ isSaving = true
+ defer {
+ isSaving = false
+ }
+
+ do {
+ try await client.addToken(request: request)
+ tokens = try await client.listTokens()
+ errorMessage = nil
+ return true
+ } catch is CancellationError {
+ return false
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return false
+ }
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func removeToken(_ token: APIToken, client: NjallaClient) async -> Bool {
+ isSaving = true
+ defer {
+ isSaving = false
+ }
+
+ do {
+ try await client.removeToken(key: token.key)
+ tokens.removeAll { $0.key == token.key }
+ errorMessage = nil
+ return true
+ } catch is CancellationError {
+ return false
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return false
+ }
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func tokenLabel(for token: APIToken) -> String {
+ let trimmedComment = token.comment?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !trimmedComment.isEmpty {
+ return trimmedComment
+ }
+
+ return String(token.key.prefix(8))
+ }
+}
diff --git a/Rune/Views/Domains/DomainDetailView.swift b/Rune/Views/Domains/DomainDetailView.swift
new file mode 100644
index 0000000..dbb90a1
--- /dev/null
+++ b/Rune/Views/Domains/DomainDetailView.swift
@@ -0,0 +1,114 @@
+import SwiftUI
+
+struct DomainDetailView: View {
+ let domainName: String
+ @ObservedObject var viewModel: DomainViewModel
+ let client: NjallaClient
+
+ var body: some View {
+ Group {
+ if viewModel.isLoadingDetail && viewModel.selectedDomain?.name != domainName {
+ ProgressView()
+ } else if let domain = currentDomain {
+ List {
+ Section("Status") {
+ DetailRow(label: "Name", value: domain.name)
+ DetailRow(label: "Status", value: textValue(domain.status))
+ DetailRow(label: "Expiry", value: domain.expiry?.formattedExpiry() ?? "Not available")
+ DetailRow(label: "Autorenew", value: boolText(domain.autorenew))
+ }
+
+ Section("Settings") {
+ DetailRow(label: "Mail Forwarding", value: boolText(domain.mailforwarding))
+ DetailRow(label: "DNSSEC", value: boolText(domain.dnssec))
+ DetailRow(label: "Registrar Lock", value: boolText(domain.lock))
+ DetailRow(label: "Nameservers", value: nameserverText(domain.nameservers))
+ }
+
+ Section("DNS") {
+ NavigationLink("Records") {
+ RecordListView(domainName: domain.name, viewModel: viewModel, client: client)
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .toolbar {
+ NavigationLink("Edit") {
+ DomainEditView(domain: domain, viewModel: viewModel, client: client)
+ }
+ }
+ } else {
+ ContentUnavailableView("Domain Unavailable", systemImage: "globe", description: Text("The domain details could not be loaded."))
+ }
+ }
+ .navigationTitle(domainName)
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ await viewModel.loadDomainDetail(named: domainName, client: client)
+ }
+ .alert("API Error", isPresented: errorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(viewModel.errorMessage ?? "")
+ }
+ }
+
+ private var currentDomain: Domain? {
+ if viewModel.selectedDomain?.name == domainName {
+ return viewModel.selectedDomain
+ }
+
+ return viewModel.domains.first(where: { $0.name == domainName })
+ }
+
+ private func boolText(_ value: Bool?) -> String {
+ guard let value else { return "Not available" }
+ return value ? "On" : "Off"
+ }
+
+ private func nameserverText(_ nameservers: [String]?) -> String {
+ guard let nameservers else {
+ return "Not available"
+ }
+
+ guard !nameservers.isEmpty else {
+ return "Default"
+ }
+
+ return nameservers.joined(separator: ", ")
+ }
+
+ private func textValue(_ value: String?) -> String {
+ guard let value, !value.isEmpty else {
+ return "Not available"
+ }
+
+ return value
+ }
+
+ private var errorBinding: Binding<Bool> {
+ Binding(
+ get: { viewModel.errorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ viewModel.errorMessage = nil
+ }
+ }
+ )
+ }
+}
+
+private struct DetailRow: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ HStack {
+ Text(label)
+ Spacer()
+ Text(value)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.trailing)
+ }
+ }
+}
diff --git a/Rune/Views/Domains/DomainEditView.swift b/Rune/Views/Domains/DomainEditView.swift
new file mode 100644
index 0000000..3f197e3
--- /dev/null
+++ b/Rune/Views/Domains/DomainEditView.swift
@@ -0,0 +1,99 @@
+import SwiftUI
+
+struct DomainEditView: View {
+ let domain: Domain
+ @ObservedObject var viewModel: DomainViewModel
+ let client: NjallaClient
+
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var autorenew: Bool
+ @State private var mailforwarding: Bool
+ @State private var dnssec: Bool
+ @State private var lock: Bool
+ @State private var nameserversText: String
+ @State private var localErrorMessage: String?
+
+ init(domain: Domain, viewModel: DomainViewModel, client: NjallaClient) {
+ self.domain = domain
+ self.viewModel = viewModel
+ self.client = client
+ _autorenew = State(initialValue: domain.autorenew ?? false)
+ _mailforwarding = State(initialValue: domain.mailforwarding ?? false)
+ _dnssec = State(initialValue: domain.dnssec ?? false)
+ _lock = State(initialValue: domain.lock ?? false)
+ _nameserversText = State(initialValue: (domain.nameservers ?? []).joined(separator: "\n"))
+ }
+
+ var body: some View {
+ Form {
+ Section("Settings") {
+ Toggle("Autorenew", isOn: $autorenew)
+ Toggle("Mail Forwarding", isOn: $mailforwarding)
+ Toggle("DNSSEC", isOn: $dnssec)
+ Toggle("Registrar Lock", isOn: $lock)
+ }
+
+ Section {
+ TextEditor(text: $nameserversText)
+ .frame(minHeight: 120)
+ } header: {
+ Text("Nameservers")
+ } footer: {
+ Text("Enter one nameserver per line. Leave blank to use Njalla defaults.")
+ }
+
+ Section {
+ Button("Save") {
+ Task {
+ await save()
+ }
+ }
+ .disabled(viewModel.isSaving)
+ }
+ }
+ .navigationTitle("Edit Domain")
+ .navigationBarTitleDisplayMode(.inline)
+ .alert("API Error", isPresented: localErrorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(localErrorMessage ?? "")
+ }
+ }
+
+ private func save() async {
+ let request = DomainUpdateRequest(
+ autorenew: autorenew,
+ mailforwarding: mailforwarding,
+ dnssec: dnssec,
+ lock: lock,
+ nameservers: nameserversText
+ .split(whereSeparator: \.isNewline)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ )
+
+ do {
+ try await viewModel.updateDomain(named: domain.name, request: request, client: client)
+ dismiss()
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ localErrorMessage = error.localizedDescription
+ }
+ }
+
+ private var localErrorBinding: Binding<Bool> {
+ Binding(
+ get: { localErrorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ localErrorMessage = nil
+ }
+ }
+ )
+ }
+}
diff --git a/Rune/Views/Domains/DomainListView.swift b/Rune/Views/Domains/DomainListView.swift
new file mode 100644
index 0000000..d728bd7
--- /dev/null
+++ b/Rune/Views/Domains/DomainListView.swift
@@ -0,0 +1,86 @@
+import SwiftUI
+
+struct DomainListView: View {
+ @ObservedObject var viewModel: DomainViewModel
+ let client: NjallaClient?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let client {
+ content(client: client)
+ } else {
+ ContentUnavailableView("Sign in required", systemImage: "key.fill", description: Text("Add a valid Njalla API token to load domains."))
+ }
+ }
+ .navigationTitle("Domains")
+ }
+ .alert("API Error", isPresented: errorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(viewModel.errorMessage ?? "")
+ }
+ }
+
+ @ViewBuilder
+ private func content(client: NjallaClient) -> some View {
+ if viewModel.isLoadingDomains && viewModel.domains.isEmpty {
+ ProgressView()
+ } else if viewModel.domains.isEmpty {
+ ContentUnavailableView("No Domains", systemImage: "globe", description: Text("No domains found on this account."))
+ } else {
+ List(viewModel.domains) { domain in
+ NavigationLink {
+ DomainDetailView(domainName: domain.name, viewModel: viewModel, client: client)
+ } label: {
+ DomainRow(domain: domain)
+ }
+ }
+ .listStyle(.insetGrouped)
+ .refreshable {
+ await viewModel.loadDomains(client: client)
+ }
+ }
+ }
+
+ private var errorBinding: Binding<Bool> {
+ Binding(
+ get: { viewModel.errorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ viewModel.errorMessage = nil
+ }
+ }
+ )
+ }
+}
+
+private struct DomainRow: View {
+ let domain: Domain
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(domain.name)
+ .font(.headline)
+
+ HStack {
+ if let status = domain.status {
+ Text(status)
+ }
+
+ if let expiry = domain.expiry {
+ Text("Expiry: \(expiry.formattedExpiry())")
+ }
+ }
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ if let autorenew = domain.autorenew {
+ Text(autorenew ? "Autorenew On" : "Autorenew Off")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
diff --git a/Rune/Views/Domains/RecordAddView.swift b/Rune/Views/Domains/RecordAddView.swift
new file mode 100644
index 0000000..8dfa81d
--- /dev/null
+++ b/Rune/Views/Domains/RecordAddView.swift
@@ -0,0 +1,144 @@
+import SwiftUI
+
+struct RecordAddView: View {
+ let domainName: String
+ @ObservedObject var viewModel: DomainViewModel
+ let client: NjallaClient
+
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var draft = DNSRecordDraft()
+ @State private var localErrorMessage: String?
+ var body: some View {
+ Form {
+ DNSRecordFormSections(draft: $draft)
+
+ Section {
+ Button("Save") {
+ Task {
+ await save()
+ }
+ }
+ .disabled(viewModel.isSaving || !draft.canSubmit)
+ }
+ }
+ .navigationTitle("Add Record")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", role: .cancel) {
+ dismiss()
+ }
+ }
+ }
+ .onChange(of: draft.type) { oldValue, newValue in
+ guard oldValue != newValue else { return }
+ draft.resetTypeSpecificFields()
+ }
+ .alert("API Error", isPresented: localErrorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(localErrorMessage ?? "")
+ }
+ }
+
+ private func save() async {
+ do {
+ try await viewModel.addRecord(for: domainName, draft: draft, client: client)
+ dismiss()
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ localErrorMessage = error.localizedDescription
+ }
+ }
+
+ private var localErrorBinding: Binding<Bool> {
+ Binding(
+ get: { localErrorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ localErrorMessage = nil
+ }
+ }
+ )
+ }
+}
+
+struct DNSRecordFormSections: View {
+ @Binding var draft: DNSRecordDraft
+
+ var body: some View {
+ Section("Record") {
+ Picker("Type", selection: $draft.type) {
+ ForEach(DNSRecordType.allCases) { type in
+ Text(type.rawValue).tag(type)
+ }
+ }
+
+ TextField("Name", text: $draft.name)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+
+ if draft.type.usesContent {
+ Section("Content") {
+ TextField("Content", text: $draft.content, axis: .vertical)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+ }
+
+ if draft.type.usesTTL {
+ Section("TTL") {
+ TextField("TTL", text: $draft.ttl)
+ .keyboardType(.numberPad)
+ }
+ }
+
+ if draft.type.usesPriority {
+ Section("Priority") {
+ TextField("Priority", text: $draft.prio)
+ .keyboardType(.numberPad)
+ }
+ }
+
+ if draft.type.usesWeight {
+ Section("Weight") {
+ TextField("Weight", text: $draft.weight)
+ .keyboardType(.numberPad)
+ }
+ }
+
+ if draft.type.usesPort {
+ Section("Port") {
+ TextField("Port", text: $draft.port)
+ .keyboardType(.numberPad)
+ }
+ }
+
+ if draft.type.usesTarget {
+ Section("Target") {
+ TextField("Target", text: $draft.target)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+ }
+
+ if draft.type.usesSSHFields {
+ Section {
+ TextField("SSH Algorithm", text: $draft.sshAlgorithm)
+ .keyboardType(.numberPad)
+ TextField("SSH Type", text: $draft.sshType)
+ .keyboardType(.numberPad)
+ } header: {
+ Text("SSHFP")
+ } footer: {
+ Text("Algorithm values: 1-5. Type values: 1-2.")
+ }
+ }
+ }
+}
diff --git a/Rune/Views/Domains/RecordEditView.swift b/Rune/Views/Domains/RecordEditView.swift
new file mode 100644
index 0000000..e0c0fde
--- /dev/null
+++ b/Rune/Views/Domains/RecordEditView.swift
@@ -0,0 +1,105 @@
+import SwiftUI
+
+struct RecordEditView: View {
+ let domainName: String
+ let record: DNSRecord
+ @ObservedObject var viewModel: DomainViewModel
+ let client: NjallaClient
+
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var draft: DNSRecordDraft
+ @State private var showingDeleteConfirmation = false
+ @State private var localErrorMessage: String?
+
+ init(domainName: String, record: DNSRecord, viewModel: DomainViewModel, client: NjallaClient) {
+ self.domainName = domainName
+ self.record = record
+ self.viewModel = viewModel
+ self.client = client
+ _draft = State(initialValue: DNSRecordDraft(record: record))
+ }
+
+ var body: some View {
+ Form {
+ DNSRecordFormSections(draft: $draft)
+
+ Section {
+ Button("Save") {
+ Task {
+ await save()
+ }
+ }
+ .disabled(viewModel.isSaving || !draft.canSubmit)
+ }
+
+ Section {
+ Button("Delete Record", role: .destructive) {
+ showingDeleteConfirmation = true
+ }
+ .foregroundStyle(.red)
+ }
+ }
+ .navigationTitle(record.name)
+ .navigationBarTitleDisplayMode(.inline)
+ .onChange(of: draft.type) { oldValue, newValue in
+ guard oldValue != newValue else { return }
+ draft.resetTypeSpecificFields()
+ }
+ .confirmationDialog(
+ "Delete \(record.type) record \(record.name)?",
+ isPresented: $showingDeleteConfirmation,
+ titleVisibility: .visible
+ ) {
+ Button("Delete Record", role: .destructive) {
+ Task {
+ await deleteRecord()
+ }
+ }
+ }
+ .alert("API Error", isPresented: localErrorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(localErrorMessage ?? "")
+ }
+ }
+
+ private func save() async {
+ do {
+ try await viewModel.editRecord(for: domainName, recordID: record.id, draft: draft, client: client)
+ dismiss()
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ localErrorMessage = error.localizedDescription
+ }
+ }
+
+ private func deleteRecord() async {
+ do {
+ try await viewModel.removeRecord(record, client: client)
+ dismiss()
+ } catch is CancellationError {
+ return
+ } catch {
+ if (error as? URLError)?.code == .cancelled {
+ return
+ }
+ localErrorMessage = error.localizedDescription
+ }
+ }
+
+ private var localErrorBinding: Binding<Bool> {
+ Binding(
+ get: { localErrorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ localErrorMessage = nil
+ }
+ }
+ )
+ }
+}
diff --git a/Rune/Views/Domains/RecordListView.swift b/Rune/Views/Domains/RecordListView.swift
new file mode 100644
index 0000000..67a4140
--- /dev/null
+++ b/Rune/Views/Domains/RecordListView.swift
@@ -0,0 +1,81 @@
+import SwiftUI
+
+struct RecordListView: View {
+ let domainName: String
+ @ObservedObject var viewModel: DomainViewModel
+ let client: NjallaClient
+
+ @State private var showingAddRecord = false
+
+ var body: some View {
+ Group {
+ if viewModel.isLoadingRecords && viewModel.records.isEmpty {
+ ProgressView()
+ } else if viewModel.records.isEmpty {
+ ContentUnavailableView("No Records", systemImage: "list.bullet", description: Text("No DNS records for this domain."))
+ } else {
+ List(viewModel.records) { record in
+ NavigationLink {
+ RecordEditView(domainName: domainName, record: record, viewModel: viewModel, client: client)
+ } label: {
+ RecordRow(record: record)
+ }
+ }
+ .listStyle(.insetGrouped)
+ }
+ }
+ .navigationTitle("DNS Records")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ Button {
+ showingAddRecord = true
+ } label: {
+ Label("Add Record", systemImage: "plus")
+ }
+ }
+ .sheet(isPresented: $showingAddRecord) {
+ NavigationStack {
+ RecordAddView(domainName: domainName, viewModel: viewModel, client: client)
+ }
+ }
+ .task {
+ await viewModel.loadRecords(for: domainName, client: client)
+ }
+ .refreshable {
+ await viewModel.loadRecords(for: domainName, client: client)
+ }
+ .alert("API Error", isPresented: errorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(viewModel.errorMessage ?? "")
+ }
+ }
+
+ private var errorBinding: Binding<Bool> {
+ Binding(
+ get: { viewModel.errorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ viewModel.errorMessage = nil
+ }
+ }
+ )
+ }
+}
+
+private struct RecordRow: View {
+ let record: DNSRecord
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("\(record.type) \(record.name)")
+ .font(.headline)
+ if let detail = [record.content, record.target].compactMap({ $0 }).first, !detail.isEmpty {
+ Text(detail)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+}
diff --git a/Rune/Views/Onboarding/OnboardingView.swift b/Rune/Views/Onboarding/OnboardingView.swift
new file mode 100644
index 0000000..3fba6c9
--- /dev/null
+++ b/Rune/Views/Onboarding/OnboardingView.swift
@@ -0,0 +1,60 @@
+import SwiftUI
+
+struct OnboardingView: View {
+ @ObservedObject var viewModel: SettingsViewModel
+
+ @State private var token = ""
+ @State private var isSubmitting = false
+ @State private var localErrorMessage: String?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ SecureField("Enter Njalla API token", text: $token)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+
+ Button("Validate and Save") {
+ Task {
+ await submit()
+ }
+ }
+ .disabled(isSubmitting || token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ } header: {
+ Text("API Token")
+ } footer: {
+ Text("Rune validates the token with `get-balance` before saving it to Keychain.")
+ }
+
+ if let localErrorMessage {
+ Section {
+ Text(localErrorMessage)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Welcome")
+ .overlay {
+ if isSubmitting {
+ ProgressView()
+ .controlSize(.large)
+ }
+ }
+ }
+ }
+
+ private func submit() async {
+ isSubmitting = true
+ defer {
+ isSubmitting = false
+ }
+
+ do {
+ try await viewModel.login(token: token)
+ localErrorMessage = nil
+ } catch {
+ localErrorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/Rune/Views/Settings/SettingsView.swift b/Rune/Views/Settings/SettingsView.swift
new file mode 100644
index 0000000..4d07887
--- /dev/null
+++ b/Rune/Views/Settings/SettingsView.swift
@@ -0,0 +1,73 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @ObservedObject var viewModel: SettingsViewModel
+ let onLogout: () -> Void
+ @State private var showingLogoutConfirmation = false
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section("Wallet") {
+ if let balance = viewModel.balance {
+ HStack {
+ Text("Balance")
+ Spacer()
+ Text("€\(balance.balance)")
+ .foregroundStyle(.secondary)
+ }
+ } else if viewModel.isLoadingBalance {
+ ProgressView()
+ } else {
+ Text("Wallet balance unavailable.")
+ .foregroundStyle(.secondary)
+ }
+
+ Button("Refresh Balance") {
+ Task {
+ do {
+ try await viewModel.refreshBalance()
+ } catch {
+ viewModel.errorMessage = error.localizedDescription
+ }
+ }
+ }
+ .disabled(viewModel.client == nil || viewModel.isLoadingBalance)
+ }
+
+ Section("Account") {
+ Button("Logout", role: .destructive) {
+ showingLogoutConfirmation = true
+ }
+ .foregroundStyle(.red)
+ }
+
+ if let errorMessage = viewModel.errorMessage {
+ Section {
+ Text(errorMessage)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Settings")
+ }
+ .confirmationDialog(
+ "Log out of Rune?",
+ isPresented: $showingLogoutConfirmation,
+ titleVisibility: .visible
+ ) {
+ Button("Log Out", role: .destructive) {
+ do {
+ try viewModel.logout()
+ onLogout()
+ } catch {
+ viewModel.errorMessage = error.localizedDescription
+ }
+ }
+
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("Your API token will be removed from this device.")
+ }
+ }
+}
diff --git a/Rune/Views/Tokens/TokenAddView.swift b/Rune/Views/Tokens/TokenAddView.swift
new file mode 100644
index 0000000..6b71783
--- /dev/null
+++ b/Rune/Views/Tokens/TokenAddView.swift
@@ -0,0 +1,137 @@
+import SwiftUI
+
+struct TokenAddView: View {
+ @ObservedObject var viewModel: TokenViewModel
+ let client: NjallaClient
+
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var comment = ""
+ @State private var fromText = ""
+ @State private var allowedMethodsText = ""
+ @State private var ipValidationMessage: String?
+ @State private var methodValidationMessage: String?
+
+ var body: some View {
+ Form {
+ Section("Token") {
+ TextField("Comment", text: $comment)
+ }
+
+ Section {
+ TextEditor(text: $fromText)
+ .frame(minHeight: 100)
+ if let ipValidationMessage {
+ Text(ipValidationMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ } header: {
+ Text("IP Restrictions")
+ } footer: {
+ Text("Enter one IPv4, IPv6, or CIDR range per line.")
+ }
+
+ Section {
+ TextEditor(text: $allowedMethodsText)
+ .frame(minHeight: 100)
+ if let methodValidationMessage {
+ Text(methodValidationMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ } header: {
+ Text("Allowed Methods")
+ } footer: {
+ Text("Enter one API method per line, for example `list-domains`.")
+ }
+
+ Section {
+ Button("Save") {
+ Task {
+ await save()
+ }
+ }
+ .disabled(viewModel.isSaving)
+ }
+ }
+ .navigationTitle("Add Token")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", role: .cancel) {
+ dismiss()
+ }
+ }
+ }
+ .onChange(of: fromText) { _, _ in
+ ipValidationMessage = nil
+ }
+ .onChange(of: allowedMethodsText) { _, _ in
+ methodValidationMessage = nil
+ }
+ .alert("API Error", isPresented: errorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(viewModel.errorMessage ?? "")
+ }
+ }
+
+ private func save() async {
+ guard validateInput() else {
+ return
+ }
+
+ let request = TokenCreateRequest(
+ comment: comment,
+ from: lines(fromText),
+ allowedMethods: lines(allowedMethodsText)
+ )
+
+ if await viewModel.addToken(request: request, client: client) {
+ dismiss()
+ }
+ }
+
+ private func lines(_ value: String) -> [String] {
+ value
+ .split(whereSeparator: \.isNewline)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+
+ private func validateInput() -> Bool {
+ let ipEntries = lines(fromText)
+ let methodEntries = lines(allowedMethodsText)
+
+ ipValidationMessage = ipEntries.allSatisfy(isValidIPOrCIDR(_:))
+ ? nil
+ : "One or more entries are not valid IP addresses or CIDR ranges."
+
+ methodValidationMessage = methodEntries.allSatisfy(isValidMethodName(_:))
+ ? nil
+ : "One or more method names appear invalid. Use format: list-domains"
+
+ return ipValidationMessage == nil && methodValidationMessage == nil
+ }
+
+ private func isValidMethodName(_ value: String) -> Bool {
+ value.range(of: "^[a-z-]+$", options: .regularExpression) != nil
+ }
+
+ private func isValidIPOrCIDR(_ value: String) -> Bool {
+ let pattern = #"^((\d{1,3}\.){3}\d{1,3})(/(3[0-2]|[12]?\d))?$|^([0-9A-Fa-f:]+)(/\d{1,3})?$"#
+ return value.range(of: pattern, options: .regularExpression) != nil
+ }
+
+ private var errorBinding: Binding<Bool> {
+ Binding(
+ get: { viewModel.errorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ viewModel.errorMessage = nil
+ }
+ }
+ )
+ }
+}
diff --git a/Rune/Views/Tokens/TokenListView.swift b/Rune/Views/Tokens/TokenListView.swift
new file mode 100644
index 0000000..51bae49
--- /dev/null
+++ b/Rune/Views/Tokens/TokenListView.swift
@@ -0,0 +1,143 @@
+import SwiftUI
+
+struct TokenListView: View {
+ @ObservedObject var viewModel: TokenViewModel
+ let client: NjallaClient?
+ let onTokenRemoved: (String) -> Void
+
+ @State private var showingAddToken = false
+ @State private var tokenPendingDeletion: APIToken?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let client {
+ content(client: client)
+ } else {
+ ContentUnavailableView("Sign in required", systemImage: "key.fill", description: Text("Add a valid Njalla API token to load account tokens."))
+ }
+ }
+ .navigationTitle("Tokens")
+ .toolbar {
+ if client != nil {
+ Button {
+ showingAddToken = true
+ } label: {
+ Label("Add Token", systemImage: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showingAddToken) {
+ if let client {
+ NavigationStack {
+ TokenAddView(viewModel: viewModel, client: client)
+ }
+ }
+ }
+ .confirmationDialog(
+ deletionTitle,
+ isPresented: deleteBinding,
+ titleVisibility: .visible
+ ) {
+ Button("Delete Token", role: .destructive) {
+ guard let tokenPendingDeletion, let client else { return }
+ Task {
+ let removed = await viewModel.removeToken(tokenPendingDeletion, client: client)
+ if removed {
+ onTokenRemoved(tokenPendingDeletion.key)
+ }
+ self.tokenPendingDeletion = nil
+ }
+ }
+ }
+ .alert("API Error", isPresented: errorBinding) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(viewModel.errorMessage ?? "")
+ }
+ }
+
+ @ViewBuilder
+ private func content(client: NjallaClient) -> some View {
+ if viewModel.isLoading && viewModel.tokens.isEmpty {
+ ProgressView()
+ } else if viewModel.tokens.isEmpty {
+ ContentUnavailableView("No Tokens", systemImage: "key.horizontal", description: Text("No API tokens were found on this account."))
+ } else {
+ List(viewModel.tokens) { token in
+ Button {
+ tokenPendingDeletion = token
+ } label: {
+ TokenRow(token: token, label: viewModel.tokenLabel(for: token))
+ }
+ .buttonStyle(.plain)
+ }
+ .listStyle(.insetGrouped)
+ .refreshable {
+ await viewModel.loadTokens(client: client)
+ }
+ }
+ }
+
+ private var deletionTitle: String {
+ guard let tokenPendingDeletion else {
+ return ""
+ }
+
+ return "Delete token \(viewModel.tokenLabel(for: tokenPendingDeletion))?"
+ }
+
+ private var deleteBinding: Binding<Bool> {
+ Binding(
+ get: { tokenPendingDeletion != nil },
+ set: { newValue in
+ if !newValue {
+ tokenPendingDeletion = nil
+ }
+ }
+ )
+ }
+
+ private var errorBinding: Binding<Bool> {
+ Binding(
+ get: { viewModel.errorMessage != nil },
+ set: { newValue in
+ if !newValue {
+ viewModel.errorMessage = nil
+ }
+ }
+ )
+ }
+}
+
+private struct TokenRow: View {
+ let token: APIToken
+ let label: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(label)
+ .font(.headline)
+
+ Text(methodsText)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ if let from = token.from, !from.isEmpty {
+ Text("From: \(from.joined(separator: ", "))")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private var methodsText: String {
+ guard let methods = token.allowedMethods, !methods.isEmpty else {
+ return "Methods: Unrestricted"
+ }
+
+ return "Methods: \(methods.joined(separator: ", "))"
+ }
+}