diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 9 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 16 | ||||
| -rw-r--r-- | DomainDig/Info.plist | 2 | ||||
| -rw-r--r-- | DomainDig/LocalNotificationService.swift | 65 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 136 | ||||
| -rw-r--r-- | DomainDig/SweepActivityController.swift | 63 |
7 files changed, 243 insertions, 50 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index 608d9c5..3a86f97 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { nonisolated static var current: String { - "4.5.0" + "4.6.0" } } diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index 8716806..d34c51d 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -40,6 +40,7 @@ struct DomainDigApp: App { viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() IntegrationService.shared.processQueueNow() viewModel.refreshWidgetData() + consumeShareInbox() } .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in viewModel.refreshPersistedData() @@ -59,6 +60,14 @@ struct DomainDigApp: App { viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() IntegrationService.shared.processQueueNow() viewModel.refreshWidgetData() + consumeShareInbox() } } + + /// Picks up a domain shared via the share extension and routes it into an + /// inspection through the intent router (consumed by `RootTabView`). + private func consumeShareInbox() { + guard let domain = DomainDigShareInbox.consume() else { return } + DomainDigIntentRouter.shared.pendingAction = .inspect(domain) + } } diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 3f37cb9..dcae907 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -2834,6 +2834,11 @@ final class DomainViewModel { customPortScanTask?.cancel() batchTask?.cancel() + SweepActivityController.shared.begin( + title: source == .watchlistRefresh ? "Watchlist Sweep" : "Batch Lookup", + total: domains.count + ) + batchTask = Task { [weak self] in guard let self else { return } self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded() @@ -2928,6 +2933,11 @@ final class DomainViewModel { errorMessage: "Lookup cancelled" ) batchCompletedCount += 1 + SweepActivityController.shared.update( + completed: batchCompletedCount, + total: batchTotalCount, + currentDomain: batchCurrentDomain + ) return } @@ -2956,6 +2966,11 @@ final class DomainViewModel { errorMessage: payload.snapshot.statusMessage ) batchCompletedCount += 1 + SweepActivityController.shared.update( + completed: batchCompletedCount, + total: batchTotalCount, + currentDomain: batchCurrentDomain + ) } private func finishBatchLookup(source: BatchLookupSource) { @@ -2989,6 +3004,7 @@ final class DomainViewModel { generatedAt: Date() ) latestBatchSweepSummary = summary + SweepActivityController.shared.end(changed: changedCount, warnings: warningCount) if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName { let workflowReports: [DomainReport] = summary.results.compactMap { result in diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist index 9e7e3bb..41b193b 100644 --- a/DomainDig/Info.plist +++ b/DomainDig/Info.plist @@ -23,6 +23,8 @@ </array> <key>CKSharingSupported</key> <true/> + <key>NSSupportsLiveActivities</key> + <true/> <key>UIBackgroundModes</key> <array> <string>fetch</string> diff --git a/DomainDig/LocalNotificationService.swift b/DomainDig/LocalNotificationService.swift index eef0567..f417619 100644 --- a/DomainDig/LocalNotificationService.swift +++ b/DomainDig/LocalNotificationService.swift @@ -7,8 +7,27 @@ final class LocalNotificationService { private init() {} + static let domainUserInfoKey = "domain" + static let domainCategoryIdentifier = "domain-event" + static let reinspectActionIdentifier = "reinspect" + func configureForegroundPresentation() { - UNUserNotificationCenter.current().delegate = NotificationCenterDelegate.shared + let center = UNUserNotificationCenter.current() + center.delegate = NotificationCenterDelegate.shared + + let reinspect = UNNotificationAction( + identifier: Self.reinspectActionIdentifier, + title: "Re-inspect", + options: [] + ) + center.setNotificationCategories([ + UNNotificationCategory( + identifier: Self.domainCategoryIdentifier, + actions: [reinspect], + intentIdentifiers: [], + options: [] + ) + ]) } func requestAuthorizationIfNeeded() async -> Bool { @@ -44,7 +63,8 @@ final class LocalNotificationService { identifier: "domain-change-\(domain)", title: domain, body: message, - interruptionLevel: severity == .high ? .timeSensitive : .active + interruptionLevel: severity == .high ? .timeSensitive : .active, + domain: domain ) } @@ -53,7 +73,8 @@ final class LocalNotificationService { identifier: "cert-warning-\(domain)", title: domain, body: "Certificate expires in \(daysRemaining) days", - interruptionLevel: .timeSensitive + interruptionLevel: .timeSensitive, + domain: domain ) } @@ -74,7 +95,8 @@ final class LocalNotificationService { identifier: "monitoring-\(domain)-\(UUID().uuidString)", title: domain, body: message, - interruptionLevel: interruptionLevel + interruptionLevel: interruptionLevel, + domain: domain ) } @@ -103,7 +125,8 @@ final class LocalNotificationService { identifier: "monitoring-summary-\(domain)-\(UUID().uuidString)", title: domain, body: body, - interruptionLevel: interruptionLevel + interruptionLevel: interruptionLevel, + domain: domain ) } @@ -127,13 +150,20 @@ final class LocalNotificationService { identifier: String, title: String, body: String, - interruptionLevel: UNNotificationInterruptionLevel + interruptionLevel: UNNotificationInterruptionLevel, + domain: String? = nil ) async { let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default content.interruptionLevel = interruptionLevel + if let domain { + // Group alerts per domain and let taps/actions route back into it. + content.threadIdentifier = domain + content.userInfo = [Self.domainUserInfoKey: domain] + content.categoryIdentifier = Self.domainCategoryIdentifier + } let request = UNNotificationRequest( identifier: identifier, @@ -154,4 +184,27 @@ private final class NotificationCenterDelegate: NSObject, UNUserNotificationCent ) async -> UNNotificationPresentationOptions { [.banner, .list, .sound] } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let userInfo = response.notification.request.content.userInfo + guard let domain = userInfo[LocalNotificationService.domainUserInfoKey] as? String, + !domain.isEmpty + else { return } + + let action: DomainDigDeepLink.Action + switch response.actionIdentifier { + case LocalNotificationService.reinspectActionIdentifier: + action = .inspect(domain) + default: + // Default tap: open the tracked domain's detail. + action = .detail(domain) + } + + await MainActor.run { + DomainDigIntentRouter.shared.pendingAction = action + } + } } diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index 9823d37..db1f524 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -1,15 +1,36 @@ import SwiftUI -private enum RootTab: Hashable { +private enum RootTab: Hashable, CaseIterable { case dashboard case audit case history case inspect case settings + + var title: String { + switch self { + case .dashboard: return "Dashboard" + case .audit: return "Audit" + case .history: return "History" + case .inspect: return "Inspect" + case .settings: return "Settings" + } + } + + var systemImage: String { + switch self { + case .dashboard: return "square.grid.2x2" + case .audit: return "checklist" + case .history: return "clock.arrow.trianglehead.counterclockwise.rotate.90" + case .inspect: return "magnifyingglass" + case .settings: return "gearshape" + } + } } struct RootTabView: View { @Bindable var viewModel: DomainViewModel + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var purchaseService = PurchaseService.shared @State private var intentRouter = DomainDigIntentRouter.shared @State private var detailDomain: TrackedDomain? @@ -18,44 +39,12 @@ struct RootTabView: View { var body: some View { let _ = purchaseService.currentTier - TabView(selection: $selectedTab) { - NavigationStack { - DashboardView(viewModel: viewModel) - } - .tabItem { - Label("Dashboard", systemImage: "square.grid.2x2") - } - .tag(RootTab.dashboard) - - NavigationStack { - AuditListView(viewModel: viewModel) - } - .tabItem { - Label("Audit", systemImage: "checklist") - } - .tag(RootTab.audit) - - NavigationStack { - HistoryView(viewModel: viewModel) - } - .tabItem { - Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - } - .tag(RootTab.history) - - ContentView(viewModel: viewModel) - .tabItem { - Label("Inspect", systemImage: "magnifyingglass") - } - .tag(RootTab.inspect) - - NavigationStack { - SettingsView(viewModel: viewModel) - } - .tabItem { - Label("Settings", systemImage: "gearshape") + Group { + if horizontalSizeClass == .regular { + splitLayout + } else { + tabLayout } - .tag(RootTab.settings) } .sheet(isPresented: Binding( get: { viewModel.isPaywallPresented }, @@ -79,16 +68,16 @@ struct RootTabView: View { } ) } - .onChange(of: purchaseService.currentTier) { _, newValue in - if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false { - selectedTab = .dashboard - } - } .sheet(item: $detailDomain) { trackedDomain in NavigationStack { TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain) } } + .onChange(of: purchaseService.currentTier) { _, newValue in + if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false { + selectedTab = .dashboard + } + } .onOpenURL { url in guard let action = DomainDigDeepLink.action(from: url) else { return } perform(action) @@ -101,6 +90,67 @@ struct RootTabView: View { } } + // MARK: Layouts + + /// Compact (iPhone, iPad slide-over): the classic tab bar. + private var tabLayout: some View { + TabView(selection: $selectedTab) { + ForEach(RootTab.allCases, id: \.self) { tab in + section(for: tab) + .tabItem { + Label(tab.title, systemImage: tab.systemImage) + } + .tag(tab) + } + } + } + + /// Regular width (iPad, large iPhone landscape): two-column split view. + private var splitLayout: some View { + NavigationSplitView { + List(RootTab.allCases, id: \.self, selection: sidebarSelection) { tab in + Label(tab.title, systemImage: tab.systemImage) + .tag(tab) + } + .navigationTitle("DomainDig") + } detail: { + section(for: selectedTab) + } + } + + private var sidebarSelection: Binding<RootTab?> { + Binding( + get: { selectedTab }, + set: { selectedTab = $0 ?? selectedTab } + ) + } + + @ViewBuilder + private func section(for tab: RootTab) -> some View { + switch tab { + case .dashboard: + NavigationStack { + DashboardView(viewModel: viewModel) + } + case .audit: + NavigationStack { + AuditListView(viewModel: viewModel) + } + case .history: + NavigationStack { + HistoryView(viewModel: viewModel) + } + case .inspect: + ContentView(viewModel: viewModel) + case .settings: + NavigationStack { + SettingsView(viewModel: viewModel) + } + } + } + + // MARK: Intent / deep-link routing + private func consume(_ action: DomainDigDeepLink.Action?) { guard let action else { return } intentRouter.pendingAction = nil diff --git a/DomainDig/SweepActivityController.swift b/DomainDig/SweepActivityController.swift new file mode 100644 index 0000000..26fcb2d --- /dev/null +++ b/DomainDig/SweepActivityController.swift @@ -0,0 +1,63 @@ +import ActivityKit +import Foundation + +/// Starts, updates, and ends the sweep Live Activity around a batch run. +@MainActor +final class SweepActivityController { + static let shared = SweepActivityController() + + private var activity: Activity<SweepActivityAttributes>? + + private init() {} + + func begin(title: String, total: Int) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + // A previous activity that never ended (e.g. app killed mid-sweep) + // would otherwise linger; replace it. + end(changed: 0, warnings: 0, immediately: true) + + let state = SweepActivityAttributes.ContentState( + completed: 0, + total: total, + currentDomain: nil, + changed: 0, + warnings: 0 + ) + activity = try? Activity.request( + attributes: SweepActivityAttributes(title: title, startedAt: Date()), + content: ActivityContent(state: state, staleDate: nil) + ) + } + + func update(completed: Int, total: Int, currentDomain: String?) { + guard let activity else { return } + let state = SweepActivityAttributes.ContentState( + completed: completed, + total: total, + currentDomain: currentDomain, + changed: 0, + warnings: 0 + ) + Task { + await activity.update(ActivityContent(state: state, staleDate: nil)) + } + } + + func end(changed: Int, warnings: Int, immediately: Bool = false) { + guard let activity else { return } + self.activity = nil + let state = SweepActivityAttributes.ContentState( + completed: activity.content.state.total, + total: activity.content.state.total, + currentDomain: nil, + changed: changed, + warnings: warnings + ) + Task { + await activity.end( + ActivityContent(state: state, staleDate: nil), + dismissalPolicy: immediately ? .immediate : .after(Date().addingTimeInterval(60)) + ) + } + } +} |
