feat(lock): detect Spotlight and launcher overlays for rules#11
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (2)
Summary by CodeRabbit
WalkthroughThis 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 DiagramsequenceDiagram
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
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches✨ Simplify code
Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (15)
Sources/LockIME/AppState.swiftSources/LockIME/Localizable.xcstringsSources/LockIME/UI/Components.swiftSources/LockIME/UI/Settings/AppRulesSettingsPane.swiftSources/LockIME/UI/Settings/PermissionsSettingsPane.swiftSources/LockIME/UI/Settings/URLRulesSettingsPane.swiftSources/LockIME/UI/SettingsRootView.swiftSources/LockIMEKit/AppMonitor/FloatingAppMonitor.swiftSources/LockIMEKit/AppMonitor/FloatingAppMonitoring.swiftSources/LockIMEKit/AppMonitor/LauncherOverlayCatalog.swiftSources/LockIMEKit/LockEngine/LockEngine.swiftTests/LockIMEKitTests/LauncherOverlayCatalogTests.swiftTests/LockIMEKitTests/LockEngineTests.swiftTests/LockIMEKitTests/Support/MockFloatingMonitor.swiftdocs/DESIGN.md
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>
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,frontmostApplicationnever changes.Fix
FloatingAppMonitorobserves the known launcher processes (AXObserveron 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 tocom.apple.Spotlightwhile open and reverts on dismissal. (CGWindowListdoes not expose the redesigned macOS 26 Spotlight window, so AX is the only reliable signal.)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.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
onDisappearto 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 testgreen, incl.LocalizationGuardTests(9 new catalog keys across all 9 languages) and a newLockEngine launcher overlayssuite +LauncherOverlayCatalogTests.docs/DESIGN.mdupdated for the 7th tab + the single-grant Permissions model.