Skip to content

Make per-site disable live: capture the focused page URL#529

Merged
FuJacob merged 1 commit into
mainfrom
feat/per-domain-capture
Jun 2, 2026
Merged

Make per-site disable live: capture the focused page URL#529
FuJacob merged 1 commit into
mainfrom
feat/per-domain-capture

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented Jun 2, 2026

Summary

#527 landed the pure matching core for per-site disable but left it inert. This wires it end to end behind a default-off cotabbyPerDomainDisableEnabled flag, so a domain on the user's list actually suppresses autocomplete the way a disabled app does.

  • AXHelper.webURL(near:) — a fail-safe, read-only walk up a bounded number of ancestors reading kAXURLAttribute (browsers expose it on the web area / window, not the focused field). Returns nil on any miss, tolerates the value arriving as URL/NSURL/String, and never mutates AX state.
  • FocusedInputSnapshot.focusedURLString — a new optional field, populated by FocusSnapshotResolver only when the feature is enabled, so the default focus-capture path performs zero extra Accessibility round-trips.
  • SuggestionAvailabilityEvaluator reads the URL straight off the snapshot it already receives; the disabled-domains list comes from PerDomainDisableSettings (a defaults-backed array, hidden-flag pattern, no UI yet), threaded through all availability call sites alongside the existing per-app list.

Validation

xcodebuild ... test ... CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
  -only-testing:CotabbyTests/PerDomainDisableSettingsTests \
  -only-testing:CotabbyTests/BrowserDomainTests \
  -only-testing:CotabbyTests/SuggestionAvailabilityEvaluatorTests
# ** TEST SUCCEEDED **  (full build of the app target compiles the resolver, AX, and
#   coordinator wiring; every existing availability test passes unchanged)
#   PerDomainDisableSettings: flag + list default off/empty, read stored values
#   evaluator: per-site reason fires from the snapshot URL; inert with no list

swiftlint --strict   # exit 0 (CI-equivalent, lints Cotabby/)
xcodegen generate    # registered the new source + test file

To exercise on device: defaults write com.jacobfu.tabby cotabbyPerDomainDisableEnabled -bool YES and defaults write com.jacobfu.tabby cotabbyDisabledDomains -array bank.com.

Linked issues

None. Completes the per-site disable feature started in #527.

Risk / rollout notes

  • Default-off and inert by construction. With the flag off (or an empty list, or non-browser focus) the URL is never read and the gate never fires, so behavior is byte-for-byte unchanged — the existing availability tests confirm this. The focusedURLString field defaults to nil, so the two FocusedInputSnapshot construction sites are unaffected.
  • The AX read is fail-safe (nil on any miss), so even an imperfect read cannot cause a regression — at worst the feature stays inert.
  • Needs on device before promoting: that the URL read resolves across Chrome (OOPIF), Safari, and Arc, and adds no measurable focus-capture latency when enabled; plus a settings UI for the domain list (the defaults-backed array is the dogfood surface).
  • project.pbxproj regenerated by XcodeGen for the two new files.

Greptile Summary

This PR wires the per-site disable feature end-to-end: a new AXHelper.webURL(near:) walks the AX ancestor tree to find the browser's page URL, which is stored in a new focusedURLString field on FocusedInputSnapshot and checked by SuggestionAvailabilityEvaluator against a UserDefaults-backed domain list. The entire path is gated behind a default-off cotabbyPerDomainDisableEnabled flag, leaving existing behavior byte-for-byte unchanged.

  • AXHelper.webURL(near:) climbs up to maxClimb ancestors using kAXURLAttribute, returning nil on any miss; FocusSnapshotResolver only invokes it when the feature flag is on.
  • PerDomainDisableSettings is a thin, injectable UserDefaults reader for the flag and domain list; SuggestionAvailabilityEvaluator adds a disabledDomains parameter threaded through all call sites in the three coordinator files.
  • New test files cover the settings reader and updated evaluator cases; existing availability tests compile and pass unchanged.

Confidence Score: 4/5

Safe to merge; the feature is default-off and the gating path has no effect on existing behavior when the flag is unset.

The AX ancestor walk in webURL uses a closed range (0...maxClimb) that makes one extra parentElement() call on the final iteration when no URL is found — a wasted AX round-trip per focus event when the feature is enabled. The coordinator files also call PerDomainDisableSettings.disabledDomains() independently at each evaluator site rather than reading once per event handler. Neither issue affects correctness, but the AX call is worth tidying before the feature exits dogfood.

Cotabby/Support/AXHelper.swift — the webURL loop boundary; Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift — redundant disabledDomains() reads per event.

Important Files Changed

Filename Overview
Cotabby/Support/AXHelper.swift Adds webURL(near:maxClimb:) + urlString(on:) helpers; closed-range loop makes one extra parentElement() AX call on the final iteration when no URL is found.
Cotabby/Support/PerDomainDisableSettings.swift New thin UserDefaults reader for the per-site flag and domain list; default-off, cleanly injectable for tests.
Cotabby/Support/SuggestionAvailabilityEvaluator.swift Adds disabledDomains parameter to all three evaluator entry points and wires the per-site gate to focusSnapshot.context?.focusedURLString; logic is correct and the gate stays inert when URL is nil.
Cotabby/Models/FocusModels.swift Adds optional focusedURLString field to FocusedInputSnapshot with a nil default; existing call sites are unaffected.
Cotabby/Services/Focus/FocusSnapshotResolver.swift Populates focusedURLString only when PerDomainDisableSettings.isEnabled() is true, keeping the default focus-capture path free of extra AX round-trips.
Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift Threads disabledDomains through five evaluator call sites; PerDomainDisableSettings.disabledDomains() is called separately at each site rather than once per event handler.
CotabbyTests/PerDomainDisableSettingsTests.swift New tests using an isolated UserDefaults suite; covers default-off and stored values for both the flag and domain list.
CotabbyTests/SuggestionAvailabilityEvaluatorTests.swift Updates helper to pass focusedURLString through the snapshot; adds per-site disable and inert-by-default test cases.

Sequence Diagram

sequenceDiagram
    participant Coord as SuggestionCoordinator
    participant Resolver as FocusSnapshotResolver
    participant Settings as PerDomainDisableSettings
    participant AX as AXHelper
    participant Eval as SuggestionAvailabilityEvaluator
    participant Domain as BrowserDomain

    Coord->>Resolver: resolve(focusedElement)
    Resolver->>Settings: isEnabled()
    alt feature enabled
        Settings-->>Resolver: true
        Resolver->>AX: webURL(near: focusedElement)
        AX->>AX: urlString(on: element) x up to maxClimb levels
        AX-->>Resolver: url string or nil
    else feature disabled
        Settings-->>Resolver: false
        Note over Resolver: focusedURLString = nil (no AX call)
    end
    Resolver-->>Coord: FocusSnapshot (focusedURLString set or nil)

    Coord->>Settings: disabledDomains()
    Settings-->>Coord: "Set<String>"
    Coord->>Eval: disabledReason(disabledDomains:focusSnapshot:...)
    Eval->>Eval: focusSnapshot.context?.focusedURLString
    alt URL present
        Eval->>Domain: host(fromURLString:)
        Domain-->>Eval: host string
        Eval->>Domain: isHostDisabled(host, disabledDomains)
        Domain-->>Eval: true/false
        alt host disabled
            Eval-->>Coord: Cotabby is disabled on host.
        else host not disabled
            Eval-->>Coord: nil (enabled)
        end
    else URL nil (feature off or non-browser)
        Eval-->>Coord: nil (enabled)
    end
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Make per-site disable live: capture the ..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

#527 landed the matching core; this wires it end to end behind a default-off
`cotabbyPerDomainDisableEnabled` flag so a domain on the user's list actually
suppresses autocomplete.

- `AXHelper.webURL(near:)` is a fail-safe, read-only walk up a bounded number of
  ancestors for `kAXURLAttribute` (browsers expose it on the web area, not the
  field). It returns nil on any miss, so a failed read degrades to "no per-site
  rule applies" rather than misbehaving.
- `FocusSnapshotResolver` reads it into a new `FocusedInputSnapshot.focusedURLString`
  *only* when the feature is enabled, so the default focus-capture path performs no
  extra Accessibility round-trips.
- `SuggestionAvailabilityEvaluator` reads that URL straight off the snapshot it
  already receives; the disabled-domains list comes from `PerDomainDisableSettings`
  (a `defaults`-backed array, no UI yet) and is threaded through the availability
  call sites alongside the existing per-app list.

Default-off and inert by construction: with the flag off (or an empty list, or a
non-browser focus) behavior is byte-for-byte unchanged, which the existing
availability tests confirm. Promoting it needs on-device validation that the URL
read resolves across Chrome (OOPIF), Safari, and Arc, and adds no measurable
focus-capture latency — and a settings UI for the list.
@FuJacob FuJacob merged commit 4728310 into main Jun 2, 2026
4 checks passed
@FuJacob FuJacob deleted the feat/per-domain-capture branch June 2, 2026 03:35
Comment on lines +502 to +512
static func webURL(near element: AXUIElement, maxClimb: Int = 6) -> String? {
var current = element
for _ in 0...maxClimb {
if let url = urlString(on: current) {
return url
}
guard let parent = parentElement(of: current) else { break }
current = parent
}
return nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The closed range 0...maxClimb makes maxClimb + 1 iterations (e.g., 7 when maxClimb = 6). On the last iteration, after the URL check fails, the function still calls parentElement(of: current) and assigns the result to current, but that value is never used — the loop ends. This is one wasted AX round-trip per call when no URL is found at the deepest level. Changing the range to 0..<maxClimb and adding a final check outside the loop eliminates the extra call while preserving the same traversal depth.

Suggested change
static func webURL(near element: AXUIElement, maxClimb: Int = 6) -> String? {
var current = element
for _ in 0...maxClimb {
if let url = urlString(on: current) {
return url
}
guard let parent = parentElement(of: current) else { break }
current = parent
}
return nil
}
static func webURL(near element: AXUIElement, maxClimb: Int = 6) -> String? {
var current = element
for _ in 0..<maxClimb {
if let url = urlString(on: current) {
return url
}
guard let parent = parentElement(of: current) else { return nil }
current = parent
}
return urlString(on: current)
}

Fix in Codex Fix in Claude Code

Comment on lines 50 to 54
SuggestionAvailabilityEvaluator.shouldCaptureVisualContext(
globallyEnabled: settingsSnapshot.isGloballyEnabled,
disabledAppBundleIdentifiers: settingsSnapshot.disabledAppBundleIdentifiers,
disabledDomains: PerDomainDisableSettings.disabledDomains(),
inputMonitoringGranted: permissionManager.inputMonitoringGranted,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 PerDomainDisableSettings.disabledDomains() is called at every evaluator call site independently, which means a single focus-change event (e.g., handleFocusSnapshotChange) triggers multiple UserDefaults.stringArray(forKey:) reads in sequence. Within that one function call alone there are two consecutive reads (one for shouldCaptureVisualContext, one for disabledReason). The same pattern appears across SuggestionCoordinator+Lifecycle.swift and SuggestionCoordinator+Prediction.swift. Caching the result in a local let at the top of each event-handler function (or reading it once in the coordinator) would remove the redundant lookups.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant