From 168d201f52716ac0f9212a1306f12856d048179a Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Fri, 12 Jun 2026 04:46:32 -0400 Subject: [PATCH 1/2] feat(lock): detect Spotlight and launcher overlays for rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launcher overlays (Spotlight, Raycast, Alfred, LaunchBar) take keyboard focus without changing NSWorkspace.frontmostApplication, so per-app rules resolved against the app behind them: Spotlight could not be locked to its own source, and an underlying CJKV lock leaked into the search field (issue #9). An Accessibility-gated FloatingAppMonitor now observes the known launcher processes and reads the system-wide focused UI element to attribute the real keyboard owner; the engine resolves rules against that launcher when present and reverts on dismissal. The core lock stays permission-free — without the grant, behavior is unchanged. Accessibility now gates two optional features (per-URL enhanced mode and launcher detection), so its single grant moved to a dedicated Permissions tab; App Rules and URL Rules show a passive note that routes there, never a prompt duplicated per feature. The grant watcher's abandon-stop moved to the Settings window (not a tab switch) and a status refresh now runs the full grant completion, so switching tabs mid-grant or granting out-of-band no longer leaves the launcher monitor unattached. Signed-off-by: Kevin Cui --- Sources/LockIME/AppState.swift | 27 +- Sources/LockIME/Localizable.xcstrings | 520 ++++++++++++++++++ Sources/LockIME/UI/Components.swift | 57 ++ .../UI/Settings/AppRulesSettingsPane.swift | 18 + .../UI/Settings/PermissionsSettingsPane.swift | 60 ++ .../UI/Settings/URLRulesSettingsPane.swift | 31 +- Sources/LockIME/UI/SettingsRootView.swift | 52 +- .../AppMonitor/FloatingAppMonitor.swift | 193 +++++++ .../AppMonitor/FloatingAppMonitoring.swift | 16 + .../AppMonitor/LauncherOverlayCatalog.swift | 45 ++ .../LockIMEKit/LockEngine/LockEngine.swift | 46 +- .../LauncherOverlayCatalogTests.swift | 28 + Tests/LockIMEKitTests/LockEngineTests.swift | 159 ++++++ .../Support/MockFloatingMonitor.swift | 23 + docs/DESIGN.md | 17 +- 15 files changed, 1241 insertions(+), 51 deletions(-) create mode 100644 Sources/LockIME/UI/Settings/PermissionsSettingsPane.swift create mode 100644 Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift create mode 100644 Sources/LockIMEKit/AppMonitor/FloatingAppMonitoring.swift create mode 100644 Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift create mode 100644 Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift create mode 100644 Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index 7b579f4..89f29af 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -77,6 +77,10 @@ final class AppState { /// User language choice, loaded eagerly so scenes get the right locale. private(set) var languagePreference: LanguagePreference = .system + /// The selected Settings tab. Held here (not as view `@State`) so a feature + /// pane can route the user to General's single Accessibility grant. + var settingsTab: SettingsTab = .general + /// Master on/off, mirroring `config.isEnabled`. var isLocked: Bool { config.isEnabled } @@ -324,8 +328,16 @@ final class AppState { commit() } + /// Reconcile the cached flag with the live trust state. This is the recovery + /// path: the user may grant while the polling watcher is stopped (e.g. after + /// closing the window mid-flow) or entirely out-of-band in System Settings, + /// so a refresh that observes a *new* grant must run the same completion the + /// watcher would have. Also flips the flag back on revoke. func refreshAccessibilityStatus() { - accessibilityGranted = AXIsProcessTrusted() + let trusted = AXIsProcessTrusted() + let newlyGranted = trusted && !accessibilityGranted + accessibilityGranted = trusted + if newlyGranted { handleAccessibilityGranted() } } /// Open System Settings with the floating drag helper, then start watching @@ -351,8 +363,19 @@ final class AppState { /// Run when the grant is detected: close the floating helper (the system /// never does) and flip the flag so the toggle becomes usable at once. private func completeAccessibilityGrant() { - permissionFlow.closePanel(returnToPreviousApp: true) accessibilityGranted = true + handleAccessibilityGranted() + } + + /// Run once when the grant is first observed — by the polling watcher *or* a + /// status refresh. Closes the floating helper (the system never does), stops + /// the now-finished watcher, and attaches the launcher-overlay monitor + /// (Spotlight, Raycast, …) which needs the grant to register its observers. + /// All three are idempotent, so observing the grant twice is harmless. + private func handleAccessibilityGranted() { + permissionFlow.closePanel(returnToPreviousApp: true) + accessibilityWatcher.stop() + engine?.accessibilityDidChange() } #if DEBUG diff --git a/Sources/LockIME/Localizable.xcstrings b/Sources/LockIME/Localizable.xcstrings index b756e3a..917d1e9 100644 --- a/Sources/LockIME/Localizable.xcstrings +++ b/Sources/LockIME/Localizable.xcstrings @@ -5825,6 +5825,526 @@ } } } + }, + "Accessibility": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "辅助功能" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輔助使用" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセシビリティ" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accessibilité" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bedienungshilfen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Accesibilidad" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Acessibilidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Универсальный доступ" + } + } + } + }, + "Grant once to unlock two optional features:": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授予一次即可解锁两项可选功能:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "授予一次即可解鎖兩項選用功能:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一度許可すると、2 つのオプション機能が使えます:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisez une fois pour activer deux fonctionnalités optionnelles :" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einmal erteilen, um zwei optionale Funktionen freizuschalten:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Concede una vez para activar dos funciones opcionales:" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conceda uma vez para ativar dois recursos opcionais:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предоставьте один раз, чтобы включить две дополнительные функции:" + } + } + } + }, + "Per-URL rules in your browser": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器中的按网址规则" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器中的按網址規則" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザの URL 別ルール" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Règles par URL dans votre navigateur" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Regeln pro URL in Ihrem Browser" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reglas por URL en tu navegador" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Regras por URL no seu navegador" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Правила по URL в браузере" + } + } + } + }, + "Targeting launchers like Spotlight in app rules": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 App 规则中锁定 Spotlight 等启动器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 App 規則中鎖定 Spotlight 等啟動器" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリルールで Spotlight などのランチャーを対象にする" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cibler des lanceurs comme Spotlight dans les règles d’app" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Launcher wie Spotlight in App-Regeln ansteuern" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Apuntar a lanzadores como Spotlight en las reglas de apps" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Definir lançadores como o Spotlight nas regras de apps" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нацеливание на лаунчеры вроде Spotlight в правилах приложений" + } + } + } + }, + "Accessibility access granted": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已授予辅助功能权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已授予輔助使用權限" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセシビリティアクセスを許可済み" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accès à l’accessibilité autorisé" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bedienungshilfen-Zugriff erteilt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acceso de accesibilidad concedido" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Acesso de acessibilidade concedido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступ к универсальному доступу предоставлен" + } + } + } + }, + "The core lock needs no permissions.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "核心锁定无需任何权限。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "核心鎖定不需要任何權限。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コアのロックに権限は不要です。" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le verrouillage de base ne nécessite aucune autorisation." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Kernsperre benötigt keine Berechtigungen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El bloqueo básico no necesita permisos." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O bloqueio básico não precisa de permissões." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Базовой блокировке разрешения не нужны." + } + } + } + }, + "Enhanced mode requires Accessibility": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "增强模式需要辅助功能权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "增強模式需要輔助使用權限" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拡張モードにはアクセシビリティが必要です" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode avancé nécessite l’accessibilité" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der erweiterte Modus erfordert Bedienungshilfen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El modo avanzado requiere accesibilidad" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O modo avançado requer acessibilidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расширенный режим требует универсального доступа" + } + } + } + }, + "Detecting launchers like Spotlight requires Accessibility": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测 Spotlight 等启动器需要辅助功能权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偵測 Spotlight 等啟動器需要輔助使用權限" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Spotlight などのランチャーの検出にはアクセシビリティが必要です" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La détection des lanceurs comme Spotlight nécessite l’accessibilité" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Erkennen von Launchern wie Spotlight erfordert Bedienungshilfen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detectar lanzadores como Spotlight requiere accesibilidad" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Detetar lançadores como o Spotlight requer acessibilidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для обнаружения лаунчеров вроде Spotlight требуется универсальный доступ" + } + } + } + }, + "Permissions": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "權限" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisations" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permisos" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Permissões" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешения" + } + } + } + }, + "Set Up in Permissions…": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在「权限」中设置…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在「權限」中設定…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「権限」で設定…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurer dans Autorisations…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In „Berechtigungen“ einrichten…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configurar en Permisos…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Configurar em Permissões…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настроить в «Разрешениях»…" + } + } + } } } } diff --git a/Sources/LockIME/UI/Components.swift b/Sources/LockIME/UI/Components.swift index 0d67657..ceae31a 100644 --- a/Sources/LockIME/UI/Components.swift +++ b/Sources/LockIME/UI/Components.swift @@ -1,6 +1,63 @@ +import AppKit import LockIMEKit import SwiftUI +/// The single Accessibility grant action, shared so it lives in exactly one +/// place (General's Accessibility section). Opens the Accessibility privacy pane +/// with the floating drag helper; the grant is detected by `AppState`, which +/// closes the helper and flips `accessibilityGranted` the instant access is +/// allowed (the system sends no notification). +struct GrantAccessibilityButton: View { + @Environment(AppState.self) private var state + @Environment(\.locale) private var locale + + var body: some View { + Button { + state.requestAccessibilityAccess( + localeIdentifier: locale.identifier, + suggestedAppURLs: [Bundle.main.bundleURL], + sourceFrame: Self.clickSourceFrame() + ) + } label: { + Label("Grant Accessibility Access", systemImage: "arrow.right.circle.fill") + } + } + + /// Uses the click location so the helper panel flies out from the button. + private static func clickSourceFrame() -> CGRect { + let mouse = NSEvent.mouseLocation + return CGRect(x: mouse.x - 16, y: mouse.y - 16, width: 32, height: 32) + } +} + +/// A passive "Requires Accessibility" note for feature panes (App Rules, URL +/// Rules). It carries no grant button of its own — that lives only in General — +/// it just explains the dependency in its own words and routes there, so the +/// permission reads as one capability with a single grant, never a prompt +/// duplicated per feature. +struct AccessibilityRequiredNote: View { + @Environment(AppState.self) private var state + private let message: LocalizedStringKey + + init(_ message: LocalizedStringKey) { self.message = message } + + var body: some View { + HStack(spacing: DS.Spacing.md) { + Image(systemName: "accessibility") + .foregroundStyle(.secondary) + Text(message) + .font(DS.Font.sectionFooter) + .foregroundStyle(.secondary) + Spacer(minLength: DS.Spacing.sm) + Button("Set Up in Permissions…") { + state.settingsTab = .permissions + } + .buttonStyle(.link) + } + .padding(.vertical, DS.Spacing.xxs) + } +} + /// A standard app row — icon + display name + bundle identifier — shared by the /// App Rules pane and the app picker so both read with identical rhythm. struct AppRowLabel: View { diff --git a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift index fb3ce76..43febc0 100644 --- a/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift @@ -1,3 +1,4 @@ +import AppKit import LockIMEKit import SwiftUI @@ -39,6 +40,13 @@ struct AppRulesSettingsPane: View { } label: { Label("Add App…", systemImage: "plus") } + + // Launcher overlays (Spotlight, Raycast, …) are the only app + // rules that need Accessibility — show the routing note only + // when one is configured but access isn't granted yet. + if hasLauncherRule, !state.accessibilityGranted { + AccessibilityRequiredNote("Detecting launchers like Spotlight requires Accessibility") + } } header: { Text("Per-app rules") } footer: { @@ -47,6 +55,10 @@ struct AppRulesSettingsPane: View { } .formStyle(.grouped) .navigationTitle(state.loc("App Rules")) + .onAppear { state.refreshAccessibilityStatus() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + state.refreshAccessibilityStatus() + } .sheet(isPresented: $isPickingApp) { AppPickerSheet { app in withAnimation(DS.Motion.list) { @@ -58,6 +70,12 @@ struct AppRulesSettingsPane: View { } } + /// Whether any configured rule targets a launcher overlay — the only app + /// rules whose enforcement depends on Accessibility. + private var hasLauncherRule: Bool { + state.config.appRules.contains { LauncherOverlayCatalog.isLauncher($0.bundleID) } + } + private var emptyState: some View { HStack(spacing: DS.Spacing.md) { Image(systemName: "macwindow") diff --git a/Sources/LockIME/UI/Settings/PermissionsSettingsPane.swift b/Sources/LockIME/UI/Settings/PermissionsSettingsPane.swift new file mode 100644 index 0000000..e6fa42a --- /dev/null +++ b/Sources/LockIME/UI/Settings/PermissionsSettingsPane.swift @@ -0,0 +1,60 @@ +import AppKit +import LockIMEKit +import SwiftUI + +/// The single home for LockIME's optional system permissions. Today that's just +/// Accessibility, which gates two features (per-URL rules + launcher/Spotlight +/// detection); the feature panes only carry a passive `AccessibilityRequiredNote` +/// that routes here, so the grant is requested in exactly one place. +struct PermissionsSettingsPane: View { + @Environment(AppState.self) private var state + + var body: some View { + Form { + Section { + if state.accessibilityGranted { + Label("Accessibility access granted", systemImage: "checkmark.circle.fill") + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: DS.Spacing.sm) { + Text("Grant once to unlock two optional features:") + VStack(alignment: .leading, spacing: DS.Spacing.xs) { + FeatureBullet("Per-URL rules in your browser") + FeatureBullet("Targeting launchers like Spotlight in app rules") + } + } + GrantAccessibilityButton() + } + } header: { + Text("Accessibility") + } footer: { + SectionFooter("The core lock needs no permissions.") + } + } + .formStyle(.grouped) + .navigationTitle(state.loc("Permissions")) + // The watcher's abandon-stop lives on SettingsRootView (window close), so + // switching tabs mid-grant doesn't kill detection; panes only reconcile + // the shared status, which also recovers a grant the watcher missed. + .onAppear { state.refreshAccessibilityStatus() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + state.refreshAccessibilityStatus() + } + } +} + +/// One unlocked-feature line in the Accessibility section's rationale. +private struct FeatureBullet: View { + private let text: LocalizedStringKey + init(_ text: LocalizedStringKey) { self.text = text } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: DS.Spacing.sm) { + Image(systemName: "checkmark") + .font(.caption) + .foregroundStyle(.secondary) + Text(text) + .foregroundStyle(.secondary) + } + } +} diff --git a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift index a164b8a..9501775 100644 --- a/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift +++ b/Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift @@ -23,7 +23,7 @@ struct URLRulesSettingsPane: View { .disabled(!state.accessibilityGranted) if !state.accessibilityGranted { - GrantAccessibilityButton() + AccessibilityRequiredNote("Enhanced mode requires Accessibility") } } header: { Text("Enhanced mode") @@ -59,8 +59,9 @@ struct URLRulesSettingsPane: View { } .formStyle(.grouped) .navigationTitle(state.loc("URL Rules")) + // The grant button (and its watcher lifecycle) now lives in General; this + // pane only reflects the shared status, refreshing to catch a revoke. .onAppear { state.refreshAccessibilityStatus() } - .onDisappear { state.stopAccessibilityWatch() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in state.refreshAccessibilityStatus() } @@ -106,32 +107,6 @@ struct URLRulesSettingsPane: View { } } -/// Opens the Accessibility privacy pane with the floating drag helper. The -/// grant is detected by `AppState`, which closes the helper and enables the -/// toggle the instant access is allowed (the system sends no notification). -private struct GrantAccessibilityButton: View { - @Environment(AppState.self) private var state - @Environment(\.locale) private var locale - - var body: some View { - Button { - state.requestAccessibilityAccess( - localeIdentifier: locale.identifier, - suggestedAppURLs: [Bundle.main.bundleURL], - sourceFrame: Self.clickSourceFrame() - ) - } label: { - Label("Grant Accessibility Access", systemImage: "arrow.right.circle.fill") - } - } - - /// Uses the click location so the helper panel flies out from the button. - private static func clickSourceFrame() -> CGRect { - let mouse = NSEvent.mouseLocation - return CGRect(x: mouse.x - 16, y: mouse.y - 16, width: 32, height: 32) - } -} - private struct URLRuleRow: View { @Environment(AppState.self) private var state let rule: URLRule diff --git a/Sources/LockIME/UI/SettingsRootView.swift b/Sources/LockIME/UI/SettingsRootView.swift index 499136c..005d8bc 100644 --- a/Sources/LockIME/UI/SettingsRootView.swift +++ b/Sources/LockIME/UI/SettingsRootView.swift @@ -1,67 +1,93 @@ import LockIMEKit import SwiftUI +/// The Settings window's tabs. A stable identity lets one pane route the user to +/// another — App Rules / URL Rules point at General's single Accessibility grant. +enum SettingsTab: Hashable { + case general, appRules, urlRules, shortcuts, permissions, updates, log +} + /// Root of the Settings window — a standard multi-pane macOS settings TabView, /// the same shape as System Settings. Each pane is its own grouped `Form`. /// /// Two bodies for one view: the `Tab` builder (and tab badges) is macOS 15+, /// and the deployment floor is 14 — Sonoma falls back to the `.tabItem` API, /// which draws the same settings tabs minus the Updates badge. Keep both in -/// sync when adding a pane. +/// sync when adding a pane. Both drive their selection off `AppState.settingsTab` +/// so the Accessibility badges can programmatically switch to General. struct SettingsRootView: View { @Environment(AppState.self) private var state var body: some View { + let selection = Binding( + get: { state.settingsTab }, + set: { state.settingsTab = $0 } + ) Group { if #available(macOS 15.0, *) { - tabs + tabs(selection: selection) } else { - legacyTabs + legacyTabs(selection: selection) } } .scenePadding() .frame(minWidth: 680, idealWidth: 700, minHeight: 460) + // The Settings *window* closing (not a tab switch — this root outlives + // those) is the "abandon" signal for an in-flight Accessibility grant. + .onDisappear { state.stopAccessibilityWatch() } } @available(macOS 15.0, *) - private var tabs: some View { - TabView { - Tab("General", systemImage: "gearshape") { + private func tabs(selection: Binding) -> some View { + TabView(selection: selection) { + Tab("General", systemImage: "gearshape", value: SettingsTab.general) { GeneralSettingsPane() } - Tab("App Rules", systemImage: "macwindow.on.rectangle") { + Tab("App Rules", systemImage: "macwindow.on.rectangle", value: SettingsTab.appRules) { AppRulesSettingsPane() } - Tab("URL Rules", systemImage: "globe") { + Tab("URL Rules", systemImage: "globe", value: SettingsTab.urlRules) { URLRulesSettingsPane() } - Tab("Shortcuts", systemImage: "command") { + Tab("Shortcuts", systemImage: "command", value: SettingsTab.shortcuts) { ShortcutsSettingsPane() } - Tab("Updates", systemImage: "arrow.down.circle") { + Tab("Permissions", systemImage: "hand.raised", value: SettingsTab.permissions) { + PermissionsSettingsPane() + } + Tab("Updates", systemImage: "arrow.down.circle", value: SettingsTab.updates) { UpdatesSettingsPane() } .badge(state.updateController.pendingUpdateVersion != nil ? 1 : 0) - Tab("Log", systemImage: "list.bullet.rectangle") { + Tab("Log", systemImage: "list.bullet.rectangle", value: SettingsTab.log) { ActivationLogPane() } } } - private var legacyTabs: some View { - TabView { + private func legacyTabs(selection: Binding) -> some View { + TabView(selection: selection) { GeneralSettingsPane() .tabItem { Label("General", systemImage: "gearshape") } + .tag(SettingsTab.general) AppRulesSettingsPane() .tabItem { Label("App Rules", systemImage: "macwindow.on.rectangle") } + .tag(SettingsTab.appRules) URLRulesSettingsPane() .tabItem { Label("URL Rules", systemImage: "globe") } + .tag(SettingsTab.urlRules) ShortcutsSettingsPane() .tabItem { Label("Shortcuts", systemImage: "command") } + .tag(SettingsTab.shortcuts) + PermissionsSettingsPane() + .tabItem { Label("Permissions", systemImage: "hand.raised") } + .tag(SettingsTab.permissions) UpdatesSettingsPane() .tabItem { Label("Updates", systemImage: "arrow.down.circle") } + .tag(SettingsTab.updates) ActivationLogPane() .tabItem { Label("Log", systemImage: "list.bullet.rectangle") } + .tag(SettingsTab.log) } } } diff --git a/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift b/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift new file mode 100644 index 0000000..2efb44d --- /dev/null +++ b/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift @@ -0,0 +1,193 @@ +import AppKit +import ApplicationServices +import Foundation + +/// Tracks launcher overlays (Spotlight, Raycast, …) that take keyboard focus +/// without changing `NSWorkspace.frontmostApplication`, via the Accessibility +/// API. The technique mirrors InputSourcePro: register `AXObserver`s on the +/// known launcher *processes* (which run persistently — Spotlight always does) +/// for window/focus lifecycle notifications, then read the system-wide focused +/// UI element to learn which app actually owns the keyboard. +/// +/// Empirically (macOS 26): opening a launcher fires `windowCreated` / +/// `focusedUIElementChanged`, at which point the system-wide focused element +/// resolves to the launcher; dismissing it fires `uiElementDestroyed`, after +/// which focus resolves back to the underlying app. So the whole thing is +/// event-driven — no polling. +/// +/// **Accessibility-gated.** Without the grant `AXObserverAddNotification` fails +/// and we observe nothing, leaving the permission-free core unchanged; the +/// engine calls `refresh()` once the grant is detected to attach for real. +@MainActor +public final class FloatingAppMonitor: FloatingAppMonitoring { + private var onChange: (@MainActor (String?) -> Void)? + private var observers: [pid_t: AXObserver] = [:] + private var workspaceTokens: [any NSObjectProtocol] = [] + /// The launcher bundle ID last reported, for change de-duplication. + private var current: String? + + private let systemWide: AXUIElement + + public init() { + 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 (String?) -> Void) { + guard self.onChange == nil else { return } + self.onChange = onChange + + let center = NSWorkspace.shared.notificationCenter + for name in [NSWorkspace.didLaunchApplicationNotification, NSWorkspace.didTerminateApplicationNotification] { + let token = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in + // Pull the Sendable bits out of the (non-Sendable) Notification + // before hopping onto the main actor. + let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + guard let bundleID = app?.bundleIdentifier, + LauncherOverlayCatalog.isLauncher(bundleID), + let pid = app?.processIdentifier + else { return } + let launched = note.name == NSWorkspace.didLaunchApplicationNotification + MainActor.assumeIsolated { + self?.handleRunningAppsChange(pid: pid, launched: launched) + } + } + workspaceTokens.append(token) + } + + attachAll() + } + + public func refresh() { + guard onChange != nil else { return } + attachAll() // pick up launchers we couldn't attach before the grant + evaluate() + } + + public func stop() { + for token in workspaceTokens { + NSWorkspace.shared.notificationCenter.removeObserver(token) + } + workspaceTokens.removeAll() + for pid in Array(observers.keys) { detach(pid) } + onChange = nil + current = nil + } + + deinit { + // Observers and workspace tokens are torn down in `stop()` (called from + // the engine's `stop()`); a nonisolated deinit can't touch them. + } + + // MARK: - Attachment + + private func attachAll() { + for app in NSWorkspace.shared.runningApplications + where LauncherOverlayCatalog.isLauncher(app.bundleIdentifier) { + attach(app.processIdentifier) + } + } + + private func handleRunningAppsChange(pid: pid_t, launched: Bool) { + if launched { + attach(pid) + } else { + detach(pid) + evaluate() // the terminated launcher can't hold focus any more + } + } + + /// AX notifications worth a re-read: a launcher overlay appearing, taking + /// focus, or being torn down. + private static let watchedNotifications: [CFString] = [ + kAXWindowCreatedNotification as CFString, + kAXFocusedUIElementChangedNotification as CFString, + kAXMainWindowChangedNotification as CFString, + kAXUIElementDestroyedNotification as CFString, + ] + + private func attach(_ pid: pid_t) { + guard observers[pid] == nil, AXIsProcessTrusted() else { return } + + var observer: AXObserver? + guard AXObserverCreate(pid, floatingAXCallback, &observer) == .success, + let observer + else { return } + + let appElement = AXUIElementCreateApplication(pid) + let context = Unmanaged.passUnretained(self).toOpaque() + var attached = false + for name in Self.watchedNotifications { + if AXObserverAddNotification(observer, appElement, name, context) == .success { + attached = true + } + } + // Not trusted yet (every add failed) — leave unattached so `refresh()` + // retries once Accessibility is granted. + guard attached else { return } + + CFRunLoopAddSource( + CFRunLoopGetCurrent(), + AXObserverGetRunLoopSource(observer), + .defaultMode + ) + observers[pid] = observer + } + + private func detach(_ pid: pid_t) { + guard let observer = observers.removeValue(forKey: pid) else { return } + CFRunLoopRemoveSource( + CFRunLoopGetCurrent(), + AXObserverGetRunLoopSource(observer), + .defaultMode + ) + } + + // MARK: - Evaluation + + /// Called from the AX callback on every watched notification. Reads the + /// real keyboard-focused app and reports the launcher overlay (or `nil`). + fileprivate func evaluate() { + let launcher = LauncherOverlayCatalog.launcher(forFocusedBundleID: focusedBundleID()) + guard launcher != current else { return } + current = launcher + onChange?(launcher) + } + + /// The bundle identifier of the app owning the system-wide focused UI + /// element — which, unlike `NSWorkspace.frontmostApplication`, follows a + /// launcher overlay when it takes keyboard focus. + private func focusedBundleID() -> String? { + var focused: CFTypeRef? + guard AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &focused) == .success, + let element = focused, CFGetTypeID(element) == AXUIElementGetTypeID() + else { return nil } + + var pid: pid_t = 0 + // Safe: the CFGetTypeID check above guarantees this is an AXUIElement. + guard AXUIElementGetPid(element as! AXUIElement, &pid) == .success else { return nil } + return NSRunningApplication(processIdentifier: pid)?.bundleIdentifier + } +} + +/// Free C callback (an `AXObserverCallback` cannot capture context). The monitor +/// is passed through `refcon`; it owns the observers and outlives them, 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 floatingAXCallback( + _ observer: AXObserver, + _ element: AXUIElement, + _ notification: CFString, + _ refcon: UnsafeMutableRawPointer? +) { + guard let refcon else { return } + // Reconstruct the instance *outside* the main-actor hop (matching + // `InputSourceChangeObserver`): 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/FloatingAppMonitoring.swift b/Sources/LockIMEKit/AppMonitor/FloatingAppMonitoring.swift new file mode 100644 index 0000000..351bfda --- /dev/null +++ b/Sources/LockIMEKit/AppMonitor/FloatingAppMonitoring.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Abstraction over launcher-overlay tracking, so the engine can be tested with +/// a mock instead of the real Accessibility-backed monitor. +@MainActor +public protocol FloatingAppMonitoring: AnyObject { + /// Begin observing launcher overlays. `onChange` is invoked with the bundle + /// identifier of the launcher overlay that just took keyboard focus, or + /// `nil` when focus returned to a normal app (resolve against the + /// `NSWorkspace` frontmost app again). + func start(onChange: @escaping @MainActor (String?) -> Void) + /// Re-attempt observer attachment. macOS doesn't notify us when Accessibility + /// is granted, so the engine calls this once the grant is detected. + func refresh() + func stop() +} diff --git a/Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift b/Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift new file mode 100644 index 0000000..9fa4da6 --- /dev/null +++ b/Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Launcher-overlay apps — Spotlight, Raycast, Alfred, LaunchBar — that draw a +/// transient search field *over* whatever app is frontmost **without becoming +/// the frontmost application themselves**. +/// +/// macOS never updates `NSWorkspace.frontmostApplication` for these overlays, so +/// `AppActivationMonitor` keeps reporting the app behind the overlay. Per-app +/// rules therefore resolve against the wrong app: locking is impossible to scope +/// to the launcher, and a CJKV lock on the underlying app leaks into the search +/// field (the reported bug — issue #9). `FloatingAppMonitor` recovers the real +/// keyboard-focused app via the Accessibility API and consults this catalog to +/// decide whether that app is a launcher overlay worth treating as the active +/// app for rule resolution. +/// +/// Scoped to a curated allow-list (mirrors InputSourcePro's "Spotlight-like +/// apps" set) rather than "any app whose focused element differs from the +/// frontmost", which would misfire for helper processes and our own panels. +public enum LauncherOverlayCatalog { + /// Bundle identifiers of known launcher overlays. Spotlight is the headline + /// case and is the only one that is *exclusively* an overlay; the others are + /// regular apps whose command bar happens to float over the frontmost app — + /// resolving their own bundle ID is correct in both modes, so listing them + /// is safe. + public static let bundleIDs: Set = [ + "com.apple.Spotlight", // Spotlight (Cmd-Space) + "com.raycast.macos", // Raycast + "com.runningwithcrayons.Alfred", // Alfred + "at.obdev.LaunchBar", // LaunchBar + ] + + /// Whether `bundleID` is a known launcher overlay. + public static func isLauncher(_ bundleID: String?) -> Bool { + guard let bundleID else { return false } + return bundleIDs.contains(bundleID) + } + + /// The launcher overlay currently holding keyboard focus, given the bundle + /// ID resolved from the system-wide focused UI element. Returns the id when + /// it names a known launcher, or `nil` to mean "focus is on a normal app — + /// fall back to `NSWorkspace.frontmostApplication`". + public static func launcher(forFocusedBundleID focusedBundleID: String?) -> String? { + isLauncher(focusedBundleID) ? focusedBundleID : nil + } +} diff --git a/Sources/LockIMEKit/LockEngine/LockEngine.swift b/Sources/LockIMEKit/LockEngine/LockEngine.swift index 51d9af2..f1fb586 100644 --- a/Sources/LockIMEKit/LockEngine/LockEngine.swift +++ b/Sources/LockIMEKit/LockEngine/LockEngine.swift @@ -11,6 +11,7 @@ public final class LockEngine { private let observer: InputSourceChangeObserver private let enabledSourcesObserver: InputSourceChangeObserver private let appMonitor: any FrontmostAppMonitoring + private let floatingAppMonitor: any FloatingAppMonitoring private let urlProvider: (any BrowserURLProviding)? private var urlPollTask: Task? @@ -26,12 +27,21 @@ public final class LockEngine { private var config: LockConfiguration = .default private var frontmostBundleID: String? + /// The launcher overlay (Spotlight, Raycast, …) currently holding keyboard + /// 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? + + /// The app rules should resolve against right now: the focused launcher + /// overlay when one is up, otherwise the `NSWorkspace` frontmost app. + private var effectiveBundleID: String? { launcherBundleID ?? frontmostBundleID } public var activationCount: Int { controller.activationCount } public init( provider: (any InputSourceProviding)? = nil, appMonitor: (any FrontmostAppMonitoring)? = nil, + floatingAppMonitor: (any FloatingAppMonitoring)? = nil, urlProvider: (any BrowserURLProviding)? = nil ) { let provider = provider ?? TISInputSourceProvider() @@ -40,6 +50,7 @@ public final class LockEngine { self.observer = InputSourceChangeObserver() self.enabledSourcesObserver = InputSourceChangeObserver(.enabledSourcesChanged) self.appMonitor = appMonitor ?? AppActivationMonitor() + self.floatingAppMonitor = floatingAppMonitor ?? FloatingAppMonitor() self.urlProvider = urlProvider self.controller.onActivation = { [weak self] event in self?.onActivation?(event) @@ -51,6 +62,7 @@ public final class LockEngine { observer.start { [weak self] in self?.handleSourceChange() } 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) } notifyCurrent() } @@ -58,10 +70,18 @@ public final class LockEngine { observer.stop() enabledSourcesObserver.stop() appMonitor.stop() + floatingAppMonitor.stop() urlPollTask?.cancel() urlPollTask = nil } + /// Re-attempt launcher-overlay observer attachment. macOS doesn't notify us + /// when Accessibility is granted, so the app calls this once it detects the + /// grant — only then can the overlay observers attach. + public func accessibilityDidChange() { + floatingAppMonitor.refresh() + } + /// Apply a configuration: update rules/default, set master enable, and /// re-resolve + enforce the appropriate target for the frontmost app. public func apply(_ config: LockConfiguration) { @@ -94,7 +114,21 @@ public final class LockEngine { private func handleFrontmostChange(_ bundleID: String?) { frontmostBundleID = bundleID - onFrontmostChange?(bundleID) + // A normal app activating means no launcher overlay is up (overlays + // never raise an activation), so clear any stale launcher attribution. + launcherBundleID = nil + onFrontmostChange?(effectiveBundleID) + reevaluate(reason: .appActivated) + updateURLPolling() + notifyCurrent() + } + + /// A launcher overlay (Spotlight, Raycast, …) took or released keyboard + /// focus. While it holds focus, rules resolve against *it* rather than the + /// unchanged frontmost app; `nil` reverts to the frontmost app. + private func handleLauncherChange(_ bundleID: String?) { + launcherBundleID = bundleID + onFrontmostChange?(effectiveBundleID) reevaluate(reason: .appActivated) updateURLPolling() notifyCurrent() @@ -102,7 +136,7 @@ public final class LockEngine { private func reevaluate(reason: ActivationReason) { let urlMatch = enhancedURLMatch() - switch RuleResolver.resolve(config: config, frontmostBundleID: frontmostBundleID, urlMatch: urlMatch) { + switch RuleResolver.resolve(config: config, frontmostBundleID: effectiveBundleID, urlMatch: urlMatch) { case .lock(let id): controller.setTarget(id, reason: urlMatch != nil ? .urlMatched : reason) case .ignore, .noTarget: @@ -113,17 +147,19 @@ public final class LockEngine { /// The locked source from a matching URL rule, when enhanced mode is on. private func enhancedURLMatch() -> InputSourceID? { guard config.enhancedModeEnabled, let urlProvider, !config.urlRules.isEmpty else { return nil } - let urlString = urlProvider.currentURL(forBundleID: frontmostBundleID) ?? "" + let urlString = urlProvider.currentURL(forBundleID: effectiveBundleID) ?? "" return URLMatcher.match(host: URLMatcher.host(from: urlString), rules: config.urlRules) } /// Poll the URL only while a browser is frontmost and enhanced mode is on, - /// so in-page navigation re-resolves the rule without a global event tap. + /// so in-page navigation re-resolves the rule without a global event tap. A + /// launcher overlay over a browser suspends the poll (its bundle isn't a + /// browser), and dismissing it resumes it. private func updateURLPolling() { urlPollTask?.cancel() urlPollTask = nil guard config.enhancedModeEnabled, urlProvider != nil, !config.urlRules.isEmpty, - BrowserBundleIDs.isBrowser(frontmostBundleID) + BrowserBundleIDs.isBrowser(effectiveBundleID) else { return } urlPollTask = Task { @MainActor [weak self] in while !Task.isCancelled { diff --git a/Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift b/Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift new file mode 100644 index 0000000..6314a3e --- /dev/null +++ b/Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift @@ -0,0 +1,28 @@ +import Testing + +@testable import LockIMEKit + +@Suite("LauncherOverlayCatalog") +struct LauncherOverlayCatalogTests { + @Test("recognises the curated launcher overlays") + func recognisesLaunchers() { + #expect(LauncherOverlayCatalog.isLauncher("com.apple.Spotlight")) + #expect(LauncherOverlayCatalog.isLauncher("com.raycast.macos")) + #expect(LauncherOverlayCatalog.isLauncher("com.runningwithcrayons.Alfred")) + #expect(LauncherOverlayCatalog.isLauncher("at.obdev.LaunchBar")) + } + + @Test("ordinary apps and nil are not launchers") + func rejectsNonLaunchers() { + #expect(!LauncherOverlayCatalog.isLauncher("com.apple.Safari")) + #expect(!LauncherOverlayCatalog.isLauncher("com.foo.App")) + #expect(!LauncherOverlayCatalog.isLauncher(nil)) + } + + @Test("launcher(forFocusedBundleID:) passes through launchers and nils out the rest") + func resolvesFocusedBundle() { + #expect(LauncherOverlayCatalog.launcher(forFocusedBundleID: "com.apple.Spotlight") == "com.apple.Spotlight") + #expect(LauncherOverlayCatalog.launcher(forFocusedBundleID: "com.apple.Safari") == nil) + #expect(LauncherOverlayCatalog.launcher(forFocusedBundleID: nil) == nil) + } +} diff --git a/Tests/LockIMEKitTests/LockEngineTests.swift b/Tests/LockIMEKitTests/LockEngineTests.swift index 9131dcd..7470056 100644 --- a/Tests/LockIMEKitTests/LockEngineTests.swift +++ b/Tests/LockIMEKitTests/LockEngineTests.swift @@ -144,6 +144,165 @@ struct LockEngineTests { } } +@MainActor +@Suite("LockEngine launcher overlays") +struct LockEngineLauncherTests { + 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 spotlight = "com.apple.Spotlight" + + private func makeEngine( + current: InputSourceID, + frontmost: String? + ) -> (LockEngine, MockInputSourceProvider, MockFloatingMonitor) { + let provider = MockInputSourceProvider( + current: current, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let floating = MockFloatingMonitor() + let engine = LockEngine( + provider: provider, + appMonitor: MockFrontmostMonitor(bundleID: frontmost), + floatingAppMonitor: floating + ) + engine.start() + return (engine, provider, floating) + } + + @Test("a launcher overlay retargets to its own rule, and dismissing it reverts") + func launcherRetargetsAndReverts() { + let (engine, provider, floating) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: spotlight, mode: .locked, lockedSourceID: abc)] + )) + #expect(provider.current == us) // foo app → default + + floating.setLauncher(spotlight) + #expect(provider.current == abc) // Spotlight's own rule applies + + floating.setLauncher(nil) + #expect(provider.current == us) // dismissed → back to foo's default + } + + // The reported bug (issue #9): with the overlay focused, `NSWorkspace` + // still reports the underlying app, so its CJKV lock used to leak into the + // search field. The overlay must resolve as *itself* — no Spotlight rule + // means the global default, not the underlying app's pinyin lock. + @Test("a launcher overlay does not inherit the underlying app's lock") + func launcherDoesNotInheritUnderlyingLock() { + let (engine, provider, floating) = makeEngine(current: pinyin, frontmost: "com.cjkv.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: "com.cjkv.App", mode: .locked, lockedSourceID: pinyin)] + )) + #expect(provider.current == pinyin) // the CJKV app is pinned to pinyin + + floating.setLauncher(spotlight) + #expect(provider.current == us) // Spotlight → global default, NOT pinyin + + floating.setLauncher(nil) + #expect(provider.current == pinyin) // dismissed → underlying lock returns + } + + @Test("a launcher rule wins even when the underlying app is ignored") + func launcherRuleBeatsIgnoredUnderlyingApp() { + let (engine, provider, floating) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [ + AppRule(bundleID: "com.foo.App", mode: .ignored), + AppRule(bundleID: spotlight, mode: .locked, lockedSourceID: abc), + ] + )) + #expect(provider.selectCalls.isEmpty) // foo is ignored → nothing forced + #expect(provider.current == us) + + floating.setLauncher(spotlight) + #expect(provider.current == abc) // Spotlight's lock applies over the overlay + } + + @Test("an ignored launcher overlay enforces nothing") + func ignoredLauncherDisengages() { + // Underlying app is also ignored, so nothing is forced up front; the + // contrast with `launcherRuleBeatsIgnoredUnderlyingApp` (same setup, a + // *locked* overlay forces abc) isolates the overlay's own ignore. + let (engine, provider, floating) = makeEngine(current: abc, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [ + AppRule(bundleID: "com.foo.App", mode: .ignored), + AppRule(bundleID: spotlight, mode: .ignored), + ] + )) + #expect(provider.selectCalls.isEmpty) // foo ignored → nothing forced + #expect(provider.current == abc) + + floating.setLauncher(spotlight) + #expect(provider.selectCalls.isEmpty) // ignored overlay → still nothing forced + #expect(provider.current == abc) + } + + @Test("a launcher overlay over a browser drops the URL-rule context") + func launcherDropsBrowserURLContext() { + let provider = MockInputSourceProvider( + current: us, + sources: [.stub(us.rawValue), .stub(abc.rawValue), .stub(pinyin.rawValue, cjkv: true)] + ) + let floating = MockFloatingMonitor() + let urls = BundleAwareURLProvider(url: "https://github.com/x", forBundleID: "com.apple.Safari") + let engine = LockEngine( + provider: provider, + appMonitor: MockFrontmostMonitor(bundleID: "com.apple.Safari"), + floatingAppMonitor: floating, + urlProvider: urls + ) + engine.start() + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + enhancedModeEnabled: true, + urlRules: [URLRule(hostPattern: "github.com", lockedSourceID: pinyin)] + )) + #expect(provider.current == pinyin) // Safari on github → URL rule + + floating.setLauncher(spotlight) + #expect(provider.current == us) // overlay isn't the browser → default, not the URL rule + + floating.setLauncher(nil) + #expect(provider.current == pinyin) // dismissed → URL rule applies again + } + + @Test("accessibilityDidChange asks the floating monitor to re-attach") + func accessibilityRefreshesMonitor() { + let (engine, _, floating) = makeEngine(current: us, frontmost: "com.foo.App") + #expect(floating.refreshCount == 0) + engine.accessibilityDidChange() + #expect(floating.refreshCount == 1) + } +} + +/// A URL provider that, like the real Accessibility reader, only yields a URL +/// for the matching browser bundle — so a launcher overlay (a different bundle) +/// reads no URL. +@MainActor +private final class BundleAwareURLProvider: BrowserURLProviding { + let url: String + let bundleID: String + init(url: String, forBundleID bundleID: String) { + self.url = url + self.bundleID = bundleID + } + func currentURL(forBundleID bundleID: String?) -> String? { + bundleID == self.bundleID ? url : nil + } +} + @MainActor @Suite("LockEngine accessors & lifecycle") struct LockEngineSurfaceTests { diff --git a/Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift b/Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift new file mode 100644 index 0000000..14642d5 --- /dev/null +++ b/Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift @@ -0,0 +1,23 @@ +import Foundation + +@testable import LockIMEKit + +@MainActor +final class MockFloatingMonitor: FloatingAppMonitoring { + private var handler: (@MainActor (String?) -> Void)? + private(set) var refreshCount = 0 + + func start(onChange: @escaping @MainActor (String?) -> Void) { + handler = onChange + } + + func refresh() { refreshCount += 1 } + + func stop() { handler = nil } + + /// Simulate a launcher overlay taking (`bundleID`) or releasing (`nil`) + /// keyboard focus. + func setLauncher(_ bundleID: String?) { + handler?(bundleID) + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 989255a..fc66a98 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -162,10 +162,14 @@ Keep `.keyboardShortcut` hints. Zero custom color — NSMenu supplies everything (Active-scope `.badge` is a nice-to-have — verify it renders on the real macOS 26 build before relying on it.) -### 4.2 Settings window — top 6-tab `TabView`, widened +### 4.2 Settings window — top 7-tab `TabView`, widened No sidebar (`.sidebarAdaptable` breaks `ToolbarSpacer` on macOS). Frame `minWidth 680, idealWidth 700, minHeight 460`, growable. `.scenePadding()` at -window level; panes own internal insets — verify no double-padding. +window level; panes own internal insets — verify no double-padding. Tab +selection is bound to `AppState.settingsTab` (so a feature pane can route the +user to **Permissions** for the single Accessibility grant); the root view's +`onDisappear` (window close, not a tab switch) is the abandon signal that stops +the grant watcher. - **General:** master toggle (`withAnimation(DS.Motion.toggle)` + `.contentTransition(.symbolEffect(.replace))` on the lock label), current source @@ -179,6 +183,13 @@ window level; panes own internal insets — verify no double-padding. frontmost app's rule). Recorder titles must be `LocalizedStringKey(...)`, not a bare `String` literal, or the label renders in the system language (see the i18n guards in CLAUDE.md). +- **Permissions:** the single home for the optional Accessibility grant + (`AXIsProcessTrusted`), which unlocks two features — per-URL rules and + launcher-overlay detection (Spotlight/Raycast/…). One `GrantAccessibilityButton` + (shared in `Components.swift`) lives **only** here; App Rules and URL Rules show + a passive `AccessibilityRequiredNote` that routes here, so the permission reads + as one capability with a single grant, never a prompt duplicated per feature. + The core lock stays permission-free. - **Updates:** `LabeledContent` "Last checked: …" ("Never" fallback), Check button, inline up-to-date/error result (see 4.6), badge the tab when an update is available. @@ -226,7 +237,7 @@ Reduce Transparency). Replace with: | Question | Decision | |---|---| | Toast | Delete; NSAlert + inline Updates result + "Last checked" | -| Settings nav | Keep 6-tab top TabView, widen to 680 | +| Settings nav | Keep top TabView (7 tabs incl. Permissions), widen to 680 | | Icon format | `.appiconset` PNG set, full-bleed source, pre-masked shipped PNGs | | Accent delivery | Asset-catalog `AccentColor` as Global Accent | | Update header art | Real app icon, not lock SF Symbol | From 0de8af2f73e453832b9d064363715cc0eecab58f Mon Sep 17 00:00:00 2001 From: Kevin Cui Date: Fri, 12 Jun 2026 05:02:47 -0400 Subject: [PATCH 2/2] fix(lock): clear launcher state when Accessibility is revoked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshAccessibilityStatus() reacted only to a newly-granted transition, so on revoke it flipped the flag but never told the engine. That left the dead launcher-overlay observers attached (preventing re-attach if access was granted again in the same session, since attach() guards on observers[pid] == nil) and could leave a stale launcher attribution driving rule resolution. React to both transitions: on revoke, drive the engine through the same accessibilityDidChange() path, and make FloatingAppMonitor.refresh() trust-aware — when access is gone it detaches the now-dead observers (a later refresh recreates fresh ones) and re-evaluates, which clears the stale overlay so rules fall back to the frontmost app. Signed-off-by: Kevin Cui --- Sources/LockIME/AppState.swift | 22 +++++++++++++------ .../AppMonitor/FloatingAppMonitor.swift | 9 +++++++- Tests/LockIMEKitTests/LockEngineTests.swift | 19 ++++++++++++++++ .../Support/MockFloatingMonitor.swift | 9 +++++++- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/Sources/LockIME/AppState.swift b/Sources/LockIME/AppState.swift index 89f29af..441f3ca 100644 --- a/Sources/LockIME/AppState.swift +++ b/Sources/LockIME/AppState.swift @@ -328,16 +328,24 @@ final class AppState { commit() } - /// Reconcile the cached flag with the live trust state. This is the recovery - /// path: the user may grant while the polling watcher is stopped (e.g. after - /// closing the window mid-flow) or entirely out-of-band in System Settings, - /// so a refresh that observes a *new* grant must run the same completion the - /// watcher would have. Also flips the flag back on revoke. + /// Reconcile the cached flag with the live trust state, reacting to either + /// transition. The user may grant while the polling watcher is stopped (e.g. + /// after closing the window mid-flow) or entirely out-of-band in System + /// Settings, so a refresh that observes a *new* grant must run the same + /// completion the watcher would have. A *revoke* is just as important: the + /// launcher-overlay observers are now dead, so the engine must detach them + /// and clear any stale overlay attribution (otherwise re-granting later + /// wouldn't re-attach, and rules could keep resolving against a launcher). func refreshAccessibilityStatus() { let trusted = AXIsProcessTrusted() - let newlyGranted = trusted && !accessibilityGranted + let wasGranted = accessibilityGranted accessibilityGranted = trusted - if newlyGranted { handleAccessibilityGranted() } + guard trusted != wasGranted else { return } + if trusted { + handleAccessibilityGranted() + } else { + engine?.accessibilityDidChange() + } } /// Open System Settings with the floating drag helper, then start watching diff --git a/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift b/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift index 2efb44d..091ff77 100644 --- a/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift +++ b/Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift @@ -62,7 +62,14 @@ public final class FloatingAppMonitor: FloatingAppMonitoring { public func refresh() { guard onChange != nil else { return } - attachAll() // pick up launchers we couldn't attach before the grant + if AXIsProcessTrusted() { + attachAll() // (re)attach to running launchers now that we can + } else { + // Trust was revoked: existing observers are dead and won't fire even + // if access is re-granted, so detach them (a later refresh recreates + // fresh ones). evaluate() then clears any stale launcher attribution. + for pid in Array(observers.keys) { detach(pid) } + } evaluate() } diff --git a/Tests/LockIMEKitTests/LockEngineTests.swift b/Tests/LockIMEKitTests/LockEngineTests.swift index 7470056..d05768c 100644 --- a/Tests/LockIMEKitTests/LockEngineTests.swift +++ b/Tests/LockIMEKitTests/LockEngineTests.swift @@ -285,6 +285,25 @@ struct LockEngineLauncherTests { engine.accessibilityDidChange() #expect(floating.refreshCount == 1) } + + @Test("revoking Accessibility clears a stale launcher attribution") + func revokeClearsLauncherAttribution() { + let (engine, provider, floating) = makeEngine(current: us, frontmost: "com.foo.App") + engine.apply(LockConfiguration( + isEnabled: true, + defaultSourceID: us, + appRules: [AppRule(bundleID: spotlight, mode: .locked, lockedSourceID: abc)] + )) + floating.setLauncher(spotlight) + #expect(provider.current == abc) // overlay attributed to Spotlight's rule + + // Revoking access refreshes the monitor; with trust gone it can no longer + // read focus, so it reports "no launcher" — the engine must revert to the + // frontmost app rather than stay pinned to the stale overlay. + floating.refreshClearsLauncher = true + engine.accessibilityDidChange() + #expect(provider.current == us) // reverted to foo's default + } } /// A URL provider that, like the real Accessibility reader, only yields a URL diff --git a/Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift b/Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift index 14642d5..372c3c6 100644 --- a/Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift +++ b/Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift @@ -6,12 +6,19 @@ import Foundation final class MockFloatingMonitor: FloatingAppMonitoring { private var handler: (@MainActor (String?) -> Void)? private(set) var refreshCount = 0 + /// When true, `refresh()` emits `nil` — modelling the real monitor clearing + /// its launcher attribution after Accessibility is revoked (the focused- + /// element read fails, so it reports "no launcher"). + var refreshClearsLauncher = false func start(onChange: @escaping @MainActor (String?) -> Void) { handler = onChange } - func refresh() { refreshCount += 1 } + func refresh() { + refreshCount += 1 + if refreshClearsLauncher { handler?(nil) } + } func stop() { handler = nil }