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
4 changes: 4 additions & 0 deletions Mojito.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -327,6 +328,7 @@
C31E41F813A67872400E4942 /* AnimatedGifView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedGifView.swift; sourceTree = "<group>"; };
C3B79AAA76C55A5B4FB087A6 /* EmojiBrowserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiBrowserViewModel.swift; sourceTree = "<group>"; };
C410F8B7AF8493C098EA2AF1 /* ConfettiRain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiRain.swift; sourceTree = "<group>"; };
C4D63BC4F9673FBBC43A3699 /* BrowserURLCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserURLCacheTests.swift; sourceTree = "<group>"; };
C7CD240E9450366313F15198 /* PrivacyPermissionsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyPermissionsSettings.swift; sourceTree = "<group>"; };
C876AD00FBA92A6CA56ABD95 /* s07.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; path = s07.bin; sourceTree = "<group>"; };
CAD51C35233BDBC98AE0418B /* TelemetryConsent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetryConsent.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -392,6 +394,7 @@
children = (
71409240C4105D980C71E264 /* AliasTests.swift */,
F36FC6BE9A109EAEE6A18CE1 /* AmbientEmoticonTableTests.swift */,
C4D63BC4F9673FBBC43A3699 /* BrowserURLCacheTests.swift */,
6C69999E4AC7F8B8C054B395 /* DebugReportTests.swift */,
B3042E363CC429F15721773F /* EasterEggOrderingTests.swift */,
A1B842CBBD6C7466F9BA2786 /* EmojiDatabaseTests.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
188 changes: 157 additions & 31 deletions Sources/Mojito/Context/BrowserURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String> = [
"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)
}
Expand Down Expand Up @@ -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<String> = [
"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)
}
}
Loading
Loading