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
18 changes: 9 additions & 9 deletions Sources/LockIME/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -366,54 +366,54 @@
}
}
},
"Per-URL rules work in Safari and Chromium-based browsers (Chrome, Edge, Brave, Arc, Vivaldi, Opera). Firefox is not supported, because it does not expose the active tab's URL through the macOS Accessibility API.": {
"Per-URL rules work in Safari, Firefox, and Chromium-based browsers (Chrome, Edge, Brave, Arc, Vivaldi, Opera).": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "按网址的规则仅适用于 Safari 和基于 Chromium 的浏览器(Chrome、Edge、Brave、Arc、Vivaldi、Opera)。不支持 Firefox,因为它不会通过 macOS 辅助功能(Accessibility)API 暴露当前标签页的网址。"
"value": "按网址的规则适用于 Safari、Firefox 以及基于 Chromium 的浏览器(Chrome、Edge、Brave、Arc、Vivaldi、Opera)。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "依網址的規則僅適用於 Safari 與基於 Chromium 的瀏覽器(Chrome、Edge、Brave、Arc、Vivaldi、Opera)。不支援 Firefox,因為它不會透過 macOS 輔助使用(Accessibility)API 提供目前分頁的網址。"
"value": "依網址的規則適用於 Safari、Firefox 以及基於 Chromium 的瀏覽器(Chrome、Edge、Brave、Arc、Vivaldi、Opera)。"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "URLごとのルールは SafariChromium 系ブラウザ(Chrome、Edge、Brave、Arc、Vivaldi、Opera)で動作します。Firefox は macOS のアクセシビリティ API でアクティブなタブの URL を公開しないため、サポートされません。"
"value": "URLごとのルールは Safari、Firefox、および Chromium 系ブラウザ(Chrome、Edge、Brave、Arc、Vivaldi、Opera)で動作します。"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Les règles par URL fonctionnent dans Safari et les navigateurs basés sur Chromium (Chrome, Edge, Brave, Arc, Vivaldi, Opera). Firefox n’est pas pris en charge, car il n’expose pas l’URL de l’onglet actif via l’API d’accessibilité de macOS."
"value": "Les règles par URL fonctionnent dans Safari, Firefox et les navigateurs basés sur Chromium (Chrome, Edge, Brave, Arc, Vivaldi, Opera)."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "URL-Regeln funktionieren in Safari und Chromium-basierten Browsern (Chrome, Edge, Brave, Arc, Vivaldi, Opera). Firefox wird nicht unterstützt, da es die URL des aktiven Tabs nicht über die Accessibility-API von macOS bereitstellt."
"value": "URL-Regeln funktionieren in Safari, Firefox und Chromium-basierten Browsern (Chrome, Edge, Brave, Arc, Vivaldi, Opera)."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Las reglas por URL funcionan en Safari y en navegadores basados en Chromium (Chrome, Edge, Brave, Arc, Vivaldi, Opera). Firefox no es compatible, porque no expone la URL de la pestaña activa a través de la API de Accesibilidad de macOS."
"value": "Las reglas por URL funcionan en Safari, Firefox y navegadores basados en Chromium (Chrome, Edge, Brave, Arc, Vivaldi, Opera)."
}
},
"pt": {
"stringUnit": {
"state": "translated",
"value": "As regras por URL funcionam no Safari e em navegadores baseados em Chromium (Chrome, Edge, Brave, Arc, Vivaldi, Opera). O Firefox não é compatível, pois não expõe o URL da aba ativa pela API de Acessibilidade do macOS."
"value": "As regras por URL funcionam no Safari, no Firefox e em navegadores baseados em Chromium (Chrome, Edge, Brave, Arc, Vivaldi, Opera)."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Правила для отдельных URL работают в Safari и браузерах на базе Chromium (Chrome, Edge, Brave, Arc, Vivaldi, Opera). Firefox не поддерживается, так как не предоставляет URL активной вкладки через API универсального доступа (Accessibility) macOS."
"value": "Правила для отдельных URL работают в Safari, Firefox и браузерах на базе Chromium (Chrome, Edge, Brave, Arc, Vivaldi, Opera)."
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ struct URLRulesSettingsPane: View {
} header: {
Text("URL rules")
} footer: {
SectionFooter("Per-URL rules work in Safari and Chromium-based browsers (Chrome, Edge, Brave, Arc, Vivaldi, Opera). Firefox is not supported, because it does not expose the active tab's URL through the macOS Accessibility API.")
SectionFooter("Per-URL rules work in Safari, Firefox, and Chromium-based browsers (Chrome, Edge, Brave, Arc, Vivaldi, Opera).")
}
}
.formStyle(.grouped)
Expand Down
13 changes: 10 additions & 3 deletions Sources/LockIMEKit/Enhanced/AccessibilityBrowserURLReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ public final class AccessibilityBrowserURLReader: BrowserURLProviding {

let axApp = AXUIElementCreateApplication(app.processIdentifier)

// Chromium browsers build their accessibility tree lazily; setting
// `AXManualAccessibility` makes them expose the `AXWebArea`/`AXURL` for
// non-VoiceOver clients like us. Safari needs no such opt-in.
// Chromium and Gecko build their accessibility tree lazily; Safari keeps
// it live and needs no opt-in. Each lazy engine exposes the
// `AXWebArea`/`AXURL` only after the right wake signal is set on the app
// element.
if BrowserBundleIDs.isChromium(bundleID) {
AXUIElementSetAttributeValue(axApp, "AXManualAccessibility" as CFString, kCFBooleanTrue)
} else if BrowserBundleIDs.isGecko(bundleID) {
// Gecko (Firefox and forks) wakes on `AXEnhancedUserInterface` and
// does not implement `AXManualAccessibility`. The set is idempotent;
// the tree is built asynchronously, so the first read after a cold
// wake can be nil — the engine's URL poll retries on its next tick.
AXUIElementSetAttributeValue(axApp, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
}

guard let window = element(axApp, kAXFocusedWindowAttribute) else { return nil }
Expand Down
48 changes: 38 additions & 10 deletions Sources/LockIMEKit/Enhanced/URLMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import Foundation

/// Known browser bundle identifiers, used to decide when to read a URL.
///
/// URL reading is **Accessibility-based** and therefore browser-dependent:
/// - **Safari** exposes the active tab's URL on its `AXWebArea` (`AXURL`).
/// - **Chromium** browsers (Chrome, Edge, Brave, Arc, Vivaldi, Opera) expose it
/// too, but only after their accessibility tree is enabled via the
/// `AXManualAccessibility` attribute (they build it lazily). See `chromium`.
/// - **Firefox is not supported**: it does not expose the tab URL through the
/// macOS Accessibility API at all.
/// URL reading is **Accessibility-based** and therefore browser-dependent. Each
/// supported engine exposes the active tab's URL as `AXURL` on its `AXWebArea`;
/// they differ only in how that tree is brought up for a non-VoiceOver client:
/// - **Safari** keeps its accessibility tree live — no opt-in needed.
/// - **Chromium** browsers (Chrome, Edge, Brave, Arc, Vivaldi, Opera) build it
/// lazily and expose it once `AXManualAccessibility` is set on the app
/// element. See `chromium`.
/// - **Gecko** browsers (Firefox and forks such as Zen) also build it lazily,
/// but honor a different wake signal — `AXEnhancedUserInterface` on the app
/// element — and do not implement `AXManualAccessibility`. The tree is built
/// asynchronously after the wake, so the first read may be empty. See `gecko`.
public enum BrowserBundleIDs {
/// Chromium-based browsers. These need `AXManualAccessibility` set on the
/// app element before `AXURL` becomes readable.
Expand All @@ -22,9 +26,28 @@ public enum BrowserBundleIDs {
"com.vivaldi.Vivaldi",
]

/// Browsers whose URL we can read via Accessibility (Safari + Chromium).
/// Firefox is intentionally excluded — it exposes no tab URL over AX.
public static let all: Set<String> = chromium.union([
/// Gecko-based browsers (Firefox and current-Firefox forks such as Zen,
/// Floorp, and Waterfox). These need `AXEnhancedUserInterface` set on the
/// app element to wake their lazily-built accessibility tree before `AXURL`
/// becomes readable; they do not implement Chromium's `AXManualAccessibility`.
public static let gecko: Set<String> = [
"org.mozilla.firefox",
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.nightly",
"app.zen-browser.zen",
"app.floorp.Floorp",
"net.waterfox.waterfox",
// Privacy/anonymity-focused Firefox forks. Same current-Gecko engine, so
// the `AXEnhancedUserInterface` wake applies; reading their URL requires
// waking accessibility, which these users may not expect — included here
// deliberately so per-URL rules can cover them.
"io.gitlab.librewolf-community.librewolf",
"org.torproject.torbrowser",
"net.mullvad.mullvadbrowser",
]

/// Browsers whose URL we can read via Accessibility (Safari + Chromium + Gecko).
public static let all: Set<String> = chromium.union(gecko).union([
"com.apple.Safari",
"com.apple.SafariTechnologyPreview",
])
Expand All @@ -38,6 +61,11 @@ public enum BrowserBundleIDs {
guard let bundleID else { return false }
return chromium.contains(bundleID)
}

public static func isGecko(_ bundleID: String?) -> Bool {
guard let bundleID else { return false }
return gecko.contains(bundleID)
}
}

/// Pure host extraction and pattern matching for per-URL rules.
Expand Down
31 changes: 27 additions & 4 deletions Tests/LockIMEKitTests/URLMatcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ struct URLMatcherTests {
#expect(URLMatcher.host(from: url) == expected)
}

@Test("empty / invalid URLs have no host")
@Test("empty / invalid / authority-less URLs have no host")
func noHost() {
#expect(URLMatcher.host(from: "") == nil)
#expect(URLMatcher.host(from: "not a url") == nil)
// Browser placeholder pages carry no authority, so a per-URL rule can
// never false-match them — a Firefox new tab surfaces as about:newtab,
// not a real host. (We rely on a nil host here rather than normalizing
// these scheme-by-scheme.)
#expect(URLMatcher.host(from: "about:newtab") == nil)
#expect(URLMatcher.host(from: "about:blank") == nil)
}

@Test("exact and subdomain matches")
Expand Down Expand Up @@ -67,15 +73,15 @@ struct URLMatcherTests {
#expect(URLMatcher.matchedRule(host: nil, rules: rules) == nil)
}

@Test("browser bundle detection (Safari + Chromium; Firefox excluded)")
@Test("browser bundle detection (Safari + Chromium + Gecko)")
func browsers() {
#expect(BrowserBundleIDs.isBrowser("com.apple.Safari"))
#expect(BrowserBundleIDs.isBrowser("com.google.Chrome"))
#expect(BrowserBundleIDs.isBrowser("com.microsoft.edgemac"))
#expect(BrowserBundleIDs.isBrowser("org.mozilla.firefox"))
#expect(BrowserBundleIDs.isBrowser("app.zen-browser.zen"))
#expect(!BrowserBundleIDs.isBrowser("com.apple.Terminal"))
#expect(!BrowserBundleIDs.isBrowser(nil))
// Firefox is intentionally unsupported: no tab URL over the AX API.
#expect(!BrowserBundleIDs.isBrowser("org.mozilla.firefox"))
}

@Test("Chromium detection (needs AXManualAccessibility opt-in)")
Expand All @@ -88,4 +94,21 @@ struct URLMatcherTests {
#expect(!BrowserBundleIDs.isChromium("org.mozilla.firefox"))
#expect(!BrowserBundleIDs.isChromium(nil))
}

@Test("Gecko detection (needs AXEnhancedUserInterface opt-in)")
func gecko() {
#expect(BrowserBundleIDs.isGecko("org.mozilla.firefox"))
#expect(BrowserBundleIDs.isGecko("org.mozilla.firefoxdeveloperedition"))
#expect(BrowserBundleIDs.isGecko("app.zen-browser.zen"))
#expect(BrowserBundleIDs.isGecko("app.floorp.Floorp"))
#expect(BrowserBundleIDs.isGecko("net.waterfox.waterfox"))
#expect(BrowserBundleIDs.isGecko("io.gitlab.librewolf-community.librewolf"))
#expect(BrowserBundleIDs.isGecko("org.torproject.torbrowser"))
#expect(BrowserBundleIDs.isGecko("net.mullvad.mullvadbrowser"))
// Gecko and Chromium are disjoint; Safari is neither.
#expect(!BrowserBundleIDs.isGecko("com.google.Chrome"))
#expect(!BrowserBundleIDs.isChromium("org.mozilla.firefox"))
#expect(!BrowserBundleIDs.isGecko("com.apple.Safari"))
#expect(!BrowserBundleIDs.isGecko(nil))
}
}
Loading