Skip to content

Commit 168d201

Browse files
committed
feat(lock): detect Spotlight and launcher overlays for rules
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>
1 parent 44b2954 commit 168d201

15 files changed

Lines changed: 1241 additions & 51 deletions

Sources/LockIME/AppState.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ final class AppState {
7777
/// User language choice, loaded eagerly so scenes get the right locale.
7878
private(set) var languagePreference: LanguagePreference = .system
7979

80+
/// The selected Settings tab. Held here (not as view `@State`) so a feature
81+
/// pane can route the user to General's single Accessibility grant.
82+
var settingsTab: SettingsTab = .general
83+
8084
/// Master on/off, mirroring `config.isEnabled`.
8185
var isLocked: Bool { config.isEnabled }
8286

@@ -324,8 +328,16 @@ final class AppState {
324328
commit()
325329
}
326330

331+
/// Reconcile the cached flag with the live trust state. This is the recovery
332+
/// path: the user may grant while the polling watcher is stopped (e.g. after
333+
/// closing the window mid-flow) or entirely out-of-band in System Settings,
334+
/// so a refresh that observes a *new* grant must run the same completion the
335+
/// watcher would have. Also flips the flag back on revoke.
327336
func refreshAccessibilityStatus() {
328-
accessibilityGranted = AXIsProcessTrusted()
337+
let trusted = AXIsProcessTrusted()
338+
let newlyGranted = trusted && !accessibilityGranted
339+
accessibilityGranted = trusted
340+
if newlyGranted { handleAccessibilityGranted() }
329341
}
330342

331343
/// Open System Settings with the floating drag helper, then start watching
@@ -351,8 +363,19 @@ final class AppState {
351363
/// Run when the grant is detected: close the floating helper (the system
352364
/// never does) and flip the flag so the toggle becomes usable at once.
353365
private func completeAccessibilityGrant() {
354-
permissionFlow.closePanel(returnToPreviousApp: true)
355366
accessibilityGranted = true
367+
handleAccessibilityGranted()
368+
}
369+
370+
/// Run once when the grant is first observed — by the polling watcher *or* a
371+
/// status refresh. Closes the floating helper (the system never does), stops
372+
/// the now-finished watcher, and attaches the launcher-overlay monitor
373+
/// (Spotlight, Raycast, …) which needs the grant to register its observers.
374+
/// All three are idempotent, so observing the grant twice is harmless.
375+
private func handleAccessibilityGranted() {
376+
permissionFlow.closePanel(returnToPreviousApp: true)
377+
accessibilityWatcher.stop()
378+
engine?.accessibilityDidChange()
356379
}
357380

358381
#if DEBUG

0 commit comments

Comments
 (0)