diff --git a/Sources/AppContext.swift b/Sources/AppContext.swift index fa47fc8..6f65fc8 100644 --- a/Sources/AppContext.swift +++ b/Sources/AppContext.swift @@ -1,5 +1,18 @@ import Foundation +// MARK: - Input Source IDs + +/// Runtime input source IDs for TIS API lookups (TISCreateInputSourceList etc). +/// macOS prefixes the bundle identifier to the Info.plist tsInputModeListKey +/// mode IDs, so "sh.send.inputmethod.Lexime.Japanese" in Info.plist becomes +/// "sh.send.inputmethod.Lexime.Lexime.Japanese" at runtime. These IDs are +/// derived from Bundle.main.bundleIdentifier to stay in sync automatically. +enum LeximeInputSourceID { + private static let bundleID = Bundle.main.bundleIdentifier ?? "sh.send.inputmethod.Lexime" + static let japanese = bundleID + ".Japanese" + static let roman = bundleID + ".Roman" +} + // MARK: - UserDefaults Keys enum DefaultsKey { @@ -19,6 +32,7 @@ class AppContext { let userDictPath: String let supportDir: String let candidatePanel = CandidatePanel() + let inputSourceMonitor = InputSourceMonitor() private init() { guard let resourcePath = Bundle.main.resourcePath else { @@ -149,6 +163,9 @@ class AppContext { } catch { NSLog("Lexime: snippets load error: %@", "\(error)") } + + // Start monitoring for unexpected ABC input source switches + inputSourceMonitor.startMonitoring() } /// Reload snippets from disk. Throws if the file exists but fails to load. diff --git a/Sources/InputSourceMonitor.swift b/Sources/InputSourceMonitor.swift new file mode 100644 index 0000000..cc11b38 --- /dev/null +++ b/Sources/InputSourceMonitor.swift @@ -0,0 +1,103 @@ +import Carbon +import Foundation + +/// Monitors input source changes and automatically reverts unexpected +/// switches to the standard ABC keyboard layout (which can happen due to +/// macOS IMKit race conditions) back to Lexime Roman, with secure input +/// awareness (polls for release before reverting). +final class InputSourceMonitor: NSObject { + + private static let abcSourceID = "com.apple.keylayout.ABC" + + /// Suppress notifications for this many seconds after init (avoid startup noise). + private static let startupQuietPeriod: TimeInterval = 5 + /// Delay before auto-reverting non-secure ABC switch. + private static let autoRevertDelay: TimeInterval = 0.3 + /// Polling interval for secure input release detection. + private static let secureInputPollInterval: TimeInterval = 0.5 + /// Maximum polling duration for secure input (give up after this). + private static let secureInputPollTimeout: TimeInterval = 60 + + private let startTime = Date() + private var secureInputTimer: Timer? + + func startMonitoring() { + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(inputSourceDidChange), + name: NSNotification.Name("com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged"), + object: nil + ) + NSLog("Lexime: InputSourceMonitor started") + } + + deinit { + secureInputTimer?.invalidate() + DistributedNotificationCenter.default().removeObserver(self) + } + + // MARK: - Input Source Change Handling + + @objc private func inputSourceDidChange() { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { return } + guard let idRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return } + let sourceID = Unmanaged.fromOpaque(idRef).takeUnretainedValue() as String + + guard sourceID == Self.abcSourceID else { return } + + // Startup quiet period + guard Date().timeIntervalSince(startTime) >= Self.startupQuietPeriod else { + NSLog("Lexime: ABC detected but within startup quiet period, suppressing") + return + } + + // If secure input is active (e.g. password field), poll for its + // release and auto-switch back to Lexime. + if IsSecureEventInputEnabled() { + NSLog("Lexime: ABC switch detected during secure input, polling for release") + startSecureInputPolling() + return + } + + // Non-secure ABC switch (e.g. IMKit race on Eisu/ESC key). + // Auto-revert after a short delay. + NSLog("Lexime: unexpected ABC switch detected, auto-reverting in %.1fs", Self.autoRevertDelay) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.autoRevertDelay) { [weak self] in + guard let self else { return } + self.selectLeximeRoman() + } + } + + // MARK: - Secure Input Polling + + private func startSecureInputPolling() { + secureInputTimer?.invalidate() + let deadline = Date().addingTimeInterval(Self.secureInputPollTimeout) + secureInputTimer = Timer.scheduledTimer( + withTimeInterval: Self.secureInputPollInterval, repeats: true + ) { [weak self] timer in + guard let self else { timer.invalidate(); return } + if !IsSecureEventInputEnabled() { + timer.invalidate() + self.secureInputTimer = nil + NSLog("Lexime: Secure input released, switching back to Lexime") + self.selectLeximeRoman() + } else if Date() >= deadline { + timer.invalidate() + self.secureInputTimer = nil + NSLog("Lexime: Secure input poll timed out") + } + } + } + + private func selectLeximeRoman() { + let conditions = [ + kTISPropertyInputSourceID as String: LeximeInputSourceID.roman + ] as CFDictionary + guard let list = TISCreateInputSourceList(conditions, false)?.takeRetainedValue() + as? [TISInputSource], + let source = list.first else { return } + TISSelectInputSource(source) + } + +} diff --git a/Sources/LeximeInputController.swift b/Sources/LeximeInputController.swift index d512159..902d139 100644 --- a/Sources/LeximeInputController.swift +++ b/Sources/LeximeInputController.swift @@ -19,6 +19,10 @@ class LeximeInputController: IMKInputController { let candidateManager = CandidateManager() + // IMKit mode IDs as declared in Info.plist's tsInputModeListKey. + // These match the values IMKit passes to setValue(_:forTag:client:). + // For TIS API lookups (TISCreateInputSourceList), use LeximeInputSourceID + // which includes the additional bundle ID prefix macOS adds at runtime. private static let japaneseModeID = "sh.send.inputmethod.Lexime.Japanese" private static let romanModeID = "sh.send.inputmethod.Lexime.Roman" @@ -28,6 +32,10 @@ class LeximeInputController: IMKInputController { private lazy var cachedTrigger: LexTriggerKey? = snippetTriggerKey() + /// Set when ESC is pressed during composing, so commitComposition + /// can guard against macOS switching to standard ABC. + private var escapeCausedCommit = false + override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) { super.init(server: server, delegate: delegate, client: inputClient) let version = engineVersion() @@ -129,6 +137,12 @@ class LeximeInputController: IMKInputController { } } + // Track ESC during composing so commitComposition can guard + // against macOS switching to standard ABC (race condition). + if case .escape = keyEvent, isComposing { + escapeCausedCommit = true + } + let resp = session.handleKey(event: keyEvent) applyEvents(resp, client: client) return resp.consumed @@ -244,8 +258,50 @@ class LeximeInputController: IMKInputController { override func commitComposition(_ sender: Any!) { guard let session, let client = sender as? IMKTextInput else { return } + let wasEscapeCommit = escapeCausedCommit + escapeCausedCommit = false let resp = session.commit() applyEvents(resp, client: client) + + // Guard against macOS switching to standard ABC after ESC. + // Due to a race condition in IMKit, ESC during composing can + // sometimes cause the input source to switch to com.apple.keylayout.ABC + // even though we returned consumed=true. + if wasEscapeCommit { + revertToLeximeWithRetry() + } + } + + /// Check up to 5 times (at 50ms intervals) whether macOS switched to + /// standard ABC after ESC, and revert to Lexime Japanese if so. + private func revertToLeximeWithRetry(attempt: Int = 0) { + let maxAttempts = 5 + guard attempt < maxAttempts else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + guard let self else { return } + if self.isCurrentInputSourceStandardABC() { + self.selectLeximeJapanese() + } else { + self.revertToLeximeWithRetry(attempt: attempt + 1) + } + } + } + + private func isCurrentInputSourceStandardABC() -> Bool { + guard let current = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { return false } + guard let idRef = TISGetInputSourceProperty(current, kTISPropertyInputSourceID) else { return false } + let id = Unmanaged.fromOpaque(idRef).takeUnretainedValue() as String + return id == "com.apple.keylayout.ABC" + } + + private func selectLeximeJapanese() { + let conditions = [ + kTISPropertyInputSourceID as String: LeximeInputSourceID.japanese + ] as CFDictionary + guard let list = TISCreateInputSourceList(conditions, false)?.takeRetainedValue() + as? [TISInputSource], + let source = list.first else { return } + TISSelectInputSource(source) } override func activateServer(_ sender: Any!) {