Skip to content

feat(lock): detect Spotlight and launcher overlays for rules#11

Merged
BlackHole1 merged 2 commits into
mainfrom
fix/lock-launcher-overlays
Jun 12, 2026
Merged

feat(lock): detect Spotlight and launcher overlays for rules#11
BlackHole1 merged 2 commits into
mainfrom
fix/lock-launcher-overlays

Conversation

@BlackHole1

Copy link
Copy Markdown
Member

Closes #9.

Problem

Launcher overlays — Spotlight, Raycast, Alfred, LaunchBar — take keyboard focus without changing NSWorkspace.frontmostApplication, so LockIME resolved per-app rules against the app behind the overlay. Spotlight couldn't be locked to its own input source, and a CJKV lock on the underlying app leaked into the search field (the reported bug). Verified empirically on macOS 26.5.1: while Spotlight/Raycast is focused, frontmostApplication never changes.

Fix

  • An Accessibility-gated FloatingAppMonitor observes the known launcher processes (AXObserver on window/focus lifecycle) and reads the system-wide focused UI element (kAXFocusedUIElementAttribute) to attribute the real keyboard owner — event-driven, no polling. Verified on real Spotlight: focus resolves to com.apple.Spotlight while open and reverts on dismissal. (CGWindowList does not expose the redesigned macOS 26 Spotlight window, so AX is the only reliable signal.)
  • The engine resolves rules against effectiveBundleID = launcher ?? frontmost; a launcher with no rule falls through to the global default like any app, dropping the underlying app's lock. Reverts cleanly on close.
  • The core lock stays permission-free — without the Accessibility grant, behavior is unchanged. Spotlight is already pickable in App Rules (it's a persistent process), so no picker changes were needed.

Permissions UX

Accessibility now gates two optional features (per-URL Enhanced mode + launcher detection). Following Apple HIG / macOS ("one permission, one grant") and InputSourcePro's model, the 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.

Also fixed two latent grant-watcher lifecycle bugs surfaced by review: the abandon-stop moved from a per-pane onDisappear to the Settings window close (so switching tabs mid-grant no longer kills detection), and a status refresh now runs the full grant completion (so granting out-of-band, or while the watcher is stopped, still attaches the launcher monitor).

Tests & verification

  • make build + make test green, incl. LocalizationGuardTests (9 new catalog keys across all 9 languages) and a new LockEngine launcher overlays suite + LauncherOverlayCatalogTests.
  • End-to-end on the real machine: opening Spotlight forces the locked source and holds it (production-style re-enforcement converges), reverting on close.
  • Two adversarial multi-agent reviews; the one confirmed-high finding (AXUIElement "leak") was disproven (audited CF API → ARC-managed), the lifecycle findings were fixed and re-verified.

docs/DESIGN.md updated for the 7th tab + the single-grant Permissions model.

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 <bh@bugs.cc>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: beceb24c-0402-4900-b1d0-d34f4028eaaf

📥 Commits

Reviewing files that changed from the base of the PR and between 168d201 and 0de8af2.

📒 Files selected for processing (4)
  • Sources/LockIME/AppState.swift
  • Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift
  • Tests/LockIMEKitTests/LockEngineTests.swift
  • Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift
🚧 Files skipped from review as they are similar to previous changes (2)
  • Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift
  • Tests/LockIMEKitTests/LockEngineTests.swift

Summary by CodeRabbit

  • New Features

    • Added a dedicated Permissions tab in Settings to manage Accessibility access and show rationale.
    • New UI to request Accessibility and display “requires Accessibility” notes where applicable.
    • Better handling of launcher overlays (Spotlight/Raycast/Alfred/LaunchBar) so rules apply to the overlay when active.
  • Bug Fixes

    • Fixed rule/locking inheritance so launcher overlays no longer leak or retain incorrect behavior.
  • Documentation

    • Updated design docs to reflect the new Permissions tab and settings behavior.

Walkthrough

This PR implements launcher-overlay focus detection to enable independent input-source locking for Spotlight, Raycast, and other launcher overlays. It introduces FloatingAppMonitor (Accessibility-based AXObserver tracking) and LauncherOverlayCatalog, integrates them into LockEngine to use an effectiveBundleID (launcher when active, else frontmost) for rule resolution and URL polling, centralizes Accessibility grant handling in AppState (including a settingsTab binding), adds a Permissions settings pane and UI components, consolidates localized permission strings, and includes tests and a MockFloatingMonitor.

Sequence Diagram

sequenceDiagram
  participant User
  participant Spotlight as Spotlight (Launcher)
  participant FloatingMonitor as FloatingAppMonitor
  participant LockEngine as LockEngine
  participant RuleResolver as RuleResolver

  User->>Spotlight: Focus Spotlight overlay
  Spotlight->>FloatingMonitor: AX notification (focus changed)
  FloatingMonitor->>FloatingMonitor: evaluate() -> focused bundleID
  FloatingMonitor->>LockEngine: onChange("com.apple.spotlight")
  LockEngine->>LockEngine: launcherBundleID = "com.apple.spotlight"
  LockEngine->>LockEngine: effectiveBundleID = launcherBundleID
  LockEngine->>RuleResolver: resolve(effectiveBundleID)
  RuleResolver-->>LockEngine: AppRule for Spotlight
  LockEngine->>LockEngine: reevaluate() - apply Spotlight rule
  User->>User: Input source locked per Spotlight rule
  User->>Spotlight: Dismiss Spotlight
  FloatingMonitor->>LockEngine: onChange(nil)
  LockEngine->>LockEngine: launcherBundleID = nil
  LockEngine->>LockEngine: effectiveBundleID = frontmostBundleID
  LockEngine->>RuleResolver: resolve(effectiveBundleID)
  RuleResolver-->>LockEngine: AppRule for underlying app
  LockEngine->>LockEngine: reevaluate() - restore underlying rule
Loading
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title 'feat(lock): detect Spotlight and launcher overlays for rules' follows the required format with type and scope, and clearly summarizes the main change in the changeset.
Description check ✅ Passed The pull request description provides detailed context about the problem, solution, and implementation, and is clearly related to the changes in the changeset.
Linked Issues check ✅ Passed The PR successfully implements the objective from issue #9: detecting and attributing launcher overlays using Accessibility APIs so per-app rules apply independently to overlays, preventing input-source lock leakage.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing launcher-overlay detection and the Accessibility-gated permissions UX outlined in the linked issue.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/lock-launcher-overlays

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/LockIME/AppState.swift`:
- Around line 331-340: refreshAccessibilityStatus() updates accessibilityGranted
and calls handleAccessibilityGranted() on newlyGranted but does nothing on
revoke, leaving launcherBundleID/FloatingAppMonitor.current stale; add symmetric
revoke handling by detecting a revoke transition (trusted == false &&
accessibilityGranted was true), call a new handler (e.g.,
handleAccessibilityRevoked()) or expose an engine API to accept the trust
change, and inside that handler invoke engine?.accessibilityDidChange() (or
LockEngine.accessibilityDidChange()) in a way that causes
FloatingAppMonitor.refresh() to clear/reevaluate launcherBundleID and
FloatingAppMonitor.current; update refreshAccessibilityStatus() to call this
revoke handler and add a unit/test that simulates trust flipping from true→false
to assert the overlay/launcher attribution is cleared.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ad05b599-6d28-4acc-804a-d38cfab34d32

📥 Commits

Reviewing files that changed from the base of the PR and between 44b2954 and 168d201.

📒 Files selected for processing (15)
  • Sources/LockIME/AppState.swift
  • Sources/LockIME/Localizable.xcstrings
  • Sources/LockIME/UI/Components.swift
  • Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift
  • Sources/LockIME/UI/Settings/PermissionsSettingsPane.swift
  • Sources/LockIME/UI/Settings/URLRulesSettingsPane.swift
  • Sources/LockIME/UI/SettingsRootView.swift
  • Sources/LockIMEKit/AppMonitor/FloatingAppMonitor.swift
  • Sources/LockIMEKit/AppMonitor/FloatingAppMonitoring.swift
  • Sources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swift
  • Sources/LockIMEKit/LockEngine/LockEngine.swift
  • Tests/LockIMEKitTests/LauncherOverlayCatalogTests.swift
  • Tests/LockIMEKitTests/LockEngineTests.swift
  • Tests/LockIMEKitTests/Support/MockFloatingMonitor.swift
  • docs/DESIGN.md

Comment thread Sources/LockIME/AppState.swift Outdated
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 <bh@bugs.cc>
@BlackHole1 BlackHole1 merged commit c56379f into main Jun 12, 2026
3 checks passed
@BlackHole1 BlackHole1 deleted the fix/lock-launcher-overlays branch June 12, 2026 09:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

聚焦搜索不能锁定输入法

1 participant