Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .envrc.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions ModSwitchIME.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -39,6 +41,8 @@
001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImeController.swift; sourceTree = "<group>"; };
001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourcePickerViews.swift; sourceTree = "<group>"; };
001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+StateMonitoring.swift"; sourceTree = "<group>"; };
001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModSwitchIMEError.swift; sourceTree = "<group>"; };
001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 1 addition & 1 deletion ModSwitchIME/Config/Version.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
227 changes: 227 additions & 0 deletions ModSwitchIME/ImeController+StateMonitoring.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
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<CFString>.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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The delay 0.2 used for verifying IME state after an app switch is a magic number. Consider defining it as a constant like appSwitchVerificationDelay.

self?.verifyIMEStateAfterAppSwitch()
}
}

func verifyIMEStateAfterAppSwitch() {
let actualIME = getCurrentInputSource()
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
)

refreshInputSourceCache()

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)
}
}

func setLastSwitchedIME(_ imeId: String) {
lastSwitchedIMEQueue.sync {
lastSwitchedIME = imeId
}
}

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
}
Loading