| @@ -0,0 +1,209 @@ |
| |
1 | import AppIntents |
| |
2 | import 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. |
| |
8 | struct 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 | |
| |
75 | enum 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). |
| |
90 | struct 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 |
| |
130 | final 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). |
| |
138 | enum 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. |
| |
188 | struct 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 | } |