aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 08:36:05 -0500
committerChristian Cleberg <[email protected]>2026-07-17 10:12:36 -0500
commitb4b94d54cbed9334a28cdcd70d3a8a0fd06e04ef (patch)
tree8fa4b408729da32ec2c547081ec2bba13b893fa2
parent065e1e77ff00e8f9c8785393b5eba6c3e0bb2bb1 (diff)
downloaddomain-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.pbxproj182
-rw-r--r--DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist7
-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
-rw-r--r--DomainDigShareExtension/DomainDigShareExtension.entitlements10
-rw-r--r--DomainDigShareExtension/Info.plist39
-rw-r--r--DomainDigShareExtension/ShareViewController.swift65
-rw-r--r--DomainDigWidget/DomainDigWidgetBundle.swift1
-rw-r--r--DomainDigWidget/SweepLiveActivity.swift96
-rw-r--r--Shared/DomainDigShareInbox.swift28
-rw-r--r--Shared/SweepActivityAttributes.swift25
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
+}