Commit 20d2eaf9da

20d2eaf9da95f2ccc82413c9fa02e8b0f39af575

parent: d8195992e5

Verified · cmc

cmc <hello@cleberg.net> · 2026-07-17 04:49 UTC

DomainDig v4.5.0: Add App Intents, Shortcuts, and a domaindig:// deep link

- Add InspectDomainIntent that runs the headless inspection pipeline and
  returns a summary for Shortcuts, Spotlight, the Action button, and Siri
- Add AddToWatchlistIntent that opens the app and tracks a domain through the
  existing premium-checked path via an in-process router
- Register the domaindig:// URL scheme with inspect/watch deep links routed in
  RootTabView (onOpenURL)
- Expose both intents through DomainDigShortcuts (AppShortcutsProvider)
- Bump AppVersion/marketing version to 4.5.0 and build number to 37

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -306,7 +306,7 @@
306306 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
307307 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
308308 CODE_SIGN_STYLE = Automatic;
309 CURRENT_PROJECT_VERSION = 36;
309 CURRENT_PROJECT_VERSION = 37;
310310 DEVELOPMENT_TEAM = ZCNAX3VL9D;
311311 ENABLE_PREVIEWS = YES;
312312 GENERATE_INFOPLIST_FILE = YES;
@@ -323,7 +323,7 @@
323323 "$(inherited)",
324324 "@executable_path/Frameworks",
325325 );
326 MARKETING_VERSION = 4.4.1;
326 MARKETING_VERSION = 4.5.0;
327327 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
328328 PRODUCT_NAME = "$(TARGET_NAME)";
329329 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -343,7 +343,7 @@
343343 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
344344 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
345345 CODE_SIGN_STYLE = Automatic;
346 CURRENT_PROJECT_VERSION = 36;
346 CURRENT_PROJECT_VERSION = 37;
347347 DEVELOPMENT_TEAM = ZCNAX3VL9D;
348348 ENABLE_PREVIEWS = YES;
349349 GENERATE_INFOPLIST_FILE = YES;
@@ -360,7 +360,7 @@
360360 "$(inherited)",
361361 "@executable_path/Frameworks",
362362 );
363 MARKETING_VERSION = 4.4.1;
363 MARKETING_VERSION = 4.5.0;
364364 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
365365 PRODUCT_NAME = "$(TARGET_NAME)";
366366 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
22
33enum AppVersion {
44 nonisolated static var current: String {
5 "4.4.1"
5 "4.5.0"
66 }
77}
DomainDig/DomainDigIntents.swift added +209
@@ -0,0 +1,209 @@
1import AppIntents
2import Foundation
3
4/// App Intent that runs a point-in-time domain inspection through the same
5/// headless pipeline used by the CLI (`DomainInspectionService` ->
6/// `DomainReportBuilder`) and returns a concise summary. Usable from
7/// Shortcuts, Spotlight, the Action button, and Siri.
8struct InspectDomainIntent: AppIntent {
9 static var title: LocalizedStringResource = "Inspect Domain"
10 static var description = IntentDescription(
11 "Run a DomainDig inspection and return a summary of availability, risk, TLS, email security, and certificate health."
12 )
13
14 // Read-only inspection; no need to foreground the app.
15 static var openAppWhenRun = false
16
17 @Parameter(
18 title: "Domain",
19 description: "The domain to inspect, e.g. example.com",
20 inputOptions: String.IntentInputOptions(
21 keyboardType: .URL,
22 capitalizationType: .none
23 )
24 )
25 var domain: String
26
27 static var parameterSummary: some ParameterSummary {
28 Summary("Inspect \(\.$domain)")
29 }
30
31 func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
32 let requested = domain.trimmingCharacters(in: .whitespacesAndNewlines)
33 guard !requested.isEmpty else {
34 throw InspectDomainError.emptyDomain
35 }
36
37 let snapshot = await DomainInspectionService().inspectSnapshot(domain: requested)
38 let report = DomainReportBuilder().build(from: snapshot)
39
40 let summary = Self.summaryText(for: report)
41 let dialog = IntentDialog(stringLiteral: Self.spokenSummary(for: report))
42 return .result(value: summary, dialog: dialog)
43 }
44
45 /// Multi-line summary suitable for a returned Shortcuts text value.
46 static func summaryText(for report: DomainReport) -> String {
47 let dnssec: String
48 switch report.dns.dnssecSigned {
49 case true?: dnssec = "Yes"
50 case false?: dnssec = "No"
51 case nil: dnssec = "Unknown"
52 }
53
54 var lines = [
55 "\(report.domain) — \(report.availability.rawValue.capitalized)",
56 "Risk: \(report.riskAssessment.level.title) (score \(report.riskAssessment.score))",
57 "Health: \(report.health.title)",
58 "TLS: \(report.web.tlsGrade.rawValue) · Email: \(report.email.grade?.rawValue ?? "—") · Cert: \(report.certificateExpiryState.title)",
59 "IP: \(report.dns.primaryIP ?? "unknown") · DNSSEC: \(dnssec)"
60 ]
61
62 if let insight = report.insights.first {
63 lines.append(insight)
64 }
65
66 return lines.joined(separator: "\n")
67 }
68
69 /// Short spoken/dialog line for Siri and the Shortcuts result banner.
70 static func spokenSummary(for report: DomainReport) -> String {
71 "\(report.domain) is \(report.availability.rawValue). Risk \(report.riskAssessment.level.title.lowercased()), health \(report.health.title.lowercased())."
72 }
73}
74
75enum InspectDomainError: Error, CustomLocalizedStringResourceConvertible {
76 case emptyDomain
77
78 var localizedStringResource: LocalizedStringResource {
79 switch self {
80 case .emptyDomain:
81 return "Enter a domain to inspect."
82 }
83 }
84}
85
86/// App Intent that opens DomainDig and adds a domain to the watchlist. It opens
87/// the app via the `domaindig://watch` deep link so tracking goes through the
88/// existing view-model path (premium limits, monitoring, history linking,
89/// cloud-sync recording, and the paywall when over the free limit).
90struct AddToWatchlistIntent: AppIntent {
91 static var title: LocalizedStringResource = "Add Domain to Watchlist"
92 static var description = IntentDescription(
93 "Open DomainDig and add a domain to your watchlist."
94 )
95
96 static var openAppWhenRun = true
97
98 @Parameter(
99 title: "Domain",
100 description: "The domain to add, e.g. example.com",
101 inputOptions: String.IntentInputOptions(
102 keyboardType: .URL,
103 capitalizationType: .none
104 )
105 )
106 var domain: String
107
108 static var parameterSummary: some ParameterSummary {
109 Summary("Add \(\.$domain) to the watchlist")
110 }
111
112 @MainActor
113 func perform() async throws -> some IntentResult {
114 let requested = domain.trimmingCharacters(in: .whitespacesAndNewlines)
115 guard !requested.isEmpty else {
116 throw InspectDomainError.emptyDomain
117 }
118
119 // `openAppWhenRun` runs this in the app process, so the router hands the
120 // action off to the running UI, which tracks through the existing path.
121 DomainDigIntentRouter.shared.pendingAction = .watch(requested)
122 return .result()
123 }
124}
125
126/// In-process hand-off from an `openAppWhenRun` intent to the running SwiftUI
127/// layer. `RootTabView` observes `pendingAction` and performs it.
128@MainActor
129@Observable
130final class DomainDigIntentRouter {
131 static let shared = DomainDigIntentRouter()
132 var pendingAction: DomainDigDeepLink.Action?
133 private init() {}
134}
135
136/// Shared builder/parser for the `domaindig://` URL scheme, used by both the
137/// intents (to open the app) and the app (to route incoming links).
138enum DomainDigDeepLink {
139 static let scheme = "domaindig"
140
141 enum Action: Equatable {
142 case inspect(String)
143 case watch(String)
144
145 var host: String {
146 switch self {
147 case .inspect: return "inspect"
148 case .watch: return "watch"
149 }
150 }
151
152 var domain: String {
153 switch self {
154 case let .inspect(domain), let .watch(domain): return domain
155 }
156 }
157 }
158
159 static func url(for action: Action) -> URL {
160 var components = URLComponents()
161 components.scheme = scheme
162 components.host = action.host
163 components.queryItems = [URLQueryItem(name: "domain", value: action.domain)]
164 // The scheme and host are fixed and the domain is percent-encoded by
165 // URLComponents, so this is always a valid URL.
166 return components.url!
167 }
168
169 static func action(from url: URL) -> Action? {
170 guard url.scheme == scheme else { return nil }
171
172 let domain = URLComponents(url: url, resolvingAgainstBaseURL: false)?
173 .queryItems?
174 .first { $0.name == "domain" }?
175 .value?
176 .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
177 guard !domain.isEmpty else { return nil }
178
179 switch url.host() {
180 case "inspect": return .inspect(domain)
181 case "watch": return .watch(domain)
182 default: return nil
183 }
184 }
185}
186
187/// Exposes DomainDig intents to Spotlight and Siri with invocation phrases.
188struct DomainDigShortcuts: AppShortcutsProvider {
189 static var appShortcuts: [AppShortcut] {
190 AppShortcut(
191 intent: InspectDomainIntent(),
192 phrases: [
193 "Inspect a domain with \(.applicationName)",
194 "Dig a domain with \(.applicationName)"
195 ],
196 shortTitle: "Inspect Domain",
197 systemImageName: "magnifyingglass"
198 )
199 AppShortcut(
200 intent: AddToWatchlistIntent(),
201 phrases: [
202 "Add a domain to \(.applicationName)",
203 "Watch a domain with \(.applicationName)"
204 ],
205 shortTitle: "Add to Watchlist",
206 systemImageName: "plus.circle"
207 )
208 }
209}
DomainDig/Info.plist +11
@@ -10,6 +10,17 @@
1010 <array>
1111 <string>net.cleberg.DomainDig.monitor.refresh</string>
1212 </array>
13 <key>CFBundleURLTypes</key>
14 <array>
15 <dict>
16 <key>CFBundleURLName</key>
17 <string>net.cleberg.DomainDig</string>
18 <key>CFBundleURLSchemes</key>
19 <array>
20 <string>domaindig</string>
21 </array>
22 </dict>
23 </array>
1324 <key>CKSharingSupported</key>
1425 <true/>
1526 <key>UIBackgroundModes</key>
DomainDig/RootTabView.swift +32
@@ -11,6 +11,7 @@ private enum RootTab: Hashable {
1111struct RootTabView: View {
1212 @Bindable var viewModel: DomainViewModel
1313 @State private var purchaseService = PurchaseService.shared
14 @State private var intentRouter = DomainDigIntentRouter.shared
1415 @State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard
1516
1617 var body: some View {
@@ -82,5 +83,36 @@ struct RootTabView: View {
8283 selectedTab = .dashboard
8384 }
8485 }
86 .onOpenURL { url in
87 guard let action = DomainDigDeepLink.action(from: url) else { return }
88 perform(action)
89 }
90 .onChange(of: intentRouter.pendingAction) { _, action in
91 consume(action)
92 }
93 .task {
94 consume(intentRouter.pendingAction)
95 }
96 }
97
98 private func consume(_ action: DomainDigDeepLink.Action?) {
99 guard let action else { return }
100 intentRouter.pendingAction = nil
101 perform(action)
102 }
103
104 private func perform(_ action: DomainDigDeepLink.Action) {
105 switch action {
106 case let .inspect(domain):
107 viewModel.domain = domain
108 selectedTab = .inspect
109 viewModel.run()
110 case let .watch(domain):
111 // On success show the dashboard (where tracked domains live); on
112 // failure stay put so the upgrade/paywall alert surfaces in place.
113 if viewModel.trackDomain(domain: domain, availabilityStatus: nil) {
114 selectedTab = .dashboard
115 }
116 }
85117 }
86118}