krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.0: DomainDig/LocalNotificationService.swift · raw
1import Foundation
2import UserNotifications
3
4@MainActor
5final class LocalNotificationService {
6 static let shared = LocalNotificationService()
7
8 private init() { /* Singleton; use the shared instance. */ }
9
10 static let domainUserInfoKey = "domain"
11 static let domainCategoryIdentifier = "domain-event"
12 static let reinspectActionIdentifier = "reinspect"
13
14 func configureForegroundPresentation() {
15 let center = UNUserNotificationCenter.current()
16 center.delegate = NotificationCenterDelegate.shared
17
18 let reinspect = UNNotificationAction(
19 identifier: Self.reinspectActionIdentifier,
20 title: "Re-inspect",
21 options: []
22 )
23 center.setNotificationCategories([
24 UNNotificationCategory(
25 identifier: Self.domainCategoryIdentifier,
26 actions: [reinspect],
27 intentIdentifiers: [],
28 options: []
29 )
30 ])
31 }
32
33 func requestAuthorizationIfNeeded() async -> Bool {
34 let center = UNUserNotificationCenter.current()
35 let settings = await center.notificationSettings()
36
37 switch settings.authorizationStatus {
38 case .authorized, .provisional, .ephemeral:
39 return true
40 case .notDetermined:
41 return (try? await center.requestAuthorization(options: [.alert, .badge, .sound])) ?? false
42 case .denied:
43 return false
44 @unknown default:
45 return false
46 }
47 }
48
49 func isAuthorizedForAlerts() async -> Bool {
50 let settings = await UNUserNotificationCenter.current().notificationSettings()
51 switch settings.authorizationStatus {
52 case .authorized, .provisional, .ephemeral:
53 return true
54 case .denied, .notDetermined:
55 return false
56 @unknown default:
57 return false
58 }
59 }
60
61 func notifyDomainEvent(domain: String, message: String, severity: ChangeSeverity) async {
62 await schedule(
63 identifier: "domain-change-\(domain)",
64 title: domain,
65 body: message,
66 interruptionLevel: severity == .high ? .timeSensitive : .active,
67 domain: domain
68 )
69 }
70
71 func notifyCertificateWarning(domain: String, daysRemaining: Int) async {
72 await schedule(
73 identifier: "cert-warning-\(domain)",
74 title: domain,
75 body: "Certificate expires in \(daysRemaining) days",
76 interruptionLevel: .timeSensitive,
77 domain: domain
78 )
79 }
80
81 func notifyMonitoringAlert(
82 domain: String,
83 message: String,
84 severity: MonitoringAlertSeverity
85 ) async {
86 let interruptionLevel: UNNotificationInterruptionLevel
87 switch severity {
88 case .critical:
89 interruptionLevel = .timeSensitive
90 case .warning, .info:
91 interruptionLevel = .active
92 }
93
94 await schedule(
95 identifier: "monitoring-\(domain)-\(UUID().uuidString)",
96 title: domain,
97 body: message,
98 interruptionLevel: interruptionLevel,
99 domain: domain
100 )
101 }
102
103 func notifyMonitoringSummary(
104 domain: String,
105 alerts: [MonitoringPendingAlert]
106 ) async {
107 let summary = alerts
108 .sorted { $0.detectedAt < $1.detectedAt }
109 .prefix(2)
110 .map(\.message)
111 .joined(separator: " • ")
112 let body: String
113 if alerts.count <= 1 {
114 body = alerts.first?.message ?? "Monitoring change detected"
115 } else if summary.isEmpty {
116 body = "\(alerts.count) monitoring changes detected"
117 } else {
118 body = "\(alerts.count) monitoring changes: \(summary)"
119 }
120
121 let severity = alerts.map(\.severity).max() ?? .info
122 let interruptionLevel: UNNotificationInterruptionLevel = severity == .critical ? .timeSensitive : .active
123
124 await schedule(
125 identifier: "monitoring-summary-\(domain)-\(UUID().uuidString)",
126 title: domain,
127 body: body,
128 interruptionLevel: interruptionLevel,
129 domain: domain
130 )
131 }
132
133 func notifySweepComplete(summary: BatchSweepSummary) async {
134 let body = "\(summary.changedDomains) changed, \(summary.warningDomains) warnings, \(summary.unchangedDomains) unchanged"
135 await schedule(
136 identifier: "sweep-complete",
137 title: summary.source == .watchlistRefresh ? "Check All Complete" : "Batch Complete",
138 body: body,
139 interruptionLevel: .active
140 )
141 }
142
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
152 func clearAllNotifications() async {
153 let center = UNUserNotificationCenter.current()
154 center.removeAllPendingNotificationRequests()
155 center.removeAllDeliveredNotifications()
156 }
157
158 private func schedule(
159 identifier: String,
160 title: String,
161 body: String,
162 interruptionLevel: UNNotificationInterruptionLevel,
163 domain: String? = nil
164 ) async {
165 let content = UNMutableNotificationContent()
166 content.title = title
167 content.body = body
168 content.sound = .default
169 content.interruptionLevel = interruptionLevel
170 if let domain {
171 // Group alerts per domain and let taps/actions route back into it.
172 content.threadIdentifier = domain
173 content.userInfo = [Self.domainUserInfoKey: domain]
174 content.categoryIdentifier = Self.domainCategoryIdentifier
175 }
176
177 let request = UNNotificationRequest(
178 identifier: identifier,
179 content: content,
180 trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
181 )
182
183 try? await UNUserNotificationCenter.current().add(request)
184 }
185}
186
187private final class NotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate {
188 static let shared = NotificationCenterDelegate()
189
190 func userNotificationCenter(
191 _: UNUserNotificationCenter,
192 willPresent _: UNNotification
193 ) async -> UNNotificationPresentationOptions {
194 [.banner, .list, .sound]
195 }
196
197 func userNotificationCenter(
198 _: UNUserNotificationCenter,
199 didReceive response: UNNotificationResponse
200 ) async {
201 let userInfo = response.notification.request.content.userInfo
202 guard let domain = userInfo[LocalNotificationService.domainUserInfoKey] as? String,
203 !domain.isEmpty
204 else { return }
205
206 let action: DomainDigDeepLink.Action
207 if response.actionIdentifier == LocalNotificationService.reinspectActionIdentifier {
208 action = .inspect(domain)
209 } else {
210 // Default tap: open the tracked domain's detail.
211 action = .detail(domain)
212 }
213
214 await MainActor.run {
215 DomainDigIntentRouter.shared.pendingAction = action
216 }
217 }
218}