diff options
| author | Christian Cleberg <[email protected]> | 2026-07-17 11:18:25 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-20 13:56:41 -0500 |
| commit | fd8ca178a379da71f5bb19c461524888cad9447e (patch) | |
| tree | adb481e4c7c22e41e919b97ccded0e506083f9b3 /DomainDig | |
| parent | 2b52ee5520cad3c47d30d513946645366f268762 (diff) | |
| download | domain-dig-fd8ca178a379da71f5bb19c461524888cad9447e.tar.gz domain-dig-fd8ca178a379da71f5bb19c461524888cad9447e.tar.bz2 domain-dig-fd8ca178a379da71f5bb19c461524888cad9447e.zip | |
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.
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/ContentView.swift | 4 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 3 | ||||
| -rw-r--r-- | DomainDig/Info.plist | 1 | ||||
| -rw-r--r-- | DomainDig/LocalNotificationService.swift | 9 | ||||
| -rw-r--r-- | DomainDig/ScheduledReportService.swift | 258 | ||||
| -rw-r--r-- | DomainDig/ScheduledReportsView.swift | 130 |
6 files changed, 405 insertions, 0 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index d69cfa3..8f4eada 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -2716,6 +2716,10 @@ struct SettingsView: View { NavigationLink("Monitoring") { MonitoringSettingsView(viewModel: viewModel) } + + NavigationLink("Scheduled Reports") { + ScheduledReportsView() + } } Section("Data") { diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index d34c51d..2f2070c 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -20,6 +20,7 @@ struct DomainDigApp: App { init() { LocalNotificationService.shared.configureForegroundPresentation() DomainMonitoringScheduler.shared.registerBackgroundTask() + ScheduledReportScheduler.shared.registerBackgroundTask() } var body: some Scene { @@ -38,6 +39,7 @@ struct DomainDigApp: App { localAPIService.refresh() cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + ScheduledReportScheduler.shared.syncSchedule() IntegrationService.shared.processQueueNow() viewModel.refreshWidgetData() consumeShareInbox() @@ -58,6 +60,7 @@ struct DomainDigApp: App { localAPIService.refresh() cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() + ScheduledReportScheduler.shared.syncSchedule() IntegrationService.shared.processQueueNow() viewModel.refreshWidgetData() consumeShareInbox() diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist index 41b193b..2f05dea 100644 --- a/DomainDig/Info.plist +++ b/DomainDig/Info.plist @@ -9,6 +9,7 @@ <key>BGTaskSchedulerPermittedIdentifiers</key> <array> <string>net.cleberg.DomainDig.monitor.refresh</string> + <string>net.cleberg.DomainDig.report.schedule</string> </array> <key>CFBundleURLTypes</key> <array> diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift index f417619..d3474e3 100644 --- a/DomainDig/LocalNotificationService.swift +++ b/DomainDig/LocalNotificationService.swift @@ -140,6 +140,15 @@ final class LocalNotificationService { ) } + func notifyScheduledReportReady(domainCount: Int) async { + await schedule( + identifier: "scheduled-report-\(UUID().uuidString)", + title: "Scheduled Report Ready", + body: "Report generated for \(domainCount) domain\(domainCount == 1 ? "" : "s").", + interruptionLevel: .active + ) + } + func clearAllNotifications() async { let center = UNUserNotificationCenter.current() center.removeAllPendingNotificationRequests() diff --git a/DomainDig/ScheduledReportService.swift b/DomainDig/ScheduledReportService.swift new file mode 100644 index 0000000..c1db3de --- /dev/null +++ b/DomainDig/ScheduledReportService.swift @@ -0,0 +1,258 @@ +import Foundation + +#if canImport(BackgroundTasks) +import BackgroundTasks +#endif + +#if canImport(UIKit) +import UIKit +#endif + +enum ScheduledReportCadence: String, Codable, CaseIterable, Identifiable { + case daily + case weekly + + var id: String { rawValue } + var title: String { rawValue.capitalized } + + var interval: TimeInterval { + switch self { + case .daily: return 86400 + case .weekly: return 7 * 86400 + } + } +} + +struct ScheduledReportSettings: Codable, Equatable { + var isEnabled: Bool = false + var cadence: ScheduledReportCadence = .weekly + var format: DomainExportFormat = .markdown + var lastGeneratedAt: Date? +} + +struct ScheduledReportLog: Codable, Identifiable, Equatable { + let id: UUID + let generatedAt: Date + let domainCount: Int + let format: DomainExportFormat + let fileName: String + + init( + id: UUID = UUID(), + generatedAt: Date, + domainCount: Int, + format: DomainExportFormat, + fileName: String + ) { + self.id = id + self.generatedAt = generatedAt + self.domainCount = domainCount + self.format = format + self.fileName = fileName + } +} + +/// Local, UserDefaults-backed persistence for scheduled-report settings and +/// generation history. Deliberately not part of `DomainDataPortabilityService` +/// backup/restore: this is local automation configuration, not user-authored +/// content like tracked domains or audit sessions. +enum ScheduledReportStorage { + private static let settingsKey = "scheduledReport.settings" + private static let logsKey = "scheduledReport.logs" + static let maxLogs = 20 + + static func loadSettings() -> ScheduledReportSettings { + guard let data = UserDefaults.standard.data(forKey: settingsKey), + let settings = try? JSONDecoder().decode(ScheduledReportSettings.self, from: data) + else { return ScheduledReportSettings() } + return settings + } + + static func saveSettings(_ settings: ScheduledReportSettings) { + guard let data = try? JSONEncoder().encode(settings) else { return } + UserDefaults.standard.set(data, forKey: settingsKey) + } + + static func loadLogs() -> [ScheduledReportLog] { + guard let data = UserDefaults.standard.data(forKey: logsKey), + let logs = try? JSONDecoder().decode([ScheduledReportLog].self, from: data) + else { return [] } + return logs + } + + static func saveLogs(_ logs: [ScheduledReportLog]) { + guard let data = try? JSONEncoder().encode(Array(logs.prefix(maxLogs))) else { return } + UserDefaults.standard.set(data, forKey: logsKey) + } + + static var reportsDirectory: URL { + let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("ScheduledReports", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base + } +} + +struct ScheduledReportOutcome { + let success: Bool + let message: String + let log: ScheduledReportLog? +} + +/// Generates a bundled report for all tracked domains and writes it to disk. +/// Mirrors `DomainMonitoringService`'s headless, storage-backed design so it +/// can run from a background task without a live view model. +@MainActor +final class ScheduledReportService { + static let shared = ScheduledReportService() + + private init() {} + + @discardableResult + func generateReport(trigger: MonitoringRunTrigger, requireEnabledSetting: Bool) async -> ScheduledReportOutcome { + var settings = ScheduledReportStorage.loadSettings() + + guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + if settings.isEnabled { + settings.isEnabled = false + ScheduledReportStorage.saveSettings(settings) + ScheduledReportScheduler.shared.syncSchedule() + } + return ScheduledReportOutcome(success: false, message: "Scheduled reports require Pro.", log: nil) + } + + if requireEnabledSetting, !settings.isEnabled { + return ScheduledReportOutcome(success: false, message: "Scheduled reports are disabled.", log: nil) + } + + let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() + guard !trackedDomains.isEmpty else { + return ScheduledReportOutcome(success: false, message: "No tracked domains to report on.", log: nil) + } + + let history = DomainDataPortabilityService.loadHistoryEntries() + let builder = DomainReportBuilder() + let reports = trackedDomains.compactMap { trackedDomain -> DomainReport? in + let entry = history.first { entry in + if let trackedDomainID = entry.trackedDomainID { + return trackedDomainID == trackedDomain.id + } + return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame + } + guard let entry else { return nil } + return builder.build(from: entry, historyEntries: history) + } + + guard !reports.isEmpty else { + return ScheduledReportOutcome(success: false, message: "No history available for tracked domains yet.", log: nil) + } + + guard let data = try? DomainReportExporter.data(for: reports, format: settings.format, title: "Scheduled Watchlist Report") else { + return ScheduledReportOutcome(success: false, message: "Report generation failed.", log: nil) + } + + let generatedAt = Date() + let fileName = "\(Self.fileTimestampFormatter.string(from: generatedAt))_scheduled-report.\(settings.format.fileExtension)" + let fileURL = ScheduledReportStorage.reportsDirectory.appendingPathComponent(fileName) + + do { + try data.write(to: fileURL, options: .atomic) + } catch { + return ScheduledReportOutcome(success: false, message: "Could not save report to disk.", log: nil) + } + + let log = ScheduledReportLog(generatedAt: generatedAt, domainCount: reports.count, format: settings.format, fileName: fileName) + var logs = ScheduledReportStorage.loadLogs() + logs.insert(log, at: 0) + ScheduledReportStorage.saveLogs(logs) + + settings.lastGeneratedAt = generatedAt + ScheduledReportStorage.saveSettings(settings) + + await LocalNotificationService.shared.notifyScheduledReportReady(domainCount: reports.count) + + return ScheduledReportOutcome(success: true, message: "Report generated for \(reports.count) domains.", log: log) + } + + private static let fileTimestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + return formatter + }() +} + +/// Background scheduling for report generation, mirroring +/// `DomainMonitoringScheduler`'s BGTaskScheduler-based approach with its own +/// task identifier and cadence. +@MainActor +final class ScheduledReportScheduler { + static let shared = ScheduledReportScheduler() + static let taskIdentifier = "net.cleberg.DomainDig.report.schedule" + + private var isRegistered = false + + private init() {} + + func registerBackgroundTask() { + #if canImport(BackgroundTasks) + guard !isRegistered else { return } + isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + self.handleAppRefresh(task: refreshTask) + } + #endif + } + + @discardableResult + func syncSchedule() -> String? { + #if canImport(BackgroundTasks) + let settings = ScheduledReportStorage.loadSettings() + guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + return nil + } + + #if canImport(UIKit) + guard UIApplication.shared.backgroundRefreshStatus == .available else { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + return "Background refresh is unavailable." + } + #endif + + let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + let baseDate = settings.lastGeneratedAt ?? Date() + request.earliestBeginDate = max( + baseDate.addingTimeInterval(settings.cadence.interval), + Date(timeIntervalSinceNow: 15 * 60) + ) + + do { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier) + try BGTaskScheduler.shared.submit(request) + return nil + } catch { + return "Could not schedule reports." + } + #else + return "Scheduled reports are unavailable on this platform." + #endif + } + + #if canImport(BackgroundTasks) + private func handleAppRefresh(task: BGAppRefreshTask) { + _ = syncSchedule() + + let worker = Task { + let outcome = await ScheduledReportService.shared.generateReport(trigger: .background, requireEnabledSetting: true) + task.setTaskCompleted(success: outcome.success) + } + + task.expirationHandler = { + worker.cancel() + } + } + #endif +} diff --git a/DomainDig/ScheduledReportsView.swift b/DomainDig/ScheduledReportsView.swift new file mode 100644 index 0000000..f97ee77 --- /dev/null +++ b/DomainDig/ScheduledReportsView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct ScheduledReportsView: View { + @Environment(\.appDensity) private var appDensity + @State private var settings = ScheduledReportStorage.loadSettings() + @State private var logs = ScheduledReportStorage.loadLogs() + @State private var isGenerating = false + @State private var statusMessage: String? + + var body: some View { + List { + Section("Overview") { + VStack(alignment: .leading, spacing: 8) { + if !FeatureAccessService.hasAccess(to: .automatedMonitoring) { + Text("Scheduled reports require Pro.") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + + Toggle("Scheduled Reports", isOn: Binding( + get: { settings.isEnabled }, + set: { newValue in + settings.isEnabled = newValue + saveSettings() + } + )) + .disabled(!FeatureAccessService.hasAccess(to: .automatedMonitoring)) + + Picker("Cadence", selection: Binding( + get: { settings.cadence }, + set: { newValue in + settings.cadence = newValue + saveSettings() + } + )) { + ForEach(ScheduledReportCadence.allCases) { cadence in + Text(cadence.title).tag(cadence) + } + } + + Picker("Format", selection: Binding( + get: { settings.format }, + set: { newValue in + settings.format = newValue + saveSettings() + } + )) { + ForEach([DomainExportFormat.markdown, .pdf, .json]) { format in + Text(format.title).tag(format) + } + } + + LabeledContent( + "Last Generated", + value: settings.lastGeneratedAt?.formatted(date: .abbreviated, time: .shortened) ?? "Never" + ) + + if let statusMessage { + Text(statusMessage) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + + Button(isGenerating ? "Generating…" : "Generate Now") { + Task { await generateNow() } + } + .disabled(isGenerating) + } + .padding(.vertical, 4) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if logs.isEmpty { + Section { + EmptyStateCardView( + title: "No Reports Yet", + message: "Generated reports for your watchlist appear here after a manual or scheduled run.", + suggestion: "Tap Generate Now, or enable Scheduled Reports above.", + systemImage: "doc.text", + showsCardBackground: false + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + Section("Recent Reports") { + ForEach(logs) { log in + Button { + share(log) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("\(log.domainCount) domains • \(log.format.title)") + .foregroundStyle(.primary) + Text(log.generatedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Scheduled Reports") + } + + private func saveSettings() { + ScheduledReportStorage.saveSettings(settings) + ScheduledReportScheduler.shared.syncSchedule() + } + + private func generateNow() async { + isGenerating = true + let outcome = await ScheduledReportService.shared.generateReport(trigger: .manual, requireEnabledSetting: false) + statusMessage = outcome.message + settings = ScheduledReportStorage.loadSettings() + logs = ScheduledReportStorage.loadLogs() + isGenerating = false + } + + private func share(_ log: ScheduledReportLog) { + let url = ScheduledReportStorage.reportsDirectory.appendingPathComponent(log.fileName) + guard let data = try? Data(contentsOf: url) else { + statusMessage = "That report is no longer available on disk." + return + } + ExportPresenter.share(filename: log.fileName, data: data) + } +} |
