krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.8.1: DomainDig/ScheduledReportService.swift · raw

  1import Foundation
  2
  3#if canImport(BackgroundTasks)
  4import BackgroundTasks
  5#endif
  6
  7#if canImport(UIKit)
  8import UIKit
  9#endif
 10
 11enum ScheduledReportCadence: String, Codable, CaseIterable, Identifiable {
 12    case daily
 13    case weekly
 14
 15    var id: String { rawValue }
 16    var title: String { rawValue.capitalized }
 17
 18    var interval: TimeInterval {
 19        switch self {
 20        case .daily: return 86400
 21        case .weekly: return 7 * 86400
 22        }
 23    }
 24}
 25
 26struct ScheduledReportSettings: Codable, Equatable {
 27    var isEnabled: Bool = false
 28    var cadence: ScheduledReportCadence = .weekly
 29    var format: DomainExportFormat = .markdown
 30    var lastGeneratedAt: Date?
 31}
 32
 33struct ScheduledReportLog: Codable, Identifiable, Equatable {
 34    let id: UUID
 35    let generatedAt: Date
 36    let domainCount: Int
 37    let format: DomainExportFormat
 38    let fileName: String
 39
 40    init(
 41        id: UUID = UUID(),
 42        generatedAt: Date,
 43        domainCount: Int,
 44        format: DomainExportFormat,
 45        fileName: String
 46    ) {
 47        self.id = id
 48        self.generatedAt = generatedAt
 49        self.domainCount = domainCount
 50        self.format = format
 51        self.fileName = fileName
 52    }
 53}
 54
 55/// Local, UserDefaults-backed persistence for scheduled-report settings and
 56/// generation history. Deliberately not part of `DomainDataPortabilityService`
 57/// backup/restore: this is local automation configuration, not user-authored
 58/// content like tracked domains or audit sessions.
 59enum ScheduledReportStorage {
 60    private static let settingsKey = "scheduledReport.settings"
 61    private static let logsKey = "scheduledReport.logs"
 62    static let maxLogs = 20
 63
 64    static func loadSettings() -> ScheduledReportSettings {
 65        guard let data = UserDefaults.standard.data(forKey: settingsKey),
 66              let settings = try? JSONDecoder().decode(ScheduledReportSettings.self, from: data)
 67        else { return ScheduledReportSettings() }
 68        return settings
 69    }
 70
 71    static func saveSettings(_ settings: ScheduledReportSettings) {
 72        guard let data = try? JSONEncoder().encode(settings) else { return }
 73        UserDefaults.standard.set(data, forKey: settingsKey)
 74    }
 75
 76    static func loadLogs() -> [ScheduledReportLog] {
 77        guard let data = UserDefaults.standard.data(forKey: logsKey),
 78              let logs = try? JSONDecoder().decode([ScheduledReportLog].self, from: data)
 79        else { return [] }
 80        return logs
 81    }
 82
 83    static func saveLogs(_ logs: [ScheduledReportLog]) {
 84        guard let data = try? JSONEncoder().encode(Array(logs.prefix(maxLogs))) else { return }
 85        UserDefaults.standard.set(data, forKey: logsKey)
 86    }
 87
 88    static var reportsDirectory: URL {
 89        let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
 90            .appendingPathComponent("ScheduledReports", isDirectory: true)
 91        try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
 92        return base
 93    }
 94}
 95
 96struct ScheduledReportOutcome {
 97    let success: Bool
 98    let message: String
 99    let log: ScheduledReportLog?
100}
101
102/// Generates a bundled report for all tracked domains and writes it to disk.
103/// Mirrors `DomainMonitoringService`'s headless, storage-backed design so it
104/// can run from a background task without a live view model.
105@MainActor
106final class ScheduledReportService {
107    static let shared = ScheduledReportService()
108
109    private init() {}
110
111    @discardableResult
112    func generateReport(trigger: MonitoringRunTrigger, requireEnabledSetting: Bool) async -> ScheduledReportOutcome {
113        var settings = ScheduledReportStorage.loadSettings()
114
115        guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
116            if settings.isEnabled {
117                settings.isEnabled = false
118                ScheduledReportStorage.saveSettings(settings)
119                ScheduledReportScheduler.shared.syncSchedule()
120            }
121            return ScheduledReportOutcome(success: false, message: "Scheduled reports require Pro.", log: nil)
122        }
123
124        if requireEnabledSetting, !settings.isEnabled {
125            return ScheduledReportOutcome(success: false, message: "Scheduled reports are disabled.", log: nil)
126        }
127
128        let trackedDomains = DomainDataPortabilityService.loadTrackedDomains()
129        guard !trackedDomains.isEmpty else {
130            return ScheduledReportOutcome(success: false, message: "No tracked domains to report on.", log: nil)
131        }
132
133        let history = DomainDataPortabilityService.loadHistoryEntries()
134        let builder = DomainReportBuilder()
135        let reports = trackedDomains.compactMap { trackedDomain -> DomainReport? in
136            let entry = history.first { entry in
137                if let trackedDomainID = entry.trackedDomainID {
138                    return trackedDomainID == trackedDomain.id
139                }
140                return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
141            }
142            guard let entry else { return nil }
143            return builder.build(from: entry, historyEntries: history)
144        }
145
146        guard !reports.isEmpty else {
147            return ScheduledReportOutcome(success: false, message: "No history available for tracked domains yet.", log: nil)
148        }
149
150        guard let data = try? DomainReportExporter.data(for: reports, format: settings.format, title: "Scheduled Watchlist Report") else {
151            return ScheduledReportOutcome(success: false, message: "Report generation failed.", log: nil)
152        }
153
154        let generatedAt = Date()
155        let fileName = "\(Self.fileTimestampFormatter.string(from: generatedAt))_scheduled-report.\(settings.format.fileExtension)"
156        let fileURL = ScheduledReportStorage.reportsDirectory.appendingPathComponent(fileName)
157
158        do {
159            try data.write(to: fileURL, options: .atomic)
160        } catch {
161            return ScheduledReportOutcome(success: false, message: "Could not save report to disk.", log: nil)
162        }
163
164        let log = ScheduledReportLog(generatedAt: generatedAt, domainCount: reports.count, format: settings.format, fileName: fileName)
165        var logs = ScheduledReportStorage.loadLogs()
166        logs.insert(log, at: 0)
167        ScheduledReportStorage.saveLogs(logs)
168
169        settings.lastGeneratedAt = generatedAt
170        ScheduledReportStorage.saveSettings(settings)
171
172        await LocalNotificationService.shared.notifyScheduledReportReady(domainCount: reports.count)
173
174        return ScheduledReportOutcome(success: true, message: "Report generated for \(reports.count) domains.", log: log)
175    }
176
177    private static let fileTimestampFormatter: DateFormatter = {
178        let formatter = DateFormatter()
179        formatter.dateFormat = "yyyyMMdd_HHmmss"
180        return formatter
181    }()
182}
183
184/// Background scheduling for report generation, mirroring
185/// `DomainMonitoringScheduler`'s BGTaskScheduler-based approach with its own
186/// task identifier and cadence.
187@MainActor
188final class ScheduledReportScheduler {
189    static let shared = ScheduledReportScheduler()
190    static let taskIdentifier = "net.cleberg.DomainDig.report.schedule"
191
192    private var isRegistered = false
193
194    private init() {}
195
196    func registerBackgroundTask() {
197        #if canImport(BackgroundTasks)
198        guard !isRegistered else { return }
199        isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in
200            guard let refreshTask = task as? BGAppRefreshTask else {
201                task.setTaskCompleted(success: false)
202                return
203            }
204            self.handleAppRefresh(task: refreshTask)
205        }
206        #endif
207    }
208
209    @discardableResult
210    func syncSchedule() -> String? {
211        #if canImport(BackgroundTasks)
212        let settings = ScheduledReportStorage.loadSettings()
213        guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
214            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
215            return nil
216        }
217
218        #if canImport(UIKit)
219        guard UIApplication.shared.backgroundRefreshStatus == .available else {
220            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
221            return "Background refresh is unavailable."
222        }
223        #endif
224
225        let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
226        let baseDate = settings.lastGeneratedAt ?? Date()
227        request.earliestBeginDate = max(
228            baseDate.addingTimeInterval(settings.cadence.interval),
229            Date(timeIntervalSinceNow: 15 * 60)
230        )
231
232        do {
233            BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
234            try BGTaskScheduler.shared.submit(request)
235            return nil
236        } catch {
237            return "Could not schedule reports."
238        }
239        #else
240        return "Scheduled reports are unavailable on this platform."
241        #endif
242    }
243
244    #if canImport(BackgroundTasks)
245    private func handleAppRefresh(task: BGAppRefreshTask) {
246        _ = syncSchedule()
247
248        let worker = Task {
249            let outcome = await ScheduledReportService.shared.generateReport(trigger: .background, requireEnabledSetting: true)
250            task.setTaskCompleted(success: outcome.success)
251        }
252
253        task.expirationHandler = {
254            worker.cancel()
255        }
256    }
257    #endif
258}