krz/rune

an ios client for njalla

clone: git clone https://gitbay.org/krz/rune.git

main: 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}