From 73080183e583cd3c9a5306d9967308e648e0fff9 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 1 May 2026 14:26:40 -0400 Subject: [PATCH] Resolve PR #77 merge conflicts --- .../SuggestionCoordinator+Input.swift | 3 + .../SuggestionCoordinator+Lifecycle.swift | 1 + .../SuggestionCoordinator+Prediction.swift | 4 + tabby/Models/FocusModels.swift | 168 +++++++++- tabby/Models/FocusTrackingModel.swift | 21 ++ tabby/Models/SuggestionEngineModels.swift | 71 ++++ tabby/Models/SuggestionSettingsModel.swift | 195 ++++++++++- .../Focus/FocusSnapshotResolver.swift | 59 +++- tabby/Support/AXHelper.swift | 23 ++ .../SuggestionAvailabilityEvaluator.swift | 30 ++ tabby/UI/MenuBarView.swift | 57 ++++ tabby/UI/SettingsView.swift | 104 ++++++ ...SuggestionAvailabilityEvaluatorTests.swift | 314 +++++++++++++++++- 13 files changed, 1032 insertions(+), 18 deletions(-) diff --git a/tabby/App/Coordinators/SuggestionCoordinator+Input.swift b/tabby/App/Coordinators/SuggestionCoordinator+Input.swift index 3bbc1cf..87b8197 100644 --- a/tabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/tabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -12,6 +12,7 @@ extension SuggestionCoordinator { if SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + domainOverrideRules: settingsSnapshot.domainOverrideRules, inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: focusModel.snapshot @@ -33,6 +34,7 @@ extension SuggestionCoordinator { if let disabledReason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + domainOverrideRules: settingsSnapshot.domainOverrideRules, inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: snapshot @@ -78,6 +80,7 @@ extension SuggestionCoordinator { if let disabledReason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + domainOverrideRules: settingsSnapshot.domainOverrideRules, inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: focusModel.snapshot diff --git a/tabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift b/tabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift index 9aa475f..d876b6b 100644 --- a/tabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift +++ b/tabby/App/Coordinators/SuggestionCoordinator+Lifecycle.swift @@ -71,6 +71,7 @@ extension SuggestionCoordinator { if SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + domainOverrideRules: settingsSnapshot.domainOverrideRules, inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: focusModel.snapshot diff --git a/tabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/tabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 17c472d..207ca1f 100644 --- a/tabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/tabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -10,6 +10,7 @@ extension SuggestionCoordinator { if let disabledReason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + domainOverrideRules: settingsSnapshot.domainOverrideRules, inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: focusModel.snapshot @@ -49,6 +50,7 @@ extension SuggestionCoordinator { if let disabledReason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + domainOverrideRules: settingsSnapshot.domainOverrideRules, inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: snapshot @@ -129,6 +131,7 @@ extension SuggestionCoordinator { if let disabledReason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + domainOverrideRules: settingsSnapshot.domainOverrideRules, inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: snapshot @@ -239,6 +242,7 @@ extension SuggestionCoordinator { let disabledReason = SuggestionAvailabilityEvaluator.disabledReason( globallyEnabled: settingsSnapshot.isGloballyEnabled, disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers, + domainOverrideRules: settingsSnapshot.domainOverrideRules, inputMonitoringGranted: permissionManager.inputMonitoringGranted, screenRecordingGranted: permissionManager.screenRecordingGranted, focusSnapshot: focusModel.snapshot diff --git a/tabby/Models/FocusModels.swift b/tabby/Models/FocusModels.swift index cb8cae8..aa10d94 100644 --- a/tabby/Models/FocusModels.swift +++ b/tabby/Models/FocusModels.swift @@ -5,6 +5,134 @@ import Foundation /// Pure data models for focused-input state, AX capability support, and stale-result signatures. /// These types let the rest of Tabby reason about focus without depending on raw Accessibility values. +/// Resolved browser-tab identity derived from the focused Accessibility tree. +/// +/// Why this exists as its own type: +/// app-level identity and browser-domain identity are different product concepts. The app-level +/// rule answers "is Tabby allowed anywhere in Chrome?", while the browser-domain rule answers +/// "is Tabby allowed on `github.com` inside a browser tab?" Keeping domain data separate prevents +/// URL parsing details from leaking into unrelated focus or coordinator code. +struct BrowserDomainContext: Equatable, Sendable { + let pageURL: URL + let host: String + let registrableDomain: String + + /// Product copy should prefer the broad registrable domain because that is the default match + /// scope users opt into from the menu. Exact-host matching is a later settings refinement. + var displayDomain: String { + registrableDomain + } +} + +/// Stable browser-domain identity for UI surfaces that need to remember the last real browser tab +/// even after Tabby steals focus to open its own menu. +struct FocusedBrowserDomainIdentity: Equatable, Sendable { + let applicationName: String + let bundleIdentifier: String + let pageURL: URL + let host: String + let registrableDomain: String + + var displayDomain: String { + registrableDomain + } +} + +/// Pure helper that converts a browser URL into the matching domain Tabby should use for rules. +/// +/// Tradeoff: +/// a full public-suffix implementation would require an extra dependency or bundled list. For this +/// product surface we keep the logic local and documented, with an explicit compound-suffix table +/// for the common multi-label registries users are most likely to encounter. +enum BrowserDomainNormalizer { + /// These suffixes are the cases where "last two labels" is wrong. Storing them in one place + /// keeps the heuristic auditable and easy to extend when a real user reports a miss. + private static let compoundPublicSuffixes: Set = [ + "ac.uk", + "co.il", + "co.in", + "co.jp", + "co.kr", + "co.nz", + "co.uk", + "com.au", + "com.br", + "com.cn", + "com.hk", + "com.mx", + "com.sg", + "com.tr", + "com.tw", + "gov.uk", + "net.au", + "org.au", + "org.uk" + ] + + static func browserDomainContext(for url: URL) -> BrowserDomainContext? { + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = normalizedHost(from: url) + else { + return nil + } + + return BrowserDomainContext( + pageURL: url, + host: host, + registrableDomain: registrableDomain(for: host) + ) + } + + private static func normalizedHost(from url: URL) -> String? { + guard var host = url.host?.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + else { + return nil + } + + host = host.lowercased() + return host.isEmpty ? nil : host + } + + private static func registrableDomain(for host: String) -> String { + if isIPAddress(host) || host == "localhost" { + return host + } + + let labels = host.split(separator: ".") + guard labels.count > 2 else { + return host + } + + let suffixCandidate = labels.suffix(2).joined(separator: ".") + if compoundPublicSuffixes.contains(suffixCandidate), labels.count >= 3 { + return labels.suffix(3).joined(separator: ".") + } + + return suffixCandidate + } + + /// IP literals have no registrable-domain concept, so we match them exactly. + private static func isIPAddress(_ host: String) -> Bool { + if host.contains(":") { + return true + } + + let octets = host.split(separator: ".") + guard octets.count == 4 else { + return false + } + + return octets.allSatisfy { octet in + guard let value = Int(octet), (0...255).contains(value) else { + return false + } + + return String(value) == octet + } + } +} + /// Immutable identity for one focused input observation. /// /// `elementIdentifier` is still useful because it describes the AX node we resolved, but it is not @@ -236,13 +364,31 @@ struct FocusSnapshot: Equatable { let capability: FocusCapability let context: FocusedInputSnapshot? let inspection: FocusInspectionSnapshot? + let browserDomain: BrowserDomainContext? + + init( + applicationName: String, + bundleIdentifier: String?, + capability: FocusCapability, + context: FocusedInputSnapshot?, + inspection: FocusInspectionSnapshot?, + browserDomain: BrowserDomainContext? = nil + ) { + self.applicationName = applicationName + self.bundleIdentifier = bundleIdentifier + self.capability = capability + self.context = context + self.inspection = inspection + self.browserDomain = browserDomain + } static let inactive = FocusSnapshot( applicationName: "No active application", bundleIdentifier: nil, capability: .unsupported("No focused text input"), context: nil, - inspection: nil + inspection: nil, + browserDomain: nil ) var capabilitySummary: String { @@ -268,6 +414,26 @@ struct FocusSnapshot: Equatable { bundleIdentifier: bundleIdentifier ) } + + /// Domain-aware UI should only target a real external browser tab, never Tabby's own menu. + func externalBrowserDomainIdentity( + ignoredBundleIdentifier: String? + ) -> FocusedBrowserDomainIdentity? { + guard let bundleIdentifier, + bundleIdentifier != ignoredBundleIdentifier, + let browserDomain + else { + return nil + } + + return FocusedBrowserDomainIdentity( + applicationName: applicationName, + bundleIdentifier: bundleIdentifier, + pageURL: browserDomain.pageURL, + host: browserDomain.host, + registrableDomain: browserDomain.registrableDomain + ) + } } /// Debug-only signal that an `AXObserver` notification reached Tabby. diff --git a/tabby/Models/FocusTrackingModel.swift b/tabby/Models/FocusTrackingModel.swift index 3973a30..e7cb2b6 100644 --- a/tabby/Models/FocusTrackingModel.swift +++ b/tabby/Models/FocusTrackingModel.swift @@ -10,6 +10,7 @@ import Foundation final class FocusTrackingModel: ObservableObject { @Published private(set) var snapshot: FocusSnapshot @Published private(set) var latestExternalApplication: FocusedApplicationIdentity? + @Published private(set) var latestExternalBrowserDomain: FocusedBrowserDomainIdentity? /// Debug-only pulse source for the caret overlay; not used by suggestion generation. @Published private(set) var latestObserverEvent: FocusObserverEvent? @@ -31,10 +32,14 @@ final class FocusTrackingModel: ObservableObject { latestExternalApplication = tracker.snapshot.externalApplicationIdentity( ignoredBundleIdentifier: ignoredBundleIdentifier ) + latestExternalBrowserDomain = tracker.snapshot.externalBrowserDomainIdentity( + ignoredBundleIdentifier: ignoredBundleIdentifier + ) tracker.onSnapshotChange = { [weak self] snapshot in self?.snapshot = snapshot self?.updateLatestExternalApplication(from: snapshot) + self?.updateLatestExternalBrowserDomain(from: snapshot) } tracker.onAXNotification = { [weak self] notificationName in @@ -91,6 +96,22 @@ final class FocusTrackingModel: ObservableObject { latestExternalApplication = application } + private func updateLatestExternalBrowserDomain(from snapshot: FocusSnapshot) { + guard let browserDomain = snapshot.externalBrowserDomainIdentity( + ignoredBundleIdentifier: ignoredBundleIdentifier + ) else { + // Clear the cached browser identity when the user moves into a different non-browser + // app, but preserve it while Tabby itself is focused so the menu can still reference + // the last real browser tab. + if snapshot.bundleIdentifier != ignoredBundleIdentifier { + latestExternalBrowserDomain = nil + } + return + } + + latestExternalBrowserDomain = browserDomain + } + private func publishObserverEvent(named notificationName: String) { observerEventSequence += 1 latestObserverEvent = FocusObserverEvent( diff --git a/tabby/Models/SuggestionEngineModels.swift b/tabby/Models/SuggestionEngineModels.swift index 5adede6..57b249e 100644 --- a/tabby/Models/SuggestionEngineModels.swift +++ b/tabby/Models/SuggestionEngineModels.swift @@ -44,11 +44,82 @@ struct DisabledApplicationRule: Codable, Equatable, Identifiable, Sendable { var id: String { bundleIdentifier } } +/// Domain overrides need an explicit state because "enabled" is semantically different from +/// "missing rule". A missing rule inherits the browser/app default, while an explicit enabled rule +/// records that the user intentionally wants this domain on even if a broader browser rule later +/// turns off the whole app. +enum DomainOverrideState: String, Codable, CaseIterable, Equatable, Sendable, Identifiable { + case enabled + case disabled + + var id: String { rawValue } + + var displayLabel: String { + switch self { + case .enabled: + return "Enabled" + case .disabled: + return "Disabled" + } + } +} + +/// Default domain matching uses the registrable domain (`github.com`), with an opt-in exact-host +/// mode for users who want `docs.github.com` to behave differently from `github.com`. +enum DomainMatchScope: String, Codable, CaseIterable, Equatable, Sendable, Identifiable { + case registrableDomain + case exactHost + + var id: String { rawValue } +} + +/// Durable browser-domain override authored by the user. +/// +/// Both `host` and `registrableDomain` are stored so Settings can switch between broad matching +/// and exact-host matching without re-reading the live browser URL that originally created the rule. +struct DomainOverrideRule: Codable, Equatable, Identifiable, Sendable { + let host: String + let registrableDomain: String + let state: DomainOverrideState + let matchScope: DomainMatchScope + + var id: String { + "\(matchScope.rawValue)::\(matchValue)" + } + + var matchValue: String { + switch matchScope { + case .registrableDomain: + return registrableDomain + case .exactHost: + return host + } + } + + var displayDomain: String { + matchValue + } + + var isSubdomainSpecific: Bool { + matchScope == .exactHost && host != registrableDomain + } + + func matches(_ domain: FocusedBrowserDomainIdentity) -> Bool { + switch matchScope { + case .registrableDomain: + return registrableDomain == domain.registrableDomain + case .exactHost: + return host == domain.host + } + } +} + /// A compact snapshot of the autocomplete settings the coordinator actually needs at generation /// time. Keeping this as a value type makes change detection simple and deterministic. struct SuggestionSettingsSnapshot: Equatable, Sendable { let isGloballyEnabled: Bool let disabledAppBundleIdentifiers: Set + let domainOverrideRules: [DomainOverrideRule] let selectedEngine: SuggestionEngineKind let selectedWordCountPreset: SuggestionWordCountPreset /// Normalized user-authored guidance for Tabby's instruction-rendered completion prompt. diff --git a/tabby/Models/SuggestionSettingsModel.swift b/tabby/Models/SuggestionSettingsModel.swift index d7bff8f..44d39f4 100644 --- a/tabby/Models/SuggestionSettingsModel.swift +++ b/tabby/Models/SuggestionSettingsModel.swift @@ -14,6 +14,7 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var isGloballyEnabled: Bool @Published private(set) var selectedIndicatorMode: ActivationIndicatorMode @Published private(set) var disabledAppRules: [DisabledApplicationRule] + @Published private(set) var domainOverrideRules: [DomainOverrideRule] @Published private(set) var customSuggestionTextColorHex: String? @Published private(set) var selectedEngine: SuggestionEngineKind @Published private(set) var selectedWordCountPreset: SuggestionWordCountPreset @@ -23,6 +24,7 @@ final class SuggestionSettingsModel: ObservableObject { private static let isGloballyEnabledDefaultsKey = "tabbyGloballyEnabled" private static let disabledAppRulesDefaultsKey = "tabbyDisabledAppRules" + private static let domainOverrideRulesDefaultsKey = "tabbyDomainOverrideRules" // Legacy key. Keep reading and writing through it so old builds degrade to a visible indicator. private static let showCaretIndicatorDefaultsKey = "tabbyShowCaretIndicator" private static let selectedIndicatorModeDefaultsKey = "tabbySelectedIndicatorMode" @@ -39,6 +41,7 @@ final class SuggestionSettingsModel: ObservableObject { let resolvedGloballyEnabled = userDefaults.object(forKey: Self.isGloballyEnabledDefaultsKey) as? Bool ?? true let resolvedDisabledAppRules = Self.loadDisabledAppRules(from: userDefaults) + let resolvedDomainOverrideRules = Self.loadDomainOverrideRules(from: userDefaults) let legacyShowCaretIndicator = userDefaults.object(forKey: Self.showCaretIndicatorDefaultsKey) as? Bool ?? true let resolvedIndicatorMode = userDefaults .string(forKey: Self.selectedIndicatorModeDefaultsKey) @@ -63,6 +66,7 @@ final class SuggestionSettingsModel: ObservableObject { isGloballyEnabled = resolvedGloballyEnabled disabledAppRules = resolvedDisabledAppRules + domainOverrideRules = resolvedDomainOverrideRules selectedIndicatorMode = resolvedIndicatorMode customSuggestionTextColorHex = resolvedCustomSuggestionTextColorHex selectedEngine = resolvedEngine @@ -71,6 +75,7 @@ final class SuggestionSettingsModel: ObservableObject { userDefaults.set(resolvedGloballyEnabled, forKey: Self.isGloballyEnabledDefaultsKey) persistDisabledAppRules(resolvedDisabledAppRules) + persistDomainOverrideRules(resolvedDomainOverrideRules) persistSelectedIndicatorMode(resolvedIndicatorMode) persistCustomSuggestionTextColorHex(resolvedCustomSuggestionTextColorHex) persistSelectedEngine(resolvedEngine) @@ -88,6 +93,7 @@ final class SuggestionSettingsModel: ObservableObject { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)), + domainOverrideRules: domainOverrideRules, selectedEngine: selectedEngine, selectedWordCountPreset: selectedWordCountPreset, customAIInstructions: CustomAIInstructionFormatter.normalized(customAIInstructions) @@ -199,6 +205,90 @@ final class SuggestionSettingsModel: ObservableObject { } } + /// Finds the most specific matching rule for the current browser tab. + /// + /// Exact-host overrides win over registrable-domain overrides because they represent a more + /// specific user intent. + func domainOverrideRule( + for domain: FocusedBrowserDomainIdentity + ) -> DomainOverrideRule? { + if let exactHostRule = domainOverrideRules.first(where: { + $0.matchScope == .exactHost && $0.matches(domain) + }) { + return exactHostRule + } + + return domainOverrideRules.first(where: { + $0.matchScope == .registrableDomain && $0.matches(domain) + }) + } + + func setDomainOverride( + for domain: FocusedBrowserDomainIdentity, + state: DomainOverrideState + ) { + let existingRule = domainOverrideRule(for: domain) + let updatedRule = DomainOverrideRule( + host: Self.normalizedHost(domain.host), + registrableDomain: Self.normalizedHost(domain.registrableDomain), + state: state, + matchScope: existingRule?.matchScope ?? .registrableDomain + ) + + upsertDomainOverrideRule( + updatedRule, + replacingRuleWithID: existingRule?.id + ) + } + + func setDomainOverrideState( + ruleID: String, + state: DomainOverrideState + ) { + guard let existingRule = domainOverrideRules.first(where: { $0.id == ruleID }) else { + return + } + + let updatedRule = DomainOverrideRule( + host: existingRule.host, + registrableDomain: existingRule.registrableDomain, + state: state, + matchScope: existingRule.matchScope + ) + upsertDomainOverrideRule(updatedRule, replacingRuleWithID: existingRule.id) + } + + func setDomainOverrideUsesExactHost( + ruleID: String, + usesExactHost: Bool + ) { + guard let existingRule = domainOverrideRules.first(where: { $0.id == ruleID }) else { + return + } + + let updatedScope: DomainMatchScope = usesExactHost ? .exactHost : .registrableDomain + let updatedRule = DomainOverrideRule( + host: existingRule.host, + registrableDomain: existingRule.registrableDomain, + state: existingRule.state, + matchScope: updatedScope + ) + upsertDomainOverrideRule(updatedRule, replacingRuleWithID: existingRule.id) + } + + func removeDomainOverride(ruleID: String) { + let updatedRules = domainOverrideRules.filter { + $0.id != ruleID + } + + guard updatedRules != domainOverrideRules else { + return + } + + domainOverrideRules = updatedRules + persistDomainOverrideRules(updatedRules) + } + func selectIndicatorMode(_ mode: ActivationIndicatorMode) { guard selectedIndicatorMode != mode else { return @@ -270,6 +360,16 @@ final class SuggestionSettingsModel: ObservableObject { return sanitizedDisabledAppRules(decodedRules) } + private static func loadDomainOverrideRules(from userDefaults: UserDefaults) -> [DomainOverrideRule] { + guard let data = userDefaults.data(forKey: Self.domainOverrideRulesDefaultsKey), + let decodedRules = try? JSONDecoder().decode([DomainOverrideRule].self, from: data) + else { + return [] + } + + return sanitizedDomainOverrideRules(decodedRules) + } + private static func sanitizedDisabledAppRules( _ rules: [DisabledApplicationRule] ) -> [DisabledApplicationRule] { @@ -305,6 +405,43 @@ final class SuggestionSettingsModel: ObservableObject { } } + private static func sanitizedDomainOverrideRules( + _ rules: [DomainOverrideRule] + ) -> [DomainOverrideRule] { + var rulesByIdentifier: [String: DomainOverrideRule] = [:] + + for rule in rules { + let sanitizedHost = normalizedHost(rule.host) + let sanitizedRegistrableDomain = normalizedHost(rule.registrableDomain) + guard !sanitizedHost.isEmpty, !sanitizedRegistrableDomain.isEmpty else { + continue + } + + let sanitizedRule = DomainOverrideRule( + host: sanitizedHost, + registrableDomain: sanitizedRegistrableDomain, + state: rule.state, + matchScope: rule.matchScope + ) + rulesByIdentifier[sanitizedRule.id] = sanitizedRule + } + + return sortedDomainOverrideRules(Array(rulesByIdentifier.values)) + } + + private static func sortedDomainOverrideRules( + _ rules: [DomainOverrideRule] + ) -> [DomainOverrideRule] { + rules.sorted { + if $0.displayDomain.localizedCaseInsensitiveCompare($1.displayDomain) == .orderedSame { + return $0.id < $1.id + } + + return $0.displayDomain.localizedCaseInsensitiveCompare($1.displayDomain) + == .orderedAscending + } + } + private static func normalizedBundleIdentifier(_ bundleIdentifier: String?) -> String? { guard let bundleIdentifier else { return nil @@ -322,6 +459,12 @@ final class SuggestionSettingsModel: ObservableObject { return trimmed.isEmpty ? fallbackBundleIdentifier : trimmed } + private static func normalizedHost(_ host: String) -> String { + host + .trimmingCharacters(in: CharacterSet(charactersIn: ".").union(.whitespacesAndNewlines)) + .lowercased() + } + private static func normalizedHexString(_ hex: String?) -> String? { guard let hex else { return nil @@ -355,22 +498,58 @@ final class SuggestionSettingsModel: ObservableObject { userDefaults.set(data, forKey: Self.disabledAppRulesDefaultsKey) } } + + private func persistDomainOverrideRules(_ rules: [DomainOverrideRule]) { + guard !rules.isEmpty else { + userDefaults.removeObject(forKey: Self.domainOverrideRulesDefaultsKey) + return + } + + if let data = try? JSONEncoder().encode(rules) { + userDefaults.set(data, forKey: Self.domainOverrideRulesDefaultsKey) + } + } + + private func upsertDomainOverrideRule( + _ rule: DomainOverrideRule, + replacingRuleWithID replacedRuleID: String? + ) { + var updatedRules = domainOverrideRules.filter { existingRule in + existingRule.id != rule.id && existingRule.id != replacedRuleID + } + updatedRules.append(rule) + let sortedRules = Self.sortedDomainOverrideRules(updatedRules) + + guard sortedRules != domainOverrideRules else { + return + } + + domainOverrideRules = sortedRules + persistDomainOverrideRules(sortedRules) + } } extension SuggestionSettingsModel: SuggestionSettingsProviding { var snapshotPublisher: AnyPublisher { - Publishers.CombineLatest4( - $isGloballyEnabled, - $disabledAppRules, - $selectedEngine, - $selectedWordCountPreset + Publishers.CombineLatest( + Publishers.CombineLatest4( + $isGloballyEnabled, + $disabledAppRules, + $domainOverrideRules, + $selectedEngine + ), + Publishers.CombineLatest( + $selectedWordCountPreset, + $customAIInstructions + ) ) - .combineLatest($customAIInstructions) - .map { combinedSettings, customAIInstructions in - let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings + .map { combinedSettings, secondarySettings in + let (globallyEnabled, disabledAppRules, domainOverrideRules, engine) = combinedSettings + let (wordCountPreset, customAIInstructions) = secondarySettings return SuggestionSettingsSnapshot( isGloballyEnabled: globallyEnabled, disabledAppBundleIdentifiers: Set(disabledAppRules.map(\.bundleIdentifier)), + domainOverrideRules: domainOverrideRules, selectedEngine: engine, selectedWordCountPreset: wordCountPreset, customAIInstructions: CustomAIInstructionFormatter.normalized(customAIInstructions) diff --git a/tabby/Services/Focus/FocusSnapshotResolver.swift b/tabby/Services/Focus/FocusSnapshotResolver.swift index 818e78a..e759e90 100644 --- a/tabby/Services/Focus/FocusSnapshotResolver.swift +++ b/tabby/Services/Focus/FocusSnapshotResolver.swift @@ -31,6 +31,7 @@ struct FocusSnapshotResolver { ) -> FocusSnapshot { let applicationName = application.localizedName ?? "Unknown" let bundleIdentifier = application.bundleIdentifier ?? "unknown.bundle" + let browserDomain = resolveBrowserDomain(near: focusedElement) let focusedRole = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: focusedElement) ?? "Unknown" let focusedSubrole = AXHelper.stringValue( @@ -72,7 +73,8 @@ struct FocusSnapshotResolver { bundleIdentifier: bundleIdentifier, capability: .unsupported(resolution.unsupportedReason), context: nil, - inspection: inspection + inspection: inspection, + browserDomain: browserDomain ) } @@ -82,7 +84,8 @@ struct FocusSnapshotResolver { bundleIdentifier: bundleIdentifier, capability: .unsupported("Selection range is unavailable."), context: nil, - inspection: inspection + inspection: inspection, + browserDomain: browserDomain ) } @@ -92,7 +95,8 @@ struct FocusSnapshotResolver { bundleIdentifier: bundleIdentifier, capability: .unsupported("Selection range is invalid."), context: nil, - inspection: inspection + inspection: inspection, + browserDomain: browserDomain ) } @@ -105,7 +109,8 @@ struct FocusSnapshotResolver { bundleIdentifier: bundleIdentifier, capability: .unsupported("Selection range exceeds the current field value."), context: nil, - inspection: inspection + inspection: inspection, + browserDomain: browserDomain ) } @@ -144,7 +149,8 @@ struct FocusSnapshotResolver { bundleIdentifier: bundleIdentifier, capability: .unsupported("Caret bounds are unavailable."), context: nil, - inspection: inspection + inspection: inspection, + browserDomain: browserDomain ) } @@ -176,7 +182,8 @@ struct FocusSnapshotResolver { bundleIdentifier: bundleIdentifier, capability: .blocked("Secure text input is active."), context: context, - inspection: inspection + inspection: inspection, + browserDomain: browserDomain ) } @@ -186,7 +193,8 @@ struct FocusSnapshotResolver { bundleIdentifier: bundleIdentifier, capability: .blocked("Text is currently selected."), context: context, - inspection: inspection + inspection: inspection, + browserDomain: browserDomain ) } @@ -195,7 +203,8 @@ struct FocusSnapshotResolver { bundleIdentifier: bundleIdentifier, capability: .supported, context: context, - inspection: inspection + inspection: inspection, + browserDomain: browserDomain ) } @@ -246,6 +255,40 @@ struct FocusSnapshotResolver { return ordered } + /// Browser URLs often live on an ancestor `AXWebArea`, not on the focused editable node itself. + /// We walk upward first because that is the cheapest path to the semantic container that owns + /// the tab URL across Chromium- and WebKit-based browsers. + private func resolveBrowserDomain(near focusedElement: AXUIElement) -> BrowserDomainContext? { + if let directURL = AXHelper.urlValue(for: "AXURL" as CFString, on: focusedElement), + let domain = BrowserDomainNormalizer.browserDomainContext(for: directURL) + { + return domain + } + + var currentElement: AXUIElement? = focusedElement + var fallbackDomain: BrowserDomainContext? + for _ in 0..<12 { + guard let element = currentElement else { + break + } + + let role = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) + if let url = AXHelper.urlValue(for: "AXURL" as CFString, on: element), + let domain = BrowserDomainNormalizer.browserDomainContext(for: url) + { + if role == "AXWebArea" { + return domain + } + + fallbackDomain = fallbackDomain ?? domain + } + + currentElement = AXHelper.parentElement(of: element) + } + + return fallbackDomain + } + /// Runs deep geometry search from the resolved editable candidate first, then falls back to /// the raw focused node when those are different branches of the same local AX neighborhood. private func resolveDeepGeometrySource( diff --git a/tabby/Support/AXHelper.swift b/tabby/Support/AXHelper.swift index 6d47895..b1bf31b 100644 --- a/tabby/Support/AXHelper.swift +++ b/tabby/Support/AXHelper.swift @@ -77,6 +77,29 @@ enum AXHelper { return number.boolValue } + /// Browser containers often expose `AXURL` as either a Foundation `URL` or a string. + /// Normalizing both representations here keeps Core Foundation bridging quirks out of the + /// higher-level focus resolver. + static func urlValue(for attribute: CFString, on element: AXUIElement) -> URL? { + guard let rawValue = copyAttributeValue(attribute, on: element) else { + return nil + } + + if let url = rawValue as? URL { + return url + } + + if let string = rawValue as? String { + return URL(string: string) + } + + if let attributedString = rawValue as? NSAttributedString { + return URL(string: attributedString.string) + } + + return nil + } + /// Converts loosely typed Accessibility values into `AXValue` only after verifying the Core /// Foundation type id. This keeps the unsafe CF boundary in one place and avoids force casts in /// the higher-level helpers below. diff --git a/tabby/Support/SuggestionAvailabilityEvaluator.swift b/tabby/Support/SuggestionAvailabilityEvaluator.swift index 51c39e0..837d8da 100644 --- a/tabby/Support/SuggestionAvailabilityEvaluator.swift +++ b/tabby/Support/SuggestionAvailabilityEvaluator.swift @@ -10,6 +10,7 @@ enum SuggestionAvailabilityEvaluator { static func disabledReason( globallyEnabled: Bool = true, disabledAppBundleIdentifiers: Set = [], + domainOverrideRules: [DomainOverrideRule] = [], inputMonitoringGranted: Bool, screenRecordingGranted: Bool, focusSnapshot: FocusSnapshot @@ -23,6 +24,18 @@ enum SuggestionAvailabilityEvaluator { return "Tabby is disabled in \(focusSnapshot.applicationName)." } + if let browserDomain = focusSnapshot.externalBrowserDomainIdentity( + ignoredBundleIdentifier: nil + ), + let matchingRule = matchingDomainRule( + for: browserDomain, + in: domainOverrideRules + ), + matchingRule.state == .disabled + { + return "Tabby is disabled on \(matchingRule.displayDomain)." + } + guard inputMonitoringGranted else { return "Input Monitoring permission is required before Tabby can react to typing." } @@ -43,6 +56,7 @@ enum SuggestionAvailabilityEvaluator { static func shouldSchedulePrediction( globallyEnabled: Bool = true, disabledAppBundleIdentifiers: Set = [], + domainOverrideRules: [DomainOverrideRule] = [], inputMonitoringGranted: Bool, screenRecordingGranted: Bool, focusSnapshot: FocusSnapshot @@ -50,6 +64,7 @@ enum SuggestionAvailabilityEvaluator { disabledReason( globallyEnabled: globallyEnabled, disabledAppBundleIdentifiers: disabledAppBundleIdentifiers, + domainOverrideRules: domainOverrideRules, inputMonitoringGranted: inputMonitoringGranted, screenRecordingGranted: screenRecordingGranted, focusSnapshot: focusSnapshot @@ -69,4 +84,19 @@ enum SuggestionAvailabilityEvaluator { return SuggestionRequestFactory.shouldGenerateSuggestion(for: context.precedingText) } + + private static func matchingDomainRule( + for domain: FocusedBrowserDomainIdentity, + in rules: [DomainOverrideRule] + ) -> DomainOverrideRule? { + if let exactHostRule = rules.first(where: { + $0.matchScope == .exactHost && $0.matches(domain) + }) { + return exactHostRule + } + + return rules.first(where: { + $0.matchScope == .registrableDomain && $0.matches(domain) + }) + } } diff --git a/tabby/UI/MenuBarView.swift b/tabby/UI/MenuBarView.swift index f8cbf13..41b32e4 100644 --- a/tabby/UI/MenuBarView.swift +++ b/tabby/UI/MenuBarView.swift @@ -74,6 +74,10 @@ struct MenuBarView: View { .controlSize(.small) } + if let browserDomain = focusModel.latestExternalBrowserDomain { + domainOverrideRow(for: browserDomain) + } + MenuBarPickerRow(title: "Indicator") { Picker("Indicator", selection: selectedIndicatorModeBinding) { ForEach(ActivationIndicatorMode.allCases) { mode in @@ -114,6 +118,26 @@ struct MenuBarView: View { .padding(.bottom, 12) } + /// Domain overrides are quick actions rather than toggles because the current app-level rule + /// can temporarily suppress a saved domain choice. A button lets us express "save this intent" + /// without pretending the domain rule is the only effective switch in play. + @ViewBuilder + private func domainOverrideRow(for domain: FocusedBrowserDomainIdentity) -> some View { + VStack(alignment: .leading, spacing: 4) { + Button(domainOverrideActionTitle(for: domain)) { + toggleDomainOverride(for: domain) + } + .buttonStyle(.borderless) + .font(.subheadline) + + if shouldExplainAppLevelOverride(for: domain) { + Text("\(domain.applicationName) is still disabled, so this saved domain rule will apply after you re-enable the app.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + /// Model selector with folder shortcut — only visible when local llama engine is active. @ViewBuilder private var modelRow: some View { @@ -282,6 +306,39 @@ struct MenuBarView: View { && permissionManager.inputMonitoringGranted } + private func domainOverrideActionTitle(for domain: FocusedBrowserDomainIdentity) -> String { + switch suggestionSettings.domainOverrideRule(for: domain)?.state { + case .enabled: + return "Disable on \(domain.displayDomain)" + case .disabled: + return "Enable on \(domain.displayDomain)" + case nil: + if suggestionSettings.isApplicationDisabled(bundleIdentifier: domain.bundleIdentifier) { + return "Enable on \(domain.displayDomain)" + } + + return "Disable on \(domain.displayDomain)" + } + } + + private func toggleDomainOverride(for domain: FocusedBrowserDomainIdentity) { + let nextState: DomainOverrideState + if let existingRule = suggestionSettings.domainOverrideRule(for: domain) { + nextState = existingRule.state == .disabled ? .enabled : .disabled + } else if suggestionSettings.isApplicationDisabled(bundleIdentifier: domain.bundleIdentifier) { + nextState = .enabled + } else { + nextState = .disabled + } + + suggestionSettings.setDomainOverride(for: domain, state: nextState) + } + + private func shouldExplainAppLevelOverride(for domain: FocusedBrowserDomainIdentity) -> Bool { + suggestionSettings.isApplicationDisabled(bundleIdentifier: domain.bundleIdentifier) + && suggestionSettings.domainOverrideRule(for: domain) != nil + } + private func refreshAppleIntelligenceAvailabilityIfNeeded() { guard suggestionSettings.selectedEngine == .appleIntelligence else { return diff --git a/tabby/UI/SettingsView.swift b/tabby/UI/SettingsView.swift index 703f5a1..f5ee802 100644 --- a/tabby/UI/SettingsView.swift +++ b/tabby/UI/SettingsView.swift @@ -31,6 +31,7 @@ struct SettingsView: View { generalSection autocompleteSection disabledAppsSection + domainOverridesSection customInstructionsSection permissionsSection localModelsSection @@ -183,6 +184,25 @@ struct SettingsView: View { } } + @ViewBuilder + private var domainOverridesSection: some View { + Section("Domain Overrides") { + Text("Browser rules default to the registrable domain (`github.com`). Turn on subdomain matching only when one host should behave differently from the rest of the site.") + .font(.caption) + .foregroundStyle(.secondary) + + if suggestionSettings.domainOverrideRules.isEmpty { + Text("No domain overrides yet. Create one from the menu bar while focused in a browser tab.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(suggestionSettings.domainOverrideRules) { rule in + domainOverrideRuleRow(rule) + } + } + } + } + @ViewBuilder private var customInstructionsSection: some View { Section("Custom AI Instructions") { @@ -360,6 +380,50 @@ struct SettingsView: View { } } + @ViewBuilder + private func domainOverrideRuleRow(_ rule: DomainOverrideRule) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Image(systemName: "globe") + .foregroundStyle(.secondary) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(rule.displayDomain) + + Text(domainOverrideSubtitle(for: rule)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + Picker("State", selection: domainOverrideStateBinding(for: rule)) { + ForEach(DomainOverrideState.allCases) { state in + Text(state.displayLabel) + .tag(state) + } + } + .labelsHidden() + .frame(width: 110) + + Button { + suggestionSettings.removeDomainOverride(ruleID: rule.id) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help("Remove \(rule.displayDomain) override") + } + + Toggle("Only this subdomain", isOn: domainOverrideExactHostBinding(for: rule)) + .toggleStyle(.switch) + .controlSize(.small) + } + .padding(.vertical, 4) + } + private func icon(for rule: DisabledApplicationRule) -> NSImage { // Bundle IDs are durable; app paths are not. Resolve the current app URL at render time so // Settings naturally picks up app updates, moves, or reinstalls without persisting UI cache. @@ -470,6 +534,33 @@ struct SettingsView: View { ) } + private func domainOverrideStateBinding(for rule: DomainOverrideRule) -> Binding { + Binding( + get: { + suggestionSettings.domainOverrideRules.first(where: { $0.id == rule.id })?.state + ?? rule.state + }, + set: { newState in + suggestionSettings.setDomainOverrideState(ruleID: rule.id, state: newState) + } + ) + } + + private func domainOverrideExactHostBinding(for rule: DomainOverrideRule) -> Binding { + Binding( + get: { + suggestionSettings.domainOverrideRules.first(where: { $0.id == rule.id })?.matchScope + == .exactHost + }, + set: { usesExactHost in + suggestionSettings.setDomainOverrideUsesExactHost( + ruleID: rule.id, + usesExactHost: usesExactHost + ) + } + ) + } + private var selectedModelBinding: Binding { Binding( get: { @@ -499,6 +590,19 @@ struct SettingsView: View { return "Custom ghost text color is active." } + private func domainOverrideSubtitle(for rule: DomainOverrideRule) -> String { + switch rule.matchScope { + case .registrableDomain: + return "Matches all subdomains on \(rule.registrableDomain)" + case .exactHost: + if rule.host == rule.registrableDomain { + return "Matches only \(rule.host)" + } + + return "Matches only \(rule.host); other \(rule.registrableDomain) subdomains inherit separately" + } + } + private var customAIInstructionsDescription: String { "These instructions are active for autocomplete. Use them to tell Tabby about your " + "tone, language, audience, or formatting preferences." diff --git a/tabbyTests/SuggestionAvailabilityEvaluatorTests.swift b/tabbyTests/SuggestionAvailabilityEvaluatorTests.swift index 004ec04..3381304 100644 --- a/tabbyTests/SuggestionAvailabilityEvaluatorTests.swift +++ b/tabbyTests/SuggestionAvailabilityEvaluatorTests.swift @@ -15,6 +15,7 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { private func makeSnapshot( applicationName: String = "TestApp", bundleIdentifier: String? = "app.test", + browserDomain: BrowserDomainContext? = nil, capability: FocusCapability ) -> FocusSnapshot { FocusSnapshot( @@ -22,7 +23,8 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { bundleIdentifier: bundleIdentifier, capability: capability, context: nil, - inspection: nil + inspection: nil, + browserDomain: browserDomain ) } @@ -144,6 +146,63 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { XCTAssertEqual(reason, "Tabby is disabled in Safari.") } + func test_disabledReason_whenDomainDisabled_returnsDomainSpecificCopy() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + domainOverrideRules: [ + DomainOverrideRule( + host: "docs.github.com", + registrableDomain: "github.com", + state: .disabled, + matchScope: .registrableDomain + ) + ], + inputMonitoringGranted: true, + screenRecordingGranted: true, + focusSnapshot: makeSnapshot( + applicationName: "Google Chrome", + bundleIdentifier: "com.google.Chrome", + browserDomain: BrowserDomainContext( + pageURL: URL(string: "https://docs.github.com/en")!, + host: "docs.github.com", + registrableDomain: "github.com" + ), + capability: .supported + ) + ) + + XCTAssertEqual(reason, "Tabby is disabled on github.com.") + } + + func test_disabledReason_appDisabledWinsOverEnabledDomainOverride() { + let reason = SuggestionAvailabilityEvaluator.disabledReason( + globallyEnabled: true, + disabledAppBundleIdentifiers: ["com.google.Chrome"], + domainOverrideRules: [ + DomainOverrideRule( + host: "docs.github.com", + registrableDomain: "github.com", + state: .enabled, + matchScope: .exactHost + ) + ], + inputMonitoringGranted: true, + screenRecordingGranted: true, + focusSnapshot: makeSnapshot( + applicationName: "Google Chrome", + bundleIdentifier: "com.google.Chrome", + browserDomain: BrowserDomainContext( + pageURL: URL(string: "https://docs.github.com/en")!, + host: "docs.github.com", + registrableDomain: "github.com" + ), + capability: .supported + ) + ) + + XCTAssertEqual(reason, "Tabby is disabled in Google Chrome.") + } + // MARK: - disabledReason: capability passthrough /// The .blocked and .unsupported cases both surface their own reason @@ -225,6 +284,68 @@ final class SuggestionAvailabilityEvaluatorTests: XCTestCase { XCTAssertFalse(ok) } + func test_shouldSchedulePrediction_falseWhenDomainDisabled() { + let ok = SuggestionAvailabilityEvaluator.shouldSchedulePrediction( + globallyEnabled: true, + domainOverrideRules: [ + DomainOverrideRule( + host: "mail.google.com", + registrableDomain: "google.com", + state: .disabled, + matchScope: .exactHost + ) + ], + inputMonitoringGranted: true, + screenRecordingGranted: true, + focusSnapshot: makeSnapshot( + applicationName: "Safari", + bundleIdentifier: "com.apple.Safari", + browserDomain: BrowserDomainContext( + pageURL: URL(string: "https://mail.google.com")!, + host: "mail.google.com", + registrableDomain: "google.com" + ), + capability: .supported + ) + ) + + XCTAssertFalse(ok) + } + + func test_shouldSchedulePrediction_exactHostRuleWinsOverRegistrableDomainRule() { + let ok = SuggestionAvailabilityEvaluator.shouldSchedulePrediction( + globallyEnabled: true, + domainOverrideRules: [ + DomainOverrideRule( + host: "google.com", + registrableDomain: "google.com", + state: .enabled, + matchScope: .registrableDomain + ), + DomainOverrideRule( + host: "mail.google.com", + registrableDomain: "google.com", + state: .disabled, + matchScope: .exactHost + ) + ], + inputMonitoringGranted: true, + screenRecordingGranted: true, + focusSnapshot: makeSnapshot( + applicationName: "Safari", + bundleIdentifier: "com.apple.Safari", + browserDomain: BrowserDomainContext( + pageURL: URL(string: "https://mail.google.com")!, + host: "mail.google.com", + registrableDomain: "google.com" + ), + capability: .supported + ) + ) + + XCTAssertFalse(ok) + } + func test_shouldSchedulePrediction_trueWhenDifferentAppDisabled() { let ok = SuggestionAvailabilityEvaluator.shouldSchedulePrediction( globallyEnabled: true, @@ -341,6 +462,55 @@ final class FocusSnapshotExternalApplicationIdentityTests: XCTestCase { } } +/// Browser-domain identity is cached separately from app identity because menu-bar quick actions +/// need to preserve the last real browser tab even after Tabby temporarily becomes frontmost. +final class FocusSnapshotExternalBrowserDomainIdentityTests: XCTestCase { + func test_externalBrowserDomainIdentity_returnsNonTabbyBrowserDomain() { + let snapshot = FocusSnapshot( + applicationName: "Google Chrome", + bundleIdentifier: "com.google.Chrome", + capability: .supported, + context: nil, + inspection: nil, + browserDomain: BrowserDomainContext( + pageURL: URL(string: "https://docs.github.com/en")!, + host: "docs.github.com", + registrableDomain: "github.com" + ) + ) + + XCTAssertEqual( + snapshot.externalBrowserDomainIdentity(ignoredBundleIdentifier: "com.jacobfu.tabby"), + FocusedBrowserDomainIdentity( + applicationName: "Google Chrome", + bundleIdentifier: "com.google.Chrome", + pageURL: URL(string: "https://docs.github.com/en")!, + host: "docs.github.com", + registrableDomain: "github.com" + ) + ) + } + + func test_externalBrowserDomainIdentity_ignoresTabbyApplication() { + let snapshot = FocusSnapshot( + applicationName: "Tabby", + bundleIdentifier: "com.jacobfu.tabby", + capability: .blocked("Tabby is focused."), + context: nil, + inspection: nil, + browserDomain: BrowserDomainContext( + pageURL: URL(string: "https://docs.github.com/en")!, + host: "docs.github.com", + registrableDomain: "github.com" + ) + ) + + XCTAssertNil( + snapshot.externalBrowserDomainIdentity(ignoredBundleIdentifier: "com.jacobfu.tabby") + ) + } +} + /// Tests for the durable disabled-app blocklist. /// /// These live beside the evaluator tests because the two pieces form one contract: settings own @@ -499,3 +669,145 @@ final class SuggestionSettingsModelDisabledAppsTests: XCTestCase { } } } + +/// Domain override persistence belongs in `SuggestionSettingsModel`, not in the coordinator, +/// because these are durable user-authored rules that multiple UI surfaces need to share. +final class SuggestionSettingsModelDomainOverridesTests: XCTestCase { + private static var retainedModels: [SuggestionSettingsModel] = [] + private var userDefaultsSuites: [(suiteName: String, userDefaults: UserDefaults)] = [] + + override func tearDown() { + for suite in userDefaultsSuites { + suite.userDefaults.removePersistentDomain(forName: suite.suiteName) + } + userDefaultsSuites.removeAll() + super.tearDown() + } + + func test_domainOverrideRules_surviveModelRecreation() { + runOnMainActor { + let userDefaults = makeUserDefaults() + let model = makeModel(userDefaults: userDefaults) + + model.setDomainOverride(for: browserDomain(), state: .disabled) + + let reloadedModel = makeModel(userDefaults: userDefaults) + + XCTAssertEqual( + reloadedModel.domainOverrideRules, + [ + DomainOverrideRule( + host: "docs.github.com", + registrableDomain: "github.com", + state: .disabled, + matchScope: .registrableDomain + ) + ] + ) + } + } + + func test_domainOverrideRule_prefersExactHostOverRegistrableDomain() { + runOnMainActor { + let model = makeModel() + + model.setDomainOverride(for: browserDomain(), state: .enabled) + let registrableRuleID = model.domainOverrideRules[0].id + model.setDomainOverrideUsesExactHost( + ruleID: registrableRuleID, + usesExactHost: true + ) + model.setDomainOverride( + for: FocusedBrowserDomainIdentity( + applicationName: "Google Chrome", + bundleIdentifier: "com.google.Chrome", + pageURL: URL(string: "https://github.com")!, + host: "github.com", + registrableDomain: "github.com" + ), + state: .disabled + ) + + XCTAssertEqual( + model.domainOverrideRule(for: browserDomain())?.matchScope, + .exactHost + ) + XCTAssertEqual( + model.domainOverrideRule(for: browserDomain())?.state, + .enabled + ) + } + } + + func test_snapshotPublisher_emitsWhenDomainOverrideRulesChange() { + let expectation = expectation(description: "snapshot emits after domain rule changes") + var cancellables = Set() + + runOnMainActor { + let model = makeModel() + + model.snapshotPublisher + .dropFirst() + .sink { snapshot in + XCTAssertEqual(snapshot.domainOverrideRules.count, 1) + XCTAssertEqual( + snapshot.domainOverrideRules.first?.displayDomain, + "github.com" + ) + expectation.fulfill() + } + .store(in: &cancellables) + + model.setDomainOverride(for: browserDomain(), state: .disabled) + } + + wait(for: [expectation], timeout: 1.0) + _ = cancellables + } + + @MainActor + private func makeModel( + userDefaults: UserDefaults? = nil + ) -> SuggestionSettingsModel { + let model = SuggestionSettingsModel( + configuration: .standard, + userDefaults: userDefaults ?? makeUserDefaults() + ) + Self.retainedModels.append(model) + return model + } + + private func makeUserDefaults() -> UserDefaults { + let suiteName = "SuggestionSettingsModelDomainOverridesTests-\(UUID().uuidString)" + guard let userDefaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Expected an isolated UserDefaults suite") + return .standard + } + + userDefaults.removePersistentDomain(forName: suiteName) + userDefaultsSuites.append((suiteName: suiteName, userDefaults: userDefaults)) + return userDefaults + } + + private func browserDomain() -> FocusedBrowserDomainIdentity { + FocusedBrowserDomainIdentity( + applicationName: "Google Chrome", + bundleIdentifier: "com.google.Chrome", + pageURL: URL(string: "https://docs.github.com/en")!, + host: "docs.github.com", + registrableDomain: "github.com" + ) + } + + private func runOnMainActor( + _ body: @MainActor () throws -> Result + ) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } + } +}