Skip to content
Merged
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
24 changes: 22 additions & 2 deletions Cotabby/Services/Focus/ChromiumAccessibilityEnabler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,37 @@ final class ChromiumAccessibilityEnabler {
/// not advertise it). Recorded so we stop retrying a doomed call every tick.
private var unsupportedPIDs: Set<pid_t> = []

/// The frontmost PID seen on the previous poll tick, used to detect activation edges. Updated on
/// every call (including for apps we never prime) so a switch away and back is always an edge.
private var lastFrontmostPID: pid_t?

/// Primes the application if it is a Chromium/Electron surface we cover and has not been primed
/// (or marked unsupported) yet. Safe to call every poll tick.
///
/// Electron editors are additionally re-primed on every activation edge (each time the app
/// becomes frontmost), not just once per PID. Observed on VS Code: the first
/// `AXManualAccessibility` write returns success while the app is long-running, yet the web-AX
/// tree stays dormant for minutes; a later re-assert wakes it promptly. One extra AX write per
/// app switch is negligible, and Chromium browsers keep the once-per-PID behavior that already
/// works for them.
func primeIfNeeded(application: NSRunningApplication) {
let pid = application.processIdentifier
let isActivationEdge = pid != lastFrontmostPID
lastFrontmostPID = pid

guard BrowserAppDetector.needsWebAccessibilityPriming(
bundleIdentifier: application.bundleIdentifier)
else {
return
}

let pid = application.processIdentifier
guard pid > 0, !primedPIDs.contains(pid), !unsupportedPIDs.contains(pid) else {
guard pid > 0, !unsupportedPIDs.contains(pid) else {
return
}

let reassertForElectronEditor = isActivationEdge
&& BrowserAppDetector.isElectronEditor(bundleIdentifier: application.bundleIdentifier)
guard !primedPIDs.contains(pid) || reassertForElectronEditor else {
return
}

Expand Down
23 changes: 19 additions & 4 deletions Cotabby/Support/BrowserAppDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,21 @@ enum BrowserAppDetector {
/// Electron apps (Chromium under the hood) that ship editors worth covering. This is an
/// intentional named allowlist, not a blanket Electron opt-in: most Electron apps are not
/// text-editing surfaces, and priming them wholesale risks unexpected behavior.
///
/// Entries are lowercased and matched case-insensitively (see `isElectronEditor`). VS Code's real
/// bundle id is the mixed-case `com.microsoft.VSCode`, so an exact match would silently miss it
/// and leave the editor's entire Electron AX tree dormant: no focused field resolves for the
/// editor, the Copilot chat, or the integrated terminal, so no suggestions appear anywhere in the
/// app even though screenshot-based OCR keeps working.
///
/// Cursor is intentionally absent: it ships under opaque ToDesktop bundle ids
/// (`com.todesktop.<hash>`) that change between builds, so there is no stable id to allowlist
/// here without a broad `com.todesktop.` prefix that would also prime unrelated ToDesktop apps.
private static let electronEditorBundleIdentifiers: Set<String> = [
"com.clickup.desktop-app"
"com.clickup.desktop-app",
"com.microsoft.vscode", // Visual Studio Code
"com.microsoft.vscodeinsiders", // VS Code - Insiders
"com.vscodium" // VSCodium (FOSS VS Code build)
]

/// Broad check: is the user typing inside any web browser? Used for prompt tone hints.
Expand All @@ -53,10 +66,12 @@ enum BrowserAppDetector {
hasMatchingPrefix(bundleIdentifier, in: chromiumBundlePrefixes)
}

/// Is this a named Electron editor we intentionally cover?
/// Is this a named Electron editor we intentionally cover? Case-insensitive because macOS bundle
/// ids are case-insensitive in practice and VS Code's is mixed-case (`com.microsoft.VSCode`); a
/// case-sensitive exact match here was the reason VS Code resolved no focus and got no suggestions.
static func isElectronEditor(bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return electronEditorBundleIdentifiers.contains(bundleIdentifier)
guard let lowered = bundleIdentifier?.lowercased() else { return false }
return electronEditorBundleIdentifiers.contains(lowered)
}

/// Gate for the Chromium/Electron-specific AX recovery paths (renderer priming, cursor
Expand Down
8 changes: 8 additions & 0 deletions CotabbyTests/BrowserAppDetectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ final class BrowserAppDetectorTests: XCTestCase {

func testElectronEditorAllowlist() {
XCTAssertTrue(BrowserAppDetector.isElectronEditor(bundleIdentifier: "com.clickup.desktop-app"))
// VS Code ships under the mixed-case `com.microsoft.VSCode`; matching must be case-insensitive
// or its entire Electron AX tree stays dormant and no suggestions ever resolve.
XCTAssertTrue(BrowserAppDetector.isElectronEditor(bundleIdentifier: "com.microsoft.VSCode"))
XCTAssertTrue(BrowserAppDetector.isElectronEditor(bundleIdentifier: "com.microsoft.VSCodeInsiders"))
XCTAssertTrue(BrowserAppDetector.isElectronEditor(bundleIdentifier: "com.vscodium"))
// Electron, but not a text-editing surface we cover: must stay out of the priming allowlist.
XCTAssertFalse(BrowserAppDetector.isElectronEditor(bundleIdentifier: "com.hnc.Discord"))
XCTAssertFalse(BrowserAppDetector.isElectronEditor(bundleIdentifier: nil))
}
Expand All @@ -41,6 +47,8 @@ final class BrowserAppDetectorTests: XCTestCase {
BrowserAppDetector.needsWebAccessibilityPriming(bundleIdentifier: "com.google.Chrome"))
XCTAssertTrue(
BrowserAppDetector.needsWebAccessibilityPriming(bundleIdentifier: "com.clickup.desktop-app"))
XCTAssertTrue(
BrowserAppDetector.needsWebAccessibilityPriming(bundleIdentifier: "com.microsoft.VSCode"))
XCTAssertFalse(
BrowserAppDetector.needsWebAccessibilityPriming(bundleIdentifier: "com.apple.Safari"))
XCTAssertFalse(
Expand Down