krz/domain-dig

an ios app for DNS & SSL analysis

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

fd8ca178a379da71f5bb19c461524888cad9447e

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T16:18:25Z

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.
 DomainDig/ContentView.swift              |   4 +
 DomainDig/DomainDigApp.swift             |   3 +
 DomainDig/Info.plist                     |   1 +
 DomainDig/LocalNotificationService.swift |   9 ++
 DomainDig/ScheduledReportService.swift   | 258 +++++++++++++++++++++++++++++++
 DomainDig/ScheduledReportsView.swift     | 130 ++++++++++++++++
 DomainReportExporter.swift               |   2 +-
 7 files changed, 406 insertions(+), 1 deletion(-)

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)
+    }
+}
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
index 1a04c70..7199d03 100644
--- a/DomainReportExporter.swift
+++ b/DomainReportExporter.swift
@@ -4,7 +4,7 @@ import Foundation
 import UIKit
 #endif
 
-enum DomainExportFormat: String, CaseIterable, Identifiable {
+enum DomainExportFormat: String, CaseIterable, Identifiable, Codable {
     case text = "txt"
     case csv = "csv"
     case json = "json"