diff --git a/Cotabby/Services/Focus/ChromiumAccessibilityEnabler.swift b/Cotabby/Services/Focus/ChromiumAccessibilityEnabler.swift index d4b8fcff..15761683 100644 --- a/Cotabby/Services/Focus/ChromiumAccessibilityEnabler.swift +++ b/Cotabby/Services/Focus/ChromiumAccessibilityEnabler.swift @@ -27,17 +27,37 @@ final class ChromiumAccessibilityEnabler { /// not advertise it). Recorded so we stop retrying a doomed call every tick. private var unsupportedPIDs: Set = [] + /// 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 } diff --git a/Cotabby/Support/BrowserAppDetector.swift b/Cotabby/Support/BrowserAppDetector.swift index 3ae05be7..35821da0 100644 --- a/Cotabby/Support/BrowserAppDetector.swift +++ b/Cotabby/Support/BrowserAppDetector.swift @@ -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.`) 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 = [ - "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. @@ -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 diff --git a/CotabbyTests/BrowserAppDetectorTests.swift b/CotabbyTests/BrowserAppDetectorTests.swift index 135bdccf..4b490178 100644 --- a/CotabbyTests/BrowserAppDetectorTests.swift +++ b/CotabbyTests/BrowserAppDetectorTests.swift @@ -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)) } @@ -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(