Commit fd8ca178a3

fd8ca178a379da71f5bb19c461524888cad9447e

parent: 2b52ee5520

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 16:18 UTC

v4.8.0: Add scheduled report generation

- ScheduledReportService (@MainActor, headless/storage-backed like
  DomainMonitoringService): builds the latest report for every tracked domain
  from persisted history, exports it via DomainReportExporter in the
  configured format (markdown/PDF/JSON), writes it to a local Documents
  subdirectory, logs the run, and fires a "Scheduled Report Ready" local
  notification.
- ScheduledReportScheduler mirrors DomainMonitoringScheduler's BGTaskScheduler
  approach with its own task identifier (net.cleberg.DomainDig.report.schedule,
  added to Info.plist) and a daily/weekly cadence.
- ScheduledReportsView (Settings → Scheduled Reports): enable toggle, cadence
  and format pickers, "Generate Now", and a log of past reports each shareable
  via the existing ExportPresenter share sheet.
- Gated behind the existing .automatedMonitoring capability (Pro), consistent
  with monitoring being the other background-automation feature.
- Settings/logs persist via UserDefaults (DomainExportFormat is now Codable),
  not DomainDataPortabilityService backup/restore — this is local automation
  configuration, not user-authored content, same reasoning as v4.7.0's
  watchlist saved views.

Layout: unified · split

DomainDig/ContentView.swift +4
@@ -2716,6 +2716,10 @@ struct SettingsView: View {
27162716 NavigationLink("Monitoring") {
27172717 MonitoringSettingsView(viewModel: viewModel)
27182718 }
2719
2720 NavigationLink("Scheduled Reports") {
2721 ScheduledReportsView()
2722 }
27192723 }
27202724
27212725 Section("Data") {
DomainDig/DomainDigApp.swift +3
@@ -20,6 +20,7 @@ struct DomainDigApp: App {
2020 init() {
2121 LocalNotificationService.shared.configureForegroundPresentation()
2222 DomainMonitoringScheduler.shared.registerBackgroundTask()
23 ScheduledReportScheduler.shared.registerBackgroundTask()
2324 }
2425
2526 var body: some Scene {
@@ -38,6 +39,7 @@ struct DomainDigApp: App {
3839 localAPIService.refresh()
3940 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
4041 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
42 ScheduledReportScheduler.shared.syncSchedule()
4143 IntegrationService.shared.processQueueNow()
4244 viewModel.refreshWidgetData()
4345 consumeShareInbox()
@@ -58,6 +60,7 @@ struct DomainDigApp: App {
5860 localAPIService.refresh()
5961 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
6062 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
63 ScheduledReportScheduler.shared.syncSchedule()
6164 IntegrationService.shared.processQueueNow()
6265 viewModel.refreshWidgetData()
6366 consumeShareInbox()
DomainDig/Info.plist +1
@@ -9,6 +9,7 @@
99 <key>BGTaskSchedulerPermittedIdentifiers</key>
1010 <array>
1111 <string>net.cleberg.DomainDig.monitor.refresh</string>
12 <string>net.cleberg.DomainDig.report.schedule</string>
1213 </array>
1314 <key>CFBundleURLTypes</key>
1415 <array>
DomainDig/LocalNotificationService.swift +9
@@ -140,6 +140,15 @@ final class LocalNotificationService {
140140 )
141141 }
142142
143 func notifyScheduledReportReady(domainCount: Int) async {
144 await schedule(
145 identifier: "scheduled-report-\(UUID().uuidString)",
146 title: "Scheduled Report Ready",
147 body: "Report generated for \(domainCount) domain\(domainCount == 1 ? "" : "s").",
148 interruptionLevel: .active
149 )
150 }
151
143152 func clearAllNotifications() async {
144153 let center = UNUserNotificationCenter.current()
145154 center.removeAllPendingNotificationRequests()
DomainDig/ScheduledReportService.swift added +258
@@ -0,0 +1,258 @@
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}
DomainDig/ScheduledReportsView.swift added +130
@@ -0,0 +1,130 @@
1import SwiftUI
2
3struct ScheduledReportsView: View {
4 @Environment(\.appDensity) private var appDensity
5 @State private var settings = ScheduledReportStorage.loadSettings()
6 @State private var logs = ScheduledReportStorage.loadLogs()
7 @State private var isGenerating = false
8 @State private var statusMessage: String?
9
10 var body: some View {
11 List {
12 Section("Overview") {
13 VStack(alignment: .leading, spacing: 8) {
14 if !FeatureAccessService.hasAccess(to: .automatedMonitoring) {
15 Text("Scheduled reports require Pro.")
16 .font(appDensity.font(.caption))
17 .foregroundStyle(.secondary)
18 }
19
20 Toggle("Scheduled Reports", isOn: Binding(
21 get: { settings.isEnabled },
22 set: { newValue in
23 settings.isEnabled = newValue
24 saveSettings()
25 }
26 ))
27 .disabled(!FeatureAccessService.hasAccess(to: .automatedMonitoring))
28
29 Picker("Cadence", selection: Binding(
30 get: { settings.cadence },
31 set: { newValue in
32 settings.cadence = newValue
33 saveSettings()
34 }
35 )) {
36 ForEach(ScheduledReportCadence.allCases) { cadence in
37 Text(cadence.title).tag(cadence)
38 }
39 }
40
41 Picker("Format", selection: Binding(
42 get: { settings.format },
43 set: { newValue in
44 settings.format = newValue
45 saveSettings()
46 }
47 )) {
48 ForEach([DomainExportFormat.markdown, .pdf, .json]) { format in
49 Text(format.title).tag(format)
50 }
51 }
52
53 LabeledContent(
54 "Last Generated",
55 value: settings.lastGeneratedAt?.formatted(date: .abbreviated, time: .shortened) ?? "Never"
56 )
57
58 if let statusMessage {
59 Text(statusMessage)
60 .font(appDensity.font(.caption))
61 .foregroundStyle(.secondary)
62 }
63
64 Button(isGenerating ? "Generating…" : "Generate Now") {
65 Task { await generateNow() }
66 }
67 .disabled(isGenerating)
68 }
69 .padding(.vertical, 4)
70 }
71 .listRowBackground(Color(.systemGray6).opacity(0.5))
72
73 if logs.isEmpty {
74 Section {
75 EmptyStateCardView(
76 title: "No Reports Yet",
77 message: "Generated reports for your watchlist appear here after a manual or scheduled run.",
78 suggestion: "Tap Generate Now, or enable Scheduled Reports above.",
79 systemImage: "doc.text",
80 showsCardBackground: false
81 )
82 }
83 .listRowBackground(Color(.systemGray6).opacity(0.5))
84 } else {
85 Section("Recent Reports") {
86 ForEach(logs) { log in
87 Button {
88 share(log)
89 } label: {
90 VStack(alignment: .leading, spacing: 2) {
91 Text("\(log.domainCount) domains • \(log.format.title)")
92 .foregroundStyle(.primary)
93 Text(log.generatedAt.formatted(date: .abbreviated, time: .shortened))
94 .font(.caption)
95 .foregroundStyle(.secondary)
96 }
97 }
98 }
99 }
100 .listRowBackground(Color(.systemGray6).opacity(0.5))
101 }
102 }
103 .scrollContentBackground(.hidden)
104 .background(Color.black)
105 .navigationTitle("Scheduled Reports")
106 }
107
108 private func saveSettings() {
109 ScheduledReportStorage.saveSettings(settings)
110 ScheduledReportScheduler.shared.syncSchedule()
111 }
112
113 private func generateNow() async {
114 isGenerating = true
115 let outcome = await ScheduledReportService.shared.generateReport(trigger: .manual, requireEnabledSetting: false)
116 statusMessage = outcome.message
117 settings = ScheduledReportStorage.loadSettings()
118 logs = ScheduledReportStorage.loadLogs()
119 isGenerating = false
120 }
121
122 private func share(_ log: ScheduledReportLog) {
123 let url = ScheduledReportStorage.reportsDirectory.appendingPathComponent(log.fileName)
124 guard let data = try? Data(contentsOf: url) else {
125 statusMessage = "That report is no longer available on disk."
126 return
127 }
128 ExportPresenter.share(filename: log.fileName, data: data)
129 }
130}
DomainReportExporter.swift +1 −1
@@ -4,7 +4,7 @@ import Foundation
44import UIKit
55#endif
66
7enum DomainExportFormat: String, CaseIterable, Identifiable {
7enum DomainExportFormat: String, CaseIterable, Identifiable, Codable {
88 case text = "txt"
99 case csv = "csv"
1010 case json = "json"