diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index 1c845b2..ce5904e 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -176,6 +176,13 @@ D60000722F20000000000072 /* AppURLActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000712F20000000000071 /* AppURLActionTests.swift */; }; D60000822F20000000000082 /* URLActivationSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000812F20000000000081 /* URLActivationSuppressionTests.swift */; }; D60001022F20000000000102 /* AppKindComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60001012F20000000000101 /* AppKindComparisonTests.swift */; }; + C13120191255119A7A95E7C2 /* MarkdownPunctuationMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C131A22D5FAE13E5E9F14E9F /* MarkdownPunctuationMapping.swift */; }; + C1310BA14D066C83C9331602 /* MarkdownModeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C131A06895BA856414E76CE3 /* MarkdownModeController.swift */; }; + C131FE7BD70BFF2056A66384 /* MarkdownModeSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13161EC493FE46EA8BCD8CD /* MarkdownModeSettingsView.swift */; }; + C1315059B7754D59E9CFFAD6 /* MarkdownPunctuationMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */; }; + C131285C8C1EBC5FDCF0111E /* PunctuationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1311723BF0607366B8F236C /* PunctuationServiceTests.swift */; }; + C13103D84A17E0335FF5132B /* PermissionsVMTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C131B63FBB110A4009BABB8E /* PermissionsVMTests.swift */; }; + C131FE642ECFD99AA25588ED /* SettingsBackupPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C131984D932C971B3780C1D0 /* SettingsBackupPreferencesTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -367,6 +374,13 @@ D60000712F20000000000071 /* AppURLActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLActionTests.swift; sourceTree = ""; }; D60000812F20000000000081 /* URLActivationSuppressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivationSuppressionTests.swift; sourceTree = ""; }; D60001012F20000000000101 /* AppKindComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKindComparisonTests.swift; sourceTree = ""; }; + C131A22D5FAE13E5E9F14E9F /* MarkdownPunctuationMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPunctuationMapping.swift; sourceTree = ""; }; + C131A06895BA856414E76CE3 /* MarkdownModeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownModeController.swift; sourceTree = ""; }; + C13161EC493FE46EA8BCD8CD /* MarkdownModeSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownModeSettingsView.swift; sourceTree = ""; }; + C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPunctuationMappingTests.swift; sourceTree = ""; }; + C1311723BF0607366B8F236C /* PunctuationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PunctuationServiceTests.swift; sourceTree = ""; }; + C131B63FBB110A4009BABB8E /* PermissionsVMTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsVMTests.swift; sourceTree = ""; }; + C131984D932C971B3780C1D0 /* SettingsBackupPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBackupPreferencesTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -484,6 +498,7 @@ 4A2A175D280BA7FA00E13249 /* Utilities */ = { isa = PBXGroup; children = ( + C131A22D5FAE13E5E9F14E9F /* MarkdownPunctuationMapping.swift */, 25C5A6B42E678D86005AB80E /* PunctuationService.swift */, D598C7E228B74B16004747D1 /* Indicator */, D598C7E428B74B48004747D1 /* AppKit */, @@ -529,6 +544,7 @@ 4A2A1770280BA7FA00E13249 /* Models */ = { isa = PBXGroup; children = ( + C131A06895BA856414E76CE3 /* MarkdownModeController.swift */, 4A2A1773280BA7FA00E13249 /* IndicatorVM.swift */, 4AC2614A2870735B0032AAFE /* IndicatorVM+Triggers.swift */, 4A2A1774280BA7FA00E13249 /* PreferencesVM.swift */, @@ -579,6 +595,7 @@ 4A2A177A280BA7FA00E13249 /* Components */ = { isa = PBXGroup; children = ( + C13161EC493FE46EA8BCD8CD /* MarkdownModeSettingsView.swift */, 4A2A177B280BA7FA00E13249 /* IndicatorView.swift */, 4A1BD90E287BD14000E4D8C2 /* CustomizedIndicatorView.swift */, 4AC450BE281D841300DA0329 /* PreferenceSection.swift */, @@ -738,6 +755,10 @@ D60000042F20000000000004 /* Tests */ = { isa = PBXGroup; children = ( + C131984D932C971B3780C1D0 /* SettingsBackupPreferencesTests.swift */, + C131B63FBB110A4009BABB8E /* PermissionsVMTests.swift */, + C1311723BF0607366B8F236C /* PunctuationServiceTests.swift */, + C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */, D60001012F20000000000101 /* AppKindComparisonTests.swift */, D60000512F20000000000051 /* InputSourceDeduplicationTests.swift */, D60000112F20000000000011 /* BrowserRuleSelectionTests.swift */, @@ -923,6 +944,9 @@ 4A881A93288BC55E00B76498 /* PreferencesVM+KeyboardConfig.swift in Sources */, 4A2A1789280BA7FA00E13249 /* TISInputSource+Extension.swift in Sources */, 4AC50D6B283A72810034E894 /* NSColor.swift in Sources */, + C13120191255119A7A95E7C2 /* MarkdownPunctuationMapping.swift in Sources */, + C1310BA14D066C83C9331602 /* MarkdownModeController.swift in Sources */, + C131FE7BD70BFF2056A66384 /* MarkdownModeSettingsView.swift in Sources */, 25C5A6B52E678D86005AB80E /* PunctuationService.swift in Sources */, 4ACC9B812D2640F70002B8CE /* AddSwitchingGroupButton.swift in Sources */, 4A2A178C280BA7FA00E13249 /* CancelBag.swift in Sources */, @@ -1063,6 +1087,10 @@ D60000122F20000000000012 /* BrowserRuleSelectionTests.swift in Sources */, D60000322F20000000000032 /* BrowserRuleValidationTests.swift in Sources */, D60000222F20000000000022 /* RuntimeRuleChangeNotifierTests.swift in Sources */, + C1315059B7754D59E9CFFAD6 /* MarkdownPunctuationMappingTests.swift in Sources */, + C131285C8C1EBC5FDCF0111E /* PunctuationServiceTests.swift in Sources */, + C13103D84A17E0335FF5132B /* PermissionsVMTests.swift in Sources */, + C131FE642ECFD99AA25588ED /* SettingsBackupPreferencesTests.swift in Sources */, D60000032F20000000000003 /* TestHarnessTests.swift in Sources */, D60000722F20000000000072 /* AppURLActionTests.swift in Sources */, D60000822F20000000000082 /* URLActivationSuppressionTests.swift in Sources */, diff --git a/Input Source Pro/Models/IndicatorVM.swift b/Input Source Pro/Models/IndicatorVM.swift index fed9918..a4882e4 100644 --- a/Input Source Pro/Models/IndicatorVM.swift +++ b/Input Source Pro/Models/IndicatorVM.swift @@ -14,6 +14,7 @@ final class IndicatorVM: ObservableObject { let inputSourceVM: InputSourceVM let permissionsVM: PermissionsVM let punctuationService: PunctuationService + private let markdownModeController: MarkdownModeController let logger = ISPLogger(category: String(describing: IndicatorVM.self)) @@ -29,6 +30,12 @@ final class IndicatorVM: ObservableObject { @Published private(set) var state: State + @Published + private(set) var isMarkdownModeEnabled = false + + @Published + private(set) var markdownModeFailure: PunctuationService.Failure? + var actionSubject = PassthroughSubject() var refreshShortcutSubject = PassthroughSubject() @@ -72,7 +79,14 @@ final class IndicatorVM: ObservableObject { self.preferencesVM = preferencesVM self.applicationVM = applicationVM self.inputSourceVM = inputSourceVM - self.punctuationService = PunctuationService(preferencesVM: preferencesVM) + let punctuationService = PunctuationService() + self.punctuationService = punctuationService + self.markdownModeController = MarkdownModeController( + service: punctuationService, + shouldEnableAppEnglish: applicationVM.appKind.map { + preferencesVM.getAppCustomization(app: $0.getApp())?.shouldForceEnglishPunctuation == true + } ?? false + ) state = .from( preferencesVM: preferencesVM, inputSourceChangeReason: .system, @@ -82,7 +96,7 @@ final class IndicatorVM: ObservableObject { clearAppKeyboardCacheIfNeed() watchState() - watchPunctuationRules() + watchMarkdownMode() watchFunctionKeyMode() } @@ -106,19 +120,47 @@ final class IndicatorVM: ObservableObject { .store(in: cancelBag) } - private func watchPunctuationRules() { + func setMarkdownModeEnabled(_ enabled: Bool) { + markdownModeController.setEnabled(enabled) + permissionsVM.refresh() + } + + private func watchMarkdownMode() { + markdownModeController.$isEnabled + .assign(to: &$isMarkdownModeEnabled) + markdownModeController.$failure + .assign(to: &$markdownModeFailure) + + markdownModeController.bindPreference( + values: preferencesVM.$preferences.map(\.isMarkdownModeEnabled).eraseToAnyPublisher(), + currentValue: { [preferencesVM] in preferencesVM.preferences.isMarkdownModeEnabled }, + update: { [preferencesVM] enabled in + preferencesVM.update { $0.isMarkdownModeEnabled = enabled } + } + ) + .store(in: cancelBag) + 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() - } + let shouldEnableAppEnglish = appKind.map { + self.preferencesVM.getAppCustomization(app: $0.getApp())?.shouldForceEnglishPunctuation == true + } ?? false + self.markdownModeController.appContextChanged(shouldEnableAppEnglish: shouldEnableAppEnglish) + } + .store(in: cancelBag) + + Publishers.CombineLatest(permissionsVM.$isInputMonitoringEnabled, permissionsVM.$isAccessibilityEnabled) + .sink { [weak self] _, _ in + self?.markdownModeController.revalidateMarkdown() + } + .store(in: cancelBag) + + NSWorkspace.shared.notificationCenter + .publisher(for: NSWorkspace.didLaunchApplicationNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.markdownModeController.revalidateMarkdown() } .store(in: cancelBag) } diff --git a/Input Source Pro/Models/MarkdownModeController.swift b/Input Source Pro/Models/MarkdownModeController.swift new file mode 100644 index 0000000..d8c7714 --- /dev/null +++ b/Input Source Pro/Models/MarkdownModeController.swift @@ -0,0 +1,99 @@ +import Combine + +@MainActor +final class MarkdownModeController: ObservableObject { + @Published private(set) var isEnabled = false + @Published private(set) var failure: PunctuationService.Failure? + + var onPreferenceChange: ((Bool) -> Void)? + + private let service: PunctuationService + private var shouldEnableAppEnglish: Bool + private var requestedEnabled = false + private var isStoppedForSafety = false + private var isUpdatingPreference = false + + init(service: PunctuationService, shouldEnableAppEnglish: Bool) { + self.service = service + self.shouldEnableAppEnglish = shouldEnableAppEnglish + service.onSafetyShutdown = { [weak self] shutdown in + guard shutdown.mode == .markdown else { return } + self?.recordSafetyShutdown(shutdown.failure) + } + } + + func bindPreference( + values: AnyPublisher, + currentValue: @escaping () -> Bool, + update: @escaping (Bool) -> Void + ) -> AnyCancellable { + onPreferenceChange = { [weak self] enabled in + guard let self = self, currentValue() != enabled else { return } + self.isUpdatingPreference = true + update(enabled) + self.isUpdatingPreference = false + } + var didReceivePreference = false + return values.sink { [weak self] enabled in + guard let self = self, !self.isUpdatingPreference else { return } + guard !didReceivePreference || enabled != self.isEnabled else { return } + didReceivePreference = true + self.setEnabled(enabled) + } + } + + func setEnabled(_ enabled: Bool) { + requestedEnabled = enabled + isStoppedForSafety = false + reconcile() + } + + func appContextChanged(shouldEnableAppEnglish: Bool) { + self.shouldEnableAppEnglish = shouldEnableAppEnglish + reconcile() + } + + func revalidateMarkdown() { + guard requestedEnabled else { return } + reconcile() + } + + private func reconcile() { + if requestedEnabled { + let wasMarkdownActive = service.activeMode == .markdown + switch service.enable(mode: .markdown) { + case .success: + isEnabled = true + failure = nil + onPreferenceChange?(true) + case .failure(let failure): + self.failure = failure + requestedEnabled = false + isEnabled = false + isStoppedForSafety = wasMarkdownActive + applyAppEnglishRule() + onPreferenceChange?(false) + } + } else { + isEnabled = false + applyAppEnglishRule() + onPreferenceChange?(false) + } + } + + private func applyAppEnglishRule() { + if !isStoppedForSafety && shouldEnableAppEnglish { + service.enable(mode: .appEnglish) + } else { + service.disable() + } + } + + private func recordSafetyShutdown(_ failure: PunctuationService.Failure) { + isStoppedForSafety = true + requestedEnabled = false + isEnabled = false + self.failure = failure + onPreferenceChange?(false) + } +} diff --git a/Input Source Pro/Models/PermissionsVM.swift b/Input Source Pro/Models/PermissionsVM.swift index 6e52a46..209c59e 100644 --- a/Input Source Pro/Models/PermissionsVM.swift +++ b/Input Source Pro/Models/PermissionsVM.swift @@ -20,12 +20,38 @@ final class PermissionsVM: ObservableObject { } } - @Published var isAccessibilityEnabled = PermissionsVM.checkAccessibility(prompt: false) - @Published var isInputMonitoringEnabled = PermissionsVM.checkInputMonitoring(prompt: false) + @Published var isAccessibilityEnabled: Bool + @Published var isInputMonitoringEnabled: Bool - init() { + private let accessibilityCheck: @MainActor () -> Bool + private let inputMonitoringCheck: @MainActor () -> Bool + private var activationSubscription: AnyCancellable? + + init( + accessibilityCheck: @escaping @MainActor () -> Bool = { PermissionsVM.checkAccessibility(prompt: false) }, + inputMonitoringCheck: @escaping @MainActor () -> Bool = { PermissionsVM.checkInputMonitoring(prompt: false) }, + notificationCenter: NotificationCenter = .default + ) { + self.accessibilityCheck = accessibilityCheck + self.inputMonitoringCheck = inputMonitoringCheck + isAccessibilityEnabled = accessibilityCheck() + isInputMonitoringEnabled = inputMonitoringCheck() watchAccessibilityChange() watchInputMonitoringChange() + activationSubscription = notificationCenter.publisher(for: NSApplication.didBecomeActiveNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.refresh() } + } + + func refresh() { + let accessibilityEnabled = accessibilityCheck() + let inputMonitoringEnabled = inputMonitoringCheck() + if isAccessibilityEnabled != accessibilityEnabled { + isAccessibilityEnabled = accessibilityEnabled + } + if isInputMonitoringEnabled != inputMonitoringEnabled { + isInputMonitoringEnabled = inputMonitoringEnabled + } } private func watchAccessibilityChange() { @@ -33,7 +59,7 @@ final class PermissionsVM: ObservableObject { Timer .interval(seconds: 1) - .map { _ in Self.checkAccessibility(prompt: false) } + .map { [accessibilityCheck] _ in accessibilityCheck() } .filter { $0 } .first() .assign(to: &$isAccessibilityEnabled) @@ -44,7 +70,7 @@ final class PermissionsVM: ObservableObject { Timer .interval(seconds: 1) - .map { _ in Self.checkInputMonitoring(prompt: false) } + .map { [inputMonitoringCheck] _ in inputMonitoringCheck() } .filter { $0 } .first() .assign(to: &$isInputMonitoringEnabled) diff --git a/Input Source Pro/Models/PreferencesVM.swift b/Input Source Pro/Models/PreferencesVM.swift index e9dc183..2b9b300 100644 --- a/Input Source Pro/Models/PreferencesVM.swift +++ b/Input Source Pro/Models/PreferencesVM.swift @@ -269,6 +269,7 @@ struct Preferences { static let isLaunchAtLogin = "isLaunchAtLogin" static let isShowIconInMenuBar = "isShowIconInMenuBar" + static let isMarkdownModeEnabled = "isMarkdownModeEnabled" static let isEnhancedModeEnabled = "isDetectSpotlightLikeApp" static let isCJKVFixEnabled = "isCJKVFixEnabled" static let cJKVFixStrategy = "cJKVFixStrategy" @@ -335,6 +336,14 @@ struct Preferences { fileprivate init() {} + init(markdownModeUserDefaults: UserDefaults) { + _isMarkdownModeEnabled = UserDefault( + wrappedValue: false, + Preferences.Key.isMarkdownModeEnabled, + userDefaults: markdownModeUserDefaults + ) + } + @UserDefault(Preferences.Key.prevInstalledBuildVersion) var prevInstalledBuildVersion = 0 @@ -346,6 +355,9 @@ struct Preferences { @UserDefault(Preferences.Key.isShowIconInMenuBar) var isShowIconInMenuBar = true + @UserDefault(Preferences.Key.isMarkdownModeEnabled) + var isMarkdownModeEnabled = false + @UserDefault(Preferences.Key.isEnhancedModeEnabled) var isEnhancedModeEnabled = false diff --git a/Input Source Pro/Persistence/SettingsBackup.swift b/Input Source Pro/Persistence/SettingsBackup.swift index f7f9867..b434fea 100644 --- a/Input Source Pro/Persistence/SettingsBackup.swift +++ b/Input Source Pro/Persistence/SettingsBackup.swift @@ -96,6 +96,7 @@ struct SettingsBackupApp: Codable { struct SettingsBackupPreferences: Codable { var isLaunchAtLogin: Bool? var isShowIconInMenuBar: Bool? + var isMarkdownModeEnabled: Bool? var isEnhancedModeEnabled: Bool? var isCJKVFixEnabled: Bool? var cJKVFixStrategy: CJKVFixStrategy? @@ -153,6 +154,7 @@ struct SettingsBackupPreferences: Codable { init(_ preferences: Preferences) { isLaunchAtLogin = preferences.isLaunchAtLogin isShowIconInMenuBar = preferences.isShowIconInMenuBar + isMarkdownModeEnabled = preferences.isMarkdownModeEnabled isEnhancedModeEnabled = preferences.isEnhancedModeEnabled isCJKVFixEnabled = preferences.isCJKVFixEnabled cJKVFixStrategy = preferences.cJKVFixStrategy @@ -209,6 +211,7 @@ struct SettingsBackupPreferences: Codable { func apply(to preferences: inout Preferences) { if let isLaunchAtLogin { preferences.isLaunchAtLogin = isLaunchAtLogin } if let isShowIconInMenuBar { preferences.isShowIconInMenuBar = isShowIconInMenuBar } + if let isMarkdownModeEnabled { preferences.isMarkdownModeEnabled = isMarkdownModeEnabled } if let isEnhancedModeEnabled { preferences.isEnhancedModeEnabled = isEnhancedModeEnabled } if let isCJKVFixEnabled { preferences.isCJKVFixEnabled = isCJKVFixEnabled } if let cJKVFixStrategy { preferences.cJKVFixStrategy = cJKVFixStrategy } diff --git a/Input Source Pro/Resources/en.lproj/Localizable.strings b/Input Source Pro/Resources/en.lproj/Localizable.strings index 59c749b..1c9a573 100644 --- a/Input Source Pro/Resources/en.lproj/Localizable.strings +++ b/Input Source Pro/Resources/en.lproj/Localizable.strings @@ -127,6 +127,15 @@ "Punctuation" = "Punctuation"; "ASCII Punctuation" = "ASCII Punctuation"; "Force English Punctuation" = "Force English Punctuation"; +"Markdown Mode" = "Markdown Mode"; +"Enable Markdown Mode" = "Enable Markdown Mode"; +"Markdown Mode Description" = "With Pinyin – Simplified and its default or ABC/U.S. layout, use `, $, > and [] for Markdown. Shift+comma inserts 《》. These key mappings also apply with half-width punctuation."; +"Markdown Mode Permissions Description" = "Markdown Mode requires Input Monitoring and Accessibility permissions. Grant access, then turn it on."; +"Markdown Mode Is Off" = "Markdown Mode is off"; +"Markdown Mode Another Instance Description" = "Quit other Input Source Pro instances, then turn Markdown Mode on to retry."; +"Markdown Mode Activation Failed Description" = "macOS could not start keyboard interception. Check permissions, then try enabling Markdown Mode again."; +"Markdown Mode Tap Disabled Description" = "macOS stopped keyboard interception. Check permissions, then turn Markdown Mode on to retry."; +"Markdown Mode Timeout Description" = "Keyboard handling took too long, so punctuation replacement was stopped. Turn Markdown Mode on to retry."; "Use Function Keys" = "Use Function Keys (F1, F2, etc.)"; "Use Media Keys" = "Use Media Keys (brightness, volume, etc.)"; "Use Global Setting" = "Use Global Setting"; diff --git a/Input Source Pro/Resources/ja.lproj/Localizable.strings b/Input Source Pro/Resources/ja.lproj/Localizable.strings index fd3146a..6348e54 100644 --- a/Input Source Pro/Resources/ja.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ja.lproj/Localizable.strings @@ -127,6 +127,15 @@ "Punctuation" = "句読点"; "ASCII Punctuation" = "ASCII 句読点"; "Force English Punctuation" = "英語句読点を強制使用"; +"Markdown Mode" = "Markdown モード"; +"Enable Markdown Mode" = "Markdown モードを有効にする"; +"Markdown Mode Description" = "簡体字ピンインの標準または ABC/米国配列で、指定キーを Markdown 用の `、$、>、[] に割り当てます。Shift+カンマで《》を入力します。半角句読点の設定でも同じ割り当てが適用されます。"; +"Markdown Mode Permissions Description" = "Markdown モードには入力監視とアクセシビリティの権限が必要です。権限を許可してから有効にしてください。"; +"Markdown Mode Is Off" = "Markdown モードはオフです"; +"Markdown Mode Another Instance Description" = "他の Input Source Pro を終了してから、Markdown モードを再度有効にしてください。"; +"Markdown Mode Activation Failed Description" = "macOS がキーボードイベントの処理を開始できませんでした。権限を確認してから、Markdown モードを再度有効にしてください。"; +"Markdown Mode Tap Disabled Description" = "macOS がキーボードイベントの処理を停止しました。権限を確認してから、Markdown モードを再度有効にしてください。"; +"Markdown Mode Timeout Description" = "キーの処理に時間がかかりすぎたため、句読点の置換を停止しました。Markdown モードを再度有効にしてお試しください。"; "Use Function Keys" = "ファンクションキーを使用 (F1、F2 など)"; "Use Media Keys" = "メディアキーを使用 (明るさ、音量など)"; "Use Global Setting" = "グローバル設定を使用"; diff --git a/Input Source Pro/Resources/ko.lproj/Localizable.strings b/Input Source Pro/Resources/ko.lproj/Localizable.strings index 0121c4f..95a5db0 100644 --- a/Input Source Pro/Resources/ko.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ko.lproj/Localizable.strings @@ -127,6 +127,15 @@ "Punctuation" = "구두점"; "ASCII Punctuation" = "ASCII 구두점"; "Force English Punctuation" = "영어 구두점 강제 사용"; +"Markdown Mode" = "Markdown 모드"; +"Enable Markdown Mode" = "Markdown 모드 활성화"; +"Markdown Mode Description" = "간체 병음의 기본 또는 ABC/미국식 배열에서 지정된 키를 Markdown 기호 `, $, >, []로 바꿉니다. Shift+쉼표는 《》를 입력합니다. 반각 문장 부호 설정에서도 같은 키 매핑이 적용됩니다."; +"Markdown Mode Permissions Description" = "Markdown 모드에는 입력 모니터링 및 손쉬운 사용 권한이 필요합니다. 권한을 허용한 후 켜 주세요."; +"Markdown Mode Is Off" = "Markdown 모드가 꺼져 있습니다"; +"Markdown Mode Another Instance Description" = "다른 Input Source Pro 인스턴스를 종료한 후 Markdown 모드를 다시 켜 주세요."; +"Markdown Mode Activation Failed Description" = "macOS에서 키보드 이벤트 처리를 시작하지 못했습니다. 권한을 확인한 후 Markdown 모드를 다시 켜 주세요."; +"Markdown Mode Tap Disabled Description" = "macOS에서 키보드 이벤트 처리를 중지했습니다. 권한을 확인한 후 Markdown 모드를 다시 켜 주세요."; +"Markdown Mode Timeout Description" = "키보드 처리 시간이 너무 길어 문장 부호 대체를 중지했습니다. Markdown 모드를 다시 켜서 재시도하세요."; "Use Function Keys" = "기능 키 사용 (F1, F2 등)"; "Use Media Keys" = "미디어 키 사용 (밝기, 음량 등)"; "Use Global Setting" = "전역 설정 사용"; diff --git a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings index 8049db4..fedf49b 100644 --- a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings @@ -129,6 +129,15 @@ "Punctuation" = "标点符号"; "ASCII Punctuation" = "ASCII 标点符号"; "Force English Punctuation" = "强制使用英文标点符号"; +"Markdown Mode" = "Markdown 模式"; +"Enable Markdown Mode" = "启用 Markdown 模式"; +"Markdown Mode Description" = "使用简体拼音及其默认或 ABC/美式键盘布局时,将指定按键映射为 Markdown 符号 `、$、> 和 []。Shift+逗号输入《》。这些按键映射也适用于半角标点模式。"; +"Markdown Mode Permissions Description" = "Markdown 模式需要输入监控和辅助功能权限。授予权限后,请开启此模式。"; +"Markdown Mode Is Off" = "Markdown 模式已关闭"; +"Markdown Mode Another Instance Description" = "请退出其他 Input Source Pro 实例,然后重新开启 Markdown 模式。"; +"Markdown Mode Activation Failed Description" = "macOS 无法启动键盘事件拦截。请检查权限,然后重新开启 Markdown 模式。"; +"Markdown Mode Tap Disabled Description" = "macOS 已停止键盘事件拦截。请检查权限,然后重新开启 Markdown 模式。"; +"Markdown Mode Timeout Description" = "键盘事件处理耗时过长,标点替换已停止。请重新开启 Markdown 模式以重试。"; "Use Function Keys" = "使用功能键 (F1、F2 等)"; "Use Media Keys" = "使用媒体键 (亮度、音量等)"; "Use Global Setting" = "使用全局设置"; diff --git a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings index 7d93c8c..9cb220a 100644 --- a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings @@ -129,6 +129,15 @@ "Punctuation" = "標點符號"; "ASCII Punctuation" = "ASCII 標點符號"; "Force English Punctuation" = "強制使用英文標點符號"; +"Markdown Mode" = "Markdown 模式"; +"Enable Markdown Mode" = "啟用 Markdown 模式"; +"Markdown Mode Description" = "使用簡體拼音及其預設或 ABC/美式鍵盤佈局時,將指定按鍵對應為 Markdown 符號 `、$、> 和 []。Shift+逗號輸入《》。這些按鍵對應也適用於半形標點模式。"; +"Markdown Mode Permissions Description" = "Markdown 模式需要輸入監控與輔助使用權限。授予權限後,請開啟此模式。"; +"Markdown Mode Is Off" = "Markdown 模式已關閉"; +"Markdown Mode Another Instance Description" = "請結束其他 Input Source Pro 實例,然後重新開啟 Markdown 模式。"; +"Markdown Mode Activation Failed Description" = "macOS 無法啟動鍵盤事件攔截。請檢查權限,然後重新開啟 Markdown 模式。"; +"Markdown Mode Tap Disabled Description" = "macOS 已停止鍵盤事件攔截。請檢查權限,然後重新開啟 Markdown 模式。"; +"Markdown Mode Timeout Description" = "鍵盤事件處理時間過長,標點替換已停止。請重新開啟 Markdown 模式以重試。"; "Use Function Keys" = "使用功能鍵 (F1、F2 等)"; "Use Media Keys" = "使用媒體鍵 (亮度、音量等)"; "Use Global Setting" = "使用全域設定"; diff --git a/Input Source Pro/UI/Components/MarkdownModeSettingsView.swift b/Input Source Pro/UI/Components/MarkdownModeSettingsView.swift new file mode 100644 index 0000000..2e2caaa --- /dev/null +++ b/Input Source Pro/UI/Components/MarkdownModeSettingsView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct MarkdownModeSettingsView: View { + @Binding var isEnabled: Bool + let isInputMonitoringEnabled: Bool + let isAccessibilityEnabled: Bool + let failure: PunctuationService.Failure? + + var body: some View { + SettingsSection(title: "Markdown Mode") { + VStack(alignment: .leading, spacing: 8) { + HStack { + Toggle("Enable Markdown Mode".i18n(), isOn: $isEnabled) + .labelsHidden() + Text("Enable Markdown Mode".i18n()) + .accessibilityHidden(true) + Spacer() + } + + Text("Markdown Mode Description".i18n()) + .font(.subheadline) + .foregroundColor(.secondary) + + if !isInputMonitoringEnabled || !isAccessibilityEnabled { + VStack(alignment: .leading, spacing: 8) { + Text("Markdown Mode Permissions Description".i18n()) + .font(.subheadline) + + if !isInputMonitoringEnabled { + Button("Open Permission Settings".i18n()) { + NSWorkspace.shared.openInputMonitoringPreferences() + } + } + + if !isAccessibilityEnabled { + Button("Open Accessibility Settings".i18n()) { + NSWorkspace.shared.openAccessibilityPreferences() + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(NSColor.background1.color) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + if let failure, let message = failureMessage(failure) { + VStack(alignment: .leading, spacing: 4) { + Text("Markdown Mode Is Off".i18n()) + .fontWeight(.medium) + Text(message.i18n()) + .font(.subheadline) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .padding() + } + } + + private func failureMessage(_ failure: PunctuationService.Failure) -> String? { + switch failure { + case .missingPermissions: + return nil + case .anotherInstanceRunning: + return "Markdown Mode Another Instance Description" + case .eventTapCreationFailed: + return "Markdown Mode Activation Failed Description" + case .eventTapDisabled: + return "Markdown Mode Tap Disabled Description" + case .handlerTimedOut: + return "Markdown Mode Timeout Description" + } + } +} diff --git a/Input Source Pro/UI/Screens/GeneralSettingsView.swift b/Input Source Pro/UI/Screens/GeneralSettingsView.swift index 93a6d2d..9fafcb4 100644 --- a/Input Source Pro/UI/Screens/GeneralSettingsView.swift +++ b/Input Source Pro/UI/Screens/GeneralSettingsView.swift @@ -86,6 +86,23 @@ struct GeneralSettingsView: View { .padding() } + MarkdownModeSettingsView( + isEnabled: Binding( + get: { indicatorVM.isMarkdownModeEnabled }, + set: { + indicatorVM.setMarkdownModeEnabled($0) + permissionsVM.refresh() + } + ), + isInputMonitoringEnabled: permissionsVM.isInputMonitoringEnabled, + isAccessibilityEnabled: permissionsVM.isAccessibilityEnabled, + failure: indicatorVM.markdownModeFailure + ) + .onAppear { permissionsVM.refresh() } + .onChange(of: indicatorVM.markdownModeFailure) { _ in + permissionsVM.refresh() + } + Group { SettingsSection(title: "Indicator Triggers") { HStack { diff --git a/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift b/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift new file mode 100644 index 0000000..812290f --- /dev/null +++ b/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift @@ -0,0 +1,87 @@ +import Carbon +import CoreGraphics + +enum MarkdownPunctuationMapping { + struct InputContext: Equatable { + let sourceID: String + let inputModeID: String? + let keyboardLayoutID: String + } + + private static let pinyinSourceID = "com.apple.inputmethod.SCIM.ITABC" + private static let supportedKeyboardLayouts: Set = [ + "com.apple.keylayout.PinyinKeyboard", + "com.apple.keylayout.ABC", + "com.apple.keylayout.US" + ] + + private static let replacements: [CGKeyCode: (normal: String?, shifted: String?)] = [ + CGKeyCode(kVK_ANSI_Grave): ("`", nil), + CGKeyCode(kVK_ANSI_4): (nil, "$"), + CGKeyCode(kVK_ANSI_Comma): (nil, "《》"), + CGKeyCode(kVK_ANSI_Period): (nil, ">") + ] + + private static let ansiBrackets: [CGKeyCode: (normal: String?, shifted: String?)] = [ + CGKeyCode(kVK_ANSI_LeftBracket): ("[", nil), + CGKeyCode(kVK_ANSI_RightBracket): ("]", nil) + ] + + private static let jisBrackets: [CGKeyCode: (normal: String?, shifted: String?)] = [ + CGKeyCode(kVK_ANSI_RightBracket): ("[", nil), + CGKeyCode(kVK_ANSI_Backslash): ("]", nil) + ] + + static func replacement( + for keyCode: CGKeyCode, + flags: CGEventFlags, + keyboardType: Int64, + contextProvider: () -> InputContext? = currentInputContext + ) -> String? { + guard let keyboardType = Int16(exactly: keyboardType) else { return nil } + let brackets: [CGKeyCode: (normal: String?, shifted: String?)] + switch KBGetLayoutType(keyboardType) { + case OSType(kKeyboardANSI), OSType(kKeyboardISO): + brackets = ansiBrackets + case OSType(kKeyboardJIS): + brackets = jisBrackets + default: + return nil + } + guard let mapping = replacements[keyCode] ?? brackets[keyCode] else { return nil } + + let preservedModifiers: CGEventFlags = [ + .maskCommand, .maskControl, .maskAlternate, .maskSecondaryFn, .maskAlphaShift + ] + guard flags.intersection(preservedModifiers).isEmpty, + let replacement = flags.contains(.maskShift) ? mapping.shifted : mapping.normal + else { return nil } + + guard let context = contextProvider(), + context.sourceID == pinyinSourceID, + context.inputModeID == pinyinSourceID, + supportedKeyboardLayouts.contains(context.keyboardLayoutID) + else { return nil } + + return replacement + } + + static func currentInputContext() -> InputContext? { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let layout = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + let sourceID = stringProperty(kTISPropertyInputSourceID, of: source), + let keyboardLayoutID = stringProperty(kTISPropertyInputSourceID, of: layout) + else { return nil } + + return InputContext( + sourceID: sourceID, + inputModeID: stringProperty(kTISPropertyInputModeID, of: source), + keyboardLayoutID: keyboardLayoutID + ) + } + + private static func stringProperty(_ key: CFString, of source: TISInputSource) -> String? { + guard let value = TISGetInputSourceProperty(source, key) else { return nil } + return Unmanaged.fromOpaque(value).takeUnretainedValue() as String + } +} diff --git a/Input Source Pro/Utilities/PunctuationService.swift b/Input Source Pro/Utilities/PunctuationService.swift index 8f8bbfc..7e2acb8 100644 --- a/Input Source Pro/Utilities/PunctuationService.swift +++ b/Input Source Pro/Utilities/PunctuationService.swift @@ -1,23 +1,71 @@ import AppKit import Carbon import Combine -import IOKit -import os @MainActor -class PunctuationService: ObservableObject { +final class PunctuationService: ObservableObject { + enum Mode: Equatable { + case appEnglish + case markdown + } + + enum Failure: Error, Equatable { + case missingPermissions(inputMonitoring: Bool, accessibility: Bool) + case anotherInstanceRunning + case eventTapCreationFailed + case eventTapDisabled + case handlerTimedOut + } + + struct SafetyShutdown: Equatable { + let mode: Mode + let failure: Failure + } + + final class EventTap { + private var invalidateHandler: (() -> Void)? + + init(invalidate: @escaping () -> Void) { + invalidateHandler = invalidate + } + + func invalidate() { + let invalidate = invalidateHandler + invalidateHandler = nil + invalidate?() + } + + deinit { + invalidateHandler?() + } + } + + struct Dependencies { + var hasInputMonitoring: @MainActor () -> Bool = { PermissionsVM.checkInputMonitoring(prompt: false) } + var hasAccessibility: @MainActor () -> Bool = { PermissionsVM.checkAccessibility(prompt: false) } + var hasAnotherInstance: () -> Bool = { + ["com.runjuu.Input-Source-Pro", "com.runjuu.Input-Source-Pro.Markdown"] + .flatMap { NSRunningApplication.runningApplications(withBundleIdentifier: $0) } + .contains { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier && !$0.isTerminated } + } + var createEventTap: @MainActor (CGEventTapCallBack, UnsafeMutableRawPointer) -> EventTap? = PunctuationService.createEventTap + var markdownInputContext: () -> MarkdownPunctuationMapping.InputContext? = MarkdownPunctuationMapping.currentInputContext + var now: () -> TimeInterval = CACurrentMediaTime + } + 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 let generatedEventMarker: Int64 = 0x4953_504D_44 + private let maximumHandlerDuration: TimeInterval = 0.05 + private let dependencies: Dependencies + private var eventTap: EventTap? + private(set) var activeMode: Mode? + var onSafetyShutdown: ((SafetyShutdown) -> Void)? + private var cachedInputSource: InputSource? private var inputSourceCacheTime: TimeInterval = 0 - private let inputSourceCacheTimeout: TimeInterval = 0.5 // Cache for 500ms - - private let cjkvToEnglishPunctuationMap: [UInt16: (normal: String?, shifted: String?)] = [ + private let inputSourceCacheTimeout: TimeInterval = 0.5 + + private let appEnglishPunctuationMap: [UInt16: (normal: String?, shifted: String?)] = [ UInt16(kVK_ANSI_Grave): ("`", "~"), UInt16(kVK_ANSI_4): (nil, "$"), UInt16(kVK_ANSI_6): (nil, "^"), @@ -30,173 +78,137 @@ class PunctuationService: ObservableObject { UInt16(kVK_ANSI_LeftBracket): ("[", "{"), UInt16(kVK_ANSI_RightBracket): ("]", "}") ] - - 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) - } + + init(dependencies: Dependencies = Dependencies()) { + self.dependencies = dependencies } - - 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" } + + @discardableResult + func enable(mode: Mode) -> Result { + if mode == .markdown, let failure = markdownActivationFailure() { + if activeMode == .markdown { + disable() + } + return .failure(failure) } - - 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 + + if eventTap != nil { + activeMode = mode + return .success(()) } - } - - 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 + + let callback: CGEventTapCallBack = { _, type, event, refcon in guard let refcon = refcon else { return Unmanaged.passUnretained(event) } - let service = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - return service.handleKeyEvent(proxy: proxy, type: type, event: event) + return service.handleKeyEvent(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 config in configurations { - 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" } - } + guard let eventTap = dependencies.createEventTap(callback, Unmanaged.passUnretained(self).toOpaque()) else { + disable() + return .failure(.eventTapCreationFailed) } - - // If all configurations failed, provide detailed diagnostic info - logger.debug { "❌ All event tap configurations failed. Diagnostic info:" } - #if DEBUG - checkServiceStatus() - #endif - - return false + + self.eventTap = eventTap + activeMode = mode + return .success(()) } - - 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 + + func disable() { + activeMode = nil + let eventTap = eventTap + self.eventTap = nil + eventTap?.invalidate() 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 mapping = cjkvToEnglishPunctuationMap[UInt16(keyCode)] else { - // Not a punctuation key we're interested in - return Unmanaged.passUnretained(event) + + private func markdownActivationFailure() -> Failure? { + let inputMonitoring = dependencies.hasInputMonitoring() + let accessibility = dependencies.hasAccessibility() + guard inputMonitoring && accessibility else { + return .missingPermissions(inputMonitoring: !inputMonitoring, accessibility: !accessibility) } - - guard shouldReplacePunctuation(for: event.flags) else { - // Preserve shortcuts and system key combinations that use punctuation keys. - return Unmanaged.passUnretained(event) + return dependencies.hasAnotherInstance() ? .anotherInstanceRunning : nil + } + + private static func createEventTap(callback: CGEventTapCallBack, userInfo: UnsafeMutableRawPointer) -> EventTap? { + let eventMask = CGEventMask(1 << CGEventType.keyDown.rawValue) + for placement in [CGEventTapPlacement.headInsertEventTap, .tailAppendEventTap] { + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: placement, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: callback, + userInfo: userInfo + ) else { continue } + + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + CFMachPortInvalidate(tap) + continue + } + let runLoop = CFRunLoopGetCurrent() + CFRunLoopAddSource(runLoop, source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + return EventTap { + CGEvent.tapEnable(tap: tap, enable: false) + CFRunLoopRemoveSource(runLoop, source, .commonModes) + CFMachPortInvalidate(tap) + } } + return nil + } - guard let englishReplacement = event.flags.contains(.maskShift) ? mapping.shifted : mapping.normal else { + func handleKeyEvent(type: CGEventType, event: CGEvent) -> Unmanaged { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + shutdownForSafety(.eventTapDisabled) 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) + + guard let mode = activeMode, type == .keyDown, + event.getIntegerValueField(.eventSourceUserData) != generatedEventMarker, + let keyCode = CGKeyCode(exactly: event.getIntegerValueField(.keyboardEventKeycode)) + else { return Unmanaged.passUnretained(event) } + + let handlerStartTime = dependencies.now() + defer { + if mode == .markdown, dependencies.now() - handlerStartTime > maximumHandlerDuration { + shutdownForSafety(.handlerTimedOut) + } } - - logger.debug { "🎯 Intercepting punctuation key: \(keyCode) ('\(englishReplacement)') in CJKV input method: \(currentInputSource.name)" } - - // 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) + + let replacement: String? + switch mode { + case .markdown: + replacement = MarkdownPunctuationMapping.replacement( + for: keyCode, + flags: event.flags, + keyboardType: event.getIntegerValueField(.keyboardEventKeyboardType), + contextProvider: dependencies.markdownInputContext + ) + case .appEnglish: + if let mapping = appEnglishPunctuationMap[keyCode], shouldReplacePunctuation(for: event.flags), + let punctuation = event.flags.contains(.maskShift) ? mapping.shifted : mapping.normal, + getCachedCurrentInputSource().isCJKVR { + replacement = punctuation + } else { + replacement = nil + } } + + guard let replacement = replacement, + let newEvent = createEnglishPunctuationEvent(originalEvent: event, replacement: replacement) + else { return Unmanaged.passUnretained(event) } + return Unmanaged.passRetained(newEvent) } - + + private func shutdownForSafety(_ failure: Failure) { + guard let mode = activeMode else { return } + disable() + onSafetyShutdown?(SafetyShutdown(mode: mode, failure: failure)) + } + private func createEnglishPunctuationEvent(originalEvent: CGEvent, replacement: String) -> CGEvent? { // Use the original keyCode but with English character replacement let originalKeyCode = CGKeyCode(originalEvent.getIntegerValueField(.keyboardEventKeycode)) @@ -215,6 +227,7 @@ class PunctuationService: ObservableObject { // Copy relevant properties from the original event (but not flags to avoid modifier conflicts) newEvent.timestamp = originalEvent.timestamp + newEvent.setIntegerValueField(.eventSourceUserData, value: generatedEventMarker) // Explicitly set flags to none to ensure clean character input newEvent.flags = [] @@ -229,16 +242,9 @@ class PunctuationService: ObservableObject { return flags.intersection(shortcutModifiers).isEmpty } - 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() + let currentTime = dependencies.now() // Return cached value if it's still valid if let cached = cachedInputSource, @@ -254,22 +260,4 @@ class PunctuationService: ObservableObject { 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) (CJKV: \(currentInputSource.isCJKVR)) - - Monitored Keys: \(cjkvToEnglishPunctuationMap.map { "\($0.key)→'\($0.value.normal ?? "pass")'/'\($0.value.shifted ?? "pass")'" }.joined(separator: ", ")) - """ } - } } diff --git a/README.md b/README.md index 9a75035..83be22a 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,20 @@ Keep punctuation consistent across different languages by enabling **Force Engli - Automatically types standard symbols (`` ` ~ - _ $ ^ , . ; ' " [ ] ``) even when your current input source would normally produce localized or full-width characters. - Enable it only for the apps where you need it, such as code editors or terminal windows. +**Markdown Mode** is an optional, system-wide key mapping for Apple's **Pinyin – Simplified**, using its default layout or an ABC/U.S. QWERTY layout. Enable it in General settings after granting Input Monitoring and Accessibility permissions. + +| Key | Markdown Mode output | +| --- | --- | +| Grave/backtick | `` ` `` | +| Shift+4 | `$` | +| Shift+comma | `《》` | +| Shift+period | `>` | +| Left/right bracket | `[` / `]` | + +These are key mappings, including when Pinyin uses half-width punctuation. Other input sources and layouts pass through unchanged, as do Caps Lock and Command, Control, Option, or Fn combinations. Other punctuation keys are unchanged. While enabled, Markdown Mode takes precedence over per-app punctuation rules; turning it off restores those rules. + +If macOS disables the keyboard tap, handling exceeds the safety limit, or another Input Source Pro instance is detected, active Markdown Mode stops and stays off until you explicitly retry. General settings shows the reason. The option is included in settings export/import; an imported enabled setting still requires permissions and successful activation on the receiving Mac. + ### 🎛️ App-Based Function Key Switching Automatically switch your macOS function key mode per app. - Choose whether an app should use **F1 ~ F12** as: diff --git a/Tests/MarkdownPunctuationMappingTests.swift b/Tests/MarkdownPunctuationMappingTests.swift new file mode 100644 index 0000000..79bcc4f --- /dev/null +++ b/Tests/MarkdownPunctuationMappingTests.swift @@ -0,0 +1,218 @@ +import Carbon +import XCTest +@testable import Input_Source_Pro + +final class MarkdownPunctuationMappingTests: XCTestCase { + private typealias InputContext = MarkdownPunctuationMapping.InputContext + + private let pinyin = InputContext( + sourceID: "com.apple.inputmethod.SCIM.ITABC", + inputModeID: "com.apple.inputmethod.SCIM.ITABC", + keyboardLayoutID: "com.apple.keylayout.PinyinKeyboard" + ) + + private let mappedKeys: [(keyCode: CGKeyCode, flags: CGEventFlags, replacement: String)] = [ + (CGKeyCode(kVK_ANSI_Grave), [], "`"), + (CGKeyCode(kVK_ANSI_4), .maskShift, "$"), + (CGKeyCode(kVK_ANSI_Comma), .maskShift, "《》"), + (CGKeyCode(kVK_ANSI_Period), .maskShift, ">"), + (CGKeyCode(kVK_ANSI_LeftBracket), [], "["), + (CGKeyCode(kVK_ANSI_RightBracket), [], "]") + ] + + func testANSIAndISOMapOnlySpecifiedKeysOnSupportedPinyinLayouts() { + for layoutID in ["com.apple.keylayout.PinyinKeyboard", "com.apple.keylayout.ABC", "com.apple.keylayout.US"] { + let context = InputContext( + sourceID: pinyin.sourceID, + inputModeID: pinyin.inputModeID, + keyboardLayoutID: layoutID + ) + + for keyboardType: Int64 in [40, 41] { + for key in mappedKeys { + XCTAssertEqual( + MarkdownPunctuationMapping.replacement( + for: key.keyCode, flags: key.flags, keyboardType: keyboardType + ) { context }, + key.replacement, + "Layout: \(layoutID), keyboard: \(keyboardType), key: \(key.keyCode)" + ) + } + XCTAssertNil(MarkdownPunctuationMapping.replacement( + for: CGKeyCode(kVK_ANSI_Backslash), flags: [], keyboardType: keyboardType + ) { context }) + } + } + } + + func testJISMapsItsBracketKeysAndPreservesAtSign() { + let jisKeys = Array(mappedKeys.prefix(4)) + [ + (CGKeyCode(kVK_ANSI_RightBracket), [], "["), + (CGKeyCode(kVK_ANSI_Backslash), [], "]") + ] + for layoutID in ["com.apple.keylayout.PinyinKeyboard", "com.apple.keylayout.ABC", "com.apple.keylayout.US"] { + let context = InputContext( + sourceID: pinyin.sourceID, + inputModeID: pinyin.inputModeID, + keyboardLayoutID: layoutID + ) + for key in jisKeys { + XCTAssertEqual(MarkdownPunctuationMapping.replacement( + for: key.keyCode, flags: key.flags, keyboardType: 42 + ) { context }, key.replacement) + } + XCTAssertNil(MarkdownPunctuationMapping.replacement( + for: CGKeyCode(kVK_ANSI_LeftBracket), flags: [], keyboardType: 42 + ) { context }) + } + } + + func testKeyboardChangesTakeEffectOnTheNextKey() { + for (keyboardType, expected): (Int64, String) in [(40, "]"), (42, "["), (41, "]"), (42, "[")] { + XCTAssertEqual(MarkdownPunctuationMapping.replacement( + for: CGKeyCode(kVK_ANSI_RightBracket), flags: [], keyboardType: keyboardType + ) { self.pinyin }, expected) + } + } + + func testUnknownAndOutOfRangeKeyboardTypesPassThrough() { + for keyboardType: Int64 in [0, 255, -1, Int64.max] { + for key in mappedKeys { + XCTAssertNil(MarkdownPunctuationMapping.replacement( + for: key.keyCode, flags: key.flags, keyboardType: keyboardType + ) { + XCTFail("Unknown keyboard must not query the input source") + return self.pinyin + }) + } + } + } + + func testRussianLettersPassThrough() { + for sourceID in ["com.apple.keylayout.Russian", "com.apple.keylayout.RussianWin"] { + let context = InputContext(sourceID: sourceID, inputModeID: nil, keyboardLayoutID: sourceID) + for key in mappedKeys { + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: key.keyCode, flags: key.flags, keyboardType: 40) { context }) + } + } + } + + func testLatinSourcesPassThroughEvenOnSupportedLayouts() { + for sourceID in ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] { + let context = InputContext(sourceID: sourceID, inputModeID: nil, keyboardLayoutID: sourceID) + for key in mappedKeys { + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: key.keyCode, flags: key.flags, keyboardType: 40) { context }) + } + } + } + + func testRequiresBothTheVerifiedSourceAndMode() { + let contexts = [ + InputContext(sourceID: pinyin.sourceID, inputModeID: nil, keyboardLayoutID: pinyin.keyboardLayoutID), + InputContext(sourceID: pinyin.sourceID, inputModeID: "unknown", keyboardLayoutID: pinyin.keyboardLayoutID), + InputContext(sourceID: "unknown", inputModeID: pinyin.inputModeID, keyboardLayoutID: pinyin.keyboardLayoutID), + InputContext( + sourceID: "com.apple.inputmethod.TCIM.Pinyin", + inputModeID: "com.apple.inputmethod.TCIM.Pinyin", + keyboardLayoutID: pinyin.keyboardLayoutID + ) + ] + + for context in contexts { + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { context }) + } + } + + func testUnsupportedUnderlyingLayoutsPassThrough() { + for layoutID in ["com.apple.keylayout.Russian", "com.apple.keylayout.French", "custom.layout", ""] { + let context = InputContext( + sourceID: pinyin.sourceID, + inputModeID: pinyin.inputModeID, + keyboardLayoutID: layoutID + ) + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { context }) + } + } + + func testSourceChangesTakeEffectOnTheNextKeyInBothDirections() { + let latin = InputContext(sourceID: "com.apple.keylayout.ABC", inputModeID: nil, keyboardLayoutID: "com.apple.keylayout.ABC") + var context = pinyin + var contextReads = 0 + let provider = { + contextReads += 1 + return context + } + + XCTAssertEqual( + MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40, contextProvider: provider), + "《》" + ) + context = latin + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40, contextProvider: provider)) + context = pinyin + XCTAssertEqual( + MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40, contextProvider: provider), + "《》" + ) + XCTAssertEqual(contextReads, 3) + } + + func testUnderlyingLayoutChangesTakeEffectOnTheNextKey() { + var context = pinyin + XCTAssertEqual(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { context }, "《》") + + context = InputContext(sourceID: pinyin.sourceID, inputModeID: pinyin.inputModeID, keyboardLayoutID: "custom.layout") + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { context }) + } + + func testOtherPunctuationAndShiftVariantsPassThroughWithoutReadingTheInputSource() { + let keys: [(CGKeyCode, CGEventFlags)] = [ + (CGKeyCode(kVK_ANSI_Comma), []), + (CGKeyCode(kVK_ANSI_Period), []), + (CGKeyCode(kVK_ANSI_4), []), + (CGKeyCode(kVK_ANSI_Grave), .maskShift), + (CGKeyCode(kVK_ANSI_LeftBracket), .maskShift), + (CGKeyCode(kVK_ANSI_RightBracket), .maskShift), + (CGKeyCode(kVK_ANSI_Quote), []), + (CGKeyCode(kVK_ANSI_Quote), .maskShift), + (CGKeyCode(kVK_ANSI_Semicolon), []), + (CGKeyCode(kVK_ANSI_A), []) + ] + + for (keyCode, flags) in keys { + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: keyCode, flags: flags, keyboardType: 40) { + XCTFail("Unmapped key must not query the input source") + return self.pinyin + }) + } + } + + func testShortcutsAndCapsLockPassThroughWithoutReadingTheInputSource() { + let modifiers: [CGEventFlags] = [.maskCommand, .maskControl, .maskAlternate, .maskSecondaryFn, .maskAlphaShift] + let keys = mappedKeys + [(CGKeyCode(kVK_ANSI_Backslash), [], "]")] + for keyboardType: Int64 in [40, 41, 42] { + for modifier in modifiers { + for key in keys { + XCTAssertNil(MarkdownPunctuationMapping.replacement( + for: key.keyCode, flags: key.flags.union(modifier), keyboardType: keyboardType + ) { + XCTFail("Modified key must not query the input source") + return self.pinyin + }) + } + } + } + } + + func testJISShiftedBracketKeysPassThrough() { + for keyCode in [kVK_ANSI_RightBracket, kVK_ANSI_Backslash] { + XCTAssertNil(MarkdownPunctuationMapping.replacement( + for: CGKeyCode(keyCode), flags: .maskShift, keyboardType: 42 + ) { self.pinyin }) + } + } + + func testUnavailableInputContextPassesThrough() { + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { nil }) + } +} diff --git a/Tests/PermissionsVMTests.swift b/Tests/PermissionsVMTests.swift new file mode 100644 index 0000000..22c205d --- /dev/null +++ b/Tests/PermissionsVMTests.swift @@ -0,0 +1,102 @@ +import AppKit +import Combine +import XCTest +@testable import Input_Source_Pro + +@MainActor +final class PermissionsVMTests: XCTestCase { + func testAppActivationDetectsRepeatedRegrantsWithoutGeneralSettings() { + var granted = true + let notificationCenter = NotificationCenter() + let permissions = PermissionsVM( + accessibilityCheck: { granted }, + inputMonitoringCheck: { granted }, + notificationCenter: notificationCenter + ) + + for _ in 0..<2 { + granted = false + permissions.refresh() + XCTAssertFalse(permissions.isAccessibilityEnabled) + XCTAssertFalse(permissions.isInputMonitoringEnabled) + + let regranted = expectation(description: "Permission grant is published after activation") + let subscription = permissions.$isAccessibilityEnabled + .combineLatest(permissions.$isInputMonitoringEnabled) + .filter { $0 && $1 } + .first() + .sink { _ in regranted.fulfill() } + + granted = true + notificationCenter.post(name: NSApplication.didBecomeActiveNotification, object: nil) + wait(for: [regranted], timeout: 1) + + XCTAssertTrue(permissions.isAccessibilityEnabled) + XCTAssertTrue(permissions.isInputMonitoringEnabled) + withExtendedLifetime(subscription) {} + } + } + + func testAppActivationDetectsRevocationWithoutGeneralSettings() { + var granted = true + let notificationCenter = NotificationCenter() + let permissions = PermissionsVM( + accessibilityCheck: { granted }, + inputMonitoringCheck: { granted }, + notificationCenter: notificationCenter + ) + let revoked = expectation(description: "Permission revocation is published after activation") + let subscription = permissions.$isAccessibilityEnabled + .combineLatest(permissions.$isInputMonitoringEnabled) + .filter { !$0 && !$1 } + .first() + .sink { _ in revoked.fulfill() } + + granted = false + notificationCenter.post(name: NSApplication.didBecomeActiveNotification, object: nil) + wait(for: [revoked], timeout: 1) + + XCTAssertFalse(permissions.isAccessibilityEnabled) + XCTAssertFalse(permissions.isInputMonitoringEnabled) + withExtendedLifetime(subscription) {} + } + + func testRefreshDetectsRevocationAndRegrant() { + var accessibilityGranted = true + var inputMonitoringGranted = true + let permissions = PermissionsVM( + accessibilityCheck: { accessibilityGranted }, + inputMonitoringCheck: { inputMonitoringGranted } + ) + + accessibilityGranted = false + inputMonitoringGranted = false + permissions.refresh() + + XCTAssertFalse(permissions.isAccessibilityEnabled) + XCTAssertFalse(permissions.isInputMonitoringEnabled) + + accessibilityGranted = true + inputMonitoringGranted = true + permissions.refresh() + + XCTAssertTrue(permissions.isAccessibilityEnabled) + XCTAssertTrue(permissions.isInputMonitoringEnabled) + } + + func testRefreshTracksPermissionsIndependently() { + var accessibilityGranted = true + var inputMonitoringGranted = false + let permissions = PermissionsVM( + accessibilityCheck: { accessibilityGranted }, + inputMonitoringCheck: { inputMonitoringGranted } + ) + + accessibilityGranted = false + inputMonitoringGranted = true + permissions.refresh() + + XCTAssertFalse(permissions.isAccessibilityEnabled) + XCTAssertTrue(permissions.isInputMonitoringEnabled) + } +} diff --git a/Tests/PunctuationServiceTests.swift b/Tests/PunctuationServiceTests.swift new file mode 100644 index 0000000..8367e42 --- /dev/null +++ b/Tests/PunctuationServiceTests.swift @@ -0,0 +1,350 @@ +import AppKit +import Carbon +import Combine +import XCTest +@testable import Input_Source_Pro + +@MainActor +final class PunctuationServiceTests: XCTestCase { + private final class Environment { + var inputMonitoring = true + var accessibility = true + var anotherInstance = false + var canCreateTap = true + var createdTaps = 0 + var invalidatedTaps = 0 + var time: TimeInterval = 0 + var timeStep: TimeInterval = 0 + var inputContext: MarkdownPunctuationMapping.InputContext? + + @MainActor + func makeService() -> PunctuationService { + var dependencies = PunctuationService.Dependencies() + dependencies.hasInputMonitoring = { self.inputMonitoring } + dependencies.hasAccessibility = { self.accessibility } + dependencies.hasAnotherInstance = { self.anotherInstance } + dependencies.createEventTap = { _, _ in + guard self.canCreateTap else { return nil } + self.createdTaps += 1 + return PunctuationService.EventTap { self.invalidatedTaps += 1 } + } + dependencies.markdownInputContext = { self.inputContext } + dependencies.now = { + defer { self.time += self.timeStep } + return self.time + } + return PunctuationService(dependencies: dependencies) + } + } + + func testMarkdownUsesEachEventsKeyboardType() throws { + let environment = Environment() + environment.inputContext = .init( + sourceID: "com.apple.inputmethod.SCIM.ITABC", + inputModeID: "com.apple.inputmethod.SCIM.ITABC", + keyboardLayoutID: "com.apple.keylayout.PinyinKeyboard" + ) + let service = environment.makeService() + try service.enable(mode: .markdown).get() + let keyEvent = try event() + keyEvent.setIntegerValueField(.keyboardEventKeycode, value: Int64(kVK_ANSI_RightBracket)) + + for (keyboardType, expected): (Int64, String) in [(40, "]"), (42, "["), (41, "]")] { + keyEvent.setIntegerValueField(.keyboardEventKeyboardType, value: keyboardType) + let handled = service.handleKeyEvent(type: .keyDown, event: keyEvent) + guard handled.takeUnretainedValue() !== keyEvent else { + XCTFail("Expected a replacement for keyboard type \(keyboardType)") + continue + } + let replacement = handled.takeRetainedValue() + var length = 0 + var characters = [UniChar](repeating: 0, count: 8) + replacement.keyboardGetUnicodeString( + maxStringLength: characters.count, + actualStringLength: &length, + unicodeString: &characters + ) + XCTAssertEqual(String(utf16CodeUnits: characters, count: length), expected) + } + } + + func testRejectedRevalidationInvalidatesTapImmediately() throws { + let environment = Environment() + let service = environment.makeService() + try service.enable(mode: .markdown).get() + environment.anotherInstance = true + + XCTAssertEqual(service.enable(mode: .markdown).failure, .anotherInstanceRunning) + XCTAssertNil(service.activeMode) + XCTAssertEqual(environment.invalidatedTaps, 1) + } + + func testPermissionFailureReportsEachMissingPermission() { + let environment = Environment() + environment.inputMonitoring = false + let service = environment.makeService() + + XCTAssertEqual( + service.enable(mode: .markdown).failure, + .missingPermissions(inputMonitoring: true, accessibility: false) + ) + XCTAssertEqual(environment.createdTaps, 0) + } + + func testFailedOptInPreservesExistingEnglishTap() throws { + let environment = Environment() + let service = environment.makeService() + try service.enable(mode: .appEnglish).get() + environment.anotherInstance = true + + XCTAssertEqual(service.enable(mode: .markdown).failure, .anotherInstanceRunning) + XCTAssertEqual(service.activeMode, .appEnglish) + XCTAssertEqual(environment.invalidatedTaps, 0) + XCTAssertEqual(environment.createdTaps, 1) + } + + func testDisableCleansUpOnlyOnceAndAllowsRestart() throws { + let environment = Environment() + let service = environment.makeService() + try service.enable(mode: .markdown).get() + service.disable() + service.disable() + XCTAssertEqual(environment.invalidatedTaps, 1) + + try service.enable(mode: .markdown).get() + XCTAssertEqual(service.activeMode, .markdown) + XCTAssertEqual(environment.createdTaps, 2) + } + + func testSafetyCallbackCanRestartWithoutStaleCleanupStoppingNewTap() throws { + let environment = Environment() + let service = environment.makeService() + try service.enable(mode: .markdown).get() + var shutdown: PunctuationService.SafetyShutdown? + service.onSafetyShutdown = { [weak service] failure in + shutdown = failure + XCTAssertNil(service?.activeMode) + service?.enable(mode: .appEnglish) + } + + _ = service.handleKeyEvent(type: .tapDisabledByTimeout, event: try event()) + + XCTAssertEqual(shutdown, .init(mode: .markdown, failure: .eventTapDisabled)) + XCTAssertEqual(environment.invalidatedTaps, 1) + XCTAssertEqual(environment.createdTaps, 2) + XCTAssertEqual(service.activeMode, .appEnglish) + } + + func testHandlerWatchdogOnlyStopsMarkdown() throws { + let environment = Environment() + environment.timeStep = 0.06 + let service = environment.makeService() + let keyEvent = try event() + try service.enable(mode: .appEnglish).get() + _ = service.handleKeyEvent(type: .keyDown, event: keyEvent) + XCTAssertEqual(service.activeMode, .appEnglish) + + try service.enable(mode: .markdown).get() + var shutdown: PunctuationService.SafetyShutdown? + service.onSafetyShutdown = { shutdown = $0 } + _ = service.handleKeyEvent(type: .keyDown, event: keyEvent) + XCTAssertNil(service.activeMode) + XCTAssertEqual(shutdown, .init(mode: .markdown, failure: .handlerTimedOut)) + } + + func testCreationFailureDoesNotReportActiveMarkdown() { + let environment = Environment() + environment.canCreateTap = false + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: false) + var preference: Bool? + controller.onPreferenceChange = { preference = $0 } + + controller.setEnabled(true) + + XCTAssertFalse(controller.isEnabled) + XCTAssertEqual(controller.failure, .eventTapCreationFailed) + XCTAssertEqual(preference, false) + XCTAssertNil(service.activeMode) + } + + func testStartupRequestDoesNotNeedAnAppContext() { + let environment = Environment() + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: false) + + controller.setEnabled(true) + + XCTAssertTrue(controller.isEnabled) + XCTAssertEqual(service.activeMode, .markdown) + XCTAssertEqual(environment.createdTaps, 1) + } + + func testRejectedOptInRestoresApplicableEnglishRule() { + let environment = Environment() + environment.accessibility = false + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: true) + + controller.setEnabled(true) + + XCTAssertFalse(controller.isEnabled) + XCTAssertEqual(service.activeMode, .appEnglish) + XCTAssertEqual(controller.failure, .missingPermissions(inputMonitoring: false, accessibility: true)) + } + + func testRuntimeRejectionStaysStoppedAcrossAppChangesUntilRetry() { + let environment = Environment() + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: true) + controller.setEnabled(true) + environment.anotherInstance = true + + controller.revalidateMarkdown() + controller.appContextChanged(shouldEnableAppEnglish: true) + environment.anotherInstance = false + controller.revalidateMarkdown() + + XCTAssertNil(service.activeMode) + XCTAssertFalse(controller.isEnabled) + XCTAssertEqual(controller.failure, .anotherInstanceRunning) + XCTAssertEqual(environment.invalidatedTaps, 1) + + controller.setEnabled(true) + XCTAssertTrue(controller.isEnabled) + XCTAssertNil(controller.failure) + XCTAssertEqual(service.activeMode, .markdown) + } + + func testRuntimeSafetyShutdownDoesNotFallBackToEnglish() throws { + let environment = Environment() + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: true) + controller.setEnabled(true) + + _ = service.handleKeyEvent(type: .tapDisabledByUserInput, event: try event()) + controller.appContextChanged(shouldEnableAppEnglish: true) + + XCTAssertNil(service.activeMode) + XCTAssertEqual(controller.failure, .eventTapDisabled) + XCTAssertFalse(controller.isEnabled) + XCTAssertEqual(environment.createdTaps, 1) + } + + func testLegacyTapDisableDoesNotProduceMarkdownFailure() throws { + let environment = Environment() + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: true) + controller.setEnabled(false) + + _ = service.handleKeyEvent(type: .tapDisabledByTimeout, event: try event()) + + XCTAssertNil(service.activeMode) + XCTAssertNil(controller.failure) + XCTAssertFalse(controller.isEnabled) + } + + func testAppRuleUsesIncomingContext() { + let environment = Environment() + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: false) + controller.setEnabled(false) + + controller.appContextChanged(shouldEnableAppEnglish: true) + XCTAssertEqual(service.activeMode, .appEnglish) + controller.appContextChanged(shouldEnableAppEnglish: false) + XCTAssertNil(service.activeMode) + } + + func testRejectedPublishedPreferenceCanBeRetriedWithoutAnAppChange() throws { + @MainActor + final class PreferenceStore: ObservableObject { + @Published var preferences: Preferences + + init(defaults: UserDefaults) { + preferences = Preferences(markdownModeUserDefaults: defaults) + } + + func update(_ change: (inout Preferences) -> Void) { + var draft = preferences + change(&draft) + preferences = draft + } + } + + let suiteName = "PunctuationServiceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let environment = Environment() + environment.accessibility = false + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: false) + let store = PreferenceStore(defaults: defaults) + let subscription = controller.bindPreference( + values: store.$preferences.map(\.isMarkdownModeEnabled).eraseToAnyPublisher(), + currentValue: { store.preferences.isMarkdownModeEnabled }, + update: { enabled in store.update { $0.isMarkdownModeEnabled = enabled } } + ) + let backup = try JSONDecoder().decode( + SettingsBackupPreferences.self, + from: Data(#"{"isMarkdownModeEnabled":true}"#.utf8) + ) + + store.update { backup.apply(to: &$0) } + XCTAssertFalse(store.preferences.isMarkdownModeEnabled) + XCTAssertFalse(controller.isEnabled) + XCTAssertNil(service.activeMode) + XCTAssertEqual(controller.failure, .missingPermissions(inputMonitoring: false, accessibility: true)) + + environment.accessibility = true + store.update { backup.apply(to: &$0) } + XCTAssertTrue(store.preferences.isMarkdownModeEnabled) + XCTAssertTrue(controller.isEnabled) + XCTAssertEqual(service.activeMode, .markdown) + XCTAssertNil(controller.failure) + withExtendedLifetime(subscription) {} + } + + func testUnrelatedDisabledPreferenceDoesNotClearSafetyStop() throws { + final class PreferenceStore: ObservableObject { + @Published var enabled = true + } + let environment = Environment() + let service = environment.makeService() + let controller = MarkdownModeController(service: service, shouldEnableAppEnglish: true) + let store = PreferenceStore() + let subscription = controller.bindPreference( + values: store.$enabled.eraseToAnyPublisher(), + currentValue: { store.enabled }, + update: { store.enabled = $0 } + ) + XCTAssertTrue(controller.isEnabled) + + _ = service.handleKeyEvent(type: .tapDisabledByTimeout, event: try event()) + store.enabled = false + controller.appContextChanged(shouldEnableAppEnglish: true) + + XCTAssertFalse(store.enabled) + XCTAssertFalse(controller.isEnabled) + XCTAssertNil(service.activeMode) + XCTAssertEqual(controller.failure, .eventTapDisabled) + XCTAssertEqual(environment.createdTaps, 1) + withExtendedLifetime(subscription) {} + } + + private func event() throws -> CGEvent { + let event = try XCTUnwrap(CGEvent(source: nil)) + event.type = .keyDown + event.flags = [] + event.setIntegerValueField(.keyboardEventKeycode, value: Int64(kVK_ANSI_A)) + event.setIntegerValueField(.eventSourceUserData, value: 0) + return event + } +} + +private extension Result where Success == Void, Failure == PunctuationService.Failure { + var failure: Failure? { + if case .failure(let failure) = self { return failure } + return nil + } +} diff --git a/Tests/SettingsBackupPreferencesTests.swift b/Tests/SettingsBackupPreferencesTests.swift new file mode 100644 index 0000000..af02659 --- /dev/null +++ b/Tests/SettingsBackupPreferencesTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import Input_Source_Pro + +@MainActor +final class SettingsBackupPreferencesTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUpWithError() throws { + try super.setUpWithError() + suiteName = "SettingsBackupPreferencesTests.\(UUID().uuidString)" + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + } + + override func tearDownWithError() throws { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + try super.tearDownWithError() + } + + func testExportRoundTripIncludesEnabledMarkdownMode() throws { + try assertExportRoundTrip(markdownModeEnabled: true) + } + + func testExportRoundTripIncludesDisabledMarkdownMode() throws { + try assertExportRoundTrip(markdownModeEnabled: false) + } + + func testImportEnablesMarkdownMode() throws { + try assertImport( + json: #"{"isMarkdownModeEnabled":true}"#, + initialValue: false, + expectedValue: true + ) + } + + func testImportDisablesMarkdownMode() throws { + try assertImport( + json: #"{"isMarkdownModeEnabled":false}"#, + initialValue: true, + expectedValue: false + ) + } + + func testOlderBackupPreservesEnabledMarkdownMode() throws { + try assertImport(json: "{}", initialValue: true, expectedValue: true) + } + + func testOlderBackupPreservesDisabledMarkdownMode() throws { + try assertImport(json: "{}", initialValue: false, expectedValue: false) + } + + private func assertExportRoundTrip( + markdownModeEnabled: Bool, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let preferences = makePreferences(markdownModeEnabled: markdownModeEnabled) + let data = try JSONEncoder().encode(SettingsBackupPreferences(preferences)) + let decoded = try JSONDecoder().decode(SettingsBackupPreferences.self, from: data) + + XCTAssertEqual(decoded.isMarkdownModeEnabled, markdownModeEnabled, file: file, line: line) + } + + private func assertImport( + json: String, + initialValue: Bool, + expectedValue: Bool, + file: StaticString = #filePath, + line: UInt = #line + ) throws { + var preferences = makePreferences(markdownModeEnabled: initialValue) + let backup = try JSONDecoder().decode(SettingsBackupPreferences.self, from: Data(json.utf8)) + + backup.apply(to: &preferences) + + XCTAssertEqual(preferences.isMarkdownModeEnabled, expectedValue, file: file, line: line) + } + + private func makePreferences(markdownModeEnabled: Bool) -> Preferences { + let preferences = Preferences(markdownModeUserDefaults: defaults) + preferences.isMarkdownModeEnabled = markdownModeEnabled + return preferences + } +}