summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/DomainDigApp.swift9
-rw-r--r--DomainDig/DomainViewModel.swift16
-rw-r--r--DomainDig/Info.plist2
-rw-r--r--DomainDig/LocalNotificationService.swift65
-rw-r--r--DomainDig/RootTabView.swift136
-rw-r--r--DomainDig/SweepActivityController.swift63
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))
+ )
+ }
+ }
+}