From 57ba586c63d2bfa968f163eb600f1a44cf2cbf69 Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Mon, 22 Jun 2026 00:59:57 -0400 Subject: [PATCH] feat(rules): add address-bar focus lock/switch rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a browser's address bar (omnibox) has keyboard focus, the user is typing a URL or a search, where a CJK input method is rarely wanted. This adds an optional rule that forces a chosen input source on address-bar focus, so typing web addresses lands in the right source. Detection is event-driven via Accessibility: an AXObserver on the frontmost browser watches focusedUIElementChanged, and the system-wide focused element is classified by AddressBarHeuristic — a per-engine, non-localized identifier (Chromium OmniboxViewViews, Safari WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD, Firefox urlbar-input) confirmed by a chrome-vs-AXWebArea structural check. It mirrors FloatingAppMonitor and reuses the existing Accessibility grant; the permission-free core lock is untouched. Both a continuous "lock" and a one-shot "switch" are supported (some input methods cover both languages). There is no reverse restore: leaving the address bar re-resolves the standard URL/app/default rules rather than restoring a remembered source. When both the address bar and a URL rule apply, a user-configurable priority decides the winner, defaulting to address-bar-first. The rule is off by default and is per-device runtime state, so it is excluded from config backup/import (and preserved across import). The option lives in the URL Rules settings pane as labeled pop-up menus matching the macOS Settings style. Signed-off-by: Kevin Cui --- Sources/LockIME/AppState.swift | 33 +++ Sources/LockIME/Localizable.xcstrings | 144 ++++++++++ .../UI/Settings/ActivationLogPane.swift | 3 + .../UI/Settings/URLRulesSettingsPane.swift | 68 +++++ .../AppMonitor/AddressBarFocusMonitor.swift | 243 ++++++++++++++++ .../AddressBarFocusMonitoring.swift | 21 ++ .../AppMonitor/AddressBarHeuristic.swift | 49 ++++ .../LockIMEKit/LockEngine/InputSource.swift | 6 + .../LockIMEKit/LockEngine/LockEngine.swift | 48 +++- .../LockIMEKit/Rules/LockConfiguration.swift | 39 ++- Sources/LockIMEKit/Rules/RuleResolver.swift | 40 ++- .../AddressBarHeuristicTests.swift | 76 +++++ Tests/LockIMEKitTests/ImportPlanTests.swift | 20 +- .../LockConfigurationTests.swift | 47 ++++ .../LockEngineAddressBarTests.swift | 265 ++++++++++++++++++ Tests/LockIMEKitTests/RuleResolverTests.swift | 140 +++++++++ .../Support/MockAddressBarMonitor.swift | 37 +++ 17 files changed, 1268 insertions(+), 11 deletions(-) create mode 100644 Sources/LockIMEKit/AppMonitor/AddressBarFocusMonitor.swift create mode 100644 Sources/LockIMEKit/AppMonitor/AddressBarFocusMonitoring.swift create mode 100644 Sources/LockIMEKit/AppMonitor/AddressBarHeuristic.swift create mode 100644 Tests/LockIMEKitTests/AddressBarHeuristicTests.swift create mode 100644 Tests/LockIMEKitTests/LockEngineAddressBarTests.swift create mode 100644 Tests/LockIMEKitTests/Support/MockAddressBarMonitor.swift diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index 4aab974..101c589 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -370,6 +370,39 @@ final class AppState { commit() } + // MARK: - Address-bar focus rule + + /// Turn the address-bar focus rule on or off. Enabling with no target yet + /// pre-fills it with the global default source so the rule isn't inert (a + /// `nil` target makes the resolver skip it); the source picker can change it. + func setAddressBarFocusEnabled(_ enabled: Bool) { + config.addressBarFocusEnabled = enabled + if enabled, config.addressBarSourceID == nil { + config.addressBarSourceID = config.defaultSourceID + } + commit() + } + + /// Choose whether the address-bar rule continuously locks its source or + /// switches to it once on focus. + func setAddressBarAction(_ action: RuleAction) { + config.addressBarAction = action + commit() + } + + /// Set the source the address-bar rule targets. + func setAddressBarSource(_ id: InputSourceID?) { + config.addressBarSourceID = id + commit() + } + + /// Choose which wins when both the address-bar rule and a URL rule apply: + /// `true` = address bar, `false` = URL rule (the default). + func setAddressBarOutranksURLRules(_ outranks: Bool) { + config.addressBarOutranksURLRules = outranks + commit() + } + func upsertURLRule(_ rule: URLRule) { // Insert/update in place so editing a rule's binding (match type / action / // source) keeps its position — order is priority now, and an edit must not diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index be38117..2f754c3 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -10709,6 +10709,150 @@ } } } + }, + "Address bar": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Adressleiste" } }, + "es": { "stringUnit": { "state": "translated", "value": "Barra de direcciones" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Barre d’adresse" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アドレスバー" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Barra de endereços" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Адресная строка" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "地址栏" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "網址列" } } + } + }, + "Set the input source in the address bar": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Eingabequelle in der Adressleiste festlegen" } }, + "es": { "stringUnit": { "state": "translated", "value": "Establecer la fuente de entrada en la barra de direcciones" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Définir la source de saisie dans la barre d’adresse" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アドレスバーで入力ソースを設定" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Definir a fonte de entrada na barra de endereços" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Задавать источник ввода в адресной строке" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "在地址栏中设置输入法" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "在網址列中設定輸入法" } } + } + }, + "The address-bar rule requires Accessibility": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Die Adressleisten-Regel erfordert Bedienungshilfen" } }, + "es": { "stringUnit": { "state": "translated", "value": "La regla de la barra de direcciones requiere accesibilidad" } }, + "fr": { "stringUnit": { "state": "translated", "value": "La règle de barre d’adresse nécessite l’accessibilité" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アドレスバーのルールにはアクセシビリティが必要です" } }, + "pt": { "stringUnit": { "state": "translated", "value": "A regra da barra de endereços requer acessibilidade" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Правило адресной строки требует универсального доступа" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "地址栏规则需要辅助功能权限" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "網址列規則需要輔助使用權限" } } + } + }, + "Choose an input source for the address bar.": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Wählen Sie eine Eingabequelle für die Adressleiste." } }, + "es": { "stringUnit": { "state": "translated", "value": "Elige una fuente de entrada para la barra de direcciones." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Choisissez une source de saisie pour la barre d’adresse." } }, + "ja": { "stringUnit": { "state": "translated", "value": "アドレスバーの入力ソースを選択してください。" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Escolha uma fonte de entrada para a barra de endereços." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Выберите источник ввода для адресной строки." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请为地址栏选择一个输入法。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "請為網址列選擇一個輸入法。" } } + } + }, + "Address bar focused": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Adressleiste fokussiert" } }, + "es": { "stringUnit": { "state": "translated", "value": "Barra de direcciones enfocada" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Barre d’adresse active" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アドレスバーにフォーカス" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Barra de endereços em foco" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Фокус на адресной строке" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "地址栏获得焦点" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "網址列取得焦點" } } + } + }, + "Address bar blurred": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Adressleiste nicht mehr fokussiert" } }, + "es": { "stringUnit": { "state": "translated", "value": "Barra de direcciones sin foco" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Barre d’adresse désactivée" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アドレスバーのフォーカス解除" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Barra de endereços sem foco" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Фокус снят с адресной строки" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "地址栏失去焦点" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "網址列失去焦點" } } + } + }, + "Address-bar rule": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Adressleisten-Regel" } }, + "es": { "stringUnit": { "state": "translated", "value": "Regla de barra de direcciones" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Règle de barre d’adresse" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アドレスバーのルール" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Regra da barra de endereços" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Правило адресной строки" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "地址栏规则" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "網址列規則" } } + } + }, + "URL rules first": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "URL-Regeln zuerst" } }, + "es": { "stringUnit": { "state": "translated", "value": "Reglas de URL primero" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Règles d’URL d’abord" } }, + "ja": { "stringUnit": { "state": "translated", "value": "URL ルール優先" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Regras de URL primeiro" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Сначала правила URL" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "网址规则优先" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "網址規則優先" } } + } + }, + "Address bar first": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Adressleiste zuerst" } }, + "es": { "stringUnit": { "state": "translated", "value": "Barra de direcciones primero" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Barre d’adresse d’abord" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アドレスバー優先" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Barra de endereços primeiro" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Сначала адресная строка" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "地址栏优先" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "網址列優先" } } + } + }, + "Behavior": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Verhalten" } }, + "es": { "stringUnit": { "state": "translated", "value": "Comportamiento" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Comportement" } }, + "ja": { "stringUnit": { "state": "translated", "value": "動作" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Comportamento" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Поведение" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "行为" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "行為" } } + } + }, + "Priority": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Priorität" } }, + "es": { "stringUnit": { "state": "translated", "value": "Prioridad" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Priorité" } }, + "ja": { "stringUnit": { "state": "translated", "value": "優先順位" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Prioridade" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Приоритет" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "优先级" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "優先順序" } } + } + }, + "While a browser's address bar is focused, LockIME sets your chosen input source so you can type web addresses. “Lock” keeps it fixed; “Switch” sets it once and lets you change it. “Priority” decides which wins when a URL rule also matches the page.": { + "localizations": { + "de": { "stringUnit": { "state": "translated", "value": "Solange die Adressleiste eines Browsers fokussiert ist, legt LockIME die gewählte Eingabequelle fest, damit Sie Webadressen eingeben können. „Sperren“ hält sie fest; „Wechseln“ setzt sie einmalig und lässt Sie sie ändern. „Priorität“ entscheidet, was Vorrang hat, wenn auch eine URL-Regel auf die Seite zutrifft." } }, + "es": { "stringUnit": { "state": "translated", "value": "Mientras la barra de direcciones de un navegador está enfocada, LockIME establece la fuente de entrada elegida para que puedas escribir direcciones web. «Bloquear» la mantiene fija; «Cambiar» la establece una vez y te deja cambiarla. «Prioridad» decide cuál prevalece cuando una regla de URL también coincide con la página." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Lorsque la barre d’adresse d’un navigateur est active, LockIME définit la source de saisie choisie pour vous permettre de saisir des adresses web. « Verrouiller » la maintient fixe ; « Basculer » la définit une fois et vous laisse la changer. « Priorité » détermine ce qui l’emporte lorsqu’une règle d’URL correspond aussi à la page." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ブラウザのアドレスバーがフォーカスされている間、LockIME は選択した入力ソースに設定し、ウェブアドレスを入力しやすくします。「ロック」は固定したままにし、「切り替え」は一度だけ設定して、その後は変更できます。「優先順位」は、URL ルールも現在のページに一致する場合にどちらを優先するかを決めます。" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Enquanto a barra de endereços de um navegador está em foco, o LockIME define a fonte de entrada escolhida para você digitar endereços da web. «Bloquear» a mantém fixa; «Alternar» a define uma vez e permite que você a altere. «Prioridade» decide qual prevalece quando uma regra de URL também corresponde à página." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Пока адресная строка браузера в фокусе, LockIME устанавливает выбранный источник ввода, чтобы вы могли вводить веб-адреса. «Блокировать» удерживает его постоянно; «Переключить» задаёт его один раз и позволяет изменить. «Приоритет» определяет, что важнее, когда правило URL тоже подходит к странице." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "当浏览器地址栏获得焦点时,LockIME 会设置为你选择的输入法,便于输入网址。「锁定」会保持不变;「切换」只设置一次,之后你可自行更改。「优先级」决定当某条网址规则也匹配当前页面时以哪个为准。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "當瀏覽器網址列取得焦點時,LockIME 會設定為你選擇的輸入法,方便輸入網址。「鎖定」會維持不變;「切換」只設定一次,之後你可自行變更。「優先順序」決定當某條網址規則也符合目前頁面時以哪個為準。" } } + } } } } diff --git a/Sources/LockIME/UI/Settings/ActivationLogPane.swift b/Sources/LockIME/UI/Settings/ActivationLogPane.swift index e37a8c1..8e8f070 100644 --- a/Sources/LockIME/UI/Settings/ActivationLogPane.swift +++ b/Sources/LockIME/UI/Settings/ActivationLogPane.swift @@ -81,6 +81,8 @@ struct ActivationLogPane: View { case .launcherDismissed: "Launcher closed" case .urlPolled: "URL re-checked" case .urlMatched: "URL matched" + case .addressBarFocused: "Address bar focused" + case .addressBarBlurred: "Address bar blurred" case .lockEngaged: "Lock engaged" case .configChanged: "Settings changed" case .startupApplied: "Lock restored" @@ -96,6 +98,7 @@ struct ActivationLogPane: View { case .appRule: return "App rule" case .globalDefault: return "Default rule" case .urlRule: return "URL rule" + case .addressBarRule: return "Address-bar rule" case nil: return nil } } diff --git a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift index 170bc68..94fcf4e 100644 --- a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift @@ -42,6 +42,7 @@ struct URLRulesSettingsPane: View { Form { enhancedSection(enhancedBinding) rulesSection + addressBarSection } .formStyle(.grouped) // Fallback reorder drop for the whole pane: a drag released *not* on a row @@ -97,6 +98,73 @@ struct URLRulesSettingsPane: View { } } + // MARK: Address-bar section + + /// The address-bar focus rule: while a browser's address bar (omnibox) has + /// keyboard focus, force a chosen source. Accessibility-gated like enhanced + /// mode, but independent of it (and of URL rules) — it lives on this page + /// because both read the browser via Accessibility. Off by default. + @ViewBuilder + private var addressBarSection: some View { + let enabledBinding = Binding( + get: { state.config.addressBarFocusEnabled }, + set: { state.setAddressBarFocusEnabled($0) } + ) + let actionBinding = Binding( + get: { state.config.addressBarAction }, + set: { state.setAddressBarAction($0) } + ) + let sourceBinding = Binding( + get: { state.config.addressBarSourceID }, + set: { state.setAddressBarSource($0) } + ) + let priorityBinding = Binding( + get: { state.config.addressBarOutranksURLRules }, + set: { state.setAddressBarOutranksURLRules($0) } + ) + + Section { + Toggle("Set the input source in the address bar", isOn: enabledBinding) + .disabled(!state.accessibilityGranted) + + if !state.accessibilityGranted { + AccessibilityRequiredNote("The address-bar rule requires Accessibility") + } + + if state.config.addressBarFocusEnabled { + // Labeled pop-up menus — leading label, trailing menu — matching + // the grouped-form rows elsewhere (General ▸ Language, App Rules ▸ + // default source), rather than full-width segmented controls that + // read as out of place in a macOS settings list. + Picker("Behavior", selection: actionBinding) { + Text("Lock to").tag(RuleAction.lock) + Text("Switch to").tag(RuleAction.switchOnce) + } + + Picker("Input source", selection: sourceBinding) { + ForEach(state.availableSources) { src in + Text(src.localizedName).tag(InputSourceID?.some(src.id)) + } + } + + if state.config.addressBarSourceID == nil { + Label("Choose an input source for the address bar.", systemImage: "exclamationmark.triangle") + .font(DS.Font.sectionFooter) + .foregroundStyle(DS.Palette.warning) + } + + Picker("Priority", selection: priorityBinding) { + Text("URL rules first").tag(false) + Text("Address bar first").tag(true) + } + } + } header: { + Text("Address bar") + } footer: { + SectionFooter("While a browser's address bar is focused, LockIME sets your chosen input source so you can type web addresses. “Lock” keeps it fixed; “Switch” sets it once and lets you change it. “Priority” decides which wins when a URL rule also matches the page.") + } + } + // MARK: Rules section @ViewBuilder diff --git a/Sources/LockIMEKit/AppMonitor/AddressBarFocusMonitor.swift b/Sources/LockIMEKit/AppMonitor/AddressBarFocusMonitor.swift new file mode 100644 index 0000000..8816c05 --- /dev/null +++ b/Sources/LockIMEKit/AppMonitor/AddressBarFocusMonitor.swift @@ -0,0 +1,243 @@ +import AppKit +import ApplicationServices +import Foundation + +/// Tracks whether the frontmost browser's address bar (omnibox / unified URL +/// field) has keyboard focus, via the Accessibility API. The technique mirrors +/// `FloatingAppMonitor`: register an `AXObserver` on the browser *process* for +/// `kAXFocusedUIElementChangedNotification`, then read the system-wide focused +/// UI element and classify it (`AddressBarHeuristic`) on each change. +/// +/// Unlike the launcher monitor (which watches a fixed catalog of persistent +/// launcher processes), this observes only the **single** browser the engine +/// asks for — the frontmost one — and only while the feature is on. The engine +/// drives `observe(bundleID:)` as the frontmost app and the feature toggle +/// change, so exactly one browser is observed at a time, or none. +/// +/// Empirically (macOS 26, Chrome/Safari/Firefox): focusing the address bar (via +/// ⌘L or a click) fires `focusedUIElementChanged`, at which point the focused +/// element resolves to the address-bar field; a single ⌘L fires several such +/// notifications, so the change is de-duplicated against `current`. +/// +/// **Accessibility-gated.** Without the grant `AXObserverAddNotification` fails +/// and nothing is observed, leaving the permission-free core unchanged; the +/// engine calls `refresh()` once the grant is detected to attach for real. +@MainActor +public final class AddressBarFocusMonitor: AddressBarFocusMonitoring { + private var onChange: (@MainActor (Bool) -> Void)? + private var observer: AXObserver? + private var observedBundleID: String? + private var observedPID: pid_t? + /// Last reported state, for change de-duplication (a single focus fires + /// several notifications). + private var current = false + + private let systemWide: AXUIElement + /// How far up the ancestor chain to look for the chrome-vs-web-area signal. + private let ancestorDepth: Int + + public init(ancestorDepth: Int = 12) { + self.ancestorDepth = ancestorDepth + systemWide = AXUIElementCreateSystemWide() + // Reading the focused element round-trips to the focused app; cap the + // wait so an unresponsive app can't stall the main thread. + AXUIElementSetMessagingTimeout(systemWide, 0.25) + } + + public func start(onChange: @escaping @MainActor (Bool) -> Void) { + guard self.onChange == nil else { return } + self.onChange = onChange + } + + public func observe(bundleID: String?) { + guard onChange != nil else { return } + // No-op when already observing the same browser, so a launcher dismiss + // returning to the same browser doesn't churn the observer. + if bundleID == observedBundleID, observer != nil { return } + + detach() + observedBundleID = bundleID + guard let bundleID, + let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).first + else { + report(false) + return + } + attach(pid: app.processIdentifier) + evaluate() + } + + public func refresh() { + guard onChange != nil else { return } + if AXIsProcessTrusted() { + // (Re)attach to the browser we should be observing now that we can. + if let bundleID = observedBundleID, observer == nil { + if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).first { + attach(pid: app.processIdentifier) + } + } + evaluate() + } else { + // Trust was revoked: the observer is dead and won't fire even if + // access is re-granted, so detach it (a later refresh recreates it) + // and clear any stale focus attribution. + detach() + report(false) + } + } + + public func stop() { + detach() + onChange = nil + observedBundleID = nil + current = false + } + + deinit { + // The observer is torn down in `stop()` (called from the engine's + // `stop()`); a nonisolated deinit can't touch it. + } + + // MARK: - Attachment + + private func attach(pid: pid_t) { + guard observer == nil, AXIsProcessTrusted() else { return } + + let appElement = AXUIElementCreateApplication(pid) + // Chromium and Gecko build their accessibility tree lazily; wake it so + // the focused element (and its identifying attributes) become readable, + // mirroring `AccessibilityBrowserURLReader`. Safari is always live. + if BrowserBundleIDs.isChromium(observedBundleID) { + AXUIElementSetAttributeValue(appElement, "AXManualAccessibility" as CFString, kCFBooleanTrue) + } else if BrowserBundleIDs.isGecko(observedBundleID) { + AXUIElementSetAttributeValue(appElement, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue) + } + + var newObserver: AXObserver? + guard AXObserverCreate(pid, addressBarAXCallback, &newObserver) == .success, + let newObserver + else { return } + + let context = Unmanaged.passUnretained(self).toOpaque() + guard AXObserverAddNotification( + newObserver, appElement, kAXFocusedUIElementChangedNotification as CFString, context + ) == .success else { + // Not trusted yet — leave unattached so `refresh()` retries once + // Accessibility is granted. + return + } + + CFRunLoopAddSource( + CFRunLoopGetCurrent(), + AXObserverGetRunLoopSource(newObserver), + .defaultMode + ) + observer = newObserver + observedPID = pid + } + + private func detach() { + guard let observer else { observedPID = nil; return } + CFRunLoopRemoveSource( + CFRunLoopGetCurrent(), + AXObserverGetRunLoopSource(observer), + .defaultMode + ) + self.observer = nil + observedPID = nil + } + + // MARK: - Evaluation + + /// Called from the AX callback on every focus change. Reads the system-wide + /// focused element, classifies it, and reports the de-duplicated state. + fileprivate func evaluate() { + report(focusedElementIsAddressBar()) + } + + private func report(_ focused: Bool) { + guard focused != current else { return } + current = focused + onChange?(focused) + } + + private func focusedElementIsAddressBar() -> Bool { + guard let element = focusedElement() else { return false } + // The focused element must belong to the browser we're observing — a + // stray read while focus is elsewhere (e.g. a launcher overlay mid- + // transition) must not be mistaken for the address bar. + var pid: pid_t = 0 + guard AXUIElementGetPid(element, &pid) == .success, pid == observedPID else { return false } + return AddressBarHeuristic.isAddressBar( + identifier: string(element, "AXIdentifier"), + domIdentifier: string(element, "AXDOMIdentifier"), + domClassList: stringArray(element, "AXDOMClassList"), + ancestorRoles: ancestorRoles(of: element) + ) + } + + private func focusedElement() -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &value) == .success, + let value, CFGetTypeID(value) == AXUIElementGetTypeID() + else { return nil } + // Safe: the CFGetTypeID check above guarantees this is an AXUIElement. + return (value as! AXUIElement) + } + + private func ancestorRoles(of element: AXUIElement) -> [String] { + var roles: [String] = [] + var current: AXUIElement? = parent(of: element) + var depth = 0 + while let node = current, depth < ancestorDepth { + if let role = string(node, kAXRoleAttribute as String) { roles.append(role) } + current = parent(of: node) + depth += 1 + } + return roles + } + + private func parent(of element: AXUIElement) -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &value) == .success, + let value, CFGetTypeID(value) == AXUIElementGetTypeID() + else { return nil } + return (value as! AXUIElement) + } + + private func string(_ element: AXUIElement, _ attribute: String) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let string = value as? String + else { return nil } + return string + } + + private func stringArray(_ element: AXUIElement, _ attribute: String) -> [String] { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let array = value as? [String] + else { return [] } + return array + } +} + +/// Free C callback (an `AXObserverCallback` cannot capture context). The monitor +/// is passed through `refcon`; it owns the observer and outlives it, so an +/// unretained reference is safe. The run-loop source lives on the main thread, +/// so the callback is already main-actor isolated in practice. +private func addressBarAXCallback( + _ observer: AXObserver, + _ element: AXUIElement, + _ notification: CFString, + _ refcon: UnsafeMutableRawPointer? +) { + guard let refcon else { return } + // Reconstruct the instance *outside* the main-actor hop (matching + // `FloatingAppMonitor`): sending the raw pointer across the isolation + // boundary trips strict-concurrency region analysis. + let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + MainActor.assumeIsolated { + monitor.evaluate() + } +} diff --git a/Sources/LockIMEKit/AppMonitor/AddressBarFocusMonitoring.swift b/Sources/LockIMEKit/AppMonitor/AddressBarFocusMonitoring.swift new file mode 100644 index 0000000..1de92bf --- /dev/null +++ b/Sources/LockIMEKit/AppMonitor/AddressBarFocusMonitoring.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Abstraction over browser address-bar focus tracking, so the engine can be +/// tested with a mock instead of the real Accessibility-backed monitor. +@MainActor +public protocol AddressBarFocusMonitoring: AnyObject { + /// Begin observing. `onChange(true)` fires when the observed browser's + /// address bar (omnibox / unified URL field) gains keyboard focus; + /// `onChange(false)` when it loses focus or observation stops. + func start(onChange: @escaping @MainActor (Bool) -> Void) + /// Observe the given browser process for address-bar focus, or stop + /// observing (pass `nil`). The engine calls this as the frontmost app + /// changes and the feature toggles — it observes only the frontmost browser, + /// and only while the feature is on. + func observe(bundleID: String?) + /// Re-attempt observer attachment after an Accessibility grant change. macOS + /// doesn't notify us when access is granted, so the engine calls this once + /// the grant is detected. + func refresh() + func stop() +} diff --git a/Sources/LockIMEKit/AppMonitor/AddressBarHeuristic.swift b/Sources/LockIMEKit/AppMonitor/AddressBarHeuristic.swift new file mode 100644 index 0000000..1733c55 --- /dev/null +++ b/Sources/LockIMEKit/AppMonitor/AddressBarHeuristic.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Pure decision of whether a focused Accessibility element is a browser's +/// address bar (omnibox / unified URL field), kept separate from the AX plumbing +/// so it is unit-testable without a live Accessibility tree. +/// +/// The signal is **structural + per-engine identifier**, never localized text. +/// Empirically (macOS 26, real AX API across Chrome/Safari/Firefox), each +/// engine's address bar exposes a stable, language-independent identifier, and +/// the element lives in the browser's native chrome (under an `AXToolbar`), +/// **not** inside the page content (`AXWebArea`). A page `` shares the +/// `AXTextField` role, so the role alone is never enough — the chrome-vs-web-area +/// structure is what separates them. The localized `AXDescription` +/// ("Address and search bar", localized per UI language) is deliberately ignored; +/// matching it would break under the app's in-app language override. +public enum AddressBarHeuristic { + /// Safari/WebKit exposes the unified field with this `AXIdentifier`. + public static let safariIdentifier = "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD" + /// Chromium's omnibox carries this class in its `AXDOMClassList`. + public static let chromiumDOMClass = "OmniboxViewViews" + /// Firefox/Gecko's urlbar exposes this `AXDOMIdentifier`. + public static let geckoDOMIdentifier = "urlbar-input" + + /// Whether the focused element is a browser address bar. + /// + /// - `identifier`: the element's `AXIdentifier` (Safari). + /// - `domIdentifier`: its `AXDOMIdentifier` (Firefox). + /// - `domClassList`: its `AXDOMClassList` (Chromium). + /// - `ancestorRoles`: the `AXRole`s of its ancestors, nearest first, used for + /// the structural gate (in the toolbar chrome, not in the web area). + public static func isAddressBar( + identifier: String?, + domIdentifier: String?, + domClassList: [String], + ancestorRoles: [String] + ) -> Bool { + // Structural gate: the element must sit in the native chrome (under an + // AXToolbar) and NOT inside the page content (AXWebArea). This is what + // separates the address bar from an in-page text field, which shares the + // AXTextField role but is rooted in the AXWebArea. + guard ancestorRoles.contains("AXToolbar"), !ancestorRoles.contains("AXWebArea") else { + return false + } + if identifier == safariIdentifier { return true } + if domIdentifier == geckoDOMIdentifier { return true } + if domClassList.contains(chromiumDOMClass) { return true } + return false + } +} diff --git a/Sources/LockIMEKit/LockEngine/InputSource.swift b/Sources/LockIMEKit/LockEngine/InputSource.swift index b717ce4..9befc87 100644 --- a/Sources/LockIMEKit/LockEngine/InputSource.swift +++ b/Sources/LockIMEKit/LockEngine/InputSource.swift @@ -48,6 +48,12 @@ public enum ActivationReason: String, Sendable, Codable, CaseIterable { case urlPolled /// Enhanced mode: the browser URL matched a rule and that rule was applied. case urlMatched + /// A browser's address bar gained keyboard focus and the address-bar rule + /// applied its source. + case addressBarFocused + /// A browser's address bar lost keyboard focus, so the address-bar rule + /// released and the app/URL/default rule was re-resolved. + case addressBarBlurred /// The master lock toggle was turned on while the source was off target. case lockEngaged /// A configuration edit while already locked (default source, an app rule, diff --git a/Sources/LockIMEKit/LockEngine/LockEngine.swift b/Sources/LockIMEKit/LockEngine/LockEngine.swift index bab0533..817a48f 100644 --- a/Sources/LockIMEKit/LockEngine/LockEngine.swift +++ b/Sources/LockIMEKit/LockEngine/LockEngine.swift @@ -12,6 +12,7 @@ public final class LockEngine { private let enabledSourcesObserver: InputSourceChangeObserver private let appMonitor: any FrontmostAppMonitoring private let floatingAppMonitor: any FloatingAppMonitoring + private let addressBarMonitor: any AddressBarFocusMonitoring private let urlProvider: (any BrowserURLProviding)? private var urlPollTask: Task? @@ -31,6 +32,11 @@ public final class LockEngine { /// focus, if any. It shadows `frontmostBundleID` for rule resolution because /// macOS leaves the frontmost app unchanged while an overlay is up. private var launcherBundleID: String? + /// Whether the frontmost browser's address bar currently holds keyboard + /// focus. Reset to `false` on any frontmost/launcher change (a new context + /// hasn't proven the address bar is focused yet) and re-driven by the + /// address-bar monitor's events. + private var addressBarFocused = false /// Identity of the one-shot switch rule currently in effect, so the engine /// fires a `.switchOnce` resolution only on a *genuine transition into* the @@ -69,6 +75,7 @@ public final class LockEngine { provider: (any InputSourceProviding)? = nil, appMonitor: (any FrontmostAppMonitoring)? = nil, floatingAppMonitor: (any FloatingAppMonitoring)? = nil, + addressBarMonitor: (any AddressBarFocusMonitoring)? = nil, urlProvider: (any BrowserURLProviding)? = nil ) { let provider = provider ?? TISInputSourceProvider() @@ -78,6 +85,7 @@ public final class LockEngine { self.enabledSourcesObserver = InputSourceChangeObserver(.enabledSourcesChanged) self.appMonitor = appMonitor ?? AppActivationMonitor() self.floatingAppMonitor = floatingAppMonitor ?? FloatingAppMonitor() + self.addressBarMonitor = addressBarMonitor ?? AddressBarFocusMonitor() self.urlProvider = urlProvider self.controller.onActivation = { [weak self] event in self?.onActivation?(event) @@ -90,6 +98,8 @@ public final class LockEngine { enabledSourcesObserver.start { [weak self] in self?.handleEnabledSourcesChange() } appMonitor.start { [weak self] id in self?.handleFrontmostChange(id) } floatingAppMonitor.start { [weak self] id in self?.handleLauncherChange(id) } + addressBarMonitor.start { [weak self] focused in self?.handleAddressBarFocusChange(focused) } + updateAddressBarMonitoring() notifyCurrent() } @@ -98,6 +108,7 @@ public final class LockEngine { enabledSourcesObserver.stop() appMonitor.stop() floatingAppMonitor.stop() + addressBarMonitor.stop() urlPollTask?.cancel() urlPollTask = nil } @@ -107,6 +118,7 @@ public final class LockEngine { /// grant — only then can the overlay observers attach. public func accessibilityDidChange() { floatingAppMonitor.refresh() + addressBarMonitor.refresh() } /// Apply a configuration: update rules/default, set master enable, and @@ -132,6 +144,7 @@ public final class LockEngine { reevaluate(reason: reason) // update cached target only } updateURLPolling() + updateAddressBarMonitoring() notifyCurrent() } @@ -165,9 +178,13 @@ public final class LockEngine { // A normal app activating means no launcher overlay is up (overlays // never raise an activation), so clear any stale launcher attribution. launcherBundleID = nil + // A new frontmost app hasn't proven its address bar is focused; the + // monitor will re-assert focus if the new browser's bar is active. + addressBarFocused = false onFrontmostChange?(effectiveBundleID) reevaluate(reason: .appActivated) updateURLPolling() + updateAddressBarMonitoring() notifyCurrent() } @@ -176,9 +193,26 @@ public final class LockEngine { /// unchanged frontmost app; `nil` reverts to the frontmost app. private func handleLauncherChange(_ bundleID: String?) { launcherBundleID = bundleID + // A launcher overlay shadows the browser, so the address bar isn't the + // keyboard focus any more; clear it and re-arm monitoring against the + // effective app (the overlay isn't a browser, so it suspends). + addressBarFocused = false onFrontmostChange?(effectiveBundleID) reevaluate(reason: bundleID != nil ? .launcherFocused : .launcherDismissed) updateURLPolling() + updateAddressBarMonitoring() + notifyCurrent() + } + + /// A browser's address bar gained or lost keyboard focus. While it holds + /// focus, the address-bar rule resolves (unless an explicit URL rule wins); + /// losing focus re-resolves to the app/URL/default rule. There is no + /// "restore the previous source" step — a one-shot switch simply releases, + /// and a continuous lock falls back to whatever the standing rules say. + private func handleAddressBarFocusChange(_ focused: Bool) { + guard addressBarFocused != focused else { return } + addressBarFocused = focused + reevaluate(reason: focused ? .addressBarFocused : .addressBarBlurred) notifyCurrent() } @@ -192,7 +226,8 @@ public final class LockEngine { switch RuleResolver.resolve( config: config, frontmostBundleID: effectiveBundleID, - urlMatch: urlMatch.map { (id: $0.id, action: $0.action) } + urlMatch: urlMatch.map { (id: $0.id, action: $0.action) }, + addressBarFocused: addressBarFocused ) { case .lock(let id, let ruleSource): controller.setTarget( @@ -315,6 +350,17 @@ public final class LockEngine { } } + /// Observe the frontmost browser's address-bar focus only while the feature + /// is on, a target is set, and a browser is frontmost — so the AX observer + /// attaches to exactly the browser the user is in, and detaches otherwise. A + /// launcher overlay over a browser suspends it (the overlay isn't a browser). + private func updateAddressBarMonitoring() { + let shouldObserve = config.addressBarFocusEnabled + && config.addressBarSourceID != nil + && BrowserBundleIDs.isBrowser(effectiveBundleID) + addressBarMonitor.observe(bundleID: shouldObserve ? effectiveBundleID : nil) + } + private func notifyCurrent() { onCurrentSourceChange?(currentSourceName()) } diff --git a/Sources/LockIMEKit/Rules/LockConfiguration.swift b/Sources/LockIMEKit/Rules/LockConfiguration.swift index c5fc538..9b6b8e7 100644 --- a/Sources/LockIMEKit/Rules/LockConfiguration.swift +++ b/Sources/LockIMEKit/Rules/LockConfiguration.swift @@ -171,19 +171,47 @@ public struct LockConfiguration: Codable, Sendable, Equatable { public var enhancedModeEnabled: Bool /// Per-URL rules (enhanced mode). public var urlRules: [URLRule] + /// Whether the address-bar focus rule is on: while a browser's address + /// bar (omnibox / unified URL field) has keyboard focus, force + /// `addressBarSourceID`. Accessibility-gated and independent of + /// `enhancedModeEnabled`; **off by default**, so it never acts until the + /// user opts in. Detection is event-driven via the focused AX element — see + /// `AddressBarFocusMonitor`. + public var addressBarFocusEnabled: Bool + /// Whether the address-bar rule **continuously locks** its source (a + /// bilingual IME stays fixed) or **switches to it once** on focus (then + /// releases, so the user can change it). Mirrors `URLRule.action`. + public var addressBarAction: RuleAction + /// The source the address-bar rule targets. `nil` makes the rule inert even + /// when enabled (the resolver skips it), so the UI pre-fills it on enable. + public var addressBarSourceID: InputSourceID? + /// When both a URL rule and the focused address bar apply, whether the + /// **address-bar rule wins**. Default `true` — while you're typing in the + /// address bar, that intent outranks the loaded page's URL rule. Set `false` + /// to let URL rules win instead. (Has no effect when the address bar isn't + /// focused, or when no URL rule matches the page.) + public var addressBarOutranksURLRules: Bool public init( isEnabled: Bool = false, defaultSourceID: InputSourceID? = nil, appRules: [AppRule] = [], enhancedModeEnabled: Bool = false, - urlRules: [URLRule] = [] + urlRules: [URLRule] = [], + addressBarFocusEnabled: Bool = false, + addressBarAction: RuleAction = .switchOnce, + addressBarSourceID: InputSourceID? = nil, + addressBarOutranksURLRules: Bool = true ) { self.isEnabled = isEnabled self.defaultSourceID = defaultSourceID self.appRules = appRules self.enhancedModeEnabled = enhancedModeEnabled self.urlRules = urlRules + self.addressBarFocusEnabled = addressBarFocusEnabled + self.addressBarAction = addressBarAction + self.addressBarSourceID = addressBarSourceID + self.addressBarOutranksURLRules = addressBarOutranksURLRules } // Forward/backward-compatible decoding: missing keys fall back to defaults @@ -195,6 +223,15 @@ public struct LockConfiguration: Codable, Sendable, Equatable { appRules = try container.decodeIfPresent([AppRule].self, forKey: .appRules) ?? [] enhancedModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .enhancedModeEnabled) ?? false urlRules = try container.decodeIfPresent([URLRule].self, forKey: .urlRules) ?? [] + addressBarFocusEnabled = try container.decodeIfPresent(Bool.self, forKey: .addressBarFocusEnabled) ?? false + // Decode the action as a raw *string* and map it (same rationale as + // `URLRule`): a missing key OR an unrecognized value (a newer build wrote + // an action this one doesn't know, then a downgrade reads it) both fall + // back to `.switchOnce` instead of throwing. + let rawAddressBarAction = try container.decodeIfPresent(String.self, forKey: .addressBarAction) + addressBarAction = rawAddressBarAction.flatMap(RuleAction.init(rawValue:)) ?? .switchOnce + addressBarSourceID = try container.decodeIfPresent(InputSourceID.self, forKey: .addressBarSourceID) + addressBarOutranksURLRules = try container.decodeIfPresent(Bool.self, forKey: .addressBarOutranksURLRules) ?? true } public static let `default` = LockConfiguration() diff --git a/Sources/LockIMEKit/Rules/RuleResolver.swift b/Sources/LockIMEKit/Rules/RuleResolver.swift index 39a0bd0..02ca21e 100644 --- a/Sources/LockIMEKit/Rules/RuleResolver.swift +++ b/Sources/LockIMEKit/Rules/RuleResolver.swift @@ -9,6 +9,8 @@ public enum RuleSource: String, Sendable, Codable, CaseIterable { case globalDefault /// An enhanced-mode URL rule. case urlRule + /// The address-bar focus rule — a browser's address bar has keyboard focus. + case addressBarRule } /// The outcome of resolving which source (if any) to enforce right now. @@ -26,19 +28,41 @@ public enum LockResolution: Equatable, Sendable { } /// Pure resolution of the active lock target. Precedence: -/// enhanced URL match → per-app rule → global default. +/// {enhanced URL match, address-bar focus} → per-app rule → global default, +/// where the relative order of the two browser-scoped rules is user-controlled +/// (`addressBarOutranksURLRules`, default address-bar-first). public enum RuleResolver { public static func resolve( config: LockConfiguration, frontmostBundleID: String?, - urlMatch: (id: InputSourceID, action: RuleAction)? = nil + urlMatch: (id: InputSourceID, action: RuleAction)? = nil, + addressBarFocused: Bool = false ) -> LockResolution { - // 1. Enhanced mode (P6): a matched browser-URL rule wins outright. Its - // action decides lock vs one-shot switch. - if let urlMatch { - return urlMatch.action == .switchOnce - ? .switchOnce(urlMatch.id, .urlRule) - : .lock(urlMatch.id, .urlRule) + // 1. Browser-scoped rules: a matched URL rule and/or the focused address + // bar. Both outrank the per-app/default rule; which of the *two* wins + // when both apply is user-controlled (`addressBarOutranksURLRules`, + // default true → the address bar wins, since typing in it is the more + // immediate intent). Each rule's action decides lock vs one-shot + // switch. A `nil` address-bar target makes that rule inert (it + // contributes no candidate), so an enabled-but-unconfigured rule never acts. + let urlResolution: LockResolution? = urlMatch.map { + $0.action == .switchOnce ? .switchOnce($0.id, .urlRule) : .lock($0.id, .urlRule) + } + let addressBarResolution: LockResolution? = { + guard addressBarFocused, config.addressBarFocusEnabled, let id = config.addressBarSourceID + else { return nil } + return config.addressBarAction == .switchOnce + ? .switchOnce(id, .addressBarRule) + : .lock(id, .addressBarRule) + }() + // Array index = priority (element 0 outranks element 1); `compactMap` + // drops an inactive (nil) candidate so it neither wins nor blocks the + // one behind it, and `.first` takes the highest-priority survivor. + let browserScoped = config.addressBarOutranksURLRules + ? [addressBarResolution, urlResolution] + : [urlResolution, addressBarResolution] + if let winner = browserScoped.compactMap({ $0 }).first { + return winner } // 2. Per-app rule. diff --git a/Tests/LockIMEKitTests/AddressBarHeuristicTests.swift b/Tests/LockIMEKitTests/AddressBarHeuristicTests.swift new file mode 100644 index 0000000..4f4aeca --- /dev/null +++ b/Tests/LockIMEKitTests/AddressBarHeuristicTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing + +@testable import LockIMEKit + +@Suite("AddressBarHeuristic") +struct AddressBarHeuristicTests { + // The address bar lives in the browser chrome: under an AXToolbar, never + // inside the page's AXWebArea. + private let chromeAncestors = ["AXGroup", "AXToolbar", "AXGroup", "AXWindow"] + // A page is rooted in the web content. + private let webAreaAncestors = ["AXWebArea", "AXScrollArea", "AXGroup", "AXWindow"] + + @Test("Safari's address field is detected by its AXIdentifier") + func safari() { + #expect(AddressBarHeuristic.isAddressBar( + identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD", + domIdentifier: nil, domClassList: [], ancestorRoles: chromeAncestors + )) + } + + @Test("Chromium's omnibox is detected by its AXDOMClassList") + func chromium() { + #expect(AddressBarHeuristic.isAddressBar( + identifier: nil, domIdentifier: nil, + domClassList: ["OmniboxViewViews"], ancestorRoles: chromeAncestors + )) + } + + @Test("Firefox's urlbar is detected by its AXDOMIdentifier") + func firefox() { + #expect(AddressBarHeuristic.isAddressBar( + identifier: nil, domIdentifier: "urlbar-input", + domClassList: [], ancestorRoles: chromeAncestors + )) + } + + @Test("a page input matching no identifier is not the address bar") + func pageInput() { + #expect(!AddressBarHeuristic.isAddressBar( + identifier: nil, domIdentifier: "pageinput", + domClassList: [], ancestorRoles: webAreaAncestors + )) + } + + @Test("a matching identifier inside the web area is rejected by the structural gate") + func identifierInsideWebAreaRejected() { + // Even if a page element somehow carried the omnibox class, being rooted + // in the AXWebArea (not the toolbar chrome) disqualifies it. + #expect(!AddressBarHeuristic.isAddressBar( + identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD", + domIdentifier: "urlbar-input", + domClassList: ["OmniboxViewViews"], + ancestorRoles: webAreaAncestors + )) + } + + @Test("a toolbar element without any known identifier is not the address bar") + func toolbarButNoIdentifier() { + // A different toolbar text field (e.g. a find bar) sits under the toolbar + // but carries none of the per-engine address-bar identifiers. + #expect(!AddressBarHeuristic.isAddressBar( + identifier: "SOME_OTHER_FIELD", domIdentifier: nil, + domClassList: ["FindBarView"], ancestorRoles: chromeAncestors + )) + } + + @Test("no toolbar ancestor at all is not the address bar") + func noToolbar() { + #expect(!AddressBarHeuristic.isAddressBar( + identifier: "WEB_BROWSER_ADDRESS_AND_SEARCH_FIELD", + domIdentifier: nil, domClassList: [], + ancestorRoles: ["AXGroup", "AXWindow"] + )) + } +} diff --git a/Tests/LockIMEKitTests/ImportPlanTests.swift b/Tests/LockIMEKitTests/ImportPlanTests.swift index 99ad3bb..a145931 100644 --- a/Tests/LockIMEKitTests/ImportPlanTests.swift +++ b/Tests/LockIMEKitTests/ImportPlanTests.swift @@ -230,7 +230,21 @@ struct ImportPlanTests { @Test("import never touches the per-device runtime flags") func runtimeFlagsPreserved() { - let current = LockConfiguration(isEnabled: true, defaultSourceID: "US", enhancedModeEnabled: true) + // The address-bar feature is per-device runtime state (like isEnabled / + // enhancedModeEnabled): it must never travel through a backup and must + // survive an import unchanged. A refactor of resolvedConfiguration() that + // rebuilt the result from scratch instead of from `baseConfig` would + // silently reset these (e.g. flip the user's priority back to URL-first) — + // this asserts the `var result = baseConfig` carry-over for all of them. + let current = LockConfiguration( + isEnabled: true, + defaultSourceID: "US", + enhancedModeEnabled: true, + addressBarFocusEnabled: true, + addressBarAction: .lock, + addressBarSourceID: "ABC", + addressBarOutranksURLRules: false // non-default, to prove import preserves it + ) var plan = ImportPlan(current: current, backup: backup( appRules: [AppRule(bundleID: "com.a", mode: .locked, lockedSourceID: "ABC")] ), installedSources: installed) @@ -238,6 +252,10 @@ struct ImportPlanTests { let resolved = plan.resolvedConfiguration() #expect(resolved.isEnabled == true) #expect(resolved.enhancedModeEnabled == true) + #expect(resolved.addressBarFocusEnabled == true) + #expect(resolved.addressBarAction == .lock) + #expect(resolved.addressBarSourceID == "ABC") + #expect(resolved.addressBarOutranksURLRules == false) } // MARK: - Missing sources diff --git a/Tests/LockIMEKitTests/LockConfigurationTests.swift b/Tests/LockIMEKitTests/LockConfigurationTests.swift index 6ecd7f6..8ef9e11 100644 --- a/Tests/LockIMEKitTests/LockConfigurationTests.swift +++ b/Tests/LockIMEKitTests/LockConfigurationTests.swift @@ -183,6 +183,53 @@ struct LockConfigurationTests { #expect(ex.matchType == .domain) } + @Test("address-bar fields default off and round-trip through Codable") + func addressBarRoundTrips() throws { + // Defaults: off, switch action, no source, address bar outranks URL rules. + let def = LockConfiguration.default + #expect(def.addressBarFocusEnabled == false) + #expect(def.addressBarAction == .switchOnce) + #expect(def.addressBarSourceID == nil) + #expect(def.addressBarOutranksURLRules == true) + + let original = LockConfiguration( + isEnabled: true, + defaultSourceID: "com.apple.keylayout.US", + addressBarFocusEnabled: true, + addressBarAction: .lock, + addressBarSourceID: "com.apple.keylayout.ABC", + addressBarOutranksURLRules: false // non-default, to prove it round-trips + ) + let decoded = try JSONDecoder().decode(LockConfiguration.self, from: try JSONEncoder().encode(original)) + #expect(decoded == original) + #expect(decoded.addressBarFocusEnabled) + #expect(decoded.addressBarAction == .lock) + #expect(decoded.addressBarSourceID == "com.apple.keylayout.ABC") + #expect(decoded.addressBarOutranksURLRules == false) + } + + @Test("a config predating the address-bar fields decodes to the off defaults") + func decodesLegacyWithoutAddressBarFields() throws { + let json = #"{"isEnabled": true, "defaultSourceID": "com.apple.keylayout.US", "enhancedModeEnabled": true}"# + let config = try JSONDecoder().decode(LockConfiguration.self, from: Data(json.utf8)) + #expect(config.addressBarFocusEnabled == false) + #expect(config.addressBarAction == .switchOnce) + #expect(config.addressBarSourceID == nil) + // A config predating this field defaults to the new address-bar-first behavior. + #expect(config.addressBarOutranksURLRules == true) + } + + @Test("an unknown address-bar action degrades to switchOnce instead of throwing") + func decodesUnknownAddressBarAction() throws { + // A newer build could write an action this one doesn't know; it must + // degrade rather than abort the whole config decode. + let json = #"{"addressBarFocusEnabled": true, "addressBarAction": "teleport", "addressBarSourceID": "com.apple.keylayout.ABC"}"# + let config = try JSONDecoder().decode(LockConfiguration.self, from: Data(json.utf8)) + #expect(config.addressBarFocusEnabled) + #expect(config.addressBarAction == .switchOnce) + #expect(config.addressBarSourceID == "com.apple.keylayout.ABC") + } + @Test("URLMatchType.id is its raw value (the stable persisted token)") func urlMatchTypeID() { #expect(URLMatchType.domainSuffix.rawValue == "domain-suffix") diff --git a/Tests/LockIMEKitTests/LockEngineAddressBarTests.swift b/Tests/LockIMEKitTests/LockEngineAddressBarTests.swift new file mode 100644 index 0000000..b5ef302 --- /dev/null +++ b/Tests/LockIMEKitTests/LockEngineAddressBarTests.swift @@ -0,0 +1,265 @@ +import Foundation +import Testing + +@testable import LockIMEKit + +@MainActor +@Suite("LockEngine address-bar rule") +struct LockEngineAddressBarTests { + private let us: InputSourceID = "com.apple.keylayout.US" + private let abc: InputSourceID = "com.apple.keylayout.ABC" + private let pinyin: InputSourceID = "com.apple.inputmethod.SCIM.ITABC" + private let safari = "com.apple.Safari" + + private func makeEngine( + current: InputSourceID, + frontmost: String? + ) -> (LockEngine, MockInputSourceProvider, MockFrontmostMonitor, MockAddressBarMonitor) { + let provider = MockInputSourceProvider( + current: current, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let monitor = MockFrontmostMonitor(bundleID: frontmost) + let addressBar = MockAddressBarMonitor() + let engine = LockEngine(provider: provider, appMonitor: monitor, addressBarMonitor: addressBar) + engine.start() + return (engine, provider, monitor, addressBar) + } + + private func config(action: RuleAction, source: InputSourceID?, default def: InputSourceID? = nil, appRules: [AppRule] = []) -> LockConfiguration { + LockConfiguration( + isEnabled: true, + defaultSourceID: def, + appRules: appRules, + addressBarFocusEnabled: true, + addressBarAction: action, + addressBarSourceID: source + ) + } + + @Test("lock mode: focusing the address bar locks the source; blur falls back to the default") + func lockModeFocusAndBlur() { + let (engine, provider, _, ab) = makeEngine(current: us, frontmost: safari) + engine.apply(config(action: .lock, source: abc, default: us)) + #expect(provider.current == us) // address bar not focused yet → default + #expect(ab.observedBundleID == safari) // observing the frontmost browser + + ab.setFocused(true) + #expect(provider.current == abc) // address-bar lock applied + + ab.setFocused(false) + #expect(provider.current == us) // blur → back to the default lock + } + + @Test("switch mode fires once on focus and re-arms after blur") + func switchModeFiresOnceAndReArms() { + let (engine, provider, _, ab) = makeEngine(current: us, frontmost: safari) + engine.apply(config(action: .switchOnce, source: abc, default: us)) + #expect(provider.current == us) + + ab.setFocused(true) + #expect(provider.current == abc) // switched once + #expect(provider.selectCalls == [abc]) + + provider.current = us // user switches away — no standing lock reverts it + ab.setFocused(false) // blur → default lock (us); already there, no switch + #expect(provider.current == us) + #expect(provider.selectCalls == [abc]) + + ab.setFocused(true) // genuine re-entry → fires again + #expect(provider.current == abc) + #expect(provider.selectCalls == [abc, abc]) + } + + @Test("URL-first opt-out: a matched URL rule wins even while the address bar is focused") + func urlFirstOptOutKeepsURLRule() { + let provider = MockInputSourceProvider( + current: us, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let urls = MockBrowserURLProvider(url: "https://github.com/x") + let ab = MockAddressBarMonitor() + let engine = LockEngine( + provider: provider, + appMonitor: MockFrontmostMonitor(bundleID: safari), + addressBarMonitor: ab, + urlProvider: urls + ) + engine.start() + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: pinyin)], + addressBarFocusEnabled: true, + addressBarAction: .lock, + addressBarSourceID: abc, + addressBarOutranksURLRules: false // opt out of the address-bar-first default + )) + #expect(provider.current == pinyin) // github URL rule + + ab.setFocused(true) + #expect(provider.current == pinyin) // still the URL rule — opted into URL-first + } + + @Test("with address-bar priority on, focusing the bar overrides the matched URL rule") + func addressBarPriorityOverridesURL() { + let provider = MockInputSourceProvider( + current: us, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let urls = MockBrowserURLProvider(url: "https://github.com/x") + let ab = MockAddressBarMonitor() + let engine = LockEngine( + provider: provider, + appMonitor: MockFrontmostMonitor(bundleID: safari), + addressBarMonitor: ab, + urlProvider: urls + ) + engine.start() + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: pinyin)], + addressBarFocusEnabled: true, + addressBarAction: .lock, + addressBarSourceID: abc, + addressBarOutranksURLRules: true + )) + #expect(provider.current == pinyin) // bar not focused → URL rule still applies + + ab.setFocused(true) + #expect(provider.current == abc) // focused → address bar overrides the URL rule + + ab.setFocused(false) + #expect(provider.current == pinyin) // blur → URL rule reclaims the page + } + + @Test("override on + both switch actions: bar and URL one-shots dedup by context, re-firing on each transition") + func overrideSwitchOnceDedupByContext() { + let provider = MockInputSourceProvider( + current: us, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let urls = MockBrowserURLProvider(url: "https://github.com/x") + let ab = MockAddressBarMonitor() + let engine = LockEngine( + provider: provider, + appMonitor: MockFrontmostMonitor(bundleID: safari), + addressBarMonitor: ab, + urlProvider: urls + ) + engine.start() + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: pinyin, action: .switchOnce)], + addressBarFocusEnabled: true, + addressBarAction: .switchOnce, + addressBarSourceID: abc, + addressBarOutranksURLRules: true + )) + #expect(provider.current == pinyin) // bar not focused → URL one-shot fired + #expect(provider.selectCalls == [pinyin]) + + ab.setFocused(true) + #expect(provider.current == abc) // bar overrides → its own one-shot fires + #expect(provider.selectCalls == [pinyin, abc]) + + ab.setFocused(false) + // Blur → the URL one-shot reclaims the page. Its SwitchKey (keyed on the + // host pattern) differs from the address-bar key (keyed on the bundle), + // so it is a genuine re-entry and re-fires — the documented one-shot + // re-entry contract, now reachable via the address-bar excursion. + #expect(provider.current == pinyin) + #expect(provider.selectCalls == [pinyin, abc, pinyin]) + } + + @Test("no reverse restore: blur leaves the source where the rule put it when no rule reclaims it") + func noReverseRestore() { + let (engine, provider, _, ab) = makeEngine(current: us, frontmost: safari) + // No default and no app rule, so nothing reclaims the source on blur. + engine.apply(config(action: .lock, source: abc, default: nil)) + #expect(provider.current == us) // nothing forced up front + + ab.setFocused(true) + #expect(provider.current == abc) // address-bar lock applied + + ab.setFocused(false) + #expect(provider.current == abc) // blur leaves it at abc — never restored to us + } + + @Test("the feature is dormant when off: not observed, and a stray focus event does nothing") + func dormantWhenOff() { + let (engine, provider, _, ab) = makeEngine(current: us, frontmost: safari) + engine.apply(LockConfiguration( + isEnabled: true, defaultSourceID: us, + addressBarFocusEnabled: false, addressBarAction: .lock, addressBarSourceID: abc + )) + #expect(ab.observedBundleID == nil) // off → not observing + + ab.setFocused(true) // even a stray event resolves to nothing (config gate) + #expect(provider.current == us) + } + + @Test("the address bar is observed only while a browser is frontmost") + func observedOnlyForBrowsers() { + let (engine, _, monitor, ab) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(config(action: .switchOnce, source: abc, default: us)) + #expect(ab.observedBundleID == nil) // a non-browser app → not observed + + monitor.activate(safari) + #expect(ab.observedBundleID == safari) + + monitor.activate("com.foo.App") + #expect(ab.observedBundleID == nil) // left the browser → observation stops + } + + @Test("a forced switch is logged as addressBarFocused with the address-bar rule branch") + func logsReasonAndBranch() { + let (engine, _, _, ab) = makeEngine(current: us, frontmost: safari) + var events: [ActivationEvent] = [] + engine.onActivation = { events.append($0) } + engine.apply(config(action: .lock, source: abc, default: us)) + + ab.setFocused(true) + #expect(events.last?.reason == .addressBarFocused) + #expect(events.last?.ruleSource == .addressBarRule) + #expect(events.last?.inputSource == abc) + } + + @Test("accessibilityDidChange asks the address-bar monitor to re-attach") + func accessibilityRefreshes() { + let (engine, _, _, ab) = makeEngine(current: us, frontmost: safari) + #expect(ab.refreshCount == 0) + engine.accessibilityDidChange() + #expect(ab.refreshCount == 1) + } + + @Test("a launcher overlay over a browser suspends address-bar observation") + func launcherSuspendsObservation() { + let provider = MockInputSourceProvider( + current: us, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let floating = MockFloatingMonitor() + let ab = MockAddressBarMonitor() + let engine = LockEngine( + provider: provider, + appMonitor: MockFrontmostMonitor(bundleID: safari), + floatingAppMonitor: floating, + addressBarMonitor: ab + ) + engine.start() + engine.apply(config(action: .lock, source: abc, default: us)) + #expect(ab.observedBundleID == safari) + + floating.setLauncher("com.apple.Spotlight") + #expect(ab.observedBundleID == nil) // overlay isn't a browser → suspended + + floating.setLauncher(nil) + #expect(ab.observedBundleID == safari) // dismissed → resumes + } +} diff --git a/Tests/LockIMEKitTests/RuleResolverTests.swift b/Tests/LockIMEKitTests/RuleResolverTests.swift index 8b4c09f..fb4c4f0 100644 --- a/Tests/LockIMEKitTests/RuleResolverTests.swift +++ b/Tests/LockIMEKitTests/RuleResolverTests.swift @@ -103,4 +103,144 @@ struct RuleResolverTests { ) #expect(RuleResolver.resolve(config: config, frontmostBundleID: "com.foo.App") == .lock(us, .globalDefault)) } + + // MARK: - Address-bar focus rule + + @Test("address-bar focus locks its source over the app/default rule") + func addressBarLocks() { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.apple.Safari", mode: .locked, lockedSourceID: pinyin)], + addressBarFocusEnabled: true, + addressBarAction: .lock, + addressBarSourceID: abc + ) + #expect( + RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Safari", addressBarFocused: true) + == .lock(abc, .addressBarRule) + ) + // Not focused → the app rule applies as usual. + #expect( + RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Safari", addressBarFocused: false) + == .lock(pinyin, .appRule) + ) + } + + @Test("address-bar focus with the switch action yields a one-shot switch") + func addressBarSwitches() { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: us, + addressBarFocusEnabled: true, + addressBarAction: .switchOnce, + addressBarSourceID: abc + ) + #expect( + RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Safari", addressBarFocused: true) + == .switchOnce(abc, .addressBarRule) + ) + } + + @Test("by default the address bar outranks a URL rule; URL-first is an opt-out") + func addressBarOutranksByDefault() { + // New default: addressBarOutranksURLRules == true → the address bar wins + // when both apply. + let def = LockConfiguration( + isEnabled: true, + defaultSourceID: us, + addressBarFocusEnabled: true, + addressBarAction: .lock, + addressBarSourceID: abc + ) + #expect(def.addressBarOutranksURLRules == true) + #expect( + RuleResolver.resolve( + config: def, frontmostBundleID: "com.apple.Safari", + urlMatch: (pinyin, .lock), addressBarFocused: true + ) == .lock(abc, .addressBarRule) + ) + + // Opt out → URL rules win when the user flips the flag. + var urlFirst = def + urlFirst.addressBarOutranksURLRules = false + #expect( + RuleResolver.resolve( + config: urlFirst, frontmostBundleID: "com.apple.Safari", + urlMatch: (pinyin, .lock), addressBarFocused: true + ) == .lock(pinyin, .urlRule) + ) + } + + @Test("the priority flag only matters when the address bar is actually focused") + func priorityOnlyWhenFocused() { + let config = LockConfiguration( + isEnabled: true, + defaultSourceID: us, + addressBarFocusEnabled: true, + addressBarAction: .lock, + addressBarSourceID: abc + // addressBarOutranksURLRules defaults to true + ) + // Bar not focused → the URL rule applies regardless of the priority flag. + #expect( + RuleResolver.resolve( + config: config, frontmostBundleID: "com.apple.Safari", + urlMatch: (pinyin, .lock), addressBarFocused: false + ) == .lock(pinyin, .urlRule) + ) + // No URL match, bar focused → the address bar applies. + #expect( + RuleResolver.resolve(config: config, frontmostBundleID: "com.apple.Safari", addressBarFocused: true) + == .lock(abc, .addressBarRule) + ) + } + + @Test("the winner's switch action is preserved when both browser-scoped rules apply") + func switchActionPreservedForWinner() { + // URL-first order (opt-out): a switch-action URL rule wins as a one-shot, + // even though the address-bar rule is a lock. + let urlFirst = LockConfiguration( + isEnabled: true, defaultSourceID: us, + addressBarFocusEnabled: true, addressBarAction: .lock, addressBarSourceID: abc, + addressBarOutranksURLRules: false + ) + #expect( + RuleResolver.resolve( + config: urlFirst, frontmostBundleID: "com.apple.Safari", + urlMatch: (pinyin, .switchOnce), addressBarFocused: true + ) == .switchOnce(pinyin, .urlRule) + ) + + // Address-bar first: a switch-action address-bar rule wins as a one-shot, + // even though the URL rule is a lock. + let barFirst = LockConfiguration( + isEnabled: true, defaultSourceID: us, + addressBarFocusEnabled: true, addressBarAction: .switchOnce, addressBarSourceID: abc, + addressBarOutranksURLRules: true + ) + #expect( + RuleResolver.resolve( + config: barFirst, frontmostBundleID: "com.apple.Safari", + urlMatch: (pinyin, .lock), addressBarFocused: true + ) == .switchOnce(abc, .addressBarRule) + ) + } + + @Test("the address-bar rule is inert when disabled or unconfigured") + func addressBarInertWhenOffOrUnset() { + // Disabled → falls through to the default. + let off = LockConfiguration( + isEnabled: true, defaultSourceID: us, + addressBarFocusEnabled: false, addressBarAction: .lock, addressBarSourceID: abc + ) + #expect(RuleResolver.resolve(config: off, frontmostBundleID: "com.apple.Safari", addressBarFocused: true) == .lock(us, .globalDefault)) + + // Enabled but no source set → falls through (never acts inert). + let noSource = LockConfiguration( + isEnabled: true, defaultSourceID: us, + addressBarFocusEnabled: true, addressBarAction: .lock, addressBarSourceID: nil + ) + #expect(RuleResolver.resolve(config: noSource, frontmostBundleID: "com.apple.Safari", addressBarFocused: true) == .lock(us, .globalDefault)) + } } diff --git a/Tests/LockIMEKitTests/Support/MockAddressBarMonitor.swift b/Tests/LockIMEKitTests/Support/MockAddressBarMonitor.swift new file mode 100644 index 0000000..3f7f189 --- /dev/null +++ b/Tests/LockIMEKitTests/Support/MockAddressBarMonitor.swift @@ -0,0 +1,37 @@ +import Foundation + +@testable import LockIMEKit + +/// A test double for the address-bar focus monitor. The engine drives +/// `observe(bundleID:)`; tests drive focus transitions with `setFocused`. +@MainActor +final class MockAddressBarMonitor: AddressBarFocusMonitoring { + private var handler: (@MainActor (Bool) -> Void)? + /// The bundle id the engine last asked to observe (`nil` = stopped). + private(set) var observedBundleID: String? + private(set) var refreshCount = 0 + /// When true, `refresh()` reports `false` — modelling the real monitor + /// clearing its focus attribution after Accessibility is revoked. + var refreshClearsFocus = false + + func start(onChange: @escaping @MainActor (Bool) -> Void) { + handler = onChange + } + + func observe(bundleID: String?) { + observedBundleID = bundleID + } + + func refresh() { + refreshCount += 1 + if refreshClearsFocus { handler?(false) } + } + + func stop() { handler = nil } + + /// Simulate the observed browser's address bar gaining (`true`) or losing + /// (`false`) keyboard focus. + func setFocused(_ focused: Bool) { + handler?(focused) + } +}