Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions tabby/App/Coordinators/SuggestionCoordinator+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tabby/App/Coordinators/SuggestionCoordinator+Prediction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
168 changes: 167 additions & 1 deletion tabby/Models/FocusModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
"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
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions tabby/Models/FocusTrackingModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
71 changes: 71 additions & 0 deletions tabby/Models/SuggestionEngineModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
let domainOverrideRules: [DomainOverrideRule]
let selectedEngine: SuggestionEngineKind
let selectedWordCountPreset: SuggestionWordCountPreset
/// Normalized user-authored guidance for Tabby's instruction-rendered completion prompt.
Expand Down
Loading
Loading