Commit b4b94d54cb

b4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef

parent: 065e1e77ff

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 13:36 UTC

Implement v4.6.0: sweep Live Activity, share extension, iPad split view, actionable notifications

- Sweep Live Activity: SweepActivityAttributes (Shared/), lock-screen + Dynamic
  Island UI in the widget extension, driven by SweepActivityController wired into
  the batch pipeline (begin/update/end); NSSupportsLiveActivities in Info.plist.
- Share extension (DomainDigShareExtension): accepts a web URL from the share
  sheet, extracts the host, and hands it to the app via the App Group inbox
  (DomainDigShareInbox); the app consumes it on activation and inspects it.
- iPad layout: RootTabView uses NavigationSplitView in the regular size class
  and the tab bar in compact.
- Actionable notifications: per-domain threadIdentifier grouping, a Re-inspect
  action, and tap routing into the domain detail via the intent router.
- Bump version to 4.6.0 (build 38) across app, widget, and share targets.

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +164 −18
@@ -13,8 +13,9 @@
1313 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
1414 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; };
1515 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; };
16 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
1716 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
17 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
18 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
1819/* End PBXBuildFile section */
1920
2021/* Begin PBXContainerItemProxy section */
@@ -25,6 +26,13 @@
2526 remoteGlobalIDString = 8BDA00010000000000000006;
2627 remoteInfo = DomainDigWidgetExtension;
2728 };
29 8BDB00010000000000000003 /* PBXContainerItemProxy */ = {
30 isa = PBXContainerItemProxy;
31 containerPortal = 8B7800612F6090E300933221 /* Project object */;
32 proxyType = 1;
33 remoteGlobalIDString = 8BDB00010000000000000006;
34 remoteInfo = DomainDigShareExtension;
35 };
2836/* End PBXContainerItemProxy section */
2937
3038/* Begin PBXCopyFilesBuildPhase section */
@@ -35,6 +43,7 @@
3543 dstSubfolderSpec = 13;
3644 files = (
3745 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */,
46 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */,
3847 );
3948 name = "Embed Foundation Extensions";
4049 runOnlyForDeploymentPostprocessing = 0;
@@ -49,8 +58,9 @@
4958 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
5059 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; };
5160 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; };
52 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
5361 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
62 8BDB00010000000000000001 /* DomainDigShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
63 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
5464/* End PBXFileReference section */
5565
5666/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -70,6 +80,13 @@
7080 );
7181 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */;
7282 };
83 8BDB0001000000000000000F /* Exceptions for "DomainDigShareExtension" folder in "DomainDigShareExtension" target */ = {
84 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
85 membershipExceptions = (
86 Info.plist,
87 );
88 target = 8BDB00010000000000000006 /* DomainDigShareExtension */;
89 };
7390/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
7491
7592/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -94,6 +111,14 @@
94111 path = DomainDigWidget;
95112 sourceTree = "<group>";
96113 };
114 8BDB0001000000000000000E /* DomainDigShareExtension */ = {
115 isa = PBXFileSystemSynchronizedRootGroup;
116 exceptions = (
117 8BDB0001000000000000000F /* Exceptions for "DomainDigShareExtension" folder in "DomainDigShareExtension" target */,
118 );
119 path = DomainDigShareExtension;
120 sourceTree = "<group>";
121 };
97122/* End PBXFileSystemSynchronizedRootGroup section */
98123
99124/* Begin PBXFrameworksBuildPhase section */
@@ -111,6 +136,13 @@
111136 );
112137 runOnlyForDeploymentPostprocessing = 0;
113138 };
139 8BDB00010000000000000008 /* Frameworks */ = {
140 isa = PBXFrameworksBuildPhase;
141 buildActionMask = 2147483647;
142 files = (
143 );
144 runOnlyForDeploymentPostprocessing = 0;
145 };
114146/* End PBXFrameworksBuildPhase section */
115147
116148/* Begin PBXGroup section */
@@ -120,6 +152,7 @@
120152 8B78006B2F6090E300933221 /* DomainDig */,
121153 8BDA0001000000000000000D /* Shared */,
122154 8BDA0001000000000000000E /* DomainDigWidget */,
155 8BDB0001000000000000000E /* DomainDigShareExtension */,
123156 8B78006A2F6090E300933221 /* Products */,
124157 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
125158 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
@@ -136,6 +169,7 @@
136169 children = (
137170 8B7800692F6090E300933221 /* DomainDig.app */,
138171 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */,
172 8BDB00010000000000000001 /* DomainDigShareExtension.appex */,
139173 );
140174 name = Products;
141175 sourceTree = "<group>";
@@ -156,6 +190,7 @@
156190 );
157191 dependencies = (
158192 8BDA00010000000000000004 /* PBXTargetDependency */,
193 8BDB00010000000000000004 /* PBXTargetDependency */,
159194 );
160195 fileSystemSynchronizedGroups = (
161196 8B78006B2F6090E300933221 /* DomainDig */,
@@ -191,15 +226,30 @@
191226 productReference = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */;
192227 productType = "com.apple.product-type.app-extension";
193228 };
194/* End PBXNativeTarget section */
195
196/* Begin PBXTargetDependency section */
197 8BDA00010000000000000004 /* PBXTargetDependency */ = {
198 isa = PBXTargetDependency;
199 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */;
200 targetProxy = 8BDA00010000000000000003 /* PBXContainerItemProxy */;
229 8BDB00010000000000000006 /* DomainDigShareExtension */ = {
230 isa = PBXNativeTarget;
231 buildConfigurationList = 8BDB0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigShareExtension" */;
232 buildPhases = (
233 8BDB00010000000000000007 /* Sources */,
234 8BDB00010000000000000008 /* Frameworks */,
235 8BDB00010000000000000009 /* Resources */,
236 );
237 buildRules = (
238 );
239 dependencies = (
240 );
241 fileSystemSynchronizedGroups = (
242 8BDA0001000000000000000D /* Shared */,
243 8BDB0001000000000000000E /* DomainDigShareExtension */,
244 );
245 name = DomainDigShareExtension;
246 packageProductDependencies = (
247 );
248 productName = DomainDigShareExtension;
249 productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */;
250 productType = "com.apple.product-type.app-extension";
201251 };
202/* End PBXTargetDependency section */
252/* End PBXNativeTarget section */
203253
204254/* Begin PBXProject section */
205255 8B7800612F6090E300933221 /* Project object */ = {
@@ -220,6 +270,9 @@
220270 8BDA00010000000000000006 = {
221271 CreatedOnToolsVersion = 26.3;
222272 };
273 8BDB00010000000000000006 = {
274 CreatedOnToolsVersion = 26.3;
275 };
223276 };
224277 };
225278 buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
@@ -238,6 +291,7 @@
238291 targets = (
239292 8B7800682F6090E300933221 /* DomainDig */,
240293 8BDA00010000000000000006 /* DomainDigWidgetExtension */,
294 8BDB00010000000000000006 /* DomainDigShareExtension */,
241295 );
242296 };
243297/* End PBXProject section */
@@ -257,6 +311,13 @@
257311 );
258312 runOnlyForDeploymentPostprocessing = 0;
259313 };
314 8BDB00010000000000000009 /* Resources */ = {
315 isa = PBXResourcesBuildPhase;
316 buildActionMask = 2147483647;
317 files = (
318 );
319 runOnlyForDeploymentPostprocessing = 0;
320 };
260321/* End PBXResourcesBuildPhase section */
261322
262323/* Begin PBXSourcesBuildPhase section */
@@ -281,8 +342,28 @@
281342 );
282343 runOnlyForDeploymentPostprocessing = 0;
283344 };
345 8BDB00010000000000000007 /* Sources */ = {
346 isa = PBXSourcesBuildPhase;
347 buildActionMask = 2147483647;
348 files = (
349 );
350 runOnlyForDeploymentPostprocessing = 0;
351 };
284352/* End PBXSourcesBuildPhase section */
285353
354/* Begin PBXTargetDependency section */
355 8BDA00010000000000000004 /* PBXTargetDependency */ = {
356 isa = PBXTargetDependency;
357 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */;
358 targetProxy = 8BDA00010000000000000003 /* PBXContainerItemProxy */;
359 };
360 8BDB00010000000000000004 /* PBXTargetDependency */ = {
361 isa = PBXTargetDependency;
362 target = 8BDB00010000000000000006 /* DomainDigShareExtension */;
363 targetProxy = 8BDB00010000000000000003 /* PBXContainerItemProxy */;
364 };
365/* End PBXTargetDependency section */
366
286367/* Begin XCBuildConfiguration section */
287368 8B7800722F6090E400933221 /* Debug */ = {
288369 isa = XCBuildConfiguration;
@@ -414,7 +495,7 @@
414495 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
415496 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
416497 CODE_SIGN_STYLE = Automatic;
417 CURRENT_PROJECT_VERSION = 37;
498 CURRENT_PROJECT_VERSION = 38;
418499 DEVELOPMENT_TEAM = ZCNAX3VL9D;
419500 ENABLE_PREVIEWS = YES;
420501 GENERATE_INFOPLIST_FILE = YES;
@@ -431,7 +512,7 @@
431512 "$(inherited)",
432513 "@executable_path/Frameworks",
433514 );
434 MARKETING_VERSION = 4.5.0;
515 MARKETING_VERSION = 4.6.0;
435516 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
436517 PRODUCT_NAME = "$(TARGET_NAME)";
437518 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -451,7 +532,7 @@
451532 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
452533 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
453534 CODE_SIGN_STYLE = Automatic;
454 CURRENT_PROJECT_VERSION = 37;
535 CURRENT_PROJECT_VERSION = 38;
455536 DEVELOPMENT_TEAM = ZCNAX3VL9D;
456537 ENABLE_PREVIEWS = YES;
457538 GENERATE_INFOPLIST_FILE = YES;
@@ -468,7 +549,7 @@
468549 "$(inherited)",
469550 "@executable_path/Frameworks",
470551 );
471 MARKETING_VERSION = 4.5.0;
552 MARKETING_VERSION = 4.6.0;
472553 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
473554 PRODUCT_NAME = "$(TARGET_NAME)";
474555 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -487,7 +568,7 @@
487568 APPLICATION_EXTENSION_API_ONLY = YES;
488569 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
489570 CODE_SIGN_STYLE = Automatic;
490 CURRENT_PROJECT_VERSION = 37;
571 CURRENT_PROJECT_VERSION = 38;
491572 DEVELOPMENT_TEAM = ZCNAX3VL9D;
492573 GENERATE_INFOPLIST_FILE = NO;
493574 INFOPLIST_FILE = DomainDigWidget/Info.plist;
@@ -497,7 +578,7 @@
497578 "@executable_path/Frameworks",
498579 "@executable_path/../../Frameworks",
499580 );
500 MARKETING_VERSION = 4.5.0;
581 MARKETING_VERSION = 4.6.0;
501582 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
502583 PRODUCT_NAME = "$(TARGET_NAME)";
503584 SKIP_INSTALL = YES;
@@ -515,7 +596,7 @@
515596 APPLICATION_EXTENSION_API_ONLY = YES;
516597 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
517598 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 37;
599 CURRENT_PROJECT_VERSION = 38;
519600 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520601 GENERATE_INFOPLIST_FILE = NO;
521602 INFOPLIST_FILE = DomainDigWidget/Info.plist;
@@ -525,7 +606,7 @@
525606 "@executable_path/Frameworks",
526607 "@executable_path/../../Frameworks",
527608 );
528 MARKETING_VERSION = 4.5.0;
609 MARKETING_VERSION = 4.6.0;
529610 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
530611 PRODUCT_NAME = "$(TARGET_NAME)";
531612 SKIP_INSTALL = YES;
@@ -537,6 +618,62 @@
537618 };
538619 name = Release;
539620 };
621 8BDB0001000000000000000B /* Debug */ = {
622 isa = XCBuildConfiguration;
623 buildSettings = {
624 APPLICATION_EXTENSION_API_ONLY = YES;
625 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements;
626 CODE_SIGN_STYLE = Automatic;
627 CURRENT_PROJECT_VERSION = 38;
628 DEVELOPMENT_TEAM = ZCNAX3VL9D;
629 GENERATE_INFOPLIST_FILE = NO;
630 INFOPLIST_FILE = DomainDigShareExtension/Info.plist;
631 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
632 LD_RUNPATH_SEARCH_PATHS = (
633 "$(inherited)",
634 "@executable_path/Frameworks",
635 "@executable_path/../../Frameworks",
636 );
637 MARKETING_VERSION = 4.6.0;
638 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension;
639 PRODUCT_NAME = "$(TARGET_NAME)";
640 SKIP_INSTALL = YES;
641 SWIFT_APPROACHABLE_CONCURRENCY = YES;
642 SWIFT_EMIT_LOC_STRINGS = YES;
643 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
644 SWIFT_VERSION = 5.0;
645 TARGETED_DEVICE_FAMILY = "1,2";
646 };
647 name = Debug;
648 };
649 8BDB0001000000000000000C /* Release */ = {
650 isa = XCBuildConfiguration;
651 buildSettings = {
652 APPLICATION_EXTENSION_API_ONLY = YES;
653 CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements;
654 CODE_SIGN_STYLE = Automatic;
655 CURRENT_PROJECT_VERSION = 38;
656 DEVELOPMENT_TEAM = ZCNAX3VL9D;
657 GENERATE_INFOPLIST_FILE = NO;
658 INFOPLIST_FILE = DomainDigShareExtension/Info.plist;
659 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
660 LD_RUNPATH_SEARCH_PATHS = (
661 "$(inherited)",
662 "@executable_path/Frameworks",
663 "@executable_path/../../Frameworks",
664 );
665 MARKETING_VERSION = 4.6.0;
666 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension;
667 PRODUCT_NAME = "$(TARGET_NAME)";
668 SKIP_INSTALL = YES;
669 SWIFT_APPROACHABLE_CONCURRENCY = YES;
670 SWIFT_EMIT_LOC_STRINGS = YES;
671 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
672 SWIFT_VERSION = 5.0;
673 TARGETED_DEVICE_FAMILY = "1,2";
674 };
675 name = Release;
676 };
540677/* End XCBuildConfiguration section */
541678
542679/* Begin XCConfigurationList section */
@@ -567,6 +704,15 @@
567704 defaultConfigurationIsVisible = 0;
568705 defaultConfigurationName = Release;
569706 };
707 8BDB0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigShareExtension" */ = {
708 isa = XCConfigurationList;
709 buildConfigurations = (
710 8BDB0001000000000000000B /* Debug */,
711 8BDB0001000000000000000C /* Release */,
712 );
713 defaultConfigurationIsVisible = 0;
714 defaultConfigurationName = Release;
715 };
570716/* End XCConfigurationList section */
571717 };
572718 rootObject = 8B7800612F6090E300933221 /* Project object */;
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +6 −1
@@ -9,11 +9,16 @@
99 <key>orderHint</key>
1010 <integer>0</integer>
1111 </dict>
12 <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key>
12 <key>DomainDigShareExtension.xcscheme_^#shared#^_</key>
1313 <dict>
1414 <key>orderHint</key>
1515 <integer>1</integer>
1616 </dict>
17 <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key>
18 <dict>
19 <key>orderHint</key>
20 <integer>2</integer>
21 </dict>
1722 </dict>
1823 <key>SuppressBuildableAutocreation</key>
1924 <dict>
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
22
33enum AppVersion {
44 nonisolated static var current: String {
5 "4.5.0"
5 "4.6.0"
66 }
77}
DomainDig/DomainDigApp.swift +9
@@ -40,6 +40,7 @@ struct DomainDigApp: App {
4040 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
4141 IntegrationService.shared.processQueueNow()
4242 viewModel.refreshWidgetData()
43 consumeShareInbox()
4344 }
4445 .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in
4546 viewModel.refreshPersistedData()
@@ -59,6 +60,14 @@ struct DomainDigApp: App {
5960 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
6061 IntegrationService.shared.processQueueNow()
6162 viewModel.refreshWidgetData()
63 consumeShareInbox()
6264 }
6365 }
66
67 /// Picks up a domain shared via the share extension and routes it into an
68 /// inspection through the intent router (consumed by `RootTabView`).
69 private func consumeShareInbox() {
70 guard let domain = DomainDigShareInbox.consume() else { return }
71 DomainDigIntentRouter.shared.pendingAction = .inspect(domain)
72 }
6473}
DomainDig/DomainViewModel.swift +16
@@ -2834,6 +2834,11 @@ final class DomainViewModel {
28342834 customPortScanTask?.cancel()
28352835 batchTask?.cancel()
28362836
2837 SweepActivityController.shared.begin(
2838 title: source == .watchlistRefresh ? "Watchlist Sweep" : "Batch Lookup",
2839 total: domains.count
2840 )
2841
28372842 batchTask = Task { [weak self] in
28382843 guard let self else { return }
28392844 self.notificationsAuthorized = await LocalNotificationService.shared.requestAuthorizationIfNeeded()
@@ -2928,6 +2933,11 @@ final class DomainViewModel {
29282933 errorMessage: "Lookup cancelled"
29292934 )
29302935 batchCompletedCount += 1
2936 SweepActivityController.shared.update(
2937 completed: batchCompletedCount,
2938 total: batchTotalCount,
2939 currentDomain: batchCurrentDomain
2940 )
29312941 return
29322942 }
29332943
@@ -2956,6 +2966,11 @@ final class DomainViewModel {
29562966 errorMessage: payload.snapshot.statusMessage
29572967 )
29582968 batchCompletedCount += 1
2969 SweepActivityController.shared.update(
2970 completed: batchCompletedCount,
2971 total: batchTotalCount,
2972 currentDomain: batchCurrentDomain
2973 )
29592974 }
29602975
29612976 private func finishBatchLookup(source: BatchLookupSource) {
@@ -2989,6 +3004,7 @@ final class DomainViewModel {
29893004 generatedAt: Date()
29903005 )
29913006 latestBatchSweepSummary = summary
3007 SweepActivityController.shared.end(changed: changedCount, warnings: warningCount)
29923008
29933009 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
29943010 let workflowReports: [DomainReport] = summary.results.compactMap { result in
DomainDig/Info.plist +2
@@ -23,6 +23,8 @@
2323 </array>
2424 <key>CKSharingSupported</key>
2525 <true/>
26 <key>NSSupportsLiveActivities</key>
27 <true/>
2628 <key>UIBackgroundModes</key>
2729 <array>
2830 <string>fetch</string>
DomainDig/LocalNotificationService.swift +59 −6
@@ -7,8 +7,27 @@ final class LocalNotificationService {
77
88 private init() {}
99
10 static let domainUserInfoKey = "domain"
11 static let domainCategoryIdentifier = "domain-event"
12 static let reinspectActionIdentifier = "reinspect"
13
1014 func configureForegroundPresentation() {
11 UNUserNotificationCenter.current().delegate = NotificationCenterDelegate.shared
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 ])
1231 }
1332
1433 func requestAuthorizationIfNeeded() async -> Bool {
@@ -44,7 +63,8 @@ final class LocalNotificationService {
4463 identifier: "domain-change-\(domain)",
4564 title: domain,
4665 body: message,
47 interruptionLevel: severity == .high ? .timeSensitive : .active
66 interruptionLevel: severity == .high ? .timeSensitive : .active,
67 domain: domain
4868 )
4969 }
5070
@@ -53,7 +73,8 @@ final class LocalNotificationService {
5373 identifier: "cert-warning-\(domain)",
5474 title: domain,
5575 body: "Certificate expires in \(daysRemaining) days",
56 interruptionLevel: .timeSensitive
76 interruptionLevel: .timeSensitive,
77 domain: domain
5778 )
5879 }
5980
@@ -74,7 +95,8 @@ final class LocalNotificationService {
7495 identifier: "monitoring-\(domain)-\(UUID().uuidString)",
7596 title: domain,
7697 body: message,
77 interruptionLevel: interruptionLevel
98 interruptionLevel: interruptionLevel,
99 domain: domain
78100 )
79101 }
80102
@@ -103,7 +125,8 @@ final class LocalNotificationService {
103125 identifier: "monitoring-summary-\(domain)-\(UUID().uuidString)",
104126 title: domain,
105127 body: body,
106 interruptionLevel: interruptionLevel
128 interruptionLevel: interruptionLevel,
129 domain: domain
107130 )
108131 }
109132
@@ -127,13 +150,20 @@ final class LocalNotificationService {
127150 identifier: String,
128151 title: String,
129152 body: String,
130 interruptionLevel: UNNotificationInterruptionLevel
153 interruptionLevel: UNNotificationInterruptionLevel,
154 domain: String? = nil
131155 ) async {
132156 let content = UNMutableNotificationContent()
133157 content.title = title
134158 content.body = body
135159 content.sound = .default
136160 content.interruptionLevel = interruptionLevel
161 if let domain {
162 // Group alerts per domain and let taps/actions route back into it.
163 content.threadIdentifier = domain
164 content.userInfo = [Self.domainUserInfoKey: domain]
165 content.categoryIdentifier = Self.domainCategoryIdentifier
166 }
137167
138168 let request = UNNotificationRequest(
139169 identifier: identifier,
@@ -154,4 +184,27 @@ private final class NotificationCenterDelegate: NSObject, UNUserNotificationCent
154184 ) async -> UNNotificationPresentationOptions {
155185 [.banner, .list, .sound]
156186 }
187
188 func userNotificationCenter(
189 _ center: UNUserNotificationCenter,
190 didReceive response: UNNotificationResponse
191 ) async {
192 let userInfo = response.notification.request.content.userInfo
193 guard let domain = userInfo[LocalNotificationService.domainUserInfoKey] as? String,
194 !domain.isEmpty
195 else { return }
196
197 let action: DomainDigDeepLink.Action
198 switch response.actionIdentifier {
199 case LocalNotificationService.reinspectActionIdentifier:
200 action = .inspect(domain)
201 default:
202 // Default tap: open the tracked domain's detail.
203 action = .detail(domain)
204 }
205
206 await MainActor.run {
207 DomainDigIntentRouter.shared.pendingAction = action
208 }
209 }
157210}
DomainDig/RootTabView.swift +93 −43
@@ -1,15 +1,36 @@
11import SwiftUI
22
3private enum RootTab: Hashable {
3private enum RootTab: Hashable, CaseIterable {
44 case dashboard
55 case audit
66 case history
77 case inspect
88 case settings
9
10 var title: String {
11 switch self {
12 case .dashboard: return "Dashboard"
13 case .audit: return "Audit"
14 case .history: return "History"
15 case .inspect: return "Inspect"
16 case .settings: return "Settings"
17 }
18 }
19
20 var systemImage: String {
21 switch self {
22 case .dashboard: return "square.grid.2x2"
23 case .audit: return "checklist"
24 case .history: return "clock.arrow.trianglehead.counterclockwise.rotate.90"
25 case .inspect: return "magnifyingglass"
26 case .settings: return "gearshape"
27 }
28 }
929}
1030
1131struct RootTabView: View {
1232 @Bindable var viewModel: DomainViewModel
33 @Environment(\.horizontalSizeClass) private var horizontalSizeClass
1334 @State private var purchaseService = PurchaseService.shared
1435 @State private var intentRouter = DomainDigIntentRouter.shared
1536 @State private var detailDomain: TrackedDomain?
@@ -18,44 +39,12 @@ struct RootTabView: View {
1839 var body: some View {
1940 let _ = purchaseService.currentTier
2041
21 TabView(selection: $selectedTab) {
22 NavigationStack {
23 DashboardView(viewModel: viewModel)
24 }
25 .tabItem {
26 Label("Dashboard", systemImage: "square.grid.2x2")
27 }
28 .tag(RootTab.dashboard)
29
30 NavigationStack {
31 AuditListView(viewModel: viewModel)
32 }
33 .tabItem {
34 Label("Audit", systemImage: "checklist")
35 }
36 .tag(RootTab.audit)
37
38 NavigationStack {
39 HistoryView(viewModel: viewModel)
40 }
41 .tabItem {
42 Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90")
43 }
44 .tag(RootTab.history)
45
46 ContentView(viewModel: viewModel)
47 .tabItem {
48 Label("Inspect", systemImage: "magnifyingglass")
49 }
50 .tag(RootTab.inspect)
51
52 NavigationStack {
53 SettingsView(viewModel: viewModel)
54 }
55 .tabItem {
56 Label("Settings", systemImage: "gearshape")
42 Group {
43 if horizontalSizeClass == .regular {
44 splitLayout
45 } else {
46 tabLayout
5747 }
58 .tag(RootTab.settings)
5948 }
6049 .sheet(isPresented: Binding(
6150 get: { viewModel.isPaywallPresented },
@@ -79,16 +68,16 @@ struct RootTabView: View {
7968 }
8069 )
8170 }
82 .onChange(of: purchaseService.currentTier) { _, newValue in
83 if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false {
84 selectedTab = .dashboard
85 }
86 }
8771 .sheet(item: $detailDomain) { trackedDomain in
8872 NavigationStack {
8973 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
9074 }
9175 }
76 .onChange(of: purchaseService.currentTier) { _, newValue in
77 if newValue != .free, selectedTab == .inspect, viewModel.trackedDomains.isEmpty == false {
78 selectedTab = .dashboard
79 }
80 }
9281 .onOpenURL { url in
9382 guard let action = DomainDigDeepLink.action(from: url) else { return }
9483 perform(action)
@@ -101,6 +90,67 @@ struct RootTabView: View {
10190 }
10291 }
10392
93 // MARK: Layouts
94
95 /// Compact (iPhone, iPad slide-over): the classic tab bar.
96 private var tabLayout: some View {
97 TabView(selection: $selectedTab) {
98 ForEach(RootTab.allCases, id: \.self) { tab in
99 section(for: tab)
100 .tabItem {
101 Label(tab.title, systemImage: tab.systemImage)
102 }
103 .tag(tab)
104 }
105 }
106 }
107
108 /// Regular width (iPad, large iPhone landscape): two-column split view.
109 private var splitLayout: some View {
110 NavigationSplitView {
111 List(RootTab.allCases, id: \.self, selection: sidebarSelection) { tab in
112 Label(tab.title, systemImage: tab.systemImage)
113 .tag(tab)
114 }
115 .navigationTitle("DomainDig")
116 } detail: {
117 section(for: selectedTab)
118 }
119 }
120
121 private var sidebarSelection: Binding<RootTab?> {
122 Binding(
123 get: { selectedTab },
124 set: { selectedTab = $0 ?? selectedTab }
125 )
126 }
127
128 @ViewBuilder
129 private func section(for tab: RootTab) -> some View {
130 switch tab {
131 case .dashboard:
132 NavigationStack {
133 DashboardView(viewModel: viewModel)
134 }
135 case .audit:
136 NavigationStack {
137 AuditListView(viewModel: viewModel)
138 }
139 case .history:
140 NavigationStack {
141 HistoryView(viewModel: viewModel)
142 }
143 case .inspect:
144 ContentView(viewModel: viewModel)
145 case .settings:
146 NavigationStack {
147 SettingsView(viewModel: viewModel)
148 }
149 }
150 }
151
152 // MARK: Intent / deep-link routing
153
104154 private func consume(_ action: DomainDigDeepLink.Action?) {
105155 guard let action else { return }
106156 intentRouter.pendingAction = nil
DomainDig/SweepActivityController.swift added +63
@@ -0,0 +1,63 @@
1import ActivityKit
2import Foundation
3
4/// Starts, updates, and ends the sweep Live Activity around a batch run.
5@MainActor
6final class SweepActivityController {
7 static let shared = SweepActivityController()
8
9 private var activity: Activity<SweepActivityAttributes>?
10
11 private init() {}
12
13 func begin(title: String, total: Int) {
14 guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
15 // A previous activity that never ended (e.g. app killed mid-sweep)
16 // would otherwise linger; replace it.
17 end(changed: 0, warnings: 0, immediately: true)
18
19 let state = SweepActivityAttributes.ContentState(
20 completed: 0,
21 total: total,
22 currentDomain: nil,
23 changed: 0,
24 warnings: 0
25 )
26 activity = try? Activity.request(
27 attributes: SweepActivityAttributes(title: title, startedAt: Date()),
28 content: ActivityContent(state: state, staleDate: nil)
29 )
30 }
31
32 func update(completed: Int, total: Int, currentDomain: String?) {
33 guard let activity else { return }
34 let state = SweepActivityAttributes.ContentState(
35 completed: completed,
36 total: total,
37 currentDomain: currentDomain,
38 changed: 0,
39 warnings: 0
40 )
41 Task {
42 await activity.update(ActivityContent(state: state, staleDate: nil))
43 }
44 }
45
46 func end(changed: Int, warnings: Int, immediately: Bool = false) {
47 guard let activity else { return }
48 self.activity = nil
49 let state = SweepActivityAttributes.ContentState(
50 completed: activity.content.state.total,
51 total: activity.content.state.total,
52 currentDomain: nil,
53 changed: changed,
54 warnings: warnings
55 )
56 Task {
57 await activity.end(
58 ActivityContent(state: state, staleDate: nil),
59 dismissalPolicy: immediately ? .immediate : .after(Date().addingTimeInterval(60))
60 )
61 }
62 }
63}
DomainDigShareExtension/DomainDigShareExtension.entitlements added +10
@@ -0,0 +1,10 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.security.application-groups</key>
6 <array>
7 <string>group.net.cleberg.DomainDig</string>
8 </array>
9</dict>
10</plist>
DomainDigShareExtension/Info.plist added +39
@@ -0,0 +1,39 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleDevelopmentRegion</key>
6 <string>$(DEVELOPMENT_LANGUAGE)</string>
7 <key>CFBundleDisplayName</key>
8 <string>Dig Domain</string>
9 <key>CFBundleExecutable</key>
10 <string>$(EXECUTABLE_NAME)</string>
11 <key>CFBundleIdentifier</key>
12 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 <key>CFBundleInfoDictionaryVersion</key>
14 <string>6.0</string>
15 <key>CFBundleName</key>
16 <string>$(PRODUCT_NAME)</string>
17 <key>CFBundlePackageType</key>
18 <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
19 <key>CFBundleShortVersionString</key>
20 <string>$(MARKETING_VERSION)</string>
21 <key>CFBundleVersion</key>
22 <string>$(CURRENT_PROJECT_VERSION)</string>
23 <key>NSExtension</key>
24 <dict>
25 <key>NSExtensionAttributes</key>
26 <dict>
27 <key>NSExtensionActivationRule</key>
28 <dict>
29 <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
30 <integer>1</integer>
31 </dict>
32 </dict>
33 <key>NSExtensionPointIdentifier</key>
34 <string>com.apple.share-services</string>
35 <key>NSExtensionPrincipalClass</key>
36 <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
37 </dict>
38</dict>
39</plist>
DomainDigShareExtension/ShareViewController.swift added +65
@@ -0,0 +1,65 @@
1import UIKit
2import UniformTypeIdentifiers
3
4/// Receives a web URL from the share sheet, extracts its host, and hands it to
5/// the app through the App Group inbox. Shows a brief confirmation and closes;
6/// the app picks up the domain the next time it becomes active.
7final class ShareViewController: UIViewController {
8 private let label = UILabel()
9
10 override func viewDidLoad() {
11 super.viewDidLoad()
12 view.backgroundColor = .systemBackground
13
14 label.text = "Saving…"
15 label.font = .preferredFont(forTextStyle: .headline)
16 label.textAlignment = .center
17 label.numberOfLines = 0
18 label.translatesAutoresizingMaskIntoConstraints = false
19 view.addSubview(label)
20 NSLayoutConstraint.activate([
21 label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
22 label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
23 label.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24),
24 label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24)
25 ])
26
27 extractSharedDomain { [weak self] domain in
28 DispatchQueue.main.async {
29 self?.finish(domain: domain)
30 }
31 }
32 }
33
34 private func extractSharedDomain(completion: @escaping (String?) -> Void) {
35 let providers = (extensionContext?.inputItems ?? [])
36 .compactMap { $0 as? NSExtensionItem }
37 .flatMap { $0.attachments ?? [] }
38
39 guard let provider = providers.first(where: {
40 $0.hasItemConformingToTypeIdentifier(UTType.url.identifier)
41 }) else {
42 completion(nil)
43 return
44 }
45
46 provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in
47 let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) }
48 let host = url?.host?.trimmingCharacters(in: .whitespacesAndNewlines)
49 completion(host?.isEmpty == false ? host : nil)
50 }
51 }
52
53 private func finish(domain: String?) {
54 if let domain {
55 DomainDigShareInbox.write(domain: domain)
56 label.text = "Saved \(domain) — open DomainDig to inspect it."
57 } else {
58 label.text = "No web address found."
59 }
60
61 DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { [weak self] in
62 self?.extensionContext?.completeRequest(returningItems: nil)
63 }
64 }
65}
DomainDigWidget/DomainDigWidgetBundle.swift +1
@@ -5,5 +5,6 @@ import WidgetKit
55struct DomainDigWidgetBundle: WidgetBundle {
66 var body: some Widget {
77 DomainDigPortfolioWidget()
8 SweepLiveActivity()
89 }
910}
DomainDigWidget/SweepLiveActivity.swift added +96
@@ -0,0 +1,96 @@
1import ActivityKit
2import SwiftUI
3import WidgetKit
4
5struct SweepLiveActivity: Widget {
6 var body: some WidgetConfiguration {
7 ActivityConfiguration(for: SweepActivityAttributes.self) { context in
8 // Lock Screen / banner presentation.
9 SweepActivityBannerView(context: context)
10 .padding()
11 .activityBackgroundTint(Color(.systemBackground).opacity(0.85))
12 } dynamicIsland: { context in
13 DynamicIsland {
14 DynamicIslandExpandedRegion(.leading) {
15 Label(context.attributes.title, systemImage: "arrow.trianglehead.2.clockwise")
16 .font(.caption)
17 .foregroundStyle(.secondary)
18 }
19 DynamicIslandExpandedRegion(.trailing) {
20 Text("\(context.state.completed)/\(context.state.total)")
21 .font(.caption)
22 .monospacedDigit()
23 }
24 DynamicIslandExpandedRegion(.bottom) {
25 VStack(alignment: .leading, spacing: 4) {
26 ProgressView(value: context.state.fractionComplete)
27 .tint(.cyan)
28 if let current = context.state.currentDomain {
29 Text(current)
30 .font(.caption2)
31 .foregroundStyle(.secondary)
32 .lineLimit(1)
33 }
34 }
35 }
36 } compactLeading: {
37 Image(systemName: "arrow.trianglehead.2.clockwise")
38 .foregroundStyle(.cyan)
39 } compactTrailing: {
40 Text("\(context.state.completed)/\(context.state.total)")
41 .font(.caption2)
42 .monospacedDigit()
43 } minimal: {
44 ProgressView(value: context.state.fractionComplete)
45 .progressViewStyle(.circular)
46 .tint(.cyan)
47 }
48 }
49 }
50}
51
52private struct SweepActivityBannerView: View {
53 let context: ActivityViewContext<SweepActivityAttributes>
54
55 var body: some View {
56 VStack(alignment: .leading, spacing: 8) {
57 HStack {
58 Label(context.attributes.title, systemImage: "arrow.trianglehead.2.clockwise")
59 .font(.caption)
60 .fontWeight(.semibold)
61 .foregroundStyle(.secondary)
62 Spacer()
63 Text("\(context.state.completed) of \(context.state.total)")
64 .font(.caption)
65 .monospacedDigit()
66 }
67
68 ProgressView(value: context.state.fractionComplete)
69 .tint(.cyan)
70
71 HStack {
72 if let current = context.state.currentDomain {
73 Text(current)
74 .font(.caption2)
75 .foregroundStyle(.secondary)
76 .lineLimit(1)
77 } else {
78 Text(context.state.completed >= context.state.total ? "Finished" : "Working…")
79 .font(.caption2)
80 .foregroundStyle(.secondary)
81 }
82 Spacer()
83 if context.state.changed > 0 {
84 Text("\(context.state.changed) changed")
85 .font(.caption2)
86 .foregroundStyle(.orange)
87 }
88 if context.state.warnings > 0 {
89 Text("\(context.state.warnings) warnings")
90 .font(.caption2)
91 .foregroundStyle(.red)
92 }
93 }
94 }
95 }
96}
Shared/DomainDigShareInbox.swift added +28
@@ -0,0 +1,28 @@
1import Foundation
2
3/// App Group handoff from the share extension to the app.
4///
5/// Share extensions cannot reliably open their host app, so the extension
6/// drops the shared domain here and the app consumes it the next time it
7/// becomes active, routing it into an inspection.
8enum DomainDigShareInbox {
9 private static let key = "pendingInspectDomain"
10
11 private static var defaults: UserDefaults? {
12 UserDefaults(suiteName: DomainDigWidgetStore.appGroupID)
13 }
14
15 static func write(domain: String) {
16 defaults?.set(domain, forKey: key)
17 }
18
19 /// Returns and clears the pending domain, if any.
20 static func consume() -> String? {
21 guard let defaults,
22 let domain = defaults.string(forKey: key),
23 !domain.isEmpty
24 else { return nil }
25 defaults.removeObject(forKey: key)
26 return domain
27 }
28}
Shared/SweepActivityAttributes.swift added +25
@@ -0,0 +1,25 @@
1import ActivityKit
2import Foundation
3
4/// Live Activity contract for an in-flight watchlist sweep or batch lookup.
5///
6/// Lives in `Shared/` because the app starts/updates the activity and the
7/// widget extension renders it.
8struct SweepActivityAttributes: ActivityAttributes {
9 struct ContentState: Codable, Hashable {
10 var completed: Int
11 var total: Int
12 var currentDomain: String?
13 var changed: Int
14 var warnings: Int
15
16 var fractionComplete: Double {
17 guard total > 0 else { return 0 }
18 return Double(completed) / Double(total)
19 }
20 }
21
22 /// User-facing title for the run, e.g. "Watchlist Sweep" or "Batch Lookup".
23 var title: String
24 var startedAt: Date
25}