diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index 1a24d63..bcceeaa 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 25C5A6B52E678D86005AB80E /* PunctuationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C5A6B42E678D86005AB80E /* PunctuationService.swift */; }; + 25C5A6B72E679DCD005AB80E /* InputMonitoringRequiredBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C5A6B62E679DCD005AB80E /* InputMonitoringRequiredBadge.swift */; }; 4A093071285DACAA00232089 /* NSToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A093070285DACAA00232089 /* NSToggleView.swift */; }; 4A093073285E166E00232089 /* PreferencesVM+AppKeyboardCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A093072285E166E00232089 /* PreferencesVM+AppKeyboardCache.swift */; }; 4A093075285EE55A00232089 /* RulesApplicationPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A093074285EE55A00232089 /* RulesApplicationPicker.swift */; }; @@ -174,6 +176,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 25C5A6B42E678D86005AB80E /* PunctuationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PunctuationService.swift; sourceTree = ""; }; + 25C5A6B62E679DCD005AB80E /* InputMonitoringRequiredBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitoringRequiredBadge.swift; sourceTree = ""; }; 4A093070285DACAA00232089 /* NSToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSToggleView.swift; sourceTree = ""; }; 4A093072285E166E00232089 /* PreferencesVM+AppKeyboardCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreferencesVM+AppKeyboardCache.swift"; sourceTree = ""; }; 4A093074285EE55A00232089 /* RulesApplicationPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesApplicationPicker.swift; sourceTree = ""; }; @@ -427,6 +431,7 @@ 4A2A175D280BA7FA00E13249 /* Utilities */ = { isa = PBXGroup; children = ( + 25C5A6B42E678D86005AB80E /* PunctuationService.swift */, D598C7E228B74B16004747D1 /* Indicator */, D598C7E428B74B48004747D1 /* AppKit */, D598C7E328B74B32004747D1 /* Accessibility */, @@ -516,6 +521,7 @@ 4A2A177A280BA7FA00E13249 /* Components */ = { isa = PBXGroup; children = ( + 25C5A6B62E679DCD005AB80E /* InputMonitoringRequiredBadge.swift */, 4A2A177B280BA7FA00E13249 /* IndicatorView.swift */, 4A1BD90E287BD14000E4D8C2 /* CustomizedIndicatorView.swift */, 4AC450BE281D841300DA0329 /* PreferenceSection.swift */, @@ -811,6 +817,7 @@ 4A881A93288BC55E00B76498 /* PreferencesVM+KeyboardConfig.swift in Sources */, 4A2A1789280BA7FA00E13249 /* TISInputSource+Extension.swift in Sources */, 4AC50D6B283A72810034E894 /* NSColor.swift in Sources */, + 25C5A6B52E678D86005AB80E /* PunctuationService.swift in Sources */, 4ACC9B812D2640F70002B8CE /* AddSwitchingGroupButton.swift in Sources */, 4A2A178C280BA7FA00E13249 /* CancelBag.swift in Sources */, 4A4167302813CAE600CCC28C /* PreferencesVM+BrowserRule.swift in Sources */, @@ -878,6 +885,7 @@ 4A093073285E166E00232089 /* PreferencesVM+AppKeyboardCache.swift in Sources */, 4A2A179D280BA7FA00E13249 /* GeneralSettingsView.swift in Sources */, 4A5EAA6C280BF6F700A9E332 /* AppearanceSettingsView.swift in Sources */, + 25C5A6B72E679DCD005AB80E /* InputMonitoringRequiredBadge.swift in Sources */, D5CC449E2A353E81007FF839 /* BrowserRuleMenuItem.swift in Sources */, 4A2A1780280BA7FA00E13249 /* IndicatorViewController.swift in Sources */, 4A093075285EE55A00232089 /* RulesApplicationPicker.swift in Sources */, diff --git a/Input Source Pro/Models/IndicatorVM.swift b/Input Source Pro/Models/IndicatorVM.swift index 6c53419..61eae71 100644 --- a/Input Source Pro/Models/IndicatorVM.swift +++ b/Input Source Pro/Models/IndicatorVM.swift @@ -14,6 +14,7 @@ final class IndicatorVM: ObservableObject { let preferencesVM: PreferencesVM let inputSourceVM: InputSourceVM let permissionsVM: PermissionsVM + let punctuationService: PunctuationService let logger = ISPLogger(category: String(describing: IndicatorVM.self)) @@ -62,6 +63,7 @@ final class IndicatorVM: ObservableObject { self.preferencesVM = preferencesVM self.applicationVM = applicationVM self.inputSourceVM = inputSourceVM + self.punctuationService = PunctuationService(preferencesVM: preferencesVM) state = .from( preferencesVM: preferencesVM, inputSourceChangeReason: .system, @@ -71,6 +73,7 @@ final class IndicatorVM: ObservableObject { clearAppKeyboardCacheIfNeed() watchState() + watchPunctuationRules() } private func clearAppKeyboardCacheIfNeed() { @@ -92,6 +95,23 @@ final class IndicatorVM: ObservableObject { } .store(in: cancelBag) } + + private func watchPunctuationRules() { + applicationVM.$appKind + .compactMap { $0 } + .sink { [weak self] appKind in + guard let self = self else { return } + + let app = appKind.getApp() + if self.punctuationService.shouldEnableForApp(app) { + self.logger.debug { "Enabling English punctuation for app: \(app.localizedName ?? app.bundleIdentifier ?? "Unknown")" } + self.punctuationService.enable() + } else { + self.punctuationService.disable() + } + } + .store(in: cancelBag) + } } extension IndicatorVM { diff --git a/Input Source Pro/Models/PermissionsVM.swift b/Input Source Pro/Models/PermissionsVM.swift index e4dff67..2416c39 100644 --- a/Input Source Pro/Models/PermissionsVM.swift +++ b/Input Source Pro/Models/PermissionsVM.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import IOKit @MainActor final class PermissionsVM: ObservableObject { @@ -9,10 +10,63 @@ final class PermissionsVM: ObservableObject { return AXIsProcessTrustedWithOptions([checkOptPrompt: prompt] as CFDictionary?) } + @discardableResult + static func checkInputMonitoring(prompt: Bool) -> Bool { + // Multi-strategy permission checking for better reliability + + // Strategy 1: IOHIDCheckAccess (most reliable) + if checkInputMonitoringViaIOHID() { + return true + } + + // Strategy 2: CGEvent.tapCreate (traditional method) + if checkInputMonitoringViaCGEvent(prompt: prompt) { + return true + } + + // Strategy 3: Delayed retry for timing-sensitive cases + if !prompt { + // For non-prompt calls, try a brief delay and retry + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // Retry the check after a brief delay + _ = checkInputMonitoringViaCGEvent(prompt: false) + } + } + + return false + } + + private static func checkInputMonitoringViaIOHID() -> Bool { + // Use IOHIDCheckAccess for reliable permission checking + let access = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) + return access == kIOHIDAccessTypeGranted + } + + private static func checkInputMonitoringViaCGEvent(prompt: Bool) -> Bool { + let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: prompt ? .defaultTap : .listenOnly, + eventsOfInterest: 1, + callback: { _, _, event, _ in + return Unmanaged.passUnretained(event) + }, + userInfo: nil + ) + + let hasPermission = (eventTap != nil) + if let tap = eventTap { + CFMachPortInvalidate(tap) + } + return hasPermission + } + @Published var isAccessibilityEnabled = PermissionsVM.checkAccessibility(prompt: false) + @Published var isInputMonitoringEnabled = PermissionsVM.checkInputMonitoring(prompt: false) init() { watchAccessibilityChange() + watchInputMonitoringChange() } private func watchAccessibilityChange() { @@ -25,4 +79,15 @@ final class PermissionsVM: ObservableObject { .first() .assign(to: &$isAccessibilityEnabled) } + + private func watchInputMonitoringChange() { + guard !isInputMonitoringEnabled else { return } + + Timer + .interval(seconds: 1) + .map { _ in Self.checkInputMonitoring(prompt: false) } + .filter { $0 } + .first() + .assign(to: &$isInputMonitoringEnabled) + } } diff --git a/Input Source Pro/Models/PreferencesVM+AppCustomization.swift b/Input Source Pro/Models/PreferencesVM+AppCustomization.swift index e2c006d..2e23013 100644 --- a/Input Source Pro/Models/PreferencesVM+AppCustomization.swift +++ b/Input Source Pro/Models/PreferencesVM+AppCustomization.swift @@ -65,6 +65,14 @@ extension PreferencesVM { } } + func setForceEnglishPunctuation(_ appCustomization: AppRule?, _ forceEnglishPunctuation: Bool) { + guard let appCustomization = appCustomization else { return } + + saveContext { + appCustomization.forceEnglishPunctuation = forceEnglishPunctuation + } + } + func getAppCustomization(app: NSRunningApplication) -> AppRule? { return getAppCustomization(bundleId: app.bundleId()) } diff --git a/Input Source Pro/Persistence/AppRule.swift b/Input Source Pro/Persistence/AppRule.swift index 6111445..d38bf1f 100644 --- a/Input Source Pro/Persistence/AppRule.swift +++ b/Input Source Pro/Persistence/AppRule.swift @@ -15,4 +15,8 @@ extension AppRule { return InputSource.sources.first { $0.id == inputSourceId } } + + var shouldForceEnglishPunctuation: Bool { + return forceEnglishPunctuation + } } diff --git a/Input Source Pro/Persistence/Main.xcdatamodeld/Main.xcdatamodel/contents b/Input Source Pro/Persistence/Main.xcdatamodeld/Main.xcdatamodel/contents index 8078249..cd36bdd 100644 --- a/Input Source Pro/Persistence/Main.xcdatamodeld/Main.xcdatamodel/contents +++ b/Input Source Pro/Persistence/Main.xcdatamodeld/Main.xcdatamodel/contents @@ -6,6 +6,7 @@ + diff --git a/Input Source Pro/Resources/Info.plist b/Input Source Pro/Resources/Info.plist index 721fc4c..efa720e 100644 --- a/Input Source Pro/Resources/Info.plist +++ b/Input Source Pro/Resources/Info.plist @@ -26,6 +26,8 @@ NSHumanReadableCopyright + NSAppleEventsUsageDescription + Input Source Pro needs to monitor keyboard input to automatically switch between English and Chinese punctuation marks based on your app-specific rules. SUEnableAutomaticChecks SUPublicEDKey diff --git a/Input Source Pro/Resources/Signing.entitlements b/Input Source Pro/Resources/Signing.entitlements index 49ad0bb..3775532 100644 --- a/Input Source Pro/Resources/Signing.entitlements +++ b/Input Source Pro/Resources/Signing.entitlements @@ -4,5 +4,9 @@ com.apple.security.automation.apple-events + com.apple.security.app-sandbox + + com.apple.security.device.input-monitoring + diff --git a/Input Source Pro/Resources/en.lproj/Localizable.strings b/Input Source Pro/Resources/en.lproj/Localizable.strings index ebdede7..ac703c1 100644 --- a/Input Source Pro/Resources/en.lproj/Localizable.strings +++ b/Input Source Pro/Resources/en.lproj/Localizable.strings @@ -117,6 +117,10 @@ "Restore Previously Used One" = "Restore Previously Used One"; "Indicator" = "Indicator"; "Hide Indicator" = "Hide Indicator"; +"Punctuation" = "Punctuation"; +"ASCII Punctuation" = "ASCII Punctuation"; +"Force English Punctuation" = "Force English Punctuation"; +"Input Monitoring Required" = "Input Monitoring Required"; "Add Running Apps" = "Add Running Apps"; "(unknown)" = "(unknown)"; diff --git a/Input Source Pro/Resources/ja.lproj/Localizable.strings b/Input Source Pro/Resources/ja.lproj/Localizable.strings index 3c35f6e..0c749b8 100644 --- a/Input Source Pro/Resources/ja.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ja.lproj/Localizable.strings @@ -117,6 +117,10 @@ "Restore Previously Used One" = "以前使用したものを復元"; "Indicator" = "ツールチップ"; "Hide Indicator" = "ツールチップを隠す"; +"Punctuation" = "句読点"; +"ASCII Punctuation" = "ASCII 句読点"; +"Force English Punctuation" = "英語句読点を強制使用"; +"Input Monitoring Required" = "入力監視権限が必要"; "Add Running Apps" = "実行中のアプリを追加"; "(unknown)" = "(不明)"; diff --git a/Input Source Pro/Resources/ko.lproj/Localizable.strings b/Input Source Pro/Resources/ko.lproj/Localizable.strings index be5209d..ab35f04 100644 --- a/Input Source Pro/Resources/ko.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ko.lproj/Localizable.strings @@ -117,6 +117,10 @@ "Restore Previously Used One" = "이전에 사용한 것 복원"; "Indicator" = "표시기"; "Hide Indicator" = "표시기 숨기기"; +"Punctuation" = "구두점"; +"ASCII Punctuation" = "ASCII 구두점"; +"Force English Punctuation" = "영어 구두점 강제 사용"; +"Input Monitoring Required" = "입력 모니터링 권한 필요"; "Add Running Apps" = "실행 중인 앱 추가"; "(unknown)" = "(알 수 없음)"; diff --git a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings index 061a833..2395ce0 100644 --- a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings @@ -118,6 +118,10 @@ "Restore Previously Used One" = "恢复上次使用的输入法"; "Indicator" = "提示"; "Hide Indicator" = "隐藏输入法提示"; +"Punctuation" = "标点符号"; +"ASCII Punctuation" = "ASCII 标点符号"; +"Force English Punctuation" = "强制使用英文标点符号"; +"Input Monitoring Required" = "需要输入监控权限"; "Add Running Apps" = "添加运行中的应用"; "(unknown)" = "(未知)"; diff --git a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings index 9a653a6..595120b 100644 --- a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings @@ -118,6 +118,10 @@ "Restore Previously Used One" = "復原上次使用的輸入法"; "Indicator" = "提示"; "Hide Indicator" = "隱藏輸入法提示"; +"Punctuation" = "標點符號"; +"ASCII Punctuation" = "ASCII 標點符號"; +"Force English Punctuation" = "強制使用英文標點符號"; +"Input Monitoring Required" = "需要輸入監控權限"; "Add Running Apps" = "加入執行中的 Apps"; "(unknown)" = "(未知)"; diff --git a/Input Source Pro/UI/Components/InputMonitoringRequiredBadge.swift b/Input Source Pro/UI/Components/InputMonitoringRequiredBadge.swift new file mode 100644 index 0000000..1a4fac0 --- /dev/null +++ b/Input Source Pro/UI/Components/InputMonitoringRequiredBadge.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct InputMonitoringRequiredBadge: View { + @EnvironmentObject var permissionsVM: PermissionsVM + + var body: some View { + Button(action: { + NSWorkspace.shared.openInputMonitoringPreferences() + }) { + Text("Input Monitoring Required".i18n()) + } + .buttonStyle(InputMonitoringRequiredButtonStyle()) + .opacity(permissionsVM.isInputMonitoringEnabled ? 0 : 1) + .animation(.easeInOut, value: permissionsVM.isInputMonitoringEnabled) + } +} + +struct InputMonitoringRequiredButtonStyle: ButtonStyle { + func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .font(.system(size: 10)) + .padding(.horizontal, 4) + .padding(.vertical, 3) + .background(Color.orange) + .foregroundColor(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) + } +} \ No newline at end of file diff --git a/Input Source Pro/UI/Components/RulesApplicationDetail.swift b/Input Source Pro/UI/Components/RulesApplicationDetail.swift index 25d31b2..ab3b66d 100644 --- a/Input Source Pro/UI/Components/RulesApplicationDetail.swift +++ b/Input Source Pro/UI/Components/RulesApplicationDetail.swift @@ -21,6 +21,7 @@ struct ApplicationDetail: View { @State var doRestoreKeyboardState = NSToggleViewState.off @State var doNotRestoreKeyboardState = NSToggleViewState.off @State var hideIndicator = NSToggleViewState.off + @State var forceEnglishPunctuation = NSToggleViewState.off var mixed: Bool { Set(selectedApp.map { $0.forcedKeyboard?.id }).count > 1 @@ -106,12 +107,36 @@ struct ApplicationDetail: View { } } + Divider() + .padding(.vertical, 4) + + VStack(alignment: .leading) { + Text("Punctuation".i18n()) + .fontWeight(.medium) + HStack { + Image(systemName: "textformat.abc") + .foregroundColor(.orange) + NSToggleView( + label: "Force English Punctuation".i18n(), + state: forceEnglishPunctuation, + onStateUpdate: handleToggleForceEnglishPunctuation + ) + .fixedSize() + } + } + if selectedApp.contains(where: { preferencesVM.needDisplayEnhancedModePrompt(bundleIdentifier: $0.bundleId) }) { Divider().padding(.vertical, 4) EnhancedModeRequiredBadge() } + if selectedApp.contains(where: { $0.forceEnglishPunctuation }) { + Divider().padding(.vertical, 4) + + InputMonitoringRequiredBadge() + } + Spacer() } .disabled(selectedApp.isEmpty) @@ -120,6 +145,7 @@ struct ApplicationDetail: View { updateDoRestoreKeyboardState() updateDoNotRestoreKeyboardState() updateHideIndicatorState() + updateForceEnglishPunctuationState() } } @@ -163,6 +189,16 @@ struct ApplicationDetail: View { } } + func updateForceEnglishPunctuationState() { + let stateSet = Set(selectedApp.map { $0.forceEnglishPunctuation }) + + if stateSet.count > 1 { + forceEnglishPunctuation = .mixed + } else { + forceEnglishPunctuation = stateSet.first == true ? .on : .off + } + } + func handleSelect(_ index: Int) { forceKeyboard = items[index] @@ -210,6 +246,19 @@ struct ApplicationDetail: View { } } + func handleToggleForceEnglishPunctuation() -> NSControl.StateValue { + switch forceEnglishPunctuation { + case .on: + selectedApp.forEach { preferencesVM.setForceEnglishPunctuation($0, false) } + forceEnglishPunctuation = .off + return .off + case .off, .mixed: + selectedApp.forEach { preferencesVM.setForceEnglishPunctuation($0, true) } + forceEnglishPunctuation = .on + return .on + } + } + func restoreStrategyName(strategy: KeyboardRestoreStrategy) -> String { strategy.name + restoreStrategyTips(strategy: strategy) } diff --git a/Input Source Pro/Utilities/AppKit/NSWorkspace.swift b/Input Source Pro/Utilities/AppKit/NSWorkspace.swift index 50202be..08e5e7f 100644 --- a/Input Source Pro/Utilities/AppKit/NSWorkspace.swift +++ b/Input Source Pro/Utilities/AppKit/NSWorkspace.swift @@ -7,6 +7,12 @@ extension NSWorkspace { ) } + func openInputMonitoringPreferences() { + open( + URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")! + ) + } + func openAutomationPreferences() { open( URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")! diff --git a/Input Source Pro/Utilities/PunctuationService.swift b/Input Source Pro/Utilities/PunctuationService.swift new file mode 100644 index 0000000..d5ef497 --- /dev/null +++ b/Input Source Pro/Utilities/PunctuationService.swift @@ -0,0 +1,258 @@ +import AppKit +import Carbon +import Combine +import IOKit +import os + +@MainActor +class PunctuationService: ObservableObject { + private let logger = ISPLogger(category: String(describing: PunctuationService.self)) + + private var isEnabled = false + private var eventTap: CFMachPort? + private weak var preferencesVM: PreferencesVM? + + // Performance optimization: Cache input source state to reduce system calls + private var cachedInputSource: InputSource? + private var inputSourceCacheTime: TimeInterval = 0 + private let inputSourceCacheTimeout: TimeInterval = 0.5 // Cache for 500ms + + private let cjkvToEnglishPunctuationMap: [UInt16: String] = [ + // Correct macOS keyCode mappings for punctuation marks + 43: ",", // 0x2B - Comma key -> , + 47: ".", // 0x2F - Period key -> . + 41: ";", // 0x29 - Semicolon key -> ; + 39: "'", // 0x27 - Single Quote key -> ' + 42: "\"", // 0x2A - Double Quote key -> " + 33: "[", // 0x21 - Left Bracket key -> [ + 30: "]", // 0x1E - Right Bracket key -> ] + 49: " ", // 0x31 - Space key -> space (for full-width space handling) + ] + + init(preferencesVM: PreferencesVM) { + self.preferencesVM = preferencesVM + } + + deinit { + // Ensure cleanup happens regardless of disable() being called + // Note: Direct cleanup since deinit is not on MainActor + if let eventTap = eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + CFMachPortInvalidate(eventTap) + } + } + + func enable() { + guard !isEnabled else { return } + + let hasPermission = PermissionsVM.checkInputMonitoring(prompt: false) + + if !hasPermission { + logger.debug { "Input Monitoring permission check failed, attempting fallback activation" } + // Try to enable anyway - permission check might be unreliable + // If it fails, startMonitoring() will handle it gracefully + } else { + logger.debug { "Input Monitoring permission verified" } + } + + logger.debug { "Enabling English punctuation service for app-aware switching" } + let success = startMonitoring() + + if success { + isEnabled = true + logger.debug { "English punctuation service started successfully" } + } else { + logger.debug { "Failed to start English punctuation service - Input Monitoring permission required" } + // Service will remain disabled until next enable() call or permission state change + } + } + + func disable() { + guard isEnabled else { return } + + logger.debug { "Disabling English punctuation service" } + stopMonitoring() + isEnabled = false + } + + @discardableResult + private func startMonitoring() -> Bool { + stopMonitoring() + + // Skip unreliable preflight checks - directly attempt event tap creation + // We've already verified permissions through IOHIDCheckAccess + logger.debug { "Starting event tap creation (skipping preflight checks)" } + + let eventMask = (1 << CGEventType.keyDown.rawValue) + + let callback: CGEventTapCallBack = { proxy, type, event, refcon in + guard let refcon = refcon, + let service = Unmanaged.fromOpaque(refcon).takeUnretainedValue() as? PunctuationService + else { + return Unmanaged.passUnretained(event) + } + + return service.handleKeyEvent(proxy: proxy, type: type, event: event) + } + + // Try different event tap configurations for better compatibility + // IMPORTANT: We must NOT use `.listenOnly` here because we need to + // modify/replace key events. `.listenOnly` ignores returned events. + let configurations: [(options: CGEventTapOptions, place: CGEventTapPlacement, description: String)] = [ + // Prefer default (modifiable) taps first + (.defaultTap, .headInsertEventTap, "Default + Head insertion"), + (.defaultTap, .tailAppendEventTap, "Default + Tail insertion") + ] + + for (index, config) in configurations.enumerated() { + logger.debug { "Attempting event tap creation - \(config.description)" } + + eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: config.place, + options: config.options, + eventsOfInterest: CGEventMask(eventMask), + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + + if let eventTap = eventTap { + let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) + + logger.debug { "✅ Event tap created successfully using \(config.description)" } + return true + } else { + logger.debug { "❌ Failed: \(config.description) - trying next configuration" } + } + } + + // If all configurations failed, provide detailed diagnostic info + logger.debug { "❌ All event tap configurations failed. Diagnostic info:" } + checkServiceStatus() + + return false + } + + private func stopMonitoring() { + if let eventTap = eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + CFMachPortInvalidate(eventTap) + self.eventTap = nil + logger.debug { "Event tap disabled and invalidated" } + } + + // Clear cached input source to ensure fresh state on next enable + cachedInputSource = nil + inputSourceCacheTime = 0 + } + + private func handleKeyEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged { + // Handle event tap being disabled (can happen if permissions are revoked) + guard isEnabled else { + return Unmanaged.passUnretained(event) + } + + guard type == .keyDown else { + return Unmanaged.passUnretained(event) + } + + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + + // Check if this is a punctuation key we want to intercept + guard let englishReplacement = cjkvToEnglishPunctuationMap[UInt16(keyCode)] else { + // Not a punctuation key we're interested in + return Unmanaged.passUnretained(event) + } + + // Check if we're in a Chinese/CJKV input method (with caching for performance) + let currentInputSource = getCachedCurrentInputSource() + guard currentInputSource.isCJKVR else { + // Already in English/ASCII input method, no need to intercept + return Unmanaged.passUnretained(event) + } + + logger.debug { "🎯 Intercepting punctuation key: \(keyCode) ('\(englishReplacement)') in CJKV input method: \(currentInputSource.name ?? "unknown")" } + + // Create a new event with English replacement + if let newEvent = createEnglishPunctuationEvent(originalEvent: event, replacement: englishReplacement) { + logger.debug { "✅ Successfully created replacement event, returning new event" } + return Unmanaged.passRetained(newEvent) + } else { + logger.debug { "❌ Failed to create replacement event, passing through original" } + return Unmanaged.passUnretained(event) + } + } + + private func createEnglishPunctuationEvent(originalEvent: CGEvent, replacement: String) -> CGEvent? { + // Use the original keyCode but with English character replacement + let originalKeyCode = CGKeyCode(originalEvent.getIntegerValueField(.keyboardEventKeycode)) + + // Create a new keyboard event using the original key code with privateState to avoid modifier pollution + guard let source = CGEventSource(stateID: .privateState), + let newEvent = CGEvent(keyboardEventSource: source, virtualKey: originalKeyCode, keyDown: true) + else { + logger.debug { "Failed to create CGEventSource or CGEvent with keyCode: \(originalKeyCode)" } + return nil + } + + // Set the Unicode string for the replacement character + let unicodeString = Array(replacement.utf16) + newEvent.keyboardSetUnicodeString(stringLength: unicodeString.count, unicodeString: unicodeString) + + // Copy relevant properties from the original event (but not flags to avoid modifier conflicts) + newEvent.timestamp = originalEvent.timestamp + + // Explicitly set flags to none to ensure clean character input + newEvent.flags = [] + + logger.debug { "Created ASCII replacement event for: '\(replacement)' using original keyCode: \(originalKeyCode)" } + + return newEvent + } + + func shouldEnableForApp(_ app: NSRunningApplication) -> Bool { + guard let preferencesVM = preferencesVM else { return false } + + let appRule = preferencesVM.getAppCustomization(app: app) + return appRule?.shouldForceEnglishPunctuation == true + } + + /// Get current input source with caching to improve performance during rapid typing + private func getCachedCurrentInputSource() -> InputSource { + let currentTime = CACurrentMediaTime() + + // Return cached value if it's still valid + if let cached = cachedInputSource, + currentTime - inputSourceCacheTime < inputSourceCacheTimeout { + return cached + } + + // Cache has expired or doesn't exist, fetch new value + let currentInputSource = InputSource.getCurrentInputSource() + cachedInputSource = currentInputSource + inputSourceCacheTime = currentTime + + return currentInputSource + } + + /// Check current service status and log detailed information for debugging + func checkServiceStatus() { + let permissionViaIOHID = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted + let permissionViaCGEvent = PermissionsVM.checkInputMonitoring(prompt: false) + let accessibilityEnabled = PermissionsVM.checkAccessibility(prompt: false) + let currentInputSource = InputSource.getCurrentInputSource() + + logger.debug { """ + 🔍 English Punctuation Service Diagnostic: + - Service Enabled: \(isEnabled) + - Event Tap Active: \(eventTap != nil) + - IOHIDCheckAccess (Input Monitoring): \(permissionViaIOHID ? "✅ Granted" : "❌ Denied") + - CGEvent Permission Check: \(permissionViaCGEvent ? "✅ Passed" : "❌ Failed") + - Accessibility Permission: \(accessibilityEnabled ? "✅ Granted" : "❌ Denied") + - Current Input Source: \(currentInputSource.name ?? "unknown") (CJKV: \(currentInputSource.isCJKVR)) + - Monitored Keys: \(cjkvToEnglishPunctuationMap.map { "\($0.key)→'\($0.value)'" }.joined(separator: ", ")) + """ } + } +}