summaryrefslogtreecommitdiff
path: root/DomainDig/DomainDigIntents.swift
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig/DomainDigIntents.swift')
-rw-r--r--DomainDig/DomainDigIntents.swift209
1 files changed, 209 insertions, 0 deletions
diff --git a/DomainDig/DomainDigIntents.swift b/DomainDig/DomainDigIntents.swift
new file mode 100644
index 0000000..fb2e015
--- /dev/null
+++ b/DomainDig/DomainDigIntents.swift
@@ -0,0 +1,209 @@
+import AppIntents
+import Foundation
+
+/// App Intent that runs a point-in-time domain inspection through the same
+/// headless pipeline used by the CLI (`DomainInspectionService` ->
+/// `DomainReportBuilder`) and returns a concise summary. Usable from
+/// Shortcuts, Spotlight, the Action button, and Siri.
+struct InspectDomainIntent: AppIntent {
+ static var title: LocalizedStringResource = "Inspect Domain"
+ static var description = IntentDescription(
+ "Run a DomainDig inspection and return a summary of availability, risk, TLS, email security, and certificate health."
+ )
+
+ // Read-only inspection; no need to foreground the app.
+ static var openAppWhenRun = false
+
+ @Parameter(
+ title: "Domain",
+ description: "The domain to inspect, e.g. example.com",
+ inputOptions: String.IntentInputOptions(
+ keyboardType: .URL,
+ capitalizationType: .none
+ )
+ )
+ var domain: String
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Inspect \(\.$domain)")
+ }
+
+ func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
+ let requested = domain.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !requested.isEmpty else {
+ throw InspectDomainError.emptyDomain
+ }
+
+ let snapshot = await DomainInspectionService().inspectSnapshot(domain: requested)
+ let report = DomainReportBuilder().build(from: snapshot)
+
+ let summary = Self.summaryText(for: report)
+ let dialog = IntentDialog(stringLiteral: Self.spokenSummary(for: report))
+ return .result(value: summary, dialog: dialog)
+ }
+
+ /// Multi-line summary suitable for a returned Shortcuts text value.
+ static func summaryText(for report: DomainReport) -> String {
+ let dnssec: String
+ switch report.dns.dnssecSigned {
+ case true?: dnssec = "Yes"
+ case false?: dnssec = "No"
+ case nil: dnssec = "Unknown"
+ }
+
+ var lines = [
+ "\(report.domain) — \(report.availability.rawValue.capitalized)",
+ "Risk: \(report.riskAssessment.level.title) (score \(report.riskAssessment.score))",
+ "Health: \(report.health.title)",
+ "TLS: \(report.web.tlsGrade.rawValue) · Email: \(report.email.grade?.rawValue ?? "—") · Cert: \(report.certificateExpiryState.title)",
+ "IP: \(report.dns.primaryIP ?? "unknown") · DNSSEC: \(dnssec)"
+ ]
+
+ if let insight = report.insights.first {
+ lines.append(insight)
+ }
+
+ return lines.joined(separator: "\n")
+ }
+
+ /// Short spoken/dialog line for Siri and the Shortcuts result banner.
+ static func spokenSummary(for report: DomainReport) -> String {
+ "\(report.domain) is \(report.availability.rawValue). Risk \(report.riskAssessment.level.title.lowercased()), health \(report.health.title.lowercased())."
+ }
+}
+
+enum InspectDomainError: Error, CustomLocalizedStringResourceConvertible {
+ case emptyDomain
+
+ var localizedStringResource: LocalizedStringResource {
+ switch self {
+ case .emptyDomain:
+ return "Enter a domain to inspect."
+ }
+ }
+}
+
+/// App Intent that opens DomainDig and adds a domain to the watchlist. It opens
+/// the app via the `domaindig://watch` deep link so tracking goes through the
+/// existing view-model path (premium limits, monitoring, history linking,
+/// cloud-sync recording, and the paywall when over the free limit).
+struct AddToWatchlistIntent: AppIntent {
+ static var title: LocalizedStringResource = "Add Domain to Watchlist"
+ static var description = IntentDescription(
+ "Open DomainDig and add a domain to your watchlist."
+ )
+
+ static var openAppWhenRun = true
+
+ @Parameter(
+ title: "Domain",
+ description: "The domain to add, e.g. example.com",
+ inputOptions: String.IntentInputOptions(
+ keyboardType: .URL,
+ capitalizationType: .none
+ )
+ )
+ var domain: String
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Add \(\.$domain) to the watchlist")
+ }
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ let requested = domain.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !requested.isEmpty else {
+ throw InspectDomainError.emptyDomain
+ }
+
+ // `openAppWhenRun` runs this in the app process, so the router hands the
+ // action off to the running UI, which tracks through the existing path.
+ DomainDigIntentRouter.shared.pendingAction = .watch(requested)
+ return .result()
+ }
+}
+
+/// In-process hand-off from an `openAppWhenRun` intent to the running SwiftUI
+/// layer. `RootTabView` observes `pendingAction` and performs it.
+@MainActor
+@Observable
+final class DomainDigIntentRouter {
+ static let shared = DomainDigIntentRouter()
+ var pendingAction: DomainDigDeepLink.Action?
+ 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] {
+ AppShortcut(
+ intent: InspectDomainIntent(),
+ phrases: [
+ "Inspect a domain with \(.applicationName)",
+ "Dig a domain with \(.applicationName)"
+ ],
+ shortTitle: "Inspect Domain",
+ systemImageName: "magnifyingglass"
+ )
+ AppShortcut(
+ intent: AddToWatchlistIntent(),
+ phrases: [
+ "Add a domain to \(.applicationName)",
+ "Watch a domain with \(.applicationName)"
+ ],
+ shortTitle: "Add to Watchlist",
+ systemImageName: "plus.circle"
+ )
+ }
+}