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}