From 54efda0cef671bae9e31be52492492f14a14d983 Mon Sep 17 00:00:00 2001 From: "SAKAI, Kazuaki" Date: Fri, 13 Mar 2026 22:13:24 +0900 Subject: [PATCH 1/8] fix: guard against ESC causing input source switch to standard ABC Due to a race condition in IMKit, ESC during composing can sometimes cause macOS to switch the input source to com.apple.keylayout.ABC even though we return consumed=true. After ESC-triggered commitComposition, check the input source after 50ms and revert to Lexime Japanese if standard ABC was selected. Co-Authored-By: Claude Opus 4.6 --- Sources/LeximeInputController.swift | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Sources/LeximeInputController.swift b/Sources/LeximeInputController.swift index d512159..ef20834 100644 --- a/Sources/LeximeInputController.swift +++ b/Sources/LeximeInputController.swift @@ -28,6 +28,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 +133,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 +254,36 @@ 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 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.revertToLeximeIfNeeded() + } + } + } + + /// If the current input source is standard ABC, switch back to Lexime Japanese. + private func revertToLeximeIfNeeded() { + guard let current = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { return } + let idRef = TISGetInputSourceProperty(current, kTISPropertyInputSourceID) + guard let id = Unmanaged.fromOpaque(idRef!).takeUnretainedValue() as String?, + id == "com.apple.keylayout.ABC" else { return } + + let conditions = [ + kTISPropertyInputSourceID as String: Self.japaneseModeID + ] as CFDictionary + guard let list = TISCreateInputSourceList(conditions, false)?.takeRetainedValue() + as? [TISInputSource], + let source = list.first else { return } + TISSelectInputSource(source) } override func activateServer(_ sender: Any!) { From 70e616a4f69abce9ca121d828e76236024621e3d Mon Sep 17 00:00:00 2001 From: "SAKAI, Kazuaki" Date: Sun, 15 Mar 2026 12:56:41 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20retry=20ESC=E2=86=92ABC=20revert=20c?= =?UTF-8?q?heck=20up=20to=205=20times=20at=2050ms=20intervals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single 50ms delayed check was insufficient to catch the race condition where macOS switches to standard ABC after ESC during composing. Retry up to 5 times (250ms total) to reliably detect and revert the switch. Co-Authored-By: Claude Opus 4.6 --- Sources/LeximeInputController.swift | 30 +++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Sources/LeximeInputController.swift b/Sources/LeximeInputController.swift index ef20834..fcc364a 100644 --- a/Sources/LeximeInputController.swift +++ b/Sources/LeximeInputController.swift @@ -264,19 +264,33 @@ class LeximeInputController: IMKInputController { // sometimes cause the input source to switch to com.apple.keylayout.ABC // even though we returned consumed=true. if wasEscapeCommit { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.revertToLeximeIfNeeded() + 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) } } } - /// If the current input source is standard ABC, switch back to Lexime Japanese. - private func revertToLeximeIfNeeded() { - guard let current = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { return } - let idRef = TISGetInputSourceProperty(current, kTISPropertyInputSourceID) - guard let id = Unmanaged.fromOpaque(idRef!).takeUnretainedValue() as String?, - id == "com.apple.keylayout.ABC" else { return } + 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: Self.japaneseModeID ] as CFDictionary From 4905ab07b6ab6f15dfa4479ab591b71bedb0631a Mon Sep 17 00:00:00 2001 From: "SAKAI, Kazuaki" Date: Sun, 15 Mar 2026 20:44:07 +0900 Subject: [PATCH 3/8] feat: add InputSourceMonitor to detect unexpected ABC switch Monitors input source changes via DistributedNotificationCenter and sends a macnotifier notification when the system switches to standard ABC unexpectedly. Tapping the notification switches back to Lexime. Includes lexime-select-input helper binary for notification actions. Co-Authored-By: Claude Opus 4.6 --- Sources/AppContext.swift | 4 ++ Sources/InputSourceMonitor.swift | 110 ++++++++++++++++++++++++++++++ Sources/lexime-select-input.swift | 27 ++++++++ mise.toml | 15 +++- 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 Sources/InputSourceMonitor.swift create mode 100644 Sources/lexime-select-input.swift diff --git a/Sources/AppContext.swift b/Sources/AppContext.swift index fa47fc8..17db808 100644 --- a/Sources/AppContext.swift +++ b/Sources/AppContext.swift @@ -19,6 +19,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 +150,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..bd6ef8a --- /dev/null +++ b/Sources/InputSourceMonitor.swift @@ -0,0 +1,110 @@ +import Carbon +import Foundation + +/// Monitors input source changes and notifies the user when the system +/// switches to the standard ABC keyboard layout (which can happen +/// unexpectedly due to macOS behaviour), offering a one-tap action to +/// switch back to Lexime via macnotifier. +final class InputSourceMonitor: NSObject { + + private static let abcSourceID = "com.apple.keylayout.ABC" + private static let leximeRomanID = "sh.send.inputmethod.Lexime.Lexime.Roman" + + /// Suppress notifications for this many seconds after init (avoid startup noise). + private static let startupQuietPeriod: TimeInterval = 5 + /// Minimum interval between consecutive notifications. + private static let notificationCooldown: TimeInterval = 5 + + private let startTime = Date() + private var lastNotificationTime: Date? + + func startMonitoring() { + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(inputSourceDidChange), + name: NSNotification.Name("com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged"), + object: nil + ) + NSLog("Lexime: InputSourceMonitor started") + } + + deinit { + 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 + } + + // Cooldown + if let last = lastNotificationTime, + Date().timeIntervalSince(last) < Self.notificationCooldown { + NSLog("Lexime: ABC detected but within cooldown, suppressing") + return + } + + lastNotificationTime = Date() + sendNotification() + } + + private func sendNotification() { + guard let macnotifier = findMacnotifier() else { + NSLog("Lexime: macnotifier not found, skipping notification") + return + } + + let helperPath = selectInputHelperPath() + let executeCmd = "\"\(helperPath)\" \(Self.leximeRomanID)" + + let task = Process() + task.executableURL = URL(fileURLWithPath: macnotifier) + task.arguments = [ + "-t", "Lexime", + "-m", "標準 ABC に切り替わりました", + "-e", executeCmd, + ] + + do { + try task.run() + NSLog("Lexime: Sent ABC switch notification via macnotifier") + } catch { + NSLog("Lexime: Failed to launch macnotifier: %@", "\(error)") + } + } + + // MARK: - Helper Paths + + /// Path to lexime-select-input inside the app bundle. + private func selectInputHelperPath() -> String { + if let bundlePath = Bundle.main.executablePath { + let macosDir = (bundlePath as NSString).deletingLastPathComponent + return (macosDir as NSString).appendingPathComponent("lexime-select-input") + } + return "lexime-select-input" + } + + /// Find macnotifier in PATH (Homebrew). + private func findMacnotifier() -> String? { + let candidates = [ + "/opt/homebrew/bin/macnotifier", + "/usr/local/bin/macnotifier", + ] + for path in candidates { + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + } + return nil + } +} diff --git a/Sources/lexime-select-input.swift b/Sources/lexime-select-input.swift new file mode 100644 index 0000000..aa401e7 --- /dev/null +++ b/Sources/lexime-select-input.swift @@ -0,0 +1,27 @@ +/// Tiny helper to switch macOS input source by ID. +/// Usage: lexime-select-input +/// Example: lexime-select-input sh.send.inputmethod.Lexime.Japanese +import Carbon + +guard CommandLine.arguments.count == 2 else { + fputs("Usage: lexime-select-input \n", stderr) + exit(1) +} +let sourceID = CommandLine.arguments[1] + +let conditions = [kTISPropertyInputSourceID as String: sourceID] as CFDictionary +for includeAll in [false, true] { + guard let list = TISCreateInputSourceList(conditions, includeAll)?.takeRetainedValue() + as? [TISInputSource], + let source = list.first else { + continue + } + let status = TISSelectInputSource(source) + if status != noErr { + fputs("TISSelectInputSource failed with status \(status)\n", stderr) + exit(1) + } + exit(0) +} +fputs("Input source '\(sourceID)' not found\n", stderr) +exit(1) diff --git a/mise.toml b/mise.toml index 74c8ed4..ead95c3 100644 --- a/mise.toml +++ b/mise.toml @@ -120,12 +120,23 @@ SWIFTC_FLAGS="-O -Xcc -fmodule-map-file=generated/lex_engineFFI.modulemap" LINK_FLAGS="-Lbuild -llex_engine" mkdir -p "$MACOS" "$RES" +MAIN_SOURCES=$(ls Sources/*.swift | grep -v lexime-select-input) swiftc $SWIFTC_FLAGS $LINK_FLAGS -target x86_64-apple-macosx$MACOS_MIN \ - generated/lex_engine.swift Sources/*.swift -o "$MACOS/Lexime-x86_64" + generated/lex_engine.swift $MAIN_SOURCES -o "$MACOS/Lexime-x86_64" swiftc $SWIFTC_FLAGS $LINK_FLAGS -target arm64-apple-macosx$MACOS_MIN \ - generated/lex_engine.swift Sources/*.swift -o "$MACOS/Lexime-arm64" + generated/lex_engine.swift $MAIN_SOURCES -o "$MACOS/Lexime-arm64" lipo -create "$MACOS/Lexime-x86_64" "$MACOS/Lexime-arm64" -output "$MACOS/Lexime" rm "$MACOS/Lexime-x86_64" "$MACOS/Lexime-arm64" + +# Build lexime-select-input helper +swiftc -O -target x86_64-apple-macosx$MACOS_MIN \ + Sources/lexime-select-input.swift -o "$MACOS/lexime-select-input-x86_64" +swiftc -O -target arm64-apple-macosx$MACOS_MIN \ + Sources/lexime-select-input.swift -o "$MACOS/lexime-select-input-arm64" +lipo -create "$MACOS/lexime-select-input-x86_64" "$MACOS/lexime-select-input-arm64" \ + -output "$MACOS/lexime-select-input" +rm "$MACOS/lexime-select-input-x86_64" "$MACOS/lexime-select-input-arm64" +codesign -f -s - "$MACOS/lexime-select-input" cp Info.plist "$APP/Contents/Info.plist" cp Resources/icon.tiff "$RES/icon.tiff" cp Resources/icon-roman.tiff "$RES/icon-roman.tiff" From 21d8ecbd5f536cef0f3b5ed5f6e4bd1c1ddfff39 Mon Sep 17 00:00:00 2001 From: "SAKAI, Kazuaki" Date: Sun, 15 Mar 2026 21:10:22 +0900 Subject: [PATCH 4/8] feat: auto-revert to Lexime after secure input release When ABC switch is caused by secure input (e.g. password fields), poll IsSecureEventInputEnabled() at 500ms intervals and automatically switch back to the previous Lexime mode (Japanese or Roman) when secure input is released. Also tracks the last active Lexime source ID so both notification actions and auto-revert restore the correct mode. Co-Authored-By: Claude Opus 4.6 --- Sources/InputSourceMonitor.swift | 60 +++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/Sources/InputSourceMonitor.swift b/Sources/InputSourceMonitor.swift index bd6ef8a..a583963 100644 --- a/Sources/InputSourceMonitor.swift +++ b/Sources/InputSourceMonitor.swift @@ -8,15 +8,23 @@ import Foundation final class InputSourceMonitor: NSObject { private static let abcSourceID = "com.apple.keylayout.ABC" + private static let leximeJapaneseID = "sh.send.inputmethod.Lexime.Lexime.Japanese" private static let leximeRomanID = "sh.send.inputmethod.Lexime.Lexime.Roman" /// Suppress notifications for this many seconds after init (avoid startup noise). private static let startupQuietPeriod: TimeInterval = 5 /// Minimum interval between consecutive notifications. private static let notificationCooldown: TimeInterval = 5 + /// 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 lastNotificationTime: Date? + private var secureInputTimer: Timer? + /// The Lexime input source ID that was active before ABC switch. + private var previousLeximeSourceID: String? func startMonitoring() { DistributedNotificationCenter.default().addObserver( @@ -29,6 +37,7 @@ final class InputSourceMonitor: NSObject { } deinit { + secureInputTimer?.invalidate() DistributedNotificationCenter.default().removeObserver(self) } @@ -39,6 +48,12 @@ final class InputSourceMonitor: NSObject { guard let idRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return } let sourceID = Unmanaged.fromOpaque(idRef).takeUnretainedValue() as String + // Track the last Lexime source so we can restore it after ABC switch + if sourceID.hasPrefix("sh.send.inputmethod.Lexime.") { + previousLeximeSourceID = sourceID + return + } + guard sourceID == Self.abcSourceID else { return } // Startup quiet period @@ -55,6 +70,15 @@ final class InputSourceMonitor: NSObject { } lastNotificationTime = Date() + + // If secure input is active (e.g. password field), poll for its + // release and auto-switch back to Lexime instead of notifying. + if IsSecureEventInputEnabled() { + NSLog("Lexime: ABC switch detected during secure input, polling for release") + startSecureInputPolling() + return + } + sendNotification() } @@ -65,7 +89,8 @@ final class InputSourceMonitor: NSObject { } let helperPath = selectInputHelperPath() - let executeCmd = "\"\(helperPath)\" \(Self.leximeRomanID)" + let revertID = previousLeximeSourceID ?? Self.leximeJapaneseID + let executeCmd = "\"\(helperPath)\" \(revertID)" let task = Process() task.executableURL = URL(fileURLWithPath: macnotifier) @@ -83,6 +108,39 @@ final class InputSourceMonitor: NSObject { } } + // 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.selectPreviousLexime() + } else if Date() >= deadline { + timer.invalidate() + self.secureInputTimer = nil + NSLog("Lexime: Secure input poll timed out") + } + } + } + + private func selectPreviousLexime() { + let revertID = previousLeximeSourceID ?? Self.leximeJapaneseID + let conditions = [ + kTISPropertyInputSourceID as String: revertID + ] as CFDictionary + guard let list = TISCreateInputSourceList(conditions, false)?.takeRetainedValue() + as? [TISInputSource], + let source = list.first else { return } + TISSelectInputSource(source) + } + // MARK: - Helper Paths /// Path to lexime-select-input inside the app bundle. From 4b13aafe2b1f202c83728119854b85238d02e660 Mon Sep 17 00:00:00 2001 From: "SAKAI, Kazuaki" Date: Sat, 28 Mar 2026 19:07:47 +0900 Subject: [PATCH 5/8] refactor: simplify InputSourceMonitor to auto-revert without macnotifier Remove macnotifier notification and lexime-select-input helper in favor of automatic revert to Lexime Roman. All standard ABC switches (both secure input and IMKit race conditions) now auto-revert without user intervention. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/InputSourceMonitor.swift | 87 +++++-------------------------- Sources/lexime-select-input.swift | 27 ---------- mise.toml | 15 +----- 3 files changed, 14 insertions(+), 115 deletions(-) delete mode 100644 Sources/lexime-select-input.swift diff --git a/Sources/InputSourceMonitor.swift b/Sources/InputSourceMonitor.swift index a583963..5acf46c 100644 --- a/Sources/InputSourceMonitor.swift +++ b/Sources/InputSourceMonitor.swift @@ -13,18 +13,15 @@ final class InputSourceMonitor: NSObject { /// Suppress notifications for this many seconds after init (avoid startup noise). private static let startupQuietPeriod: TimeInterval = 5 - /// Minimum interval between consecutive notifications. - private static let notificationCooldown: 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 lastNotificationTime: Date? private var secureInputTimer: Timer? - /// The Lexime input source ID that was active before ABC switch. - private var previousLeximeSourceID: String? func startMonitoring() { DistributedNotificationCenter.default().addObserver( @@ -48,12 +45,6 @@ final class InputSourceMonitor: NSObject { guard let idRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return } let sourceID = Unmanaged.fromOpaque(idRef).takeUnretainedValue() as String - // Track the last Lexime source so we can restore it after ABC switch - if sourceID.hasPrefix("sh.send.inputmethod.Lexime.") { - previousLeximeSourceID = sourceID - return - } - guard sourceID == Self.abcSourceID else { return } // Startup quiet period @@ -62,49 +53,20 @@ final class InputSourceMonitor: NSObject { return } - // Cooldown - if let last = lastNotificationTime, - Date().timeIntervalSince(last) < Self.notificationCooldown { - NSLog("Lexime: ABC detected but within cooldown, suppressing") - return - } - - lastNotificationTime = Date() - // If secure input is active (e.g. password field), poll for its - // release and auto-switch back to Lexime instead of notifying. + // release and auto-switch back to Lexime. if IsSecureEventInputEnabled() { NSLog("Lexime: ABC switch detected during secure input, polling for release") startSecureInputPolling() return } - sendNotification() - } - - private func sendNotification() { - guard let macnotifier = findMacnotifier() else { - NSLog("Lexime: macnotifier not found, skipping notification") - return - } - - let helperPath = selectInputHelperPath() - let revertID = previousLeximeSourceID ?? Self.leximeJapaneseID - let executeCmd = "\"\(helperPath)\" \(revertID)" - - let task = Process() - task.executableURL = URL(fileURLWithPath: macnotifier) - task.arguments = [ - "-t", "Lexime", - "-m", "標準 ABC に切り替わりました", - "-e", executeCmd, - ] - - do { - try task.run() - NSLog("Lexime: Sent ABC switch notification via macnotifier") - } catch { - NSLog("Lexime: Failed to launch macnotifier: %@", "\(error)") + // 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() } } @@ -121,7 +83,7 @@ final class InputSourceMonitor: NSObject { timer.invalidate() self.secureInputTimer = nil NSLog("Lexime: Secure input released, switching back to Lexime") - self.selectPreviousLexime() + self.selectLeximeRoman() } else if Date() >= deadline { timer.invalidate() self.secureInputTimer = nil @@ -130,10 +92,9 @@ final class InputSourceMonitor: NSObject { } } - private func selectPreviousLexime() { - let revertID = previousLeximeSourceID ?? Self.leximeJapaneseID + private func selectLeximeRoman() { let conditions = [ - kTISPropertyInputSourceID as String: revertID + kTISPropertyInputSourceID as String: Self.leximeRomanID ] as CFDictionary guard let list = TISCreateInputSourceList(conditions, false)?.takeRetainedValue() as? [TISInputSource], @@ -141,28 +102,4 @@ final class InputSourceMonitor: NSObject { TISSelectInputSource(source) } - // MARK: - Helper Paths - - /// Path to lexime-select-input inside the app bundle. - private func selectInputHelperPath() -> String { - if let bundlePath = Bundle.main.executablePath { - let macosDir = (bundlePath as NSString).deletingLastPathComponent - return (macosDir as NSString).appendingPathComponent("lexime-select-input") - } - return "lexime-select-input" - } - - /// Find macnotifier in PATH (Homebrew). - private func findMacnotifier() -> String? { - let candidates = [ - "/opt/homebrew/bin/macnotifier", - "/usr/local/bin/macnotifier", - ] - for path in candidates { - if FileManager.default.isExecutableFile(atPath: path) { - return path - } - } - return nil - } } diff --git a/Sources/lexime-select-input.swift b/Sources/lexime-select-input.swift deleted file mode 100644 index aa401e7..0000000 --- a/Sources/lexime-select-input.swift +++ /dev/null @@ -1,27 +0,0 @@ -/// Tiny helper to switch macOS input source by ID. -/// Usage: lexime-select-input -/// Example: lexime-select-input sh.send.inputmethod.Lexime.Japanese -import Carbon - -guard CommandLine.arguments.count == 2 else { - fputs("Usage: lexime-select-input \n", stderr) - exit(1) -} -let sourceID = CommandLine.arguments[1] - -let conditions = [kTISPropertyInputSourceID as String: sourceID] as CFDictionary -for includeAll in [false, true] { - guard let list = TISCreateInputSourceList(conditions, includeAll)?.takeRetainedValue() - as? [TISInputSource], - let source = list.first else { - continue - } - let status = TISSelectInputSource(source) - if status != noErr { - fputs("TISSelectInputSource failed with status \(status)\n", stderr) - exit(1) - } - exit(0) -} -fputs("Input source '\(sourceID)' not found\n", stderr) -exit(1) diff --git a/mise.toml b/mise.toml index ead95c3..74c8ed4 100644 --- a/mise.toml +++ b/mise.toml @@ -120,23 +120,12 @@ SWIFTC_FLAGS="-O -Xcc -fmodule-map-file=generated/lex_engineFFI.modulemap" LINK_FLAGS="-Lbuild -llex_engine" mkdir -p "$MACOS" "$RES" -MAIN_SOURCES=$(ls Sources/*.swift | grep -v lexime-select-input) swiftc $SWIFTC_FLAGS $LINK_FLAGS -target x86_64-apple-macosx$MACOS_MIN \ - generated/lex_engine.swift $MAIN_SOURCES -o "$MACOS/Lexime-x86_64" + generated/lex_engine.swift Sources/*.swift -o "$MACOS/Lexime-x86_64" swiftc $SWIFTC_FLAGS $LINK_FLAGS -target arm64-apple-macosx$MACOS_MIN \ - generated/lex_engine.swift $MAIN_SOURCES -o "$MACOS/Lexime-arm64" + generated/lex_engine.swift Sources/*.swift -o "$MACOS/Lexime-arm64" lipo -create "$MACOS/Lexime-x86_64" "$MACOS/Lexime-arm64" -output "$MACOS/Lexime" rm "$MACOS/Lexime-x86_64" "$MACOS/Lexime-arm64" - -# Build lexime-select-input helper -swiftc -O -target x86_64-apple-macosx$MACOS_MIN \ - Sources/lexime-select-input.swift -o "$MACOS/lexime-select-input-x86_64" -swiftc -O -target arm64-apple-macosx$MACOS_MIN \ - Sources/lexime-select-input.swift -o "$MACOS/lexime-select-input-arm64" -lipo -create "$MACOS/lexime-select-input-x86_64" "$MACOS/lexime-select-input-arm64" \ - -output "$MACOS/lexime-select-input" -rm "$MACOS/lexime-select-input-x86_64" "$MACOS/lexime-select-input-arm64" -codesign -f -s - "$MACOS/lexime-select-input" cp Info.plist "$APP/Contents/Info.plist" cp Resources/icon.tiff "$RES/icon.tiff" cp Resources/icon-roman.tiff "$RES/icon-roman.tiff" From a4e2100ab2e396ebb84a29c9013a4a36f0149902 Mon Sep 17 00:00:00 2001 From: "SAKAI, Kazuaki" Date: Sun, 29 Mar 2026 10:03:02 +0900 Subject: [PATCH 6/8] fix: remove unused leximeJapaneseID and update doc comment Address Copilot review: - Remove unused leximeJapaneseID constant - Update class doc comment to reflect auto-revert behavior (no longer uses macnotifier notifications) - Add comment explaining runtime ID prefix difference from Info.plist Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/InputSourceMonitor.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/InputSourceMonitor.swift b/Sources/InputSourceMonitor.swift index 5acf46c..b89429d 100644 --- a/Sources/InputSourceMonitor.swift +++ b/Sources/InputSourceMonitor.swift @@ -1,14 +1,15 @@ import Carbon import Foundation -/// Monitors input source changes and notifies the user when the system -/// switches to the standard ABC keyboard layout (which can happen -/// unexpectedly due to macOS behaviour), offering a one-tap action to -/// switch back to Lexime via macnotifier. +/// 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" - private static let leximeJapaneseID = "sh.send.inputmethod.Lexime.Lexime.Japanese" + // Runtime IDs include the bundle ID prefix, so they are "Lexime.Lexime.*" + // rather than the bare "Lexime.*" declared in Info.plist's tsInputModeListKey. private static let leximeRomanID = "sh.send.inputmethod.Lexime.Lexime.Roman" /// Suppress notifications for this many seconds after init (avoid startup noise). From fc27f36716a7194a544e29125bc4b5e927c8e5fb Mon Sep 17 00:00:00 2001 From: "SAKAI, Kazuaki" Date: Sun, 29 Mar 2026 13:12:08 +0900 Subject: [PATCH 7/8] refactor: centralize input source IDs with runtime derivation Add LeximeInputSourceID enum that derives runtime input source IDs from Bundle.main.bundleIdentifier, eliminating hard-coded "Lexime.Lexime.*" strings. Used by both InputSourceMonitor and LeximeInputController for TISCreateInputSourceList lookups. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/AppContext.swift | 11 +++++++++++ Sources/InputSourceMonitor.swift | 5 +---- Sources/LeximeInputController.swift | 3 ++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Sources/AppContext.swift b/Sources/AppContext.swift index 17db808..0bdce2f 100644 --- a/Sources/AppContext.swift +++ b/Sources/AppContext.swift @@ -1,5 +1,16 @@ import Foundation +// MARK: - Input Source IDs + +/// Canonical input source IDs derived from the bundle identifier at runtime. +/// macOS prepends the bundle ID to the bare mode names in Info.plist's +/// tsInputModeListKey, so "Japanese" becomes ".Japanese". +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 { diff --git a/Sources/InputSourceMonitor.swift b/Sources/InputSourceMonitor.swift index b89429d..cc11b38 100644 --- a/Sources/InputSourceMonitor.swift +++ b/Sources/InputSourceMonitor.swift @@ -8,9 +8,6 @@ import Foundation final class InputSourceMonitor: NSObject { private static let abcSourceID = "com.apple.keylayout.ABC" - // Runtime IDs include the bundle ID prefix, so they are "Lexime.Lexime.*" - // rather than the bare "Lexime.*" declared in Info.plist's tsInputModeListKey. - private static let leximeRomanID = "sh.send.inputmethod.Lexime.Lexime.Roman" /// Suppress notifications for this many seconds after init (avoid startup noise). private static let startupQuietPeriod: TimeInterval = 5 @@ -95,7 +92,7 @@ final class InputSourceMonitor: NSObject { private func selectLeximeRoman() { let conditions = [ - kTISPropertyInputSourceID as String: Self.leximeRomanID + kTISPropertyInputSourceID as String: LeximeInputSourceID.roman ] as CFDictionary guard let list = TISCreateInputSourceList(conditions, false)?.takeRetainedValue() as? [TISInputSource], diff --git a/Sources/LeximeInputController.swift b/Sources/LeximeInputController.swift index fcc364a..e5482f1 100644 --- a/Sources/LeximeInputController.swift +++ b/Sources/LeximeInputController.swift @@ -19,6 +19,7 @@ class LeximeInputController: IMKInputController { let candidateManager = CandidateManager() + // setValue receives bare Info.plist mode IDs (without bundle ID prefix). private static let japaneseModeID = "sh.send.inputmethod.Lexime.Japanese" private static let romanModeID = "sh.send.inputmethod.Lexime.Roman" @@ -292,7 +293,7 @@ class LeximeInputController: IMKInputController { private func selectLeximeJapanese() { let conditions = [ - kTISPropertyInputSourceID as String: Self.japaneseModeID + kTISPropertyInputSourceID as String: LeximeInputSourceID.japanese ] as CFDictionary guard let list = TISCreateInputSourceList(conditions, false)?.takeRetainedValue() as? [TISInputSource], From 1bbb3db3923ec37bb2c8d0124eac2a5e73e66abd Mon Sep 17 00:00:00 2001 From: "SAKAI, Kazuaki" Date: Sun, 29 Mar 2026 17:49:52 +0900 Subject: [PATCH 8/8] docs: clarify IMKit mode ID vs TIS runtime ID distinction Update doc comments to accurately describe: - japaneseModeID/romanModeID: IMKit mode IDs from Info.plist, used by setValue(_:forTag:client:) - LeximeInputSourceID: runtime IDs with bundle ID prefix, used by TIS API (TISCreateInputSourceList) Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/AppContext.swift | 8 +++++--- Sources/LeximeInputController.swift | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/AppContext.swift b/Sources/AppContext.swift index 0bdce2f..6f65fc8 100644 --- a/Sources/AppContext.swift +++ b/Sources/AppContext.swift @@ -2,9 +2,11 @@ import Foundation // MARK: - Input Source IDs -/// Canonical input source IDs derived from the bundle identifier at runtime. -/// macOS prepends the bundle ID to the bare mode names in Info.plist's -/// tsInputModeListKey, so "Japanese" becomes ".Japanese". +/// 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" diff --git a/Sources/LeximeInputController.swift b/Sources/LeximeInputController.swift index e5482f1..902d139 100644 --- a/Sources/LeximeInputController.swift +++ b/Sources/LeximeInputController.swift @@ -19,7 +19,10 @@ class LeximeInputController: IMKInputController { let candidateManager = CandidateManager() - // setValue receives bare Info.plist mode IDs (without bundle ID prefix). + // 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"