summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 00:27:45 -0500
committerChristian Cleberg <[email protected]>2026-07-17 00:31:57 -0500
commitd3a27d06809e6744c092f9bed4bf0537843d0210 (patch)
tree1527e935e11561dda4bc8c6cfaabdb92db5be95c
parent685f84ad0a8d2dc7b62125d5d9c3a50b75ae2468 (diff)
downloaddomain-dig-d3a27d06809e6744c092f9bed4bf0537843d0210.tar.gz
domain-dig-d3a27d06809e6744c092f9bed4bf0537843d0210.tar.bz2
domain-dig-d3a27d06809e6744c092f9bed4bf0537843d0210.zip
Complete v4.5.0: Run Sweep intent, detail deep link, and portfolio widget
Finishes the v4.5.0 "Home Screen & Shortcuts reach" scope that the tag shipped partially: - Add RunSweepIntent (opens the app and runs refreshAllTrackedDomains via the in-process router) and expose it in DomainDigShortcuts. - Extend the domaindig:// scheme with `sweep` and `domain` (detail) actions; route .detail to present TrackedDomainDetailView and .sweep to refresh the watchlist. Move DomainDigDeepLink into Shared/ so the widget can build links. - Add a WidgetKit extension (DomainDigWidgetExtension) with small/medium/large Portfolio widgets showing health counts, per-domain status, and certificate countdowns; tapping a domain deep-links into its detail. - Share portfolio state via an App Group (group.net.cleberg.DomainDig): the app writes a DomainDigWidgetData snapshot on launch/foreground and on watchlist changes and reloads timelines; the widget reads the same store.
-rw-r--r--DomainDig.xcodeproj/project.pbxproj173
-rw-r--r--DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist5
-rw-r--r--DomainDig/DomainDig.entitlements4
-rw-r--r--DomainDig/DomainDigApp.swift2
-rw-r--r--DomainDig/DomainDigIntents.swift78
-rw-r--r--DomainDig/DomainViewModel+Widget.swift60
-rw-r--r--DomainDig/DomainViewModel.swift1
-rw-r--r--DomainDig/RootTabView.swift19
-rw-r--r--DomainDigWidget/DomainDigPortfolioWidget.swift189
-rw-r--r--DomainDigWidget/DomainDigWidget.entitlements10
-rw-r--r--DomainDigWidget/DomainDigWidgetBundle.swift9
-rw-r--r--DomainDigWidget/Info.plist29
-rw-r--r--Shared/DomainDigDeepLink.swift71
-rw-r--r--Shared/DomainDigWidgetData.swift82
14 files changed, 681 insertions, 51 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index fe76a4e..dbd0873 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -14,8 +14,33 @@
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, ); }; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ 8BDA00010000000000000003 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 8B7800612F6090E300933221 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 8BDA00010000000000000006;
+ remoteInfo = DomainDigWidgetExtension;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 8BDA00010000000000000005 /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFileReference section */
8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
@@ -25,6 +50,7 @@
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; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -37,6 +63,13 @@
);
target = 8B7800682F6090E300933221 /* DomainDig */;
};
+ 8BDA0001000000000000000F /* Exceptions for "DomainDigWidget" folder in "DomainDigWidgetExtension" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -48,6 +81,19 @@
path = DomainDig;
sourceTree = "<group>";
};
+ 8BDA0001000000000000000D /* Shared */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = Shared;
+ sourceTree = "<group>";
+ };
+ 8BDA0001000000000000000E /* DomainDigWidget */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 8BDA0001000000000000000F /* Exceptions for "DomainDigWidget" folder in "DomainDigWidgetExtension" target */,
+ );
+ path = DomainDigWidget;
+ sourceTree = "<group>";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -58,6 +104,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BDA00010000000000000008 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -65,6 +118,8 @@
isa = PBXGroup;
children = (
8B78006B2F6090E300933221 /* DomainDig */,
+ 8BDA0001000000000000000D /* Shared */,
+ 8BDA0001000000000000000E /* DomainDigWidget */,
8B78006A2F6090E300933221 /* Products */,
8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
@@ -80,6 +135,7 @@
isa = PBXGroup;
children = (
8B7800692F6090E300933221 /* DomainDig.app */,
+ 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -94,13 +150,16 @@
8B7800652F6090E300933221 /* Sources */,
8B7800662F6090E300933221 /* Frameworks */,
8B7800672F6090E300933221 /* Resources */,
+ 8BDA00010000000000000005 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
+ 8BDA00010000000000000004 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
8B78006B2F6090E300933221 /* DomainDig */,
+ 8BDA0001000000000000000D /* Shared */,
);
name = DomainDig;
packageProductDependencies = (
@@ -109,8 +168,39 @@
productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
productType = "com.apple.product-type.application";
};
+ 8BDA00010000000000000006 /* DomainDigWidgetExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8BDA0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigWidgetExtension" */;
+ buildPhases = (
+ 8BDA00010000000000000007 /* Sources */,
+ 8BDA00010000000000000008 /* Frameworks */,
+ 8BDA00010000000000000009 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 8BDA0001000000000000000D /* Shared */,
+ 8BDA0001000000000000000E /* DomainDigWidget */,
+ );
+ name = DomainDigWidgetExtension;
+ packageProductDependencies = (
+ );
+ productName = DomainDigWidgetExtension;
+ 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 */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin PBXProject section */
8B7800612F6090E300933221 /* Project object */ = {
isa = PBXProject;
@@ -127,6 +217,9 @@
};
};
};
+ 8BDA00010000000000000006 = {
+ CreatedOnToolsVersion = 26.3;
+ };
};
};
buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
@@ -144,6 +237,7 @@
projectRoot = "";
targets = (
8B7800682F6090E300933221 /* DomainDig */,
+ 8BDA00010000000000000006 /* DomainDigWidgetExtension */,
);
};
/* End PBXProject section */
@@ -156,6 +250,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BDA00010000000000000009 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -173,6 +274,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BDA00010000000000000007 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
@@ -373,6 +481,62 @@
};
name = Release;
};
+ 8BDA0001000000000000000B /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 37;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = DomainDigWidget/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 4.5.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
+ 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;
+ };
+ 8BDA0001000000000000000C /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 37;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = DomainDigWidget/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = 4.5.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
+ 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 */
@@ -394,6 +558,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 8BDA0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigWidgetExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8BDA0001000000000000000B /* Debug */,
+ 8BDA0001000000000000000C /* 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 8fd771d..2e2db5b 100644
--- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -9,6 +9,11 @@
<key>orderHint</key>
<integer>0</integer>
</dict>
+ <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key>
+ <dict>
+ <key>orderHint</key>
+ <integer>1</integer>
+ </dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
diff --git a/DomainDig/DomainDig.entitlements b/DomainDig/DomainDig.entitlements
index d792729..223e66a 100644
--- a/DomainDig/DomainDig.entitlements
+++ b/DomainDig/DomainDig.entitlements
@@ -10,5 +10,9 @@
<array>
<string>CloudKit</string>
</array>
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>group.net.cleberg.DomainDig</string>
+ </array>
</dict>
</plist>
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index ff40caf..8716806 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -39,6 +39,7 @@ struct DomainDigApp: App {
cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
IntegrationService.shared.processQueueNow()
+ viewModel.refreshWidgetData()
}
.onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in
viewModel.refreshPersistedData()
@@ -57,6 +58,7 @@ struct DomainDigApp: App {
cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
IntegrationService.shared.processQueueNow()
+ viewModel.refreshWidgetData()
}
}
}
diff --git a/DomainDig/DomainDigIntents.swift b/DomainDig/DomainDigIntents.swift
index 44a1dc4..c0c73cc 100644
--- a/DomainDig/DomainDigIntents.swift
+++ b/DomainDig/DomainDigIntents.swift
@@ -124,6 +124,24 @@ struct AddToWatchlistIntent: AppIntent {
}
}
+/// App Intent that opens DomainDig and re-inspects every tracked domain. It runs
+/// through the existing view-model batch path (`refreshAllTrackedDomains`), which
+/// enforces the batch feature gate and surfaces the paywall when needed.
+struct RunSweepIntent: AppIntent {
+ static var title: LocalizedStringResource = "Run Watchlist Sweep"
+ static var description = IntentDescription(
+ "Open DomainDig and re-inspect every domain on your watchlist."
+ )
+
+ static var openAppWhenRun = true
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ DomainDigIntentRouter.shared.pendingAction = .sweep
+ return .result()
+ }
+}
+
/// In-process hand-off from an `openAppWhenRun` intent to the running SwiftUI
/// layer. `RootTabView` observes `pendingAction` and performs it.
@MainActor
@@ -134,57 +152,6 @@ final class DomainDigIntentRouter {
private init() {}
}
-/// Shared builder/parser for the `domaindig://` URL scheme, used by both the
-/// intents (to open the app) and the app (to route incoming links).
-enum DomainDigDeepLink {
- static let scheme = "domaindig"
-
- enum Action: Equatable {
- case inspect(String)
- case watch(String)
-
- var host: String {
- switch self {
- case .inspect: return "inspect"
- case .watch: return "watch"
- }
- }
-
- var domain: String {
- switch self {
- case let .inspect(domain), let .watch(domain): return domain
- }
- }
- }
-
- static func url(for action: Action) -> URL {
- var components = URLComponents()
- components.scheme = scheme
- components.host = action.host
- components.queryItems = [URLQueryItem(name: "domain", value: action.domain)]
- // The scheme and host are fixed and the domain is percent-encoded by
- // URLComponents, so this is always a valid URL.
- return components.url!
- }
-
- static func action(from url: URL) -> Action? {
- guard url.scheme == scheme else { return nil }
-
- let domain = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first { $0.name == "domain" }?
- .value?
- .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard !domain.isEmpty else { return nil }
-
- switch url.host() {
- case "inspect": return .inspect(domain)
- case "watch": return .watch(domain)
- default: return nil
- }
- }
-}
-
/// Exposes DomainDig intents to Spotlight and Siri with invocation phrases.
struct DomainDigShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
@@ -206,5 +173,14 @@ struct DomainDigShortcuts: AppShortcutsProvider {
shortTitle: "Add to Watchlist",
systemImageName: "plus.circle"
)
+ AppShortcut(
+ intent: RunSweepIntent(),
+ phrases: [
+ "Run a sweep with \(.applicationName)",
+ "Sweep my \(.applicationName) watchlist"
+ ],
+ shortTitle: "Run Sweep",
+ systemImageName: "arrow.trianglehead.2.clockwise"
+ )
}
}
diff --git a/DomainDig/DomainViewModel+Widget.swift b/DomainDig/DomainViewModel+Widget.swift
new file mode 100644
index 0000000..59b28e0
--- /dev/null
+++ b/DomainDig/DomainViewModel+Widget.swift
@@ -0,0 +1,60 @@
+import Foundation
+import WidgetKit
+
+extension DomainViewModel {
+ /// Publishes the current portfolio state to the App Group container so the
+ /// widget can render it, then asks WidgetKit to refresh its timelines.
+ func refreshWidgetData() {
+ let data = portfolioDashboardData
+ let snapshot = data.snapshot
+
+ let ordered = data.domainStates.sorted { lhs, rhs in
+ if lhs.trackedDomain.isPinned != rhs.trackedDomain.isPinned {
+ return lhs.trackedDomain.isPinned
+ }
+ return lhs.health.widgetSeverityRank > rhs.health.widgetSeverityRank
+ }
+
+ let domains = ordered.prefix(6).map { state in
+ DomainDigWidgetDomain(
+ domain: state.trackedDomain.domain,
+ isPinned: state.trackedDomain.isPinned,
+ status: state.health.widgetStatus,
+ certDaysRemaining: state.certificateDaysRemaining,
+ lastChange: state.lastChangeDate
+ )
+ }
+
+ DomainDigWidgetStore.write(
+ DomainDigWidgetData(
+ generatedAt: Date(),
+ totalDomains: snapshot.totalDomains,
+ healthyCount: snapshot.healthyCount,
+ warningCount: snapshot.warningCount,
+ criticalCount: snapshot.criticalCount,
+ expiringSoonCount: snapshot.expiringSoonCount,
+ unreachableCount: snapshot.unreachableCount,
+ domains: Array(domains)
+ )
+ )
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+}
+
+private extension DomainHealth {
+ var widgetStatus: DomainDigWidgetStatus {
+ switch self {
+ case .healthy: return .healthy
+ case .warning: return .warning
+ case .critical: return .critical
+ }
+ }
+
+ var widgetSeverityRank: Int {
+ switch self {
+ case .healthy: return 0
+ case .warning: return 1
+ case .critical: return 2
+ }
+ }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index f0c4411..3f37cb9 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -2582,6 +2582,7 @@ final class DomainViewModel {
DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
CloudSyncService.shared.scheduleSyncIfNeeded()
refreshDataLifecycleSummary()
+ refreshWidgetData()
}
private func beginBulkPersistenceDeferral() {
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
index 72038a1..9823d37 100644
--- a/DomainDig/RootTabView.swift
+++ b/DomainDig/RootTabView.swift
@@ -12,6 +12,7 @@ struct RootTabView: View {
@Bindable var viewModel: DomainViewModel
@State private var purchaseService = PurchaseService.shared
@State private var intentRouter = DomainDigIntentRouter.shared
+ @State private var detailDomain: TrackedDomain?
@State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard
var body: some View {
@@ -83,6 +84,11 @@ struct RootTabView: View {
selectedTab = .dashboard
}
}
+ .sheet(item: $detailDomain) { trackedDomain in
+ NavigationStack {
+ TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
+ }
+ }
.onOpenURL { url in
guard let action = DomainDigDeepLink.action(from: url) else { return }
perform(action)
@@ -113,6 +119,19 @@ struct RootTabView: View {
if viewModel.trackDomain(domain: domain, availabilityStatus: nil) {
selectedTab = .dashboard
}
+ case let .detail(domain):
+ // Open the tracked domain's detail (e.g. from a widget tap). If it is
+ // no longer tracked, fall back to inspecting it.
+ if let tracked = viewModel.trackedDomains.first(
+ where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
+ ) {
+ detailDomain = tracked
+ } else {
+ perform(.inspect(domain))
+ }
+ case .sweep:
+ selectedTab = .dashboard
+ viewModel.refreshAllTrackedDomains()
}
}
}
diff --git a/DomainDigWidget/DomainDigPortfolioWidget.swift b/DomainDigWidget/DomainDigPortfolioWidget.swift
new file mode 100644
index 0000000..f26964c
--- /dev/null
+++ b/DomainDigWidget/DomainDigPortfolioWidget.swift
@@ -0,0 +1,189 @@
+import SwiftUI
+import WidgetKit
+
+struct DomainDigEntry: TimelineEntry {
+ let date: Date
+ let data: DomainDigWidgetData
+}
+
+struct DomainDigProvider: TimelineProvider {
+ func placeholder(in context: Context) -> DomainDigEntry {
+ DomainDigEntry(date: Date(), data: .placeholder)
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (DomainDigEntry) -> Void) {
+ let data = context.isPreview ? .placeholder : (DomainDigWidgetStore.read() ?? .placeholder)
+ completion(DomainDigEntry(date: Date(), data: data))
+ }
+
+ func getTimeline(in context: Context, completion: @escaping (Timeline<DomainDigEntry>) -> Void) {
+ let data = DomainDigWidgetStore.read() ?? .empty
+ let entry = DomainDigEntry(date: Date(), data: data)
+ // The app reloads timelines on foreground and on watchlist changes; this
+ // periodic refresh is a backstop so cert countdowns stay roughly current.
+ let next = Calendar.current.date(byAdding: .hour, value: 6, to: Date())
+ ?? Date().addingTimeInterval(6 * 3600)
+ completion(Timeline(entries: [entry], policy: .after(next)))
+ }
+}
+
+struct DomainDigPortfolioWidget: Widget {
+ let kind = "DomainDigPortfolioWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in
+ DomainDigWidgetView(data: entry.data)
+ .containerBackground(.fill.tertiary, for: .widget)
+ }
+ .configurationDisplayName("Domain Portfolio")
+ .description("Health and certificate status for your tracked domains.")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
+
+struct DomainDigWidgetView: View {
+ @Environment(\.widgetFamily) private var family
+ let data: DomainDigWidgetData
+
+ var body: some View {
+ if data.totalDomains == 0 {
+ emptyState
+ } else {
+ switch family {
+ case .systemSmall:
+ smallView
+ default:
+ mediumOrLargeView
+ }
+ }
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 6) {
+ Image(systemName: "magnifyingglass")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ Text("No tracked domains")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ // MARK: Small
+
+ private var smallView: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 4) {
+ Image(systemName: "shield.lefthalf.filled")
+ Text("DomainDig")
+ .fontWeight(.semibold)
+ Spacer()
+ }
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+
+ Text("\(data.totalDomains)")
+ .font(.system(size: 34, weight: .bold, design: .rounded))
+ Text("tracked")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+
+ Spacer(minLength: 0)
+
+ HStack(spacing: 10) {
+ countPill(data.healthyCount, .green)
+ countPill(data.warningCount, .orange)
+ countPill(data.criticalCount, .red)
+ }
+ }
+ }
+
+ private func countPill(_ value: Int, _ color: Color) -> some View {
+ HStack(spacing: 3) {
+ Circle().fill(color).frame(width: 7, height: 7)
+ Text("\(value)").font(.caption).fontWeight(.medium)
+ }
+ }
+
+ // MARK: Medium / Large
+
+ private var mediumOrLargeView: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Label("Domain Portfolio", systemImage: "shield.lefthalf.filled")
+ .font(.caption)
+ .fontWeight(.semibold)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(data.totalDomains) tracked")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+
+ HStack(spacing: 12) {
+ summaryStat(data.healthyCount, "Healthy", .green)
+ summaryStat(data.warningCount, "Warning", .orange)
+ summaryStat(data.criticalCount, "Critical", .red)
+ summaryStat(data.expiringSoonCount, "Expiring", .yellow)
+ }
+
+ Divider()
+
+ VStack(spacing: 6) {
+ ForEach(data.domains.prefix(family == .systemLarge ? 6 : 3)) { domain in
+ Link(destination: DomainDigDeepLink.url(for: .detail(domain.domain))) {
+ domainRow(domain)
+ }
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func summaryStat(_ value: Int, _ label: String, _ color: Color) -> some View {
+ VStack(alignment: .leading, spacing: 1) {
+ Text("\(value)")
+ .font(.headline)
+ .foregroundStyle(color)
+ Text(label)
+ .font(.system(size: 9))
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func domainRow(_ domain: DomainDigWidgetDomain) -> some View {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(color(for: domain.status))
+ .frame(width: 8, height: 8)
+ if domain.isPinned {
+ Image(systemName: "pin.fill")
+ .font(.system(size: 8))
+ .foregroundStyle(.secondary)
+ }
+ Text(domain.domain)
+ .font(.caption)
+ .lineLimit(1)
+ Spacer(minLength: 4)
+ Text(certLabel(for: domain))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private func certLabel(for domain: DomainDigWidgetDomain) -> String {
+ guard let days = domain.certDaysRemaining else { return "—" }
+ if days < 0 { return "expired" }
+ return "\(days)d"
+ }
+
+ private func color(for status: DomainDigWidgetStatus) -> Color {
+ switch status {
+ case .healthy: return .green
+ case .warning: return .orange
+ case .critical: return .red
+ }
+ }
+}
diff --git a/DomainDigWidget/DomainDigWidget.entitlements b/DomainDigWidget/DomainDigWidget.entitlements
new file mode 100644
index 0000000..eaa20ba
--- /dev/null
+++ b/DomainDigWidget/DomainDigWidget.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/DomainDigWidget/DomainDigWidgetBundle.swift b/DomainDigWidget/DomainDigWidgetBundle.swift
new file mode 100644
index 0000000..483995c
--- /dev/null
+++ b/DomainDigWidget/DomainDigWidgetBundle.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import WidgetKit
+
+@main
+struct DomainDigWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ DomainDigPortfolioWidget()
+ }
+}
diff --git a/DomainDigWidget/Info.plist b/DomainDigWidget/Info.plist
new file mode 100644
index 0000000..d1b64db
--- /dev/null
+++ b/DomainDigWidget/Info.plist
@@ -0,0 +1,29 @@
+<?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>DomainDig</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>NSExtensionPointIdentifier</key>
+ <string>com.apple.widgetkit-extension</string>
+ </dict>
+</dict>
+</plist>
diff --git a/Shared/DomainDigDeepLink.swift b/Shared/DomainDigDeepLink.swift
new file mode 100644
index 0000000..ba1d64b
--- /dev/null
+++ b/Shared/DomainDigDeepLink.swift
@@ -0,0 +1,71 @@
+import Foundation
+
+/// Shared builder/parser for the `domaindig://` URL scheme, used by the intents
+/// and app (to open/route) and by the widget (to deep-link into a domain).
+///
+/// Lives in `Shared/` so it compiles into both the app and the widget target.
+/// It relies only on Foundation and no actor isolation, so it is safe in the
+/// widget extension (`APPLICATION_EXTENSION_API_ONLY`).
+enum DomainDigDeepLink {
+ static let scheme = "domaindig"
+
+ enum Action: Equatable {
+ case inspect(String)
+ case watch(String)
+ case detail(String)
+ case sweep
+
+ var host: String {
+ switch self {
+ case .inspect: return "inspect"
+ case .watch: return "watch"
+ case .detail: return "domain"
+ case .sweep: return "sweep"
+ }
+ }
+
+ /// The domain the action targets, if any. `.sweep` has no domain.
+ var domain: String? {
+ switch self {
+ case let .inspect(domain), let .watch(domain), let .detail(domain):
+ return domain
+ case .sweep:
+ return nil
+ }
+ }
+ }
+
+ static func url(for action: Action) -> URL {
+ var components = URLComponents()
+ components.scheme = scheme
+ components.host = action.host
+ if let domain = action.domain {
+ components.queryItems = [URLQueryItem(name: "domain", value: domain)]
+ }
+ // The scheme and host are fixed and any domain is percent-encoded by
+ // URLComponents, so this is always a valid URL.
+ return components.url!
+ }
+
+ static func action(from url: URL) -> Action? {
+ guard url.scheme == scheme else { return nil }
+
+ if url.host() == "sweep" {
+ return .sweep
+ }
+
+ let domain = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first { $0.name == "domain" }?
+ .value?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !domain.isEmpty else { return nil }
+
+ switch url.host() {
+ case "inspect": return .inspect(domain)
+ case "watch": return .watch(domain)
+ case "domain": return .detail(domain)
+ default: return nil
+ }
+ }
+}
diff --git a/Shared/DomainDigWidgetData.swift b/Shared/DomainDigWidgetData.swift
new file mode 100644
index 0000000..5ceae48
--- /dev/null
+++ b/Shared/DomainDigWidgetData.swift
@@ -0,0 +1,82 @@
+import Foundation
+
+/// App Group + snapshot shared between the app (writer) and the widget (reader).
+///
+/// Deliberately self-contained: it references no app-target types (TrackedDomain,
+/// PortfolioSnapshot, DomainHealth, …) so it compiles unchanged into the widget
+/// extension. The app maps its richer types into these DTOs.
+enum DomainDigWidgetStore {
+ static let appGroupID = "group.net.cleberg.DomainDig"
+ private static let key = "widgetData"
+
+ private static var defaults: UserDefaults? {
+ UserDefaults(suiteName: appGroupID)
+ }
+
+ static func write(_ data: DomainDigWidgetData) {
+ guard let defaults, let encoded = try? JSONEncoder().encode(data) else { return }
+ defaults.set(encoded, forKey: key)
+ }
+
+ static func read() -> DomainDigWidgetData? {
+ guard let defaults,
+ let encoded = defaults.data(forKey: key),
+ let data = try? JSONDecoder().decode(DomainDigWidgetData.self, from: encoded)
+ else { return nil }
+ return data
+ }
+}
+
+struct DomainDigWidgetData: Codable, Sendable {
+ var generatedAt: Date
+ var totalDomains: Int
+ var healthyCount: Int
+ var warningCount: Int
+ var criticalCount: Int
+ var expiringSoonCount: Int
+ var unreachableCount: Int
+ var domains: [DomainDigWidgetDomain]
+
+ static let placeholder = DomainDigWidgetData(
+ generatedAt: .distantPast,
+ totalDomains: 3,
+ healthyCount: 2,
+ warningCount: 1,
+ criticalCount: 0,
+ expiringSoonCount: 1,
+ unreachableCount: 0,
+ domains: [
+ DomainDigWidgetDomain(domain: "example.com", isPinned: true, status: .healthy, certDaysRemaining: 240, lastChange: nil),
+ DomainDigWidgetDomain(domain: "cleberg.net", isPinned: false, status: .warning, certDaysRemaining: 21, lastChange: nil),
+ DomainDigWidgetDomain(domain: "example.org", isPinned: false, status: .healthy, certDaysRemaining: 88, lastChange: nil)
+ ]
+ )
+
+ /// Empty state used before the app has written any data.
+ static let empty = DomainDigWidgetData(
+ generatedAt: .distantPast,
+ totalDomains: 0,
+ healthyCount: 0,
+ warningCount: 0,
+ criticalCount: 0,
+ expiringSoonCount: 0,
+ unreachableCount: 0,
+ domains: []
+ )
+}
+
+struct DomainDigWidgetDomain: Codable, Sendable, Identifiable {
+ var domain: String
+ var isPinned: Bool
+ var status: DomainDigWidgetStatus
+ var certDaysRemaining: Int?
+ var lastChange: Date?
+
+ var id: String { domain }
+}
+
+enum DomainDigWidgetStatus: String, Codable, Sendable {
+ case healthy
+ case warning
+ case critical
+}