krz/rune
an ios client for njalla
clone: git clone https://gitbay.org/krz/rune.git
v1.2.0: Rune/ViewModels/DomainViewModel.swift · raw
1import Combine
2import Foundation
3
4@MainActor
5final class DomainViewModel: ObservableObject {
6 @Published private(set) var domains: [Domain] = []
7 @Published private(set) var selectedDomain: Domain?
8 @Published private(set) var records: [DNSRecord] = []
9 @Published private(set) var forwards: [EmailForward] = []
10 @Published private(set) var glueRecords: [GlueRecord] = []
11 @Published private(set) var isLoadingDomains = false
12 @Published private(set) var isLoadingDetail = false
13 @Published private(set) var isLoadingRecords = false
14 @Published private(set) var isLoadingForwards = false
15 @Published private(set) var isLoadingGlue = false
16 @Published private(set) var isSaving = false
17 @Published private(set) var hasLoadedDomains = false
18 @Published var domainsErrorMessage: String?
19 @Published var detailErrorMessage: String?
20 @Published var recordsErrorMessage: String?
21 @Published var forwardsErrorMessage: String?
22 @Published var glueErrorMessage: String?
23 @Published var mutationErrorMessage: String?
24
25 private var recordsRefreshTask: Task<Void, Never>?
26
27 func reset() {
28 domains = []
29 selectedDomain = nil
30 records = []
31 forwards = []
32 glueRecords = []
33 isLoadingDomains = false
34 isLoadingDetail = false
35 isLoadingRecords = false
36 isLoadingForwards = false
37 isLoadingGlue = false
38 isSaving = false
39 hasLoadedDomains = false
40 domainsErrorMessage = nil
41 detailErrorMessage = nil
42 recordsErrorMessage = nil
43 forwardsErrorMessage = nil
44 glueErrorMessage = nil
45 mutationErrorMessage = nil
46 stopAutoRefreshRecords()
47 }
48
49 func loadDomains(client: NjallaClient) async {
50 guard !isLoadingDomains else { return }
51
52 isLoadingDomains = true
53 defer {
54 isLoadingDomains = false
55 hasLoadedDomains = true
56 }
57
58 do {
59 domains = try await client.listDomains().sorted {
60 $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
61 }
62 domainsErrorMessage = nil
63 } catch is CancellationError {
64 return
65 } catch {
66 if (error as? URLError)?.code == .cancelled {
67 return
68 }
69 domainsErrorMessage = error.userFacingMessage
70 }
71 }
72
73 func loadDomainDetail(named name: String, client: NjallaClient) async {
74 guard !isLoadingDetail else { return }
75
76 isLoadingDetail = true
77 defer {
78 isLoadingDetail = false
79 }
80
81 do {
82 let domain = try await client.getDomain(named: name)
83 selectedDomain = domain
84 if let index = domains.firstIndex(where: { $0.name == name }) {
85 domains[index] = domain
86 }
87 detailErrorMessage = nil
88 } catch is CancellationError {
89 return
90 } catch {
91 if (error as? URLError)?.code == .cancelled {
92 return
93 }
94 detailErrorMessage = error.userFacingMessage
95 }
96 }
97
98 func updateDomain(named name: String, request: DomainUpdateRequest, client: NjallaClient) async throws {
99 guard !isSaving else { return }
100
101 isSaving = true
102 mutationErrorMessage = nil
103
104 do {
105 let updated = try await client.editDomain(named: name, request: request)
106 guard request.isSatisfied(by: updated) else {
107 throw NjallaError.api(message: "The API response did not reflect the requested domain changes.")
108 }
109 selectedDomain = updated
110 if let index = domains.firstIndex(where: { $0.name == updated.name }) {
111 domains[index] = updated
112 }
113 detailErrorMessage = nil
114 isSaving = false
115 Task {
116 await loadDomainDetail(named: updated.name, client: client)
117 }
118 } catch {
119 isSaving = false
120 mutationErrorMessage = error.userFacingMessage
121 throw error
122 }
123 }
124
125 func loadRecords(for domain: String, client: NjallaClient) async {
126 await fetchRecords(for: domain, client: client)
127 }
128
129 func startAutoRefreshRecords(for domain: String, client: NjallaClient) {
130 guard recordsRefreshTask == nil else { return }
131
132 recordsRefreshTask = Task { [weak self] in
133 guard let self else { return }
134
135 while !Task.isCancelled {
136 try? await Task.sleep(for: .seconds(5))
137
138 guard !Task.isCancelled else { return }
139 await self.fetchRecords(for: domain, client: client)
140 }
141 }
142 }
143
144 func stopAutoRefreshRecords() {
145 recordsRefreshTask?.cancel()
146 recordsRefreshTask = nil
147 }
148
149 func dismissMutationError() {
150 mutationErrorMessage = nil
151 }
152
153 func loadForwards(for domain: String, client: NjallaClient) async {
154 guard !isLoadingForwards else { return }
155
156 isLoadingForwards = true
157 defer {
158 isLoadingForwards = false
159 }
160
161 do {
162 forwards = try await client.listForwards(for: domain).sorted {
163 ($0.from, $0.to) < ($1.from, $1.to)
164 }
165 forwardsErrorMessage = nil
166 } catch is CancellationError {
167 return
168 } catch {
169 if (error as? URLError)?.code == .cancelled {
170 return
171 }
172 forwardsErrorMessage = error.userFacingMessage
173 }
174 }
175
176 func loadGlue(for domain: String, client: NjallaClient) async {
177 guard !isLoadingGlue else { return }
178
179 isLoadingGlue = true
180 defer {
181 isLoadingGlue = false
182 }
183
184 do {
185 glueRecords = try await client.listGlue(for: domain).sorted { $0.name < $1.name }
186 glueErrorMessage = nil
187 } catch is CancellationError {
188 return
189 } catch {
190 if (error as? URLError)?.code == .cancelled {
191 return
192 }
193 glueErrorMessage = error.userFacingMessage
194 }
195 }
196
197 func addGlue(for domain: String, name: String, address4: String?, address6: String?, client: NjallaClient) async throws {
198 guard !isSaving else { return }
199
200 isSaving = true
201 mutationErrorMessage = nil
202
203 do {
204 try await client.addGlue(for: domain, name: name, address4: address4, address6: address6)
205 isSaving = false
206 Task {
207 await loadGlue(for: domain, client: client)
208 }
209 } catch {
210 isSaving = false
211 mutationErrorMessage = error.userFacingMessage
212 throw error
213 }
214 }
215
216 func editGlue(for domain: String, name: String, address4: String?, address6: String?, client: NjallaClient) async throws {
217 guard !isSaving else { return }
218
219 isSaving = true
220 mutationErrorMessage = nil
221
222 do {
223 try await client.editGlue(for: domain, name: name, address4: address4, address6: address6)
224 isSaving = false
225 Task {
226 await loadGlue(for: domain, client: client)
227 }
228 } catch {
229 isSaving = false
230 mutationErrorMessage = error.userFacingMessage
231 throw error
232 }
233 }
234
235 func removeGlue(_ record: GlueRecord, client: NjallaClient) async throws {
236 guard !isSaving else { return }
237
238 isSaving = true
239 mutationErrorMessage = nil
240
241 do {
242 try await client.removeGlue(for: record.domain, name: record.name)
243 glueRecords.removeAll { $0.id == record.id }
244 isSaving = false
245 Task {
246 await loadGlue(for: record.domain, client: client)
247 }
248 } catch {
249 isSaving = false
250 mutationErrorMessage = error.userFacingMessage
251 throw error
252 }
253 }
254
255 private func fetchRecords(for domain: String, client: NjallaClient) async {
256 guard !isLoadingRecords else { return }
257
258 isLoadingRecords = true
259 defer {
260 isLoadingRecords = false
261 }
262
263 do {
264 records = try await client.listRecords(for: domain).sorted {
265 ($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id)
266 }
267 recordsErrorMessage = nil
268 } catch is CancellationError {
269 return
270 } catch {
271 if (error as? URLError)?.code == .cancelled {
272 return
273 }
274 recordsErrorMessage = error.userFacingMessage
275 }
276 }
277
278 func addRecord(for domain: String, draft: DNSRecordDraft, client: NjallaClient) async throws {
279 guard !isSaving else { return }
280
281 isSaving = true
282 mutationErrorMessage = nil
283
284 do {
285 let record = try await client.addRecord(for: domain, draft: draft).withDomain(domain)
286 guard record.id.isEmpty == false,
287 record.type == draft.type.rawValue,
288 record.name == draft.trimmedName else {
289 throw NjallaError.api(message: "The API response did not reflect the requested record change.")
290 }
291 records = sortedRecords(records + [record])
292 recordsErrorMessage = nil
293 isSaving = false
294 Task {
295 await loadRecords(for: domain, client: client)
296 }
297 } catch {
298 isSaving = false
299 mutationErrorMessage = error.userFacingMessage
300 throw error
301 }
302 }
303
304 func editRecord(for domain: String, recordID: String, draft: DNSRecordDraft, client: NjallaClient) async throws {
305 guard !isSaving else { return }
306
307 isSaving = true
308 mutationErrorMessage = nil
309
310 do {
311 let updatedRecord = try await client.editRecord(for: domain, id: recordID, draft: draft).withDomain(domain)
312 guard updatedRecord.id == recordID,
313 updatedRecord.type == draft.type.rawValue,
314 updatedRecord.name == draft.trimmedName else {
315 throw NjallaError.api(message: "The API response did not reflect the requested record change.")
316 }
317 if let index = records.firstIndex(where: { $0.id == recordID }) {
318 records[index] = updatedRecord
319 } else {
320 records.append(updatedRecord)
321 }
322 records = sortedRecords(records)
323 recordsErrorMessage = nil
324 isSaving = false
325 Task {
326 await loadRecords(for: domain, client: client)
327 }
328 } catch {
329 isSaving = false
330 mutationErrorMessage = error.userFacingMessage
331 throw error
332 }
333 }
334
335 func removeRecord(_ record: DNSRecord, client: NjallaClient) async throws {
336 guard !isSaving else { return }
337
338 isSaving = true
339 mutationErrorMessage = nil
340
341 do {
342 let updatedRecords = try await client.removeRecord(record)
343 if updatedRecords.contains(where: { $0.id == record.id }) {
344 // Some API responses can be eventually consistent; treat successful call as authoritative.
345 records.removeAll { $0.id == record.id }
346 } else {
347 records = sortedRecords(updatedRecords)
348 }
349 recordsErrorMessage = nil
350 isSaving = false
351 Task {
352 await loadRecords(for: record.domain, client: client)
353 }
354 } catch {
355 isSaving = false
356 mutationErrorMessage = error.userFacingMessage
357 throw error
358 }
359 }
360
361 func addForward(_ forward: EmailForward, client: NjallaClient) async throws {
362 guard !isSaving else { return }
363
364 isSaving = true
365 mutationErrorMessage = nil
366
367 do {
368 try await client.addForward(forward: forward)
369 if forwards.contains(forward) == false {
370 forwards = sortedForwards(forwards + [forward])
371 }
372 forwardsErrorMessage = nil
373 isSaving = false
374 Task {
375 await loadForwards(for: forward.domain, client: client)
376 }
377 } catch {
378 isSaving = false
379 mutationErrorMessage = error.userFacingMessage
380 throw error
381 }
382 }
383
384 func removeForward(_ forward: EmailForward, client: NjallaClient) async throws {
385 guard !isSaving else { return }
386
387 isSaving = true
388 mutationErrorMessage = nil
389
390 do {
391 try await client.removeForward(forward)
392 forwards.removeAll { $0 == forward }
393 forwardsErrorMessage = nil
394 isSaving = false
395 Task {
396 await loadForwards(for: forward.domain, client: client)
397 }
398 } catch {
399 isSaving = false
400 mutationErrorMessage = error.userFacingMessage
401 throw error
402 }
403 }
404
405 private func sortedRecords(_ records: [DNSRecord]) -> [DNSRecord] {
406 records.sorted {
407 ($0.name, $0.type, $0.id) < ($1.name, $1.type, $1.id)
408 }
409 }
410
411 private func sortedForwards(_ forwards: [EmailForward]) -> [EmailForward] {
412 forwards.sorted {
413 ($0.from, $0.to) < ($1.from, $1.to)
414 }
415 }
416}