From 68a665df701264a6ea6729e74c11c5e100723b09 Mon Sep 17 00:00:00 2001 From: nissy Date: Wed, 20 May 2026 22:47:08 +0900 Subject: [PATCH 01/12] bugfix window focus --- ModSwitchIME.xcodeproj/project.pbxproj | 8 + .../ImeController+StateMonitoring.swift | 235 +++++++++ ModSwitchIME/ImeController.swift | 313 +++--------- ModSwitchIME/InputSourcePickerViews.swift | 474 ++++++++++++++++++ ModSwitchIME/MenuBarApp.swift | 54 ++ ModSwitchIME/PreferencesView.swift | 473 ----------------- .../ImeControllerSkipLogicTests2.swift | 24 + .../MenuBarIconDebounceTests.swift | 54 +- 8 files changed, 911 insertions(+), 724 deletions(-) create mode 100644 ModSwitchIME/ImeController+StateMonitoring.swift create mode 100644 ModSwitchIME/InputSourcePickerViews.swift diff --git a/ModSwitchIME.xcodeproj/project.pbxproj b/ModSwitchIME.xcodeproj/project.pbxproj index 343c659..8b59ff5 100644 --- a/ModSwitchIME.xcodeproj/project.pbxproj +++ b/ModSwitchIME.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 001A7B322C2F3A1A00E5B4C8 /* ImeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */; }; 001A7B342C2F3A1A00E5B4C8 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */; }; 001A7B362C2F3A1A00E5B4C8 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */; }; + 001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */; }; + 001A7B462C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */; }; 001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */; }; 001A7B3A2C2F3A1A00E5B4C8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */; }; 001A7B3D2C2F3A1A00E5B4C8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */; }; @@ -39,6 +41,8 @@ 001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImeController.swift; sourceTree = ""; }; 001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourcePickerViews.swift; sourceTree = ""; }; + 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+StateMonitoring.swift"; sourceTree = ""; }; 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModSwitchIMEError.swift; sourceTree = ""; }; 001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -100,8 +104,10 @@ 001A7B2B2C2F3A1A00E5B4C8 /* App.swift */, 001A7B2F2C2F3A1A00E5B4C8 /* MenuBarApp.swift */, 001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */, + 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */, 001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */, 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */, + 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */, 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */, 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */, 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */, @@ -232,8 +238,10 @@ 001A7B2C2C2F3A1A00E5B4C8 /* App.swift in Sources */, 001A7B302C2F3A1A00E5B4C8 /* MenuBarApp.swift in Sources */, 001A7B322C2F3A1A00E5B4C8 /* ImeController.swift in Sources */, + 001A7B462C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift in Sources */, 001A7B342C2F3A1A00E5B4C8 /* Preferences.swift in Sources */, 001A7B362C2F3A1A00E5B4C8 /* PreferencesView.swift in Sources */, + 001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */, 001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */, 001A7B412C2F3A1A00E5B4C8 /* KeyMonitor.swift in Sources */, 001A7B432C2F3A1A00E5B4C8 /* Logger.swift in Sources */, diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift new file mode 100644 index 0000000..3d7406c --- /dev/null +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -0,0 +1,235 @@ +import Foundation +import Carbon +import Cocoa + +extension ImeController { + func initializeCache() { + if Thread.isMainThread { + buildCacheSync() + } else { + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { [weak self] in + self?.buildCacheSync() + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 2.0) + } + } + + func buildCacheSync() { + guard let cfInputSources = TISCreateInputSourceList(nil, false) else { + Logger.error("TISCreateInputSourceList returned nil", category: .ime) + return + } + + let inputSources = cfInputSources.takeRetainedValue() as? [TISInputSource] ?? [] + + if inputSources.isEmpty { + Logger.warning("No input sources found", category: .ime) + return + } + + var newCache: [String: TISInputSource] = [:] + + for inputSource in inputSources { + if let sourceID = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) { + let id = Unmanaged.fromOpaque(sourceID).takeUnretainedValue() as String + newCache[id] = inputSource + } + } + + cacheQueue.sync { + inputSourceCache = newCache + } + Logger.debug("IME cache initialized with \(newCache.count) input sources", category: .ime) + } + + func refreshInputSourceCache() { + DispatchQueue.main.async { [weak self] in + self?.buildCacheSync() + } + } + + func refreshCacheSync() { + if Thread.isMainThread { + buildCacheSync() + return + } + + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { [weak self] in + self?.buildCacheSync() + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 1.0) + } + + // MARK: - IME Change Monitoring + + func startMonitoringIMEChanges() { + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(inputSourcesChanged), + name: NSNotification.Name("com.apple.Carbon.TISNotifyEnabledKeyboardInputSourcesChanged"), + object: nil + ) + + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(inputSourcesChanged), + name: NSNotification.Name("com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged"), + object: nil + ) + + let notificationCenter = NSWorkspace.shared.notificationCenter + + notificationCenter.addObserver( + self, + selector: #selector(systemWillSleep), + name: NSWorkspace.willSleepNotification, + object: nil + ) + + notificationCenter.addObserver( + self, + selector: #selector(systemDidWake), + name: NSWorkspace.didWakeNotification, + object: nil + ) + } + + @objc func inputSourcesChanged(_ notification: Notification) { + Logger.debug("Input sources changed, refreshing cache", category: .ime) + syncLastNotifiedIMEWithCurrentInputSource() + refreshInputSourceCache() + } + + @objc func systemWillSleep(_ notification: Notification) { + Logger.info("System will sleep - preparing IME cache", category: .ime) + } + + @objc func systemDidWake(_ notification: Notification) { + Logger.info("System did wake - refreshing IME cache", category: .ime) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.refreshInputSourceCache() + Logger.debug("IME cache refreshed after system wake", category: .ime) + } + } + + // MARK: - Application Focus Monitoring + + func startMonitoringApplicationFocus() { + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(applicationDidActivate), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + } + + @objc func applicationDidActivate(_ notification: Notification) { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { + return + } + + let appName = app.localizedName ?? "Unknown" + Logger.debug("Application activated: \(appName)", category: .ime) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.verifyIMEStateAfterAppSwitch() + } + } + + func verifyIMEStateAfterAppSwitch() { + let actualIME = getCurrentInputSource() + let expectedState = lastSwitchedIMEQueue.sync { + (ime: lastSwitchedIME, time: lastSwitchedIMETime) + } + + if let expected = expectedState.ime, actualIME != expected { + Logger.warning( + "IME state mismatch after app switch: expected=\(expected), actual=\(actualIME)", + category: .ime + ) + + refreshInputSourceCache() + + let switchedRecently = (CFAbsoluteTimeGetCurrent() - expectedState.time) <= focusReapplyWindow + if switchedRecently { + Logger.debug("Reapplying recent IME after focus change: \(expected)", category: .ime) + switchToSpecificIME(expected, fromUser: false) + } else { + postUIRefreshNotification() + } + } else { + Logger.debug("IME state verified after app switch: \(actualIME)", category: .ime) + } + } + + func setLastSwitchedIME(_ imeId: String) { + lastSwitchedIMEQueue.sync { + lastSwitchedIME = imeId + lastSwitchedIMETime = CFAbsoluteTimeGetCurrent() + } + } + + func getLastSwitchedIME() -> String? { + return lastSwitchedIMEQueue.sync { lastSwitchedIME } + } +} + +extension ImeController { + func postIMESwitchNotification(_ imeId: String, isRetry: Bool = false) { + var shouldNotify = false + let currentIME = isRetry ? getCurrentInputSource() : "" + + notificationQueue.sync { + if lastNotifiedIME != imeId { + lastNotifiedIME = imeId + shouldNotify = true + } else if isRetry && currentIME == imeId { + shouldNotify = true + } + } + + guard shouldNotify else { + Logger.debug("Skipping duplicate notification for: \(imeId)", category: .ime) + return + } + + DispatchQueue.main.async { + NotificationCenter.default.post( + name: NSNotification.Name("ModSwitchIME.didSwitchIME"), + object: nil, + userInfo: ["imeId": imeId] + ) + } + } + + func postUIRefreshNotification() { + syncLastNotifiedIMEWithCurrentInputSource() + DispatchQueue.main.async { + NotificationCenter.default.post( + name: NSNotification.Name("ModSwitchIME.shouldRefreshUI"), + object: nil + ) + } + } + + func syncLastNotifiedIMEWithCurrentInputSource() { + let currentIME = getCurrentInputSource() + notificationQueue.sync { + lastNotifiedIME = currentIME + } + } + + #if DEBUG + func debugGetLastSwitchedIME() -> String? { getLastSwitchedIME() } + func debugGetLastNotifiedIME() -> String { notificationQueue.sync { lastNotifiedIME } } + func debugSetLastNotifiedIME(_ imeId: String) { + notificationQueue.sync { lastNotifiedIME = imeId } + } + func debugSyncLastNotifiedIMEWithCurrentInputSource() { syncLastNotifiedIMEWithCurrentInputSource() } + #endif +} diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 78f6b12..c9a920b 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -10,7 +10,6 @@ protocol IMEControlling { func forceAscii() } -// swiftlint:disable:next type_body_length final class ImeController: ErrorHandler, IMEControlling { // Singleton instance static let shared = ImeController() @@ -26,12 +25,13 @@ final class ImeController: ErrorHandler, IMEControlling { var onError: ((ModSwitchIMEError) -> Void)? // Thread-safe cache for input sources - private var inputSourceCache: [String: TISInputSource] = [:] - private let cacheQueue = DispatchQueue(label: "com.modswitchime.cache") + var inputSourceCache: [String: TISInputSource] = [:] + let cacheQueue = DispatchQueue(label: "com.modswitchime.cache") // Track last switched IME for app focus verification - private var lastSwitchedIME: String? - private let lastSwitchedIMEQueue = DispatchQueue(label: "com.modswitchime.lastIME") + var lastSwitchedIME: String? + var lastSwitchedIMETime: CFAbsoluteTime = 0 + let lastSwitchedIMEQueue = DispatchQueue(label: "com.modswitchime.lastIME") // Track last switch timings separately for user-triggered and internal operations private struct SwitchThrottleState { @@ -44,7 +44,12 @@ final class ImeController: ErrorHandler, IMEControlling { private let switchThrottleQueue = DispatchQueue(label: "com.modswitchime.switchthrottle", attributes: .concurrent) private let userThrottleInterval: CFAbsoluteTime = 0.1 private let internalThrottleInterval: CFAbsoluteTime = 0.05 + let focusReapplyWindow: CFAbsoluteTime = 3.0 + private let verificationRetryDelay: TimeInterval = 0.05 // Throttle state balances rapid duplicate prevention with user retry needs + + var lastNotifiedIME: String = "" + let notificationQueue = DispatchQueue(label: "com.modswitchime.notification") private init() { // Initialize cache on startup for immediate availability @@ -55,61 +60,6 @@ final class ImeController: ErrorHandler, IMEControlling { startMonitoringApplicationFocus() } - private func initializeCache() { - // Build cache synchronously on initialization for immediate availability - if Thread.isMainThread { - buildCacheSync() - } else { - // For test environments, wait for cache to be built - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.main.async { [weak self] in - self?.buildCacheSync() - semaphore.signal() - } - // Wait with timeout to prevent deadlock - _ = semaphore.wait(timeout: .now() + 2.0) - } - } - - private func buildCacheSync() { - guard let cfInputSources = TISCreateInputSourceList(nil, false) else { - Logger.error("TISCreateInputSourceList returned nil", category: .ime) - return - } - - let inputSources = cfInputSources.takeRetainedValue() as? [TISInputSource] ?? [] - - if inputSources.isEmpty { - Logger.warning("No input sources found", category: .ime) - return - } - - var newCache: [String: TISInputSource] = [:] - - for inputSource in inputSources { - if let sourceID = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) { - let id = Unmanaged.fromOpaque(sourceID).takeUnretainedValue() as String - - // Cache all input sources, not just enabled ones - // This allows switching to disabled IMEs if needed - newCache[id] = inputSource - } - } - - // Update cache atomically - cacheQueue.async { [weak self] in - self?.inputSourceCache = newCache - } - Logger.debug("IME cache initialized with \(newCache.count) input sources", category: .ime) - } - - private func refreshInputSourceCache() { - // Refresh cache in background - DispatchQueue.main.async { [weak self] in - self?.buildCacheSync() - } - } - func forceAscii(fromUser: Bool = true) { let englishSources = ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] @@ -300,7 +250,6 @@ final class ImeController: ErrorHandler, IMEControlling { let result = TISSelectInputSource(source) if result == noErr { performHybridIMESwitch(expectedIME: expectedIME, currentIME: currentIME) - setLastSwitchedIME(expectedIME) return } lastError = ModSwitchIMEError.inputMethodSwitchFailed( @@ -326,6 +275,7 @@ final class ImeController: ErrorHandler, IMEControlling { if actualIME == expectedIME { // Success - notify UI update Logger.debug("IME switch confirmed: \(currentIME) -> \(actualIME)", category: .ime) + self.setLastSwitchedIME(expectedIME) self.postIMESwitchNotification(expectedIME) // Schedule additional verification for edge cases @@ -341,8 +291,8 @@ final class ImeController: ErrorHandler, IMEControlling { "IME switched to unexpected: \(actualIME) (expected: \(expectedIME))", category: .ime ) - // Notify UI with actual state - self.postUIRefreshNotification() + // Reapply the requested IME before falling back to showing actual state. + self.retryIMESwitchWithLimit(targetIME: expectedIME, currentIME: actualIME, retryCount: 1) } } } @@ -355,8 +305,8 @@ final class ImeController: ErrorHandler, IMEControlling { if actualIME != expectedIME { Logger.warning("Additional verification: IME mismatch detected", category: .ime) - // Correct the UI state - self.postUIRefreshNotification() + // The system or focused app overrode the switch after confirmation. Try once more. + self.retryIMESwitchWithLimit(targetIME: expectedIME, currentIME: actualIME, retryCount: 1) } } } @@ -379,6 +329,7 @@ final class ImeController: ErrorHandler, IMEControlling { if newIME == expectedIME { Logger.debug("IME switch verified: \(currentIME) -> \(newIME)", category: .ime) + self.setLastSwitchedIME(expectedIME) // Check if notification already sent to prevent duplicates var needsNotification = false self.notificationQueue.sync { @@ -391,34 +342,76 @@ final class ImeController: ErrorHandler, IMEControlling { Logger.warning("IME switch may have failed: still at current IME", category: .ime) // Retry with incremented count if retryCount < 3 { - self.retryIMESwitchWithLimit(targetIME: expectedIME, retryCount: retryCount + 1, fromUser: false) + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: currentIME, + retryCount: retryCount + 1 + ) } else { self.postUIRefreshNotification() } } else { Logger.warning("IME switched to unexpected: \(newIME) (expected: \(expectedIME))", category: .ime) - // Refresh UI with actual state - self.postUIRefreshNotification() + if retryCount < 3 { + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: newIME, + retryCount: retryCount + 1 + ) + } else { + self.postUIRefreshNotification() + } } } } private func retryIMESwitch(targetIME: String) { - retryIMESwitchWithLimit(targetIME: targetIME, retryCount: 1, fromUser: false) + retryIMESwitchWithLimit(targetIME: targetIME, retryCount: 1) + } + + private func retryIMESwitchWithLimit(targetIME: String, retryCount: Int) { + retryIMESwitchWithLimit(targetIME: targetIME, currentIME: getCurrentInputSource(), retryCount: retryCount) } - private func retryIMESwitchWithLimit(targetIME: String, retryCount: Int, fromUser: Bool = false) { + private func retryIMESwitchWithLimit(targetIME: String, currentIME: String, retryCount: Int) { guard retryCount <= 3 else { Logger.warning("Max retry attempts reached for IME switch", category: .ime) postUIRefreshNotification() return } - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + verificationRetryDelay) { [weak self] in guard let self = self else { return } - // Use switchToSpecificIME with fromUser parameter for retry - self.switchToSpecificIME(targetIME, fromUser: fromUser) + guard let (source, refreshed) = self.getInputSourceFromCacheOrRefresh(targetIME) else { + Logger.warning("Retry IME source not found: \(targetIME)", category: .ime) + self.postUIRefreshNotification() + return + } + + let result = TISSelectInputSource(source) + if result == noErr { + let ctx = refreshed ? "fresh source" : "cached source" + Logger.debug("Retrying IME switch (\(ctx)) attempt \(retryCount): \(targetIME)", category: .ime) + self.verifyIMESwitchWithLimit( + expectedIME: targetIME, + currentIME: currentIME, + retryCount: retryCount + ) + } else if retryCount < 3 { + Logger.warning( + "Retry IME switch attempt \(retryCount) failed with code: \(result)", + category: .ime + ) + self.retryIMESwitchWithLimit( + targetIME: targetIME, + currentIME: currentIME, + retryCount: retryCount + 1 + ) + } else { + Logger.warning("Retry IME switch failed with code: \(result)", category: .ime) + self.postUIRefreshNotification() + } } } @@ -441,180 +434,6 @@ final class ImeController: ErrorHandler, IMEControlling { return nil } - private func refreshCacheSync() { - // Synchronous cache refresh for critical operations - let semaphore = DispatchSemaphore(value: 0) - - DispatchQueue.main.async { [weak self] in - self?.buildCacheSync() - semaphore.signal() - } - - // Wait for cache refresh to complete (with timeout) - _ = semaphore.wait(timeout: .now() + 1.0) - } - - // MARK: - IME Change Monitoring - - private func startMonitoringIMEChanges() { - // Monitor for input source changes (removed NSTextInputContext - not available) - - // Also monitor for system notifications about input method changes - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(inputSourcesChanged), - name: NSNotification.Name("com.apple.Carbon.TISNotifyEnabledKeyboardInputSourcesChanged"), - object: nil - ) - - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(inputSourcesChanged), - name: NSNotification.Name("com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged"), - object: nil - ) - - // Monitor system sleep/wake events - let notificationCenter = NSWorkspace.shared.notificationCenter - - notificationCenter.addObserver( - self, - selector: #selector(systemWillSleep), - name: NSWorkspace.willSleepNotification, - object: nil - ) - - notificationCenter.addObserver( - self, - selector: #selector(systemDidWake), - name: NSWorkspace.didWakeNotification, - object: nil - ) - } - - @objc private func inputSourcesChanged(_ notification: Notification) { - Logger.debug("Input sources changed, refreshing cache", category: .ime) - // Refresh cache when system IMEs change - refreshInputSourceCache() - } - - @objc private func systemWillSleep(_ notification: Notification) { - Logger.info("System will sleep - preparing IME cache", category: .ime) - // Cache might become stale during sleep, mark for refresh - } - - @objc private func systemDidWake(_ notification: Notification) { - Logger.info("System did wake - refreshing IME cache", category: .ime) - - // Delay cache refresh to ensure system is fully awake - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.refreshInputSourceCache() - Logger.debug("IME cache refreshed after system wake", category: .ime) - } - } - - // MARK: - Application Focus Monitoring - - private func startMonitoringApplicationFocus() { - NSWorkspace.shared.notificationCenter.addObserver( - self, - selector: #selector(applicationDidActivate), - name: NSWorkspace.didActivateApplicationNotification, - object: nil - ) - } - - @objc private func applicationDidActivate(_ notification: Notification) { - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { - return - } - - let appName = app.localizedName ?? "Unknown" - Logger.debug("Application activated: \(appName)", category: .ime) - - // Verify IME state after a short delay to ensure app is fully focused - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - self?.verifyIMEStateAfterAppSwitch() - } - } - - private func verifyIMEStateAfterAppSwitch() { - let actualIME = getCurrentInputSource() - - // Get expected IME from last switch - let expectedIME = lastSwitchedIMEQueue.sync { lastSwitchedIME } - - if let expected = expectedIME, actualIME != expected { - Logger.warning( - "IME state mismatch after app switch: expected=\(expected), actual=\(actualIME)", - category: .ime - ) - - // Optionally refresh cache to ensure accuracy - refreshInputSourceCache() - - // Request UI to refresh to reflect the actual current IME - // Do not force switch (some apps intentionally change IME) - postUIRefreshNotification() - } else { - Logger.debug("IME state verified after app switch: \(actualIME)", category: .ime) - } - } - - private func setLastSwitchedIME(_ imeId: String) { - lastSwitchedIMEQueue.sync { [weak self] in - self?.lastSwitchedIME = imeId - } - } - - private func getLastSwitchedIME() -> String? { - return lastSwitchedIMEQueue.sync { lastSwitchedIME } - } - - // MARK: - Thread-safe Notification Helpers - - // Track last notified IME to prevent duplicates - private var lastNotifiedIME: String = "" - private let notificationQueue = DispatchQueue(label: "com.modswitchime.notification") - - private func postIMESwitchNotification(_ imeId: String, isRetry: Bool = false) { - // Prevent duplicate notifications - var shouldNotify = false - let currentIME = isRetry ? getCurrentInputSource() : "" // Get current IME outside of sync block - - notificationQueue.sync { - if lastNotifiedIME != imeId { - lastNotifiedIME = imeId - shouldNotify = true - } else if isRetry && currentIME == imeId { - // Allow retry notification if actually switched - shouldNotify = true - } - } - - guard shouldNotify else { - Logger.debug("Skipping duplicate notification for: \(imeId)", category: .ime) - return - } - - DispatchQueue.main.async { - NotificationCenter.default.post( - name: NSNotification.Name("ModSwitchIME.didSwitchIME"), - object: nil, - userInfo: ["imeId": imeId] - ) - } - } - - private func postUIRefreshNotification() { - DispatchQueue.main.async { - NotificationCenter.default.post( - name: NSNotification.Name("ModSwitchIME.shouldRefreshUI"), - object: nil - ) - } - } - // Removed performSwitch and related methods - no longer needed after simplification func getCurrentInputSource() -> String { diff --git a/ModSwitchIME/InputSourcePickerViews.swift b/ModSwitchIME/InputSourcePickerViews.swift new file mode 100644 index 0000000..b5beb12 --- /dev/null +++ b/ModSwitchIME/InputSourcePickerViews.swift @@ -0,0 +1,474 @@ +import SwiftUI + +// MARK: - Modifier Key Input Source Picker + +struct ModifierKeyInputSourcePicker: View { + let modifierKey: ModifierKey + @Binding var selectedSourceId: String + @Binding var isPresented: Bool + @State private var searchText = "" + @State private var selectedLanguage: String? + @State private var showDisabledSources = false + + private var groupedInputSources: [String: [Preferences.InputSource]] { + // Use Preferences.getAllInputSources (back to working implementation) + let cachedSources = Preferences.getAllInputSources(includeDisabled: showDisabledSources) + + let filtered = searchText.isEmpty ? cachedSources : cachedSources.filter { + $0.localizedName.localizedCaseInsensitiveContains(searchText) || + $0.sourceId.localizedCaseInsensitiveContains(searchText) + } + + return Dictionary(grouping: filtered) { source in + Preferences.getInputSourceLanguage(source.sourceId) + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Select IME for \(modifierKey.displayName)") + .font(.headline) + Spacer() + Toggle("Show disabled sources", isOn: $showDisabledSources) + .toggleStyle(.checkbox) + .font(.caption) + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.escape) + } + .padding() + + // Clear selection button + if !selectedSourceId.isEmpty { + Button("Remove Assignment") { + selectedSourceId = "" + isPresented = false + } + .padding(.horizontal) + .padding(.bottom, 8) + } + + // Search field + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search", text: $searchText) + .textFieldStyle(.plain) + } + .padding(8) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + .padding(.horizontal) + .padding(.bottom, 8) + + Divider() + + // Language list or Input source list + if selectedLanguage == nil { + // Language selection screen + ScrollView { + VStack(spacing: 0) { + ForEach(groupedInputSources.keys.sorted(), id: \.self) { language in + LanguageRowView( + language: language, + count: groupedInputSources[language]?.count ?? 0 + ) { selectedLanguage = language } + + let sortedKeys = groupedInputSources.keys.sorted() + if language != sortedKeys.last { + Divider() + } + } + } + .padding(.vertical, 8) + } + } else { + // Input source selection screen + VStack(spacing: 0) { + // Back button + HStack { + Button { + selectedLanguage = nil + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Languages") + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + + Spacer() + + Text(selectedLanguage ?? "") + .font(.headline) + + Spacer() + + // Keep spacing + Text("").frame(width: 50) + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + ScrollView { + VStack(spacing: 0) { + if let sources = groupedInputSources[selectedLanguage ?? ""] { + ForEach(sources) { source in + InputSourceRowView( + source: source, + isSelected: source.sourceId == selectedSourceId + ) { + selectedSourceId = source.sourceId + isPresented = false + } + + if source.id != sources.last?.id { + Divider() + .padding(.leading, 52) + } + } + } + } + .padding(.vertical, 8) + } + } + } + } + .frame(width: 400, height: 500) + .background(Color(NSColor.windowBackgroundColor)) + } +} + +// MARK: - Input Source Row View + +struct InputSourceRowView: View { + let source: Preferences.InputSource + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: { + // Only allow selection if the source is enabled + if source.isEnabled { + action() + } + }, label: { + HStack(spacing: 12) { + // Flag Icon - display larger + Text(Preferences.getInputSourceIcon(source.sourceId) ?? "⌨️") + .font(.system(size: 20)) + .frame(width: 28, height: 28) + + // Name only (no source ID) + Text(getDisplayName()) + .font(.system(size: 13)) + .foregroundColor(source.isEnabled ? .primary : .secondary) + + Spacer() + + // Checkmark + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.blue) + .font(.system(size: 12, weight: .medium)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .contentShape(Rectangle()) + }) + .buttonStyle(.plain) + .background(isSelected ? Color.blue.opacity(0.1) : Color.clear) + .disabled(!source.isEnabled) // Disable interaction for disabled sources + .opacity(source.isEnabled ? 1.0 : 0.5) // Disabled sources are shown with reduced opacity + } + + private func getDisplayName() -> String { + if let googleName = getGoogleInputDisplayName() { + return googleName + } + + if let atokName = getATOKDisplayName() { + return atokName + } + + if let kotoeriName = getKotoeriDisplayName() { + return kotoeriName + } + + if let keyboardName = getKeyboardLayoutDisplayName() { + return keyboardName + } + + return source.localizedName + } + + private func getGoogleInputDisplayName() -> String? { + guard source.sourceId.contains("com.google.inputmethod.Japanese") else { return nil } + + if source.sourceId.contains("Hiragana") { + return "Hiragana (Google)" + } else if source.sourceId.contains("Katakana") { + return "Katakana (Google)" + } else if source.sourceId.contains("FullWidthRoman") { + return "Full-width Alphanumeric (Google)" + } else if source.sourceId.contains("HalfWidthKana") { + return "Half-width Katakana (Google)" + } else if source.sourceId.contains("Roman") { + return "Alphanumeric (Google)" + } + return nil + } + + private func getATOKDisplayName() -> String? { + guard source.sourceId.contains("ATOK") else { return nil } + + if source.sourceId.contains("Japanese.Katakana") { + return "Katakana (ATOK)" + } else if source.sourceId.contains("Japanese.FullWidthRoman") { + return "Full-width Alphanumeric (ATOK)" + } else if source.sourceId.contains("Japanese.HalfWidthEiji") { + return "Half-width Alphanumeric (ATOK)" + } else if source.sourceId.contains("Roman") { + return "Alphanumeric (ATOK)" + } else if source.sourceId.hasSuffix(".Japanese") { + return "Hiragana (ATOK)" + } + return nil + } + + private func getKotoeriDisplayName() -> String? { + guard source.sourceId.contains("com.apple.inputmethod.Kotoeri") else { return nil } + + if source.sourceId.contains("Hiragana") { + return "Hiragana" + } else if source.sourceId.contains("Katakana") { + return "Katakana" + } else if source.sourceId.contains("FullWidthRoman") { + return "Full-width Alphanumeric" + } else if source.sourceId.contains("HalfWidthKana") { + return "Half-width Katakana" + } else if source.sourceId.contains("Roman") { + return "Alphanumeric" + } + return nil + } + + private func getKeyboardLayoutDisplayName() -> String? { + if source.sourceId == "com.apple.keylayout.ABC" { + return "ABC" + } else if source.sourceId == "com.apple.keylayout.US" { + return "US" + } + return nil + } +} + +// MARK: - Idle IME Picker + +struct IdleIMEPicker: View { + @Binding var selectedSourceId: String + @Binding var isPresented: Bool + @State private var searchText = "" + @State private var selectedLanguage: String? + @State private var showDisabledSources = false + + private var groupedInputSources: [String: [Preferences.InputSource]] { + // Use Preferences.getAllInputSources (back to working implementation) + let cachedSources = Preferences.getAllInputSources(includeDisabled: showDisabledSources) + + let filtered = searchText.isEmpty ? cachedSources : cachedSources.filter { + $0.localizedName.localizedCaseInsensitiveContains(searchText) || + $0.sourceId.localizedCaseInsensitiveContains(searchText) + } + + return Dictionary(grouping: filtered) { source in + Preferences.getInputSourceLanguage(source.sourceId) + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Select IME for Idle Return") + .font(.headline) + Spacer() + Toggle("Show disabled sources", isOn: $showDisabledSources) + .toggleStyle(.checkbox) + .font(.caption) + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.escape) + } + .padding() + + // Clear selection button + Button("Reset to English") { + selectedSourceId = "" + isPresented = false + } + .padding(.horizontal) + .padding(.bottom, 8) + + // Search field + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search", text: $searchText) + .textFieldStyle(.plain) + } + .padding(8) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + .padding(.horizontal) + .padding(.bottom, 8) + + Divider() + + // Language list or Input source list + if selectedLanguage == nil { + // Language selection screen + ScrollView { + VStack(spacing: 0) { + ForEach(groupedInputSources.keys.sorted(), id: \.self) { language in + LanguageRowView( + language: language, + count: groupedInputSources[language]?.count ?? 0 + ) { selectedLanguage = language } + + let sortedKeys = groupedInputSources.keys.sorted() + if language != sortedKeys.last { + Divider() + } + } + } + .padding(.vertical, 8) + } + } else { + // Input source selection screen + VStack(spacing: 0) { + // Back button + HStack { + Button { + selectedLanguage = nil + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Languages") + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + + Spacer() + + Text(selectedLanguage ?? "") + .font(.headline) + + Spacer() + + // Keep spacing + Text("").frame(width: 50) + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + ScrollView { + VStack(spacing: 0) { + if let sources = groupedInputSources[selectedLanguage ?? ""] { + ForEach(sources) { source in + InputSourceRowView( + source: source, + isSelected: source.sourceId == selectedSourceId + ) { + selectedSourceId = source.sourceId + isPresented = false + } + + if source.id != sources.last?.id { + Divider() + .padding(.leading, 52) + } + } + } + } + .padding(.vertical, 8) + } + } + } + } + .frame(width: 400, height: 500) + .background(Color(NSColor.windowBackgroundColor)) + } +} + +// MARK: - Language Row View + +struct LanguageRowView: View { + let language: String + let count: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + // Language icon + Text(getLanguageIcon()) + .font(.system(size: 24)) + .frame(width: 32, height: 32) + + // Language name + Text(language) + .font(.system(size: 14)) + .foregroundColor(.primary) + + Spacer() + + // Number of input sources + Text("\(count)") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.gray.opacity(0.2)) + .cornerRadius(10) + + // Arrow + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Color.clear) + } + + private func getLanguageIcon() -> String { + switch language { + case "Japanese": return "🇯🇵" + case "Chinese": return "🇨🇳" + case "Korean": return "🇰🇷" + case "Vietnamese": return "🇻🇳" + case "Arabic": return "🇸🇦" + case "Hebrew": return "🇮🇱" + case "Thai": return "🇹🇭" + case "Indic Languages": return "🇮🇳" + case "Cyrillic Scripts": return "🇷🇺" + case "European Languages": return "🇪🇺" + default: return "🌐" + } + } +} diff --git a/ModSwitchIME/MenuBarApp.swift b/ModSwitchIME/MenuBarApp.swift index c1706c2..cd33aa2 100644 --- a/ModSwitchIME/MenuBarApp.swift +++ b/ModSwitchIME/MenuBarApp.swift @@ -31,6 +31,9 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { private var imeDisplayNameCache: [String: String] = [:] // Debounced icon refresh work item to avoid flicker and race with TIS private var iconRefreshWorkItem: DispatchWorkItem? + // Periodic reconciliation catches missed system notifications during window/Space changes + private var imeReconciliationTimer: Timer? + private var lastDisplayedIME: String? // Shared ImeController instance to avoid duplication // Note: This ImeController will also monitor IME changes for cache updates, @@ -444,6 +447,8 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { // Stop KeyMonitor keyMonitor?.stop() keyMonitor = nil + imeReconciliationTimer?.invalidate() + imeReconciliationTimer = nil } private func showErrorAlert(error: Error) { @@ -591,9 +596,35 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { name: NSNotification.Name("ModSwitchIME.shouldRefreshUI"), object: nil ) + + setupFocusContextMonitoring() // Initial icon update refreshIconDebounced() + startIMEStateReconciliationTimer() + } + + private func setupFocusContextMonitoring() { + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(handleFocusContextChanged), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(handleFocusContextChanged), + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFocusContextChanged), + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) } @objc private func imeStateChanged(_ notification: Notification) { @@ -621,6 +652,21 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { // Always update based on actual current IME state refreshIconDebounced() } + + @objc private func handleFocusContextChanged(_ notification: Notification) { + Logger.debug("Focus context changed: \(notification.name.rawValue)", category: .main) + refreshIconDebounced(delay: 0.12) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.refreshIconDebounced(delay: 0) + } + } + + private func startIMEStateReconciliationTimer() { + imeReconciliationTimer?.invalidate() + imeReconciliationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.refreshIconIfIMEChanged() + } + } @objc private func systemWillSleep(_ notification: Notification) { // System is going to sleep @@ -764,6 +810,7 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { keyMonitor?.stop() // Cancel any pending icon refresh iconRefreshWorkItem?.cancel() + imeReconciliationTimer?.invalidate() // Remove all notification observers to prevent memory leaks windowCloseObservers.forEach { NotificationCenter.default.removeObserver($0) } windowCloseObservers.removeAll() @@ -787,6 +834,12 @@ extension MenuBarApp { DispatchQueue.main.asyncAfter(deadline: .now() + d, execute: item) } + private func refreshIconIfIMEChanged() { + let currentIME = getCurrentIME() + guard currentIME != lastDisplayedIME else { return } + refreshIconDebounced() + } + private func updateIconWithCurrentIME() { // Ensure UI updates happen on main thread DispatchQueue.main.async { [weak self] in @@ -806,6 +859,7 @@ extension MenuBarApp { private func updateIconForIME(_ imeId: String) { guard let button = statusBarItem?.button else { return } + lastDisplayedIME = imeId let displayName = getIMEDisplayName(imeId) let tooltip = "\(displayName) (\(imeId))" // Always use SF Symbol globe for the menu bar icon. diff --git a/ModSwitchIME/PreferencesView.swift b/ModSwitchIME/PreferencesView.swift index 2236130..3c5fb1a 100644 --- a/ModSwitchIME/PreferencesView.swift +++ b/ModSwitchIME/PreferencesView.swift @@ -272,480 +272,7 @@ struct ModifierKeyRow: View { } } -// MARK: - Modifier Key Input Source Picker - -struct ModifierKeyInputSourcePicker: View { - let modifierKey: ModifierKey - @Binding var selectedSourceId: String - @Binding var isPresented: Bool - @State private var searchText = "" - @State private var selectedLanguage: String? - @State private var showDisabledSources = false - - private var groupedInputSources: [String: [Preferences.InputSource]] { - // Use Preferences.getAllInputSources (back to working implementation) - let cachedSources = Preferences.getAllInputSources(includeDisabled: showDisabledSources) - - let filtered = searchText.isEmpty ? cachedSources : cachedSources.filter { - $0.localizedName.localizedCaseInsensitiveContains(searchText) || - $0.sourceId.localizedCaseInsensitiveContains(searchText) - } - - return Dictionary(grouping: filtered) { source in - Preferences.getInputSourceLanguage(source.sourceId) - } - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text("Select IME for \(modifierKey.displayName)") - .font(.headline) - Spacer() - Toggle("Show disabled sources", isOn: $showDisabledSources) - .toggleStyle(.checkbox) - .font(.caption) - Button("Cancel") { - isPresented = false - } - .keyboardShortcut(.escape) - } - .padding() - - // Clear selection button - if !selectedSourceId.isEmpty { - Button("Remove Assignment") { - selectedSourceId = "" - isPresented = false - } - .padding(.horizontal) - .padding(.bottom, 8) - } - - // Search field - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search", text: $searchText) - .textFieldStyle(.plain) - } - .padding(8) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(6) - .padding(.horizontal) - .padding(.bottom, 8) - - Divider() - - // Language list or Input source list - if selectedLanguage == nil { - // Language selection screen - ScrollView { - VStack(spacing: 0) { - ForEach(groupedInputSources.keys.sorted(), id: \.self) { language in - LanguageRowView( - language: language, - count: groupedInputSources[language]?.count ?? 0 - ) { selectedLanguage = language } - - let sortedKeys = groupedInputSources.keys.sorted() - if language != sortedKeys.last { - Divider() - } - } - } - .padding(.vertical, 8) - } - } else { - // Input source selection screen - VStack(spacing: 0) { - // Back button - HStack { - Button { - selectedLanguage = nil - } label: { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - Text("Languages") - } - .foregroundColor(.blue) - } - .buttonStyle(.plain) - - Spacer() - - Text(selectedLanguage ?? "") - .font(.headline) - - Spacer() - - // Keep spacing - Text("").frame(width: 50) - } - .padding(.horizontal) - .padding(.vertical, 8) - - Divider() - - ScrollView { - VStack(spacing: 0) { - if let sources = groupedInputSources[selectedLanguage ?? ""] { - ForEach(sources) { source in - InputSourceRowView( - source: source, - isSelected: source.sourceId == selectedSourceId - ) { - selectedSourceId = source.sourceId - isPresented = false - } - - if source.id != sources.last?.id { - Divider() - .padding(.leading, 52) - } - } - } - } - .padding(.vertical, 8) - } - } - } - } - .frame(width: 400, height: 500) - .background(Color(NSColor.windowBackgroundColor)) - } -} - // Make ModifierKey conform to Identifiable for sheet presentation extension ModifierKey: Identifiable { var id: String { rawValue } } - -// MARK: - Input Source Row View - -struct InputSourceRowView: View { - let source: Preferences.InputSource - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: { - // Only allow selection if the source is enabled - if source.isEnabled { - action() - } - }, label: { - HStack(spacing: 12) { - // Flag Icon - display larger - Text(Preferences.getInputSourceIcon(source.sourceId) ?? "⌨️") - .font(.system(size: 20)) - .frame(width: 28, height: 28) - - // Name only (no source ID) - Text(getDisplayName()) - .font(.system(size: 13)) - .foregroundColor(source.isEnabled ? .primary : .secondary) - - Spacer() - - // Checkmark - if isSelected { - Image(systemName: "checkmark") - .foregroundColor(.blue) - .font(.system(size: 12, weight: .medium)) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 6) - .contentShape(Rectangle()) - }) - .buttonStyle(.plain) - .background(isSelected ? Color.blue.opacity(0.1) : Color.clear) - .disabled(!source.isEnabled) // Disable interaction for disabled sources - .opacity(source.isEnabled ? 1.0 : 0.5) // Disabled sources are shown with reduced opacity - } - - private func getDisplayName() -> String { - if let googleName = getGoogleInputDisplayName() { - return googleName - } - - if let atokName = getATOKDisplayName() { - return atokName - } - - if let kotoeriName = getKotoeriDisplayName() { - return kotoeriName - } - - if let keyboardName = getKeyboardLayoutDisplayName() { - return keyboardName - } - - return source.localizedName - } - - private func getGoogleInputDisplayName() -> String? { - guard source.sourceId.contains("com.google.inputmethod.Japanese") else { return nil } - - if source.sourceId.contains("Hiragana") { - return "Hiragana (Google)" - } else if source.sourceId.contains("Katakana") { - return "Katakana (Google)" - } else if source.sourceId.contains("FullWidthRoman") { - return "Full-width Alphanumeric (Google)" - } else if source.sourceId.contains("HalfWidthKana") { - return "Half-width Katakana (Google)" - } else if source.sourceId.contains("Roman") { - return "Alphanumeric (Google)" - } - return nil - } - - private func getATOKDisplayName() -> String? { - guard source.sourceId.contains("ATOK") else { return nil } - - if source.sourceId.contains("Japanese.Katakana") { - return "Katakana (ATOK)" - } else if source.sourceId.contains("Japanese.FullWidthRoman") { - return "Full-width Alphanumeric (ATOK)" - } else if source.sourceId.contains("Japanese.HalfWidthEiji") { - return "Half-width Alphanumeric (ATOK)" - } else if source.sourceId.contains("Roman") { - return "Alphanumeric (ATOK)" - } else if source.sourceId.hasSuffix(".Japanese") { - return "Hiragana (ATOK)" - } - return nil - } - - private func getKotoeriDisplayName() -> String? { - guard source.sourceId.contains("com.apple.inputmethod.Kotoeri") else { return nil } - - if source.sourceId.contains("Hiragana") { - return "Hiragana" - } else if source.sourceId.contains("Katakana") { - return "Katakana" - } else if source.sourceId.contains("FullWidthRoman") { - return "Full-width Alphanumeric" - } else if source.sourceId.contains("HalfWidthKana") { - return "Half-width Katakana" - } else if source.sourceId.contains("Roman") { - return "Alphanumeric" - } - return nil - } - - private func getKeyboardLayoutDisplayName() -> String? { - if source.sourceId == "com.apple.keylayout.ABC" { - return "ABC" - } else if source.sourceId == "com.apple.keylayout.US" { - return "US" - } - return nil - } -} - -// MARK: - Idle IME Picker - -struct IdleIMEPicker: View { - @Binding var selectedSourceId: String - @Binding var isPresented: Bool - @State private var searchText = "" - @State private var selectedLanguage: String? - @State private var showDisabledSources = false - - private var groupedInputSources: [String: [Preferences.InputSource]] { - // Use Preferences.getAllInputSources (back to working implementation) - let cachedSources = Preferences.getAllInputSources(includeDisabled: showDisabledSources) - - let filtered = searchText.isEmpty ? cachedSources : cachedSources.filter { - $0.localizedName.localizedCaseInsensitiveContains(searchText) || - $0.sourceId.localizedCaseInsensitiveContains(searchText) - } - - return Dictionary(grouping: filtered) { source in - Preferences.getInputSourceLanguage(source.sourceId) - } - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text("Select IME for Idle Return") - .font(.headline) - Spacer() - Toggle("Show disabled sources", isOn: $showDisabledSources) - .toggleStyle(.checkbox) - .font(.caption) - Button("Cancel") { - isPresented = false - } - .keyboardShortcut(.escape) - } - .padding() - - // Clear selection button - Button("Reset to English") { - selectedSourceId = "" - isPresented = false - } - .padding(.horizontal) - .padding(.bottom, 8) - - // Search field - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search", text: $searchText) - .textFieldStyle(.plain) - } - .padding(8) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(6) - .padding(.horizontal) - .padding(.bottom, 8) - - Divider() - - // Language list or Input source list - if selectedLanguage == nil { - // Language selection screen - ScrollView { - VStack(spacing: 0) { - ForEach(groupedInputSources.keys.sorted(), id: \.self) { language in - LanguageRowView( - language: language, - count: groupedInputSources[language]?.count ?? 0 - ) { selectedLanguage = language } - - let sortedKeys = groupedInputSources.keys.sorted() - if language != sortedKeys.last { - Divider() - } - } - } - .padding(.vertical, 8) - } - } else { - // Input source selection screen - VStack(spacing: 0) { - // Back button - HStack { - Button { - selectedLanguage = nil - } label: { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - Text("Languages") - } - .foregroundColor(.blue) - } - .buttonStyle(.plain) - - Spacer() - - Text(selectedLanguage ?? "") - .font(.headline) - - Spacer() - - // Keep spacing - Text("").frame(width: 50) - } - .padding(.horizontal) - .padding(.vertical, 8) - - Divider() - - ScrollView { - VStack(spacing: 0) { - if let sources = groupedInputSources[selectedLanguage ?? ""] { - ForEach(sources) { source in - InputSourceRowView( - source: source, - isSelected: source.sourceId == selectedSourceId - ) { - selectedSourceId = source.sourceId - isPresented = false - } - - if source.id != sources.last?.id { - Divider() - .padding(.leading, 52) - } - } - } - } - .padding(.vertical, 8) - } - } - } - } - .frame(width: 400, height: 500) - .background(Color(NSColor.windowBackgroundColor)) - } -} - -// MARK: - Language Row View - -struct LanguageRowView: View { - let language: String - let count: Int - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack { - // Language icon - Text(getLanguageIcon()) - .font(.system(size: 24)) - .frame(width: 32, height: 32) - - // Language name - Text(language) - .font(.system(size: 14)) - .foregroundColor(.primary) - - Spacer() - - // Number of input sources - Text("\(count)") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.gray.opacity(0.2)) - .cornerRadius(10) - - // Arrow - Image(systemName: "chevron.right") - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .background(Color.clear) - } - - private func getLanguageIcon() -> String { - switch language { - case "Japanese": return "🇯🇵" - case "Chinese": return "🇨🇳" - case "Korean": return "🇰🇷" - case "Vietnamese": return "🇻🇳" - case "Arabic": return "🇸🇦" - case "Hebrew": return "🇮🇱" - case "Thai": return "🇹🇭" - case "Indic Languages": return "🇮🇳" - case "Cyrillic Scripts": return "🇷🇺" - case "European Languages": return "🇪🇺" - default: return "🌐" - } - } -} diff --git a/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift b/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift index a4741cd..2ecb76a 100644 --- a/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift +++ b/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift @@ -139,4 +139,28 @@ final class ImeControllerSkipLogicTests2: XCTestCase { wait(for: [refreshExpectation], timeout: 1.0) } + + func testLastNotifiedIMESyncsToActualInputSource() { + let actualIME = controller.getCurrentInputSource() + controller.debugSetLastNotifiedIME("com.apple.keylayout.StaleTestIME") + + controller.debugSyncLastNotifiedIMEWithCurrentInputSource() + + XCTAssertEqual(controller.debugGetLastNotifiedIME(), actualIME) + } + + func testLastSwitchedIMEUpdatesAfterConfirmedSwitch() throws { + let currentIME = controller.getCurrentInputSource() + if currentIME == "Unknown" { + throw XCTSkip("Current input source is unavailable in this environment") + } + + controller.switchToSpecificIME(currentIME, fromUser: true) + + let exp = expectation(description: "wait for hybrid switch confirmation") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { exp.fulfill() } + wait(for: [exp], timeout: 1.0) + + XCTAssertEqual(controller.debugGetLastSwitchedIME(), currentIME) + } } diff --git a/ModSwitchIMETests/MenuBarIconDebounceTests.swift b/ModSwitchIMETests/MenuBarIconDebounceTests.swift index 8fcf761..2aec09d 100644 --- a/ModSwitchIMETests/MenuBarIconDebounceTests.swift +++ b/ModSwitchIMETests/MenuBarIconDebounceTests.swift @@ -1,18 +1,22 @@ import XCTest +import AppKit @testable import ModSwitchIME final class MenuBarIconDebounceTests: XCTestCase { - func testInternalNotificationDebouncedToSingleUpdate() { - // Given: a MenuBarApp instance + private func makeInitializedApp() -> MenuBarApp { let app = MenuBarApp() - // Allow async initialization + initial icon update to complete let initExp = expectation(description: "wait init") DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { initExp.fulfill() } wait(for: [initExp], timeout: 1.0) - // Reset debug counter and record baseline MenuBarApp.debugResetIconUpdateCount() + return app + } + + func testInternalNotificationDebouncedToSingleUpdate() { + // Given: a MenuBarApp instance + let app = makeInitializedApp() let before = MenuBarApp.debugIconUpdateCount // When: fire multiple internal IME switch notifications rapidly @@ -34,4 +38,46 @@ final class MenuBarIconDebounceTests: XCTestCase { XCTAssertLessThan(delta, 3, "Debounce should coalesce rapid notifications to a small number of updates") _ = app // keep reference alive for test duration } + + func testApplicationActivationRefreshesIconTwiceForSettledIMEState() { + let app = makeInitializedApp() + let before = MenuBarApp.debugIconUpdateCount + let userInfo: [AnyHashable: Any] = [NSWorkspace.applicationUserInfoKey: NSRunningApplication.current] + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.didActivateApplicationNotification, + object: nil, + userInfo: userInfo + ) + + let exp = expectation(description: "wait focus refresh") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { exp.fulfill() } + wait(for: [exp], timeout: 1.0) + + let delta = MenuBarApp.debugIconUpdateCount - before + XCTAssertGreaterThanOrEqual(delta, 2, "Focus changes should refresh once quickly and once after settling") + _ = app + } + + func testSpaceAndScreenChangesRefreshIcon() { + let app = makeInitializedApp() + let before = MenuBarApp.debugIconUpdateCount + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + NotificationCenter.default.post( + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + + let exp = expectation(description: "wait context refresh") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { exp.fulfill() } + wait(for: [exp], timeout: 1.0) + + let delta = MenuBarApp.debugIconUpdateCount - before + XCTAssertGreaterThanOrEqual(delta, 2, "Space and screen changes should refresh the IME icon") + _ = app + } } From c41a65084559168134c2688f44aeffe01d178428 Mon Sep 17 00:00:00 2001 From: nissy Date: Wed, 20 May 2026 22:55:54 +0900 Subject: [PATCH 02/12] bugfix window focus --- ModSwitchIME/ImeController+StateMonitoring.swift | 16 ++++------------ ModSwitchIME/ImeController.swift | 2 -- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift index 3d7406c..5cc0961 100644 --- a/ModSwitchIME/ImeController+StateMonitoring.swift +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -143,11 +143,9 @@ extension ImeController { func verifyIMEStateAfterAppSwitch() { let actualIME = getCurrentInputSource() - let expectedState = lastSwitchedIMEQueue.sync { - (ime: lastSwitchedIME, time: lastSwitchedIMETime) - } + let expectedIME = lastSwitchedIMEQueue.sync { lastSwitchedIME } - if let expected = expectedState.ime, actualIME != expected { + if let expected = expectedIME, actualIME != expected { Logger.warning( "IME state mismatch after app switch: expected=\(expected), actual=\(actualIME)", category: .ime @@ -155,13 +153,8 @@ extension ImeController { refreshInputSourceCache() - let switchedRecently = (CFAbsoluteTimeGetCurrent() - expectedState.time) <= focusReapplyWindow - if switchedRecently { - Logger.debug("Reapplying recent IME after focus change: \(expected)", category: .ime) - switchToSpecificIME(expected, fromUser: false) - } else { - postUIRefreshNotification() - } + Logger.debug("Reapplying IME after focus change: \(expected)", category: .ime) + switchToSpecificIME(expected, fromUser: false) } else { Logger.debug("IME state verified after app switch: \(actualIME)", category: .ime) } @@ -170,7 +163,6 @@ extension ImeController { func setLastSwitchedIME(_ imeId: String) { lastSwitchedIMEQueue.sync { lastSwitchedIME = imeId - lastSwitchedIMETime = CFAbsoluteTimeGetCurrent() } } diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index c9a920b..22851b2 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -30,7 +30,6 @@ final class ImeController: ErrorHandler, IMEControlling { // Track last switched IME for app focus verification var lastSwitchedIME: String? - var lastSwitchedIMETime: CFAbsoluteTime = 0 let lastSwitchedIMEQueue = DispatchQueue(label: "com.modswitchime.lastIME") // Track last switch timings separately for user-triggered and internal operations @@ -44,7 +43,6 @@ final class ImeController: ErrorHandler, IMEControlling { private let switchThrottleQueue = DispatchQueue(label: "com.modswitchime.switchthrottle", attributes: .concurrent) private let userThrottleInterval: CFAbsoluteTime = 0.1 private let internalThrottleInterval: CFAbsoluteTime = 0.05 - let focusReapplyWindow: CFAbsoluteTime = 3.0 private let verificationRetryDelay: TimeInterval = 0.05 // Throttle state balances rapid duplicate prevention with user retry needs From 816fdf09e39b9634a528ad472874b7a099a5d129 Mon Sep 17 00:00:00 2001 From: nissy Date: Wed, 20 May 2026 23:06:36 +0900 Subject: [PATCH 03/12] bugfix window focus --- .envrc.example | 2 +- ModSwitchIME/Config/Version.xcconfig | 2 +- VERSION | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.envrc.example b/.envrc.example index 28f2944..8e0756b 100644 --- a/.envrc.example +++ b/.envrc.example @@ -26,7 +26,7 @@ export XCODE_VERSION="16.0" export MACOS_DEPLOYMENT_TARGET="13.0" # Version (managed by Version.xcconfig, but can be overridden here) -export VERSION="1.1.6" +export VERSION="1.1.7" export BUILD_NUMBER="1" # Optional: Set specific Xcode version diff --git a/ModSwitchIME/Config/Version.xcconfig b/ModSwitchIME/Config/Version.xcconfig index 51dfb6a..e1c249c 100644 --- a/ModSwitchIME/Config/Version.xcconfig +++ b/ModSwitchIME/Config/Version.xcconfig @@ -2,7 +2,7 @@ // Centralized version configuration for ModSwitchIME // Marketing version shown to users (e.g., 1.0.0, 1.2.3) -MARKETING_VERSION = 1.1.6 +MARKETING_VERSION = 1.1.7 // Build number (incremented for each build) // Can be set to $(CURRENT_PROJECT_VERSION) for automatic increment diff --git a/VERSION b/VERSION index 0664a8f..2bf1ca5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.6 +1.1.7 From d9e636da786467f9851e0831bd7564ef75c4bb13 Mon Sep 17 00:00:00 2001 From: nissy Date: Thu, 21 May 2026 00:25:28 +0900 Subject: [PATCH 04/12] bugfix window focus --- ModSwitchIME/ImeController.swift | 231 +++++++++++++----- ModSwitchIME/KeyMonitor.swift | 121 +++++---- .../ImeControllerSkipLogicTests2.swift | 8 +- ModSwitchIMETests/KeyMonitorSimpleTest.swift | 21 ++ 4 files changed, 281 insertions(+), 100 deletions(-) diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 22851b2..913e881 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -44,6 +44,9 @@ final class ImeController: ErrorHandler, IMEControlling { private let userThrottleInterval: CFAbsoluteTime = 0.1 private let internalThrottleInterval: CFAbsoluteTime = 0.05 private let verificationRetryDelay: TimeInterval = 0.05 + private let asciiFallbackDelay: TimeInterval = 0.2 + private var asciiFallbackGeneration: UInt64 = 0 + private let asciiFallbackQueue = DispatchQueue(label: "com.modswitchime.asciifallback") // Throttle state balances rapid duplicate prevention with user retry needs var lastNotifiedIME: String = "" @@ -60,28 +63,35 @@ final class ImeController: ErrorHandler, IMEControlling { func forceAscii(fromUser: Bool = true) { let englishSources = ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] + var availableSources = availableInputSourceIDs(from: englishSources) - // Try to switch to the first available English source - // Don't check immediately as the switch is asynchronous - for sourceID in englishSources { - // Check if this source exists in our cache - var sourceExists = false - cacheQueue.sync { - sourceExists = inputSourceCache[sourceID] != nil - } - - if sourceExists { - // Use switchToSpecificIME - switchToSpecificIME(sourceID, fromUser: fromUser) - return // Don't check immediately, trust the switch will work - } + if availableSources.isEmpty { + refreshCacheSync() + availableSources = availableInputSourceIDs(from: englishSources) } - // If we get here, no English sources are available - let error = ModSwitchIMEError.inputSourceNotFound("English input source") - handleError(error) + guard let primarySource = availableSources.first else { + // If we get here, no English sources are available + let error = ModSwitchIMEError.inputSourceNotFound("English input source") + handleError(error) + return + } + + let fallbackToken = createAsciiFallbackToken() + + switchToSpecificIME( + primarySource, + fromUser: fromUser, + invalidatesPendingAsciiFallback: false + ) + scheduleAsciiFallbackIfNeeded( + primarySource: primarySource, + availableSources: availableSources, + fromUser: fromUser, + fallbackToken: fallbackToken + ) } - + // Wrapper for compatibility func forceAscii() { forceAscii(fromUser: true) @@ -89,56 +99,35 @@ final class ImeController: ErrorHandler, IMEControlling { // Removed toggleByCmd - no longer used after architecture changes - func switchToSpecificIME(_ imeId: String, fromUser: Bool = true) { + func switchToSpecificIME( + _ imeId: String, + fromUser: Bool = true, + invalidatesPendingAsciiFallback: Bool = true + ) { // Validate IME ID guard isValidIMEId(imeId) else { Logger.warning("Invalid IME ID provided: \(imeId)", category: .ime) handleError(ModSwitchIMEError.invalidConfiguration) return } + + if invalidatesPendingAsciiFallback { + invalidateAsciiFallbackToken() + } let currentIME = getCurrentInputSource() let now = CFAbsoluteTimeGetCurrent() - - if fromUser { - var shouldSkip = false - switchThrottleQueue.sync { - shouldSkip = switchThrottleState.lastUserTarget == imeId && - (now - switchThrottleState.lastUserRequestTime) < userThrottleInterval - } - - if shouldSkip { - Logger.debug("Skipping rapid duplicate user switch: \(imeId)", category: .ime) - postUIRefreshNotification() - return - } - } else { - var shouldSkip = false - if currentIME == imeId { - Logger.debug("Already on target IME (internal): \(imeId)", category: .ime) - return - } - - switchThrottleQueue.sync { - shouldSkip = switchThrottleState.lastInternalTarget == imeId && - (now - switchThrottleState.lastInternalRequestTime) < internalThrottleInterval - } - if shouldSkip { - Logger.debug("Skipping throttled internal switch: \(imeId)", category: .ime) - return - } + if shouldSkipSwitch( + targetIME: imeId, + fromUser: fromUser, + currentIME: currentIME, + now: now + ) { + return } - switchThrottleQueue.async(flags: .barrier) { - if fromUser { - self.switchThrottleState.lastUserTarget = imeId - self.switchThrottleState.lastUserRequestTime = now - } else { - self.switchThrottleState.lastInternalTarget = imeId - self.switchThrottleState.lastInternalRequestTime = now - } - } + recordSwitchThrottle(targetIME: imeId, fromUser: fromUser, now: now) // Execute IME switch do { @@ -475,3 +464,133 @@ final class ImeController: ErrorHandler, IMEControlling { DistributedNotificationCenter.default().removeObserver(self) } } + +private extension ImeController { + func availableInputSourceIDs(from candidateIDs: [String]) -> [String] { + var available: [String] = [] + cacheQueue.sync { + available = candidateIDs.filter { inputSourceCache[$0] != nil } + } + return available + } + + func scheduleAsciiFallbackIfNeeded( + primarySource: String, + availableSources: [String], + fromUser: Bool, + fallbackToken: UInt64 + ) { + guard availableSources.count > 1 else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + asciiFallbackDelay) { [weak self] in + self?.applyAsciiFallbackIfNeeded( + primarySource: primarySource, + availableSources: availableSources, + fromUser: fromUser, + fallbackToken: fallbackToken + ) + } + } + + func applyAsciiFallbackIfNeeded( + primarySource: String, + availableSources: [String], + fromUser: Bool, + fallbackToken: UInt64 + ) { + guard isCurrentAsciiFallbackToken(fallbackToken) else { return } + + let actualIME = getCurrentInputSource() + guard actualIME != primarySource, !availableSources.contains(actualIME) else { + return + } + + guard let fallbackSource = availableSources.first(where: { $0 != primarySource }) else { + return + } + + Logger.warning( + "ASCII switch did not apply, trying fallback: \(primarySource) -> \(fallbackSource)", + category: .ime + ) + switchToSpecificIME(fallbackSource, fromUser: fromUser) + } + + func createAsciiFallbackToken() -> UInt64 { + return asciiFallbackQueue.sync { + asciiFallbackGeneration &+= 1 + return asciiFallbackGeneration + } + } + + func invalidateAsciiFallbackToken() { + asciiFallbackQueue.sync { + asciiFallbackGeneration &+= 1 + } + } + + func isCurrentAsciiFallbackToken(_ token: UInt64) -> Bool { + return asciiFallbackQueue.sync { + asciiFallbackGeneration == token + } + } + + func shouldSkipSwitch( + targetIME: String, + fromUser: Bool, + currentIME: String, + now: CFAbsoluteTime + ) -> Bool { + if fromUser { + return shouldSkipUserSwitch(targetIME: targetIME, currentIME: currentIME, now: now) + } + return shouldSkipInternalSwitch(targetIME: targetIME, currentIME: currentIME, now: now) + } + + func shouldSkipUserSwitch(targetIME: String, currentIME: String, now: CFAbsoluteTime) -> Bool { + var shouldSkip = false + switchThrottleQueue.sync { + shouldSkip = switchThrottleState.lastUserTarget == targetIME && + (now - switchThrottleState.lastUserRequestTime) < userThrottleInterval && + currentIME == targetIME + } + + if shouldSkip { + Logger.debug("Skipping rapid duplicate user switch: \(targetIME)", category: .ime) + postUIRefreshNotification() + } + return shouldSkip + } + + func shouldSkipInternalSwitch(targetIME: String, currentIME: String, now: CFAbsoluteTime) -> Bool { + if currentIME == targetIME { + Logger.debug("Already on target IME (internal): \(targetIME)", category: .ime) + return true + } + + var shouldSkip = false + switchThrottleQueue.sync { + shouldSkip = switchThrottleState.lastInternalTarget == targetIME && + (now - switchThrottleState.lastInternalRequestTime) < internalThrottleInterval + } + + if shouldSkip { + Logger.debug("Skipping throttled internal switch: \(targetIME)", category: .ime) + } + return shouldSkip + } + + func recordSwitchThrottle(targetIME: String, fromUser: Bool, now: CFAbsoluteTime) { + switchThrottleQueue.async(flags: .barrier) { + if fromUser { + self.switchThrottleState.lastUserTarget = targetIME + self.switchThrottleState.lastUserRequestTime = now + } else { + self.switchThrottleState.lastInternalTarget = targetIME + self.switchThrottleState.lastInternalRequestTime = now + } + } + } +} diff --git a/ModSwitchIME/KeyMonitor.swift b/ModSwitchIME/KeyMonitor.swift index 58c1377..d551250 100644 --- a/ModSwitchIME/KeyMonitor.swift +++ b/ModSwitchIME/KeyMonitor.swift @@ -145,9 +145,7 @@ final class KeyMonitor { cancellables.removeAll() // Clear state - keyStates.removeAll() - lastPressedKey = nil - nonModifierKeyPressed = false + resetKeyState() Logger.info("KeyMonitor stopped", category: .keyboard) } @@ -173,9 +171,11 @@ final class KeyMonitor { } } case .keyUp: - // Only update if we were tracking non-modifier key press - if nonModifierKeyPressed { - nonModifierKeyPressed = false + stateQueue.sync { + // Only update if we were tracking non-modifier key press + if nonModifierKeyPressed { + nonModifierKeyPressed = false + } } case .tapDisabledByTimeout: Logger.error("Event tap disabled by timeout", category: .keyboard) @@ -223,7 +223,6 @@ final class KeyMonitor { return Unmanaged.passUnretained(event) } - // swiftlint:disable:next cyclomatic_complexity function_body_length private func handleFlagsChanged(event: CGEvent) { let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let flags = event.flags @@ -231,25 +230,33 @@ final class KeyMonitor { guard let modifierKey = ModifierKey.from(keyCode: keyCode) else { return } + + let targetIME = stateQueue.sync { + processFlagsChanged(modifierKey: modifierKey, flags: flags) + } + + if let targetIME = targetIME { + imeController.switchToSpecificIME(targetIME) + } + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func processFlagsChanged(modifierKey: ModifierKey, flags: CGEventFlags) -> String? { // IMPORTANT: For left/right keys that share the same flag mask (like leftCommand/rightCommand), // we need to determine press/release based on the presence in keyStates let wasAlreadyPressed = keyStates[modifierKey] != nil let currentlyPressed = flags.contains(modifierKey.flagMask) - // For Command keys (and other pairs that share flagMask), we need special handling - let isSharedFlagKey = (modifierKey.flagMask == .maskCommand || - modifierKey.flagMask == .maskShift || - modifierKey.flagMask == .maskControl || - modifierKey.flagMask == .maskAlternate) + let isSharedFlagKey = isSharedFlagKey(modifierKey) let isKeyDown: Bool if isSharedFlagKey && wasAlreadyPressed { // For shared flag keys, if the key was already pressed and we get an event for it, // it's likely a release (since macOS sends events for both press and release) // We'll determine based on the current state and other pressed keys - let otherPressedKeysWithSameFlag = keyStates.keys.filter { - $0 != modifierKey && $0.flagMask == modifierKey.flagMask + let otherPressedKeysWithSameFlag = keyStates.keys.filter { + $0 != modifierKey && $0.flagMask == modifierKey.flagMask } if otherPressedKeysWithSameFlag.isEmpty { @@ -267,10 +274,12 @@ final class KeyMonitor { isKeyDown = false } else if currentlyPressed && wasAlreadyPressed { // Key is still down (no change) - return // No action needed + return nil } else { // Key is still up (no change) - return // No action needed + cleanupReleasedSharedFlagKeys(for: modifierKey, flags: flags) + clearLastPressedKeyIfNeeded() + return nil } let now = CFAbsoluteTimeGetCurrent() @@ -291,7 +300,7 @@ final class KeyMonitor { // Count other CURRENTLY pressed keys that have IME configured (excluding current key) // IMPORTANT: Only consider keys that are actually still pressed (in keyStates) - let otherPressedKeys = stateQueue.sync { keyStates.filter { $0.key != modifierKey } } + let otherPressedKeys = keyStates.filter { $0.key != modifierKey } var otherKeysWithIME: [ModifierKey] = [] for (key, _) in otherPressedKeys { if preferences.getIME(for: key) != nil && preferences.isKeyEnabled(key) { @@ -322,7 +331,7 @@ final class KeyMonitor { "Multi-key IME switch: \(modifierKey.displayName) -> \(targetIME)", category: .keyboard ) - imeController.switchToSpecificIME(targetIME) + return targetIME } } else { // Not a multi-key press scenario @@ -339,32 +348,24 @@ final class KeyMonitor { let keyState = keyStates[modifierKey] keyStates.removeValue(forKey: modifierKey) - handleKeyRelease(modifierKey: modifierKey, keyState: keyState, event: event) + let targetIME = targetIMEForKeyRelease(modifierKey: modifierKey, keyState: keyState) - // IMPORTANT: For left/right Command keys that share the same flagMask, - // we need to ensure both are removed when flags indicate no Command keys are pressed - if modifierKey.flagMask == .maskCommand && !flags.contains(.maskCommand) { - // No Command keys are pressed according to flags, remove both if they exist - let commandKeysToRemove = keyStates.keys.filter { $0.flagMask == .maskCommand } - for key in commandKeysToRemove { - keyStates.removeValue(forKey: key) - } - } + cleanupReleasedSharedFlagKeys(for: modifierKey, flags: flags) // Clear lastPressedKey when all keys are released - if keyStates.isEmpty { - lastPressedKey = nil - // All keys released - clear all state - } + clearLastPressedKeyIfNeeded() + return targetIME } + + return nil } - private func handleKeyRelease(modifierKey: ModifierKey, keyState: ModifierKeyState?, event: CGEvent) { + private func targetIMEForKeyRelease(modifierKey: ModifierKey, keyState: ModifierKeyState?) -> String? { // Check if IME is configured for this key guard let targetIME = preferences.getIME(for: modifierKey), preferences.isKeyEnabled(modifierKey) else { // No IME configured or disabled for this key - return + return nil } // Check if we have a valid state @@ -373,7 +374,7 @@ final class KeyMonitor { "Key release without corresponding press: \(modifierKey.displayName)", category: .keyboard ) - return + return nil } // Check if other keys are currently pressed @@ -387,7 +388,41 @@ final class KeyMonitor { ) // Direct switch without checking current IME for better performance - imeController.switchToSpecificIME(targetIME) + return targetIME + } + + return nil + } + + private func isSharedFlagKey(_ modifierKey: ModifierKey) -> Bool { + return modifierKey.flagMask == .maskCommand || + modifierKey.flagMask == .maskShift || + modifierKey.flagMask == .maskControl || + modifierKey.flagMask == .maskAlternate + } + + private func cleanupReleasedSharedFlagKeys(for modifierKey: ModifierKey, flags: CGEventFlags) { + guard isSharedFlagKey(modifierKey), !flags.contains(modifierKey.flagMask) else { + return + } + + let keysToRemove = keyStates.keys.filter { $0.flagMask == modifierKey.flagMask } + for key in keysToRemove { + keyStates.removeValue(forKey: key) + } + } + + private func clearLastPressedKeyIfNeeded() { + if keyStates.isEmpty { + lastPressedKey = nil + } + } + + private func resetKeyState() { + stateQueue.sync { + keyStates.removeAll() + lastPressedKey = nil + nonModifierKeyPressed = false } } @@ -628,12 +663,14 @@ final class KeyMonitor { // Compatibility method for tests func getModifierKeyStates() -> [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] { - // Convert current key states to the expected format - var states: [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] = [:] - for (key, state) in keyStates { - states[key] = (isDown: true, downTime: state.downTime) + return stateQueue.sync { + // Convert current key states to the expected format + var states: [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] = [:] + for (key, state) in keyStates { + states[key] = (isDown: true, downTime: state.downTime) + } + return states } - return states } private func verifyEventTapEnabled() -> Bool { @@ -651,7 +688,7 @@ final class KeyMonitor { stop() // Ensure all resources are cleaned up cancellables.removeAll() - keyStates.removeAll() + resetKeyState() cancelRetryTimer() stopIdleTimer() } diff --git a/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift b/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift index 2ecb76a..a5ba45d 100644 --- a/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift +++ b/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift @@ -114,8 +114,12 @@ final class ImeControllerSkipLogicTests2: XCTestCase { XCTAssertTrue(true, "Chattering prevention should work with 100ms threshold") } - func testDuplicateUserSwitchPostsRefreshNotification() { - let targetIME = "com.apple.keylayout.ABC" + func testDuplicateUserSwitchPostsRefreshNotification() throws { + let targetIME = controller.getCurrentInputSource() + if targetIME == "Unknown" { + throw XCTSkip("Current input source is unavailable in this environment") + } + let refreshExpectation = expectation(description: "Duplicate user switch triggers UI refresh") var secondCallStarted = false diff --git a/ModSwitchIMETests/KeyMonitorSimpleTest.swift b/ModSwitchIMETests/KeyMonitorSimpleTest.swift index 42c5358..5686d14 100644 --- a/ModSwitchIMETests/KeyMonitorSimpleTest.swift +++ b/ModSwitchIMETests/KeyMonitorSimpleTest.swift @@ -152,6 +152,27 @@ class KeyMonitorSimpleTest: XCTestCase { XCTAssertEqual(mockImeController.switchToSpecificIMECalls.count, 0) #endif } + + func testStaleSharedFlagStateIsClearedForOptionKeys() { + #if DEBUG + keyMonitor.simulateFlagsChanged( + keyCode: ModifierKey.leftOption.keyCode, + flags: ModifierKey.leftOption.flagMask + ) + + XCTAssertTrue(keyMonitor.getKeyPressTimestamps().keys.contains(.leftOption)) + + keyMonitor.simulateFlagsChanged( + keyCode: ModifierKey.rightOption.keyCode, + flags: [] + ) + + XCTAssertTrue( + keyMonitor.getKeyPressTimestamps().isEmpty, + "Stale left/right Option state should be cleared when Option flag is not present" + ) + #endif + } // MARK: - Event Tap Health Monitoring Tests From b68a9499e4ab8f5c33a5afc8fe0d3c56aec667c3 Mon Sep 17 00:00:00 2001 From: nissy Date: Thu, 21 May 2026 00:45:43 +0900 Subject: [PATCH 05/12] bugfix window focus --- ModSwitchIME/ImeController.swift | 137 ++++++++++++++++--------------- ModSwitchIME/KeyMonitor.swift | 2 + 2 files changed, 74 insertions(+), 65 deletions(-) diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 913e881..1964564 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -47,6 +47,8 @@ final class ImeController: ErrorHandler, IMEControlling { private let asciiFallbackDelay: TimeInterval = 0.2 private var asciiFallbackGeneration: UInt64 = 0 private let asciiFallbackQueue = DispatchQueue(label: "com.modswitchime.asciifallback") + private var switchGeneration: UInt64 = 0 + private let switchGenerationQueue = DispatchQueue(label: "com.modswitchime.switchgeneration") // Throttle state balances rapid duplicate prevention with user retry needs var lastNotifiedIME: String = "" @@ -114,7 +116,7 @@ final class ImeController: ErrorHandler, IMEControlling { if invalidatesPendingAsciiFallback { invalidateAsciiFallbackToken() } - + let currentIME = getCurrentInputSource() let now = CFAbsoluteTimeGetCurrent() @@ -127,11 +129,12 @@ final class ImeController: ErrorHandler, IMEControlling { return } + let switchToken = createSwitchToken() recordSwitchThrottle(targetIME: imeId, fromUser: fromUser, now: now) // Execute IME switch do { - try selectInputSource(imeId) + try selectInputSource(imeId, switchToken: switchToken) } catch { let imeError = ModSwitchIMEError.inputSourceNotFound(imeId) handleError(imeError) @@ -172,23 +175,7 @@ final class ImeController: ErrorHandler, IMEControlling { return true } - // Choose an alternate IME to force a visible state change before selecting the target again. - private func chooseAlternateIME(for target: String) -> String? { - var cache: [String: TISInputSource] = [:] - cacheQueue.sync { cache = inputSourceCache } - let englishCandidates = ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] - let available = englishCandidates.filter { cache[$0] != nil } - guard !available.isEmpty else { return nil } - if target == "com.apple.keylayout.ABC" { - return available.first { $0 == "com.apple.keylayout.US" } ?? available.first - } - if target == "com.apple.keylayout.US" { - return available.first { $0 == "com.apple.keylayout.ABC" } ?? available.first - } - return available.first - } - - func selectInputSource(_ inputSourceID: String) throws { + func selectInputSource(_ inputSourceID: String, switchToken: UInt64) throws { // Validate input guard isValidIMEId(inputSourceID) else { throw ModSwitchIMEError.invalidInputSource("Invalid IME ID format: \(inputSourceID)") @@ -203,7 +190,8 @@ final class ImeController: ErrorHandler, IMEControlling { try selectWithRetries(source: source, expectedIME: inputSourceID, currentIME: currentIME, - refreshed: refreshed) + refreshed: refreshed, + switchToken: switchToken) return } throw ModSwitchIMEError.inputSourceNotFound(inputSourceID) @@ -230,13 +218,15 @@ final class ImeController: ErrorHandler, IMEControlling { source: TISInputSource, expectedIME: String, currentIME: String, - refreshed: Bool + refreshed: Bool, + switchToken: UInt64 ) throws { var lastError: Error? for attempt in 0..<3 { + guard isCurrentSwitchToken(switchToken) else { return } let result = TISSelectInputSource(source) if result == noErr { - performHybridIMESwitch(expectedIME: expectedIME, currentIME: currentIME) + performHybridIMESwitch(expectedIME: expectedIME, currentIME: currentIME, switchToken: switchToken) return } lastError = ModSwitchIMEError.inputMethodSwitchFailed( @@ -252,10 +242,11 @@ final class ImeController: ErrorHandler, IMEControlling { throw lastError ?? ModSwitchIMEError.inputMethodSwitchFailed("Unknown error") } - private func performHybridIMESwitch(expectedIME: String, currentIME: String) { + private func performHybridIMESwitch(expectedIME: String, currentIME: String, switchToken: UInt64) { // Hybrid approach: Check actual switch after a short delay before notifying DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } let actualIME = self.getCurrentInputSource() @@ -266,12 +257,17 @@ final class ImeController: ErrorHandler, IMEControlling { self.postIMESwitchNotification(expectedIME) // Schedule additional verification for edge cases - self.scheduleAdditionalVerification(expectedIME: expectedIME, delay: 0.05) + self.scheduleAdditionalVerification(expectedIME: expectedIME, delay: 0.05, switchToken: switchToken) } else if actualIME == currentIME { // Not switched yet - schedule another check Logger.debug("IME not switched yet, scheduling verification", category: .ime) // Prevent infinite recursion by limiting retry depth - self.verifyIMESwitchWithLimit(expectedIME: expectedIME, currentIME: currentIME, retryCount: 1) + self.verifyIMESwitchWithLimit( + expectedIME: expectedIME, + currentIME: currentIME, + retryCount: 1, + switchToken: switchToken + ) } else { // Switched to unexpected IME Logger.warning( @@ -279,30 +275,43 @@ final class ImeController: ErrorHandler, IMEControlling { category: .ime ) // Reapply the requested IME before falling back to showing actual state. - self.retryIMESwitchWithLimit(targetIME: expectedIME, currentIME: actualIME, retryCount: 1) + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: actualIME, + retryCount: 1, + switchToken: switchToken + ) } } } - private func scheduleAdditionalVerification(expectedIME: String, delay: TimeInterval) { + private func scheduleAdditionalVerification(expectedIME: String, delay: TimeInterval, switchToken: UInt64) { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } let actualIME = self.getCurrentInputSource() if actualIME != expectedIME { Logger.warning("Additional verification: IME mismatch detected", category: .ime) // The system or focused app overrode the switch after confirmation. Try once more. - self.retryIMESwitchWithLimit(targetIME: expectedIME, currentIME: actualIME, retryCount: 1) + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: actualIME, + retryCount: 1, + switchToken: switchToken + ) } } } - private func verifyIMESwitch(expectedIME: String, currentIME: String) { - verifyIMESwitchWithLimit(expectedIME: expectedIME, currentIME: currentIME, retryCount: 1) - } - - private func verifyIMESwitchWithLimit(expectedIME: String, currentIME: String, retryCount: Int) { + private func verifyIMESwitchWithLimit( + expectedIME: String, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + guard isCurrentSwitchToken(switchToken) else { return } guard retryCount <= 3 else { Logger.warning("Max retry attempts reached for IME switch verification", category: .ime) postUIRefreshNotification() @@ -312,6 +321,7 @@ final class ImeController: ErrorHandler, IMEControlling { // Verify the switch after a short delay (reduced from 0.1 to 0.05) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } let newIME = self.getCurrentInputSource() if newIME == expectedIME { @@ -332,7 +342,8 @@ final class ImeController: ErrorHandler, IMEControlling { self.retryIMESwitchWithLimit( targetIME: expectedIME, currentIME: currentIME, - retryCount: retryCount + 1 + retryCount: retryCount + 1, + switchToken: switchToken ) } else { self.postUIRefreshNotification() @@ -343,7 +354,8 @@ final class ImeController: ErrorHandler, IMEControlling { self.retryIMESwitchWithLimit( targetIME: expectedIME, currentIME: newIME, - retryCount: retryCount + 1 + retryCount: retryCount + 1, + switchToken: switchToken ) } else { self.postUIRefreshNotification() @@ -352,15 +364,13 @@ final class ImeController: ErrorHandler, IMEControlling { } } - private func retryIMESwitch(targetIME: String) { - retryIMESwitchWithLimit(targetIME: targetIME, retryCount: 1) - } - - private func retryIMESwitchWithLimit(targetIME: String, retryCount: Int) { - retryIMESwitchWithLimit(targetIME: targetIME, currentIME: getCurrentInputSource(), retryCount: retryCount) - } - - private func retryIMESwitchWithLimit(targetIME: String, currentIME: String, retryCount: Int) { + private func retryIMESwitchWithLimit( + targetIME: String, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + guard isCurrentSwitchToken(switchToken) else { return } guard retryCount <= 3 else { Logger.warning("Max retry attempts reached for IME switch", category: .ime) postUIRefreshNotification() @@ -369,6 +379,7 @@ final class ImeController: ErrorHandler, IMEControlling { DispatchQueue.main.asyncAfter(deadline: .now() + verificationRetryDelay) { [weak self] in guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } guard let (source, refreshed) = self.getInputSourceFromCacheOrRefresh(targetIME) else { Logger.warning("Retry IME source not found: \(targetIME)", category: .ime) @@ -383,7 +394,8 @@ final class ImeController: ErrorHandler, IMEControlling { self.verifyIMESwitchWithLimit( expectedIME: targetIME, currentIME: currentIME, - retryCount: retryCount + retryCount: retryCount, + switchToken: switchToken ) } else if retryCount < 3 { Logger.warning( @@ -393,7 +405,8 @@ final class ImeController: ErrorHandler, IMEControlling { self.retryIMESwitchWithLimit( targetIME: targetIME, currentIME: currentIME, - retryCount: retryCount + 1 + retryCount: retryCount + 1, + switchToken: switchToken ) } else { Logger.warning("Retry IME switch failed with code: \(result)", category: .ime) @@ -402,25 +415,6 @@ final class ImeController: ErrorHandler, IMEControlling { } } - private func findFreshInputSource(_ inputSourceID: String) -> TISInputSource? { - guard let cfInputSources = TISCreateInputSourceList(nil, false) else { - return nil - } - - let inputSources = cfInputSources.takeRetainedValue() as? [TISInputSource] ?? [] - - for inputSource in inputSources { - if let sourceID = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) { - let id = Unmanaged.fromOpaque(sourceID).takeUnretainedValue() as String - if id == inputSourceID { - return inputSource - } - } - } - - return nil - } - // Removed performSwitch and related methods - no longer needed after simplification func getCurrentInputSource() -> String { @@ -537,6 +531,19 @@ private extension ImeController { } } + func createSwitchToken() -> UInt64 { + return switchGenerationQueue.sync { + switchGeneration &+= 1 + return switchGeneration + } + } + + func isCurrentSwitchToken(_ token: UInt64) -> Bool { + return switchGenerationQueue.sync { + switchGeneration == token + } + } + func shouldSkipSwitch( targetIME: String, fromUser: Bool, diff --git a/ModSwitchIME/KeyMonitor.swift b/ModSwitchIME/KeyMonitor.swift index d551250..c4806f3 100644 --- a/ModSwitchIME/KeyMonitor.swift +++ b/ModSwitchIME/KeyMonitor.swift @@ -179,6 +179,7 @@ final class KeyMonitor { } case .tapDisabledByTimeout: Logger.error("Event tap disabled by timeout", category: .keyboard) + resetKeyState() if let eventTap = eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) // Attempt to recreate if re-enable failed @@ -197,6 +198,7 @@ final class KeyMonitor { } case .tapDisabledByUserInput: Logger.error("Event tap disabled by user input", category: .keyboard) + resetKeyState() onError?(.eventTapDisabled(automatic: false)) DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in if let eventTap = self?.eventTap { From 3cf85c154da7f696fd0fc26faff7195fca766f26 Mon Sep 17 00:00:00 2001 From: nissy Date: Thu, 21 May 2026 13:33:55 +0900 Subject: [PATCH 06/12] bugfix window focus --- .../ImeController+StateMonitoring.swift | 38 +++++++++ ModSwitchIME/ImeController.swift | 80 ++++++++----------- ModSwitchIME/InputSourceManager.swift | 42 ++++++++++ .../InputSourceHelpersTests.swift | 31 +++++++ 4 files changed, 145 insertions(+), 46 deletions(-) diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift index 5cc0961..effa935 100644 --- a/ModSwitchIME/ImeController+StateMonitoring.swift +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -225,3 +225,41 @@ extension ImeController { func debugSyncLastNotifiedIMEWithCurrentInputSource() { syncLastNotifiedIMEWithCurrentInputSource() } #endif } + +extension ImeController { + func resolveInputSourceFromCacheOrRefresh(_ id: String) -> ResolvedInputSource? { + if let resolved = resolveInputSource(id, refreshed: false) { + return resolved + } + + Logger.warning("IME not found or not selectable, refreshing: \(id)", category: .ime) + refreshCacheSync() + return resolveInputSource(id, refreshed: true) + } + + private func resolveInputSource(_ id: String, refreshed: Bool) -> ResolvedInputSource? { + var cache: [String: TISInputSource] = [:] + cacheQueue.sync { cache = inputSourceCache } + + if let source = cache[id], isSelectableInputSource(source) { + return ResolvedInputSource(id: id, source: source, refreshed: refreshed) + } + + let selectableCache = cache.filter { isSelectableInputSource($0.value) } + if let fallbackID = InputSourceManager.fallbackInputSourceID(for: id, availableIDs: Set(selectableCache.keys)), + let fallbackSource = selectableCache[fallbackID] { + Logger.info("Resolved IME \(id) to selectable source \(fallbackID)", category: .ime) + return ResolvedInputSource(id: fallbackID, source: fallbackSource, refreshed: refreshed) + } + + return nil + } + + private func isSelectableInputSource(_ source: TISInputSource) -> Bool { + guard let selectableRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsSelectCapable) else { + return false + } + let selectable = Unmanaged.fromOpaque(selectableRef).takeUnretainedValue() + return CFBooleanGetValue(selectable) + } +} diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 1964564..023a668 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -54,6 +54,12 @@ final class ImeController: ErrorHandler, IMEControlling { var lastNotifiedIME: String = "" let notificationQueue = DispatchQueue(label: "com.modswitchime.notification") + struct ResolvedInputSource { + let id: String + let source: TISInputSource + let refreshed: Bool + } + private init() { // Initialize cache on startup for immediate availability initializeCache() @@ -117,11 +123,17 @@ final class ImeController: ErrorHandler, IMEControlling { invalidateAsciiFallbackToken() } + guard let resolvedTarget = resolveInputSourceFromCacheOrRefresh(imeId) else { + handleError(ModSwitchIMEError.inputSourceNotFound(imeId)) + postUIRefreshNotification() + return + } + let currentIME = getCurrentInputSource() let now = CFAbsoluteTimeGetCurrent() if shouldSkipSwitch( - targetIME: imeId, + targetIME: resolvedTarget.id, fromUser: fromUser, currentIME: currentIME, now: now @@ -130,14 +142,13 @@ final class ImeController: ErrorHandler, IMEControlling { } let switchToken = createSwitchToken() - recordSwitchThrottle(targetIME: imeId, fromUser: fromUser, now: now) + recordSwitchThrottle(targetIME: resolvedTarget.id, fromUser: fromUser, now: now) // Execute IME switch do { - try selectInputSource(imeId, switchToken: switchToken) + try selectInputSource(resolvedTarget, currentIME: currentIME, switchToken: switchToken) } catch { - let imeError = ModSwitchIMEError.inputSourceNotFound(imeId) - handleError(imeError) + handleError(ModSwitchIMEError.inputSourceNotFound(resolvedTarget.id)) // Refresh UI to show actual state on error postUIRefreshNotification() } @@ -175,42 +186,19 @@ final class ImeController: ErrorHandler, IMEControlling { return true } - func selectInputSource(_ inputSourceID: String, switchToken: UInt64) throws { - // Validate input - guard isValidIMEId(inputSourceID) else { - throw ModSwitchIMEError.invalidInputSource("Invalid IME ID format: \(inputSourceID)") - } - - // Get current IME before switching - let currentIME = getCurrentInputSource() - Logger.debug("Switching IME: \(currentIME) -> \(inputSourceID)", category: .ime) - - // Resolve input source from cache, optionally refreshing - if let (source, refreshed) = getInputSourceFromCacheOrRefresh(inputSourceID) { - try selectWithRetries(source: source, - expectedIME: inputSourceID, - currentIME: currentIME, - refreshed: refreshed, - switchToken: switchToken) - return - } - throw ModSwitchIMEError.inputSourceNotFound(inputSourceID) - } - - // Resolve input source from cache; if missing, refresh synchronously and retry lookup. - private func getInputSourceFromCacheOrRefresh(_ id: String) -> (TISInputSource, Bool)? { - var cached: TISInputSource? - cacheQueue.sync { cached = inputSourceCache[id] } - if let s = cached { - return (s, false) - } - Logger.warning("IME not found in cache, refreshing: \(id)", category: .ime) - refreshCacheSync() - cacheQueue.sync { cached = inputSourceCache[id] } - if let s = cached { - return (s, true) - } - return nil + private func selectInputSource( + _ resolvedInputSource: ResolvedInputSource, + currentIME: String, + switchToken: UInt64 + ) throws { + Logger.debug("Switching IME: \(currentIME) -> \(resolvedInputSource.id)", category: .ime) + try selectWithRetries( + source: resolvedInputSource.source, + expectedIME: resolvedInputSource.id, + currentIME: currentIME, + refreshed: resolvedInputSource.refreshed, + switchToken: switchToken + ) } // Try selecting the given source up to 3 times with small backoff. @@ -381,18 +369,18 @@ final class ImeController: ErrorHandler, IMEControlling { guard let self = self else { return } guard self.isCurrentSwitchToken(switchToken) else { return } - guard let (source, refreshed) = self.getInputSourceFromCacheOrRefresh(targetIME) else { + guard let resolvedTarget = self.resolveInputSourceFromCacheOrRefresh(targetIME) else { Logger.warning("Retry IME source not found: \(targetIME)", category: .ime) self.postUIRefreshNotification() return } - let result = TISSelectInputSource(source) + let result = TISSelectInputSource(resolvedTarget.source) if result == noErr { - let ctx = refreshed ? "fresh source" : "cached source" - Logger.debug("Retrying IME switch (\(ctx)) attempt \(retryCount): \(targetIME)", category: .ime) + let ctx = resolvedTarget.refreshed ? "fresh source" : "cached source" + Logger.debug("Retrying IME switch (\(ctx)) attempt \(retryCount): \(resolvedTarget.id)", category: .ime) self.verifyIMESwitchWithLimit( - expectedIME: targetIME, + expectedIME: resolvedTarget.id, currentIME: currentIME, retryCount: retryCount, switchToken: switchToken diff --git a/ModSwitchIME/InputSourceManager.swift b/ModSwitchIME/InputSourceManager.swift index 8a7c34a..5d9c21e 100644 --- a/ModSwitchIME/InputSourceManager.swift +++ b/ModSwitchIME/InputSourceManager.swift @@ -57,6 +57,48 @@ struct InputSourceManager { return nil } + + static func fallbackInputSourceID(for requestedID: String, availableIDs: Set) -> String? { + let explicitCandidates = explicitFallbackInputSourceIDs(for: requestedID) + for candidate in explicitCandidates where availableIDs.contains(candidate) { + return candidate + } + + let childPrefix = requestedID + "." + let childCandidates = availableIDs.filter { $0.hasPrefix(childPrefix) } + return childCandidates.min { lhs, rhs in + let lhsPriority = fallbackPriority(lhs) + let rhsPriority = fallbackPriority(rhs) + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + return lhs < rhs + } + } + + private static func explicitFallbackInputSourceIDs(for requestedID: String) -> [String] { + if requestedID == "com.google.inputmethod.Japanese" { + return [ + "com.google.inputmethod.Japanese.base", + "com.google.inputmethod.Japanese.Hiragana", + "com.google.inputmethod.Japanese.Roman" + ] + } + return [] + } + + private static func fallbackPriority(_ inputSourceID: String) -> Int { + if inputSourceID.hasSuffix(".base") || inputSourceID.hasSuffix(".Hiragana") { + return 0 + } + if inputSourceID.hasSuffix(".Japanese") { + return 1 + } + if inputSourceID.hasSuffix(".Roman") { + return 2 + } + return 10 + } /// Get the list of actually enabled input sources from system preferences static func getSystemEnabledInputSourceIDs() -> Set { diff --git a/ModSwitchIMETests/InputSourceHelpersTests.swift b/ModSwitchIMETests/InputSourceHelpersTests.swift index 6ceb04b..248aca9 100644 --- a/ModSwitchIMETests/InputSourceHelpersTests.swift +++ b/ModSwitchIMETests/InputSourceHelpersTests.swift @@ -23,4 +23,35 @@ final class InputSourceHelpersTests: XCTestCase { XCTAssertEqual(InputSourceManager.getInputSourceLanguage("com.apple.keylayout.French"), "European Languages") XCTAssertEqual(InputSourceManager.getInputSourceLanguage("com.unknown.vendor"), "English & Others") } + + func testGoogleJapaneseParentFallsBackToBaseMode() { + let availableIDs: Set = [ + "com.google.inputmethod.Japanese", + "com.google.inputmethod.Japanese.Roman", + "com.google.inputmethod.Japanese.base" + ] + + XCTAssertEqual( + InputSourceManager.fallbackInputSourceID( + for: "com.google.inputmethod.Japanese", + availableIDs: availableIDs + ), + "com.google.inputmethod.Japanese.base" + ) + } + + func testFallbackInputSourcePrefersJapaneseModeBeforeRoman() { + let availableIDs: Set = [ + "com.example.inputmethod.Test.Roman", + "com.example.inputmethod.Test.Hiragana" + ] + + XCTAssertEqual( + InputSourceManager.fallbackInputSourceID( + for: "com.example.inputmethod.Test", + availableIDs: availableIDs + ), + "com.example.inputmethod.Test.Hiragana" + ) + } } From 3c72cd8139d59285baf544e7299ac8e1e907be3d Mon Sep 17 00:00:00 2001 From: nissy Date: Thu, 21 May 2026 13:55:30 +0900 Subject: [PATCH 07/12] bugfix window focus --- ModSwitchIME.xcodeproj/project.pbxproj | 4 + .../ImeController+StateMonitoring.swift | 48 ++++++++++ ModSwitchIME/ImeController.swift | 18 +++- ModSwitchIME/KeyMonitor+DeferredSwitch.swift | 96 +++++++++++++++++++ ModSwitchIME/KeyMonitor.swift | 69 ++++--------- 5 files changed, 182 insertions(+), 53 deletions(-) create mode 100644 ModSwitchIME/KeyMonitor+DeferredSwitch.swift diff --git a/ModSwitchIME.xcodeproj/project.pbxproj b/ModSwitchIME.xcodeproj/project.pbxproj index 8b59ff5..3755675 100644 --- a/ModSwitchIME.xcodeproj/project.pbxproj +++ b/ModSwitchIME.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 001A7B3D2C2F3A1A00E5B4C8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */; }; 001A7B412C2F3A1A00E5B4C8 /* KeyMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */; }; 001A7B432C2F3A1A00E5B4C8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */; }; + 001A7B482C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B492C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift */; }; 3B7B971675E44DF784A30E7D /* InputSourceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E795C604248469D9AD8FA2E /* InputSourceManager.swift */; }; 4A8B9C7D2E3F5A2100D6E8F9 /* ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A8B9C7C2E3F5A2100D6E8F9 /* ErrorHandling.swift */; }; 5B9C8D3F2F4A6B2300E7F8AA /* AccessibilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9C8D3E2F4A6B2300E7F8A9 /* AccessibilityManager.swift */; }; @@ -50,6 +51,7 @@ 001A7B3F2C2F3A1A00E5B4C8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyMonitor.swift; sourceTree = ""; }; 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 001A7B492C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyMonitor+DeferredSwitch.swift"; sourceTree = ""; }; 3E795C604248469D9AD8FA2E /* InputSourceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourceManager.swift; sourceTree = ""; }; 4154FC862E01CADD0065FB88 /* ModSwitchIMETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ModSwitchIMETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4A8B9C7C2E3F5A2100D6E8F9 /* ErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandling.swift; sourceTree = ""; }; @@ -110,6 +112,7 @@ 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */, 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */, 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */, + 001A7B492C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift */, 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */, 3E795C604248469D9AD8FA2E /* InputSourceManager.swift */, 4A8B9C7C2E3F5A2100D6E8F9 /* ErrorHandling.swift */, @@ -244,6 +247,7 @@ 001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */, 001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */, 001A7B412C2F3A1A00E5B4C8 /* KeyMonitor.swift in Sources */, + 001A7B482C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift in Sources */, 001A7B432C2F3A1A00E5B4C8 /* Logger.swift in Sources */, 3B7B971675E44DF784A30E7D /* InputSourceManager.swift in Sources */, 4A8B9C7D2E3F5A2100D6E8F9 /* ErrorHandling.swift in Sources */, diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift index effa935..5b474b4 100644 --- a/ModSwitchIME/ImeController+StateMonitoring.swift +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -255,6 +255,54 @@ extension ImeController { return nil } + func shouldResetBeforeReapplying( + _ resolvedInputSource: ResolvedInputSource, + currentIME: String, + fromUser: Bool + ) -> Bool { + guard fromUser, currentIME == resolvedInputSource.id else { + return false + } + return isKeyboardInputMode(resolvedInputSource.source) + } + + func resetInputContextBeforeSelecting(expectedIME: String) { + guard let resetSource = resetInputSourceForReapply(excluding: expectedIME) else { + Logger.warning("No reset input source available before reapplying IME: \(expectedIME)", category: .ime) + return + } + + let result = TISSelectInputSource(resetSource) + if result == noErr { + Thread.sleep(forTimeInterval: 0.03) + } else { + Logger.warning("Reset input source selection failed with code: \(result)", category: .ime) + } + } + + private func resetInputSourceForReapply(excluding targetIME: String) -> TISInputSource? { + let resetCandidates = ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] + var cache: [String: TISInputSource] = [:] + cacheQueue.sync { cache = inputSourceCache } + + return resetCandidates.compactMap { candidateID in + guard candidateID != targetIME, + let source = cache[candidateID], + isSelectableInputSource(source) else { + return nil + } + return source + }.first + } + + private func isKeyboardInputMode(_ source: TISInputSource) -> Bool { + guard let typeRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceType) else { + return false + } + let type = Unmanaged.fromOpaque(typeRef).takeUnretainedValue() as String + return type == (kTISTypeKeyboardInputMode as String) + } + private func isSelectableInputSource(_ source: TISInputSource) -> Bool { guard let selectableRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsSelectCapable) else { return false diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 023a668..8d4f128 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -146,7 +146,16 @@ final class ImeController: ErrorHandler, IMEControlling { // Execute IME switch do { - try selectInputSource(resolvedTarget, currentIME: currentIME, switchToken: switchToken) + try selectInputSource( + resolvedTarget, + currentIME: currentIME, + resetBeforeSelecting: shouldResetBeforeReapplying( + resolvedTarget, + currentIME: currentIME, + fromUser: fromUser + ), + switchToken: switchToken + ) } catch { handleError(ModSwitchIMEError.inputSourceNotFound(resolvedTarget.id)) // Refresh UI to show actual state on error @@ -189,6 +198,7 @@ final class ImeController: ErrorHandler, IMEControlling { private func selectInputSource( _ resolvedInputSource: ResolvedInputSource, currentIME: String, + resetBeforeSelecting: Bool, switchToken: UInt64 ) throws { Logger.debug("Switching IME: \(currentIME) -> \(resolvedInputSource.id)", category: .ime) @@ -197,6 +207,7 @@ final class ImeController: ErrorHandler, IMEControlling { expectedIME: resolvedInputSource.id, currentIME: currentIME, refreshed: resolvedInputSource.refreshed, + resetBeforeSelecting: resetBeforeSelecting, switchToken: switchToken ) } @@ -207,8 +218,13 @@ final class ImeController: ErrorHandler, IMEControlling { expectedIME: String, currentIME: String, refreshed: Bool, + resetBeforeSelecting: Bool, switchToken: UInt64 ) throws { + if resetBeforeSelecting { + resetInputContextBeforeSelecting(expectedIME: expectedIME) + } + var lastError: Error? for attempt in 0..<3 { guard isCurrentSwitchToken(switchToken) else { return } diff --git a/ModSwitchIME/KeyMonitor+DeferredSwitch.swift b/ModSwitchIME/KeyMonitor+DeferredSwitch.swift new file mode 100644 index 0000000..9218bd0 --- /dev/null +++ b/ModSwitchIME/KeyMonitor+DeferredSwitch.swift @@ -0,0 +1,96 @@ +import Foundation +import CoreGraphics + +extension KeyMonitor { + func scheduleModifierIMESwitch(_ targetIME: String) { + let generation = pendingModifierSwitchQueue.sync { + pendingModifierSwitchGeneration &+= 1 + let generation = pendingModifierSwitchGeneration + pendingModifierSwitch = PendingModifierSwitch(targetIME: targetIME, generation: generation) + return generation + } + + DispatchQueue.main.asyncAfter(deadline: .now() + modifierSwitchDelay) { [weak self] in + self?.performPendingModifierSwitch(generation: generation) + } + } + + func performPendingModifierSwitch(generation: UInt64) { + let targetIME = pendingModifierSwitchQueue.sync { () -> String? in + guard let pending = pendingModifierSwitch, pending.generation == generation else { + return nil + } + pendingModifierSwitch = nil + return pending.targetIME + } + + guard let targetIME = targetIME else { return } + imeController.switchToSpecificIME(targetIME) + } + + func cancelPendingModifierSwitch() { + pendingModifierSwitchQueue.sync { + pendingModifierSwitchGeneration &+= 1 + pendingModifierSwitch = nil + } + } +} + +#if DEBUG +extension KeyMonitor { + func getKeyPressTimestamps() -> [ModifierKey: CFAbsoluteTime] { + return stateQueue.sync { + var timestamps: [ModifierKey: CFAbsoluteTime] = [:] + for (key, state) in keyStates { + timestamps[key] = state.downTime + } + return timestamps + } + } + + func simulateFlagsChanged(keyCode: Int64, flags: CGEventFlags) { + guard let event = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(keyCode), keyDown: true) else { + Logger.error("Failed to create CGEvent for testing", category: .keyboard) + return + } + event.flags = flags + handleFlagsChanged(event: event) + flushPendingModifierSwitchForTesting() + } + + func flushPendingModifierSwitchForTesting() { + let pending = pendingModifierSwitchQueue.sync { () -> PendingModifierSwitch? in + guard let pending = pendingModifierSwitch else { return nil } + pendingModifierSwitchGeneration &+= 1 + pendingModifierSwitch = nil + return pending + } + + guard let pending = pending else { return } + imeController.switchToSpecificIME(pending.targetIME) + } + + func forceStateSync(flags: CGEventFlags) { + stateQueue.sync { + let keysToRemove = keyStates.keys.filter { !flags.contains($0.flagMask) } + for key in keysToRemove { + keyStates.removeValue(forKey: key) + } + + if keyStates.isEmpty { + lastPressedKey = nil + } + } + } + + func getModifierKeyStates() -> [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] { + return stateQueue.sync { + var states: [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] = [:] + for (key, state) in keyStates { + states[key] = (isDown: true, downTime: state.downTime) + } + return states + } + } +} +#endif diff --git a/ModSwitchIME/KeyMonitor.swift b/ModSwitchIME/KeyMonitor.swift index c4806f3..ea6b5a6 100644 --- a/ModSwitchIME/KeyMonitor.swift +++ b/ModSwitchIME/KeyMonitor.swift @@ -7,7 +7,7 @@ import Combine final class KeyMonitor { private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? - private let imeController: IMEControlling + let imeController: IMEControlling private let preferences: Preferences init(preferences: Preferences = Preferences.shared, imeController: IMEControlling = ImeController.shared) { @@ -24,7 +24,7 @@ final class KeyMonitor { var onError: ((ModSwitchIMEError) -> Void)? // Optimized state management using a single struct - private struct ModifierKeyState { + struct ModifierKeyState { var downTime: CFAbsoluteTime var isInMultiKeyPress: Bool = false var hadNonModifierPress: Bool = false @@ -32,9 +32,9 @@ final class KeyMonitor { // Single dictionary for all key states (faster than multiple collections) // Protected by serial queue for thread safety - private let stateQueue = DispatchQueue(label: "com.modswitchime.keymonitor.state") - private var keyStates: [ModifierKey: ModifierKeyState] = [:] - private var lastPressedKey: ModifierKey? + let stateQueue = DispatchQueue(label: "com.modswitchime.keymonitor.state") + var keyStates: [ModifierKey: ModifierKeyState] = [:] + var lastPressedKey: ModifierKey? private var nonModifierKeyPressed = false // Track if any non-modifier key is pressed // Idle timer related @@ -46,6 +46,14 @@ final class KeyMonitor { private var lastEventTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() private let eventTapHealthCheckInterval: TimeInterval = 5.0 private let eventTapInactivityThreshold: TimeInterval = 30.0 + struct PendingModifierSwitch { + let targetIME: String + let generation: UInt64 + } + var pendingModifierSwitch: PendingModifierSwitch? + var pendingModifierSwitchGeneration: UInt64 = 0 + let pendingModifierSwitchQueue = DispatchQueue(label: "com.modswitchime.keymonitor.pendingSwitch") + let modifierSwitchDelay: TimeInterval = 0.02 // Debug var isIdleTimerRunning: Bool { @@ -140,6 +148,8 @@ final class KeyMonitor { // Cancel retry timer cancelRetryTimer() + + cancelPendingModifierSwitch() // Cancel observations cancellables.removeAll() @@ -225,7 +235,7 @@ final class KeyMonitor { return Unmanaged.passUnretained(event) } - private func handleFlagsChanged(event: CGEvent) { + func handleFlagsChanged(event: CGEvent) { let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let flags = event.flags @@ -238,7 +248,7 @@ final class KeyMonitor { } if let targetIME = targetIME { - imeController.switchToSpecificIME(targetIME) + scheduleModifierIMESwitch(targetIME) } } @@ -630,51 +640,6 @@ final class KeyMonitor { // MARK: - Testing Support #if DEBUG - func getKeyPressTimestamps() -> [ModifierKey: CFAbsoluteTime] { - return stateQueue.sync { - var timestamps: [ModifierKey: CFAbsoluteTime] = [:] - for (key, state) in keyStates { - timestamps[key] = state.downTime - } - return timestamps - } - } - - func simulateFlagsChanged(keyCode: Int64, flags: CGEventFlags) { - guard let event = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(keyCode), keyDown: true) else { - Logger.error("Failed to create CGEvent for testing", category: .keyboard) - return - } - event.flags = flags - handleFlagsChanged(event: event) - } - - // Force sync state with current flags for testing - func forceStateSync(flags: CGEventFlags) { - stateQueue.sync { - let keysToRemove = keyStates.keys.filter { !flags.contains($0.flagMask) } - for key in keysToRemove { - keyStates.removeValue(forKey: key) - } - - if keyStates.isEmpty { - lastPressedKey = nil - } - } - } - - // Compatibility method for tests - func getModifierKeyStates() -> [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] { - return stateQueue.sync { - // Convert current key states to the expected format - var states: [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] = [:] - for (key, state) in keyStates { - states[key] = (isDown: true, downTime: state.downTime) - } - return states - } - } - private func verifyEventTapEnabled() -> Bool { guard eventTap != nil else { return false } // Check if event tap is enabled by querying its state From 840364a48b751c1b156261aab7363fe5f90a584a Mon Sep 17 00:00:00 2001 From: nissy Date: Sat, 23 May 2026 11:44:39 +0900 Subject: [PATCH 08/12] bugfix window focus --- ModSwitchIME.xcodeproj/project.pbxproj | 4 - .../ImeController+StateMonitoring.swift | 48 ---------- ModSwitchIME/ImeController.swift | 18 +--- ModSwitchIME/KeyMonitor+DeferredSwitch.swift | 96 ------------------- ModSwitchIME/KeyMonitor.swift | 70 ++++++++++---- 5 files changed, 53 insertions(+), 183 deletions(-) delete mode 100644 ModSwitchIME/KeyMonitor+DeferredSwitch.swift diff --git a/ModSwitchIME.xcodeproj/project.pbxproj b/ModSwitchIME.xcodeproj/project.pbxproj index 3755675..8b59ff5 100644 --- a/ModSwitchIME.xcodeproj/project.pbxproj +++ b/ModSwitchIME.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 001A7B3D2C2F3A1A00E5B4C8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */; }; 001A7B412C2F3A1A00E5B4C8 /* KeyMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */; }; 001A7B432C2F3A1A00E5B4C8 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */; }; - 001A7B482C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B492C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift */; }; 3B7B971675E44DF784A30E7D /* InputSourceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E795C604248469D9AD8FA2E /* InputSourceManager.swift */; }; 4A8B9C7D2E3F5A2100D6E8F9 /* ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A8B9C7C2E3F5A2100D6E8F9 /* ErrorHandling.swift */; }; 5B9C8D3F2F4A6B2300E7F8AA /* AccessibilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B9C8D3E2F4A6B2300E7F8A9 /* AccessibilityManager.swift */; }; @@ -51,7 +50,6 @@ 001A7B3F2C2F3A1A00E5B4C8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyMonitor.swift; sourceTree = ""; }; 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; - 001A7B492C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyMonitor+DeferredSwitch.swift"; sourceTree = ""; }; 3E795C604248469D9AD8FA2E /* InputSourceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourceManager.swift; sourceTree = ""; }; 4154FC862E01CADD0065FB88 /* ModSwitchIMETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ModSwitchIMETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4A8B9C7C2E3F5A2100D6E8F9 /* ErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandling.swift; sourceTree = ""; }; @@ -112,7 +110,6 @@ 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */, 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */, 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */, - 001A7B492C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift */, 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */, 3E795C604248469D9AD8FA2E /* InputSourceManager.swift */, 4A8B9C7C2E3F5A2100D6E8F9 /* ErrorHandling.swift */, @@ -247,7 +244,6 @@ 001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */, 001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */, 001A7B412C2F3A1A00E5B4C8 /* KeyMonitor.swift in Sources */, - 001A7B482C2F3A1A00E5B4C8 /* KeyMonitor+DeferredSwitch.swift in Sources */, 001A7B432C2F3A1A00E5B4C8 /* Logger.swift in Sources */, 3B7B971675E44DF784A30E7D /* InputSourceManager.swift in Sources */, 4A8B9C7D2E3F5A2100D6E8F9 /* ErrorHandling.swift in Sources */, diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift index 5b474b4..effa935 100644 --- a/ModSwitchIME/ImeController+StateMonitoring.swift +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -255,54 +255,6 @@ extension ImeController { return nil } - func shouldResetBeforeReapplying( - _ resolvedInputSource: ResolvedInputSource, - currentIME: String, - fromUser: Bool - ) -> Bool { - guard fromUser, currentIME == resolvedInputSource.id else { - return false - } - return isKeyboardInputMode(resolvedInputSource.source) - } - - func resetInputContextBeforeSelecting(expectedIME: String) { - guard let resetSource = resetInputSourceForReapply(excluding: expectedIME) else { - Logger.warning("No reset input source available before reapplying IME: \(expectedIME)", category: .ime) - return - } - - let result = TISSelectInputSource(resetSource) - if result == noErr { - Thread.sleep(forTimeInterval: 0.03) - } else { - Logger.warning("Reset input source selection failed with code: \(result)", category: .ime) - } - } - - private func resetInputSourceForReapply(excluding targetIME: String) -> TISInputSource? { - let resetCandidates = ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] - var cache: [String: TISInputSource] = [:] - cacheQueue.sync { cache = inputSourceCache } - - return resetCandidates.compactMap { candidateID in - guard candidateID != targetIME, - let source = cache[candidateID], - isSelectableInputSource(source) else { - return nil - } - return source - }.first - } - - private func isKeyboardInputMode(_ source: TISInputSource) -> Bool { - guard let typeRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceType) else { - return false - } - let type = Unmanaged.fromOpaque(typeRef).takeUnretainedValue() as String - return type == (kTISTypeKeyboardInputMode as String) - } - private func isSelectableInputSource(_ source: TISInputSource) -> Bool { guard let selectableRef = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsSelectCapable) else { return false diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 8d4f128..023a668 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -146,16 +146,7 @@ final class ImeController: ErrorHandler, IMEControlling { // Execute IME switch do { - try selectInputSource( - resolvedTarget, - currentIME: currentIME, - resetBeforeSelecting: shouldResetBeforeReapplying( - resolvedTarget, - currentIME: currentIME, - fromUser: fromUser - ), - switchToken: switchToken - ) + try selectInputSource(resolvedTarget, currentIME: currentIME, switchToken: switchToken) } catch { handleError(ModSwitchIMEError.inputSourceNotFound(resolvedTarget.id)) // Refresh UI to show actual state on error @@ -198,7 +189,6 @@ final class ImeController: ErrorHandler, IMEControlling { private func selectInputSource( _ resolvedInputSource: ResolvedInputSource, currentIME: String, - resetBeforeSelecting: Bool, switchToken: UInt64 ) throws { Logger.debug("Switching IME: \(currentIME) -> \(resolvedInputSource.id)", category: .ime) @@ -207,7 +197,6 @@ final class ImeController: ErrorHandler, IMEControlling { expectedIME: resolvedInputSource.id, currentIME: currentIME, refreshed: resolvedInputSource.refreshed, - resetBeforeSelecting: resetBeforeSelecting, switchToken: switchToken ) } @@ -218,13 +207,8 @@ final class ImeController: ErrorHandler, IMEControlling { expectedIME: String, currentIME: String, refreshed: Bool, - resetBeforeSelecting: Bool, switchToken: UInt64 ) throws { - if resetBeforeSelecting { - resetInputContextBeforeSelecting(expectedIME: expectedIME) - } - var lastError: Error? for attempt in 0..<3 { guard isCurrentSwitchToken(switchToken) else { return } diff --git a/ModSwitchIME/KeyMonitor+DeferredSwitch.swift b/ModSwitchIME/KeyMonitor+DeferredSwitch.swift deleted file mode 100644 index 9218bd0..0000000 --- a/ModSwitchIME/KeyMonitor+DeferredSwitch.swift +++ /dev/null @@ -1,96 +0,0 @@ -import Foundation -import CoreGraphics - -extension KeyMonitor { - func scheduleModifierIMESwitch(_ targetIME: String) { - let generation = pendingModifierSwitchQueue.sync { - pendingModifierSwitchGeneration &+= 1 - let generation = pendingModifierSwitchGeneration - pendingModifierSwitch = PendingModifierSwitch(targetIME: targetIME, generation: generation) - return generation - } - - DispatchQueue.main.asyncAfter(deadline: .now() + modifierSwitchDelay) { [weak self] in - self?.performPendingModifierSwitch(generation: generation) - } - } - - func performPendingModifierSwitch(generation: UInt64) { - let targetIME = pendingModifierSwitchQueue.sync { () -> String? in - guard let pending = pendingModifierSwitch, pending.generation == generation else { - return nil - } - pendingModifierSwitch = nil - return pending.targetIME - } - - guard let targetIME = targetIME else { return } - imeController.switchToSpecificIME(targetIME) - } - - func cancelPendingModifierSwitch() { - pendingModifierSwitchQueue.sync { - pendingModifierSwitchGeneration &+= 1 - pendingModifierSwitch = nil - } - } -} - -#if DEBUG -extension KeyMonitor { - func getKeyPressTimestamps() -> [ModifierKey: CFAbsoluteTime] { - return stateQueue.sync { - var timestamps: [ModifierKey: CFAbsoluteTime] = [:] - for (key, state) in keyStates { - timestamps[key] = state.downTime - } - return timestamps - } - } - - func simulateFlagsChanged(keyCode: Int64, flags: CGEventFlags) { - guard let event = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(keyCode), keyDown: true) else { - Logger.error("Failed to create CGEvent for testing", category: .keyboard) - return - } - event.flags = flags - handleFlagsChanged(event: event) - flushPendingModifierSwitchForTesting() - } - - func flushPendingModifierSwitchForTesting() { - let pending = pendingModifierSwitchQueue.sync { () -> PendingModifierSwitch? in - guard let pending = pendingModifierSwitch else { return nil } - pendingModifierSwitchGeneration &+= 1 - pendingModifierSwitch = nil - return pending - } - - guard let pending = pending else { return } - imeController.switchToSpecificIME(pending.targetIME) - } - - func forceStateSync(flags: CGEventFlags) { - stateQueue.sync { - let keysToRemove = keyStates.keys.filter { !flags.contains($0.flagMask) } - for key in keysToRemove { - keyStates.removeValue(forKey: key) - } - - if keyStates.isEmpty { - lastPressedKey = nil - } - } - } - - func getModifierKeyStates() -> [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] { - return stateQueue.sync { - var states: [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] = [:] - for (key, state) in keyStates { - states[key] = (isDown: true, downTime: state.downTime) - } - return states - } - } -} -#endif diff --git a/ModSwitchIME/KeyMonitor.swift b/ModSwitchIME/KeyMonitor.swift index ea6b5a6..ed7a274 100644 --- a/ModSwitchIME/KeyMonitor.swift +++ b/ModSwitchIME/KeyMonitor.swift @@ -7,7 +7,7 @@ import Combine final class KeyMonitor { private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? - let imeController: IMEControlling + private let imeController: IMEControlling private let preferences: Preferences init(preferences: Preferences = Preferences.shared, imeController: IMEControlling = ImeController.shared) { @@ -24,7 +24,7 @@ final class KeyMonitor { var onError: ((ModSwitchIMEError) -> Void)? // Optimized state management using a single struct - struct ModifierKeyState { + private struct ModifierKeyState { var downTime: CFAbsoluteTime var isInMultiKeyPress: Bool = false var hadNonModifierPress: Bool = false @@ -32,9 +32,9 @@ final class KeyMonitor { // Single dictionary for all key states (faster than multiple collections) // Protected by serial queue for thread safety - let stateQueue = DispatchQueue(label: "com.modswitchime.keymonitor.state") - var keyStates: [ModifierKey: ModifierKeyState] = [:] - var lastPressedKey: ModifierKey? + private let stateQueue = DispatchQueue(label: "com.modswitchime.keymonitor.state") + private var keyStates: [ModifierKey: ModifierKeyState] = [:] + private var lastPressedKey: ModifierKey? private var nonModifierKeyPressed = false // Track if any non-modifier key is pressed // Idle timer related @@ -46,15 +46,6 @@ final class KeyMonitor { private var lastEventTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() private let eventTapHealthCheckInterval: TimeInterval = 5.0 private let eventTapInactivityThreshold: TimeInterval = 30.0 - struct PendingModifierSwitch { - let targetIME: String - let generation: UInt64 - } - var pendingModifierSwitch: PendingModifierSwitch? - var pendingModifierSwitchGeneration: UInt64 = 0 - let pendingModifierSwitchQueue = DispatchQueue(label: "com.modswitchime.keymonitor.pendingSwitch") - let modifierSwitchDelay: TimeInterval = 0.02 - // Debug var isIdleTimerRunning: Bool { return idleTimer != nil @@ -149,8 +140,6 @@ final class KeyMonitor { // Cancel retry timer cancelRetryTimer() - cancelPendingModifierSwitch() - // Cancel observations cancellables.removeAll() @@ -235,7 +224,7 @@ final class KeyMonitor { return Unmanaged.passUnretained(event) } - func handleFlagsChanged(event: CGEvent) { + private func handleFlagsChanged(event: CGEvent) { let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let flags = event.flags @@ -248,7 +237,7 @@ final class KeyMonitor { } if let targetIME = targetIME { - scheduleModifierIMESwitch(targetIME) + imeController.switchToSpecificIME(targetIME) } } @@ -640,6 +629,51 @@ final class KeyMonitor { // MARK: - Testing Support #if DEBUG + func getKeyPressTimestamps() -> [ModifierKey: CFAbsoluteTime] { + return stateQueue.sync { + var timestamps: [ModifierKey: CFAbsoluteTime] = [:] + for (key, state) in keyStates { + timestamps[key] = state.downTime + } + return timestamps + } + } + + func simulateFlagsChanged(keyCode: Int64, flags: CGEventFlags) { + guard let event = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(keyCode), keyDown: true) else { + Logger.error("Failed to create CGEvent for testing", category: .keyboard) + return + } + event.flags = flags + handleFlagsChanged(event: event) + } + + // Force sync state with current flags for testing + func forceStateSync(flags: CGEventFlags) { + stateQueue.sync { + let keysToRemove = keyStates.keys.filter { !flags.contains($0.flagMask) } + for key in keysToRemove { + keyStates.removeValue(forKey: key) + } + + if keyStates.isEmpty { + lastPressedKey = nil + } + } + } + + // Compatibility method for tests + func getModifierKeyStates() -> [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] { + return stateQueue.sync { + // Convert current key states to the expected format + var states: [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] = [:] + for (key, state) in keyStates { + states[key] = (isDown: true, downTime: state.downTime) + } + return states + } + } + private func verifyEventTapEnabled() -> Bool { guard eventTap != nil else { return false } // Check if event tap is enabled by querying its state From 5fda607d425a2f6b2507a1bc9c8311efddb6b2b0 Mon Sep 17 00:00:00 2001 From: nissy Date: Sat, 23 May 2026 12:44:42 +0900 Subject: [PATCH 09/12] bugfix window focus --- .../ImeController+StateMonitoring.swift | 4 + ModSwitchIME/ImeController.swift | 26 +- .../IMEAppSpecificEdgeCaseTests.swift | 446 ++++++++++++++++++ ModSwitchIMETests/IMESyncEdgeCaseTests.swift | 1 + 4 files changed, 475 insertions(+), 2 deletions(-) create mode 100644 ModSwitchIMETests/IMEAppSpecificEdgeCaseTests.swift diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift index effa935..c14f4cd 100644 --- a/ModSwitchIME/ImeController+StateMonitoring.swift +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -223,6 +223,10 @@ extension ImeController { notificationQueue.sync { lastNotifiedIME = imeId } } func debugSyncLastNotifiedIMEWithCurrentInputSource() { syncLastNotifiedIMEWithCurrentInputSource() } + func debugResetTestHooks() { + debugCurrentInputSourceProvider = nil + debugSelectInputSourceHandler = nil + } #endif } diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 023a668..3aba9c4 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -54,6 +54,11 @@ final class ImeController: ErrorHandler, IMEControlling { var lastNotifiedIME: String = "" let notificationQueue = DispatchQueue(label: "com.modswitchime.notification") + #if DEBUG + var debugCurrentInputSourceProvider: (() -> String)? + var debugSelectInputSourceHandler: ((String) -> OSStatus)? + #endif + struct ResolvedInputSource { let id: String let source: TISInputSource @@ -212,7 +217,7 @@ final class ImeController: ErrorHandler, IMEControlling { var lastError: Error? for attempt in 0..<3 { guard isCurrentSwitchToken(switchToken) else { return } - let result = TISSelectInputSource(source) + let result = selectTISInputSource(source, expectedIME: expectedIME) if result == noErr { performHybridIMESwitch(expectedIME: expectedIME, currentIME: currentIME, switchToken: switchToken) return @@ -246,6 +251,7 @@ final class ImeController: ErrorHandler, IMEControlling { // Schedule additional verification for edge cases self.scheduleAdditionalVerification(expectedIME: expectedIME, delay: 0.05, switchToken: switchToken) + self.scheduleAdditionalVerification(expectedIME: expectedIME, delay: 0.2, switchToken: switchToken) } else if actualIME == currentIME { // Not switched yet - schedule another check Logger.debug("IME not switched yet, scheduling verification", category: .ime) @@ -375,7 +381,7 @@ final class ImeController: ErrorHandler, IMEControlling { return } - let result = TISSelectInputSource(resolvedTarget.source) + let result = self.selectTISInputSource(resolvedTarget.source, expectedIME: resolvedTarget.id) if result == noErr { let ctx = resolvedTarget.refreshed ? "fresh source" : "cached source" Logger.debug("Retrying IME switch (\(ctx)) attempt \(retryCount): \(resolvedTarget.id)", category: .ime) @@ -416,8 +422,24 @@ final class ImeController: ErrorHandler, IMEControlling { return result } } + + private func selectTISInputSource(_ source: TISInputSource, expectedIME: String) -> OSStatus { + #if DEBUG + if let debugSelectInputSourceHandler { + return debugSelectInputSourceHandler(expectedIME) + } + #endif + + return TISSelectInputSource(source) + } private func getCurrentInputSourceSync() -> String { + #if DEBUG + if let debugCurrentInputSourceProvider { + return debugCurrentInputSourceProvider() + } + #endif + guard let currentSource = TISCopyCurrentKeyboardInputSource() else { return "Unknown" } diff --git a/ModSwitchIMETests/IMEAppSpecificEdgeCaseTests.swift b/ModSwitchIMETests/IMEAppSpecificEdgeCaseTests.swift new file mode 100644 index 0000000..c826443 --- /dev/null +++ b/ModSwitchIMETests/IMEAppSpecificEdgeCaseTests.swift @@ -0,0 +1,446 @@ +import XCTest +import Carbon +@testable import ModSwitchIME + +final class IMEAppSpecificEdgeCaseTests: XCTestCase { + private var notificationObservers: [NSObjectProtocol] = [] + + override func tearDown() { + notificationObservers.forEach { NotificationCenter.default.removeObserver($0) } + notificationObservers.removeAll() + super.tearDown() + } + + private func resolvedInputSourceIDs(for controller: ImeController) -> [String] { + let currentIME = controller.getCurrentInputSource() + var cacheIDs: [String] = [] + controller.cacheQueue.sync { + cacheIDs = Array(controller.inputSourceCache.keys).sorted() + } + + let preferredIDs = [ + "com.apple.keylayout.ABC", + "com.apple.keylayout.US", + currentIME + ] + cacheIDs + + var seen = Set() + var resolvedIDs: [String] = [] + + for id in preferredIDs where !id.isEmpty && id != "Unknown" { + guard let resolved = controller.resolveInputSourceFromCacheOrRefresh(id), + !seen.contains(resolved.id) else { + continue + } + seen.insert(resolved.id) + resolvedIDs.append(resolved.id) + } + + return resolvedIDs + } + + func testAppSpecificIMEOverrideAfterConfirmationTriggersRetry() throws { + let controller = ImeController.createForTesting() + defer { controller.debugResetTestHooks() } + + guard let targetIME = resolvedInputSourceIDs(for: controller).first else { + throw XCTSkip("No selectable input source is available") + } + + let appSpecificIME = "com.apple.inputmethod.AppSpecific.Internal" + let lock = NSLock() + var currentReads = [appSpecificIME, targetIME, appSpecificIME, targetIME, targetIME] + var selectedIMEs: [String] = [] + + controller.debugCurrentInputSourceProvider = { + lock.lock() + defer { lock.unlock() } + if currentReads.count > 1 { + return currentReads.removeFirst() + } + return currentReads.first ?? targetIME + } + controller.debugSelectInputSourceHandler = { imeId in + lock.lock() + selectedIMEs.append(imeId) + lock.unlock() + return noErr + } + + controller.switchToSpecificIME(targetIME, fromUser: true) + + let waitExpectation = expectation(description: "wait for app-specific IME override retry") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: 1.0) + + lock.lock() + let targetSelectCount = selectedIMEs.filter { $0 == targetIME }.count + lock.unlock() + + XCTAssertGreaterThanOrEqual( + targetSelectCount, + 2, + "A confirmed switch that is reverted by an app-specific IME should be retried" + ) + } + + func testPendingInternalRetryDoesNotOverrideNewUserSwitch() throws { + let controller = ImeController.createForTesting() + defer { controller.debugResetTestHooks() } + + let ids = resolvedInputSourceIDs(for: controller) + guard ids.count >= 2 else { + throw XCTSkip("At least two selectable input sources are required") + } + + let firstUserTarget = ids[0] + let secondUserTarget = ids[1] + let appSpecificIME = "com.apple.inputmethod.AppSpecific.Internal" + let lock = NSLock() + var simulatedCurrentIME = appSpecificIME + var selectedIMEs: [String] = [] + + controller.debugCurrentInputSourceProvider = { + lock.lock() + defer { lock.unlock() } + return simulatedCurrentIME + } + controller.debugSelectInputSourceHandler = { imeId in + lock.lock() + selectedIMEs.append(imeId) + simulatedCurrentIME = imeId == secondUserTarget ? secondUserTarget : appSpecificIME + lock.unlock() + return noErr + } + + controller.switchToSpecificIME(firstUserTarget, fromUser: true) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + controller.switchToSpecificIME(secondUserTarget, fromUser: true) + } + + let waitExpectation = expectation(description: "wait for stale retry cancellation") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: 1.0) + + lock.lock() + let firstTargetSelectCount = selectedIMEs.filter { $0 == firstUserTarget }.count + let secondTargetSelectCount = selectedIMEs.filter { $0 == secondUserTarget }.count + lock.unlock() + + XCTAssertEqual(firstTargetSelectCount, 1, "Stale retry should not re-select previous target") + XCTAssertEqual(secondTargetSelectCount, 1, "The newer user switch should be selected once") + } + + func testNoErrWithoutActualIMEChangePostsRefreshInsteadOfSwitchNotification() throws { + let controller = ImeController.createForTesting() + defer { controller.debugResetTestHooks() } + + guard let targetIME = resolvedInputSourceIDs(for: controller).first else { + throw XCTSkip("No selectable input source is available") + } + + let unchangedIME = "com.apple.inputmethod.AppSpecific.Internal" + let refreshExpectation = expectation(description: "UI refresh after unchanged IME") + let lock = NSLock() + var selectCount = 0 + var switchNotificationCount = 0 + + controller.debugCurrentInputSourceProvider = { unchangedIME } + controller.debugSelectInputSourceHandler = { _ in + lock.lock() + selectCount += 1 + lock.unlock() + return noErr + } + + let switchObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("ModSwitchIME.didSwitchIME"), + object: nil, + queue: .main + ) { _ in + lock.lock() + switchNotificationCount += 1 + lock.unlock() + } + notificationObservers.append(switchObserver) + + let refreshObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("ModSwitchIME.shouldRefreshUI"), + object: nil, + queue: .main + ) { _ in + refreshExpectation.fulfill() + } + notificationObservers.append(refreshObserver) + + controller.switchToSpecificIME(targetIME, fromUser: true) + + wait(for: [refreshExpectation], timeout: 1.0) + + lock.lock() + let finalSelectCount = selectCount + let finalSwitchNotificationCount = switchNotificationCount + lock.unlock() + + XCTAssertGreaterThanOrEqual(finalSelectCount, 3, "Unchanged IME should retry before refresh") + XCTAssertEqual(finalSwitchNotificationCount, 0, "noErr alone must not notify an IME switch") + } + + func testAppFocusMismatchReappliesLastConfirmedIME() throws { + let controller = ImeController.createForTesting() + defer { controller.debugResetTestHooks() } + + guard let targetIME = resolvedInputSourceIDs(for: controller).first else { + throw XCTSkip("No selectable input source is available") + } + + let appSpecificIME = "com.apple.inputmethod.AppSpecific.Internal" + let lock = NSLock() + var simulatedCurrentIME = appSpecificIME + var selectedIMEs: [String] = [] + + controller.debugCurrentInputSourceProvider = { + lock.lock() + defer { lock.unlock() } + return simulatedCurrentIME + } + controller.debugSelectInputSourceHandler = { imeId in + lock.lock() + selectedIMEs.append(imeId) + simulatedCurrentIME = imeId + lock.unlock() + return noErr + } + + controller.setLastSwitchedIME(targetIME) + controller.verifyIMEStateAfterAppSwitch() + + let waitExpectation = expectation(description: "wait for focus mismatch reapply") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: 1.0) + + lock.lock() + let didReapplyTarget = selectedIMEs.contains(targetIME) + lock.unlock() + + XCTAssertTrue(didReapplyTarget, "App focus mismatch should reapply last confirmed IME") + } + + func testDelayedAppSpecificIMEOverrideAfterAdditionalVerificationTriggersRetry() throws { + let controller = ImeController.createForTesting() + defer { controller.debugResetTestHooks() } + + guard let targetIME = resolvedInputSourceIDs(for: controller).first else { + throw XCTSkip("No selectable input source is available") + } + + let appSpecificIME = "com.apple.inputmethod.AppSpecific.Internal" + let lock = NSLock() + var simulatedCurrentIME = appSpecificIME + var selectedIMEs: [String] = [] + + controller.debugCurrentInputSourceProvider = { + lock.lock() + defer { lock.unlock() } + return simulatedCurrentIME + } + controller.debugSelectInputSourceHandler = { imeId in + lock.lock() + selectedIMEs.append(imeId) + simulatedCurrentIME = imeId + lock.unlock() + return noErr + } + + controller.switchToSpecificIME(targetIME, fromUser: true) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + lock.lock() + simulatedCurrentIME = appSpecificIME + lock.unlock() + } + + let waitExpectation = expectation(description: "wait for delayed app-specific IME override") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: 1.0) + + lock.lock() + let targetSelectCount = selectedIMEs.filter { $0 == targetIME }.count + lock.unlock() + + XCTAssertGreaterThanOrEqual(targetSelectCount, 2, "Delayed override should be retried") + } + + func testRepeatedAppSpecificIMEOverrideEndsWithUIRefresh() throws { + let controller = ImeController.createForTesting() + defer { controller.debugResetTestHooks() } + + guard let targetIME = resolvedInputSourceIDs(for: controller).first else { + throw XCTSkip("No selectable input source is available") + } + + let appSpecificIME = "com.apple.inputmethod.AppSpecific.Internal" + let refreshExpectation = expectation(description: "refresh after repeated app-specific overrides") + let lock = NSLock() + var selectedIMEs: [String] = [] + + controller.debugCurrentInputSourceProvider = { appSpecificIME } + controller.debugSelectInputSourceHandler = { imeId in + lock.lock() + selectedIMEs.append(imeId) + lock.unlock() + return noErr + } + + let refreshObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("ModSwitchIME.shouldRefreshUI"), + object: nil, + queue: .main + ) { _ in + refreshExpectation.fulfill() + } + notificationObservers.append(refreshObserver) + + controller.switchToSpecificIME(targetIME, fromUser: true) + + wait(for: [refreshExpectation], timeout: 1.0) + + lock.lock() + let targetSelectCount = selectedIMEs.filter { $0 == targetIME }.count + lock.unlock() + + XCTAssertGreaterThanOrEqual(targetSelectCount, 3, "Repeated overrides should retry before refresh") + XCTAssertLessThanOrEqual(targetSelectCount, 4, "Repeated overrides should stop after retry limit") + } + + func testIMEChangeNotificationBeforeFocusVerificationStillReappliesExpectedIME() throws { + let controller = ImeController.createForTesting() + defer { controller.debugResetTestHooks() } + + guard let targetIME = resolvedInputSourceIDs(for: controller).first else { + throw XCTSkip("No selectable input source is available") + } + + let appSpecificIME = "com.apple.inputmethod.AppSpecific.Internal" + let lock = NSLock() + var selectedIMEs: [String] = [] + + controller.debugCurrentInputSourceProvider = { appSpecificIME } + controller.debugSelectInputSourceHandler = { imeId in + lock.lock() + selectedIMEs.append(imeId) + lock.unlock() + return noErr + } + controller.setLastSwitchedIME(targetIME) + + controller.inputSourcesChanged(Notification( + name: NSNotification.Name("com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged") + )) + controller.verifyIMEStateAfterAppSwitch() + + let waitExpectation = expectation(description: "wait for notification-before-focus reapply") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: 1.0) + + lock.lock() + let didReapplyTarget = selectedIMEs.contains(targetIME) + lock.unlock() + + XCTAssertTrue(didReapplyTarget, "IME notification before focus must not block reapply") + } + + func testFocusVerificationBeforeIMEChangeNotificationStillReappliesExpectedIME() throws { + let controller = ImeController.createForTesting() + defer { controller.debugResetTestHooks() } + + guard let targetIME = resolvedInputSourceIDs(for: controller).first else { + throw XCTSkip("No selectable input source is available") + } + + let appSpecificIME = "com.apple.inputmethod.AppSpecific.Internal" + let lock = NSLock() + var selectedIMEs: [String] = [] + + controller.debugCurrentInputSourceProvider = { appSpecificIME } + controller.debugSelectInputSourceHandler = { imeId in + lock.lock() + selectedIMEs.append(imeId) + lock.unlock() + return noErr + } + controller.setLastSwitchedIME(targetIME) + + controller.verifyIMEStateAfterAppSwitch() + controller.inputSourcesChanged(Notification( + name: NSNotification.Name("com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged") + )) + + let waitExpectation = expectation(description: "wait for focus-before-notification reapply") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: 1.0) + + lock.lock() + let didReapplyTarget = selectedIMEs.contains(targetIME) + lock.unlock() + + XCTAssertTrue(didReapplyTarget, "Focus verification before IME notification must reapply") + } + + func testMenuBarPeriodicReconciliationCatchesSilentAppSpecificIMEOverride() throws { + let controller = ImeController.shared + let ids = resolvedInputSourceIDs(for: controller) + guard let targetIME = ids.first else { + throw XCTSkip("No selectable input source is available") + } + + defer { controller.debugResetTestHooks() } + + let appSpecificIME = "com.apple.inputmethod.AppSpecific.Internal" + let lock = NSLock() + var simulatedCurrentIME = targetIME + controller.debugCurrentInputSourceProvider = { + lock.lock() + defer { lock.unlock() } + return simulatedCurrentIME + } + + let app = MenuBarApp() + let initExpectation = expectation(description: "wait for menu bar initialization") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + initExpectation.fulfill() + } + wait(for: [initExpectation], timeout: 1.0) + + MenuBarApp.debugResetIconUpdateCount() + lock.lock() + simulatedCurrentIME = appSpecificIME + lock.unlock() + + let waitExpectation = expectation(description: "wait for periodic reconciliation") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.25) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: 2.0) + + XCTAssertGreaterThanOrEqual( + MenuBarApp.debugIconUpdateCount, + 1, + "Periodic reconciliation should update the icon after a silent app-specific IME override" + ) + _ = app + } +} diff --git a/ModSwitchIMETests/IMESyncEdgeCaseTests.swift b/ModSwitchIMETests/IMESyncEdgeCaseTests.swift index 7909119..e4cad1c 100644 --- a/ModSwitchIMETests/IMESyncEdgeCaseTests.swift +++ b/ModSwitchIMETests/IMESyncEdgeCaseTests.swift @@ -1,4 +1,5 @@ import XCTest +import Carbon @testable import ModSwitchIME final class IMESyncEdgeCaseTests: XCTestCase { From 080606883b64eb4d15857217c8f02bdf06c851a7 Mon Sep 17 00:00:00 2001 From: nissy Date: Sat, 23 May 2026 22:35:03 +0900 Subject: [PATCH 10/12] bugfix window focus --- ModSwitchIME.xcodeproj/project.pbxproj | 16 + ModSwitchIME/DiagnosticLogger.swift | 219 ++++++++++++ ModSwitchIME/ImeController+Diagnostics.swift | 225 ++++++++++++ .../ImeController+StateMonitoring.swift | 29 ++ ModSwitchIME/ImeController+SwitchState.swift | 139 ++++++++ .../ImeController+SwitchVerification.swift | 244 +++++++++++++ ModSwitchIME/ImeController.swift | 330 +++--------------- ModSwitchIME/MenuBarApp.swift | 39 +++ ModSwitchIME/Preferences.swift | 15 +- ModSwitchIMETests/DiagnosticLoggerTests.swift | 104 ++++++ 10 files changed, 1073 insertions(+), 287 deletions(-) create mode 100644 ModSwitchIME/DiagnosticLogger.swift create mode 100644 ModSwitchIME/ImeController+Diagnostics.swift create mode 100644 ModSwitchIME/ImeController+SwitchState.swift create mode 100644 ModSwitchIME/ImeController+SwitchVerification.swift create mode 100644 ModSwitchIMETests/DiagnosticLoggerTests.swift diff --git a/ModSwitchIME.xcodeproj/project.pbxproj b/ModSwitchIME.xcodeproj/project.pbxproj index 8b59ff5..df963ac 100644 --- a/ModSwitchIME.xcodeproj/project.pbxproj +++ b/ModSwitchIME.xcodeproj/project.pbxproj @@ -14,6 +14,10 @@ 001A7B362C2F3A1A00E5B4C8 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */; }; 001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */; }; 001A7B462C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */; }; + A1D1A0012F00000100000001 /* ImeController+Diagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1A0002F00000100000001 /* ImeController+Diagnostics.swift */; }; + A1D1A0032F00000100000001 /* DiagnosticLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1A0022F00000100000001 /* DiagnosticLogger.swift */; }; + A1D1A0052F00000100000001 /* ImeController+SwitchVerification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1A0042F00000100000001 /* ImeController+SwitchVerification.swift */; }; + A1D1A0072F00000100000001 /* ImeController+SwitchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D1A0062F00000100000001 /* ImeController+SwitchState.swift */; }; 001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */; }; 001A7B3A2C2F3A1A00E5B4C8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */; }; 001A7B3D2C2F3A1A00E5B4C8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */; }; @@ -43,6 +47,10 @@ 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourcePickerViews.swift; sourceTree = ""; }; 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+StateMonitoring.swift"; sourceTree = ""; }; + A1D1A0002F00000100000001 /* ImeController+Diagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+Diagnostics.swift"; sourceTree = ""; }; + A1D1A0022F00000100000001 /* DiagnosticLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLogger.swift; sourceTree = ""; }; + A1D1A0042F00000100000001 /* ImeController+SwitchVerification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+SwitchVerification.swift"; sourceTree = ""; }; + A1D1A0062F00000100000001 /* ImeController+SwitchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+SwitchState.swift"; sourceTree = ""; }; 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModSwitchIMEError.swift; sourceTree = ""; }; 001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -105,12 +113,16 @@ 001A7B2F2C2F3A1A00E5B4C8 /* MenuBarApp.swift */, 001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */, 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */, + A1D1A0002F00000100000001 /* ImeController+Diagnostics.swift */, + A1D1A0042F00000100000001 /* ImeController+SwitchVerification.swift */, + A1D1A0062F00000100000001 /* ImeController+SwitchState.swift */, 001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */, 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */, 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */, 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */, 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */, 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */, + A1D1A0022F00000100000001 /* DiagnosticLogger.swift */, 3E795C604248469D9AD8FA2E /* InputSourceManager.swift */, 4A8B9C7C2E3F5A2100D6E8F9 /* ErrorHandling.swift */, 5B9C8D3E2F4A6B2300E7F8A9 /* AccessibilityManager.swift */, @@ -239,12 +251,16 @@ 001A7B302C2F3A1A00E5B4C8 /* MenuBarApp.swift in Sources */, 001A7B322C2F3A1A00E5B4C8 /* ImeController.swift in Sources */, 001A7B462C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift in Sources */, + A1D1A0012F00000100000001 /* ImeController+Diagnostics.swift in Sources */, + A1D1A0052F00000100000001 /* ImeController+SwitchVerification.swift in Sources */, + A1D1A0072F00000100000001 /* ImeController+SwitchState.swift in Sources */, 001A7B342C2F3A1A00E5B4C8 /* Preferences.swift in Sources */, 001A7B362C2F3A1A00E5B4C8 /* PreferencesView.swift in Sources */, 001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */, 001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */, 001A7B412C2F3A1A00E5B4C8 /* KeyMonitor.swift in Sources */, 001A7B432C2F3A1A00E5B4C8 /* Logger.swift in Sources */, + A1D1A0032F00000100000001 /* DiagnosticLogger.swift in Sources */, 3B7B971675E44DF784A30E7D /* InputSourceManager.swift in Sources */, 4A8B9C7D2E3F5A2100D6E8F9 /* ErrorHandling.swift in Sources */, 5B9C8D3F2F4A6B2300E7F8AA /* AccessibilityManager.swift in Sources */, diff --git a/ModSwitchIME/DiagnosticLogger.swift b/ModSwitchIME/DiagnosticLogger.swift new file mode 100644 index 0000000..14b3cc8 --- /dev/null +++ b/ModSwitchIME/DiagnosticLogger.swift @@ -0,0 +1,219 @@ +import Cocoa +import Foundation + +final class DiagnosticLogger { + static let shared = DiagnosticLogger() + + enum Event: String { + case loggingEnabled + case loggingDisabled + case switchRequested + case switchSkipped + case inputSourceResolved + case inputSourceResolutionFailed + case inputSourceStateSnapshot + case selectAttempt + case switchStateCheck + case switchConfirmed + case switchPending + case unexpectedIME + case additionalVerificationMismatch + case retryScheduled + case retrySelectAttempt + case retryLimitReached + case notificationPosted + case notificationSkipped + case uiRefreshRequested + case inputSourceNotification + case appActivated + case focusVerificationMatched + case focusVerificationMismatch + case iconUpdated + case reconciliationMismatch + case systemSleep + case systemWake + } + + private let queue = DispatchQueue(label: "com.modswitchime.diagnostic-log") + private let maxFileSize = 1_000_000 + private let maxFieldLength = 300 + private let allowedFieldKeys: Set = [ + "actualIME", + "attempt", + "bundleID", + "category", + "currentIME", + "delay", + "displayedIME", + "enabled", + "enabledCount", + "expectedIME", + "inEnabledList", + "imeId", + "isRetry", + "notification", + "osVersion", + "parentEnabled", + "parentIME", + "parentInEnabledList", + "phase", + "previousIME", + "reason", + "refreshed", + "resolvedIME", + "resultCode", + "retryCount", + "selectCapable", + "source", + "targetIME" + ] + private var enabled: Bool + private let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + #if DEBUG + private var debugLogFileURL: URL? + #endif + + private init() { + enabled = UserDefaults.standard.bool(forKey: "diagnosticLoggingEnabled") + } + + var isEnabled: Bool { + queue.sync { enabled } + } + + var logFileURL: URL { + #if DEBUG + if let debugLogFileURL { + return debugLogFileURL + } + #endif + + let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + return libraryURL + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent("ModSwitchIME", isDirectory: true) + .appendingPathComponent("diagnostic.log") + } + + var rotatedLogFileURL: URL { + logFileURL.deletingLastPathComponent().appendingPathComponent("diagnostic.log.1") + } + + func setEnabled(_ enabled: Bool) { + queue.async { + let changed = self.enabled != enabled + self.enabled = enabled + + guard changed else { return } + + if enabled { + self.write(.loggingEnabled, fields: [:], force: true) + } else { + self.write(.loggingDisabled, fields: [:], force: true) + } + } + } + + func record(_ event: Event, fields: [String: String] = [:]) { + queue.async { + self.write(event, fields: fields, force: false) + } + } + + func revealInFinder() { + let url = logFileURL + if FileManager.default.fileExists(atPath: url.path) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } else { + NSWorkspace.shared.open(url.deletingLastPathComponent()) + } + } + + private func write(_ event: Event, fields: [String: String], force: Bool) { + guard force || enabled else { return } + + var record = sanitizedFields(fields) + record["timestamp"] = dateFormatter.string(from: Date()) + record["event"] = event.rawValue + + do { + let data = try JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) + var line = data + line.append(0x0A) + + try ensureLogDirectory() + try rotateIfNeeded(addingBytes: line.count) + try append(line) + } catch { + Logger.warning("Failed to write diagnostic log: \(error.localizedDescription)", category: .main) + } + } + + private func ensureLogDirectory() throws { + try FileManager.default.createDirectory( + at: logFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + } + + private func rotateIfNeeded(addingBytes bytes: Int) throws { + let fileManager = FileManager.default + guard fileManager.fileExists(atPath: logFileURL.path) else { return } + + let attributes = try fileManager.attributesOfItem(atPath: logFileURL.path) + let currentSize = attributes[.size] as? NSNumber + guard (currentSize?.intValue ?? 0) + bytes > maxFileSize else { return } + + if fileManager.fileExists(atPath: rotatedLogFileURL.path) { + try fileManager.removeItem(at: rotatedLogFileURL) + } + try fileManager.moveItem(at: logFileURL, to: rotatedLogFileURL) + } + + private func append(_ data: Data) throws { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: logFileURL.path) { + _ = fileManager.createFile(atPath: logFileURL.path, contents: nil) + } + + let handle = try FileHandle(forWritingTo: logFileURL) + defer { + try? handle.close() + } + try handle.seekToEnd() + try handle.write(contentsOf: data) + } + + private func sanitizedFields(_ fields: [String: String]) -> [String: String] { + var sanitized: [String: String] = [:] + for (key, value) in fields where allowedFieldKeys.contains(key) { + sanitized[key] = String(value.prefix(maxFieldLength)) + } + return sanitized + } + + #if DEBUG + func debugSetLogFileURL(_ url: URL?) { + queue.sync { + debugLogFileURL = url + } + } + + func debugReset(enabled: Bool = false) { + queue.sync { + self.enabled = enabled + self.debugLogFileURL = nil + } + } + + func debugFlush() { + queue.sync {} + } + #endif +} diff --git a/ModSwitchIME/ImeController+Diagnostics.swift b/ModSwitchIME/ImeController+Diagnostics.swift new file mode 100644 index 0000000..5a8805f --- /dev/null +++ b/ModSwitchIME/ImeController+Diagnostics.swift @@ -0,0 +1,225 @@ +import Foundation +import Carbon +import Cocoa + +extension ImeController { + func recordSwitchRequestedDiagnostic(targetIME: String, fromUser: Bool) { + DiagnosticLogger.shared.record(.switchRequested, fields: [ + "targetIME": targetIME, + "source": fromUser ? "user" : "internal", + "osVersion": ProcessInfo.processInfo.operatingSystemVersionString, + "bundleID": NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "unknown" + ]) + } + + func recordInputSourceResolutionFailedDiagnostic(targetIME: String, reason: String) { + DiagnosticLogger.shared.record(.inputSourceResolutionFailed, fields: [ + "targetIME": targetIME, + "reason": reason + ]) + } + + func recordInputSourceResolvedDiagnostic(requestedIME: String, resolved: ResolvedInputSource) { + DiagnosticLogger.shared.record(.inputSourceResolved, fields: [ + "targetIME": requestedIME, + "resolvedIME": resolved.id, + "refreshed": String(resolved.refreshed) + ]) + recordInputSourceStateSnapshot( + sourceID: resolved.id, + source: resolved.source, + phase: "resolved" + ) + } + + func recordSwitchSkippedDiagnostic(targetIME: String, currentIME: String, fromUser: Bool) { + DiagnosticLogger.shared.record(.switchSkipped, fields: [ + "targetIME": targetIME, + "currentIME": currentIME, + "source": fromUser ? "user" : "internal" + ]) + } + + func recordSelectAttemptDiagnostic( + targetIME: String, + currentIME: String, + attempt: Int, + result: OSStatus + ) { + DiagnosticLogger.shared.record(.selectAttempt, fields: [ + "targetIME": targetIME, + "currentIME": currentIME, + "attempt": String(attempt), + "resultCode": String(result) + ]) + } + + func recordSwitchStateCheckDiagnostic(expectedIME: String, delay: TimeInterval, switchToken: UInt64) { + guard DiagnosticLogger.shared.isEnabled else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } + + DiagnosticLogger.shared.record(.switchStateCheck, fields: [ + "expectedIME": expectedIME, + "actualIME": self.getCurrentInputSource(), + "delay": String(delay), + "bundleID": NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "unknown" + ]) + } + } + + func recordSwitchConfirmedDiagnostic( + expectedIME: String, + actualIME: String, + previousIME: String, + retryCount: Int? = nil + ) { + var fields = [ + "expectedIME": expectedIME, + "actualIME": actualIME, + "previousIME": previousIME + ] + if let retryCount { + fields["retryCount"] = String(retryCount) + } + DiagnosticLogger.shared.record(.switchConfirmed, fields: fields) + } + + func recordSwitchPendingDiagnostic( + expectedIME: String, + actualIME: String, + previousIME: String, + retryCount: Int + ) { + DiagnosticLogger.shared.record(.switchPending, fields: [ + "expectedIME": expectedIME, + "actualIME": actualIME, + "previousIME": previousIME, + "retryCount": String(retryCount) + ]) + } + + func recordUnexpectedIMEDiagnostic( + expectedIME: String, + actualIME: String, + previousIME: String, + retryCount: Int + ) { + DiagnosticLogger.shared.record(.unexpectedIME, fields: [ + "expectedIME": expectedIME, + "actualIME": actualIME, + "previousIME": previousIME, + "retryCount": String(retryCount) + ]) + } + + func recordAdditionalVerificationMismatchDiagnostic( + expectedIME: String, + actualIME: String, + delay: TimeInterval + ) { + DiagnosticLogger.shared.record(.additionalVerificationMismatch, fields: [ + "expectedIME": expectedIME, + "actualIME": actualIME, + "delay": String(delay) + ]) + } + + func recordRetryLimitReachedDiagnostic( + targetIME: String, + currentIME: String, + retryCount: Int, + phase: String + ) { + DiagnosticLogger.shared.record(.retryLimitReached, fields: [ + "targetIME": targetIME, + "currentIME": currentIME, + "retryCount": String(retryCount), + "phase": phase + ]) + } + + func recordRetryScheduledDiagnostic(targetIME: String, currentIME: String, retryCount: Int) { + DiagnosticLogger.shared.record(.retryScheduled, fields: [ + "targetIME": targetIME, + "currentIME": currentIME, + "retryCount": String(retryCount) + ]) + } + + func recordRetrySelectAttemptDiagnostic( + targetIME: String, + currentIME: String, + retryCount: Int, + result: OSStatus + ) { + DiagnosticLogger.shared.record(.retrySelectAttempt, fields: [ + "targetIME": targetIME, + "currentIME": currentIME, + "retryCount": String(retryCount), + "resultCode": String(result) + ]) + } + + private func recordInputSourceStateSnapshot(sourceID: String, source: TISInputSource, phase: String) { + guard DiagnosticLogger.shared.isEnabled else { return } + + let enabledIDs = enabledInputSourceIDs() + let parentID = InputSourceManager.getParentIMEId(sourceID) ?? "" + DiagnosticLogger.shared.record(.inputSourceStateSnapshot, fields: [ + "targetIME": sourceID, + "phase": phase, + "selectCapable": inputSourceBooleanProperty(source, kTISPropertyInputSourceIsSelectCapable), + "enabled": inputSourceBooleanProperty(source, kTISPropertyInputSourceIsEnabled), + "category": inputSourceStringProperty(source, kTISPropertyInputSourceCategory), + "inEnabledList": String(enabledIDs.contains(sourceID)), + "enabledCount": String(enabledIDs.count), + "parentIME": parentID, + "parentInEnabledList": parentID.isEmpty ? "" : String(enabledIDs.contains(parentID)), + "parentEnabled": parentID.isEmpty ? "" : String(inputSourceEnabledState(for: parentID)) + ]) + } + + private func enabledInputSourceIDs() -> Set { + guard let list = TISCreateInputSourceList(nil, false)?.takeRetainedValue() as? [TISInputSource] else { + return [] + } + + return Set(list.compactMap { source in + inputSourceStringProperty(source, kTISPropertyInputSourceID).nilIfEmpty + }) + } + + private func inputSourceEnabledState(for sourceID: String) -> Bool { + let filter = [kTISPropertyInputSourceID as String: sourceID] as CFDictionary + guard let list = TISCreateInputSourceList(filter, true)?.takeRetainedValue() as? [TISInputSource], + let source = list.first else { + return false + } + + return inputSourceBooleanProperty(source, kTISPropertyInputSourceIsEnabled) == "true" + } + + private func inputSourceStringProperty(_ source: TISInputSource, _ property: CFString) -> String { + guard let value = TISGetInputSourceProperty(source, property) else { + return "" + } + return Unmanaged.fromOpaque(value).takeUnretainedValue() as String + } + + private func inputSourceBooleanProperty(_ source: TISInputSource, _ property: CFString) -> String { + guard let value = TISGetInputSourceProperty(source, property) else { + return "" + } + let boolean = Unmanaged.fromOpaque(value).takeUnretainedValue() + return String(CFBooleanGetValue(boolean)) + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift index c14f4cd..ace4133 100644 --- a/ModSwitchIME/ImeController+StateMonitoring.swift +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -100,16 +100,22 @@ extension ImeController { @objc func inputSourcesChanged(_ notification: Notification) { Logger.debug("Input sources changed, refreshing cache", category: .ime) + DiagnosticLogger.shared.record(.inputSourceNotification, fields: [ + "notification": notification.name.rawValue, + "currentIME": getCurrentInputSource() + ]) syncLastNotifiedIMEWithCurrentInputSource() refreshInputSourceCache() } @objc func systemWillSleep(_ notification: Notification) { Logger.info("System will sleep - preparing IME cache", category: .ime) + DiagnosticLogger.shared.record(.systemSleep) } @objc func systemDidWake(_ notification: Notification) { Logger.info("System did wake - refreshing IME cache", category: .ime) + DiagnosticLogger.shared.record(.systemWake) DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.refreshInputSourceCache() @@ -135,6 +141,9 @@ extension ImeController { let appName = app.localizedName ?? "Unknown" Logger.debug("Application activated: \(appName)", category: .ime) + DiagnosticLogger.shared.record(.appActivated, fields: [ + "bundleID": app.bundleIdentifier ?? "unknown" + ]) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.verifyIMEStateAfterAppSwitch() @@ -150,6 +159,10 @@ extension ImeController { "IME state mismatch after app switch: expected=\(expected), actual=\(actualIME)", category: .ime ) + DiagnosticLogger.shared.record(.focusVerificationMismatch, fields: [ + "expectedIME": expected, + "actualIME": actualIME + ]) refreshInputSourceCache() @@ -157,6 +170,10 @@ extension ImeController { switchToSpecificIME(expected, fromUser: false) } else { Logger.debug("IME state verified after app switch: \(actualIME)", category: .ime) + DiagnosticLogger.shared.record(.focusVerificationMatched, fields: [ + "actualIME": actualIME, + "expectedIME": expectedIME ?? "" + ]) } } @@ -187,8 +204,17 @@ extension ImeController { guard shouldNotify else { Logger.debug("Skipping duplicate notification for: \(imeId)", category: .ime) + DiagnosticLogger.shared.record(.notificationSkipped, fields: [ + "imeId": imeId, + "reason": "duplicate" + ]) return } + + DiagnosticLogger.shared.record(.notificationPosted, fields: [ + "imeId": imeId, + "isRetry": String(isRetry) + ]) DispatchQueue.main.async { NotificationCenter.default.post( @@ -200,6 +226,9 @@ extension ImeController { } func postUIRefreshNotification() { + DiagnosticLogger.shared.record(.uiRefreshRequested, fields: [ + "currentIME": getCurrentInputSource() + ]) syncLastNotifiedIMEWithCurrentInputSource() DispatchQueue.main.async { NotificationCenter.default.post( diff --git a/ModSwitchIME/ImeController+SwitchState.swift b/ModSwitchIME/ImeController+SwitchState.swift new file mode 100644 index 0000000..a343029 --- /dev/null +++ b/ModSwitchIME/ImeController+SwitchState.swift @@ -0,0 +1,139 @@ +import Foundation + +extension ImeController { + func availableInputSourceIDs(from candidateIDs: [String]) -> [String] { + var available: [String] = [] + cacheQueue.sync { + available = candidateIDs.filter { inputSourceCache[$0] != nil } + } + return available + } + + func scheduleAsciiFallbackIfNeeded( + primarySource: String, + availableSources: [String], + fromUser: Bool, + fallbackToken: UInt64 + ) { + guard availableSources.count > 1 else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + asciiFallbackDelay) { [weak self] in + self?.applyAsciiFallbackIfNeeded( + primarySource: primarySource, + availableSources: availableSources, + fromUser: fromUser, + fallbackToken: fallbackToken + ) + } + } + + func applyAsciiFallbackIfNeeded( + primarySource: String, + availableSources: [String], + fromUser: Bool, + fallbackToken: UInt64 + ) { + guard isCurrentAsciiFallbackToken(fallbackToken) else { return } + + let actualIME = getCurrentInputSource() + guard actualIME != primarySource, !availableSources.contains(actualIME) else { + return + } + + guard let fallbackSource = availableSources.first(where: { $0 != primarySource }) else { + return + } + + Logger.warning( + "ASCII switch did not apply, trying fallback: \(primarySource) -> \(fallbackSource)", + category: .ime + ) + switchToSpecificIME(fallbackSource, fromUser: fromUser) + } + + func createAsciiFallbackToken() -> UInt64 { + return asciiFallbackQueue.sync { + asciiFallbackGeneration &+= 1 + return asciiFallbackGeneration + } + } + + func invalidateAsciiFallbackToken() { + asciiFallbackQueue.sync { + asciiFallbackGeneration &+= 1 + } + } + + func isCurrentAsciiFallbackToken(_ token: UInt64) -> Bool { + return asciiFallbackQueue.sync { + asciiFallbackGeneration == token + } + } + + func createSwitchToken() -> UInt64 { + return switchGenerationQueue.sync { + switchGeneration &+= 1 + return switchGeneration + } + } + + func isCurrentSwitchToken(_ token: UInt64) -> Bool { + return switchGenerationQueue.sync { + switchGeneration == token + } + } + + func shouldSkipSwitch(targetIME: String, fromUser: Bool, currentIME: String, now: CFAbsoluteTime) -> Bool { + if fromUser { + return shouldSkipUserSwitch(targetIME: targetIME, currentIME: currentIME, now: now) + } + return shouldSkipInternalSwitch(targetIME: targetIME, currentIME: currentIME, now: now) + } + + func shouldSkipUserSwitch(targetIME: String, currentIME: String, now: CFAbsoluteTime) -> Bool { + var shouldSkip = false + switchThrottleQueue.sync { + shouldSkip = switchThrottleState.lastUserTarget == targetIME && + (now - switchThrottleState.lastUserRequestTime) < userThrottleInterval && + currentIME == targetIME + } + + if shouldSkip { + Logger.debug("Skipping rapid duplicate user switch: \(targetIME)", category: .ime) + postUIRefreshNotification() + } + return shouldSkip + } + + func shouldSkipInternalSwitch(targetIME: String, currentIME: String, now: CFAbsoluteTime) -> Bool { + if currentIME == targetIME { + Logger.debug("Already on target IME (internal): \(targetIME)", category: .ime) + return true + } + + var shouldSkip = false + switchThrottleQueue.sync { + shouldSkip = switchThrottleState.lastInternalTarget == targetIME && + (now - switchThrottleState.lastInternalRequestTime) < internalThrottleInterval + } + + if shouldSkip { + Logger.debug("Skipping throttled internal switch: \(targetIME)", category: .ime) + } + return shouldSkip + } + + func recordSwitchThrottle(targetIME: String, fromUser: Bool, now: CFAbsoluteTime) { + switchThrottleQueue.async(flags: .barrier) { + if fromUser { + self.switchThrottleState.lastUserTarget = targetIME + self.switchThrottleState.lastUserRequestTime = now + } else { + self.switchThrottleState.lastInternalTarget = targetIME + self.switchThrottleState.lastInternalRequestTime = now + } + } + } +} diff --git a/ModSwitchIME/ImeController+SwitchVerification.swift b/ModSwitchIME/ImeController+SwitchVerification.swift new file mode 100644 index 0000000..944a59e --- /dev/null +++ b/ModSwitchIME/ImeController+SwitchVerification.swift @@ -0,0 +1,244 @@ +import Foundation +import Carbon + +extension ImeController { + func scheduleAdditionalVerification(expectedIME: String, delay: TimeInterval, switchToken: UInt64) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } + + let actualIME = self.getCurrentInputSource() + guard actualIME != expectedIME else { return } + + Logger.warning("Additional verification: IME mismatch detected", category: .ime) + self.recordAdditionalVerificationMismatchDiagnostic( + expectedIME: expectedIME, + actualIME: actualIME, + delay: delay + ) + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: actualIME, + retryCount: 1, + switchToken: switchToken + ) + } + } + + func verifyIMESwitchWithLimit( + expectedIME: String, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + guard isCurrentSwitchToken(switchToken) else { return } + guard retryCount <= 3 else { + Logger.warning("Max retry attempts reached for IME switch verification", category: .ime) + recordRetryLimitReachedDiagnostic( + targetIME: expectedIME, + currentIME: currentIME, + retryCount: retryCount, + phase: "verification" + ) + postUIRefreshNotification() + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.handleVerificationResult( + expectedIME: expectedIME, + currentIME: currentIME, + retryCount: retryCount, + switchToken: switchToken + ) + } + } + + private func handleVerificationResult( + expectedIME: String, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + guard isCurrentSwitchToken(switchToken) else { return } + let newIME = getCurrentInputSource() + + if newIME == expectedIME { + handleVerifiedIME(expectedIME: expectedIME, actualIME: newIME, currentIME: currentIME, retryCount: retryCount) + } else if newIME == currentIME { + handlePendingIME(expectedIME: expectedIME, currentIME: currentIME, retryCount: retryCount, switchToken: switchToken) + } else { + handleUnexpectedIME( + expectedIME: expectedIME, + actualIME: newIME, + currentIME: currentIME, + retryCount: retryCount, + switchToken: switchToken + ) + } + } + + private func handleVerifiedIME(expectedIME: String, actualIME: String, currentIME: String, retryCount: Int) { + Logger.debug("IME switch verified: \(currentIME) -> \(actualIME)", category: .ime) + recordSwitchConfirmedDiagnostic( + expectedIME: expectedIME, + actualIME: actualIME, + previousIME: currentIME, + retryCount: retryCount + ) + setLastSwitchedIME(expectedIME) + + var needsNotification = false + notificationQueue.sync { + needsNotification = lastNotifiedIME != expectedIME + } + if needsNotification { + postIMESwitchNotification(expectedIME) + } + } + + private func handlePendingIME( + expectedIME: String, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + Logger.warning("IME switch may have failed: still at current IME", category: .ime) + recordSwitchPendingDiagnostic( + expectedIME: expectedIME, + actualIME: currentIME, + previousIME: currentIME, + retryCount: retryCount + ) + retryOrRefresh(targetIME: expectedIME, currentIME: currentIME, retryCount: retryCount, switchToken: switchToken) + } + + private func handleUnexpectedIME( + expectedIME: String, + actualIME: String, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + Logger.warning("IME switched to unexpected: \(actualIME) (expected: \(expectedIME))", category: .ime) + recordUnexpectedIMEDiagnostic( + expectedIME: expectedIME, + actualIME: actualIME, + previousIME: currentIME, + retryCount: retryCount + ) + retryOrRefresh(targetIME: expectedIME, currentIME: actualIME, retryCount: retryCount, switchToken: switchToken) + } + + private func retryOrRefresh(targetIME: String, currentIME: String, retryCount: Int, switchToken: UInt64) { + if retryCount < 3 { + retryIMESwitchWithLimit( + targetIME: targetIME, + currentIME: currentIME, + retryCount: retryCount + 1, + switchToken: switchToken + ) + } else { + postUIRefreshNotification() + } + } + + func retryIMESwitchWithLimit(targetIME: String, currentIME: String, retryCount: Int, switchToken: UInt64) { + guard isCurrentSwitchToken(switchToken) else { return } + guard retryCount <= 3 else { + Logger.warning("Max retry attempts reached for IME switch", category: .ime) + recordRetryLimitReachedDiagnostic( + targetIME: targetIME, + currentIME: currentIME, + retryCount: retryCount, + phase: "select" + ) + postUIRefreshNotification() + return + } + + recordRetryScheduledDiagnostic(targetIME: targetIME, currentIME: currentIME, retryCount: retryCount) + + DispatchQueue.main.asyncAfter(deadline: .now() + verificationRetryDelay) { [weak self] in + self?.retryIMESwitchNow( + targetIME: targetIME, + currentIME: currentIME, + retryCount: retryCount, + switchToken: switchToken + ) + } + } + + private func retryIMESwitchNow(targetIME: String, currentIME: String, retryCount: Int, switchToken: UInt64) { + guard isCurrentSwitchToken(switchToken) else { return } + + guard let resolvedTarget = resolveInputSourceFromCacheOrRefresh(targetIME) else { + Logger.warning("Retry IME source not found: \(targetIME)", category: .ime) + recordInputSourceResolutionFailedDiagnostic(targetIME: targetIME, reason: "retry-not-found") + postUIRefreshNotification() + return + } + + let result = selectTISInputSource(resolvedTarget.source, expectedIME: resolvedTarget.id) + recordRetrySelectAttemptDiagnostic( + targetIME: resolvedTarget.id, + currentIME: currentIME, + retryCount: retryCount, + result: result + ) + handleRetrySelectResult( + result, + targetIME: targetIME, + resolvedTarget: resolvedTarget, + currentIME: currentIME, + retryCount: retryCount, + switchToken: switchToken + ) + } + + private func handleRetrySelectResult( + _ result: OSStatus, + targetIME: String, + resolvedTarget: ResolvedInputSource, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + if result == noErr { + let ctx = resolvedTarget.refreshed ? "fresh source" : "cached source" + Logger.debug("Retrying IME switch (\(ctx)) attempt \(retryCount): \(resolvedTarget.id)", category: .ime) + recordSwitchStateCheckDiagnostic( + expectedIME: resolvedTarget.id, + delay: 0.05, + switchToken: switchToken + ) + recordSwitchStateCheckDiagnostic( + expectedIME: resolvedTarget.id, + delay: 0.2, + switchToken: switchToken + ) + recordSwitchStateCheckDiagnostic( + expectedIME: resolvedTarget.id, + delay: 0.5, + switchToken: switchToken + ) + verifyIMESwitchWithLimit( + expectedIME: resolvedTarget.id, + currentIME: currentIME, + retryCount: retryCount, + switchToken: switchToken + ) + } else if retryCount < 3 { + Logger.warning("Retry IME switch attempt \(retryCount) failed with code: \(result)", category: .ime) + retryIMESwitchWithLimit( + targetIME: targetIME, + currentIME: currentIME, + retryCount: retryCount + 1, + switchToken: switchToken + ) + } else { + Logger.warning("Retry IME switch failed with code: \(result)", category: .ime) + postUIRefreshNotification() + } + } +} diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 3aba9c4..13ea50d 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -33,22 +33,22 @@ final class ImeController: ErrorHandler, IMEControlling { let lastSwitchedIMEQueue = DispatchQueue(label: "com.modswitchime.lastIME") // Track last switch timings separately for user-triggered and internal operations - private struct SwitchThrottleState { + struct SwitchThrottleState { var lastUserTarget: String = "" var lastUserRequestTime: CFAbsoluteTime = 0 var lastInternalTarget: String = "" var lastInternalRequestTime: CFAbsoluteTime = 0 } - private var switchThrottleState = SwitchThrottleState() - private let switchThrottleQueue = DispatchQueue(label: "com.modswitchime.switchthrottle", attributes: .concurrent) - private let userThrottleInterval: CFAbsoluteTime = 0.1 - private let internalThrottleInterval: CFAbsoluteTime = 0.05 - private let verificationRetryDelay: TimeInterval = 0.05 - private let asciiFallbackDelay: TimeInterval = 0.2 - private var asciiFallbackGeneration: UInt64 = 0 - private let asciiFallbackQueue = DispatchQueue(label: "com.modswitchime.asciifallback") - private var switchGeneration: UInt64 = 0 - private let switchGenerationQueue = DispatchQueue(label: "com.modswitchime.switchgeneration") + var switchThrottleState = SwitchThrottleState() + let switchThrottleQueue = DispatchQueue(label: "com.modswitchime.switchthrottle", attributes: .concurrent) + let userThrottleInterval: CFAbsoluteTime = 0.1 + let internalThrottleInterval: CFAbsoluteTime = 0.05 + let verificationRetryDelay: TimeInterval = 0.05 + let asciiFallbackDelay: TimeInterval = 0.2 + var asciiFallbackGeneration: UInt64 = 0 + let asciiFallbackQueue = DispatchQueue(label: "com.modswitchime.asciifallback") + var switchGeneration: UInt64 = 0 + let switchGenerationQueue = DispatchQueue(label: "com.modswitchime.switchgeneration") // Throttle state balances rapid duplicate prevention with user retry needs var lastNotifiedIME: String = "" @@ -117,9 +117,12 @@ final class ImeController: ErrorHandler, IMEControlling { fromUser: Bool = true, invalidatesPendingAsciiFallback: Bool = true ) { + recordSwitchRequestedDiagnostic(targetIME: imeId, fromUser: fromUser) + // Validate IME ID guard isValidIMEId(imeId) else { Logger.warning("Invalid IME ID provided: \(imeId)", category: .ime) + recordInputSourceResolutionFailedDiagnostic(targetIME: imeId, reason: "invalid-id") handleError(ModSwitchIMEError.invalidConfiguration) return } @@ -129,11 +132,14 @@ final class ImeController: ErrorHandler, IMEControlling { } guard let resolvedTarget = resolveInputSourceFromCacheOrRefresh(imeId) else { + recordInputSourceResolutionFailedDiagnostic(targetIME: imeId, reason: "not-found") handleError(ModSwitchIMEError.inputSourceNotFound(imeId)) postUIRefreshNotification() return } + recordInputSourceResolvedDiagnostic(requestedIME: imeId, resolved: resolvedTarget) + let currentIME = getCurrentInputSource() let now = CFAbsoluteTimeGetCurrent() @@ -143,6 +149,7 @@ final class ImeController: ErrorHandler, IMEControlling { currentIME: currentIME, now: now ) { + recordSwitchSkippedDiagnostic(targetIME: resolvedTarget.id, currentIME: currentIME, fromUser: fromUser) return } @@ -218,7 +225,16 @@ final class ImeController: ErrorHandler, IMEControlling { for attempt in 0..<3 { guard isCurrentSwitchToken(switchToken) else { return } let result = selectTISInputSource(source, expectedIME: expectedIME) + recordSelectAttemptDiagnostic( + targetIME: expectedIME, + currentIME: currentIME, + attempt: attempt + 1, + result: result + ) if result == noErr { + recordSwitchStateCheckDiagnostic(expectedIME: expectedIME, delay: 0.05, switchToken: switchToken) + recordSwitchStateCheckDiagnostic(expectedIME: expectedIME, delay: 0.2, switchToken: switchToken) + recordSwitchStateCheckDiagnostic(expectedIME: expectedIME, delay: 0.5, switchToken: switchToken) performHybridIMESwitch(expectedIME: expectedIME, currentIME: currentIME, switchToken: switchToken) return } @@ -246,6 +262,11 @@ final class ImeController: ErrorHandler, IMEControlling { if actualIME == expectedIME { // Success - notify UI update Logger.debug("IME switch confirmed: \(currentIME) -> \(actualIME)", category: .ime) + self.recordSwitchConfirmedDiagnostic( + expectedIME: expectedIME, + actualIME: actualIME, + previousIME: currentIME + ) self.setLastSwitchedIME(expectedIME) self.postIMESwitchNotification(expectedIME) @@ -255,6 +276,12 @@ final class ImeController: ErrorHandler, IMEControlling { } else if actualIME == currentIME { // Not switched yet - schedule another check Logger.debug("IME not switched yet, scheduling verification", category: .ime) + self.recordSwitchPendingDiagnostic( + expectedIME: expectedIME, + actualIME: actualIME, + previousIME: currentIME, + retryCount: 1 + ) // Prevent infinite recursion by limiting retry depth self.verifyIMESwitchWithLimit( expectedIME: expectedIME, @@ -268,27 +295,13 @@ final class ImeController: ErrorHandler, IMEControlling { "IME switched to unexpected: \(actualIME) (expected: \(expectedIME))", category: .ime ) - // Reapply the requested IME before falling back to showing actual state. - self.retryIMESwitchWithLimit( - targetIME: expectedIME, - currentIME: actualIME, - retryCount: 1, - switchToken: switchToken + self.recordUnexpectedIMEDiagnostic( + expectedIME: expectedIME, + actualIME: actualIME, + previousIME: currentIME, + retryCount: 1 ) - } - } - } - - private func scheduleAdditionalVerification(expectedIME: String, delay: TimeInterval, switchToken: UInt64) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self = self else { return } - guard self.isCurrentSwitchToken(switchToken) else { return } - - let actualIME = self.getCurrentInputSource() - if actualIME != expectedIME { - Logger.warning("Additional verification: IME mismatch detected", - category: .ime) - // The system or focused app overrode the switch after confirmation. Try once more. + // Reapply the requested IME before falling back to showing actual state. self.retryIMESwitchWithLimit( targetIME: expectedIME, currentIME: actualIME, @@ -299,116 +312,6 @@ final class ImeController: ErrorHandler, IMEControlling { } } - private func verifyIMESwitchWithLimit( - expectedIME: String, - currentIME: String, - retryCount: Int, - switchToken: UInt64 - ) { - guard isCurrentSwitchToken(switchToken) else { return } - guard retryCount <= 3 else { - Logger.warning("Max retry attempts reached for IME switch verification", category: .ime) - postUIRefreshNotification() - return - } - - // Verify the switch after a short delay (reduced from 0.1 to 0.05) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - guard let self = self else { return } - guard self.isCurrentSwitchToken(switchToken) else { return } - let newIME = self.getCurrentInputSource() - - if newIME == expectedIME { - Logger.debug("IME switch verified: \(currentIME) -> \(newIME)", category: .ime) - self.setLastSwitchedIME(expectedIME) - // Check if notification already sent to prevent duplicates - var needsNotification = false - self.notificationQueue.sync { - needsNotification = self.lastNotifiedIME != expectedIME - } - if needsNotification { - self.postIMESwitchNotification(expectedIME) - } - } else if newIME == currentIME { - Logger.warning("IME switch may have failed: still at current IME", category: .ime) - // Retry with incremented count - if retryCount < 3 { - self.retryIMESwitchWithLimit( - targetIME: expectedIME, - currentIME: currentIME, - retryCount: retryCount + 1, - switchToken: switchToken - ) - } else { - self.postUIRefreshNotification() - } - } else { - Logger.warning("IME switched to unexpected: \(newIME) (expected: \(expectedIME))", category: .ime) - if retryCount < 3 { - self.retryIMESwitchWithLimit( - targetIME: expectedIME, - currentIME: newIME, - retryCount: retryCount + 1, - switchToken: switchToken - ) - } else { - self.postUIRefreshNotification() - } - } - } - } - - private func retryIMESwitchWithLimit( - targetIME: String, - currentIME: String, - retryCount: Int, - switchToken: UInt64 - ) { - guard isCurrentSwitchToken(switchToken) else { return } - guard retryCount <= 3 else { - Logger.warning("Max retry attempts reached for IME switch", category: .ime) - postUIRefreshNotification() - return - } - - DispatchQueue.main.asyncAfter(deadline: .now() + verificationRetryDelay) { [weak self] in - guard let self = self else { return } - guard self.isCurrentSwitchToken(switchToken) else { return } - - guard let resolvedTarget = self.resolveInputSourceFromCacheOrRefresh(targetIME) else { - Logger.warning("Retry IME source not found: \(targetIME)", category: .ime) - self.postUIRefreshNotification() - return - } - - let result = self.selectTISInputSource(resolvedTarget.source, expectedIME: resolvedTarget.id) - if result == noErr { - let ctx = resolvedTarget.refreshed ? "fresh source" : "cached source" - Logger.debug("Retrying IME switch (\(ctx)) attempt \(retryCount): \(resolvedTarget.id)", category: .ime) - self.verifyIMESwitchWithLimit( - expectedIME: resolvedTarget.id, - currentIME: currentIME, - retryCount: retryCount, - switchToken: switchToken - ) - } else if retryCount < 3 { - Logger.warning( - "Retry IME switch attempt \(retryCount) failed with code: \(result)", - category: .ime - ) - self.retryIMESwitchWithLimit( - targetIME: targetIME, - currentIME: currentIME, - retryCount: retryCount + 1, - switchToken: switchToken - ) - } else { - Logger.warning("Retry IME switch failed with code: \(result)", category: .ime) - self.postUIRefreshNotification() - } - } - } - // Removed performSwitch and related methods - no longer needed after simplification func getCurrentInputSource() -> String { @@ -423,7 +326,7 @@ final class ImeController: ErrorHandler, IMEControlling { } } - private func selectTISInputSource(_ source: TISInputSource, expectedIME: String) -> OSStatus { + func selectTISInputSource(_ source: TISInputSource, expectedIME: String) -> OSStatus { #if DEBUG if let debugSelectInputSourceHandler { return debugSelectInputSourceHandler(expectedIME) @@ -468,146 +371,3 @@ final class ImeController: ErrorHandler, IMEControlling { DistributedNotificationCenter.default().removeObserver(self) } } - -private extension ImeController { - func availableInputSourceIDs(from candidateIDs: [String]) -> [String] { - var available: [String] = [] - cacheQueue.sync { - available = candidateIDs.filter { inputSourceCache[$0] != nil } - } - return available - } - - func scheduleAsciiFallbackIfNeeded( - primarySource: String, - availableSources: [String], - fromUser: Bool, - fallbackToken: UInt64 - ) { - guard availableSources.count > 1 else { - return - } - - DispatchQueue.main.asyncAfter(deadline: .now() + asciiFallbackDelay) { [weak self] in - self?.applyAsciiFallbackIfNeeded( - primarySource: primarySource, - availableSources: availableSources, - fromUser: fromUser, - fallbackToken: fallbackToken - ) - } - } - - func applyAsciiFallbackIfNeeded( - primarySource: String, - availableSources: [String], - fromUser: Bool, - fallbackToken: UInt64 - ) { - guard isCurrentAsciiFallbackToken(fallbackToken) else { return } - - let actualIME = getCurrentInputSource() - guard actualIME != primarySource, !availableSources.contains(actualIME) else { - return - } - - guard let fallbackSource = availableSources.first(where: { $0 != primarySource }) else { - return - } - - Logger.warning( - "ASCII switch did not apply, trying fallback: \(primarySource) -> \(fallbackSource)", - category: .ime - ) - switchToSpecificIME(fallbackSource, fromUser: fromUser) - } - - func createAsciiFallbackToken() -> UInt64 { - return asciiFallbackQueue.sync { - asciiFallbackGeneration &+= 1 - return asciiFallbackGeneration - } - } - - func invalidateAsciiFallbackToken() { - asciiFallbackQueue.sync { - asciiFallbackGeneration &+= 1 - } - } - - func isCurrentAsciiFallbackToken(_ token: UInt64) -> Bool { - return asciiFallbackQueue.sync { - asciiFallbackGeneration == token - } - } - - func createSwitchToken() -> UInt64 { - return switchGenerationQueue.sync { - switchGeneration &+= 1 - return switchGeneration - } - } - - func isCurrentSwitchToken(_ token: UInt64) -> Bool { - return switchGenerationQueue.sync { - switchGeneration == token - } - } - - func shouldSkipSwitch( - targetIME: String, - fromUser: Bool, - currentIME: String, - now: CFAbsoluteTime - ) -> Bool { - if fromUser { - return shouldSkipUserSwitch(targetIME: targetIME, currentIME: currentIME, now: now) - } - return shouldSkipInternalSwitch(targetIME: targetIME, currentIME: currentIME, now: now) - } - - func shouldSkipUserSwitch(targetIME: String, currentIME: String, now: CFAbsoluteTime) -> Bool { - var shouldSkip = false - switchThrottleQueue.sync { - shouldSkip = switchThrottleState.lastUserTarget == targetIME && - (now - switchThrottleState.lastUserRequestTime) < userThrottleInterval && - currentIME == targetIME - } - - if shouldSkip { - Logger.debug("Skipping rapid duplicate user switch: \(targetIME)", category: .ime) - postUIRefreshNotification() - } - return shouldSkip - } - - func shouldSkipInternalSwitch(targetIME: String, currentIME: String, now: CFAbsoluteTime) -> Bool { - if currentIME == targetIME { - Logger.debug("Already on target IME (internal): \(targetIME)", category: .ime) - return true - } - - var shouldSkip = false - switchThrottleQueue.sync { - shouldSkip = switchThrottleState.lastInternalTarget == targetIME && - (now - switchThrottleState.lastInternalRequestTime) < internalThrottleInterval - } - - if shouldSkip { - Logger.debug("Skipping throttled internal switch: \(targetIME)", category: .ime) - } - return shouldSkip - } - - func recordSwitchThrottle(targetIME: String, fromUser: Bool, now: CFAbsoluteTime) { - switchThrottleQueue.async(flags: .barrier) { - if fromUser { - self.switchThrottleState.lastUserTarget = targetIME - self.switchThrottleState.lastUserRequestTime = now - } else { - self.switchThrottleState.lastInternalTarget = targetIME - self.switchThrottleState.lastInternalRequestTime = now - } - } - } -} diff --git a/ModSwitchIME/MenuBarApp.swift b/ModSwitchIME/MenuBarApp.swift index cd33aa2..a744a0f 100644 --- a/ModSwitchIME/MenuBarApp.swift +++ b/ModSwitchIME/MenuBarApp.swift @@ -286,6 +286,10 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { // Launch at Login addMenuItem(to: menu, title: "Launch at Login", action: #selector(toggleLaunchAtLogin), tag: 102) menu.addItem(NSMenuItem.separator()) + + addMenuItem(to: menu, title: "Diagnostic Logging", action: #selector(toggleDiagnosticLogging), tag: 104) + addMenuItem(to: menu, title: "Reveal Diagnostic Log", action: #selector(revealDiagnosticLog), tag: 105) + menu.addItem(NSMenuItem.separator()) // Restart addMenuItem( @@ -426,6 +430,14 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { updateLaunchAtLoginMenuItem() } + + @objc private func toggleDiagnosticLogging() { + preferences.diagnosticLoggingEnabled.toggle() + } + + @objc private func revealDiagnosticLog() { + DiagnosticLogger.shared.revealInFinder() + } private func updateLaunchAtLoginMenuItem() { // Ensure UI updates happen on main thread @@ -629,6 +641,10 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { @objc private func imeStateChanged(_ notification: Notification) { Logger.debug("IME state changed notification received", category: .main) + DiagnosticLogger.shared.record(.inputSourceNotification, fields: [ + "notification": notification.name.rawValue, + "currentIME": getCurrentIME() + ]) refreshIconDebounced() } @@ -655,6 +671,12 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { @objc private func handleFocusContextChanged(_ notification: Notification) { Logger.debug("Focus context changed: \(notification.name.rawValue)", category: .main) + let bundleID = NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "unknown" + DiagnosticLogger.shared.record(.appActivated, fields: [ + "notification": notification.name.rawValue, + "bundleID": bundleID, + "currentIME": getCurrentIME() + ]) refreshIconDebounced(delay: 0.12) DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in self?.refreshIconDebounced(delay: 0) @@ -671,12 +693,14 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { @objc private func systemWillSleep(_ notification: Notification) { // System is going to sleep Logger.info("System will sleep - stopping KeyMonitor", category: .main) + DiagnosticLogger.shared.record(.systemSleep) keyMonitor?.stop() } @objc private func systemDidWake(_ notification: Notification) { // System woke up Logger.info("System did wake - restarting KeyMonitor", category: .main) + DiagnosticLogger.shared.record(.systemWake) // Delay restart to ensure system is fully awake DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in @@ -768,9 +792,13 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { Idle Auto Switch: \(idleEnabled ? "ON" : "OFF") Idle Timeout: \(Int(idleTimeout)) seconds Timer Running: \(timerRunning ? "YES" : "NO") + Diagnostic Logging: \(preferences.diagnosticLoggingEnabled ? "ON" : "OFF") KeyMonitor: \(keyMonitor != nil ? "Initialized" : "Not initialized") + Diagnostic Log: + \(DiagnosticLogger.shared.logFileURL.path) + Build Time: \(buildTimestamp) """ @@ -837,6 +865,10 @@ extension MenuBarApp { private func refreshIconIfIMEChanged() { let currentIME = getCurrentIME() guard currentIME != lastDisplayedIME else { return } + DiagnosticLogger.shared.record(.reconciliationMismatch, fields: [ + "displayedIME": lastDisplayedIME ?? "", + "actualIME": currentIME + ]) refreshIconDebounced() } @@ -875,6 +907,9 @@ extension MenuBarApp { } button.toolTip = tooltip Logger.debug("Updated icon for IME: \(imeId) using globe", category: .main) + DiagnosticLogger.shared.record(.iconUpdated, fields: [ + "imeId": imeId + ]) #if DEBUG Self.debugIconUpdateCount += 1 #endif @@ -923,6 +958,10 @@ extension MenuBarApp: NSMenuDelegate { // Always show normal restart option item.title = "Restart ModSwitchIME" item.attributedTitle = nil + case 104: // Diagnostic Logging + item.state = preferences.diagnosticLoggingEnabled ? .on : .off + case 105: // Reveal Diagnostic Log + item.isEnabled = true default: break } diff --git a/ModSwitchIME/Preferences.swift b/ModSwitchIME/Preferences.swift index 01c76ef..73685e2 100644 --- a/ModSwitchIME/Preferences.swift +++ b/ModSwitchIME/Preferences.swift @@ -143,7 +143,7 @@ final class Preferences: ObservableObject { let keysToRemove = [ "idleOffEnabled", "idleTimeout", "launchAtLogin", "motherImeId", "cmdKeyTimeout", "cmdKeyTimeoutEnabled", "idleReturnIME", - "modifierKeyMappings", "modifierKeyEnabled" + "modifierKeyMappings", "modifierKeyEnabled", "diagnosticLoggingEnabled" ] for key in keysToRemove { UserDefaults.standard.removeObject(forKey: key) @@ -202,6 +202,13 @@ final class Preferences: ObservableObject { } } } + + @Published var diagnosticLoggingEnabled: Bool { + didSet { + UserDefaults.standard.set(diagnosticLoggingEnabled, forKey: "diagnosticLoggingEnabled") + DiagnosticLogger.shared.setEnabled(diagnosticLoggingEnabled) + } + } // Modifier key IME mappings @Published var modifierKeyMappings: [ModifierKey: String] = [:] { @@ -229,10 +236,14 @@ final class Preferences: ObservableObject { self.cmdKeyTimeout = UserDefaults.standard.object(forKey: "cmdKeyTimeout") as? Double ?? 0.3 self.cmdKeyTimeoutEnabled = UserDefaults.standard.object(forKey: "cmdKeyTimeoutEnabled") as? Bool ?? false self.idleReturnIME = UserDefaults.standard.object(forKey: "idleReturnIME") as? String - + self.diagnosticLoggingEnabled = + UserDefaults.standard.object(forKey: "diagnosticLoggingEnabled") as? Bool ?? false + // Load modifier key mappings self.modifierKeyMappings = loadModifierKeyMappings() self.modifierKeyEnabled = loadModifierKeyEnabled() + + DiagnosticLogger.shared.setEnabled(diagnosticLoggingEnabled) } static func getAvailableInputSources() -> [(id: String, name: String)] { diff --git a/ModSwitchIMETests/DiagnosticLoggerTests.swift b/ModSwitchIMETests/DiagnosticLoggerTests.swift new file mode 100644 index 0000000..23de762 --- /dev/null +++ b/ModSwitchIMETests/DiagnosticLoggerTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import ModSwitchIME + +final class DiagnosticLoggerTests: XCTestCase { + private var temporaryDirectory: URL! + private var logFileURL: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ModSwitchIME-DiagnosticLoggerTests-\(UUID().uuidString)", isDirectory: true) + logFileURL = temporaryDirectory.appendingPathComponent("diagnostic.log") + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + + UserDefaults.standard.removeObject(forKey: "diagnosticLoggingEnabled") + DiagnosticLogger.shared.debugReset(enabled: false) + DiagnosticLogger.shared.debugSetLogFileURL(logFileURL) + } + + override func tearDownWithError() throws { + DiagnosticLogger.shared.debugReset(enabled: false) + UserDefaults.standard.removeObject(forKey: "diagnosticLoggingEnabled") + if let temporaryDirectory, FileManager.default.fileExists(atPath: temporaryDirectory.path) { + try FileManager.default.removeItem(at: temporaryDirectory) + } + try super.tearDownWithError() + } + + func testDisabledDiagnosticLoggingDoesNotCreateFile() throws { + DiagnosticLogger.shared.record(.switchRequested, fields: [ + "targetIME": "com.apple.inputmethod.Kotoeri.Japanese", + "source": "user" + ]) + DiagnosticLogger.shared.debugFlush() + + XCTAssertFalse(FileManager.default.fileExists(atPath: logFileURL.path)) + } + + func testEnabledDiagnosticLoggingWritesStructuredRecord() throws { + DiagnosticLogger.shared.setEnabled(true) + DiagnosticLogger.shared.debugFlush() + + DiagnosticLogger.shared.record(.switchRequested, fields: [ + "targetIME": "com.apple.inputmethod.Kotoeri.Japanese", + "currentIME": "com.apple.keylayout.ABC", + "source": "user" + ]) + DiagnosticLogger.shared.debugFlush() + + let records = try readRecords() + let switchRecord = try XCTUnwrap(records.first { $0["event"] as? String == "switchRequested" }) + + XCTAssertNotNil(switchRecord["timestamp"]) + XCTAssertEqual(switchRecord["targetIME"] as? String, "com.apple.inputmethod.Kotoeri.Japanese") + XCTAssertEqual(switchRecord["currentIME"] as? String, "com.apple.keylayout.ABC") + XCTAssertEqual(switchRecord["source"] as? String, "user") + } + + func testDiagnosticLoggerDropsUnknownFields() throws { + DiagnosticLogger.shared.setEnabled(true) + DiagnosticLogger.shared.debugFlush() + + DiagnosticLogger.shared.record(.switchRequested, fields: [ + "targetIME": "com.apple.keylayout.ABC", + "typedText": "secret user input" + ]) + DiagnosticLogger.shared.debugFlush() + + let logText = try String(contentsOf: logFileURL, encoding: .utf8) + XCTAssertFalse(logText.contains("typedText")) + XCTAssertFalse(logText.contains("secret user input")) + XCTAssertTrue(logText.contains("com.apple.keylayout.ABC")) + } + + func testPreferencePersistsAndControlsDiagnosticLogging() { + let preferences = Preferences.createForTesting() + DiagnosticLogger.shared.debugSetLogFileURL(logFileURL) + DiagnosticLogger.shared.debugFlush() + + preferences.diagnosticLoggingEnabled = true + DiagnosticLogger.shared.debugFlush() + + XCTAssertTrue(UserDefaults.standard.bool(forKey: "diagnosticLoggingEnabled")) + XCTAssertTrue(DiagnosticLogger.shared.isEnabled) + + preferences.diagnosticLoggingEnabled = false + DiagnosticLogger.shared.debugFlush() + + XCTAssertFalse(UserDefaults.standard.bool(forKey: "diagnosticLoggingEnabled")) + XCTAssertFalse(DiagnosticLogger.shared.isEnabled) + } + + private func readRecords() throws -> [[String: Any]] { + let logText = try String(contentsOf: logFileURL, encoding: .utf8) + return try logText + .split(separator: "\n") + .map { line in + let data = Data(line.utf8) + let object = try JSONSerialization.jsonObject(with: data) + return try XCTUnwrap(object as? [String: Any]) + } + } +} From d725512e95919a2639763129e4482086da362798 Mon Sep 17 00:00:00 2001 From: nissy Date: Sun, 24 May 2026 13:03:21 +0900 Subject: [PATCH 11/12] bugfix window focus --- ModSwitchIME/ImeController+StateMonitoring.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift index ace4133..6cc6758 100644 --- a/ModSwitchIME/ImeController+StateMonitoring.swift +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -208,6 +208,7 @@ extension ImeController { "imeId": imeId, "reason": "duplicate" ]) + postUIRefreshNotification() return } From 34901f030f0f4faefcb0d2c971337781f6d17d70 Mon Sep 17 00:00:00 2001 From: nissy Date: Sun, 24 May 2026 16:04:42 +0900 Subject: [PATCH 12/12] bugfix window focus --- ModSwitchIME/InputSourceManager.swift | 54 +++++++ ModSwitchIME/Preferences.swift | 139 ++++++++++++------ ModSwitchIMETests/DiagnosticLoggerTests.swift | 6 +- ModSwitchIMETests/ErrorHandlingTests.swift | 24 ++- .../InputSourceHelpersTests.swift | 72 +++++++++ .../KeyMonitorIntegrationTests.swift | 2 +- ModSwitchIMETests/PreferencesLogicTests.swift | 128 ++++++++-------- .../PreferencesNoDefaultsTests.swift | 29 ++-- ModSwitchIMETests/PreferencesViewTests.swift | 6 +- .../UIStateTransitionTests.swift | 6 +- 10 files changed, 320 insertions(+), 146 deletions(-) diff --git a/ModSwitchIME/InputSourceManager.swift b/ModSwitchIME/InputSourceManager.swift index 5d9c21e..af861c6 100644 --- a/ModSwitchIME/InputSourceManager.swift +++ b/ModSwitchIME/InputSourceManager.swift @@ -64,6 +64,10 @@ struct InputSourceManager { return candidate } + if let equivalentID = equivalentVersionedInputSourceID(for: requestedID, availableIDs: availableIDs) { + return equivalentID + } + let childPrefix = requestedID + "." let childCandidates = availableIDs.filter { $0.hasPrefix(childPrefix) } return childCandidates.min { lhs, rhs in @@ -76,6 +80,56 @@ struct InputSourceManager { } } + static func equivalentVersionedInputSourceID(for requestedID: String, availableIDs: Set) -> String? { + let requestedParts = requestedID.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + guard let versionedIndex = requestedParts.indices.reversed().first(where: { + versionedComponent(requestedParts[$0]) != nil + }), + let requestedVersionedComponent = versionedComponent(requestedParts[versionedIndex]) else { + return nil + } + + let requestedSuffix = requestedParts.dropFirst(versionedIndex + 1).joined(separator: ".") + var bestCandidate: (id: String, version: Int)? + + for candidateID in availableIDs { + let candidateParts = candidateID.split(separator: ".", omittingEmptySubsequences: false).map(String.init) + guard candidateParts.count > versionedIndex, + candidateParts.prefix(versionedIndex) == requestedParts.prefix(versionedIndex), + let candidateVersionedComponent = versionedComponent(candidateParts[versionedIndex]), + candidateVersionedComponent.name == requestedVersionedComponent.name else { + continue + } + + let candidateSuffix = candidateParts.dropFirst(versionedIndex + 1).joined(separator: ".") + guard candidateSuffix == requestedSuffix else { + continue + } + + if let currentBest = bestCandidate { + if candidateVersionedComponent.version > currentBest.version { + bestCandidate = (candidateID, candidateVersionedComponent.version) + } + } else { + bestCandidate = (candidateID, candidateVersionedComponent.version) + } + } + + return bestCandidate?.id + } + + private static func versionedComponent(_ component: String) -> (name: String, version: Int)? { + let name = component.prefix { !$0.isNumber } + let digits = component.dropFirst(name.count) + guard !name.isEmpty, + !digits.isEmpty, + digits.allSatisfy(\.isNumber), + let version = Int(digits) else { + return nil + } + return (String(name), version) + } + private static func explicitFallbackInputSourceIDs(for requestedID: String) -> [String] { if requestedID == "com.google.inputmethod.Japanese" { return [ diff --git a/ModSwitchIME/Preferences.swift b/ModSwitchIME/Preferences.swift index 73685e2..b032aeb 100644 --- a/ModSwitchIME/Preferences.swift +++ b/ModSwitchIME/Preferences.swift @@ -136,76 +136,77 @@ enum ModifierKey: String, CaseIterable, Codable { final class Preferences: ObservableObject { static let shared = Preferences() - + + let userDefaults: UserDefaults + // For testing purposes only internal static func createForTesting() -> Preferences { - // Clear test-related UserDefaults to ensure clean state - let keysToRemove = [ - "idleOffEnabled", "idleTimeout", "launchAtLogin", "motherImeId", - "cmdKeyTimeout", "cmdKeyTimeoutEnabled", "idleReturnIME", - "modifierKeyMappings", "modifierKeyEnabled", "diagnosticLoggingEnabled" - ] - for key in keysToRemove { - UserDefaults.standard.removeObject(forKey: key) + let suiteName = "com.nissy.ModSwitchIME.tests.\(UUID().uuidString)" + guard let userDefaults = UserDefaults(suiteName: suiteName) else { + return Preferences() } - UserDefaults.standard.synchronize() - return Preferences() + userDefaults.removePersistentDomain(forName: suiteName) + return Preferences(userDefaults: userDefaults) } - // For testing migration scenarios - doesn't clear UserDefaults + // For migration tests with seeded data, use createForTesting(userDefaults:). internal static func createForMigrationTesting() -> Preferences { - return Preferences() + return createForTesting() + } + + internal static func createForTesting(userDefaults: UserDefaults) -> Preferences { + return Preferences(userDefaults: userDefaults) } @Published var idleOffEnabled: Bool { didSet { - UserDefaults.standard.set(idleOffEnabled, forKey: "idleOffEnabled") + userDefaults.set(idleOffEnabled, forKey: "idleOffEnabled") } } @Published var idleTimeout: Double { didSet { - UserDefaults.standard.set(idleTimeout, forKey: "idleTimeout") + userDefaults.set(idleTimeout, forKey: "idleTimeout") } } @Published var launchAtLogin: Bool { didSet { - UserDefaults.standard.set(launchAtLogin, forKey: "launchAtLogin") + userDefaults.set(launchAtLogin, forKey: "launchAtLogin") } } @Published var motherImeId: String { didSet { - UserDefaults.standard.set(motherImeId, forKey: "motherImeId") + userDefaults.set(motherImeId, forKey: "motherImeId") } } @Published var cmdKeyTimeout: Double { didSet { - UserDefaults.standard.set(cmdKeyTimeout, forKey: "cmdKeyTimeout") + userDefaults.set(cmdKeyTimeout, forKey: "cmdKeyTimeout") } } @Published var cmdKeyTimeoutEnabled: Bool { didSet { - UserDefaults.standard.set(cmdKeyTimeoutEnabled, forKey: "cmdKeyTimeoutEnabled") + userDefaults.set(cmdKeyTimeoutEnabled, forKey: "cmdKeyTimeoutEnabled") } } @Published var idleReturnIME: String? { didSet { if let ime = idleReturnIME { - UserDefaults.standard.set(ime, forKey: "idleReturnIME") + userDefaults.set(ime, forKey: "idleReturnIME") } else { - UserDefaults.standard.removeObject(forKey: "idleReturnIME") + userDefaults.removeObject(forKey: "idleReturnIME") } } } @Published var diagnosticLoggingEnabled: Bool { didSet { - UserDefaults.standard.set(diagnosticLoggingEnabled, forKey: "diagnosticLoggingEnabled") + userDefaults.set(diagnosticLoggingEnabled, forKey: "diagnosticLoggingEnabled") DiagnosticLogger.shared.setEnabled(diagnosticLoggingEnabled) } } @@ -224,27 +225,49 @@ final class Preferences: ObservableObject { } } - private init() { + private init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults // Privacy Note: Only essential app preferences are stored locally // No user data, keystrokes, or sensitive information is persisted - UserDefaults.standard.synchronize() + userDefaults.synchronize() - self.idleOffEnabled = UserDefaults.standard.object(forKey: "idleOffEnabled") as? Bool ?? false - self.idleTimeout = UserDefaults.standard.object(forKey: "idleTimeout") as? Double ?? 5.0 - self.launchAtLogin = UserDefaults.standard.object(forKey: "launchAtLogin") as? Bool ?? false - self.motherImeId = UserDefaults.standard.object(forKey: "motherImeId") as? String ?? "" - self.cmdKeyTimeout = UserDefaults.standard.object(forKey: "cmdKeyTimeout") as? Double ?? 0.3 - self.cmdKeyTimeoutEnabled = UserDefaults.standard.object(forKey: "cmdKeyTimeoutEnabled") as? Bool ?? false - self.idleReturnIME = UserDefaults.standard.object(forKey: "idleReturnIME") as? String + self.idleOffEnabled = userDefaults.object(forKey: "idleOffEnabled") as? Bool ?? false + self.idleTimeout = Preferences.storedFiniteDouble( + forKey: "idleTimeout", + defaultValue: 5.0, + userDefaults: userDefaults + ) + self.launchAtLogin = userDefaults.object(forKey: "launchAtLogin") as? Bool ?? false + self.motherImeId = userDefaults.object(forKey: "motherImeId") as? String ?? "" + self.cmdKeyTimeout = Preferences.storedFiniteDouble( + forKey: "cmdKeyTimeout", + defaultValue: 0.3, + userDefaults: userDefaults + ) + self.cmdKeyTimeoutEnabled = userDefaults.object(forKey: "cmdKeyTimeoutEnabled") as? Bool ?? false + self.idleReturnIME = userDefaults.object(forKey: "idleReturnIME") as? String self.diagnosticLoggingEnabled = - UserDefaults.standard.object(forKey: "diagnosticLoggingEnabled") as? Bool ?? false + userDefaults.object(forKey: "diagnosticLoggingEnabled") as? Bool ?? false // Load modifier key mappings self.modifierKeyMappings = loadModifierKeyMappings() self.modifierKeyEnabled = loadModifierKeyEnabled() + normalizeStoredInputSourceIDs() DiagnosticLogger.shared.setEnabled(diagnosticLoggingEnabled) } + + private static func storedFiniteDouble( + forKey key: String, + defaultValue: Double, + userDefaults: UserDefaults + ) -> Double { + guard let value = userDefaults.object(forKey: key) as? Double, + value.isFinite else { + return defaultValue + } + return value + } static func getAvailableInputSources() -> [(id: String, name: String)] { if Thread.isMainThread { @@ -417,20 +440,20 @@ final class Preferences: ObservableObject { private func saveModifierKeyMappings() { // Don't save if empty (prevents issues on first launch) if modifierKeyMappings.isEmpty { - UserDefaults.standard.removeObject(forKey: "modifierKeyMappings") - UserDefaults.standard.synchronize() + userDefaults.removeObject(forKey: "modifierKeyMappings") + userDefaults.synchronize() return } let encoder = JSONEncoder() if let data = try? encoder.encode(modifierKeyMappings) { - UserDefaults.standard.set(data, forKey: "modifierKeyMappings") - UserDefaults.standard.synchronize() + userDefaults.set(data, forKey: "modifierKeyMappings") + userDefaults.synchronize() } } private func loadModifierKeyMappings() -> [ModifierKey: String] { - guard let data = UserDefaults.standard.data(forKey: "modifierKeyMappings"), + guard let data = userDefaults.data(forKey: "modifierKeyMappings"), let mappings = try? JSONDecoder().decode([ModifierKey: String].self, from: data) else { return [:] } @@ -440,25 +463,57 @@ final class Preferences: ObservableObject { private func saveModifierKeyEnabled() { // Don't save if empty (prevents issues on first launch) if modifierKeyEnabled.isEmpty { - UserDefaults.standard.removeObject(forKey: "modifierKeyEnabled") - UserDefaults.standard.synchronize() + userDefaults.removeObject(forKey: "modifierKeyEnabled") + userDefaults.synchronize() return } let encoder = JSONEncoder() if let data = try? encoder.encode(modifierKeyEnabled) { - UserDefaults.standard.set(data, forKey: "modifierKeyEnabled") - UserDefaults.standard.synchronize() + userDefaults.set(data, forKey: "modifierKeyEnabled") + userDefaults.synchronize() } } private func loadModifierKeyEnabled() -> [ModifierKey: Bool] { - guard let data = UserDefaults.standard.data(forKey: "modifierKeyEnabled"), + guard let data = userDefaults.data(forKey: "modifierKeyEnabled"), let enabled = try? JSONDecoder().decode([ModifierKey: Bool].self, from: data) else { return [:] } return enabled } + + private func normalizeStoredInputSourceIDs() { + let availableIDs = Set(Preferences.getAllInputSources(includeDisabled: false).map(\.sourceId)) + + let normalizedMappings = modifierKeyMappings.mapValues { + normalizedInputSourceID($0, availableIDs: availableIDs) + } + if normalizedMappings != modifierKeyMappings { + modifierKeyMappings = normalizedMappings + } + + if let idleReturnIME { + let normalizedID = normalizedInputSourceID(idleReturnIME, availableIDs: availableIDs) + if normalizedID != idleReturnIME { + self.idleReturnIME = normalizedID + } + } + + if !motherImeId.isEmpty { + let normalizedID = normalizedInputSourceID(motherImeId, availableIDs: availableIDs) + if normalizedID != motherImeId { + motherImeId = normalizedID + } + } + } + + private func normalizedInputSourceID(_ inputSourceID: String, availableIDs: Set) -> String { + if availableIDs.contains(inputSourceID) { + return inputSourceID + } + return InputSourceManager.fallbackInputSourceID(for: inputSourceID, availableIDs: availableIDs) ?? inputSourceID + } // Get IME ID for a specific modifier key func getIME(for key: ModifierKey) -> String? { diff --git a/ModSwitchIMETests/DiagnosticLoggerTests.swift b/ModSwitchIMETests/DiagnosticLoggerTests.swift index 23de762..3816af8 100644 --- a/ModSwitchIMETests/DiagnosticLoggerTests.swift +++ b/ModSwitchIMETests/DiagnosticLoggerTests.swift @@ -13,14 +13,12 @@ final class DiagnosticLoggerTests: XCTestCase { logFileURL = temporaryDirectory.appendingPathComponent("diagnostic.log") try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) - UserDefaults.standard.removeObject(forKey: "diagnosticLoggingEnabled") DiagnosticLogger.shared.debugReset(enabled: false) DiagnosticLogger.shared.debugSetLogFileURL(logFileURL) } override func tearDownWithError() throws { DiagnosticLogger.shared.debugReset(enabled: false) - UserDefaults.standard.removeObject(forKey: "diagnosticLoggingEnabled") if let temporaryDirectory, FileManager.default.fileExists(atPath: temporaryDirectory.path) { try FileManager.default.removeItem(at: temporaryDirectory) } @@ -81,13 +79,13 @@ final class DiagnosticLoggerTests: XCTestCase { preferences.diagnosticLoggingEnabled = true DiagnosticLogger.shared.debugFlush() - XCTAssertTrue(UserDefaults.standard.bool(forKey: "diagnosticLoggingEnabled")) + XCTAssertTrue(preferences.userDefaults.bool(forKey: "diagnosticLoggingEnabled")) XCTAssertTrue(DiagnosticLogger.shared.isEnabled) preferences.diagnosticLoggingEnabled = false DiagnosticLogger.shared.debugFlush() - XCTAssertFalse(UserDefaults.standard.bool(forKey: "diagnosticLoggingEnabled")) + XCTAssertFalse(preferences.userDefaults.bool(forKey: "diagnosticLoggingEnabled")) XCTAssertFalse(DiagnosticLogger.shared.isEnabled) } diff --git a/ModSwitchIMETests/ErrorHandlingTests.swift b/ModSwitchIMETests/ErrorHandlingTests.swift index a856bfd..fc1e491 100644 --- a/ModSwitchIMETests/ErrorHandlingTests.swift +++ b/ModSwitchIMETests/ErrorHandlingTests.swift @@ -238,36 +238,30 @@ class ErrorHandlingTests: XCTestCase { func testUserDefaultsCorruptedData() { // Given: Corrupted UserDefaults data - UserDefaults.standard.set("invalid_boolean_value", forKey: "idleOffEnabled") - UserDefaults.standard.set("not_a_number", forKey: "idleTimeout") + let testPreferences = Preferences.createForTesting() + testPreferences.userDefaults.set("invalid_boolean_value", forKey: "idleOffEnabled") + testPreferences.userDefaults.set("not_a_number", forKey: "idleTimeout") // When: Creating preferences with corrupted data - let preferences = Preferences.createForTesting() + let preferences = Preferences.createForTesting(userDefaults: testPreferences.userDefaults) // Then: Should use default values when data is corrupted XCTAssertFalse(preferences.idleOffEnabled, "Should use default false for corrupted boolean") XCTAssertEqual(preferences.idleTimeout, 5.0, "Should use default 5.0 for corrupted number") - - // Cleanup - UserDefaults.standard.removeObject(forKey: "idleOffEnabled") - UserDefaults.standard.removeObject(forKey: "idleTimeout") } func testUserDefaultsExtremeValues() { // Given: Extreme values in UserDefaults - UserDefaults.standard.set(Double.infinity, forKey: "idleTimeout") - UserDefaults.standard.set(Double.nan, forKey: "idleTimeout") + let testPreferences = Preferences.createForTesting() + testPreferences.userDefaults.set(Double.infinity, forKey: "idleTimeout") // When: Creating preferences with extreme values - let preferences1 = Preferences.createForTesting() + let preferences1 = Preferences.createForTesting(userDefaults: testPreferences.userDefaults) XCTAssertFalse(preferences1.idleTimeout.isInfinite, "Should handle infinity gracefully") - UserDefaults.standard.set(Double.nan, forKey: "idleTimeout") - let preferences2 = Preferences.createForTesting() + testPreferences.userDefaults.set(Double.nan, forKey: "idleTimeout") + let preferences2 = Preferences.createForTesting(userDefaults: testPreferences.userDefaults) XCTAssertFalse(preferences2.idleTimeout.isNaN, "Should handle NaN gracefully") - - // Cleanup - UserDefaults.standard.removeObject(forKey: "idleTimeout") } // MARK: - Memory and Resource Error Handling Tests diff --git a/ModSwitchIMETests/InputSourceHelpersTests.swift b/ModSwitchIMETests/InputSourceHelpersTests.swift index 248aca9..8649dd8 100644 --- a/ModSwitchIMETests/InputSourceHelpersTests.swift +++ b/ModSwitchIMETests/InputSourceHelpersTests.swift @@ -54,4 +54,76 @@ final class InputSourceHelpersTests: XCTestCase { "com.example.inputmethod.Test.Hiragana" ) } + + func testATOKVersionedInputSourceFallsBackToInstalledVersion() { + let availableIDs: Set = [ + "com.justsystems.inputmethod.atok35.Roman", + "com.justsystems.inputmethod.atok35.Japanese", + "com.justsystems.inputmethod.atok35.Japanese.Katakana" + ] + + XCTAssertEqual( + InputSourceManager.fallbackInputSourceID( + for: "com.justsystems.inputmethod.atok34.Japanese", + availableIDs: availableIDs + ), + "com.justsystems.inputmethod.atok35.Japanese" + ) + } + + func testATOKVersionedInputSourceKeepsRequestedMode() { + let availableIDs: Set = [ + "com.justsystems.inputmethod.atok35.Japanese", + "com.justsystems.inputmethod.atok35.Japanese.Katakana" + ] + + XCTAssertEqual( + InputSourceManager.fallbackInputSourceID( + for: "com.justsystems.inputmethod.atok34.Japanese.Katakana", + availableIDs: availableIDs + ), + "com.justsystems.inputmethod.atok35.Japanese.Katakana" + ) + } + + func testVersionedInputSourceFallsBackWithoutATOKSpecificRule() { + let availableIDs: Set = [ + "com.example.inputmethod.engine11.Japanese", + "com.example.inputmethod.engine11.Roman" + ] + + XCTAssertEqual( + InputSourceManager.fallbackInputSourceID( + for: "com.example.inputmethod.engine10.Japanese", + availableIDs: availableIDs + ), + "com.example.inputmethod.engine11.Japanese" + ) + } + + func testVersionedInputSourceDoesNotCrossVendorPrefix() { + let availableIDs: Set = [ + "com.other.inputmethod.engine11.Japanese" + ] + + XCTAssertNil( + InputSourceManager.fallbackInputSourceID( + for: "com.example.inputmethod.engine10.Japanese", + availableIDs: availableIDs + ) + ) + } + + func testVersionedInputSourceIgnoresNumericVendorComponent() { + let availableIDs: Set = [ + "com.vendor3.inputmethod.engine10.Japanese" + ] + + XCTAssertNil( + InputSourceManager.fallbackInputSourceID( + for: "com.vendor2.inputmethod.engine10.Japanese", + availableIDs: availableIDs + ) + ) + } } diff --git a/ModSwitchIMETests/KeyMonitorIntegrationTests.swift b/ModSwitchIMETests/KeyMonitorIntegrationTests.swift index 9b3abac..a5f8ee3 100644 --- a/ModSwitchIMETests/KeyMonitorIntegrationTests.swift +++ b/ModSwitchIMETests/KeyMonitorIntegrationTests.swift @@ -159,7 +159,7 @@ class KeyMonitorIntegrationTests: XCTestCase { XCTAssertEqual(preferences.motherImeId, testImeId, "Should maintain IME preference") // Test that the preference is properly stored without involving system calls - let storedValue = UserDefaults.standard.string(forKey: "motherImeId") + let storedValue = preferences.userDefaults.string(forKey: "motherImeId") XCTAssertEqual(storedValue, testImeId, "IME preference should be stored in UserDefaults") } diff --git a/ModSwitchIMETests/PreferencesLogicTests.swift b/ModSwitchIMETests/PreferencesLogicTests.swift index d4af061..b22b1b2 100644 --- a/ModSwitchIMETests/PreferencesLogicTests.swift +++ b/ModSwitchIMETests/PreferencesLogicTests.swift @@ -9,12 +9,12 @@ class PreferencesLogicTests: XCTestCase { preferences = Preferences.createForTesting() // Store original values for cleanup - UserDefaults.standard.set("test_running", forKey: "test_marker") + preferences.userDefaults.set("test_running", forKey: "test_marker") } override func tearDown() { // Cleanup test data - UserDefaults.standard.removeObject(forKey: "test_marker") + preferences.userDefaults.removeObject(forKey: "test_marker") preferences = nil super.tearDown() } @@ -38,54 +38,66 @@ class PreferencesLogicTests: XCTestCase { // Then: Should not have any default modifier key mappings XCTAssertTrue(testPreferences.modifierKeyMappings.isEmpty, "Should not have default modifier key mappings") } + + func testCreateForTestingDoesNotModifyStandardUserDefaults() { + let markerKey = "ModSwitchIMEStandardDefaultsIsolationMarker" + let originalMappings = UserDefaults.standard.object(forKey: "modifierKeyMappings") + let sentinelMappings = Data("standard-sentinel".utf8) + UserDefaults.standard.set("keep", forKey: markerKey) + UserDefaults.standard.set(sentinelMappings, forKey: "modifierKeyMappings") + defer { + UserDefaults.standard.removeObject(forKey: markerKey) + if let originalMappings { + UserDefaults.standard.set(originalMappings, forKey: "modifierKeyMappings") + } else { + UserDefaults.standard.removeObject(forKey: "modifierKeyMappings") + } + } + + let testPreferences = Preferences.createForTesting() + testPreferences.idleTimeout = 42 + testPreferences.setIME("com.example.inputmethod.engine10.Japanese", for: .rightCommand) + + XCTAssertEqual(UserDefaults.standard.string(forKey: markerKey), "keep") + XCTAssertEqual(UserDefaults.standard.data(forKey: "modifierKeyMappings"), sentinelMappings) + XCTAssertNotNil(testPreferences.userDefaults.object(forKey: "modifierKeyMappings")) + } // MARK: - Preferences State Management Tests func testPreferencesInitializationWithExistingData() { - // Given: Store original values - let originalIdleOffEnabled = Preferences.shared.idleOffEnabled - let originalIdleTimeout = Preferences.shared.idleTimeout - let originalLaunchAtLogin = Preferences.shared.launchAtLogin - let originalMotherImeId = Preferences.shared.motherImeId - // When: Setting new values - Preferences.shared.idleOffEnabled = true - Preferences.shared.idleTimeout = 45.0 - Preferences.shared.launchAtLogin = true - Preferences.shared.motherImeId = "com.test.ime" + preferences.idleOffEnabled = true + preferences.idleTimeout = 45.0 + preferences.launchAtLogin = true + preferences.motherImeId = "com.test.ime" // Then: Values should be set - XCTAssertTrue(Preferences.shared.idleOffEnabled, "Should set idleOffEnabled") - XCTAssertEqual(Preferences.shared.idleTimeout, 45.0, "Should set idleTimeout") - XCTAssertTrue(Preferences.shared.launchAtLogin, "Should set launchAtLogin") - XCTAssertEqual(Preferences.shared.motherImeId, "com.test.ime", "Should set motherImeId") + XCTAssertTrue(preferences.idleOffEnabled, "Should set idleOffEnabled") + XCTAssertEqual(preferences.idleTimeout, 45.0, "Should set idleTimeout") + XCTAssertTrue(preferences.launchAtLogin, "Should set launchAtLogin") + XCTAssertEqual(preferences.motherImeId, "com.test.ime", "Should set motherImeId") // Verify persistence in UserDefaults - XCTAssertTrue(UserDefaults.standard.bool(forKey: "idleOffEnabled"), "Should persist idleOffEnabled") - XCTAssertEqual(UserDefaults.standard.double(forKey: "idleTimeout"), 45.0, "Should persist idleTimeout") - XCTAssertTrue(UserDefaults.standard.bool(forKey: "launchAtLogin"), "Should persist launchAtLogin") + XCTAssertTrue(preferences.userDefaults.bool(forKey: "idleOffEnabled"), "Should persist idleOffEnabled") + XCTAssertEqual(preferences.userDefaults.double(forKey: "idleTimeout"), 45.0, "Should persist idleTimeout") + XCTAssertTrue(preferences.userDefaults.bool(forKey: "launchAtLogin"), "Should persist launchAtLogin") XCTAssertEqual( - UserDefaults.standard.string(forKey: "motherImeId"), + preferences.userDefaults.string(forKey: "motherImeId"), "com.test.ime", "Should persist motherImeId" ) - - // Cleanup - restore original values - Preferences.shared.idleOffEnabled = originalIdleOffEnabled - Preferences.shared.idleTimeout = originalIdleTimeout - Preferences.shared.launchAtLogin = originalLaunchAtLogin - Preferences.shared.motherImeId = originalMotherImeId } func testPreferencesDefaultValues() { // Given: No existing preferences data - UserDefaults.standard.removeObject(forKey: "idleOffEnabled") - UserDefaults.standard.removeObject(forKey: "idleTimeout") - UserDefaults.standard.removeObject(forKey: "launchAtLogin") - UserDefaults.standard.removeObject(forKey: "motherImeId") + preferences.userDefaults.removeObject(forKey: "idleOffEnabled") + preferences.userDefaults.removeObject(forKey: "idleTimeout") + preferences.userDefaults.removeObject(forKey: "launchAtLogin") + preferences.userDefaults.removeObject(forKey: "motherImeId") // When: Creating new Preferences instance - let testPreferences = Preferences.createForTesting() + let testPreferences = Preferences.createForTesting(userDefaults: preferences.userDefaults) // Then: Should use default values XCTAssertFalse(testPreferences.idleOffEnabled, "Default idleOffEnabled should be false") @@ -163,7 +175,7 @@ class PreferencesLogicTests: XCTestCase { preferences.idleTimeout = newTimeout // Then: Should immediately sync to UserDefaults - let userDefaultsValue = UserDefaults.standard.double(forKey: "idleTimeout") + let userDefaultsValue = preferences.userDefaults.double(forKey: "idleTimeout") XCTAssertEqual(userDefaultsValue, newTimeout, "Should sync to UserDefaults immediately") // Restore original value @@ -182,9 +194,9 @@ class PreferencesLogicTests: XCTestCase { preferences.launchAtLogin = !initialLaunch // Then: All changes should be persisted - XCTAssertEqual(UserDefaults.standard.bool(forKey: "idleOffEnabled"), !initialIdleOff) - XCTAssertEqual(UserDefaults.standard.double(forKey: "idleTimeout"), 90.0) - XCTAssertEqual(UserDefaults.standard.bool(forKey: "launchAtLogin"), !initialLaunch) + XCTAssertEqual(preferences.userDefaults.bool(forKey: "idleOffEnabled"), !initialIdleOff) + XCTAssertEqual(preferences.userDefaults.double(forKey: "idleTimeout"), 90.0) + XCTAssertEqual(preferences.userDefaults.bool(forKey: "launchAtLogin"), !initialLaunch) // Restore original values preferences.idleOffEnabled = initialIdleOff @@ -216,18 +228,18 @@ class PreferencesLogicTests: XCTestCase { // and loaded correctly when creating new instances // Given: Store the original value to restore later - let originalValue = UserDefaults.standard.string(forKey: "motherImeId") + let originalValue = preferences.userDefaults.string(forKey: "motherImeId") // Set a test value in UserDefaults AFTER creating test instance let testImeId = "com.apple.inputmethod.test.ime" - let testPreferences = Preferences.createForTesting() + let testPreferences = Preferences.createForTesting(userDefaults: preferences.userDefaults) // When: Setting a value and verifying it's saved to UserDefaults testPreferences.motherImeId = testImeId // Then: The value should be persisted to UserDefaults XCTAssertEqual( - UserDefaults.standard.string(forKey: "motherImeId"), + preferences.userDefaults.string(forKey: "motherImeId"), testImeId, "Should persist motherImeId to UserDefaults" ) @@ -238,16 +250,16 @@ class PreferencesLogicTests: XCTestCase { // Verify it was saved to UserDefaults XCTAssertEqual( - UserDefaults.standard.string(forKey: "motherImeId"), + preferences.userDefaults.string(forKey: "motherImeId"), newImeId, "Should persist motherImeId to UserDefaults" ) // Cleanup: Restore original value if let original = originalValue { - UserDefaults.standard.set(original, forKey: "motherImeId") + preferences.userDefaults.set(original, forKey: "motherImeId") } else { - UserDefaults.standard.removeObject(forKey: "motherImeId") + preferences.userDefaults.removeObject(forKey: "motherImeId") } } @@ -347,51 +359,51 @@ class PreferencesLogicTests: XCTestCase { func testEmptyModifierKeyMappingsNotSaved() { // Given: Clean state - UserDefaults.standard.removeObject(forKey: "modifierKeyMappings") - let testPreferences = Preferences.createForTesting() + preferences.userDefaults.removeObject(forKey: "modifierKeyMappings") + let testPreferences = Preferences.createForTesting(userDefaults: preferences.userDefaults) // When: Empty mappings (initial state) // Then: Should not save empty dictionary to UserDefaults - XCTAssertNil(UserDefaults.standard.object(forKey: "modifierKeyMappings"), + XCTAssertNil(preferences.userDefaults.object(forKey: "modifierKeyMappings"), "Should not save empty modifierKeyMappings to UserDefaults") // When: Adding a mapping testPreferences.setIME("com.apple.keylayout.US", for: .leftCommand) // Then: Should save to UserDefaults - XCTAssertNotNil(UserDefaults.standard.object(forKey: "modifierKeyMappings"), + XCTAssertNotNil(preferences.userDefaults.object(forKey: "modifierKeyMappings"), "Should save non-empty modifierKeyMappings to UserDefaults") // When: Removing all mappings testPreferences.setIME(nil, for: .leftCommand) // Then: Should remove from UserDefaults - XCTAssertNil(UserDefaults.standard.object(forKey: "modifierKeyMappings"), + XCTAssertNil(preferences.userDefaults.object(forKey: "modifierKeyMappings"), "Should remove modifierKeyMappings from UserDefaults when empty") } func testEmptyModifierKeyEnabledNotSaved() { // Given: Clean state - UserDefaults.standard.removeObject(forKey: "modifierKeyEnabled") - let testPreferences = Preferences.createForTesting() + preferences.userDefaults.removeObject(forKey: "modifierKeyEnabled") + let testPreferences = Preferences.createForTesting(userDefaults: preferences.userDefaults) // When: Empty enabled states (initial state) // Then: Should not save empty dictionary to UserDefaults - XCTAssertNil(UserDefaults.standard.object(forKey: "modifierKeyEnabled"), + XCTAssertNil(preferences.userDefaults.object(forKey: "modifierKeyEnabled"), "Should not save empty modifierKeyEnabled to UserDefaults") // When: Enabling a key testPreferences.setKeyEnabled(true, for: .leftCommand) // Then: Should save to UserDefaults - XCTAssertNotNil(UserDefaults.standard.object(forKey: "modifierKeyEnabled"), + XCTAssertNotNil(preferences.userDefaults.object(forKey: "modifierKeyEnabled"), "Should save non-empty modifierKeyEnabled to UserDefaults") // When: Disabling all keys (removing from dictionary) testPreferences.modifierKeyEnabled.removeAll() // Then: Should remove from UserDefaults - XCTAssertNil(UserDefaults.standard.object(forKey: "modifierKeyEnabled"), + XCTAssertNil(preferences.userDefaults.object(forKey: "modifierKeyEnabled"), "Should remove modifierKeyEnabled from UserDefaults when empty") } @@ -406,7 +418,7 @@ class PreferencesLogicTests: XCTestCase { preferences.modifierKeyMappings = testMappings // Then: Should persist to UserDefaults as JSON - let data = UserDefaults.standard.data(forKey: "modifierKeyMappings") + let data = preferences.userDefaults.data(forKey: "modifierKeyMappings") XCTAssertNotNil(data, "Should save modifierKeyMappings as data") // Verify data can be decoded back @@ -434,7 +446,7 @@ class PreferencesLogicTests: XCTestCase { preferences.modifierKeyEnabled = testEnabled // Then: Should persist to UserDefaults as JSON - let data = UserDefaults.standard.data(forKey: "modifierKeyEnabled") + let data = preferences.userDefaults.data(forKey: "modifierKeyEnabled") XCTAssertNotNil(data, "Should save modifierKeyEnabled as data") // Verify data can be decoded back @@ -457,11 +469,11 @@ class PreferencesLogicTests: XCTestCase { // Given: Empty array data in UserDefaults (simulating the bug) let emptyArrayData = Data("[]".utf8) - UserDefaults.standard.set(emptyArrayData, forKey: "modifierKeyMappings") - UserDefaults.standard.set(emptyArrayData, forKey: "modifierKeyEnabled") + preferences.userDefaults.set(emptyArrayData, forKey: "modifierKeyMappings") + preferences.userDefaults.set(emptyArrayData, forKey: "modifierKeyEnabled") // When: Creating new Preferences instance - let testPreferences = Preferences.createForTesting() + let testPreferences = Preferences.createForTesting(userDefaults: preferences.userDefaults) // Then: Should handle gracefully and return empty dictionaries XCTAssertTrue(testPreferences.modifierKeyMappings.isEmpty, @@ -470,7 +482,7 @@ class PreferencesLogicTests: XCTestCase { "Should handle empty array data gracefully for enabled states") // Cleanup - UserDefaults.standard.removeObject(forKey: "modifierKeyMappings") - UserDefaults.standard.removeObject(forKey: "modifierKeyEnabled") + preferences.userDefaults.removeObject(forKey: "modifierKeyMappings") + preferences.userDefaults.removeObject(forKey: "modifierKeyEnabled") } } diff --git a/ModSwitchIMETests/PreferencesNoDefaultsTests.swift b/ModSwitchIMETests/PreferencesNoDefaultsTests.swift index 872dfde..b106caf 100644 --- a/ModSwitchIMETests/PreferencesNoDefaultsTests.swift +++ b/ModSwitchIMETests/PreferencesNoDefaultsTests.swift @@ -6,17 +6,6 @@ class PreferencesNoDefaultsTests: XCTestCase { override func setUp() { super.setUp() - // Clear all UserDefaults to ensure clean state - let keysToRemove = [ - "idleOffEnabled", "idleTimeout", "launchAtLogin", "motherImeId", - "cmdKeyTimeout", "cmdKeyTimeoutEnabled", "idleReturnIME", - "modifierKeyMappings", "modifierKeyEnabled" - ] - for key in keysToRemove { - UserDefaults.standard.removeObject(forKey: key) - } - UserDefaults.standard.synchronize() - preferences = Preferences.createForTesting() } @@ -77,15 +66,15 @@ class PreferencesNoDefaultsTests: XCTestCase { "modifierKeyMappings", "modifierKeyEnabled" ] for key in keysToRemove { - UserDefaults.standard.removeObject(forKey: key) + preferences.userDefaults.removeObject(forKey: key) } // Set up the scenario: motherImeId exists but no modifier mappings - UserDefaults.standard.set(testImeId, forKey: "motherImeId") - UserDefaults.standard.synchronize() + preferences.userDefaults.set(testImeId, forKey: "motherImeId") + preferences.userDefaults.synchronize() // When: Creating new preferences instance (using migration testing method) - let testPreferences = Preferences.createForMigrationTesting() + let testPreferences = Preferences.createForTesting(userDefaults: preferences.userDefaults) // Then: Should NOT migrate motherImeId to any command mapping XCTAssertEqual(testPreferences.motherImeId, testImeId, "motherImeId should be preserved") @@ -108,22 +97,22 @@ class PreferencesNoDefaultsTests: XCTestCase { "modifierKeyMappings", "modifierKeyEnabled" ] for key in keysToRemove { - UserDefaults.standard.removeObject(forKey: key) + preferences.userDefaults.removeObject(forKey: key) } // Set up the scenario: both old motherImeId and existing mappings - UserDefaults.standard.set(oldImeId, forKey: "motherImeId") + preferences.userDefaults.set(oldImeId, forKey: "motherImeId") // Set up existing modifier key mappings let mappings = [ModifierKey.leftCommand: newImeId] let encoder = JSONEncoder() if let data = try? encoder.encode(mappings) { - UserDefaults.standard.set(data, forKey: "modifierKeyMappings") + preferences.userDefaults.set(data, forKey: "modifierKeyMappings") } - UserDefaults.standard.synchronize() + preferences.userDefaults.synchronize() // When: Creating new preferences instance (using migration testing method) - let testPreferences = Preferences.createForMigrationTesting() + let testPreferences = Preferences.createForTesting(userDefaults: preferences.userDefaults) // Then: Should keep existing mappings and NOT do any migration XCTAssertEqual(testPreferences.motherImeId, oldImeId, "motherImeId should be preserved") diff --git a/ModSwitchIMETests/PreferencesViewTests.swift b/ModSwitchIMETests/PreferencesViewTests.swift index 99f8a1a..7603a71 100644 --- a/ModSwitchIMETests/PreferencesViewTests.swift +++ b/ModSwitchIMETests/PreferencesViewTests.swift @@ -53,7 +53,7 @@ class PreferencesViewTests: XCTestCase { preferences.idleTimeout = newTimeout // Then: Value should persist in UserDefaults - let persistedValue = UserDefaults.standard.double(forKey: "idleTimeout") + let persistedValue = preferences.userDefaults.double(forKey: "idleTimeout") XCTAssertEqual(persistedValue, newTimeout, "Timeout value should persist in UserDefaults") // Cleanup: Restore original value @@ -79,7 +79,7 @@ class PreferencesViewTests: XCTestCase { preferences.motherImeId = testImeId // Then: Value should persist in UserDefaults - let persistedValue = UserDefaults.standard.string(forKey: "motherImeId") + let persistedValue = preferences.userDefaults.string(forKey: "motherImeId") XCTAssertEqual(persistedValue, testImeId, "Mother IME ID should persist in UserDefaults") } @@ -123,7 +123,7 @@ class PreferencesViewTests: XCTestCase { XCTAssertNotEqual(preferences.launchAtLogin, initialState, "Launch at login state should change") // And: Should persist in UserDefaults - let persistedValue = UserDefaults.standard.bool(forKey: "launchAtLogin") + let persistedValue = preferences.userDefaults.bool(forKey: "launchAtLogin") XCTAssertEqual(persistedValue, preferences.launchAtLogin, "Launch at login should persist in UserDefaults") // Cleanup: Restore original state diff --git a/ModSwitchIMETests/UIStateTransitionTests.swift b/ModSwitchIMETests/UIStateTransitionTests.swift index 2fd5fdb..ca9e815 100644 --- a/ModSwitchIMETests/UIStateTransitionTests.swift +++ b/ModSwitchIMETests/UIStateTransitionTests.swift @@ -285,9 +285,9 @@ class UIStateTransitionTests: XCTestCase { XCTAssertEqual(preferences.motherImeId, "test.ime.id") // And verify they persist in UserDefaults - XCTAssertEqual(UserDefaults.standard.bool(forKey: "idleOffEnabled"), true) - XCTAssertEqual(UserDefaults.standard.double(forKey: "idleTimeout"), 45.0) - XCTAssertEqual(UserDefaults.standard.string(forKey: "motherImeId"), "test.ime.id") + XCTAssertEqual(preferences.userDefaults.bool(forKey: "idleOffEnabled"), true) + XCTAssertEqual(preferences.userDefaults.double(forKey: "idleTimeout"), 45.0) + XCTAssertEqual(preferences.userDefaults.string(forKey: "motherImeId"), "test.ime.id") // Cleanup - restore original values preferences.idleOffEnabled = originalIdleOffEnabled