krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

d3a27d06809e6744c092f9bed4bf0537843d0210

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T05:27:45Z

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.
 DomainDig.xcodeproj/project.pbxproj                | 173 +++++++++++++++++++
 .../xcschemes/xcschememanagement.plist             |   5 +
 DomainDig/DomainDig.entitlements                   |   4 +
 DomainDig/DomainDigApp.swift                       |   2 +
 DomainDig/DomainDigIntents.swift                   |  78 +++------
 DomainDig/DomainViewModel+Widget.swift             |  60 +++++++
 DomainDig/DomainViewModel.swift                    |   1 +
 DomainDig/RootTabView.swift                        |  19 +++
 DomainDigWidget/DomainDigPortfolioWidget.swift     | 189 +++++++++++++++++++++
 DomainDigWidget/DomainDigWidget.entitlements       |  10 ++
 DomainDigWidget/DomainDigWidgetBundle.swift        |   9 +
 DomainDigWidget/Info.plist                         |  29 ++++
 Shared/DomainDigDeepLink.swift                     |  71 ++++++++
 Shared/DomainDigWidgetData.swift                   |  82 +++++++++
 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
+}