Skip to content
17 changes: 17 additions & 0 deletions Sources/AppContext.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
103 changes: 103 additions & 0 deletions Sources/InputSourceMonitor.swift
Original file line number Diff line number Diff line change
@@ -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<CFString>.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)
}

}
56 changes: 56 additions & 0 deletions Sources/LeximeInputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<CFString>.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!) {
Expand Down
Loading