fix(input): harden focus-steal lifecycle and lock UX#2
Merged
Conversation
Post-review fixes verified against the codebase. Four independent issues: - focus-steal replay was a bare DispatchQueue.main.async with no cancel handle; if deactivateServer fired between enqueue and dispatch, the closure would resolve self.client() against the *next* active app and leak buffered Korean keys into the wrong window. Wrap it in a stored DispatchWorkItem, cancel from activate/deactivate, and bail if currentBundleId no longer matches the enqueue-time target. - setValue:forTag: silently rejected external mode changes while English Lock was active, leaving users wondering why the menu bar switch did nothing. Show LockOverlay on rejection so the lock state is visible. - isCurrentAppLocked() ran a UserDefaults.dictionary(forKey:) lookup per key event from the CGEventTap callback. Cache the lock state on the controller; refresh on activateApp and after each toggleLock. - main.swift force-unwrapped infoDictionary["InputMethodConnectionName"] and bundleIdentifier; a malformed bundle would SIGABRT silently. Replace with guard let + fatalError carrying the missing-key name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
forceKoreanForReplay was calling perAppStore.saveMode(.korean, ...) as a side effect of transient focus-steal correction. This meant a single focus-steal event in an app that the user normally uses in English would flip that app's stored default to Korean, surprising the user on the next activation. Per-app mode is now only updated by paths that reflect explicit user intent: mode toggle, external menu-bar change, ESC-to-English, and deactivate-time save. Focus-steal correction stays purely transient. The bundleId parameter on forceKoreanForReplay was only used for the removed save call, so it's gone too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Post-review hardening of
OngeulInputControllerandmain.swift. Four independent issues uncovered during a full Swift+Rust review, each verified against the actual code paths.DispatchQueue.main.asyncwith no cancel handle. IfdeactivateServerfired between enqueue and dispatch, the closure resolvedself.client()against the next active app and leaked buffered Korean keys into the wrong window. Now wrapped in a storedDispatchWorkItem, cancelled from activate/deactivate, with an enqueue-timecurrentBundleIdsnapshot used as a sanity check at dispatch time.setValue:forTag:rejected external mode changes while English Lock was active without any visible feedback.LockOverlayis now re-shown so users see why the menu-bar switch didn't take.isCurrentAppLocked()is called from theCGEventTapcallback on every key event. It walked throughcoordinator.isLocked → EnglishLockStore → UserDefaults.dictionary(forKey:)every time. Now cached on the controller, refreshed onactivateAppand after eachtoggleLock.main.swiftforce-unwraps —infoDictionary!["..."] as! StringandbundleIdentifier!wouldSIGABRTsilently on a malformed bundle. Replaced withguard let … else { fatalError(...) }so the missing key is visible in the crash log.Build + all 78 unit tests pass locally.
Test plan
LockOverlayflashes the locked iconisCurrentAppLockedunder autorepeat (verifiable via profiler if desired)InputMethodConnectionName) → crash report contains the missing-key name instead of an opaque SIGABRT🤖 Generated with Claude Code