krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

20d2eaf9da95f2ccc82413c9fa02e8b0f39af575

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-17T04:49:32Z

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
 DomainDig.xcodeproj/project.pbxproj |   8 +-
 DomainDig/AppVersion.swift          |   2 +-
 DomainDig/DomainDigIntents.swift    | 209 ++++++++++++++++++++++++++++++++++++
 DomainDig/Info.plist                |  11 ++
 DomainDig/RootTabView.swift         |  32 ++++++
 5 files changed, 257 insertions(+), 5 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 1a97a4a..fe76a4e 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -306,7 +306,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 36;
+				CURRENT_PROJECT_VERSION = 37;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -323,7 +323,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 4.4.1;
+				MARKETING_VERSION = 4.5.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -343,7 +343,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 36;
+				CURRENT_PROJECT_VERSION = 37;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -360,7 +360,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 4.4.1;
+				MARKETING_VERSION = 4.5.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index 07d3664..608d9c5 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
 
 enum AppVersion {
     nonisolated static var current: String {
-        "4.4.1"
+        "4.5.0"
     }
 }
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"
+        )
+    }
+}
diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist
index 5b0e7e6..9e7e3bb 100644
--- a/DomainDig/Info.plist
+++ b/DomainDig/Info.plist
@@ -10,6 +10,17 @@
 	<array>
 		<string>net.cleberg.DomainDig.monitor.refresh</string>
 	</array>
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleURLName</key>
+			<string>net.cleberg.DomainDig</string>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>domaindig</string>
+			</array>
+		</dict>
+	</array>
 	<key>CKSharingSupported</key>
 	<true/>
 	<key>UIBackgroundModes</key>
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
index f2c9f6c..72038a1 100644
--- a/DomainDig/RootTabView.swift
+++ b/DomainDig/RootTabView.swift
@@ -11,6 +11,7 @@ private enum RootTab: Hashable {
 struct RootTabView: View {
     @Bindable var viewModel: DomainViewModel
     @State private var purchaseService = PurchaseService.shared
+    @State private var intentRouter = DomainDigIntentRouter.shared
     @State private var selectedTab: RootTab = FeatureAccessService.currentTier == .free ? .inspect : .dashboard
 
     var body: some View {
@@ -82,5 +83,36 @@ struct RootTabView: View {
                 selectedTab = .dashboard
             }
         }
+        .onOpenURL { url in
+            guard let action = DomainDigDeepLink.action(from: url) else { return }
+            perform(action)
+        }
+        .onChange(of: intentRouter.pendingAction) { _, action in
+            consume(action)
+        }
+        .task {
+            consume(intentRouter.pendingAction)
+        }
+    }
+
+    private func consume(_ action: DomainDigDeepLink.Action?) {
+        guard let action else { return }
+        intentRouter.pendingAction = nil
+        perform(action)
+    }
+
+    private func perform(_ action: DomainDigDeepLink.Action) {
+        switch action {
+        case let .inspect(domain):
+            viewModel.domain = domain
+            selectedTab = .inspect
+            viewModel.run()
+        case let .watch(domain):
+            // On success show the dashboard (where tracked domains live); on
+            // failure stay put so the upgrade/paywall alert surfaces in place.
+            if viewModel.trackDomain(domain: domain, availabilityStatus: nil) {
+                selectedTab = .dashboard
+            }
+        }
     }
 }