From 20d2eaf9da95f2ccc82413c9fa02e8b0f39af575 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 23:49:32 -0500 Subject: 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(-) create mode 100644 DomainDig/DomainDigIntents.swift 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 & 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 @@ net.cleberg.DomainDig.monitor.refresh + CFBundleURLTypes + + + CFBundleURLName + net.cleberg.DomainDig + CFBundleURLSchemes + + domaindig + + + CKSharingSupported UIBackgroundModes 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 + } + } } } -- cgit v1.2.3