summaryrefslogtreecommitdiff
path: root/DomainDig/DomainDigIntents.swift
blob: 44a1dc4ec06550ba32e34e880f08f50162e31aee (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
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)")
    }

    @MainActor
    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"
        )
    }
}