From 950380ab725c91835e89d99b9da41159c5a4d697 Mon Sep 17 00:00:00 2001 From: xuke57 Date: Sun, 30 Aug 2026 15:05:40 +0800 Subject: [PATCH 1/7] feat: add fail-safe Markdown punctuation mode --- Input Source Pro/Models/IndicatorVM.swift | 40 +++++-- Input Source Pro/Models/PreferencesVM.swift | 4 + .../Resources/en.lproj/Localizable.strings | 5 + .../Resources/ja.lproj/Localizable.strings | 5 + .../Resources/ko.lproj/Localizable.strings | 5 + .../zh-Hans.lproj/Localizable.strings | 5 + .../zh-Hant.lproj/Localizable.strings | 5 + .../UI/Screens/GeneralSettingsView.swift | 53 +++++++++ .../Utilities/PunctuationService.swift | 102 ++++++++++++++++-- README.md | 6 +- 10 files changed, 209 insertions(+), 21 deletions(-) diff --git a/Input Source Pro/Models/IndicatorVM.swift b/Input Source Pro/Models/IndicatorVM.swift index fed9918..8c8233a 100644 --- a/Input Source Pro/Models/IndicatorVM.swift +++ b/Input Source Pro/Models/IndicatorVM.swift @@ -29,6 +29,9 @@ final class IndicatorVM: ObservableObject { @Published private(set) var state: State + @Published + private(set) var isMarkdownModeSafetyWarningVisible = false + var actionSubject = PassthroughSubject() var refreshShortcutSubject = PassthroughSubject() @@ -82,7 +85,7 @@ final class IndicatorVM: ObservableObject { clearAppKeyboardCacheIfNeed() watchState() - watchPunctuationRules() + watchMarkdownMode() watchFunctionKeyMode() } @@ -106,16 +109,33 @@ final class IndicatorVM: ObservableObject { .store(in: cancelBag) } - private func watchPunctuationRules() { - applicationVM.$appKind - .compactMap { $0 } - .sink { [weak self] appKind in + private func watchMarkdownMode() { + punctuationService.onSafetyShutdown = { [weak self, weak preferencesVM] reason in + self?.logger.debug { "Markdown mode safety shutdown: \(reason)" } + self?.isMarkdownModeSafetyWarningVisible = true + preferencesVM?.update { $0.isMarkdownModeEnabled = false } + } + + Publishers.CombineLatest( + preferencesVM.$preferences + .map(\.isMarkdownModeEnabled) + .removeDuplicates(), + applicationVM.$appKind.compactMap { $0 } + ) + .sink { [weak self] isMarkdownModeEnabled, 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() + + if isMarkdownModeEnabled { + self.logger.debug { "Enabling Markdown mode globally" } + if !self.punctuationService.enable(mode: .markdown) { + self.isMarkdownModeSafetyWarningVisible = true + self.preferencesVM.update { $0.isMarkdownModeEnabled = false } + } else { + self.isMarkdownModeSafetyWarningVisible = false + } + } else if self.punctuationService.shouldEnableForApp(appKind.getApp()) { + self.logger.debug { "Enabling English punctuation for app rule" } + self.punctuationService.enable(mode: .appEnglish) } else { self.punctuationService.disable() } diff --git a/Input Source Pro/Models/PreferencesVM.swift b/Input Source Pro/Models/PreferencesVM.swift index e9dc183..9ff00d2 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" @@ -346,6 +347,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/Resources/en.lproj/Localizable.strings b/Input Source Pro/Resources/en.lproj/Localizable.strings index 59c749b..533398d 100644 --- a/Input Source Pro/Resources/en.lproj/Localizable.strings +++ b/Input Source Pro/Resources/en.lproj/Localizable.strings @@ -127,6 +127,11 @@ "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" = "While using a CJK input source, replace ·, ¥, 》, 【, and 】 with `, $, >, [, and ]. Typing 《 inserts the complete 《》 pair; other punctuation is unchanged."; +"Markdown Mode Disabled for Safety" = "Markdown Mode was disabled for safety"; +"Markdown Mode Disabled Description" = "Check Input Monitoring and Accessibility permissions, and quit other Input Source Pro instances before trying again."; "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..b37269a 100644 --- a/Input Source Pro/Resources/ja.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ja.lproj/Localizable.strings @@ -127,6 +127,11 @@ "Punctuation" = "句読点"; "ASCII Punctuation" = "ASCII 句読点"; "Force English Punctuation" = "英語句読点を強制使用"; +"Markdown Mode" = "Markdown モード"; +"Enable Markdown Mode" = "Markdown モードを有効にする"; +"Markdown Mode Description" = "CJK 入力中は ·、¥、》、【、】を `、$、>、[、] に置換し、《を入力すると《》を補完します。その他の句読点は変更しません。"; +"Markdown Mode Disabled for Safety" = "安全のため Markdown モードを無効にしました"; +"Markdown Mode Disabled Description" = "入力監視とアクセシビリティの権限を確認し、他の Input Source Pro を終了してから再試行してください。"; "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..455c09f 100644 --- a/Input Source Pro/Resources/ko.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ko.lproj/Localizable.strings @@ -127,6 +127,11 @@ "Punctuation" = "구두점"; "ASCII Punctuation" = "ASCII 구두점"; "Force English Punctuation" = "영어 구두점 강제 사용"; +"Markdown Mode" = "Markdown 모드"; +"Enable Markdown Mode" = "Markdown 모드 활성화"; +"Markdown Mode Description" = "CJK 입력기를 사용할 때 ·, ¥, 》, 【, 】를 `, $, >, [, ]로 바꾸고 《 입력 시 《》 쌍을 완성합니다. 다른 문장 부호는 바꾸지 않습니다."; +"Markdown Mode Disabled for Safety" = "안전을 위해 Markdown 모드를 비활성화했습니다"; +"Markdown Mode Disabled Description" = "입력 모니터링 및 손쉬운 사용 권한을 확인하고 다른 Input Source Pro 인스턴스를 종료한 후 다시 시도하세요."; "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..368755a 100644 --- a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings @@ -129,6 +129,11 @@ "Punctuation" = "标点符号"; "ASCII Punctuation" = "ASCII 标点符号"; "Force English Punctuation" = "强制使用英文标点符号"; +"Markdown Mode" = "Markdown 模式"; +"Enable Markdown Mode" = "启用 Markdown 模式"; +"Markdown Mode Description" = "使用中文输入法时,将 ·、¥、》、【、】替换为 `、$、>、[、];输入《时补全为《》,其他标点保持不变。"; +"Markdown Mode Disabled for Safety" = "Markdown 模式已为安全起见关闭"; +"Markdown Mode Disabled Description" = "请检查输入监控和辅助功能权限,并退出其他 Input Source Pro 实例后再试。"; "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..abf997a 100644 --- a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings @@ -129,6 +129,11 @@ "Punctuation" = "標點符號"; "ASCII Punctuation" = "ASCII 標點符號"; "Force English Punctuation" = "強制使用英文標點符號"; +"Markdown Mode" = "Markdown 模式"; +"Enable Markdown Mode" = "啟用 Markdown 模式"; +"Markdown Mode Description" = "使用中文輸入法時,將 ·、¥、》、【、】替換為 `、$、>、[、];輸入《時補全為《》,其他標點維持不變。"; +"Markdown Mode Disabled for Safety" = "Markdown 模式已基於安全考量關閉"; +"Markdown Mode Disabled Description" = "請檢查輸入監控與輔助使用權限,並結束其他 Input Source Pro 實例後再試。"; "Use Function Keys" = "使用功能鍵 (F1、F2 等)"; "Use Media Keys" = "使用媒體鍵 (亮度、音量等)"; "Use Global Setting" = "使用全域設定"; diff --git a/Input Source Pro/UI/Screens/GeneralSettingsView.swift b/Input Source Pro/UI/Screens/GeneralSettingsView.swift index 93a6d2d..a13a953 100644 --- a/Input Source Pro/UI/Screens/GeneralSettingsView.swift +++ b/Input Source Pro/UI/Screens/GeneralSettingsView.swift @@ -86,6 +86,59 @@ struct GeneralSettingsView: View { .padding() } + SettingsSection(title: "Markdown Mode") { + VStack(alignment: .leading, spacing: 8) { + HStack { + Toggle("", isOn: $preferencesVM.preferences.isMarkdownModeEnabled) + + VStack(alignment: .leading, spacing: 2) { + Text("Enable Markdown Mode".i18n()) + Text("Markdown Mode Description".i18n()) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + } + + if preferencesVM.preferences.isMarkdownModeEnabled && + (!PermissionsVM.checkInputMonitoring(prompt: false) || + !PermissionsVM.checkAccessibility(prompt: false)) { + HStack { + Text("This feature requires input monitoring permission to work".i18n()) + .font(.subheadline) + + Spacer() + + Button("Open Permission Settings".i18n()) { + NSWorkspace.shared.openInputMonitoringPreferences() + } + + Button("Open Accessibility Settings".i18n()) { + NSWorkspace.shared.openAccessibilityPreferences() + } + } + .padding(8) + .background(NSColor.background1.color) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + if indicatorVM.isMarkdownModeSafetyWarningVisible { + VStack(alignment: .leading, spacing: 4) { + Text("Markdown Mode Disabled for Safety".i18n()) + .fontWeight(.medium) + Text("Markdown Mode Disabled Description".i18n()) + .font(.subheadline) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + .padding() + } + Group { SettingsSection(title: "Indicator Triggers") { HStack { diff --git a/Input Source Pro/Utilities/PunctuationService.swift b/Input Source Pro/Utilities/PunctuationService.swift index 8f8bbfc..c228503 100644 --- a/Input Source Pro/Utilities/PunctuationService.swift +++ b/Input Source Pro/Utilities/PunctuationService.swift @@ -6,18 +6,27 @@ import os @MainActor class PunctuationService: ObservableObject { + enum Mode: Equatable { + case appEnglish + case markdown + } + private let logger = ISPLogger(category: String(describing: PunctuationService.self)) + private let generatedEventMarker: Int64 = 0x4953_504D_44 + private let maximumHandlerDuration: TimeInterval = 0.05 private var isEnabled = false private var eventTap: CFMachPort? + private var mode = Mode.appEnglish private weak var preferencesVM: PreferencesVM? + var onSafetyShutdown: ((String) -> Void)? // 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: (normal: String?, shifted: String?)] = [ + private let appEnglishPunctuationMap: [UInt16: (normal: String?, shifted: String?)] = [ UInt16(kVK_ANSI_Grave): ("`", "~"), UInt16(kVK_ANSI_4): (nil, "$"), UInt16(kVK_ANSI_6): (nil, "^"), @@ -30,7 +39,25 @@ class PunctuationService: ObservableObject { UInt16(kVK_ANSI_LeftBracket): ("[", "{"), UInt16(kVK_ANSI_RightBracket): ("]", "}") ] - + + private let markdownPunctuationMap: [UInt16: (normal: String?, shifted: String?)] = [ + UInt16(kVK_ANSI_Grave): ("`", nil), + UInt16(kVK_ANSI_4): (nil, "$"), + UInt16(kVK_ANSI_Comma): (nil, "《》"), + UInt16(kVK_ANSI_Period): (nil, ">"), + UInt16(kVK_ANSI_LeftBracket): ("[", nil), + UInt16(kVK_ANSI_RightBracket): ("]", nil) + ] + + private var punctuationReplacementMap: [UInt16: (normal: String?, shifted: String?)] { + switch mode { + case .appEnglish: + return appEnglishPunctuationMap + case .markdown: + return markdownPunctuationMap + } + } + init(preferencesVM: PreferencesVM) { self.preferencesVM = preferencesVM } @@ -44,8 +71,24 @@ class PunctuationService: ObservableObject { } } - func enable() { - guard !isEnabled else { return } + @discardableResult + func enable(mode: Mode) -> Bool { + if mode == .markdown { + guard PermissionsVM.checkInputMonitoring(prompt: false), + PermissionsVM.checkAccessibility(prompt: false) + else { + logger.debug { "Refusing to enable Markdown mode without required permissions" } + return false + } + + guard !hasAnotherInputSourceProInstance else { + logger.debug { "Refusing to enable Markdown mode while another Input Source Pro instance is running" } + return false + } + } + + self.mode = mode + guard !isEnabled else { return true } let hasPermission = PermissionsVM.checkInputMonitoring(prompt: false) @@ -67,6 +110,8 @@ class PunctuationService: ObservableObject { logger.debug { "Failed to start English punctuation service - Input Monitoring permission required" } // Service will remain disabled until next enable() call or permission state change } + + return success } func disable() { @@ -152,6 +197,11 @@ class PunctuationService: ObservableObject { } private func handleKeyEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + scheduleSafetyShutdown(reason: "The keyboard event tap was disabled by macOS") + return Unmanaged.passUnretained(event) + } + // Handle event tap being disabled (can happen if permissions are revoked) guard isEnabled else { return Unmanaged.passUnretained(event) @@ -160,11 +210,23 @@ class PunctuationService: ObservableObject { guard type == .keyDown else { return Unmanaged.passUnretained(event) } + + guard event.getIntegerValueField(.eventSourceUserData) != generatedEventMarker else { + return Unmanaged.passUnretained(event) + } + + let handlerStartTime = CACurrentMediaTime() + defer { + let duration = CACurrentMediaTime() - handlerStartTime + if duration > maximumHandlerDuration { + scheduleSafetyShutdown(reason: "Keyboard event handling exceeded the safety limit") + } + } let keyCode = event.getIntegerValueField(.keyboardEventKeycode) // Check if this is a punctuation key we want to intercept - guard let mapping = cjkvToEnglishPunctuationMap[UInt16(keyCode)] else { + guard let mapping = punctuationReplacementMap[UInt16(keyCode)] else { // Not a punctuation key we're interested in return Unmanaged.passUnretained(event) } @@ -215,6 +277,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,13 +292,36 @@ class PunctuationService: ObservableObject { return flags.intersection(shortcutModifiers).isEmpty } + private var hasAnotherInputSourceProInstance: Bool { + let knownBundleIdentifiers = [ + "com.runjuu.Input-Source-Pro", + "com.runjuu.Input-Source-Pro.Markdown" + ] + + return knownBundleIdentifiers + .flatMap { NSRunningApplication.runningApplications(withBundleIdentifier: $0) } + .contains { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier && !$0.isTerminated } + } + + private func scheduleSafetyShutdown(reason: String) { + guard isEnabled else { return } + isEnabled = false + logger.debug { "Disabling punctuation service for safety: \(reason)" } + + Task { @MainActor [weak self] in + guard let self = self else { return } + self.stopMonitoring() + self.onSafetyShutdown?(reason) + } + } + 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() @@ -269,7 +355,7 @@ class PunctuationService: ObservableObject { - 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: ", ")) + - Monitored Keys: \(punctuationReplacementMap.map { "\($0.key)→'\($0.value.normal ?? "pass")'/'\($0.value.shifted ?? "pass")'" }.joined(separator: ", ")) """ } } } diff --git a/README.md b/README.md index 9a75035..1ba76f3 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ - Customizable and designed to stay out of your way. ### ✍️ App-Aware Punctuation Modes -Keep punctuation consistent across different languages by enabling **Force English Punctuation** for specific apps. -- 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. +This customized build adds an opt-in **Markdown Mode** that keeps selected punctuation consistent across all apps. +- Automatically replaces `·` with `` ` ``, `¥` with `$`, `》` with `>`, and `【】` with `[]`. Since the right book-title mark is replaced, typing `《` inserts the complete `《》` pair. Chinese prose punctuation, including localized quotation marks, remains unchanged. +- Turn on **Markdown Mode** in General settings, then optionally enable **Launch at Login** to apply it automatically after signing in. ### 🎛️ App-Based Function Key Switching Automatically switch your macOS function key mode per app. From 545983a144f67dc8985006a3fbcd1134c5b83050 Mon Sep 17 00:00:00 2001 From: runjuu Date: Tue, 8 Sep 2026 16:28:49 -0700 Subject: [PATCH 2/7] fix(markdown): restrict key mapping to supported Pinyin layouts Read the current source, mode and keyboard layout for each candidate key. Pass unsupported sources, layouts and modified shortcuts through unchanged, and cover source transitions with regression tests. --- Input Source Pro.xcodeproj/project.pbxproj | 8 + .../MarkdownPunctuationMapping.swift | 68 ++++++++ .../Utilities/PunctuationService.swift | 30 ++-- Tests/MarkdownPunctuationMappingTests.swift | 155 ++++++++++++++++++ 4 files changed, 241 insertions(+), 20 deletions(-) create mode 100644 Input Source Pro/Utilities/MarkdownPunctuationMapping.swift create mode 100644 Tests/MarkdownPunctuationMappingTests.swift diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index 1c845b2..d191cd2 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -176,6 +176,8 @@ 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 */; }; + C1315059B7754D59E9CFFAD6 /* MarkdownPunctuationMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -367,6 +369,8 @@ 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 = ""; }; + C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPunctuationMappingTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -484,6 +488,7 @@ 4A2A175D280BA7FA00E13249 /* Utilities */ = { isa = PBXGroup; children = ( + C131A22D5FAE13E5E9F14E9F /* MarkdownPunctuationMapping.swift */, 25C5A6B42E678D86005AB80E /* PunctuationService.swift */, D598C7E228B74B16004747D1 /* Indicator */, D598C7E428B74B48004747D1 /* AppKit */, @@ -738,6 +743,7 @@ D60000042F20000000000004 /* Tests */ = { isa = PBXGroup; children = ( + C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */, D60001012F20000000000101 /* AppKindComparisonTests.swift */, D60000512F20000000000051 /* InputSourceDeduplicationTests.swift */, D60000112F20000000000011 /* BrowserRuleSelectionTests.swift */, @@ -923,6 +929,7 @@ 4A881A93288BC55E00B76498 /* PreferencesVM+KeyboardConfig.swift in Sources */, 4A2A1789280BA7FA00E13249 /* TISInputSource+Extension.swift in Sources */, 4AC50D6B283A72810034E894 /* NSColor.swift in Sources */, + C13120191255119A7A95E7C2 /* MarkdownPunctuationMapping.swift in Sources */, 25C5A6B52E678D86005AB80E /* PunctuationService.swift in Sources */, 4ACC9B812D2640F70002B8CE /* AddSwitchingGroupButton.swift in Sources */, 4A2A178C280BA7FA00E13249 /* CancelBag.swift in Sources */, @@ -1063,6 +1070,7 @@ D60000122F20000000000012 /* BrowserRuleSelectionTests.swift in Sources */, D60000322F20000000000032 /* BrowserRuleValidationTests.swift in Sources */, D60000222F20000000000022 /* RuntimeRuleChangeNotifierTests.swift in Sources */, + C1315059B7754D59E9CFFAD6 /* MarkdownPunctuationMappingTests.swift in Sources */, D60000032F20000000000003 /* TestHarnessTests.swift in Sources */, D60000722F20000000000072 /* AppURLActionTests.swift in Sources */, D60000822F20000000000082 /* URLActivationSuppressionTests.swift in Sources */, diff --git a/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift b/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift new file mode 100644 index 0000000..59434c8 --- /dev/null +++ b/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift @@ -0,0 +1,68 @@ +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, ">"), + CGKeyCode(kVK_ANSI_LeftBracket): ("[", nil), + CGKeyCode(kVK_ANSI_RightBracket): ("]", nil) + ] + + static func replacement( + for keyCode: CGKeyCode, + flags: CGEventFlags, + contextProvider: () -> InputContext? = currentInputContext + ) -> String? { + guard let mapping = replacements[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 c228503..d319e09 100644 --- a/Input Source Pro/Utilities/PunctuationService.swift +++ b/Input Source Pro/Utilities/PunctuationService.swift @@ -40,24 +40,6 @@ class PunctuationService: ObservableObject { UInt16(kVK_ANSI_RightBracket): ("]", "}") ] - private let markdownPunctuationMap: [UInt16: (normal: String?, shifted: String?)] = [ - UInt16(kVK_ANSI_Grave): ("`", nil), - UInt16(kVK_ANSI_4): (nil, "$"), - UInt16(kVK_ANSI_Comma): (nil, "《》"), - UInt16(kVK_ANSI_Period): (nil, ">"), - UInt16(kVK_ANSI_LeftBracket): ("[", nil), - UInt16(kVK_ANSI_RightBracket): ("]", nil) - ] - - private var punctuationReplacementMap: [UInt16: (normal: String?, shifted: String?)] { - switch mode { - case .appEnglish: - return appEnglishPunctuationMap - case .markdown: - return markdownPunctuationMap - } - } - init(preferencesVM: PreferencesVM) { self.preferencesVM = preferencesVM } @@ -224,9 +206,17 @@ class PunctuationService: ObservableObject { } let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + + if mode == .markdown { + guard let keyCode = CGKeyCode(exactly: keyCode), + let replacement = MarkdownPunctuationMapping.replacement(for: keyCode, flags: event.flags), + let newEvent = createEnglishPunctuationEvent(originalEvent: event, replacement: replacement) + else { return Unmanaged.passUnretained(event) } + return Unmanaged.passRetained(newEvent) + } // Check if this is a punctuation key we want to intercept - guard let mapping = punctuationReplacementMap[UInt16(keyCode)] else { + guard let mapping = appEnglishPunctuationMap[UInt16(keyCode)] else { // Not a punctuation key we're interested in return Unmanaged.passUnretained(event) } @@ -355,7 +345,7 @@ class PunctuationService: ObservableObject { - CGEvent Permission Check: \(permissionViaCGEvent ? "✅ Passed" : "❌ Failed") - Accessibility Permission: \(accessibilityEnabled ? "✅ Granted" : "❌ Denied") - Current Input Source: \(currentInputSource.name) (CJKV: \(currentInputSource.isCJKVR)) - - Monitored Keys: \(punctuationReplacementMap.map { "\($0.key)→'\($0.value.normal ?? "pass")'/'\($0.value.shifted ?? "pass")'" }.joined(separator: ", ")) + - Monitored Keys: \(appEnglishPunctuationMap.map { "\($0.key)→'\($0.value.normal ?? "pass")'/'\($0.value.shifted ?? "pass")'" }.joined(separator: ", ")) """ } } } diff --git a/Tests/MarkdownPunctuationMappingTests.swift b/Tests/MarkdownPunctuationMappingTests.swift new file mode 100644 index 0000000..0776dbf --- /dev/null +++ b/Tests/MarkdownPunctuationMappingTests.swift @@ -0,0 +1,155 @@ +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 testMapsOnlySpecifiedKeysOnSupportedPinyinLayouts() { + 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 mappedKeys { + XCTAssertEqual( + MarkdownPunctuationMapping.replacement(for: key.keyCode, flags: key.flags) { context }, + key.replacement, + "Layout: \(layoutID), key: \(key.keyCode)" + ) + } + } + } + + 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) { 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) { 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) { 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) { 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, contextProvider: provider), + "《》" + ) + context = latin + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, contextProvider: provider)) + context = pinyin + XCTAssertEqual( + MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, contextProvider: provider), + "《》" + ) + XCTAssertEqual(contextReads, 3) + } + + func testUnderlyingLayoutChangesTakeEffectOnTheNextKey() { + var context = pinyin + XCTAssertEqual(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift) { context }, "《》") + + context = InputContext(sourceID: pinyin.sourceID, inputModeID: pinyin.inputModeID, keyboardLayoutID: "custom.layout") + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift) { 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) { + XCTFail("Unmapped key must not query the input source") + return self.pinyin + }) + } + } + + func testShortcutsAndCapsLockPassThroughWithoutReadingTheInputSource() { + let modifiers: [CGEventFlags] = [.maskCommand, .maskControl, .maskAlternate, .maskSecondaryFn, .maskAlphaShift] + for modifier in modifiers { + for key in mappedKeys { + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: key.keyCode, flags: key.flags.union(modifier)) { + XCTFail("Modified key must not query the input source") + return self.pinyin + }) + } + } + } + + func testUnavailableInputContextPassesThrough() { + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift) { nil }) + } +} From f1e5cc337db8b05694fd5af2522a5d9a9c83076f Mon Sep 17 00:00:00 2001 From: runjuu Date: Tue, 8 Sep 2026 16:29:10 -0700 Subject: [PATCH 3/7] fix(settings): preserve Markdown mode in backups Include the optional Markdown preference in export and import while preserving existing values for older backups. Cover enabled, disabled and missing values with isolated preference storage. --- Input Source Pro.xcodeproj/project.pbxproj | 4 + Input Source Pro/Models/PreferencesVM.swift | 8 ++ .../Persistence/SettingsBackup.swift | 3 + Tests/SettingsBackupPreferencesTests.swift | 86 +++++++++++++++++++ 4 files changed, 101 insertions(+) create mode 100644 Tests/SettingsBackupPreferencesTests.swift diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index d191cd2..b82bcf6 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -178,6 +178,7 @@ D60001022F20000000000102 /* AppKindComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60001012F20000000000101 /* AppKindComparisonTests.swift */; }; C13120191255119A7A95E7C2 /* MarkdownPunctuationMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C131A22D5FAE13E5E9F14E9F /* MarkdownPunctuationMapping.swift */; }; C1315059B7754D59E9CFFAD6 /* MarkdownPunctuationMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */; }; + C131FE642ECFD99AA25588ED /* SettingsBackupPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C131984D932C971B3780C1D0 /* SettingsBackupPreferencesTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -371,6 +372,7 @@ D60001012F20000000000101 /* AppKindComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKindComparisonTests.swift; sourceTree = ""; }; C131A22D5FAE13E5E9F14E9F /* MarkdownPunctuationMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPunctuationMapping.swift; sourceTree = ""; }; C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownPunctuationMappingTests.swift; sourceTree = ""; }; + C131984D932C971B3780C1D0 /* SettingsBackupPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBackupPreferencesTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -743,6 +745,7 @@ D60000042F20000000000004 /* Tests */ = { isa = PBXGroup; children = ( + C131984D932C971B3780C1D0 /* SettingsBackupPreferencesTests.swift */, C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */, D60001012F20000000000101 /* AppKindComparisonTests.swift */, D60000512F20000000000051 /* InputSourceDeduplicationTests.swift */, @@ -1071,6 +1074,7 @@ D60000322F20000000000032 /* BrowserRuleValidationTests.swift in Sources */, D60000222F20000000000022 /* RuntimeRuleChangeNotifierTests.swift in Sources */, C1315059B7754D59E9CFFAD6 /* MarkdownPunctuationMappingTests.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/PreferencesVM.swift b/Input Source Pro/Models/PreferencesVM.swift index 9ff00d2..2b9b300 100644 --- a/Input Source Pro/Models/PreferencesVM.swift +++ b/Input Source Pro/Models/PreferencesVM.swift @@ -336,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 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/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 + } +} From 35ed81496fa8c58e3d28c7303762bc1539c6e63d Mon Sep 17 00:00:00 2001 From: runjuu Date: Tue, 8 Sep 2026 16:29:30 -0700 Subject: [PATCH 4/7] fix(markdown): make activation and safety shutdown consistent Stop and release the active event tap before reporting runtime failures, scope the watchdog to Markdown, and keep safety shutdowns stopped until explicit retry. Reconcile preferences, permissions, app rules and instance launches through one controller. Keep permission actions available after rejected activation, refresh revoked grants, and display specific failure reasons. Add lifecycle, imported-preference retry and permission regression coverage. --- Input Source Pro.xcodeproj/project.pbxproj | 16 + Input Source Pro/Models/IndicatorVM.swift | 78 ++-- .../Models/MarkdownModeController.swift | 99 +++++ Input Source Pro/Models/PermissionsVM.swift | 31 +- .../Resources/en.lproj/Localizable.strings | 10 +- .../Resources/ja.lproj/Localizable.strings | 10 +- .../Resources/ko.lproj/Localizable.strings | 10 +- .../zh-Hans.lproj/Localizable.strings | 10 +- .../zh-Hant.lproj/Localizable.strings | 10 +- .../Components/MarkdownModeSettingsView.swift | 78 ++++ .../UI/Screens/GeneralSettingsView.swift | 67 +-- .../Utilities/PunctuationService.swift | 391 +++++++----------- Tests/PermissionsVMTests.swift | 44 ++ Tests/PunctuationServiceTests.swift | 318 ++++++++++++++ 14 files changed, 834 insertions(+), 338 deletions(-) create mode 100644 Input Source Pro/Models/MarkdownModeController.swift create mode 100644 Input Source Pro/UI/Components/MarkdownModeSettingsView.swift create mode 100644 Tests/PermissionsVMTests.swift create mode 100644 Tests/PunctuationServiceTests.swift diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index b82bcf6..ce5904e 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -177,7 +177,11 @@ 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 */ @@ -371,7 +375,11 @@ 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 */ @@ -536,6 +544,7 @@ 4A2A1770280BA7FA00E13249 /* Models */ = { isa = PBXGroup; children = ( + C131A06895BA856414E76CE3 /* MarkdownModeController.swift */, 4A2A1773280BA7FA00E13249 /* IndicatorVM.swift */, 4AC2614A2870735B0032AAFE /* IndicatorVM+Triggers.swift */, 4A2A1774280BA7FA00E13249 /* PreferencesVM.swift */, @@ -586,6 +595,7 @@ 4A2A177A280BA7FA00E13249 /* Components */ = { isa = PBXGroup; children = ( + C13161EC493FE46EA8BCD8CD /* MarkdownModeSettingsView.swift */, 4A2A177B280BA7FA00E13249 /* IndicatorView.swift */, 4A1BD90E287BD14000E4D8C2 /* CustomizedIndicatorView.swift */, 4AC450BE281D841300DA0329 /* PreferenceSection.swift */, @@ -746,6 +756,8 @@ isa = PBXGroup; children = ( C131984D932C971B3780C1D0 /* SettingsBackupPreferencesTests.swift */, + C131B63FBB110A4009BABB8E /* PermissionsVMTests.swift */, + C1311723BF0607366B8F236C /* PunctuationServiceTests.swift */, C13142E5127B2ED3F334E51A /* MarkdownPunctuationMappingTests.swift */, D60001012F20000000000101 /* AppKindComparisonTests.swift */, D60000512F20000000000051 /* InputSourceDeduplicationTests.swift */, @@ -933,6 +945,8 @@ 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 */, @@ -1074,6 +1088,8 @@ 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 */, diff --git a/Input Source Pro/Models/IndicatorVM.swift b/Input Source Pro/Models/IndicatorVM.swift index 8c8233a..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)) @@ -30,7 +31,10 @@ final class IndicatorVM: ObservableObject { private(set) var state: State @Published - private(set) var isMarkdownModeSafetyWarningVisible = false + private(set) var isMarkdownModeEnabled = false + + @Published + private(set) var markdownModeFailure: PunctuationService.Failure? var actionSubject = PassthroughSubject() @@ -75,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, @@ -109,36 +120,47 @@ final class IndicatorVM: ObservableObject { .store(in: cancelBag) } - private func watchMarkdownMode() { - punctuationService.onSafetyShutdown = { [weak self, weak preferencesVM] reason in - self?.logger.debug { "Markdown mode safety shutdown: \(reason)" } - self?.isMarkdownModeSafetyWarningVisible = true - preferencesVM?.update { $0.isMarkdownModeEnabled = false } - } + func setMarkdownModeEnabled(_ enabled: Bool) { + markdownModeController.setEnabled(enabled) + permissionsVM.refresh() + } - Publishers.CombineLatest( - preferencesVM.$preferences - .map(\.isMarkdownModeEnabled) - .removeDuplicates(), - applicationVM.$appKind.compactMap { $0 } + 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 } + } ) - .sink { [weak self] isMarkdownModeEnabled, appKind in + .store(in: cancelBag) + + applicationVM.$appKind + .sink { [weak self] appKind in guard let self = self else { return } + let shouldEnableAppEnglish = appKind.map { + self.preferencesVM.getAppCustomization(app: $0.getApp())?.shouldForceEnglishPunctuation == true + } ?? false + self.markdownModeController.appContextChanged(shouldEnableAppEnglish: shouldEnableAppEnglish) + } + .store(in: cancelBag) - if isMarkdownModeEnabled { - self.logger.debug { "Enabling Markdown mode globally" } - if !self.punctuationService.enable(mode: .markdown) { - self.isMarkdownModeSafetyWarningVisible = true - self.preferencesVM.update { $0.isMarkdownModeEnabled = false } - } else { - self.isMarkdownModeSafetyWarningVisible = false - } - } else if self.punctuationService.shouldEnableForApp(appKind.getApp()) { - self.logger.debug { "Enabling English punctuation for app rule" } - self.punctuationService.enable(mode: .appEnglish) - } else { - self.punctuationService.disable() - } + 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..3118b39 100644 --- a/Input Source Pro/Models/PermissionsVM.swift +++ b/Input Source Pro/Models/PermissionsVM.swift @@ -20,20 +20,41 @@ 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 + + init( + accessibilityCheck: @escaping @MainActor () -> Bool = { PermissionsVM.checkAccessibility(prompt: false) }, + inputMonitoringCheck: @escaping @MainActor () -> Bool = { PermissionsVM.checkInputMonitoring(prompt: false) } + ) { + self.accessibilityCheck = accessibilityCheck + self.inputMonitoringCheck = inputMonitoringCheck + isAccessibilityEnabled = accessibilityCheck() + isInputMonitoringEnabled = inputMonitoringCheck() watchAccessibilityChange() watchInputMonitoringChange() } + func refresh() { + let accessibilityEnabled = accessibilityCheck() + let inputMonitoringEnabled = inputMonitoringCheck() + if isAccessibilityEnabled != accessibilityEnabled { + isAccessibilityEnabled = accessibilityEnabled + } + if isInputMonitoringEnabled != inputMonitoringEnabled { + isInputMonitoringEnabled = inputMonitoringEnabled + } + } + private func watchAccessibilityChange() { guard !isAccessibilityEnabled else { return } Timer .interval(seconds: 1) - .map { _ in Self.checkAccessibility(prompt: false) } + .map { [accessibilityCheck] _ in accessibilityCheck() } .filter { $0 } .first() .assign(to: &$isAccessibilityEnabled) @@ -44,7 +65,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/Resources/en.lproj/Localizable.strings b/Input Source Pro/Resources/en.lproj/Localizable.strings index 533398d..1c9a573 100644 --- a/Input Source Pro/Resources/en.lproj/Localizable.strings +++ b/Input Source Pro/Resources/en.lproj/Localizable.strings @@ -129,9 +129,13 @@ "Force English Punctuation" = "Force English Punctuation"; "Markdown Mode" = "Markdown Mode"; "Enable Markdown Mode" = "Enable Markdown Mode"; -"Markdown Mode Description" = "While using a CJK input source, replace ·, ¥, 》, 【, and 】 with `, $, >, [, and ]. Typing 《 inserts the complete 《》 pair; other punctuation is unchanged."; -"Markdown Mode Disabled for Safety" = "Markdown Mode was disabled for safety"; -"Markdown Mode Disabled Description" = "Check Input Monitoring and Accessibility permissions, and quit other Input Source Pro instances before trying again."; +"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 b37269a..6348e54 100644 --- a/Input Source Pro/Resources/ja.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ja.lproj/Localizable.strings @@ -129,9 +129,13 @@ "Force English Punctuation" = "英語句読点を強制使用"; "Markdown Mode" = "Markdown モード"; "Enable Markdown Mode" = "Markdown モードを有効にする"; -"Markdown Mode Description" = "CJK 入力中は ·、¥、》、【、】を `、$、>、[、] に置換し、《を入力すると《》を補完します。その他の句読点は変更しません。"; -"Markdown Mode Disabled for Safety" = "安全のため Markdown モードを無効にしました"; -"Markdown Mode Disabled Description" = "入力監視とアクセシビリティの権限を確認し、他の Input Source Pro を終了してから再試行してください。"; +"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 455c09f..95a5db0 100644 --- a/Input Source Pro/Resources/ko.lproj/Localizable.strings +++ b/Input Source Pro/Resources/ko.lproj/Localizable.strings @@ -129,9 +129,13 @@ "Force English Punctuation" = "영어 구두점 강제 사용"; "Markdown Mode" = "Markdown 모드"; "Enable Markdown Mode" = "Markdown 모드 활성화"; -"Markdown Mode Description" = "CJK 입력기를 사용할 때 ·, ¥, 》, 【, 】를 `, $, >, [, ]로 바꾸고 《 입력 시 《》 쌍을 완성합니다. 다른 문장 부호는 바꾸지 않습니다."; -"Markdown Mode Disabled for Safety" = "안전을 위해 Markdown 모드를 비활성화했습니다"; -"Markdown Mode Disabled Description" = "입력 모니터링 및 손쉬운 사용 권한을 확인하고 다른 Input Source Pro 인스턴스를 종료한 후 다시 시도하세요."; +"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 368755a..fedf49b 100644 --- a/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hans.lproj/Localizable.strings @@ -131,9 +131,13 @@ "Force English Punctuation" = "强制使用英文标点符号"; "Markdown Mode" = "Markdown 模式"; "Enable Markdown Mode" = "启用 Markdown 模式"; -"Markdown Mode Description" = "使用中文输入法时,将 ·、¥、》、【、】替换为 `、$、>、[、];输入《时补全为《》,其他标点保持不变。"; -"Markdown Mode Disabled for Safety" = "Markdown 模式已为安全起见关闭"; -"Markdown Mode Disabled Description" = "请检查输入监控和辅助功能权限,并退出其他 Input Source Pro 实例后再试。"; +"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 abf997a..9cb220a 100644 --- a/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings +++ b/Input Source Pro/Resources/zh-Hant.lproj/Localizable.strings @@ -131,9 +131,13 @@ "Force English Punctuation" = "強制使用英文標點符號"; "Markdown Mode" = "Markdown 模式"; "Enable Markdown Mode" = "啟用 Markdown 模式"; -"Markdown Mode Description" = "使用中文輸入法時,將 ·、¥、》、【、】替換為 `、$、>、[、];輸入《時補全為《》,其他標點維持不變。"; -"Markdown Mode Disabled for Safety" = "Markdown 模式已基於安全考量關閉"; -"Markdown Mode Disabled Description" = "請檢查輸入監控與輔助使用權限,並結束其他 Input Source Pro 實例後再試。"; +"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 a13a953..6999598 100644 --- a/Input Source Pro/UI/Screens/GeneralSettingsView.swift +++ b/Input Source Pro/UI/Screens/GeneralSettingsView.swift @@ -86,57 +86,24 @@ struct GeneralSettingsView: View { .padding() } - SettingsSection(title: "Markdown Mode") { - VStack(alignment: .leading, spacing: 8) { - HStack { - Toggle("", isOn: $preferencesVM.preferences.isMarkdownModeEnabled) - - VStack(alignment: .leading, spacing: 2) { - Text("Enable Markdown Mode".i18n()) - Text("Markdown Mode Description".i18n()) - .font(.subheadline) - .foregroundColor(.secondary) - } - - Spacer() - } - - if preferencesVM.preferences.isMarkdownModeEnabled && - (!PermissionsVM.checkInputMonitoring(prompt: false) || - !PermissionsVM.checkAccessibility(prompt: false)) { - HStack { - Text("This feature requires input monitoring permission to work".i18n()) - .font(.subheadline) - - Spacer() - - Button("Open Permission Settings".i18n()) { - NSWorkspace.shared.openInputMonitoringPreferences() - } - - Button("Open Accessibility Settings".i18n()) { - NSWorkspace.shared.openAccessibilityPreferences() - } - } - .padding(8) - .background(NSColor.background1.color) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - - if indicatorVM.isMarkdownModeSafetyWarningVisible { - VStack(alignment: .leading, spacing: 4) { - Text("Markdown Mode Disabled for Safety".i18n()) - .fontWeight(.medium) - Text("Markdown Mode Disabled Description".i18n()) - .font(.subheadline) - } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.orange.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + MarkdownModeSettingsView( + isEnabled: Binding( + get: { indicatorVM.isMarkdownModeEnabled }, + set: { + indicatorVM.setMarkdownModeEnabled($0) + permissionsVM.refresh() } - } - .padding() + ), + isInputMonitoringEnabled: permissionsVM.isInputMonitoringEnabled, + isAccessibilityEnabled: permissionsVM.isAccessibilityEnabled, + failure: indicatorVM.markdownModeFailure + ) + .onAppear { permissionsVM.refresh() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + permissionsVM.refresh() + } + .onChange(of: indicatorVM.markdownModeFailure) { _ in + permissionsVM.refresh() } Group { diff --git a/Input Source Pro/Utilities/PunctuationService.swift b/Input Source Pro/Utilities/PunctuationService.swift index d319e09..a4e1039 100644 --- a/Input Source Pro/Utilities/PunctuationService.swift +++ b/Input Source Pro/Utilities/PunctuationService.swift @@ -1,31 +1,70 @@ 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 let generatedEventMarker: Int64 = 0x4953_504D_44 private let maximumHandlerDuration: TimeInterval = 0.05 - - private var isEnabled = false - private var eventTap: CFMachPort? - private var mode = Mode.appEnglish - private weak var preferencesVM: PreferencesVM? - var onSafetyShutdown: ((String) -> Void)? - - // Performance optimization: Cache input source state to reduce system calls + 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 inputSourceCacheTimeout: TimeInterval = 0.5 + private let appEnglishPunctuationMap: [UInt16: (normal: String?, shifted: String?)] = [ UInt16(kVK_ANSI_Grave): ("`", "~"), UInt16(kVK_ANSI_4): (nil, "$"), @@ -40,215 +79,135 @@ class PunctuationService: ObservableObject { 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 } - - @discardableResult - func enable(mode: Mode) -> Bool { - if mode == .markdown { - guard PermissionsVM.checkInputMonitoring(prompt: false), - PermissionsVM.checkAccessibility(prompt: false) - else { - logger.debug { "Refusing to enable Markdown mode without required permissions" } - return false - } - guard !hasAnotherInputSourceProInstance else { - logger.debug { "Refusing to enable Markdown mode while another Input Source Pro instance is running" } - return false + @discardableResult + func enable(mode: Mode) -> Result { + if mode == .markdown, let failure = markdownActivationFailure() { + if activeMode == .markdown { + disable() } + return .failure(failure) } - self.mode = mode - guard !isEnabled else { return true } - - 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 + if eventTap != nil { + activeMode = mode + return .success(()) } - 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 { - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - scheduleSafetyShutdown(reason: "The keyboard event tap was disabled by macOS") - return Unmanaged.passUnretained(event) - } - // Handle event tap being disabled (can happen if permissions are revoked) - guard isEnabled else { - 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 type == .keyDown else { - 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 event.getIntegerValueField(.eventSourceUserData) != generatedEventMarker else { + func handleKeyEvent(type: CGEventType, event: CGEvent) -> Unmanaged { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + shutdownForSafety(.eventTapDisabled) return Unmanaged.passUnretained(event) } - let handlerStartTime = CACurrentMediaTime() + 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 { - let duration = CACurrentMediaTime() - handlerStartTime - if duration > maximumHandlerDuration { - scheduleSafetyShutdown(reason: "Keyboard event handling exceeded the safety limit") + if mode == .markdown, dependencies.now() - handlerStartTime > maximumHandlerDuration { + shutdownForSafety(.handlerTimedOut) } } - - let keyCode = event.getIntegerValueField(.keyboardEventKeycode) - if mode == .markdown { - guard let keyCode = CGKeyCode(exactly: keyCode), - let replacement = MarkdownPunctuationMapping.replacement(for: keyCode, flags: event.flags), - let newEvent = createEnglishPunctuationEvent(originalEvent: event, replacement: replacement) - else { return Unmanaged.passUnretained(event) } - return Unmanaged.passRetained(newEvent) - } - - // Check if this is a punctuation key we want to intercept - guard let mapping = appEnglishPunctuationMap[UInt16(keyCode)] else { - // Not a punctuation key we're interested in - return Unmanaged.passUnretained(event) - } - - guard shouldReplacePunctuation(for: event.flags) else { - // Preserve shortcuts and system key combinations that use punctuation keys. - return Unmanaged.passUnretained(event) + let replacement: String? + switch mode { + case .markdown: + replacement = MarkdownPunctuationMapping.replacement( + for: keyCode, + flags: event.flags, + 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 englishReplacement = event.flags.contains(.maskShift) ? mapping.shifted : mapping.normal else { - 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)" } - - // 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) - } + 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)) @@ -282,39 +241,9 @@ class PunctuationService: ObservableObject { return flags.intersection(shortcutModifiers).isEmpty } - private var hasAnotherInputSourceProInstance: Bool { - let knownBundleIdentifiers = [ - "com.runjuu.Input-Source-Pro", - "com.runjuu.Input-Source-Pro.Markdown" - ] - - return knownBundleIdentifiers - .flatMap { NSRunningApplication.runningApplications(withBundleIdentifier: $0) } - .contains { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier && !$0.isTerminated } - } - - private func scheduleSafetyShutdown(reason: String) { - guard isEnabled else { return } - isEnabled = false - logger.debug { "Disabling punctuation service for safety: \(reason)" } - - Task { @MainActor [weak self] in - guard let self = self else { return } - self.stopMonitoring() - self.onSafetyShutdown?(reason) - } - } - - 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, @@ -330,22 +259,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: \(appEnglishPunctuationMap.map { "\($0.key)→'\($0.value.normal ?? "pass")'/'\($0.value.shifted ?? "pass")'" }.joined(separator: ", ")) - """ } - } } diff --git a/Tests/PermissionsVMTests.swift b/Tests/PermissionsVMTests.swift new file mode 100644 index 0000000..90198f0 --- /dev/null +++ b/Tests/PermissionsVMTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import Input_Source_Pro + +@MainActor +final class PermissionsVMTests: XCTestCase { + 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..4c4c235 --- /dev/null +++ b/Tests/PunctuationServiceTests.swift @@ -0,0 +1,318 @@ +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 + + @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 = { nil } + dependencies.now = { + defer { self.time += self.timeStep } + return self.time + } + return PunctuationService(dependencies: dependencies) + } + } + + 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 + } +} From e24610b3aa105a8d51b351650a7e4e1a912f2bd8 Mon Sep 17 00:00:00 2001 From: runjuu Date: Tue, 8 Sep 2026 16:29:49 -0700 Subject: [PATCH 5/7] docs: clarify Markdown mappings and preserve per-app guidance --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1ba76f3..83be22a 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,23 @@ - Customizable and designed to stay out of your way. ### ✍️ App-Aware Punctuation Modes -This customized build adds an opt-in **Markdown Mode** that keeps selected punctuation consistent across all apps. -- Automatically replaces `·` with `` ` ``, `¥` with `$`, `》` with `>`, and `【】` with `[]`. Since the right book-title mark is replaced, typing `《` inserts the complete `《》` pair. Chinese prose punctuation, including localized quotation marks, remains unchanged. -- Turn on **Markdown Mode** in General settings, then optionally enable **Launch at Login** to apply it automatically after signing in. +Keep punctuation consistent across different languages by enabling **Force English Punctuation** for specific apps. +- 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. From d5acd925a319703a43e7bf9af4911f04a5d7210f Mon Sep 17 00:00:00 2001 From: runjuu Date: Tue, 8 Sep 2026 16:44:08 -0700 Subject: [PATCH 6/7] fix(markdown): respect keyboard hardware when mapping brackets --- .../MarkdownPunctuationMapping.swift | 23 +++- .../Utilities/PunctuationService.swift | 1 + Tests/MarkdownPunctuationMappingTests.swift | 109 ++++++++++++++---- Tests/PunctuationServiceTests.swift | 34 +++++- 4 files changed, 141 insertions(+), 26 deletions(-) diff --git a/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift b/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift index 59434c8..812290f 100644 --- a/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift +++ b/Input Source Pro/Utilities/MarkdownPunctuationMapping.swift @@ -19,17 +19,36 @@ enum MarkdownPunctuationMapping { CGKeyCode(kVK_ANSI_Grave): ("`", nil), CGKeyCode(kVK_ANSI_4): (nil, "$"), CGKeyCode(kVK_ANSI_Comma): (nil, "《》"), - CGKeyCode(kVK_ANSI_Period): (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 mapping = replacements[keyCode] else { return nil } + 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 diff --git a/Input Source Pro/Utilities/PunctuationService.swift b/Input Source Pro/Utilities/PunctuationService.swift index a4e1039..7e2acb8 100644 --- a/Input Source Pro/Utilities/PunctuationService.swift +++ b/Input Source Pro/Utilities/PunctuationService.swift @@ -184,6 +184,7 @@ final class PunctuationService: ObservableObject { replacement = MarkdownPunctuationMapping.replacement( for: keyCode, flags: event.flags, + keyboardType: event.getIntegerValueField(.keyboardEventKeyboardType), contextProvider: dependencies.markdownInputContext ) case .appEnglish: diff --git a/Tests/MarkdownPunctuationMappingTests.swift b/Tests/MarkdownPunctuationMappingTests.swift index 0776dbf..79bcc4f 100644 --- a/Tests/MarkdownPunctuationMappingTests.swift +++ b/Tests/MarkdownPunctuationMappingTests.swift @@ -20,7 +20,7 @@ final class MarkdownPunctuationMappingTests: XCTestCase { (CGKeyCode(kVK_ANSI_RightBracket), [], "]") ] - func testMapsOnlySpecifiedKeysOnSupportedPinyinLayouts() { + func testANSIAndISOMapOnlySpecifiedKeysOnSupportedPinyinLayouts() { for layoutID in ["com.apple.keylayout.PinyinKeyboard", "com.apple.keylayout.ABC", "com.apple.keylayout.US"] { let context = InputContext( sourceID: pinyin.sourceID, @@ -28,12 +28,62 @@ final class MarkdownPunctuationMappingTests: XCTestCase { 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 { - XCTAssertEqual( - MarkdownPunctuationMapping.replacement(for: key.keyCode, flags: key.flags) { context }, - key.replacement, - "Layout: \(layoutID), key: \(key.keyCode)" - ) + XCTAssertNil(MarkdownPunctuationMapping.replacement( + for: key.keyCode, flags: key.flags, keyboardType: keyboardType + ) { + XCTFail("Unknown keyboard must not query the input source") + return self.pinyin + }) } } } @@ -42,7 +92,7 @@ final class MarkdownPunctuationMappingTests: XCTestCase { 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) { context }) + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: key.keyCode, flags: key.flags, keyboardType: 40) { context }) } } } @@ -51,7 +101,7 @@ final class MarkdownPunctuationMappingTests: XCTestCase { 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) { context }) + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: key.keyCode, flags: key.flags, keyboardType: 40) { context }) } } } @@ -69,7 +119,7 @@ final class MarkdownPunctuationMappingTests: XCTestCase { ] for context in contexts { - XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift) { context }) + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { context }) } } @@ -80,7 +130,7 @@ final class MarkdownPunctuationMappingTests: XCTestCase { inputModeID: pinyin.inputModeID, keyboardLayoutID: layoutID ) - XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift) { context }) + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { context }) } } @@ -94,14 +144,14 @@ final class MarkdownPunctuationMappingTests: XCTestCase { } XCTAssertEqual( - MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, contextProvider: provider), + 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, contextProvider: provider)) + 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, contextProvider: provider), + MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40, contextProvider: provider), "《》" ) XCTAssertEqual(contextReads, 3) @@ -109,10 +159,10 @@ final class MarkdownPunctuationMappingTests: XCTestCase { func testUnderlyingLayoutChangesTakeEffectOnTheNextKey() { var context = pinyin - XCTAssertEqual(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift) { context }, "《》") + 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) { context }) + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { context }) } func testOtherPunctuationAndShiftVariantsPassThroughWithoutReadingTheInputSource() { @@ -130,7 +180,7 @@ final class MarkdownPunctuationMappingTests: XCTestCase { ] for (keyCode, flags) in keys { - XCTAssertNil(MarkdownPunctuationMapping.replacement(for: keyCode, flags: flags) { + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: keyCode, flags: flags, keyboardType: 40) { XCTFail("Unmapped key must not query the input source") return self.pinyin }) @@ -139,17 +189,30 @@ final class MarkdownPunctuationMappingTests: XCTestCase { func testShortcutsAndCapsLockPassThroughWithoutReadingTheInputSource() { let modifiers: [CGEventFlags] = [.maskCommand, .maskControl, .maskAlternate, .maskSecondaryFn, .maskAlphaShift] - for modifier in modifiers { - for key in mappedKeys { - XCTAssertNil(MarkdownPunctuationMapping.replacement(for: key.keyCode, flags: key.flags.union(modifier)) { - XCTFail("Modified key must not query the input source") - return self.pinyin - }) + 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) { nil }) + XCTAssertNil(MarkdownPunctuationMapping.replacement(for: CGKeyCode(kVK_ANSI_Comma), flags: .maskShift, keyboardType: 40) { nil }) } } diff --git a/Tests/PunctuationServiceTests.swift b/Tests/PunctuationServiceTests.swift index 4c4c235..8367e42 100644 --- a/Tests/PunctuationServiceTests.swift +++ b/Tests/PunctuationServiceTests.swift @@ -15,6 +15,7 @@ final class PunctuationServiceTests: XCTestCase { var invalidatedTaps = 0 var time: TimeInterval = 0 var timeStep: TimeInterval = 0 + var inputContext: MarkdownPunctuationMapping.InputContext? @MainActor func makeService() -> PunctuationService { @@ -27,7 +28,7 @@ final class PunctuationServiceTests: XCTestCase { self.createdTaps += 1 return PunctuationService.EventTap { self.invalidatedTaps += 1 } } - dependencies.markdownInputContext = { nil } + dependencies.markdownInputContext = { self.inputContext } dependencies.now = { defer { self.time += self.timeStep } return self.time @@ -36,6 +37,37 @@ final class PunctuationServiceTests: XCTestCase { } } + 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() From 918b3aa1b0a03a1e148353b3d0cdc8d298d3e395 Mon Sep 17 00:00:00 2001 From: runjuu Date: Tue, 8 Sep 2026 16:44:20 -0700 Subject: [PATCH 7/7] fix(permissions): refresh grants on application activation --- Input Source Pro/Models/PermissionsVM.swift | 7 ++- .../UI/Screens/GeneralSettingsView.swift | 3 - Tests/PermissionsVMTests.swift | 58 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Input Source Pro/Models/PermissionsVM.swift b/Input Source Pro/Models/PermissionsVM.swift index 3118b39..209c59e 100644 --- a/Input Source Pro/Models/PermissionsVM.swift +++ b/Input Source Pro/Models/PermissionsVM.swift @@ -25,10 +25,12 @@ final class PermissionsVM: ObservableObject { 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) } + inputMonitoringCheck: @escaping @MainActor () -> Bool = { PermissionsVM.checkInputMonitoring(prompt: false) }, + notificationCenter: NotificationCenter = .default ) { self.accessibilityCheck = accessibilityCheck self.inputMonitoringCheck = inputMonitoringCheck @@ -36,6 +38,9 @@ final class PermissionsVM: ObservableObject { isInputMonitoringEnabled = inputMonitoringCheck() watchAccessibilityChange() watchInputMonitoringChange() + activationSubscription = notificationCenter.publisher(for: NSApplication.didBecomeActiveNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.refresh() } } func refresh() { diff --git a/Input Source Pro/UI/Screens/GeneralSettingsView.swift b/Input Source Pro/UI/Screens/GeneralSettingsView.swift index 6999598..9fafcb4 100644 --- a/Input Source Pro/UI/Screens/GeneralSettingsView.swift +++ b/Input Source Pro/UI/Screens/GeneralSettingsView.swift @@ -99,9 +99,6 @@ struct GeneralSettingsView: View { failure: indicatorVM.markdownModeFailure ) .onAppear { permissionsVM.refresh() } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - permissionsVM.refresh() - } .onChange(of: indicatorVM.markdownModeFailure) { _ in permissionsVM.refresh() } diff --git a/Tests/PermissionsVMTests.swift b/Tests/PermissionsVMTests.swift index 90198f0..22c205d 100644 --- a/Tests/PermissionsVMTests.swift +++ b/Tests/PermissionsVMTests.swift @@ -1,8 +1,66 @@ +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