diff options
| author | Christian Cleberg <[email protected]> | 2026-07-17 08:36:05 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-17 10:12:36 -0500 |
| commit | b4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef (patch) | |
| tree | 8fa4b408729da32ec2c547081ec2bba13b893fa2 | |
| parent | 065e1e77ff00e8f9c8785393b5eba6c3e0bb2bb1 (diff) | |
| download | domain-dig-b4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef.tar.gz domain-dig-b4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef.tar.bz2 domain-dig-b4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef.zip | |
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.
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 182 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist | 7 | ||||
| -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 | ||||
| -rw-r--r-- | DomainDigShareExtension/DomainDigShareExtension.entitlements | 10 | ||||
| -rw-r--r-- | DomainDigShareExtension/Info.plist | 39 | ||||
| -rw-r--r-- | DomainDigShareExtension/ShareViewController.swift | 65 | ||||
| -rw-r--r-- | DomainDigWidget/DomainDigWidgetBundle.swift | 1 | ||||
| -rw-r--r-- | DomainDigWidget/SweepLiveActivity.swift | 96 | ||||
| -rw-r--r-- | Shared/DomainDigShareInbox.swift | 28 | ||||
| -rw-r--r-- | Shared/SweepActivityAttributes.swift | 25 |
16 files changed, 677 insertions, 69 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index dbd0873..708ef72 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -13,8 +13,9 @@ 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; }; 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; }; - 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -25,6 +26,13 @@ remoteGlobalIDString = 8BDA00010000000000000006; remoteInfo = DomainDigWidgetExtension; }; + 8BDB00010000000000000003 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B7800612F6090E300933221 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8BDB00010000000000000006; + remoteInfo = DomainDigShareExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -35,6 +43,7 @@ dstSubfolderSpec = 13; files = ( 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */, + 8BDB00010000000000000002 /* DomainDigShareExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -49,8 +58,9 @@ 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; }; 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; }; - 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BDB00010000000000000001 /* DomainDigShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -70,6 +80,13 @@ ); target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */; }; + 8BDB0001000000000000000F /* Exceptions for "DomainDigShareExtension" folder in "DomainDigShareExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 8BDB00010000000000000006 /* DomainDigShareExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -94,6 +111,14 @@ path = DomainDigWidget; sourceTree = "<group>"; }; + 8BDB0001000000000000000E /* DomainDigShareExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8BDB0001000000000000000F /* Exceptions for "DomainDigShareExtension" folder in "DomainDigShareExtension" target */, + ); + path = DomainDigShareExtension; + sourceTree = "<group>"; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -111,6 +136,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BDB00010000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -120,6 +152,7 @@ 8B78006B2F6090E300933221 /* DomainDig */, 8BDA0001000000000000000D /* Shared */, 8BDA0001000000000000000E /* DomainDigWidget */, + 8BDB0001000000000000000E /* DomainDigShareExtension */, 8B78006A2F6090E300933221 /* Products */, 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */, 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, @@ -136,6 +169,7 @@ children = ( 8B7800692F6090E300933221 /* DomainDig.app */, 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */, + 8BDB00010000000000000001 /* DomainDigShareExtension.appex */, ); name = Products; sourceTree = "<group>"; @@ -156,6 +190,7 @@ ); dependencies = ( 8BDA00010000000000000004 /* PBXTargetDependency */, + 8BDB00010000000000000004 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 8B78006B2F6090E300933221 /* DomainDig */, @@ -191,15 +226,30 @@ productReference = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; productType = "com.apple.product-type.app-extension"; }; -/* End PBXNativeTarget section */ - -/* Begin PBXTargetDependency section */ - 8BDA00010000000000000004 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */; - targetProxy = 8BDA00010000000000000003 /* PBXContainerItemProxy */; + 8BDB00010000000000000006 /* DomainDigShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8BDB0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigShareExtension" */; + buildPhases = ( + 8BDB00010000000000000007 /* Sources */, + 8BDB00010000000000000008 /* Frameworks */, + 8BDB00010000000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8BDA0001000000000000000D /* Shared */, + 8BDB0001000000000000000E /* DomainDigShareExtension */, + ); + name = DomainDigShareExtension; + packageProductDependencies = ( + ); + productName = DomainDigShareExtension; + productReference = 8BDB00010000000000000001 /* DomainDigShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; }; -/* End PBXTargetDependency section */ +/* End PBXNativeTarget section */ /* Begin PBXProject section */ 8B7800612F6090E300933221 /* Project object */ = { @@ -220,6 +270,9 @@ 8BDA00010000000000000006 = { CreatedOnToolsVersion = 26.3; }; + 8BDB00010000000000000006 = { + CreatedOnToolsVersion = 26.3; + }; }; }; buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */; @@ -238,6 +291,7 @@ targets = ( 8B7800682F6090E300933221 /* DomainDig */, 8BDA00010000000000000006 /* DomainDigWidgetExtension */, + 8BDB00010000000000000006 /* DomainDigShareExtension */, ); }; /* End PBXProject section */ @@ -257,6 +311,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BDB00010000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -281,8 +342,28 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BDB00010000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 8BDA00010000000000000004 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */; + targetProxy = 8BDA00010000000000000003 /* PBXContainerItemProxy */; + }; + 8BDB00010000000000000004 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8BDB00010000000000000006 /* DomainDigShareExtension */; + targetProxy = 8BDB00010000000000000003 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 8B7800722F6090E400933221 /* Debug */ = { isa = XCBuildConfiguration; @@ -414,7 +495,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -431,7 +512,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.5.0; + MARKETING_VERSION = 4.6.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -451,7 +532,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -468,7 +549,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.5.0; + MARKETING_VERSION = 4.6.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -487,7 +568,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = DomainDigWidget/Info.plist; @@ -497,7 +578,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 4.5.0; + MARKETING_VERSION = 4.6.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -515,7 +596,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = DomainDigWidget/Info.plist; @@ -525,7 +606,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 4.5.0; + MARKETING_VERSION = 4.6.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -537,6 +618,62 @@ }; name = Release; }; + 8BDB0001000000000000000B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 38; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DomainDigShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 4.6.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8BDB0001000000000000000C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = DomainDigShareExtension/DomainDigShareExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 38; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = DomainDigShareExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 4.6.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigShareExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -567,6 +704,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8BDB0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigShareExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8BDB0001000000000000000B /* Debug */, + 8BDB0001000000000000000C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 8B7800612F6090E300933221 /* Project object */; diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist index 2e2db5b..5f600f0 100644 --- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,11 +9,16 @@ <key>orderHint</key> <integer>0</integer> </dict> - <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key> + <key>DomainDigShareExtension.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> <integer>1</integer> </dict> + <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key> + <dict> + <key>orderHint</key> + <integer>2</integer> + </dict> </dict> <key>SuppressBuildableAutocreation</key> <dict> 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)) + ) + } + } +} diff --git a/DomainDigShareExtension/DomainDigShareExtension.entitlements b/DomainDigShareExtension/DomainDigShareExtension.entitlements new file mode 100644 index 0000000..eaa20ba --- /dev/null +++ b/DomainDigShareExtension/DomainDigShareExtension.entitlements @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.application-groups</key> + <array> + <string>group.net.cleberg.DomainDig</string> + </array> +</dict> +</plist> diff --git a/DomainDigShareExtension/Info.plist b/DomainDigShareExtension/Info.plist new file mode 100644 index 0000000..ac17ff0 --- /dev/null +++ b/DomainDigShareExtension/Info.plist @@ -0,0 +1,39 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDisplayName</key> + <string>Dig Domain</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> + <key>CFBundleShortVersionString</key> + <string>$(MARKETING_VERSION)</string> + <key>CFBundleVersion</key> + <string>$(CURRENT_PROJECT_VERSION)</string> + <key>NSExtension</key> + <dict> + <key>NSExtensionAttributes</key> + <dict> + <key>NSExtensionActivationRule</key> + <dict> + <key>NSExtensionActivationSupportsWebURLWithMaxCount</key> + <integer>1</integer> + </dict> + </dict> + <key>NSExtensionPointIdentifier</key> + <string>com.apple.share-services</string> + <key>NSExtensionPrincipalClass</key> + <string>$(PRODUCT_MODULE_NAME).ShareViewController</string> + </dict> +</dict> +</plist> diff --git a/DomainDigShareExtension/ShareViewController.swift b/DomainDigShareExtension/ShareViewController.swift new file mode 100644 index 0000000..411f9e7 --- /dev/null +++ b/DomainDigShareExtension/ShareViewController.swift @@ -0,0 +1,65 @@ +import UIKit +import UniformTypeIdentifiers + +/// Receives a web URL from the share sheet, extracts its host, and hands it to +/// the app through the App Group inbox. Shows a brief confirmation and closes; +/// the app picks up the domain the next time it becomes active. +final class ShareViewController: UIViewController { + private let label = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + label.text = "Saving…" + label.font = .preferredFont(forTextStyle: .headline) + label.textAlignment = .center + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + label.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24), + label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24) + ]) + + extractSharedDomain { [weak self] domain in + DispatchQueue.main.async { + self?.finish(domain: domain) + } + } + } + + private func extractSharedDomain(completion: @escaping (String?) -> Void) { + let providers = (extensionContext?.inputItems ?? []) + .compactMap { $0 as? NSExtensionItem } + .flatMap { $0.attachments ?? [] } + + guard let provider = providers.first(where: { + $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) + }) else { + completion(nil) + return + } + + provider.loadItem(forTypeIdentifier: UTType.url.identifier) { item, _ in + let url = item as? URL ?? (item as? Data).flatMap { URL(dataRepresentation: $0, relativeTo: nil) } + let host = url?.host?.trimmingCharacters(in: .whitespacesAndNewlines) + completion(host?.isEmpty == false ? host : nil) + } + } + + private func finish(domain: String?) { + if let domain { + DomainDigShareInbox.write(domain: domain) + label.text = "Saved \(domain) — open DomainDig to inspect it." + } else { + label.text = "No web address found." + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { [weak self] in + self?.extensionContext?.completeRequest(returningItems: nil) + } + } +} diff --git a/DomainDigWidget/DomainDigWidgetBundle.swift b/DomainDigWidget/DomainDigWidgetBundle.swift index 483995c..8c12c90 100644 --- a/DomainDigWidget/DomainDigWidgetBundle.swift +++ b/DomainDigWidget/DomainDigWidgetBundle.swift @@ -5,5 +5,6 @@ import WidgetKit struct DomainDigWidgetBundle: WidgetBundle { var body: some Widget { DomainDigPortfolioWidget() + SweepLiveActivity() } } diff --git a/DomainDigWidget/SweepLiveActivity.swift b/DomainDigWidget/SweepLiveActivity.swift new file mode 100644 index 0000000..b99a070 --- /dev/null +++ b/DomainDigWidget/SweepLiveActivity.swift @@ -0,0 +1,96 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct SweepLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: SweepActivityAttributes.self) { context in + // Lock Screen / banner presentation. + SweepActivityBannerView(context: context) + .padding() + .activityBackgroundTint(Color(.systemBackground).opacity(0.85)) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Label(context.attributes.title, systemImage: "arrow.trianglehead.2.clockwise") + .font(.caption) + .foregroundStyle(.secondary) + } + DynamicIslandExpandedRegion(.trailing) { + Text("\(context.state.completed)/\(context.state.total)") + .font(.caption) + .monospacedDigit() + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 4) { + ProgressView(value: context.state.fractionComplete) + .tint(.cyan) + if let current = context.state.currentDomain { + Text(current) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } compactLeading: { + Image(systemName: "arrow.trianglehead.2.clockwise") + .foregroundStyle(.cyan) + } compactTrailing: { + Text("\(context.state.completed)/\(context.state.total)") + .font(.caption2) + .monospacedDigit() + } minimal: { + ProgressView(value: context.state.fractionComplete) + .progressViewStyle(.circular) + .tint(.cyan) + } + } + } +} + +private struct SweepActivityBannerView: View { + let context: ActivityViewContext<SweepActivityAttributes> + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label(context.attributes.title, systemImage: "arrow.trianglehead.2.clockwise") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + Spacer() + Text("\(context.state.completed) of \(context.state.total)") + .font(.caption) + .monospacedDigit() + } + + ProgressView(value: context.state.fractionComplete) + .tint(.cyan) + + HStack { + if let current = context.state.currentDomain { + Text(current) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + Text(context.state.completed >= context.state.total ? "Finished" : "Working…") + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + if context.state.changed > 0 { + Text("\(context.state.changed) changed") + .font(.caption2) + .foregroundStyle(.orange) + } + if context.state.warnings > 0 { + Text("\(context.state.warnings) warnings") + .font(.caption2) + .foregroundStyle(.red) + } + } + } + } +} diff --git a/Shared/DomainDigShareInbox.swift b/Shared/DomainDigShareInbox.swift new file mode 100644 index 0000000..3a577e7 --- /dev/null +++ b/Shared/DomainDigShareInbox.swift @@ -0,0 +1,28 @@ +import Foundation + +/// App Group handoff from the share extension to the app. +/// +/// Share extensions cannot reliably open their host app, so the extension +/// drops the shared domain here and the app consumes it the next time it +/// becomes active, routing it into an inspection. +enum DomainDigShareInbox { + private static let key = "pendingInspectDomain" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: DomainDigWidgetStore.appGroupID) + } + + static func write(domain: String) { + defaults?.set(domain, forKey: key) + } + + /// Returns and clears the pending domain, if any. + static func consume() -> String? { + guard let defaults, + let domain = defaults.string(forKey: key), + !domain.isEmpty + else { return nil } + defaults.removeObject(forKey: key) + return domain + } +} diff --git a/Shared/SweepActivityAttributes.swift b/Shared/SweepActivityAttributes.swift new file mode 100644 index 0000000..ee03c2d --- /dev/null +++ b/Shared/SweepActivityAttributes.swift @@ -0,0 +1,25 @@ +import ActivityKit +import Foundation + +/// Live Activity contract for an in-flight watchlist sweep or batch lookup. +/// +/// Lives in `Shared/` because the app starts/updates the activity and the +/// widget extension renders it. +struct SweepActivityAttributes: ActivityAttributes { + struct ContentState: Codable, Hashable { + var completed: Int + var total: Int + var currentDomain: String? + var changed: Int + var warnings: Int + + var fractionComplete: Double { + guard total > 0 else { return 0 } + return Double(completed) / Double(total) + } + } + + /// User-facing title for the run, e.g. "Watchlist Sweep" or "Batch Lookup". + var title: String + var startedAt: Date +} |
