diff --git a/Mojito.xcodeproj/project.pbxproj b/Mojito.xcodeproj/project.pbxproj index e5ef085..8d83397 100644 --- a/Mojito.xcodeproj/project.pbxproj +++ b/Mojito.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 5EBE8D89D81DB7FF6AF09968 /* TadaSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3E46F6B55025038F101A4A /* TadaSound.swift */; }; 62417E7F0AA71DEF9928E2B9 /* SynthRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 212C94A63FBCB28A41CA1A1A /* SynthRenderer.swift */; }; 63C4CA064E4D7B82A160275D /* PanelPositioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7C809FB011043F44297508 /* PanelPositioner.swift */; }; + 64D188B1FE9F71925A3FD279 /* BrowserURLCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D63BC4F9673FBBC43A3699 /* BrowserURLCacheTests.swift */; }; 6607717F2E4D11B5563F8184 /* Shortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC79E6EAFAF655E0E63E78DC /* Shortcuts.swift */; }; 67560B8C0EC9028ED9659937 /* EmojiRain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BF5C449E03B6F883693B87 /* EmojiRain.swift */; }; 67B986DB09F844C3688F3A31 /* v03.bin in Resources */ = {isa = PBXBuildFile; fileRef = 2C4CF18CE5AC46767EFCA557 /* v03.bin */; }; @@ -327,6 +328,7 @@ C31E41F813A67872400E4942 /* AnimatedGifView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedGifView.swift; sourceTree = ""; }; C3B79AAA76C55A5B4FB087A6 /* EmojiBrowserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiBrowserViewModel.swift; sourceTree = ""; }; C410F8B7AF8493C098EA2AF1 /* ConfettiRain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiRain.swift; sourceTree = ""; }; + C4D63BC4F9673FBBC43A3699 /* BrowserURLCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserURLCacheTests.swift; sourceTree = ""; }; C7CD240E9450366313F15198 /* PrivacyPermissionsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyPermissionsSettings.swift; sourceTree = ""; }; C876AD00FBA92A6CA56ABD95 /* s07.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = s07.bin; sourceTree = ""; }; CAD51C35233BDBC98AE0418B /* TelemetryConsent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryConsent.swift; sourceTree = ""; }; @@ -392,6 +394,7 @@ children = ( 71409240C4105D980C71E264 /* AliasTests.swift */, F36FC6BE9A109EAEE6A18CE1 /* AmbientEmoticonTableTests.swift */, + C4D63BC4F9673FBBC43A3699 /* BrowserURLCacheTests.swift */, 6C69999E4AC7F8B8C054B395 /* DebugReportTests.swift */, B3042E363CC429F15721773F /* EasterEggOrderingTests.swift */, A1B842CBBD6C7466F9BA2786 /* EmojiDatabaseTests.swift */, @@ -1054,6 +1057,7 @@ files = ( 8D768748EE3F9F440F3F94EA /* AliasTests.swift in Sources */, EFCA5419E2CE5D16F2DE7DB3 /* AmbientEmoticonTableTests.swift in Sources */, + 64D188B1FE9F71925A3FD279 /* BrowserURLCacheTests.swift in Sources */, B878C3CC891907E89DE33158 /* DebugReportTests.swift in Sources */, B33ABD1825CC5908D3DE7030 /* EasterEggOrderingTests.swift in Sources */, DD501615C4A6623E5BA03369 /* EmojiDatabaseTests.swift in Sources */, diff --git a/Sources/Mojito/Context/BrowserURL.swift b/Sources/Mojito/Context/BrowserURL.swift index d7865da..9a601d2 100644 --- a/Sources/Mojito/Context/BrowserURL.swift +++ b/Sources/Mojito/Context/BrowserURL.swift @@ -9,6 +9,26 @@ enum BrowserURL { guard let bundleID, let pid else { return nil } guard isBrowser(bundleID: bundleID) else { return nil } + // Arc suppresses the Chromium web-content a11y tree entirely — no + // AXWebArea, no AXURL, and its address bar is a transient command bar + // with no persistent AXTextField. The AX walk below always comes up nil + // for it, so per-site exclusions can only be read via AppleScript. Both + // of those cost matter here because `detect` runs inside the CGEventTap + // callback (see `Engine.process`): the synchronous AppleScript Apple + // Event AND the multi-node AX walk are each cross-process IPC that, on a + // busy Arc, blow past the tap timeout — macOS then disables the tap and + // drops the keystroke, which stalled Arc's command bar on every + // word+terminator (W-555). So for Arc we skip the AX walk entirely and + // serve the URL from an async cache (`BrowserURLCache`), which keeps the + // AppleScript on the main thread — where NSAppleScript is supported — + // but on its own run-loop turn, never inside the tap callback. Gated to + // this one bundle ID: it's the only browser that needs AppleScript (Dia, + // same vendor, exposes AXURL fine), and the first call triggers a + // one-time Automation prompt we don't want to inflict elsewhere. + if BrowserURLCache.appleScriptBundleIDs.contains(bundleID) { + return BrowserURLCache.shared.url(forBundleID: bundleID, pid: pid) + } + let app = AXUIElementCreateApplication(pid) // Most browsers expose the page URL as an `AXURL` attribute somewhere @@ -21,39 +41,9 @@ enum BrowserURL { if let raw = focusedAddressBarValue(in: app), let url = normalizedURL(from: raw) { return url } - - // Arc suppresses the Chromium web-content a11y tree entirely — no - // AXWebArea, no AXURL, and its address bar is a transient command bar - // with no persistent AXTextField. The AX paths above always return nil - // there, so per-site exclusions silently can't fire. AppleScript is the - // only way to read Arc's tab URL. It's gated to this one bundle ID - // because it's the sole browser we've found that needs it (Dia, same - // vendor, exposes AXURL fine) and because the first call triggers a - // one-time Automation permission prompt we don't want to inflict on - // browsers the AX path already handles. - if appleScriptURLBundleIDs.contains(bundleID), - let url = appleScriptURL(bundleID: bundleID) { - return url - } return nil } - private static let appleScriptURLBundleIDs: Set = [ - "company.thebrowser.Browser", // Arc - ] - - /// `URL of active tab of front window` via AppleScript. Returns nil on any - /// failure — no window, denied Automation permission, or a non-URL value — - /// so callers fall through to "no URL" exactly as if AX had come up empty. - private static func appleScriptURL(bundleID: String) -> URL? { - let source = "tell application id \"\(bundleID)\" to return URL of active tab of front window" - guard let script = NSAppleScript(source: source) else { return nil } - var error: NSDictionary? - let result = script.executeAndReturnError(&error) - guard error == nil, let raw = result.stringValue else { return nil } - return normalizedURL(from: raw) - } - private static func isBrowser(bundleID: String) -> Bool { knownBrowserBundleIDs.contains(bundleID) } @@ -179,10 +169,146 @@ enum BrowserURL { return result == .success ? ref : nil } - private static func normalizedURL(from raw: String) -> URL? { + fileprivate static func normalizedURL(from raw: String) -> URL? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } if trimmed.contains("://") { return URL(string: trimmed) } return URL(string: "https://" + trimmed) } } + +/// URL cache for browsers whose tab URL is only readable via AppleScript (Arc). +/// `BrowserURL.detect` is called synchronously inside the CGEventTap callback, +/// so it can't run the AppleScript there — a slow Apple Event trips the tap +/// timeout and drops the keystroke (W-555). But `NSAppleScript` is documented +/// main-thread-only (Cocoa Thread Safety Summary), so it can't just be shoved +/// onto a background queue either. +/// +/// Resolution: the hot-path read (`url(forBundleID:pid:)`) never does IPC — it +/// returns the last resolved value and schedules a refresh. The refresh runs +/// the AppleScript on the main thread, where it's supported, but via +/// `DispatchQueue.main.async` so it lands on its own run-loop turn rather than +/// inside the tap callback. A throttle keeps refreshes to at most one per +/// second, so the brief main-thread block can't recur per keystroke the way the +/// old synchronous call did. Same spirit as `FocusedElementCache` moving slow +/// IPC out of the tap path (W-547). Bounded staleness (≈ one keystroke, or one +/// navigation until the next read/refresh) is acceptable for per-site exclusion +/// matching; the value is only served for the pid it was resolved from, so it +/// can't bleed across an app switch. +@MainActor +final class BrowserURLCache { + static let shared = BrowserURLCache( + observeActivations: true, + minRefreshInterval: 1.0, + now: { Date() }, + resolver: { BrowserURLCache.appleScriptURL(bundleID: $0) } + ) + + static let appleScriptBundleIDs: Set = [ + "company.thebrowser.Browser", // Arc + ] + + /// Last resolved URL and the pid it belongs to. A `nil` url is a real + /// answer (no window / denied Automation / non-URL value), cached as such + /// via `haveResult` so we don't refetch it every keystroke. + private var cachedURL: URL? + private var cachedPID: pid_t? + private var haveResult = false + + /// Single-flight: one refresh scheduled at a time, so a burst of reads (a + /// word+terminator produces several) collapses to one AppleScript. Always + /// cleared by the scheduled block, so a failed/timed-out script can't wedge + /// it (the block runs regardless of the script's result). + private var refreshing = false + + /// Throttles opportunistic per-read refreshes; app-activation refreshes + /// bypass it (`force`) since a switch is infrequent and changes the pid. + private var lastRefreshAt: Date? + private let minRefreshInterval: TimeInterval + + /// Seams. Production wires the real `NSAppleScript` resolver and wall clock; + /// tests inject a stub resolver + controllable clock so the deferral, + /// single-flight, throttle, and pid-guard are checkable without AppleScript + /// or a real app switch (`observeActivations: false`). + private let now: () -> Date + private let resolver: (String) -> URL? + + init( + observeActivations: Bool, + minRefreshInterval: TimeInterval, + now: @escaping () -> Date, + resolver: @escaping (String) -> URL? + ) { + self.minRefreshInterval = minRefreshInterval + self.now = now + self.resolver = resolver + guard observeActivations else { return } + // Prefetch on activation so switching to Arc and immediately typing has + // a warm URL rather than a first-keystroke miss. The closure refers to + // `.shared` rather than capturing `self`, so it doesn't retain the + // singleton (which lives for the app's lifetime anyway). + NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { _ in + MainActor.assumeIsolated { + guard let app = NSWorkspace.shared.frontmostApplication, + let bundleID = app.bundleIdentifier, + BrowserURLCache.appleScriptBundleIDs.contains(bundleID) else { return } + BrowserURLCache.shared.scheduleRefresh( + bundleID: bundleID, pid: app.processIdentifier, force: true + ) + } + } + } + + /// Cheap synchronous read for the hot path — no AppleScript, no AX IPC on + /// this thread, so it never blocks the tap callback. Returns the cached URL + /// only if it belongs to this pid, and schedules a throttled refresh so the + /// next read reflects any navigation. A pid mismatch (or no result yet) + /// reads as nil — exclusions treat that as "no URL", same as the AX paths + /// coming up empty; the refresh fills it in for the following keystroke. + func url(forBundleID bundleID: String, pid: pid_t) -> URL? { + let value = (haveResult && cachedPID == pid) ? cachedURL : nil + scheduleRefresh(bundleID: bundleID, pid: pid, force: false) + return value + } + + /// Resolves the URL on a *later* main-run-loop turn. `NSAppleScript` must + /// run on the main thread, but never inside the tap callback that calls + /// `detect` — `DispatchQueue.main.async` gives it its own turn, and the + /// throttle keeps it rare enough that the brief main-thread block can't + /// stall typing the way the per-keystroke synchronous call did. + private func scheduleRefresh(bundleID: String, pid: pid_t, force: Bool) { + guard !refreshing else { return } + if !force, haveResult, let last = lastRefreshAt, + now().timeIntervalSince(last) < minRefreshInterval { + return + } + refreshing = true + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.cachedURL = self.resolver(bundleID) + self.cachedPID = pid + self.haveResult = true + self.lastRefreshAt = self.now() + self.refreshing = false + } + } + } + + /// `URL of active tab of front window` via AppleScript. Returns nil on any + /// failure — no window, denied Automation permission, or a non-URL value — + /// so callers fall through to "no URL" exactly as if AX had come up empty. + /// Called only from `scheduleRefresh`'s main-thread block. + private static func appleScriptURL(bundleID: String) -> URL? { + let source = "tell application id \"\(bundleID)\" to return URL of active tab of front window" + guard let script = NSAppleScript(source: source) else { return nil } + var error: NSDictionary? + let result = script.executeAndReturnError(&error) + guard error == nil, let raw = result.stringValue else { return nil } + return BrowserURL.normalizedURL(from: raw) + } +} diff --git a/Tests/MojitoTests/BrowserURLCacheTests.swift b/Tests/MojitoTests/BrowserURLCacheTests.swift new file mode 100644 index 0000000..3fbea71 --- /dev/null +++ b/Tests/MojitoTests/BrowserURLCacheTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing +@testable import Mojito + +/// Guards the W-555 fix: reading Arc's tab URL must never do blocking IPC on the +/// caller's thread (that thread is the CGEventTap callback, and a synchronous +/// AppleScript there tripped the tap timeout and dropped keystrokes). These +/// pin the concurrency contract — deferred resolve, single-flight, throttle, +/// pid-guard — with a stub resolver and a controllable clock, so no live Arc, +/// AppleScript, or app switch is needed. +/// +/// The end-to-end "keys don't drop while typing in Arc" behavior is timing- and +/// Arc-dependent and can't be asserted deterministically; this instead nails the +/// property that *causes* the drop when violated. +@MainActor +struct BrowserURLCacheTests { + + private static let arc = "company.thebrowser.Browser" + + /// Records how the injected resolver was called — count, argument, and the + /// thread — so tests can assert the AppleScript work is deferred to a later + /// main-thread turn rather than run inline on the hot path. + private final class ResolverSpy { + var calls = 0 + var lastBundleID: String? + var ranOnMainThread = false + var stub: URL? + func resolve(_ bundleID: String) -> URL? { + calls += 1 + lastBundleID = bundleID + ranOnMainThread = Thread.isMainThread + return stub + } + } + + private final class Clock { + var now: Date + init(_ start: Date) { now = start } + } + + private func makeCache( + spy: ResolverSpy, + clock: Clock, + minRefreshInterval: TimeInterval = 1_000 + ) -> BrowserURLCache { + BrowserURLCache( + observeActivations: false, + minRefreshInterval: minRefreshInterval, + now: { clock.now }, + resolver: { spy.resolve($0) } + ) + } + + /// Let the main queue drain the `DispatchQueue.main.async` refresh block. + private func drain() async throws { + try await Task.sleep(nanoseconds: 30_000_000) + } + + // MARK: - The core contract + + @Test func hotPathReturnsNilWhenColdAndDefersTheResolve() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://example.com") + let cache = makeCache(spy: spy, clock: Clock(Date())) + + let value = cache.url(forBundleID: Self.arc, pid: 42) + + // Cold cache → nil, and — the crux — the resolver has NOT run yet. + // If it ran synchronously here, that's the AppleScript back on the tap + // thread and the bug is reintroduced. + #expect(value == nil) + #expect(spy.calls == 0) + + try await drain() + + // It runs on a later turn, on the main thread (where NSAppleScript is + // supported), exactly once. + #expect(spy.calls == 1) + #expect(spy.ranOnMainThread) + #expect(spy.lastBundleID == Self.arc) + } + + @Test func servesCachedValueForSamePidAfterRefresh() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://example.com") + let cache = makeCache(spy: spy, clock: Clock(Date())) + + _ = cache.url(forBundleID: Self.arc, pid: 42) + try await drain() + + #expect(cache.url(forBundleID: Self.arc, pid: 42) == URL(string: "https://example.com")) + } + + @Test func doesNotServeValueAcrossPidChange() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://example.com") + let cache = makeCache(spy: spy, clock: Clock(Date())) + + _ = cache.url(forBundleID: Self.arc, pid: 42) + try await drain() + + // A different pid means a different app instance — the URL resolved for + // pid 42 must not leak to pid 99. + #expect(cache.url(forBundleID: Self.arc, pid: 99) == nil) + } + + @Test func burstOfReadsCollapsesToOneResolve() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://example.com") + let cache = makeCache(spy: spy, clock: Clock(Date())) + + // A word+terminator produces several reads back-to-back; single-flight + // must collapse them to one AppleScript. + for _ in 0..<5 { _ = cache.url(forBundleID: Self.arc, pid: 42) } + try await drain() + + #expect(spy.calls == 1) + } + + @Test func throttleSkipsRefreshWithinIntervalThenAllowsAfter() async throws { + let spy = ResolverSpy(); spy.stub = URL(string: "https://a.example") + let clock = Clock(Date(timeIntervalSince1970: 10_000)) + let cache = makeCache(spy: spy, clock: clock, minRefreshInterval: 1.0) + + _ = cache.url(forBundleID: Self.arc, pid: 42) + try await drain() + #expect(spy.calls == 1) + + // Within the throttle window: read again, no new resolve, still serving A. + clock.now += 0.5 + spy.stub = URL(string: "https://b.example") + #expect(cache.url(forBundleID: Self.arc, pid: 42) == URL(string: "https://a.example")) + try await drain() + #expect(spy.calls == 1) + + // Past the window: the next read refreshes and picks up B. + clock.now += 0.6 + _ = cache.url(forBundleID: Self.arc, pid: 42) + try await drain() + #expect(spy.calls == 2) + #expect(cache.url(forBundleID: Self.arc, pid: 42) == URL(string: "https://b.example")) + } + + @Test func hotPathReturnsPromptlyEvenIfResolverWouldBlock() async throws { + // Simulate a slow AppleScript: the resolver sleeps. The hot-path read + // must still return without waiting for it (the whole point of the fix). + let cache = BrowserURLCache( + observeActivations: false, + minRefreshInterval: 0, + now: { Date() }, + resolver: { _ in Thread.sleep(forTimeInterval: 0.2); return URL(string: "https://slow.example") } + ) + + let start = Date() + _ = cache.url(forBundleID: Self.arc, pid: 42) + let elapsed = Date().timeIntervalSince(start) + + // The read itself did no blocking work — well under the resolver's 200ms. + #expect(elapsed < 0.05) + try await drain() + } + + @Test func arcIsGatedIntoTheAppleScriptPath() { + #expect(BrowserURLCache.appleScriptBundleIDs.contains(Self.arc)) + } +}