Commit d3a27d0680

d3a27d06809e6744c092f9bed4bf0537843d0210

parent: 685f84ad0a

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 05:27 UTC

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.

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +173
@@ -14,8 +14,33 @@
1414 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; };
1515 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; };
1616 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; };
17 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1718/* End PBXBuildFile section */
1819
20/* Begin PBXContainerItemProxy section */
21 8BDA00010000000000000003 /* PBXContainerItemProxy */ = {
22 isa = PBXContainerItemProxy;
23 containerPortal = 8B7800612F6090E300933221 /* Project object */;
24 proxyType = 1;
25 remoteGlobalIDString = 8BDA00010000000000000006;
26 remoteInfo = DomainDigWidgetExtension;
27 };
28/* End PBXContainerItemProxy section */
29
30/* Begin PBXCopyFilesBuildPhase section */
31 8BDA00010000000000000005 /* Embed Foundation Extensions */ = {
32 isa = PBXCopyFilesBuildPhase;
33 buildActionMask = 2147483647;
34 dstPath = "";
35 dstSubfolderSpec = 13;
36 files = (
37 8BDA00010000000000000002 /* DomainDigWidgetExtension.appex in Embed Foundation Extensions */,
38 );
39 name = "Embed Foundation Extensions";
40 runOnlyForDeploymentPostprocessing = 0;
41 };
42/* End PBXCopyFilesBuildPhase section */
43
1944/* Begin PBXFileReference section */
2045 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
2146 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
@@ -25,6 +50,7 @@
2550 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; };
2651 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; };
2752 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; };
53 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DomainDigWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
2854/* End PBXFileReference section */
2955
3056/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -37,6 +63,13 @@
3763 );
3864 target = 8B7800682F6090E300933221 /* DomainDig */;
3965 };
66 8BDA0001000000000000000F /* Exceptions for "DomainDigWidget" folder in "DomainDigWidgetExtension" target */ = {
67 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
68 membershipExceptions = (
69 Info.plist,
70 );
71 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */;
72 };
4073/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
4174
4275/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -48,6 +81,19 @@
4881 path = DomainDig;
4982 sourceTree = "<group>";
5083 };
84 8BDA0001000000000000000D /* Shared */ = {
85 isa = PBXFileSystemSynchronizedRootGroup;
86 path = Shared;
87 sourceTree = "<group>";
88 };
89 8BDA0001000000000000000E /* DomainDigWidget */ = {
90 isa = PBXFileSystemSynchronizedRootGroup;
91 exceptions = (
92 8BDA0001000000000000000F /* Exceptions for "DomainDigWidget" folder in "DomainDigWidgetExtension" target */,
93 );
94 path = DomainDigWidget;
95 sourceTree = "<group>";
96 };
5197/* End PBXFileSystemSynchronizedRootGroup section */
5298
5399/* Begin PBXFrameworksBuildPhase section */
@@ -58,6 +104,13 @@
58104 );
59105 runOnlyForDeploymentPostprocessing = 0;
60106 };
107 8BDA00010000000000000008 /* Frameworks */ = {
108 isa = PBXFrameworksBuildPhase;
109 buildActionMask = 2147483647;
110 files = (
111 );
112 runOnlyForDeploymentPostprocessing = 0;
113 };
61114/* End PBXFrameworksBuildPhase section */
62115
63116/* Begin PBXGroup section */
@@ -65,6 +118,8 @@
65118 isa = PBXGroup;
66119 children = (
67120 8B78006B2F6090E300933221 /* DomainDig */,
121 8BDA0001000000000000000D /* Shared */,
122 8BDA0001000000000000000E /* DomainDigWidget */,
68123 8B78006A2F6090E300933221 /* Products */,
69124 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
70125 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
@@ -80,6 +135,7 @@
80135 isa = PBXGroup;
81136 children = (
82137 8B7800692F6090E300933221 /* DomainDig.app */,
138 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */,
83139 );
84140 name = Products;
85141 sourceTree = "<group>";
@@ -94,13 +150,16 @@
94150 8B7800652F6090E300933221 /* Sources */,
95151 8B7800662F6090E300933221 /* Frameworks */,
96152 8B7800672F6090E300933221 /* Resources */,
153 8BDA00010000000000000005 /* Embed Foundation Extensions */,
97154 );
98155 buildRules = (
99156 );
100157 dependencies = (
158 8BDA00010000000000000004 /* PBXTargetDependency */,
101159 );
102160 fileSystemSynchronizedGroups = (
103161 8B78006B2F6090E300933221 /* DomainDig */,
162 8BDA0001000000000000000D /* Shared */,
104163 );
105164 name = DomainDig;
106165 packageProductDependencies = (
@@ -109,8 +168,39 @@
109168 productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
110169 productType = "com.apple.product-type.application";
111170 };
171 8BDA00010000000000000006 /* DomainDigWidgetExtension */ = {
172 isa = PBXNativeTarget;
173 buildConfigurationList = 8BDA0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigWidgetExtension" */;
174 buildPhases = (
175 8BDA00010000000000000007 /* Sources */,
176 8BDA00010000000000000008 /* Frameworks */,
177 8BDA00010000000000000009 /* Resources */,
178 );
179 buildRules = (
180 );
181 dependencies = (
182 );
183 fileSystemSynchronizedGroups = (
184 8BDA0001000000000000000D /* Shared */,
185 8BDA0001000000000000000E /* DomainDigWidget */,
186 );
187 name = DomainDigWidgetExtension;
188 packageProductDependencies = (
189 );
190 productName = DomainDigWidgetExtension;
191 productReference = 8BDA00010000000000000001 /* DomainDigWidgetExtension.appex */;
192 productType = "com.apple.product-type.app-extension";
193 };
112194/* End PBXNativeTarget section */
113195
196/* Begin PBXTargetDependency section */
197 8BDA00010000000000000004 /* PBXTargetDependency */ = {
198 isa = PBXTargetDependency;
199 target = 8BDA00010000000000000006 /* DomainDigWidgetExtension */;
200 targetProxy = 8BDA00010000000000000003 /* PBXContainerItemProxy */;
201 };
202/* End PBXTargetDependency section */
203
114204/* Begin PBXProject section */
115205 8B7800612F6090E300933221 /* Project object */ = {
116206 isa = PBXProject;
@@ -127,6 +217,9 @@
127217 };
128218 };
129219 };
220 8BDA00010000000000000006 = {
221 CreatedOnToolsVersion = 26.3;
222 };
130223 };
131224 };
132225 buildConfigurationList = 8B7800642F6090E300933221 /* Build configuration list for PBXProject "DomainDig" */;
@@ -144,6 +237,7 @@
144237 projectRoot = "";
145238 targets = (
146239 8B7800682F6090E300933221 /* DomainDig */,
240 8BDA00010000000000000006 /* DomainDigWidgetExtension */,
147241 );
148242 };
149243/* End PBXProject section */
@@ -156,6 +250,13 @@
156250 );
157251 runOnlyForDeploymentPostprocessing = 0;
158252 };
253 8BDA00010000000000000009 /* Resources */ = {
254 isa = PBXResourcesBuildPhase;
255 buildActionMask = 2147483647;
256 files = (
257 );
258 runOnlyForDeploymentPostprocessing = 0;
259 };
159260/* End PBXResourcesBuildPhase section */
160261
161262/* Begin PBXSourcesBuildPhase section */
@@ -173,6 +274,13 @@
173274 );
174275 runOnlyForDeploymentPostprocessing = 0;
175276 };
277 8BDA00010000000000000007 /* Sources */ = {
278 isa = PBXSourcesBuildPhase;
279 buildActionMask = 2147483647;
280 files = (
281 );
282 runOnlyForDeploymentPostprocessing = 0;
283 };
176284/* End PBXSourcesBuildPhase section */
177285
178286/* Begin XCBuildConfiguration section */
@@ -373,6 +481,62 @@
373481 };
374482 name = Release;
375483 };
484 8BDA0001000000000000000B /* Debug */ = {
485 isa = XCBuildConfiguration;
486 buildSettings = {
487 APPLICATION_EXTENSION_API_ONLY = YES;
488 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
489 CODE_SIGN_STYLE = Automatic;
490 CURRENT_PROJECT_VERSION = 37;
491 DEVELOPMENT_TEAM = ZCNAX3VL9D;
492 GENERATE_INFOPLIST_FILE = NO;
493 INFOPLIST_FILE = DomainDigWidget/Info.plist;
494 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
495 LD_RUNPATH_SEARCH_PATHS = (
496 "$(inherited)",
497 "@executable_path/Frameworks",
498 "@executable_path/../../Frameworks",
499 );
500 MARKETING_VERSION = 4.5.0;
501 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
502 PRODUCT_NAME = "$(TARGET_NAME)";
503 SKIP_INSTALL = YES;
504 SWIFT_APPROACHABLE_CONCURRENCY = YES;
505 SWIFT_EMIT_LOC_STRINGS = YES;
506 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
507 SWIFT_VERSION = 5.0;
508 TARGETED_DEVICE_FAMILY = "1,2";
509 };
510 name = Debug;
511 };
512 8BDA0001000000000000000C /* Release */ = {
513 isa = XCBuildConfiguration;
514 buildSettings = {
515 APPLICATION_EXTENSION_API_ONLY = YES;
516 CODE_SIGN_ENTITLEMENTS = DomainDigWidget/DomainDigWidget.entitlements;
517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 37;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 GENERATE_INFOPLIST_FILE = NO;
521 INFOPLIST_FILE = DomainDigWidget/Info.plist;
522 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
523 LD_RUNPATH_SEARCH_PATHS = (
524 "$(inherited)",
525 "@executable_path/Frameworks",
526 "@executable_path/../../Frameworks",
527 );
528 MARKETING_VERSION = 4.5.0;
529 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig.DomainDigWidget;
530 PRODUCT_NAME = "$(TARGET_NAME)";
531 SKIP_INSTALL = YES;
532 SWIFT_APPROACHABLE_CONCURRENCY = YES;
533 SWIFT_EMIT_LOC_STRINGS = YES;
534 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
535 SWIFT_VERSION = 5.0;
536 TARGETED_DEVICE_FAMILY = "1,2";
537 };
538 name = Release;
539 };
376540/* End XCBuildConfiguration section */
377541
378542/* Begin XCConfigurationList section */
@@ -394,6 +558,15 @@
394558 defaultConfigurationIsVisible = 0;
395559 defaultConfigurationName = Release;
396560 };
561 8BDA0001000000000000000A /* Build configuration list for PBXNativeTarget "DomainDigWidgetExtension" */ = {
562 isa = XCConfigurationList;
563 buildConfigurations = (
564 8BDA0001000000000000000B /* Debug */,
565 8BDA0001000000000000000C /* Release */,
566 );
567 defaultConfigurationIsVisible = 0;
568 defaultConfigurationName = Release;
569 };
397570/* End XCConfigurationList section */
398571 };
399572 rootObject = 8B7800612F6090E300933221 /* Project object */;
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +5
@@ -9,6 +9,11 @@
99 <key>orderHint</key>
1010 <integer>0</integer>
1111 </dict>
12 <key>DomainDigWidgetExtension.xcscheme_^#shared#^_</key>
13 <dict>
14 <key>orderHint</key>
15 <integer>1</integer>
16 </dict>
1217 </dict>
1318 <key>SuppressBuildableAutocreation</key>
1419 <dict>
DomainDig/DomainDig.entitlements +4
@@ -10,5 +10,9 @@
1010 <array>
1111 <string>CloudKit</string>
1212 </array>
13 <key>com.apple.security.application-groups</key>
14 <array>
15 <string>group.net.cleberg.DomainDig</string>
16 </array>
1317</dict>
1418</plist>
DomainDig/DomainDigApp.swift +2
@@ -39,6 +39,7 @@ struct DomainDigApp: App {
3939 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
4040 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
4141 IntegrationService.shared.processQueueNow()
42 viewModel.refreshWidgetData()
4243 }
4344 .onReceive(NotificationCenter.default.publisher(for: .cloudSyncDidApplyChanges)) { _ in
4445 viewModel.refreshPersistedData()
@@ -57,6 +58,7 @@ struct DomainDigApp: App {
5758 cloudSyncService.scheduleSyncIfNeeded(trigger: .launch)
5859 viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule()
5960 IntegrationService.shared.processQueueNow()
61 viewModel.refreshWidgetData()
6062 }
6163 }
6264}
DomainDig/DomainDigIntents.swift +27 −51
@@ -124,6 +124,24 @@ struct AddToWatchlistIntent: AppIntent {
124124 }
125125}
126126
127/// App Intent that opens DomainDig and re-inspects every tracked domain. It runs
128/// through the existing view-model batch path (`refreshAllTrackedDomains`), which
129/// enforces the batch feature gate and surfaces the paywall when needed.
130struct RunSweepIntent: AppIntent {
131 static var title: LocalizedStringResource = "Run Watchlist Sweep"
132 static var description = IntentDescription(
133 "Open DomainDig and re-inspect every domain on your watchlist."
134 )
135
136 static var openAppWhenRun = true
137
138 @MainActor
139 func perform() async throws -> some IntentResult {
140 DomainDigIntentRouter.shared.pendingAction = .sweep
141 return .result()
142 }
143}
144
127145/// In-process hand-off from an `openAppWhenRun` intent to the running SwiftUI
128146/// layer. `RootTabView` observes `pendingAction` and performs it.
129147@MainActor
@@ -134,57 +152,6 @@ final class DomainDigIntentRouter {
134152 private init() {}
135153}
136154
137/// Shared builder/parser for the `domaindig://` URL scheme, used by both the
138/// intents (to open the app) and the app (to route incoming links).
139enum DomainDigDeepLink {
140 static let scheme = "domaindig"
141
142 enum Action: Equatable {
143 case inspect(String)
144 case watch(String)
145
146 var host: String {
147 switch self {
148 case .inspect: return "inspect"
149 case .watch: return "watch"
150 }
151 }
152
153 var domain: String {
154 switch self {
155 case let .inspect(domain), let .watch(domain): return domain
156 }
157 }
158 }
159
160 static func url(for action: Action) -> URL {
161 var components = URLComponents()
162 components.scheme = scheme
163 components.host = action.host
164 components.queryItems = [URLQueryItem(name: "domain", value: action.domain)]
165 // The scheme and host are fixed and the domain is percent-encoded by
166 // URLComponents, so this is always a valid URL.
167 return components.url!
168 }
169
170 static func action(from url: URL) -> Action? {
171 guard url.scheme == scheme else { return nil }
172
173 let domain = URLComponents(url: url, resolvingAgainstBaseURL: false)?
174 .queryItems?
175 .first { $0.name == "domain" }?
176 .value?
177 .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
178 guard !domain.isEmpty else { return nil }
179
180 switch url.host() {
181 case "inspect": return .inspect(domain)
182 case "watch": return .watch(domain)
183 default: return nil
184 }
185 }
186}
187
188155/// Exposes DomainDig intents to Spotlight and Siri with invocation phrases.
189156struct DomainDigShortcuts: AppShortcutsProvider {
190157 static var appShortcuts: [AppShortcut] {
@@ -206,5 +173,14 @@ struct DomainDigShortcuts: AppShortcutsProvider {
206173 shortTitle: "Add to Watchlist",
207174 systemImageName: "plus.circle"
208175 )
176 AppShortcut(
177 intent: RunSweepIntent(),
178 phrases: [
179 "Run a sweep with \(.applicationName)",
180 "Sweep my \(.applicationName) watchlist"
181 ],
182 shortTitle: "Run Sweep",
183 systemImageName: "arrow.trianglehead.2.clockwise"
184 )
209185 }
210186}
DomainDig/DomainViewModel+Widget.swift added +60
@@ -0,0 +1,60 @@
1import Foundation
2import WidgetKit
3
4extension DomainViewModel {
5 /// Publishes the current portfolio state to the App Group container so the
6 /// widget can render it, then asks WidgetKit to refresh its timelines.
7 func refreshWidgetData() {
8 let data = portfolioDashboardData
9 let snapshot = data.snapshot
10
11 let ordered = data.domainStates.sorted { lhs, rhs in
12 if lhs.trackedDomain.isPinned != rhs.trackedDomain.isPinned {
13 return lhs.trackedDomain.isPinned
14 }
15 return lhs.health.widgetSeverityRank > rhs.health.widgetSeverityRank
16 }
17
18 let domains = ordered.prefix(6).map { state in
19 DomainDigWidgetDomain(
20 domain: state.trackedDomain.domain,
21 isPinned: state.trackedDomain.isPinned,
22 status: state.health.widgetStatus,
23 certDaysRemaining: state.certificateDaysRemaining,
24 lastChange: state.lastChangeDate
25 )
26 }
27
28 DomainDigWidgetStore.write(
29 DomainDigWidgetData(
30 generatedAt: Date(),
31 totalDomains: snapshot.totalDomains,
32 healthyCount: snapshot.healthyCount,
33 warningCount: snapshot.warningCount,
34 criticalCount: snapshot.criticalCount,
35 expiringSoonCount: snapshot.expiringSoonCount,
36 unreachableCount: snapshot.unreachableCount,
37 domains: Array(domains)
38 )
39 )
40 WidgetCenter.shared.reloadAllTimelines()
41 }
42}
43
44private extension DomainHealth {
45 var widgetStatus: DomainDigWidgetStatus {
46 switch self {
47 case .healthy: return .healthy
48 case .warning: return .warning
49 case .critical: return .critical
50 }
51 }
52
53 var widgetSeverityRank: Int {
54 switch self {
55 case .healthy: return 0
56 case .warning: return 1
57 case .critical: return 2
58 }
59 }
60}
DomainDig/DomainViewModel.swift +1
@@ -2582,6 +2582,7 @@ final class DomainViewModel {
25822582 DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
25832583 CloudSyncService.shared.scheduleSyncIfNeeded()
25842584 refreshDataLifecycleSummary()
2585 refreshWidgetData()
25852586 }
25862587
25872588 private func beginBulkPersistenceDeferral() {
DomainDig/RootTabView.swift +19
@@ -12,6 +12,7 @@ struct RootTabView: View {
1212 @Bindable var viewModel: DomainViewModel
1313 @State private var purchaseService = PurchaseService.shared
1414 @State private var intentRouter = DomainDigIntentRouter.shared
15 @State private var detailDomain: TrackedDomain?
1516 @State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard
1617
1718 var body: some View {
@@ -83,6 +84,11 @@ struct RootTabView: View {
8384 selectedTab = .dashboard
8485 }
8586 }
87 .sheet(item: $detailDomain) { trackedDomain in
88 NavigationStack {
89 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
90 }
91 }
8692 .onOpenURL { url in
8793 guard let action = DomainDigDeepLink.action(from: url) else { return }
8894 perform(action)
@@ -113,6 +119,19 @@ struct RootTabView: View {
113119 if viewModel.trackDomain(domain: domain, availabilityStatus: nil) {
114120 selectedTab = .dashboard
115121 }
122 case let .detail(domain):
123 // Open the tracked domain's detail (e.g. from a widget tap). If it is
124 // no longer tracked, fall back to inspecting it.
125 if let tracked = viewModel.trackedDomains.first(
126 where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
127 ) {
128 detailDomain = tracked
129 } else {
130 perform(.inspect(domain))
131 }
132 case .sweep:
133 selectedTab = .dashboard
134 viewModel.refreshAllTrackedDomains()
116135 }
117136 }
118137}
DomainDigWidget/DomainDigPortfolioWidget.swift added +189
@@ -0,0 +1,189 @@
1import SwiftUI
2import WidgetKit
3
4struct DomainDigEntry: TimelineEntry {
5 let date: Date
6 let data: DomainDigWidgetData
7}
8
9struct DomainDigProvider: TimelineProvider {
10 func placeholder(in context: Context) -> DomainDigEntry {
11 DomainDigEntry(date: Date(), data: .placeholder)
12 }
13
14 func getSnapshot(in context: Context, completion: @escaping (DomainDigEntry) -> Void) {
15 let data = context.isPreview ? .placeholder : (DomainDigWidgetStore.read() ?? .placeholder)
16 completion(DomainDigEntry(date: Date(), data: data))
17 }
18
19 func getTimeline(in context: Context, completion: @escaping (Timeline<DomainDigEntry>) -> Void) {
20 let data = DomainDigWidgetStore.read() ?? .empty
21 let entry = DomainDigEntry(date: Date(), data: data)
22 // The app reloads timelines on foreground and on watchlist changes; this
23 // periodic refresh is a backstop so cert countdowns stay roughly current.
24 let next = Calendar.current.date(byAdding: .hour, value: 6, to: Date())
25 ?? Date().addingTimeInterval(6 * 3600)
26 completion(Timeline(entries: [entry], policy: .after(next)))
27 }
28}
29
30struct DomainDigPortfolioWidget: Widget {
31 let kind = "DomainDigPortfolioWidget"
32
33 var body: some WidgetConfiguration {
34 StaticConfiguration(kind: kind, provider: DomainDigProvider()) { entry in
35 DomainDigWidgetView(data: entry.data)
36 .containerBackground(.fill.tertiary, for: .widget)
37 }
38 .configurationDisplayName("Domain Portfolio")
39 .description("Health and certificate status for your tracked domains.")
40 .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
41 }
42}
43
44struct DomainDigWidgetView: View {
45 @Environment(\.widgetFamily) private var family
46 let data: DomainDigWidgetData
47
48 var body: some View {
49 if data.totalDomains == 0 {
50 emptyState
51 } else {
52 switch family {
53 case .systemSmall:
54 smallView
55 default:
56 mediumOrLargeView
57 }
58 }
59 }
60
61 private var emptyState: some View {
62 VStack(spacing: 6) {
63 Image(systemName: "magnifyingglass")
64 .font(.title2)
65 .foregroundStyle(.secondary)
66 Text("No tracked domains")
67 .font(.caption)
68 .foregroundStyle(.secondary)
69 .multilineTextAlignment(.center)
70 }
71 }
72
73 // MARK: Small
74
75 private var smallView: some View {
76 VStack(alignment: .leading, spacing: 8) {
77 HStack(spacing: 4) {
78 Image(systemName: "shield.lefthalf.filled")
79 Text("DomainDig")
80 .fontWeight(.semibold)
81 Spacer()
82 }
83 .font(.caption2)
84 .foregroundStyle(.secondary)
85
86 Text("\(data.totalDomains)")
87 .font(.system(size: 34, weight: .bold, design: .rounded))
88 Text("tracked")
89 .font(.caption2)
90 .foregroundStyle(.secondary)
91
92 Spacer(minLength: 0)
93
94 HStack(spacing: 10) {
95 countPill(data.healthyCount, .green)
96 countPill(data.warningCount, .orange)
97 countPill(data.criticalCount, .red)
98 }
99 }
100 }
101
102 private func countPill(_ value: Int, _ color: Color) -> some View {
103 HStack(spacing: 3) {
104 Circle().fill(color).frame(width: 7, height: 7)
105 Text("\(value)").font(.caption).fontWeight(.medium)
106 }
107 }
108
109 // MARK: Medium / Large
110
111 private var mediumOrLargeView: some View {
112 VStack(alignment: .leading, spacing: 10) {
113 HStack {
114 Label("Domain Portfolio", systemImage: "shield.lefthalf.filled")
115 .font(.caption)
116 .fontWeight(.semibold)
117 .foregroundStyle(.secondary)
118 Spacer()
119 Text("\(data.totalDomains) tracked")
120 .font(.caption2)
121 .foregroundStyle(.secondary)
122 }
123
124 HStack(spacing: 12) {
125 summaryStat(data.healthyCount, "Healthy", .green)
126 summaryStat(data.warningCount, "Warning", .orange)
127 summaryStat(data.criticalCount, "Critical", .red)
128 summaryStat(data.expiringSoonCount, "Expiring", .yellow)
129 }
130
131 Divider()
132
133 VStack(spacing: 6) {
134 ForEach(data.domains.prefix(family == .systemLarge ? 6 : 3)) { domain in
135 Link(destination: DomainDigDeepLink.url(for: .detail(domain.domain))) {
136 domainRow(domain)
137 }
138 }
139 }
140 Spacer(minLength: 0)
141 }
142 }
143
144 private func summaryStat(_ value: Int, _ label: String, _ color: Color) -> some View {
145 VStack(alignment: .leading, spacing: 1) {
146 Text("\(value)")
147 .font(.headline)
148 .foregroundStyle(color)
149 Text(label)
150 .font(.system(size: 9))
151 .foregroundStyle(.secondary)
152 }
153 .frame(maxWidth: .infinity, alignment: .leading)
154 }
155
156 private func domainRow(_ domain: DomainDigWidgetDomain) -> some View {
157 HStack(spacing: 6) {
158 Circle()
159 .fill(color(for: domain.status))
160 .frame(width: 8, height: 8)
161 if domain.isPinned {
162 Image(systemName: "pin.fill")
163 .font(.system(size: 8))
164 .foregroundStyle(.secondary)
165 }
166 Text(domain.domain)
167 .font(.caption)
168 .lineLimit(1)
169 Spacer(minLength: 4)
170 Text(certLabel(for: domain))
171 .font(.caption2)
172 .foregroundStyle(.secondary)
173 }
174 }
175
176 private func certLabel(for domain: DomainDigWidgetDomain) -> String {
177 guard let days = domain.certDaysRemaining else { return "—" }
178 if days < 0 { return "expired" }
179 return "\(days)d"
180 }
181
182 private func color(for status: DomainDigWidgetStatus) -> Color {
183 switch status {
184 case .healthy: return .green
185 case .warning: return .orange
186 case .critical: return .red
187 }
188 }
189}
DomainDigWidget/DomainDigWidget.entitlements added +10
@@ -0,0 +1,10 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.security.application-groups</key>
6 <array>
7 <string>group.net.cleberg.DomainDig</string>
8 </array>
9</dict>
10</plist>
DomainDigWidget/DomainDigWidgetBundle.swift added +9
@@ -0,0 +1,9 @@
1import SwiftUI
2import WidgetKit
3
4@main
5struct DomainDigWidgetBundle: WidgetBundle {
6 var body: some Widget {
7 DomainDigPortfolioWidget()
8 }
9}
DomainDigWidget/Info.plist added +29
@@ -0,0 +1,29 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleDevelopmentRegion</key>
6 <string>$(DEVELOPMENT_LANGUAGE)</string>
7 <key>CFBundleDisplayName</key>
8 <string>DomainDig</string>
9 <key>CFBundleExecutable</key>
10 <string>$(EXECUTABLE_NAME)</string>
11 <key>CFBundleIdentifier</key>
12 <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13 <key>CFBundleInfoDictionaryVersion</key>
14 <string>6.0</string>
15 <key>CFBundleName</key>
16 <string>$(PRODUCT_NAME)</string>
17 <key>CFBundlePackageType</key>
18 <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
19 <key>CFBundleShortVersionString</key>
20 <string>$(MARKETING_VERSION)</string>
21 <key>CFBundleVersion</key>
22 <string>$(CURRENT_PROJECT_VERSION)</string>
23 <key>NSExtension</key>
24 <dict>
25 <key>NSExtensionPointIdentifier</key>
26 <string>com.apple.widgetkit-extension</string>
27 </dict>
28</dict>
29</plist>
Shared/DomainDigDeepLink.swift added +71
@@ -0,0 +1,71 @@
1import Foundation
2
3/// Shared builder/parser for the `domaindig://` URL scheme, used by the intents
4/// and app (to open/route) and by the widget (to deep-link into a domain).
5///
6/// Lives in `Shared/` so it compiles into both the app and the widget target.
7/// It relies only on Foundation and no actor isolation, so it is safe in the
8/// widget extension (`APPLICATION_EXTENSION_API_ONLY`).
9enum DomainDigDeepLink {
10 static let scheme = "domaindig"
11
12 enum Action: Equatable {
13 case inspect(String)
14 case watch(String)
15 case detail(String)
16 case sweep
17
18 var host: String {
19 switch self {
20 case .inspect: return "inspect"
21 case .watch: return "watch"
22 case .detail: return "domain"
23 case .sweep: return "sweep"
24 }
25 }
26
27 /// The domain the action targets, if any. `.sweep` has no domain.
28 var domain: String? {
29 switch self {
30 case let .inspect(domain), let .watch(domain), let .detail(domain):
31 return domain
32 case .sweep:
33 return nil
34 }
35 }
36 }
37
38 static func url(for action: Action) -> URL {
39 var components = URLComponents()
40 components.scheme = scheme
41 components.host = action.host
42 if let domain = action.domain {
43 components.queryItems = [URLQueryItem(name: "domain", value: domain)]
44 }
45 // The scheme and host are fixed and any domain is percent-encoded by
46 // URLComponents, so this is always a valid URL.
47 return components.url!
48 }
49
50 static func action(from url: URL) -> Action? {
51 guard url.scheme == scheme else { return nil }
52
53 if url.host() == "sweep" {
54 return .sweep
55 }
56
57 let domain = URLComponents(url: url, resolvingAgainstBaseURL: false)?
58 .queryItems?
59 .first { $0.name == "domain" }?
60 .value?
61 .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
62 guard !domain.isEmpty else { return nil }
63
64 switch url.host() {
65 case "inspect": return .inspect(domain)
66 case "watch": return .watch(domain)
67 case "domain": return .detail(domain)
68 default: return nil
69 }
70 }
71}
Shared/DomainDigWidgetData.swift added +82
@@ -0,0 +1,82 @@
1import Foundation
2
3/// App Group + snapshot shared between the app (writer) and the widget (reader).
4///
5/// Deliberately self-contained: it references no app-target types (TrackedDomain,
6/// PortfolioSnapshot, DomainHealth, …) so it compiles unchanged into the widget
7/// extension. The app maps its richer types into these DTOs.
8enum DomainDigWidgetStore {
9 static let appGroupID = "group.net.cleberg.DomainDig"
10 private static let key = "widgetData"
11
12 private static var defaults: UserDefaults? {
13 UserDefaults(suiteName: appGroupID)
14 }
15
16 static func write(_ data: DomainDigWidgetData) {
17 guard let defaults, let encoded = try? JSONEncoder().encode(data) else { return }
18 defaults.set(encoded, forKey: key)
19 }
20
21 static func read() -> DomainDigWidgetData? {
22 guard let defaults,
23 let encoded = defaults.data(forKey: key),
24 let data = try? JSONDecoder().decode(DomainDigWidgetData.self, from: encoded)
25 else { return nil }
26 return data
27 }
28}
29
30struct DomainDigWidgetData: Codable, Sendable {
31 var generatedAt: Date
32 var totalDomains: Int
33 var healthyCount: Int
34 var warningCount: Int
35 var criticalCount: Int
36 var expiringSoonCount: Int
37 var unreachableCount: Int
38 var domains: [DomainDigWidgetDomain]
39
40 static let placeholder = DomainDigWidgetData(
41 generatedAt: .distantPast,
42 totalDomains: 3,
43 healthyCount: 2,
44 warningCount: 1,
45 criticalCount: 0,
46 expiringSoonCount: 1,
47 unreachableCount: 0,
48 domains: [
49 DomainDigWidgetDomain(domain: "example.com", isPinned: true, status: .healthy, certDaysRemaining: 240, lastChange: nil),
50 DomainDigWidgetDomain(domain: "cleberg.net", isPinned: false, status: .warning, certDaysRemaining: 21, lastChange: nil),
51 DomainDigWidgetDomain(domain: "example.org", isPinned: false, status: .healthy, certDaysRemaining: 88, lastChange: nil)
52 ]
53 )
54
55 /// Empty state used before the app has written any data.
56 static let empty = DomainDigWidgetData(
57 generatedAt: .distantPast,
58 totalDomains: 0,
59 healthyCount: 0,
60 warningCount: 0,
61 criticalCount: 0,
62 expiringSoonCount: 0,
63 unreachableCount: 0,
64 domains: []
65 )
66}
67
68struct DomainDigWidgetDomain: Codable, Sendable, Identifiable {
69 var domain: String
70 var isPinned: Bool
71 var status: DomainDigWidgetStatus
72 var certDaysRemaining: Int?
73 var lastChange: Date?
74
75 var id: String { domain }
76}
77
78enum DomainDigWidgetStatus: String, Codable, Sendable {
79 case healthy
80 case warning
81 case critical
82}