From 5a0fd9f0e1916adec9ef47453a6e3370196720f0 Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Wed, 2 Sep 2026 13:26:32 +0900 Subject: [PATCH 01/12] Fix English first-letter caps and ABC input-source revival. Stop treating empty cursor context as a sentence start after a Korean-to-English switch, and stop requesting a disabled ABC layout on every focus change. Co-authored-by: Cursor --- CHANGELOG.md | 10 ++ Sources/PriType/main.swift | 18 ++- .../PriTypeCore/ClientContextDetector.swift | 22 ++-- .../PriTypeCore/ConfigurationManager.swift | 41 ++++-- Sources/PriTypeCore/HangulComposer.swift | 15 ++- Sources/PriTypeCore/InputSourceManager.swift | 48 +++++++ .../PriTypeCore/PriTypeInputController.swift | 119 ++++++------------ .../PriTypeCore/RightCommandSuppressor.swift | 12 +- .../SettingsWindowController.swift | 24 +--- .../PriTypeCore/TextConvenienceHandler.swift | 7 +- .../PriTypeCoreTests/ClientContextTests.swift | 18 ++- .../HangulComposerTests.swift | 15 ++- .../InputSourceManagerTests.swift | 21 ++++ .../TextConvenienceHandlerTests.swift | 5 +- 14 files changed, 231 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3896fc61..7ac9f24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 수정 +- 한→영 전환 직후 첫 글자가 대문자로 들어가던 문제를 고쳤습니다. 영어 자동 대문자 폴백이 커서 앞 텍스트를 빈 문자열로 보면 문장 시작으로 취급했는데, Chromium/Electron은 전환 직후 그 조회가 자주 비어 있었습니다. 이제 빈 컨텍스트는 패스스루하고, `.!?` 뒤 공백처럼 문장 끝이 확인될 때만 대문자로 넣습니다. +- 설정에서 ABC를 꺼도 다음 포커스/전환에서 다시 살아나던 문제를 고쳤습니다. 비활성 ABC 레이아웃을 `overrideKeyboardWithKeyboardNamed`로 요청하던 경로를 제거하고, 영어 모드에서만, 그리고 ABC/US가 실제로 켜져 있을 때만 오버라이드합니다. +- 커스텀 한/영 전환키에서 `selectInputMode:`를 호출하지 않습니다. Latin-only 필드에서 TIS가 실제 ABC 소스로 넘어가던 경로입니다. +- 다른 앱 비밀번호 필드의 전역 Secure Input 때문에 현재 앱 한글이 영문 자판으로 나가던 문제를 줄였습니다. 일반 텍스트 필드는 조합을 유지합니다. +- CGEventTap이 반복 실패한 뒤 IOKit으로 넘길 때 기존 탭을 끄지 않아 전환키가 두 번 먹을 수 있던 문제를 고쳤습니다. +- 한/영 전환 시 한자 후보창을 닫습니다. +- 시스템 자동 대문자/스마트 치환 설정을 프로세스 시작 때 한 번만 캐시하던 것을, 읽을 때마다 다시 반영하도록 바꿨습니다. +- 시작 시 손쉬운 사용 권한 폴링이 권한을 안 주면 끝나지 않던 타이머에 2분 상한을 넣었습니다. + ### 조사 (한글 조합 밑줄 — macOS 26에서는 marked text로 제거 불가) - 조합 밑줄을 모든 앱에서 없애기 위해 marked text 속성을 엔진별로 조정했으나(`PreeditUnderline`: Blink는 `underlineStyle 1 + alpha 1/255`, 그 외는 `underlineStyle 0 + NSColor.clear`), **macOS 26에서는 효과가 없음을 실측으로 확인했습니다**. NSTextInputClient 프로브로 실제 IMK 전송 경로를 측정한 결과, IME가 보내는 모든 속성 조합 — underline 0+clear, alpha 1/255, `NSMarkedClauseSegment` 1~9(kNoHilite 포함 전체 TSM hilite 카테고리), 심지어 속성 없는 문자열까지 13종 전부 — 이 앱에는 동일한 `NSUnderline=2 + 액센트 블루`로 재생성되어 도착합니다. 수신 측 프레임워크가 IME 스타일을 폐기하고 시스템 표준 스타일을 합성하므로, **macOS 26에서는 어떤 IME도 marked text 밑줄을 숨길 수 없습니다**(애플 한글 IME도 동일한 밑줄). 엔진별 속성 튜닝은 속성이 통과되는 구버전 macOS에서만 유효하며 코드에 유지합니다(오분류·부작용 없음). 밑줄 없는 입력은 marked text를 쓰지 않는 직접 삽입 모드(`com.pritype.experimentalDirectInsertion`)로 제공됩니다. 측정 과정은 `PreeditUnderline` 주석에 기록했습니다. diff --git a/Sources/PriType/main.swift b/Sources/PriType/main.swift index 1c5f53a1..19853fe7 100644 --- a/Sources/PriType/main.swift +++ b/Sources/PriType/main.swift @@ -62,12 +62,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, @unchecked Sendable { DebugLogger.log("Requesting Accessibility permission...") IOKitManager.requestAccessibilityPermission() - // Poll until user grants permission from the system popup + // Poll until user grants permission, with a 2-minute cap so a + // never-granted prompt cannot leave a timer running forever. + let pollDeadline = Date().addingTimeInterval(120) Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in - guard AXIsProcessTrusted() else { return } - timer.invalidate() - DebugLogger.log("Accessibility granted via system popup — starting key monitoring") - self.setupIOKit() + if AXIsProcessTrusted() { + timer.invalidate() + DebugLogger.log("Accessibility granted via system popup — starting key monitoring") + self.setupIOKit() + return + } + if Date() >= pollDeadline { + timer.invalidate() + DebugLogger.log("Accessibility not granted within 2 minutes; stop polling") + } } return } diff --git a/Sources/PriTypeCore/ClientContextDetector.swift b/Sources/PriTypeCore/ClientContextDetector.swift index a2020e78..c4d1f780 100644 --- a/Sources/PriTypeCore/ClientContextDetector.swift +++ b/Sources/PriTypeCore/ClientContextDetector.swift @@ -28,23 +28,23 @@ struct SecureInputPolicy: Sendable { return true } - guard signals.hasInvalidSelection || signals.hasGlobalSecureInput else { - return false - } - - if signals.hasInvalidSelection && !signals.hasTextInputCapability { - return true - } - - if !signals.hasMarkedTextSupport || !signals.hasTextInputCapability { + // Password-like fields: no marked-text capability, or a missing selection + // *and* no text-input attributes. Invalid selection alone is not enough — + // Chromium reports NSNotFound in ordinary web fields too, and treating + // that as secure input made Korean type as ABC qwerty. + let looksLikeSecureField = !signals.hasTextInputCapability || !signals.hasMarkedTextSupport + if signals.hasInvalidSelection && looksLikeSecureField { return true } - if signals.hasInvalidSelection { + // Global Secure Event Input is process-wide. A password field in another + // app must not disable Hangul in this client if this client looks like a + // normal text field. + if signals.hasGlobalSecureInput && (looksLikeSecureField || signals.hasInvalidSelection) { return true } - return signals.hasGlobalSecureInput + return false } } diff --git a/Sources/PriTypeCore/ConfigurationManager.swift b/Sources/PriTypeCore/ConfigurationManager.swift index 23183025..c799a5c6 100644 --- a/Sources/PriTypeCore/ConfigurationManager.swift +++ b/Sources/PriTypeCore/ConfigurationManager.swift @@ -515,26 +515,53 @@ public final class ConfigurationManager: ConfigurationProviding, @unchecked Send /// Mirrors macOS "Add period with double-space" for PriType Korean input. public var doubleSpacePeriodEnabled: Bool { - return systemTextFeatureLock.withLock { cachedDoubleSpacePeriodEnabled } + return systemTextFeatureLock.withLock { + let value = Self.readSystemTextFeature( + key: SystemTextInputKeys.automaticPeriodSubstitution, + defaultValue: true + ) + cachedDoubleSpacePeriodEnabled = value + return value + } } /// Mirrors macOS "Capitalize words automatically". /// - /// PriType reads and caches this setting for observability, but does not - /// apply it in Korean composition. English mode passes through to macOS, so - /// the system handles capitalization without PriType tracking text context. + /// Re-read on each access so toggling the system setting takes effect without + /// restarting the input method. public var autoCapitalizationEnabled: Bool { - return systemTextFeatureLock.withLock { cachedAutoCapitalizationEnabled } + return systemTextFeatureLock.withLock { + let value = Self.readSystemTextFeature( + key: SystemTextInputKeys.automaticCapitalization, + defaultValue: true + ) + cachedAutoCapitalizationEnabled = value + return value + } } /// Mirrors macOS "Use smart quotes". public var smartQuoteSubstitutionEnabled: Bool { - return systemTextFeatureLock.withLock { cachedSmartQuoteSubstitutionEnabled } + return systemTextFeatureLock.withLock { + let value = Self.readSystemTextFeature( + key: SystemTextInputKeys.automaticQuoteSubstitution, + defaultValue: true + ) + cachedSmartQuoteSubstitutionEnabled = value + return value + } } /// Mirrors macOS "Use smart dashes". public var smartDashSubstitutionEnabled: Bool { - return systemTextFeatureLock.withLock { cachedSmartDashSubstitutionEnabled } + return systemTextFeatureLock.withLock { + let value = Self.readSystemTextFeature( + key: SystemTextInputKeys.automaticDashSubstitution, + defaultValue: true + ) + cachedSmartDashSubstitutionEnabled = value + return value + } } private static func readSystemTextFeature(key: String, defaultValue: Bool) -> Bool { diff --git a/Sources/PriTypeCore/HangulComposer.swift b/Sources/PriTypeCore/HangulComposer.swift index a011664e..ac0e6771 100644 --- a/Sources/PriTypeCore/HangulComposer.swift +++ b/Sources/PriTypeCore/HangulComposer.swift @@ -167,9 +167,9 @@ public class HangulComposer: @unchecked Sendable { /// - Important: `inputMode` is the single source of truth for the Korean/ /// English state. The only sanctioned writers are /// `PriTypeInputController.performPriTypeModeTransition` (custom toggle) and - /// `PriTypeInputController.setValue(_:forTag:)` (macOS re-selecting the - /// PriType source, which always lands back in `.korean`). No other path — - /// including `activateServer` focus changes — may mutate the mode. + /// `PriTypeInputController.setValue(_:forTag:)` (macOS selecting a PriType + /// mode, including the English child mode). No other path — including + /// `activateServer` focus changes — may mutate the mode. public func setInputMode(_ mode: InputMode) { guard inputMode != mode else { return @@ -177,6 +177,12 @@ public class HangulComposer: @unchecked Sendable { DebugLogger.log("setInputMode called externally: \(mode)") + hanjaMode = false + hanjaKey = "" + if Thread.isMainThread, HanjaCandidateWindow.shared.isVisible { + HanjaCandidateWindow.shared.dismiss() + } + if let delegate = lastDelegate, !context.isEmpty() { commitComposition(delegate: delegate) DebugLogger.log("Composition committed before explicit mode switch") @@ -375,7 +381,8 @@ public class HangulComposer: @unchecked Sendable { // installs via `overrideKeyboardWithKeyboardNamed(ABC/US)`. // - Some macOS text conveniences do not fire for this internal English // mode in every host, so PriType supplies a narrow fallback for only - // the transformed cases (double-space period and auto-capitalization). + // the transformed cases (double-space period, and auto-capitalization + // after an explicit sentence end — never for empty/unknown cursor context). // Keeping this path mostly pass-through avoids the classic buffer-vs- // cursor desync that a PriType-side English buffer invites. if inputMode == .english { diff --git a/Sources/PriTypeCore/InputSourceManager.swift b/Sources/PriTypeCore/InputSourceManager.swift index cab6c4e7..21a05391 100644 --- a/Sources/PriTypeCore/InputSourceManager.swift +++ b/Sources/PriTypeCore/InputSourceManager.swift @@ -77,6 +77,54 @@ public final class InputSourceManager: @unchecked Sendable { return sources.contains { $0.id.contains("US") || $0.name == "U.S." } } + /// Enabled ABC or US keyboard layout ID, or nil if the user has neither on. + /// + /// Used before `overrideKeyboardWithKeyboardNamed`. Requesting a *disabled* + /// layout is what re-inserts ABC into `AppleEnabledInputSources` after the + /// user turned it off. + public func enabledRomanKeyboardLayoutID() -> String? { + let enabledIDs = Set(getEnabledKeyboardInputSources().map(\.id)) + let candidates = ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] + return candidates.first { enabledIDs.contains($0) } + } + + /// Remove the default ABC keyboard layout from the enabled input-source list. + /// + /// Matches KeyboardLayout ID 252 and the name "ABC". Does not touch U.S. + /// Already-absent is treated as success. Does not enable or select sources. + @discardableResult + public func disableDefaultABCInputSource() -> Bool { + guard let defaults = UserDefaults(suiteName: "com.apple.HIToolbox"), + var sources = defaults.array(forKey: "AppleEnabledInputSources") as? [[String: Any]] else { + return false + } + + let originalCount = sources.count + sources.removeAll { Self.isDefaultABCSource($0) } + guard sources.count < originalCount else { + return true + } + + defaults.set(sources, forKey: "AppleEnabledInputSources") + CFPreferencesAppSynchronize("com.apple.HIToolbox" as CFString) + DebugLogger.log("InputSourceManager: disabled default ABC input source") + return true + } + + internal static func isDefaultABCSource(_ source: [String: Any]) -> Bool { + if (source["KeyboardLayout ID"] as? Int) == abcKeyboardLayoutID { + return true + } + if (source["KeyboardLayout Name"] as? String) == "ABC" { + return true + } + if let bundleID = source["Bundle ID"] as? String, + bundleID.contains("keylayout.ABC") { + return true + } + return false + } + /// Remove stale legacy entries without enabling or selecting input sources. /// /// This intentionally does not enable PriType itself. Calling diff --git a/Sources/PriTypeCore/PriTypeInputController.swift b/Sources/PriTypeCore/PriTypeInputController.swift index f0a9a2ae..8ab6bcb5 100644 --- a/Sources/PriTypeCore/PriTypeInputController.swift +++ b/Sources/PriTypeCore/PriTypeInputController.swift @@ -28,11 +28,6 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { // macOS Caps Lock / input-source switching moves between these two modes. private static let priTypeInputSourceID = "com.pritype.inputmethod.v2" // Korean mode (== bundle id) private static let priTypeEnglishInputModeID = "com.pritype.inputmethod.v2.english" - private static let romanKeyboardLayoutID = resolveRomanKeyboardLayoutID() - private static let romanKeyboardLayoutCandidates = [ - "com.apple.keylayout.ABC", - "com.apple.keylayout.US" - ] // MARK: - Shared State // @@ -112,7 +107,6 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { session?.disarmFocusLossFinalizer() session = newSession newSession.armFocusLossFinalizer() - syncRomanKeyboardLayout(for: client) return newSession } @@ -139,7 +133,17 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { // MARK: - Keyboard Layout (English pass-through support) + /// Install the ABC/US keyboard layout for English pass-through, but only + /// when that layout is already enabled. Requesting a disabled ABC layout + /// is what makes "ABC 끄기" appear to succeed and then reverse on the next + /// keystroke. private func syncRomanKeyboardLayout(for client: IMKTextInput, force: Bool = false) { + guard composer.inputMode == .english else { return } + guard let layoutID = InputSourceManager.shared.enabledRomanKeyboardLayoutID() else { + DebugLogger.log("PriTypeInputController: skip keyboard override; ABC/US is not enabled") + return + } + let clientID = ObjectIdentifier(client as AnyObject) let now = CFAbsoluteTimeGetCurrent() guard force || lastKeyboardOverrideClientID != clientID || now - lastKeyboardOverrideTime > 0.5 else { @@ -153,29 +157,10 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { return } - _ = object.perform(selector, with: Self.romanKeyboardLayoutID) + _ = object.perform(selector, with: layoutID) lastKeyboardOverrideClientID = clientID lastKeyboardOverrideTime = now - DebugLogger.log("PriTypeInputController: override keyboard layout -> \(Self.romanKeyboardLayoutID)") - } - - private static func resolveRomanKeyboardLayoutID() -> String { - let filter: [String: Any] = [ - kTISPropertyInputSourceCategory as String: kTISCategoryKeyboardInputSource as String - ] - - guard let sourceList = TISCreateInputSourceList(filter as CFDictionary, true)?.takeRetainedValue() as? [TISInputSource] else { - return romanKeyboardLayoutCandidates[0] - } - - let availableIDs = Set(sourceList.compactMap { source -> String? in - guard let idPointer = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { - return nil - } - return Unmanaged.fromOpaque(idPointer).takeUnretainedValue() as String - }) - - return romanKeyboardLayoutCandidates.first { availableIDs.contains($0) } ?? romanKeyboardLayoutCandidates[0] + DebugLogger.log("PriTypeInputController: override keyboard layout -> \(layoutID)") } // MARK: - Mode Transitions (한/영) @@ -191,27 +176,15 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { session.finalize(reason: .modeTransition) composer.clearLocalBuffer() - syncRomanKeyboardLayout(for: session.client, force: true) composer.setInputMode(nextMode) - syncSelectedInputModeForMenuBar(client: session.client, mode: nextMode) - } - - /// Best-effort: tell macOS which PriType mode is active so the menu-bar input - /// source indicator (and Caps Lock's notion of the current mode) matches a - /// custom-key toggle. Cosmetic + consistency only — `composer.inputMode` is - /// already the authoritative composition state, so even if this is delayed or - /// unsupported, typing is unaffected (no first-key race). Without it, a - /// custom-key toggle and macOS's selected mode could drift apart. - private func syncSelectedInputModeForMenuBar(client: IMKTextInput, mode: InputMode) { - let modeID = mode == .english ? Self.priTypeEnglishInputModeID : Self.priTypeInputSourceID - let selector = NSSelectorFromString("selectInputMode:") - let object = client as AnyObject - guard object.responds(to: selector) else { - DebugLogger.log("PriTypeInputController: client does not support selectInputMode:") - return + // Do not call selectInputMode: here. That IMK call is cosmetic for the + // menu bar, but in Latin-only fields TIS often reacts by selecting the + // real ABC source and dropping the PriType session. + // Do not overrideKeyboard on a stale session (deactivateServer already + // ran): the previous client may no longer be the focused field. + if !session.contextNeedsRefresh, nextMode == .english { + syncRomanKeyboardLayout(for: session.client, force: true) } - _ = object.perform(selector, with: modeID) - DebugLogger.log("PriTypeInputController: selectInputMode -> \(modeID)") } // MARK: - IMK Lifecycle @@ -222,12 +195,11 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { assert(Thread.isMainThread, "IMK activateServer must run on main thread") #endif super.activateServer(sender) - // NOTE: Focus changes never reset `composer.inputMode`. The Korean/English - // state is owned solely by the toggle path and the `setValue` ingress, so - // switching apps preserves whatever mode the user last chose. if let client = sender as? IMKTextInput { - syncRomanKeyboardLayout(for: client, force: true) - + // NOTE: Focus changes never reset `composer.inputMode`. The Korean/English + // state is owned solely by the toggle path and the `setValue` ingress, so + // switching apps preserves whatever mode the user last chose. + // // PERFORMANCE: Analyze context ONCE per activation (lightweight — no // client IPC) and let `ensureSession` upgrade it lazily. This avoids // heavy IPC calls (validAttributes, coordinates) on every focus change. @@ -239,6 +211,7 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { session?.disarmFocusLossFinalizer() session = newSession newSession.armFocusLossFinalizer() + syncRomanKeyboardLayout(for: client, force: true) DebugLogger.log("Activated for client: \(newSession.context.bundleId) (Lightweight Context)") } else { // Fallback if sender is not IMKTextInput (rare). Keep the old session's @@ -329,10 +302,6 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { return } - if let client = sender as? IMKTextInput { - syncRomanKeyboardLayout(for: client, force: true) - } - if composer.inputMode != targetMode { DebugLogger.log("PriTypeInputController: macOS selected PriType \(targetMode) mode") // System-driven mode switches end composition through the same @@ -340,6 +309,10 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { finalizeActiveComposition(sender: sender, reason: .systemModeSwitch) composer.setInputMode(targetMode) } + + if let client = sender as? IMKTextInput { + syncRomanKeyboardLayout(for: client, force: true) + } return } @@ -399,33 +372,19 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { } private func shouldPassThroughSecureInput(client: IMKTextInput, context: ClientContext) -> Bool { - let bundleId = context.bundleId - - if SecureInputPolicy.isSystemSecureClient(bundleId) { - DebugLogger.log("Secure Input: System secure client (\(bundleId)), passing through") - return true - } - - let hasGlobalSecureInput = IsSecureEventInputEnabled() - - if hasGlobalSecureInput { - DebugLogger.log("Secure Input: global secure input active in '\(bundleId)', passing through") - return true - } - - guard !context.hasTextInputCapability else { - return false - } - let selectionRange = client.selectedRange() - let hasInvalidSelection = selectionRange.location == NSNotFound - - if hasInvalidSelection { - DebugLogger.log("Secure Input: invalid selection in '\(bundleId)', passing through") - return true + let signals = SecureInputSignals( + bundleId: context.bundleId, + hasTextInputCapability: context.hasTextInputCapability, + hasInvalidSelection: selectionRange.location == NSNotFound, + hasGlobalSecureInput: IsSecureEventInputEnabled(), + hasMarkedTextSupport: context.hasTextInputCapability + ) + let passThrough = SecureInputPolicy.shouldPassThrough(signals) + if passThrough { + DebugLogger.log("Secure Input: pass-through bundle=\(context.bundleId) global=\(signals.hasGlobalSecureInput) invalidSel=\(signals.hasInvalidSelection) textCap=\(context.hasTextInputCapability)") } - - return false + return passThrough } // 마우스 클릭 등으로 조합 영역 외부 클릭 시 조합 커밋 diff --git a/Sources/PriTypeCore/RightCommandSuppressor.swift b/Sources/PriTypeCore/RightCommandSuppressor.swift index 4bafc593..75a4387e 100644 --- a/Sources/PriTypeCore/RightCommandSuppressor.swift +++ b/Sources/PriTypeCore/RightCommandSuppressor.swift @@ -146,13 +146,19 @@ public final class RightCommandSuppressor: @unchecked Sendable { tapDisableCount += 1 if tapDisableCount >= maxTapDisableRetries { - // CGEventTap is repeatedly failing — switch to IOKit backup + // CGEventTap is repeatedly failing — stop it completely so IOKit + // is the only handler. Re-enabling here used to leave a zombie + // tap that double-fired the toggle. DebugLogger.log("RightCommandSuppressor: Tap disabled \(tapDisableCount) times, switching to IOKit fallback") + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: false) + } let callback = onTapFailed - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + self?.stop() callback?() } - // Still try to re-enable in case IOKit also needs it + return Unmanaged.passUnretained(event) } else { DebugLogger.log("RightCommandSuppressor: Tap disabled (\(tapDisableCount)/\(maxTapDisableRetries)), re-enabling") } diff --git a/Sources/PriTypeCore/SettingsWindowController.swift b/Sources/PriTypeCore/SettingsWindowController.swift index 69a6e113..0494d428 100644 --- a/Sources/PriTypeCore/SettingsWindowController.swift +++ b/Sources/PriTypeCore/SettingsWindowController.swift @@ -618,33 +618,15 @@ struct SettingsView: View { /// handles 한/영. Restored from v2.6.5 (removed in the 2.7 line). Reversible: /// the user can re-add ABC in System Settings (needed for the login screen). private func removeABCKeyboard() { - guard let defaults = UserDefaults(suiteName: "com.apple.HIToolbox"), - var sources = defaults.array(forKey: "AppleEnabledInputSources") as? [[String: Any]] else { - withAnimation { removeABCStatus = .error } - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - withAnimation { self.removeABCStatus = .idle } - } - return - } - - let originalCount = sources.count - sources.removeAll { source in - (source["KeyboardLayout Name"] as? String) == "ABC" - } - - if sources.count < originalCount { - defaults.set(sources, forKey: "AppleEnabledInputSources") - _ = CFPreferencesAppSynchronize("com.apple.HIToolbox" as CFString) - - // Restart TextInputMenuAgent so the menu-bar input-source list refreshes now. + let succeeded = InputSourceManager.shared.disableDefaultABCInputSource() + if succeeded { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") task.arguments = ["TextInputMenuAgent"] try? task.run() } - // Treat "already absent" as success too — the end state is what matters. - withAnimation { removeABCStatus = .success } + withAnimation { removeABCStatus = succeeded ? .success : .error } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { withAnimation { self.removeABCStatus = .idle } } diff --git a/Sources/PriTypeCore/TextConvenienceHandler.swift b/Sources/PriTypeCore/TextConvenienceHandler.swift index 9e55fe36..e2c379c8 100644 --- a/Sources/PriTypeCore/TextConvenienceHandler.swift +++ b/Sources/PriTypeCore/TextConvenienceHandler.swift @@ -218,8 +218,13 @@ public final class TextConvenienceHandler: @unchecked Sendable { } private func shouldAutoCapitalize(after beforeCursor: String) -> Bool { + // Empty context must NOT capitalize. After a Korean→English switch, + // many hosts (Chromium/Electron) report an empty substring even mid-sentence. + // Treating that as "start of document" is what made the first English + // letter uppercase. Empty/unknown context stays pass-through so macOS + // can apply its own capitalization when it actually knows the caret. if beforeCursor.isEmpty { - return true + return false } let scalars = Array(beforeCursor.unicodeScalars) diff --git a/Tests/PriTypeCoreTests/ClientContextTests.swift b/Tests/PriTypeCoreTests/ClientContextTests.swift index 6ede5178..b6568d29 100644 --- a/Tests/PriTypeCoreTests/ClientContextTests.swift +++ b/Tests/PriTypeCoreTests/ClientContextTests.swift @@ -65,9 +65,9 @@ struct ClientContextTests { #expect(!unknownCtx.shouldUseImmediateMode) } - @Test("Secure input policy passes through global secure input") - func secureInputPolicyPassesThroughGlobalSecureInput() { - #expect(SecureInputPolicy.shouldPassThrough(SecureInputSignals( + @Test("Secure input policy does not treat leaked global secure input as this field") + func secureInputPolicyIgnoresGlobalSecureInputOnNormalFields() { + #expect(!SecureInputPolicy.shouldPassThrough(SecureInputSignals( bundleId: "com.example.messenger", hasTextInputCapability: true, hasInvalidSelection: false, @@ -86,7 +86,7 @@ struct ClientContextTests { hasMarkedTextSupport: false ))) - #expect(SecureInputPolicy.shouldPassThrough(SecureInputSignals( + #expect(!SecureInputPolicy.shouldPassThrough(SecureInputSignals( bundleId: "com.example.messenger", hasTextInputCapability: true, hasInvalidSelection: true, @@ -94,13 +94,21 @@ struct ClientContextTests { hasMarkedTextSupport: true ))) - #expect(SecureInputPolicy.shouldPassThrough(SecureInputSignals( + #expect(!SecureInputPolicy.shouldPassThrough(SecureInputSignals( bundleId: "com.google.Chrome", hasTextInputCapability: true, hasInvalidSelection: true, hasGlobalSecureInput: false, hasMarkedTextSupport: true ))) + + #expect(SecureInputPolicy.shouldPassThrough(SecureInputSignals( + bundleId: "com.google.Chrome", + hasTextInputCapability: true, + hasInvalidSelection: true, + hasGlobalSecureInput: true, + hasMarkedTextSupport: true + ))) } @Test("Secure input policy always passes through system secure clients") diff --git a/Tests/PriTypeCoreTests/HangulComposerTests.swift b/Tests/PriTypeCoreTests/HangulComposerTests.swift index feaee7f5..0f7f09f0 100644 --- a/Tests/PriTypeCoreTests/HangulComposerTests.swift +++ b/Tests/PriTypeCoreTests/HangulComposerTests.swift @@ -141,14 +141,21 @@ struct HangulComposerTests { #expect(delegate.markedText.isEmpty) } - @Test("English mode applies auto-capitalization fallback") - func englishModeAutoCapitalizationFallback() { + @Test("English mode does not auto-capitalize when cursor context is empty") + func englishModeDoesNotAutoCapitalizeEmptyContext() { let (composer, delegate, _) = makeComposer() composer.setInputMode(.english) let firstLetter = TestEventFactory.keyEvent(char: "h", keyCode: 4)! - #expect(composer.handle(firstLetter, delegate: delegate)) - #expect(delegate.fullText == "H") + #expect(!composer.handle(firstLetter, delegate: delegate)) + #expect(delegate.insertedTexts.isEmpty) + #expect(delegate.fullText.isEmpty) + } + + @Test("English mode applies auto-capitalization after a sentence end") + func englishModeAutoCapitalizationFallback() { + let (composer, delegate, _) = makeComposer() + composer.setInputMode(.english) delegate.fullText = "Hello. " let sentenceLetter = TestEventFactory.keyEvent(char: "w", keyCode: 13)! diff --git a/Tests/PriTypeCoreTests/InputSourceManagerTests.swift b/Tests/PriTypeCoreTests/InputSourceManagerTests.swift index a0608952..00d582fb 100644 --- a/Tests/PriTypeCoreTests/InputSourceManagerTests.swift +++ b/Tests/PriTypeCoreTests/InputSourceManagerTests.swift @@ -71,4 +71,25 @@ struct InputSourceManagerTests { #expect(!sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.korean" }) #expect(sanitized.contains { ($0["Bundle ID"] as? String) == "com.apple.PressAndHold" }) } + + @Test("Identifies ABC sources by layout id and name") + func isDefaultABCSource() { + #expect(InputSourceManager.isDefaultABCSource([ + "InputSourceKind": "Keyboard Layout", + "KeyboardLayout ID": 252, + "KeyboardLayout Name": "ABC" + ])) + #expect(InputSourceManager.isDefaultABCSource([ + "KeyboardLayout Name": "ABC" + ])) + #expect(!InputSourceManager.isDefaultABCSource([ + "InputSourceKind": "Keyboard Layout", + "KeyboardLayout ID": 0, + "KeyboardLayout Name": "U.S." + ])) + #expect(!InputSourceManager.isDefaultABCSource([ + "Bundle ID": "com.pritype.inputmethod.v2", + "InputSourceKind": "Keyboard Input Method" + ])) + } } diff --git a/Tests/PriTypeCoreTests/TextConvenienceHandlerTests.swift b/Tests/PriTypeCoreTests/TextConvenienceHandlerTests.swift index 69d44a85..6e926be0 100644 --- a/Tests/PriTypeCoreTests/TextConvenienceHandlerTests.swift +++ b/Tests/PriTypeCoreTests/TextConvenienceHandlerTests.swift @@ -75,7 +75,6 @@ struct TextConvenienceHandlerTests { #expect(!handler.isHangul("!")) } - // English mode performs no composition and is a pure pass-through, so it no - // longer routes through TextConvenienceHandler. The behaviour is covered by - // `HangulComposerTests.englishModePurePassthrough`. + // English auto-capitalization: empty cursor context must pass through. + // Sentence-end fallback is covered by HangulComposerTests. } From 3cf256e5326dea995d3980214762c6d08fbb778f Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Wed, 2 Sep 2026 16:54:08 +0900 Subject: [PATCH 02/12] Fix Confluence list splits and duplicate PriType input-source rows. Web hosts were ending composition on the first choseong, and TIS minted extra PriType rows when the Korean mode id matched the bundle id and Hang+Latn were both advertised. Co-authored-by: Cursor --- CHANGELOG.md | 3 + Info.plist | 6 +- Sources/PriType/main.swift | 4 +- Sources/PriTypeCore/AboutInfo.swift | 7 +- Sources/PriTypeCore/Brand.swift | 75 +++++++ .../PriTypeCore/ClientContextDetector.swift | 33 +++ Sources/PriTypeCore/HangulComposer.swift | 9 +- Sources/PriTypeCore/InputSession.swift | 10 +- Sources/PriTypeCore/InputSourceManager.swift | 81 +++++-- Sources/PriTypeCore/L10n.swift | 20 +- Sources/PriTypeCore/PriTypeError.swift | 4 +- .../PriTypeCore/PriTypeInputController.swift | 29 ++- Sources/PriTypeCore/ReleaseChannel.swift | 6 + .../Resources/en.lproj/Localizable.strings | 20 +- .../Resources/ko.lproj/Localizable.strings | 20 +- .../SettingsWindowController.swift | 92 ++++---- Sources/PriTypeCore/TextDelivery.swift | 61 ++++- Sources/PriTypeCore/UpdateNotifier.swift | 2 +- Tests/PriTypeCoreTests/BrandTests.swift | 20 ++ .../PriTypeCoreTests/ClientContextTests.swift | 30 +++ .../HangulComposerTests.swift | 30 +++ .../InputSourceManagerTests.swift | 113 +++++++++- .../RegistrationContractTests.swift | 20 +- .../TextDeliveryPolicyTests.swift | 82 +++++++ .../PriTypeCoreTests/UpdateCheckerTests.swift | 3 + install.sh | 210 +++++++++++++++--- scripts/tis-control.swift | 103 +++++++++ 27 files changed, 945 insertions(+), 148 deletions(-) create mode 100644 Sources/PriTypeCore/Brand.swift create mode 100644 Tests/PriTypeCoreTests/BrandTests.swift create mode 100755 scripts/tis-control.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac9f24c..3984f763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 수정 +- 로컬 설치 `./install.sh --as-patchtype`은 입력기를 **PatchType / 패치타입**으로 등록합니다. 번들 ID는 `com.calvinjkim.patchtype`, 버전은 `2.7.4-patch.1 (local patch)`라서 설정·정보창에서 공식 PriType 2.7.4와 구분됩니다. 소스 트리 `Info.plist`의 공식 identity는 PR용으로 유지합니다. +- Confluence 같은 웹 편집기 목록에서 `감사합니다.`를 치면 초성 `ㄱ`이 따로 한 항목이 되고 나머지가 다음 항목으로 들어가던 문제를 고쳤습니다. 웹 호스트는 첫 `setMarkedText`가 비어 있는 목록의 선택 영역을 통째로 갈아치우지 않도록 캐럿에만 삽입하고, 조합 중인 초성을 호환 자모(U+3131)가 아니라 초성 자모(U+1100)로 보내 조합이 첫 타건에서 끝나지 않게 합니다. 카카오톡 등 네이티브 호스트는 기존 `NSNotFound` + 호환 자모 경로를 유지합니다. +- 설정에 PriType가 여러 개로 보이던 문제를 줄였습니다. 한글 모드 키를 번들 ID와 같게 두면 TIS가 `com.pritype.inputmethod.v2.v2`를 만들고 모든 행이 `PriType`으로 표시됩니다. 한글 모드를 `com.pritype.inputmethod.v2.korean`으로 구분해 `한글`/`영어`로 보이게 하고, 예전 모드 ID는 기동 시 마이그레이션합니다. `~/Library`와 `/Library`에 동시에 설치하면 여전히 복제가 생기므로 `install.sh`가 경고합니다. 문자 레퍼토리에서 `Latn`을 빼고 설정에 한글 모드만 노출합니다. `Hang`+`Latn`이면 입력 소스 추가 화면이 PriType를 모든 라틴/한글 자판과 짝지어 수백 줄로 보여 줍니다. - 한→영 전환 직후 첫 글자가 대문자로 들어가던 문제를 고쳤습니다. 영어 자동 대문자 폴백이 커서 앞 텍스트를 빈 문자열로 보면 문장 시작으로 취급했는데, Chromium/Electron은 전환 직후 그 조회가 자주 비어 있었습니다. 이제 빈 컨텍스트는 패스스루하고, `.!?` 뒤 공백처럼 문장 끝이 확인될 때만 대문자로 넣습니다. - 설정에서 ABC를 꺼도 다음 포커스/전환에서 다시 살아나던 문제를 고쳤습니다. 비활성 ABC 레이아웃을 `overrideKeyboardWithKeyboardNamed`로 요청하던 경로를 제거하고, 영어 모드에서만, 그리고 ABC/US가 실제로 켜져 있을 때만 오버라이드합니다. - 커스텀 한/영 전환키에서 `selectInputMode:`를 호출하지 않습니다. Latin-only 필드에서 TIS가 실제 ABC 소스로 넘어가던 경로입니다. diff --git a/Info.plist b/Info.plist index 8842e419..5ca8e20c 100644 --- a/Info.plist +++ b/Info.plist @@ -28,7 +28,7 @@ tsInputModeListKey - com.pritype.inputmethod.v2 + com.pritype.inputmethod.v2.korean TISIconIsTemplate @@ -57,8 +57,7 @@ tsVisibleInputModeOrderedArrayKey - com.pritype.inputmethod.v2 - com.pritype.inputmethod.v2.english + com.pritype.inputmethod.v2.korean InputMethodConnectionName @@ -74,7 +73,6 @@ tsInputMethodCharacterRepertoireKey Hang - Latn TISIconIsTemplate diff --git a/Sources/PriType/main.swift b/Sources/PriType/main.swift index 19853fe7..1b0ed2a8 100644 --- a/Sources/PriType/main.swift +++ b/Sources/PriType/main.swift @@ -3,7 +3,7 @@ import InputMethodKit import Cocoa import PriTypeCore -let kConnectionName = "PriType_InputString_v2" +let kConnectionName = Brand.connectionName // MARK: - App Delegate @@ -34,7 +34,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, @unchecked Sendable { UpdateNotifier.shared.setup() // Check for updates in background (respects user preference and 24h throttle) - if ConfigurationManager.shared.autoUpdateCheckEnabled { + if ConfigurationManager.shared.autoUpdateCheckEnabled && Brand.tracksUpstreamUpdates { Task.detached(priority: .utility) { let result = await UpdateChecker.shared.checkForUpdatesIfNeeded() if case .updateAvailable(let info) = result { diff --git a/Sources/PriTypeCore/AboutInfo.swift b/Sources/PriTypeCore/AboutInfo.swift index dfdfd285..a55c0e63 100644 --- a/Sources/PriTypeCore/AboutInfo.swift +++ b/Sources/PriTypeCore/AboutInfo.swift @@ -35,7 +35,12 @@ public struct AboutInfo: Sendable { public static var copyright: String { L10n.app.copyright } /// Full description for about dialog (localized) - public static var description: String { L10n.about.description } + public static var description: String { + if Brand.tracksUpstreamUpdates && releaseChannel != .local { + return L10n.about.description + } + return "\(L10n.about.description)\n\(L10n.about.patchNote)" + } // MARK: - About Dialog diff --git a/Sources/PriTypeCore/Brand.swift b/Sources/PriTypeCore/Brand.swift new file mode 100644 index 00000000..b7e24833 --- /dev/null +++ b/Sources/PriTypeCore/Brand.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Runtime identity of the installed input method. +/// +/// The source-tree `Info.plist` keeps the official PriType identity so upstream +/// PRs stay a PriType patch. `./install.sh --as-patchtype` rewrites the +/// *installed* bundle (name, bundle id, version, connection name). These +/// accessors follow `Bundle.main` in that installed process and fall back to +/// the official IDs in tests and unpackaged tools. +public enum Brand: Sendable { + public static let officialDisplayName = "PriType" + public static let officialBundleID = "com.pritype.inputmethod.v2" + public static let officialKoreanModeID = "com.pritype.inputmethod.v2.korean" + public static let officialEnglishModeID = "com.pritype.inputmethod.v2.english" + public static let officialConnectionName = "PriType_InputString_v2" + + public static let patchDisplayName = "PatchType" + public static let patchBundleID = "com.calvinjkim.patchtype" + public static let patchKoreanModeID = "com.calvinjkim.patchtype.korean" + public static let patchEnglishModeID = "com.calvinjkim.patchtype.english" + public static let patchConnectionName = "PatchType_InputString" + public static let patchVersion = "2.7.4-patch.1" + public static let patchBuild = "51" + + /// Bundle id of the running IME, or the official id outside an IME process. + public static var bundleID: String { + let id = Bundle.main.bundleIdentifier ?? "" + if id == patchBundleID || id == officialBundleID { + return id + } + return officialBundleID + } + + /// Localized display name from the running IME, otherwise "PriType". + public static var displayName: String { + let runningID = Bundle.main.bundleIdentifier ?? "" + guard runningID == bundleID, + let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, + !name.isEmpty else { + return officialDisplayName + } + return name + } + + public static var koreanModeID: String { "\(bundleID).korean" } + public static var englishModeID: String { "\(bundleID).english" } + + public static var connectionName: String { + if let name = Bundle.main.object(forInfoDictionaryKey: "InputMethodConnectionName") as? String, + !name.isEmpty, + Bundle.main.bundleIdentifier == bundleID { + return name + } + return officialConnectionName + } + + /// PatchType builds must not offer official PriType GitHub updates. + public static var tracksUpstreamUpdates: Bool { + bundleID == officialBundleID + } + + public static func koreanModeID(forBundleID bundleID: String) -> String { + "\(bundleID).korean" + } + + public static func englishModeID(forBundleID bundleID: String) -> String { + "\(bundleID).english" + } + + /// TIS mints `.` when the Korean mode key equals the bundle id. + public static func mintedCollisionModeID(forBundleID bundleID: String) -> String { + let last = bundleID.split(separator: ".").last.map(String.init) ?? bundleID + return "\(bundleID).\(last)" + } +} diff --git a/Sources/PriTypeCore/ClientContextDetector.swift b/Sources/PriTypeCore/ClientContextDetector.swift index c4d1f780..59e7cc92 100644 --- a/Sources/PriTypeCore/ClientContextDetector.swift +++ b/Sources/PriTypeCore/ClientContextDetector.swift @@ -161,6 +161,20 @@ public enum ClientCompatibilityPolicy { hermesBundleIds.contains(bundleId) } + /// Whether `bundleId` is a browser / Electron / CEF / Atlassian web editor. + /// These hosts run contenteditable (often ProseMirror), not AppKit text views. + public static func isWebContentHost(bundleId: String) -> Bool { + if directInsertionDenylist.contains(bundleId) { return true } + if blinkRendererBundleIds.contains(bundleId) { return true } + let lower = bundleId.lowercased() + return lower.contains("electron") + || lower.contains("chrome") + || lower.contains("chromium") + || lower.contains("atlassian") + || lower.contains("confluence") + || lower.contains("jira") + } + /// Whether direct insertion must be denied for `bundleId` because the host cannot /// reliably support in-place real-text rewrites (Electron/Chromium/browsers). /// Explicit list + a keyword heuristic for unlisted Electron/Chromium wrappers. @@ -172,6 +186,25 @@ public enum ClientCompatibilityPolicy { || lower.contains("chromium") } + /// Web editors treat Hangul Compatibility Jamo (U+3131 ㄱ) as a finished letter + /// and may fire compositionend after the first choseong. That commits `ㄱ` into + /// its own Confluence/ProseMirror list item while libhangul continues composing + /// the same syllable in the next item (`- ㄱ` / `- 감사합니다.`). Keep the + /// engine's choseong jamo (U+1100) so the host leaves composition open. + /// Native AppKit hosts keep compatibility jamo for display. + public static func prefersRawJamoPreedit(bundleId: String) -> Bool { + isWebContentHost(bundleId: bundleId) + } + + /// Web editors' empty list items often have a non-collapsed / placeholder + /// selection. `setMarkedText` with `replacementRange = NSNotFound` then + /// **replaces that selection** (Apple NSTextInputClient). Collapsing the + /// first mark to `{caret, 0}` inserts without eating the list structure. + /// Native hosts (KakaoTalk) must keep NSNotFound. + public static func prefersCollapsedCompositionReplacement(bundleId: String) -> Bool { + isWebContentHost(bundleId: bundleId) + } + /// Hosts whose text fields are rendered by Blink (Chromium/Electron/CEF). /// ENGINE classification, not per-app behavior: it decides only which form of /// "invisible underline" attributes the preedit uses (see `PreeditUnderline`), diff --git a/Sources/PriTypeCore/HangulComposer.swift b/Sources/PriTypeCore/HangulComposer.swift index ac0e6771..ea37bf1d 100644 --- a/Sources/PriTypeCore/HangulComposer.swift +++ b/Sources/PriTypeCore/HangulComposer.swift @@ -496,7 +496,14 @@ public class HangulComposer: @unchecked Sendable { // Update preedit text (the single live syllable). if !preedit.isEmpty { - let preeditStr = CompositionHelpers.normalizeJamoForDisplay(preedit) + let preeditStr: String + if ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: lastInputBundleId) { + // Keep U+1100 choseong so web hosts do not compositionend after the + // first jamo (compatibility U+3131 looks like a finished letter). + preeditStr = CompositionHelpers.convertToString(preedit) + } else { + preeditStr = CompositionHelpers.normalizeJamoForDisplay(preedit) + } delegate.setMarkedText(preeditStr) } else { delegate.setMarkedText("") diff --git a/Sources/PriTypeCore/InputSession.swift b/Sources/PriTypeCore/InputSession.swift index 8678ed13..5ed0f0df 100644 --- a/Sources/PriTypeCore/InputSession.swift +++ b/Sources/PriTypeCore/InputSession.swift @@ -160,7 +160,7 @@ final class InputSession: @unchecked Sendable { // Nothing to commit, but the session-ending event (e.g. a mouse click) // likely moved the caret — stale direct-insertion tracking must never // survive it, or the next keystroke could rewrite unrelated text. - (adapter as? DirectInsertionAdapter)?.resetPreeditTracking() + resetAdapterTracking() return false } @@ -175,9 +175,15 @@ final class InputSession: @unchecked Sendable { } Self.finalizeMarkedComposition(composer: composer, client: client, reason: reason) + resetAdapterTracking() return true } + private func resetAdapterTracking() { + (adapter as? DirectInsertionAdapter)?.resetPreeditTracking() + (adapter as? MarkedTextAdapter)?.resetLiveMarkedText() + } + /// The marked-text finalize, callable against any client. `PriTypeInputController` /// uses this directly when IMK hands it a sender that is not this session's client. static func finalizeMarkedComposition( @@ -204,6 +210,6 @@ final class InputSession: @unchecked Sendable { /// a stale live-preedit length can never delete real text on the next keystroke. func discardForSecureInput() { composer.discardCompositionForPassThrough() - (adapter as? DirectInsertionAdapter)?.resetPreeditTracking() + resetAdapterTracking() } } diff --git a/Sources/PriTypeCore/InputSourceManager.swift b/Sources/PriTypeCore/InputSourceManager.swift index 21a05391..74835760 100644 --- a/Sources/PriTypeCore/InputSourceManager.swift +++ b/Sources/PriTypeCore/InputSourceManager.swift @@ -28,15 +28,23 @@ public final class InputSourceManager: @unchecked Sendable { /// Keyboard Layout ID for ABC (252) public static let abcKeyboardLayoutID = 252 - private static let priTypeBundleID = "com.pritype.inputmethod.v2" - private static let priTypeKoreanInputMode = "com.pritype.inputmethod.v2" - private static let priTypeEnglishInputMode = "com.pritype.inputmethod.v2.english" - // Both PriType modes are current. cleanupStaleInputSources must NOT strip the - // English mode (it is a real registered mode, not a stale leftover). - private static let currentPriTypeInputModes: Set = [ - priTypeKoreanInputMode, - priTypeEnglishInputMode - ] + /// Modes belonging to `activeBundleID`. cleanupStaleInputSources must NOT + /// strip the English mode (it is a real registered mode, not a leftover). + private static func currentInputModes(forBundleID bundleID: String) -> Set { + [ + Brand.koreanModeID(forBundleID: bundleID), + Brand.englishModeID(forBundleID: bundleID) + ] + } + + /// Old Korean mode key was the bundle id itself. TIS then minted + /// `.` and Settings showed every row as the app name. + private static func legacyKoreanModeIds(forBundleID bundleID: String) -> Set { + [ + bundleID, + Brand.mintedCollisionModeID(forBundleID: bundleID) + ] + } // MARK: - TIS API Methods @@ -142,7 +150,8 @@ public final class InputSourceManager: @unchecked Sendable { enabledSources = Self.sanitizedInputSources( enabledSources, removeAppleKoreanInputModes: false, - allowsPriTypeParentEntry: true + allowsPriTypeParentEntry: true, + activeBundleID: Brand.bundleID ) var didChange = !Self.inputSourcesEqual(enabledSources, originalEnabledSources) @@ -157,7 +166,8 @@ public final class InputSourceManager: @unchecked Sendable { let sanitizedSources = Self.sanitizedInputSources( originalSources, removeAppleKoreanInputModes: false, - allowsPriTypeParentEntry: true + allowsPriTypeParentEntry: true, + activeBundleID: Brand.bundleID ) if !Self.inputSourcesEqual(sanitizedSources, originalSources) { defaults.set(sanitizedSources, forKey: key) @@ -182,15 +192,26 @@ public final class InputSourceManager: @unchecked Sendable { internal static func sanitizedInputSources( _ sources: [[String: Any]], removeAppleKoreanInputModes: Bool, - allowsPriTypeParentEntry: Bool + allowsPriTypeParentEntry: Bool, + activeBundleID: String = Brand.bundleID ) -> [[String: Any]] { var seen = Set() + let currentModes = currentInputModes(forBundleID: activeBundleID) + let legacyKorean = legacyKoreanModeIds(forBundleID: activeBundleID) return sources.compactMap { source in + let source = migrateLegacyPriTypeMode( + source, + bundleID: activeBundleID, + koreanModeID: Brand.koreanModeID(forBundleID: activeBundleID), + legacyIds: legacyKorean + ) if shouldRemoveInputSource( source, removeAppleKoreanInputModes: removeAppleKoreanInputModes, - allowsPriTypeParentEntry: allowsPriTypeParentEntry + allowsPriTypeParentEntry: allowsPriTypeParentEntry, + activeBundleID: activeBundleID, + currentModes: currentModes ) { return nil } @@ -204,17 +225,45 @@ public final class InputSourceManager: @unchecked Sendable { } } + /// Rewrite the Korean mode id that used to equal the bundle id so enabled / + /// selected lists keep working after the Info.plist rename. + private static func migrateLegacyPriTypeMode( + _ source: [String: Any], + bundleID: String, + koreanModeID: String, + legacyIds: Set + ) -> [String: Any] { + guard (source["Bundle ID"] as? String) == bundleID, + (source["InputSourceKind"] as? String) == "Input Mode", + let inputMode = source["Input Mode"] as? String, + legacyIds.contains(inputMode) else { + return source + } + var migrated = source + migrated["Input Mode"] = koreanModeID + return migrated + } + private static func shouldRemoveInputSource( _ source: [String: Any], removeAppleKoreanInputModes: Bool, - allowsPriTypeParentEntry: Bool + allowsPriTypeParentEntry: Bool, + activeBundleID: String, + currentModes: Set ) -> Bool { - if (source["Bundle ID"] as? String) == priTypeBundleID { + if let bundleID = source["Bundle ID"] as? String, + bundleID == Brand.officialBundleID, + activeBundleID != Brand.officialBundleID { + // PatchType must not leave official PriType rows in HIToolbox. + return true + } + + if (source["Bundle ID"] as? String) == activeBundleID { let inputMode = source["Input Mode"] as? String guard let inputMode else { return !allowsPriTypeParentEntry } - if !currentPriTypeInputModes.contains(inputMode) { + if !currentModes.contains(inputMode) { return true } return false diff --git a/Sources/PriTypeCore/L10n.swift b/Sources/PriTypeCore/L10n.swift index c0eceafe..37dc261b 100644 --- a/Sources/PriTypeCore/L10n.swift +++ b/Sources/PriTypeCore/L10n.swift @@ -71,16 +71,16 @@ public enum L10n { public static var conflict: String { localized("keyBinding.conflict") } public static var conflictRestored: String { localized("keyBinding.conflictRestored") } public static var reset: String { localized("keyBinding.reset") } - public static var capsLockSummary: String { localized("keyBinding.capsLockSummary") } + public static var capsLockSummary: String { String(format: localized("keyBinding.capsLockSummary"), app.name) } public static var capsLockStatusTitle: String { localized("keyBinding.capsLockStatusTitle") } public static var capsLockStatusOn: String { localized("keyBinding.capsLockStatusOn") } public static var capsLockStatusOff: String { localized("keyBinding.capsLockStatusOff") } - public static var capsLockOnDescription: String { localized("keyBinding.capsLockOnDescription") } - public static var capsLockOffDescription: String { localized("keyBinding.capsLockOffDescription") } + public static var capsLockOnDescription: String { String(format: localized("keyBinding.capsLockOnDescription"), app.name) } + public static var capsLockOffDescription: String { String(format: localized("keyBinding.capsLockOffDescription"), app.name) } public static var disabledByCapsLock: String { localized("keyBinding.disabledByCapsLock") } public static var managedByMacOS: String { localized("keyBinding.managedByMacOS") } public static var capsLockBlockedTitle: String { localized("keyBinding.capsLockBlockedTitle") } - public static var capsLockBlockedMessage: String { localized("keyBinding.capsLockBlockedMessage") } + public static var capsLockBlockedMessage: String { String(format: localized("keyBinding.capsLockBlockedMessage"), app.name) } public static var capsLockOpenSettings: String { localized("keyBinding.capsLockOpenSettings") } } @@ -90,14 +90,17 @@ public enum L10n { public static var title: String { localized("about.title") } public static var description: String { localized("about.description") } public static var version: String { localized("about.version") } + public static var patchNote: String { localized("about.patchNote") } } // MARK: - App public enum app { - public static var name: String { "PriType" } + public static var name: String { Brand.displayName } public static var copyright: String { localized("app.copyright") } - public static var quit: String { localized("app.quit") } + public static var quit: String { String(format: localized("app.quit"), name) } + public static var settingsMenu: String { String(format: localized("app.settingsMenu"), name) } + public static var aboutMenu: String { String(format: localized("app.aboutMenu"), name) } } // MARK: - Update @@ -110,9 +113,10 @@ public enum L10n { public static var available: String { localized("update.available") } public static var download: String { localized("update.download") } public static var error: String { localized("update.error") } - public static var notificationTitle: String { localized("update.notificationTitle") } + public static var notificationTitle: String { String(format: localized("update.notificationTitle"), app.name) } public static var notificationBody: String { localized("update.notificationBody") } public static var autoCheck: String { localized("update.autoCheck") } + public static var localPatchDisabled: String { localized("update.localPatchDisabled") } } // MARK: - System @@ -124,7 +128,7 @@ public enum L10n { public static var accessibilityRequest: String { localized("system.accessibilityRequest") } public static var accessibilitySubtitle: String { localized("system.accessibilitySubtitle") } public static var removeABC: String { localized("system.removeABC") } - public static var removeABCSubtitle: String { localized("system.removeABCSubtitle") } + public static var removeABCSubtitle: String { String(format: localized("system.removeABCSubtitle"), app.name) } public static var removeABCButton: String { localized("system.removeABCButton") } public static var removeABCSuccess: String { localized("system.removeABCSuccess") } public static var removeABCFailed: String { localized("system.removeABCFailed") } diff --git a/Sources/PriTypeCore/PriTypeError.swift b/Sources/PriTypeCore/PriTypeError.swift index cf937bc4..942f8ae3 100644 --- a/Sources/PriTypeCore/PriTypeError.swift +++ b/Sources/PriTypeCore/PriTypeError.swift @@ -35,7 +35,7 @@ public enum PriTypeError: LocalizedError, Sendable { case .eventTapDisabled: return "CGEventTap이 시스템에 의해 비활성화되었습니다." case .accessibilityPermissionDenied: - return "접근성 권한이 필요합니다. 시스템 설정에서 PriType에 권한을 부여하세요." + return "접근성 권한이 필요합니다. 시스템 설정에서 \(Brand.displayName)에 권한을 부여하세요." case .hidManagerOpenFailed(let code): return "IOHIDManager 열기 실패 (코드: \(code))" } @@ -44,7 +44,7 @@ public enum PriTypeError: LocalizedError, Sendable { public var recoverySuggestion: String? { switch self { case .eventTapCreationFailed, .accessibilityPermissionDenied: - return "시스템 설정 > 개인정보 보호 및 보안 > 접근성에서 PriType을 활성화하세요." + return "시스템 설정 > 개인정보 보호 및 보안 > 접근성에서 \(Brand.displayName)을 활성화하세요." case .eventTapDisabled: return "입력기를 재시작하거나 시스템을 다시 시작하세요." case .hidManagerOpenFailed: diff --git a/Sources/PriTypeCore/PriTypeInputController.swift b/Sources/PriTypeCore/PriTypeInputController.swift index 8ab6bcb5..ea622155 100644 --- a/Sources/PriTypeCore/PriTypeInputController.swift +++ b/Sources/PriTypeCore/PriTypeInputController.swift @@ -23,11 +23,21 @@ import Carbon.HIToolbox /// ``` @objc(PriTypeInputController) public class PriTypeInputController: IMKInputController, @unchecked Sendable { - // Two PriType input modes registered in Info.plist ComponentInputModeDict. + // Two input modes registered in Info.plist ComponentInputModeDict. // Korean composes; English is a pure pass-through (ABC layout override). // macOS Caps Lock / input-source switching moves between these two modes. - private static let priTypeInputSourceID = "com.pritype.inputmethod.v2" // Korean mode (== bundle id) - private static let priTypeEnglishInputModeID = "com.pritype.inputmethod.v2.english" + // The Korean mode id MUST be distinct from the bundle id — using the bundle + // id as the mode key made TIS mint `.` and every Settings row + // displayed as the app name. + private static let priTypeKoreanInputModeID = Brand.koreanModeID + private static let priTypeEnglishInputModeID = Brand.englishModeID + private static let legacyKoreanInputModeIDs: Set = [ + Brand.bundleID, + Brand.mintedCollisionModeID(forBundleID: Brand.bundleID), + Brand.officialBundleID, + Brand.officialKoreanModeID, + Brand.mintedCollisionModeID(forBundleID: Brand.officialBundleID) + ] // MARK: - Shared State // @@ -292,9 +302,12 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { // next keyDown already sees the new mode (no first-key race). let targetMode: InputMode? switch inputModeID { - case Self.priTypeEnglishInputModeID: targetMode = .english - case Self.priTypeInputSourceID: targetMode = .korean - default: targetMode = nil + case Self.priTypeEnglishInputModeID: + targetMode = .english + case Self.priTypeKoreanInputModeID: + targetMode = .korean + default: + targetMode = Self.legacyKoreanInputModeIDs.contains(inputModeID) ? .korean : nil } DebugLogger.log("PriTypeInputController: setValue inputMode='\(inputModeID)' target=\(String(describing: targetMode)) current=\(composer.inputMode)") guard let targetMode else { @@ -404,14 +417,14 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { let menu = NSMenu() // Settings - let settingsItem = NSMenuItem(title: "PriType 설정...", action: #selector(openSettings(_:)), keyEquivalent: "") + let settingsItem = NSMenuItem(title: L10n.app.settingsMenu, action: #selector(openSettings(_:)), keyEquivalent: "") settingsItem.target = self menu.addItem(settingsItem) menu.addItem(NSMenuItem.separator()) // About - let aboutItem = NSMenuItem(title: "PriType 정보", action: #selector(showAbout(_:)), keyEquivalent: "") + let aboutItem = NSMenuItem(title: L10n.app.aboutMenu, action: #selector(showAbout(_:)), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) diff --git a/Sources/PriTypeCore/ReleaseChannel.swift b/Sources/PriTypeCore/ReleaseChannel.swift index 3cd43169..457791b6 100644 --- a/Sources/PriTypeCore/ReleaseChannel.swift +++ b/Sources/PriTypeCore/ReleaseChannel.swift @@ -4,6 +4,7 @@ import Foundation public enum ReleaseChannel: String, Sendable, Codable { case stable case beta + case local public var displayName: String { switch self { @@ -11,6 +12,8 @@ public enum ReleaseChannel: String, Sendable, Codable { return "Stable" case .beta: return "Beta" + case .local: + return "local patch" } } @@ -19,6 +22,9 @@ public enum ReleaseChannel: String, Sendable, Codable { public static func detect(plistValue: String?, version: String) -> ReleaseChannel { if let plistValue { let normalized = plistValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == ReleaseChannel.local.rawValue || normalized == "patch" { + return .local + } if normalized == ReleaseChannel.beta.rawValue { return .beta } diff --git a/Sources/PriTypeCore/Resources/en.lproj/Localizable.strings b/Sources/PriTypeCore/Resources/en.lproj/Localizable.strings index 3b79d3c6..d002ece0 100644 --- a/Sources/PriTypeCore/Resources/en.lproj/Localizable.strings +++ b/Sources/PriTypeCore/Resources/en.lproj/Localizable.strings @@ -26,26 +26,29 @@ "keyBinding.conflict" = "Cannot use the same key"; "keyBinding.conflictRestored" = "Cannot use the same key. Restored the previous setting."; "keyBinding.reset" = "Reset to Default"; -"keyBinding.capsLockSummary" = "When Caps Lock input-source switching is on, PriType's toggle key is disabled."; +"keyBinding.capsLockSummary" = "When Caps Lock input-source switching is on, %@'s toggle key is disabled."; "keyBinding.capsLockStatusTitle" = "Caps Lock input-source switch"; "keyBinding.capsLockStatusOn" = "On"; "keyBinding.capsLockStatusOff" = "Off"; -"keyBinding.capsLockOnDescription" = "Caps Lock switches Korean/English through macOS Input Source settings. PriType's toggle key is disabled to avoid duplicate switching."; -"keyBinding.capsLockOffDescription" = "PriType's toggle key switches Korean/English. Enable Caps Lock in macOS Input Source settings if you prefer that key."; +"keyBinding.capsLockOnDescription" = "Caps Lock switches Korean/English through macOS Input Source settings. %@'s toggle key is disabled to avoid duplicate switching."; +"keyBinding.capsLockOffDescription" = "%@'s toggle key switches Korean/English. Enable Caps Lock in macOS Input Source settings if you prefer that key."; "keyBinding.disabledByCapsLock" = "Disabled while Caps Lock input-source switching is on"; "keyBinding.managedByMacOS" = "macOS Caps Lock"; "keyBinding.capsLockBlockedTitle" = "Set Caps Lock in macOS"; -"keyBinding.capsLockBlockedMessage" = "Caps Lock is not a PriType custom key. Enable Caps Lock switching in macOS Input Source settings."; +"keyBinding.capsLockBlockedMessage" = "Caps Lock is not a %@ custom key. Enable Caps Lock switching in macOS Input Source settings."; "keyBinding.capsLockOpenSettings" = "Open Input Source Settings"; /* About */ "about.title" = "About"; "about.description" = "Korean Input Method for macOS"; "about.version" = "Version"; +"about.patchNote" = "Local patch based on PriType 2.7.4. Not an official release."; /* App */ "app.copyright" = "© 2025"; -"app.quit" = "Quit PriType"; +"app.quit" = "Quit %@"; +"app.settingsMenu" = "%@ Settings..."; +"app.aboutMenu" = "About %@"; /* Update */ "update.title" = "Update"; @@ -55,9 +58,10 @@ "update.available" = "New version available: %@"; "update.download" = "Download"; "update.error" = "Check failed"; -"update.notificationTitle" = "PriType Update Available"; -"update.notificationBody" = "PriType %@ is now available. Click to download."; +"update.notificationTitle" = "%@ Update Available"; +"update.notificationBody" = "%1$@ %2$@ is now available. Click to download."; "update.autoCheck" = "Automatically check for updates"; +"update.localPatchDisabled" = "This is a local patch, distinct from official PriType. It does not check for upstream updates."; /* System */ "system.title" = "System Options"; @@ -66,7 +70,7 @@ "system.accessibilityRequest" = "Request Permission"; "system.accessibilitySubtitle" = "Required for detecting shortcut keys such as Right Command and Right Option."; "system.removeABC" = "Turn Off ABC Input Source"; -"system.removeABCSubtitle" = "Removes the default ABC input source when PriType alone handles Korean and English. You can add it again in System Settings."; +"system.removeABCSubtitle" = "Removes the default ABC input source when %@ alone handles Korean and English. You can add it again in System Settings."; "system.removeABCButton" = "Turn Off"; "system.removeABCSuccess" = "Disabled"; "system.removeABCFailed" = "Failed"; diff --git a/Sources/PriTypeCore/Resources/ko.lproj/Localizable.strings b/Sources/PriTypeCore/Resources/ko.lproj/Localizable.strings index c2d7195c..d6ba78f6 100644 --- a/Sources/PriTypeCore/Resources/ko.lproj/Localizable.strings +++ b/Sources/PriTypeCore/Resources/ko.lproj/Localizable.strings @@ -26,26 +26,29 @@ "keyBinding.conflict" = "같은 키는 사용할 수 없습니다"; "keyBinding.conflictRestored" = "같은 키는 사용할 수 없습니다. 기존 설정으로 복원했습니다."; "keyBinding.reset" = "기본값으로 복원"; -"keyBinding.capsLockSummary" = "Caps Lock 입력 소스 전환이 켜져 있으면 PriType 한/영 전환키는 비활성화됩니다."; +"keyBinding.capsLockSummary" = "Caps Lock 입력 소스 전환이 켜져 있으면 %@ 한/영 전환키는 비활성화됩니다."; "keyBinding.capsLockStatusTitle" = "Caps Lock 입력 소스 전환"; "keyBinding.capsLockStatusOn" = "켜짐"; "keyBinding.capsLockStatusOff" = "꺼짐"; -"keyBinding.capsLockOnDescription" = "macOS 입력 소스 설정을 따라 Caps Lock으로 한/영을 전환합니다. PriType 전환키는 중복 동작을 막기 위해 비활성화됩니다."; -"keyBinding.capsLockOffDescription" = "PriType 전환키로 한/영을 전환합니다. Caps Lock을 쓰려면 macOS 입력 소스 설정에서 켜 주세요."; +"keyBinding.capsLockOnDescription" = "macOS 입력 소스 설정을 따라 Caps Lock으로 한/영을 전환합니다. %@ 전환키는 중복 동작을 막기 위해 비활성화됩니다."; +"keyBinding.capsLockOffDescription" = "%@ 전환키로 한/영을 전환합니다. Caps Lock을 쓰려면 macOS 입력 소스 설정에서 켜 주세요."; "keyBinding.disabledByCapsLock" = "Caps Lock 입력 소스 전환이 켜져 있어 비활성화됨"; "keyBinding.managedByMacOS" = "macOS Caps Lock"; "keyBinding.capsLockBlockedTitle" = "Caps Lock은 macOS에서 설정합니다"; -"keyBinding.capsLockBlockedMessage" = "PriType 전환키로는 사용할 수 없습니다. macOS 입력 소스 설정에서 Caps Lock 전환을 켜 주세요."; +"keyBinding.capsLockBlockedMessage" = "%@ 전환키로는 사용할 수 없습니다. macOS 입력 소스 설정에서 Caps Lock 전환을 켜 주세요."; "keyBinding.capsLockOpenSettings" = "입력 소스 설정 열기"; /* About */ "about.title" = "정보"; "about.description" = "macOS용 한글 입력기"; "about.version" = "버전"; +"about.patchNote" = "원본 PriType 2.7.4 기반 로컬 패치입니다. 공식 빌드가 아닙니다."; /* App */ "app.copyright" = "© 2025"; -"app.quit" = "PriType 종료"; +"app.quit" = "%@ 종료"; +"app.settingsMenu" = "%@ 설정..."; +"app.aboutMenu" = "%@ 정보"; /* Update */ "update.title" = "업데이트"; @@ -55,9 +58,10 @@ "update.available" = "새 버전 사용 가능: %@"; "update.download" = "다운로드"; "update.error" = "확인 실패"; -"update.notificationTitle" = "PriType 업데이트 사용 가능"; -"update.notificationBody" = "PriType %@이(가) 출시되었습니다. 클릭하여 다운로드하세요."; +"update.notificationTitle" = "%@ 업데이트 사용 가능"; +"update.notificationBody" = "%1$@ %2$@이(가) 출시되었습니다. 클릭하여 다운로드하세요."; "update.autoCheck" = "자동으로 업데이트 확인"; +"update.localPatchDisabled" = "이 빌드는 원본 PriType과 구분되는 로컬 패치입니다. 공식 업데이트는 확인하지 않습니다."; /* System */ "system.title" = "시스템 옵션"; @@ -66,7 +70,7 @@ "system.accessibilityRequest" = "권한 요청"; "system.accessibilitySubtitle" = "우측 Command·Option 같은 전환키와 한자키 감지에 필요합니다."; "system.removeABC" = "ABC 입력 소스 끄기"; -"system.removeABCSubtitle" = "PriType만으로 한/영을 쓸 때 기본 ABC를 입력 소스 목록에서 제거합니다. 필요하면 시스템 설정에서 다시 추가할 수 있습니다."; +"system.removeABCSubtitle" = "%@만으로 한/영을 쓸 때 기본 ABC를 입력 소스 목록에서 제거합니다. 필요하면 시스템 설정에서 다시 추가할 수 있습니다."; "system.removeABCButton" = "ABC 끄기"; "system.removeABCSuccess" = "비활성화 완료"; "system.removeABCFailed" = "비활성화 실패"; diff --git a/Sources/PriTypeCore/SettingsWindowController.swift b/Sources/PriTypeCore/SettingsWindowController.swift index 0494d428..ba002ae0 100644 --- a/Sources/PriTypeCore/SettingsWindowController.swift +++ b/Sources/PriTypeCore/SettingsWindowController.swift @@ -32,7 +32,7 @@ public class SettingsWindowController: NSObject { let newWindow = NSWindow(contentViewController: hostingController) // Visually hidden (titleVisibility = .hidden) but still used by the Window // menu, Mission Control, and VoiceOver — so keep it localized. - newWindow.title = "PriType \(L10n.settings.title)" + newWindow.title = "\(L10n.app.name) \(L10n.settings.title)" newWindow.styleMask = [.titled, .closable, .miniaturizable, .fullSizeContentView] newWindow.titlebarAppearsTransparent = true newWindow.titleVisibility = .hidden @@ -282,50 +282,64 @@ struct SettingsView: View { clearKeyConflict() } - SettingsSection( - title: L10n.update.title, - icon: "arrow.triangle.2.circlepath" - ) { - VStack(spacing: 0) { - SettingsToggleRow( - title: L10n.update.autoCheck, - icon: "clock.arrow.2.circlepath", - isOn: $autoUpdateCheckEnabled - ) - - Divider() - .opacity(0.2) - .padding(.horizontal, 12) + if Brand.tracksUpstreamUpdates { + SettingsSection( + title: L10n.update.title, + icon: "arrow.triangle.2.circlepath" + ) { + VStack(spacing: 0) { + SettingsToggleRow( + title: L10n.update.autoCheck, + icon: "clock.arrow.2.circlepath", + isOn: $autoUpdateCheckEnabled + ) - HStack(spacing: 10) { - Button(action: { checkForUpdates() }) { - HStack(spacing: 6) { - if updateStatus == .checking { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - .font(.system(size: 12, weight: .medium)) + Divider() + .opacity(0.2) + .padding(.horizontal, 12) + + HStack(spacing: 10) { + Button(action: { checkForUpdates() }) { + HStack(spacing: 6) { + if updateStatus == .checking { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .medium)) + } + Text(L10n.update.checkButton) + .font(.system(size: 13, weight: .medium)) } - Text(L10n.update.checkButton) - .font(.system(size: 13, weight: .medium)) } - } - .buttonStyle(.bordered) - .buttonBorderShape(.roundedRectangle(radius: 7)) - .controlSize(.small) - .disabled(updateStatus == .checking) + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle(radius: 7)) + .controlSize(.small) + .disabled(updateStatus == .checking) - Spacer() + Spacer() - updateStatusView + updateStatusView + } + .padding(.vertical, 10) + .padding(.horizontal, 12) } - .padding(.vertical, 10) - .padding(.horizontal, 12) } - } - .onChange(of: autoUpdateCheckEnabled) { _, newValue in - ConfigurationManager.shared.autoUpdateCheckEnabled = newValue + .onChange(of: autoUpdateCheckEnabled) { _, newValue in + ConfigurationManager.shared.autoUpdateCheckEnabled = newValue + } + } else { + SettingsSection( + title: L10n.update.title, + icon: "arrow.triangle.2.circlepath" + ) { + Text(L10n.update.localPatchDisabled) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 10) + .padding(.horizontal, 12) + } } SettingsSection( @@ -457,7 +471,7 @@ struct SettingsView: View { SettingsHeaderIcon() VStack(alignment: .leading, spacing: 3) { - Text("PriType") + Text(L10n.app.name) .font(.system(size: 23, weight: .semibold)) .foregroundStyle(.primary) Text(L10n.settings.title) diff --git a/Sources/PriTypeCore/TextDelivery.swift b/Sources/PriTypeCore/TextDelivery.swift index c00bfad9..08866b43 100644 --- a/Sources/PriTypeCore/TextDelivery.swift +++ b/Sources/PriTypeCore/TextDelivery.swift @@ -166,10 +166,55 @@ class BaseClientAdapter: NSObject, HangulComposerDelegate { } } +// MARK: - MarkedTextReplacement + +/// Pure policy for `setMarkedText` `replacementRange`. +/// +/// Native AppKit hosts (KakaoTalk) need `NSNotFound`: the host replaces its own +/// marked text. Web contenteditable hosts (Confluence/ProseMirror lists) often +/// have a non-collapsed placeholder selection in an empty `
  • `; Apple's +/// NSTextInputClient then **replaces that selection** when there is no marked +/// text yet, which splits the list (`- ㄱ` then `- 감사합니다.`). +enum MarkedTextReplacement { + static let notFound = NSRange(location: NSNotFound, length: NSNotFound) + static let maxLocation = 10_000_000 + + static func range( + isClearing: Bool, + hasLiveMarkedText: Bool, + selectedRange: NSRange, + prefersCollapsedStart: Bool + ) -> NSRange { + if isClearing || !prefersCollapsedStart || hasLiveMarkedText { + return notFound + } + guard selectedRange.location != NSNotFound, + selectedRange.location < maxLocation else { + return notFound + } + return NSRange(location: selectedRange.location, length: 0) + } +} + // MARK: - MarkedTextAdapter /// Standard adapter with invisible-underline marked text for composition display final class MarkedTextAdapter: BaseClientAdapter { + /// True after we have sent a non-empty marked string that has not yet been + /// committed or cleared. Used instead of `client.markedRange()` because + /// Chromium often reports NSNotFound for markedRange even during preedit. + private var hasLiveMarkedText = false + + override func insertText(_ text: String) { + hasLiveMarkedText = false + super.insertText(text) + } + + /// Session-ending commits talk to the IMK client directly and skip `insertText`. + func resetLiveMarkedText() { + hasLiveMarkedText = false + } + override func setMarkedText(_ text: String) { // Canonical marked-text protocol, matching Apple's own input methods: // set the marked text directly with replacementRange = NSNotFound (an @@ -178,11 +223,25 @@ final class MarkedTextAdapter: BaseClientAdapter { // non-canonical path (clearing via insertText("") over an explicit marked // range) left native hosts like KakaoTalk in an inconsistent composition // state — a stranded/underlined preedit that never committed on focus loss. + // + // Web hosts are the exception on the *first* mark only: insert at the + // caret (`length: 0`) so an empty-list placeholder selection is not + // replaced. Later updates still use NSNotFound. + let prefersCollapsedStart = ClientCompatibilityPolicy + .prefersCollapsedCompositionReplacement(bundleId: bundleId) + let needsCollapsedStart = prefersCollapsedStart && !text.isEmpty && !hasLiveMarkedText + let replacement = MarkedTextReplacement.range( + isClearing: text.isEmpty, + hasLiveMarkedText: hasLiveMarkedText, + selectedRange: needsCollapsedStart ? client.selectedRange() : MarkedTextReplacement.notFound, + prefersCollapsedStart: prefersCollapsedStart + ) + hasLiveMarkedText = !text.isEmpty let attributed = NSAttributedString(string: text, attributes: preeditAttributes) client.setMarkedText( attributed, selectionRange: NSRange(location: text.utf16.count, length: 0), - replacementRange: NSRange(location: NSNotFound, length: NSNotFound) + replacementRange: replacement ) } } diff --git a/Sources/PriTypeCore/UpdateNotifier.swift b/Sources/PriTypeCore/UpdateNotifier.swift index 9cfce48f..d920a591 100644 --- a/Sources/PriTypeCore/UpdateNotifier.swift +++ b/Sources/PriTypeCore/UpdateNotifier.swift @@ -107,7 +107,7 @@ public final class UpdateNotifier: NSObject, @unchecked Sendable, UNUserNotifica let content = UNMutableNotificationContent() content.title = L10n.update.notificationTitle - content.body = String(format: L10n.update.notificationBody, update.version) + content.body = String(format: L10n.update.notificationBody, L10n.app.name, update.version) content.sound = .default content.categoryIdentifier = categoryIdentifier diff --git a/Tests/PriTypeCoreTests/BrandTests.swift b/Tests/PriTypeCoreTests/BrandTests.swift new file mode 100644 index 00000000..e8e8d0ef --- /dev/null +++ b/Tests/PriTypeCoreTests/BrandTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import PriTypeCore + +@Suite("Brand") +struct BrandTests { + @Test("Unpackaged test host falls back to official PriType identity") + func officialFallbackOutsideIME() { + #expect(Brand.bundleID == Brand.officialBundleID) + #expect(Brand.displayName == Brand.officialDisplayName) + #expect(Brand.koreanModeID == Brand.officialKoreanModeID) + #expect(Brand.englishModeID == Brand.officialEnglishModeID) + #expect(Brand.tracksUpstreamUpdates) + } + + @Test("Minted collision mode id uses the bundle's last component") + func mintedCollisionModeID() { + #expect(Brand.mintedCollisionModeID(forBundleID: Brand.officialBundleID) == "com.pritype.inputmethod.v2.v2") + #expect(Brand.mintedCollisionModeID(forBundleID: Brand.patchBundleID) == "com.calvinjkim.patchtype.patchtype") + } +} diff --git a/Tests/PriTypeCoreTests/ClientContextTests.swift b/Tests/PriTypeCoreTests/ClientContextTests.swift index b6568d29..afaf5cf9 100644 --- a/Tests/PriTypeCoreTests/ClientContextTests.swift +++ b/Tests/PriTypeCoreTests/ClientContextTests.swift @@ -145,6 +145,36 @@ struct ClientContextTests { #expect(ClientCompatibilityPolicy.prefersDirectInsertionForComposition(bundleId: "com.nousresearch.hermes.setup")) #expect(!ClientCompatibilityPolicy.prefersDirectInsertionForComposition(bundleId: "com.openai.codex")) } + + @Test("Web content hosts include browsers, Electron, and Atlassian wrappers") + func webContentHosts() { + for id in [ + "com.google.Chrome", + "com.apple.Safari", + "org.mozilla.firefox", + "com.anthropic.claudefordesktop", + "com.atlassian.confluence", + "com.example.MyElectronApp" + ] { + #expect(ClientCompatibilityPolicy.isWebContentHost(bundleId: id), "\(id) should be a web content host") + #expect(ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id), "\(id) should keep raw jamo") + #expect(ClientCompatibilityPolicy.prefersCollapsedCompositionReplacement(bundleId: id), "\(id) should start composition without replacing host selection") + } + } + + @Test("Native AppKit hosts are not treated as web content") + func nativeHostsAreNotWebContent() { + for id in [ + "com.kakao.KakaoTalkMac", + "com.apple.TextEdit", + "com.apple.Notes", + "com.apple.dt.Xcode" + ] { + #expect(!ClientCompatibilityPolicy.isWebContentHost(bundleId: id), "\(id) should stay native") + #expect(!ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id)) + #expect(!ClientCompatibilityPolicy.prefersCollapsedCompositionReplacement(bundleId: id)) + } + } // MARK: - Resolution / Desktop Detection (migrated from ResolutionTests.swift) diff --git a/Tests/PriTypeCoreTests/HangulComposerTests.swift b/Tests/PriTypeCoreTests/HangulComposerTests.swift index 0f7f09f0..321c6ba2 100644 --- a/Tests/PriTypeCoreTests/HangulComposerTests.swift +++ b/Tests/PriTypeCoreTests/HangulComposerTests.swift @@ -42,6 +42,36 @@ struct HangulComposerTests { #expect(delegate.markedText == "안") } + + @Test("Web hosts keep choseong jamo in preedit instead of compatibility jamo") + func webHostKeepsRawChoseongJamo() { + let (composer, delegate, _) = makeComposer() + composer.markKeystroke(bundleId: "com.google.Chrome") + _ = composer.handle(TestEventFactory.keyEvent(char: "r", keyCode: 15)!, delegate: delegate) + + #expect(delegate.markedText == "\u{1100}", + "Chrome preedit must stay U+1100 so the host keeps composition open; got '\(delegate.markedText)'") + #expect(delegate.insertedTexts.isEmpty, "First choseong must not commit") + } + + @Test("Native hosts display compatibility jamo in preedit") + func nativeHostUsesCompatibilityJamo() { + let (composer, delegate, _) = makeComposer() + composer.markKeystroke(bundleId: "com.kakao.KakaoTalkMac") + _ = composer.handle(TestEventFactory.keyEvent(char: "r", keyCode: 15)!, delegate: delegate) + + #expect(delegate.markedText == "\u{3131}", + "Native preedit stays U+3131 for display; got '\(delegate.markedText)'") + } + + @Test("Web host syllable composition still produces a precomposed syllable") + func webHostSyllableStillPrecomposed() { + let (composer, delegate, _) = makeComposer() + composer.markKeystroke(bundleId: "com.google.Chrome") + _ = composer.handle(TestEventFactory.keyEvent(char: "r", keyCode: 15)!, delegate: delegate) + _ = composer.handle(TestEventFactory.keyEvent(char: "k", keyCode: 40)!, delegate: delegate) + #expect(delegate.markedText == "가") + } // MARK: - Syllable Boundary Tests diff --git a/Tests/PriTypeCoreTests/InputSourceManagerTests.swift b/Tests/PriTypeCoreTests/InputSourceManagerTests.swift index 00d582fb..dd20e5eb 100644 --- a/Tests/PriTypeCoreTests/InputSourceManagerTests.swift +++ b/Tests/PriTypeCoreTests/InputSourceManagerTests.swift @@ -5,8 +5,6 @@ import Testing struct InputSourceManagerTests { @Test("Keeps PriType parent and BOTH Korean + English modes in enabled sources") func keepsPriTypeParentAndBothModesInEnabledSources() { - // Dual-mode design: korean (com.pritype.inputmethod.v2) and english - // (com.pritype.inputmethod.v2.english) are BOTH current — neither is stale. let sources: [[String: Any]] = [ [ "Bundle ID": "com.pritype.inputmethod.v2", @@ -15,7 +13,7 @@ struct InputSourceManagerTests { [ "Bundle ID": "com.pritype.inputmethod.v2", "InputSourceKind": "Input Mode", - "Input Mode": "com.pritype.inputmethod.v2" + "Input Mode": "com.pritype.inputmethod.v2.korean" ], [ "Bundle ID": "com.pritype.inputmethod.v2", @@ -32,12 +30,14 @@ struct InputSourceManagerTests { #expect(sanitized.count == 3) #expect(sanitized.contains { $0["Input Mode"] == nil }) // parent - #expect(sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2" }) // korean - #expect(sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.english" }) // english (kept) + #expect(sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.korean" }) + #expect(sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.english" }) } - @Test("Keeps PriType parent and removes stale child modes from selected and history sources") - func keepsPriTypeParentAndRemovesStaleChildModesFromSelectedAndHistorySources() { + @Test("Migrates legacy Korean mode id that collided with the bundle id") + func migratesLegacyKoreanModeIdEqualToBundle() { + // Old Info.plist used the bundle id as the Korean mode key. TIS then + // minted com.pritype.inputmethod.v2.v2 and every Settings row said PriType. let sources: [[String: Any]] = [ [ "Bundle ID": "com.pritype.inputmethod.v2", @@ -48,11 +48,43 @@ struct InputSourceManagerTests { "InputSourceKind": "Input Mode", "Input Mode": "com.pritype.inputmethod.v2" ], + [ + "Bundle ID": "com.pritype.inputmethod.v2", + "InputSourceKind": "Input Mode", + "Input Mode": "com.pritype.inputmethod.v2.english" + ] + ] + + let sanitized = InputSourceManager.sanitizedInputSources( + sources, + removeAppleKoreanInputModes: false, + allowsPriTypeParentEntry: true + ) + + #expect(sanitized.count == 3) + #expect(sanitized.contains { ($0["Bundle ID"] as? String) == "com.pritype.inputmethod.v2" && $0["Input Mode"] == nil }) + #expect(sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.korean" }) + #expect(!sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2" }) + #expect(sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.english" }) + } + + @Test("Keeps PriType parent and removes unknown stale child modes") + func keepsPriTypeParentAndRemovesStaleChildModesFromSelectedAndHistorySources() { + let sources: [[String: Any]] = [ + [ + "Bundle ID": "com.pritype.inputmethod.v2", + "InputSourceKind": "Keyboard Input Method" + ], [ "Bundle ID": "com.pritype.inputmethod.v2", "InputSourceKind": "Input Mode", "Input Mode": "com.pritype.inputmethod.v2.korean" ], + [ + "Bundle ID": "com.pritype.inputmethod.v2", + "InputSourceKind": "Input Mode", + "Input Mode": "com.pritype.inputmethod.v2.legacy" + ], [ "Bundle ID": "com.apple.PressAndHold", "InputSourceKind": "Non Keyboard Input Method" @@ -67,8 +99,8 @@ struct InputSourceManagerTests { #expect(sanitized.count == 3) #expect(sanitized.contains { ($0["Bundle ID"] as? String) == "com.pritype.inputmethod.v2" && $0["Input Mode"] == nil }) - #expect(sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2" }) - #expect(!sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.korean" }) + #expect(sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.korean" }) + #expect(!sanitized.contains { ($0["Input Mode"] as? String) == "com.pritype.inputmethod.v2.legacy" }) #expect(sanitized.contains { ($0["Bundle ID"] as? String) == "com.apple.PressAndHold" }) } @@ -92,4 +124,67 @@ struct InputSourceManagerTests { "InputSourceKind": "Keyboard Input Method" ])) } + + @Test("Migrating a legacy Korean mode that is already present as .korean does not duplicate") + func migrateThenDedupKoreanModes() { + let sources: [[String: Any]] = [ + [ + "Bundle ID": "com.pritype.inputmethod.v2", + "InputSourceKind": "Input Mode", + "Input Mode": "com.pritype.inputmethod.v2" + ], + [ + "Bundle ID": "com.pritype.inputmethod.v2", + "InputSourceKind": "Input Mode", + "Input Mode": "com.pritype.inputmethod.v2.korean" + ] + ] + let sanitized = InputSourceManager.sanitizedInputSources( + sources, + removeAppleKoreanInputModes: false, + allowsPriTypeParentEntry: true + ) + #expect(sanitized.count == 1) + #expect((sanitized[0]["Input Mode"] as? String) == "com.pritype.inputmethod.v2.korean") + } + + @Test("PatchType identity strips leftover official PriType rows") + func patchTypeStripsOfficialPriTypeRows() { + let sources: [[String: Any]] = [ + [ + "Bundle ID": Brand.officialBundleID, + "InputSourceKind": "Keyboard Input Method" + ], + [ + "Bundle ID": Brand.officialBundleID, + "InputSourceKind": "Input Mode", + "Input Mode": Brand.officialKoreanModeID + ], + [ + "Bundle ID": Brand.patchBundleID, + "InputSourceKind": "Keyboard Input Method" + ], + [ + "Bundle ID": Brand.patchBundleID, + "InputSourceKind": "Input Mode", + "Input Mode": Brand.patchKoreanModeID + ], + [ + "Bundle ID": "com.apple.inputmethod.Korean", + "InputSourceKind": "Keyboard Input Method" + ] + ] + + let sanitized = InputSourceManager.sanitizedInputSources( + sources, + removeAppleKoreanInputModes: false, + allowsPriTypeParentEntry: true, + activeBundleID: Brand.patchBundleID + ) + + #expect(!sanitized.contains { ($0["Bundle ID"] as? String) == Brand.officialBundleID }) + #expect(sanitized.contains { ($0["Bundle ID"] as? String) == Brand.patchBundleID && $0["Input Mode"] == nil }) + #expect(sanitized.contains { ($0["Input Mode"] as? String) == Brand.patchKoreanModeID }) + #expect(sanitized.contains { ($0["Bundle ID"] as? String) == "com.apple.inputmethod.Korean" }) + } } diff --git a/Tests/PriTypeCoreTests/RegistrationContractTests.swift b/Tests/PriTypeCoreTests/RegistrationContractTests.swift index 7e0fe284..126f78c0 100644 --- a/Tests/PriTypeCoreTests/RegistrationContractTests.swift +++ b/Tests/PriTypeCoreTests/RegistrationContractTests.swift @@ -44,14 +44,24 @@ struct RegistrationContractTests { let list = modes(info) #expect(list.count == 2, "expected exactly 2 input modes, got \(list.count)") - let korean = list["com.pritype.inputmethod.v2"] as? [String: Any] + let korean = list["com.pritype.inputmethod.v2.korean"] as? [String: Any] let english = list["com.pritype.inputmethod.v2.english"] as? [String: Any] #expect(korean?["tsInputModeScriptKey"] as? String == "smKorean") #expect(english?["tsInputModeScriptKey"] as? String == "smRoman") let comp = info["ComponentInputModeDict"] as? [String: Any] let visible = comp?["tsVisibleInputModeOrderedArrayKey"] as? [String] - #expect(visible == ["com.pritype.inputmethod.v2", "com.pritype.inputmethod.v2.english"]) + #expect(visible == ["com.pritype.inputmethod.v2.korean"]) + } + + @Test("Korean mode id is not the bundle id (avoids TIS ID .v2.v2 and duplicate PriType names)") + func koreanModeIdDistinctFromBundle() throws { + let info = try loadInfoPlist() + let bundleId = info["CFBundleIdentifier"] as? String + #expect(bundleId == "com.pritype.inputmethod.v2") + #expect(modes(info)[bundleId ?? ""] == nil, + "mode key == bundle id makes TIS mint com.pritype.inputmethod.v2.v2 and names every entry PriType") + #expect(modes(info)["com.pritype.inputmethod.v2.korean"] != nil) } @Test("Declares Caps Lock language-switch capability") @@ -77,10 +87,12 @@ struct RegistrationContractTests { func coreIdentity() throws { let info = try loadInfoPlist() #expect(info["CFBundleIdentifier"] as? String == "com.pritype.inputmethod.v2") + #expect(info["CFBundleDisplayName"] as? String == "PriType") + #expect(info["CFBundleShortVersionString"] as? String == "2.7.4") #expect(info["InputMethodConnectionName"] as? String == "PriType_InputString_v2") #expect(info["InputMethodServerControllerClass"] as? String == "PriTypeInputController") let repertoire = info["tsInputMethodCharacterRepertoireKey"] as? [String] - #expect(repertoire?.contains("Hang") == true, "must declare Hangul repertoire") + #expect(repertoire == ["Hang"], "Hang only — Latn makes Settings list PriType against every Latin keyboard") } @Test("Input source icons are mode-specific template images") @@ -90,7 +102,7 @@ struct RegistrationContractTests { #expect(info["tsInputMethodIconFileKey"] as? String == "icon.tiff") let list = modes(info) - let korean = list["com.pritype.inputmethod.v2"] as? [String: Any] + let korean = list["com.pritype.inputmethod.v2.korean"] as? [String: Any] let english = list["com.pritype.inputmethod.v2.english"] as? [String: Any] #expect(korean?["TISIconIsTemplate"] as? Bool == true) diff --git a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift index 2219e9fc..fbb107dd 100644 --- a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift +++ b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift @@ -138,3 +138,85 @@ struct CompositionFinalizeReasonTests { #expect(Set(reasons.map(\.rawValue)).count == reasons.count) } } + +// MARK: - Web-host first-mark replacement range + +/// Confluence/ProseMirror empty list items often have a non-collapsed or +/// placeholder selection. Apple's setMarkedText docs: if there is no marked +/// text and replacementRange is NSNotFound, the **current selection is replaced**. +/// Replacing that selection with the first ㄱ looks like splitting a list item +/// (`- ㄱ` then `- 감사합니다.`). Native hosts must keep NSNotFound (KakaoTalk). +@Suite("MarkedTextReplacement") +struct MarkedTextReplacementTests { + private let notFound = NSRange(location: NSNotFound, length: NSNotFound) + private let placeholderSelection = NSRange(location: 0, length: 1) + private let collapsedCaret = NSRange(location: 12, length: 0) + + @Test("Native hosts always use NSNotFound") + func nativeAlwaysNotFound() { + #expect(MarkedTextReplacement.range( + isClearing: false, + hasLiveMarkedText: false, + selectedRange: placeholderSelection, + prefersCollapsedStart: false + ) == notFound) + } + + @Test("Clearing marked text always uses NSNotFound") + func clearingAlwaysNotFound() { + #expect(MarkedTextReplacement.range( + isClearing: true, + hasLiveMarkedText: false, + selectedRange: placeholderSelection, + prefersCollapsedStart: true + ) == notFound) + } + + @Test("Subsequent preedit updates use NSNotFound so the host replaces its marked text") + func liveMarkedUsesNotFound() { + #expect(MarkedTextReplacement.range( + isClearing: false, + hasLiveMarkedText: true, + selectedRange: placeholderSelection, + prefersCollapsedStart: true + ) == notFound) + } + + @Test("First mark on a web host inserts at the caret without replacing host selection") + func firstWebMarkInsertsCollapsed() { + let range = MarkedTextReplacement.range( + isClearing: false, + hasLiveMarkedText: false, + selectedRange: placeholderSelection, + prefersCollapsedStart: true + ) + #expect(range == NSRange(location: 0, length: 0), + "Must not replace a non-collapsed list-item selection; got \(range)") + } + + @Test("First mark on a web host with a collapsed caret still inserts at that caret") + func firstWebMarkCollapsedCaret() { + #expect(MarkedTextReplacement.range( + isClearing: false, + hasLiveMarkedText: false, + selectedRange: collapsedCaret, + prefersCollapsedStart: true + ) == NSRange(location: 12, length: 0)) + } + + @Test("Invalid or Chromium-garbage selection falls back to NSNotFound") + func garbageSelectionFallsBack() { + #expect(MarkedTextReplacement.range( + isClearing: false, + hasLiveMarkedText: false, + selectedRange: NSRange(location: NSNotFound, length: 0), + prefersCollapsedStart: true + ) == notFound) + #expect(MarkedTextReplacement.range( + isClearing: false, + hasLiveMarkedText: false, + selectedRange: NSRange(location: 20_000_000, length: 0), + prefersCollapsedStart: true + ) == notFound) + } +} diff --git a/Tests/PriTypeCoreTests/UpdateCheckerTests.swift b/Tests/PriTypeCoreTests/UpdateCheckerTests.swift index b38adfe3..04d6d002 100644 --- a/Tests/PriTypeCoreTests/UpdateCheckerTests.swift +++ b/Tests/PriTypeCoreTests/UpdateCheckerTests.swift @@ -61,6 +61,9 @@ struct UpdateCheckerTests { #expect(ReleaseChannel.detect(tagName: "v3.0.0", name: "PriType 3.0", prerelease: true) == .beta) #expect(ReleaseChannel.detect(plistValue: "beta", version: "3.0.0") == .beta) #expect(ReleaseChannel.detect(plistValue: "stable", version: "3.0.0-beta.1") == .stable) + #expect(ReleaseChannel.detect(plistValue: "local", version: "2.7.4-patch.1") == .local) + #expect(ReleaseChannel.detect(plistValue: "patch", version: "2.7.4") == .local) + #expect(ReleaseChannel.local.displayName == "local patch") } @Test("Stable update candidate ignores higher beta versions") diff --git a/install.sh b/install.sh index c0775e96..013314b9 100755 --- a/install.sh +++ b/install.sh @@ -1,41 +1,148 @@ #!/bin/bash -set -e +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./install.sh [--as-patchtype] + + (default) Install as PriTypeV2 using the source-tree identity. + --as-patchtype Install as PatchType / 패치타입 with a distinct bundle id + and version 2.7.4-patch.1 so it cannot be confused with + official PriType 2.7.4. Copies to /Library/Input Methods + only (never also ~/Library). A new bundle id usually + needs a logout before it appears in Input Sources. +EOF +} + +AS_PATCHTYPE=0 +for arg in "$@"; do + case "$arg" in + --as-patchtype) AS_PATCHTYPE=1 ;; + -h|--help) usage; exit 0 ;; + *) + echo "Unknown argument: $arg" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [ "$AS_PATCHTYPE" -eq 1 ]; then + APP_NAME="PatchType" + EXEC_NAME="PatchType" + SYSTEM_ONLY=1 +else + APP_NAME="PriTypeV2" + EXEC_NAME="PriTypeV2" + SYSTEM_ONLY=0 +fi -# Define variables -APP_NAME="PriTypeV2" BUILD_DIR=".build/release" -INSTALL_DIR="$HOME/Library/Input Methods" -APP_BUNDLE="${APP_NAME}.app" +STAGE_DIR=".build/ime-bundle" +APP_BUNDLE="${STAGE_DIR}/${APP_NAME}.app" CONTENTS_DIR="${APP_BUNDLE}/Contents" MACOS_DIR="${CONTENTS_DIR}/MacOS" RESOURCES_DIR="${CONTENTS_DIR}/Resources" +USER_INSTALL_DIR="$HOME/Library/Input Methods" +SYSTEM_INSTALL_DIR="/Library/Input Methods" +USER_BUNDLE="${USER_INSTALL_DIR}/${APP_NAME}.app" +SYSTEM_BUNDLE="${SYSTEM_INSTALL_DIR}/${APP_NAME}.app" +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" + +apply_patchtype_identity() { + python3 - "$1" <<'PY' +import plistlib +import sys + +path = sys.argv[1] +old = "com.pritype.inputmethod.v2" +new = "com.calvinjkim.patchtype" + +with open(path, "rb") as handle: + info = plistlib.load(handle) + +modes = info.get("ComponentInputModeDict", {}).get("tsInputModeListKey", {}) +rewritten = {} +for key, value in modes.items(): + rewritten[key.replace(old, new)] = value +info.setdefault("ComponentInputModeDict", {})["tsInputModeListKey"] = rewritten +info["ComponentInputModeDict"]["tsVisibleInputModeOrderedArrayKey"] = [f"{new}.korean"] + +info["CFBundleDisplayName"] = "PatchType" +info["CFBundleName"] = "PatchType" +info["CFBundleExecutable"] = "PatchType" +info["CFBundleIdentifier"] = new +info["CFBundleShortVersionString"] = "2.7.4-patch.1" +info["CFBundleVersion"] = "51" +info["InputMethodConnectionName"] = "PatchType_InputString" +info["PriTypeReleaseChannel"] = "local" + +with open(path, "wb") as handle: + plistlib.dump(info, handle, sort_keys=False) +PY + + mkdir -p "$RESOURCES_DIR/en.lproj" "$RESOURCES_DIR/ko.lproj" + cat > "$RESOURCES_DIR/en.lproj/InfoPlist.strings" <<'EOF' +/* Localized versions of Info.plist keys */ +"CFBundleName" = "PatchType"; +"CFBundleDisplayName" = "PatchType"; +"com.calvinjkim.patchtype" = "PatchType"; +"com.calvinjkim.patchtype.korean" = "Patch Korean"; +"com.calvinjkim.patchtype.english" = "Patch English"; +EOF + cat > "$RESOURCES_DIR/ko.lproj/InfoPlist.strings" <<'EOF' +/* Localized versions of Info.plist keys */ +"CFBundleName" = "패치타입"; +"CFBundleDisplayName" = "패치타입"; +"com.calvinjkim.patchtype" = "패치타입"; +"com.calvinjkim.patchtype.korean" = "패치 한글"; +"com.calvinjkim.patchtype.english" = "패치 영어"; +EOF +} + +copy_to_system() { + local src="$1" + local dest="$2" + if sudo -n true 2>/dev/null; then + sudo ditto "$src" "$dest" + sudo rm -rf \ + "/Library/Input Methods/PriTypeV2.app" \ + "/Library/Input Methods/PriType.app" \ + "/tmp/PriTypeV2.app.disabled" + return 0 + fi + + echo "Administrator access is required to install into /Library/Input Methods." + echo "A password dialog may appear." + osascript - "$src" "$dest" <<'APPLESCRIPT' +on run argv + set src to item 1 of argv + set dest to item 2 of argv + do shell script "ditto " & quoted form of src & " " & quoted form of dest & " && rm -rf '/Library/Input Methods/PriTypeV2.app' '/Library/Input Methods/PriType.app' '/tmp/PriTypeV2.app.disabled'" with administrator privileges +end run +APPLESCRIPT +} echo "Building release..." -swift build -c release +swift build -c release --product PriType -echo "Creating bundle structure..." +echo "Creating bundle structure at $APP_BUNDLE..." +rm -rf "$STAGE_DIR" mkdir -p "$MACOS_DIR" mkdir -p "$RESOURCES_DIR" echo "Copying executable..." -cp "$BUILD_DIR/PriType" "$MACOS_DIR/PriTypeV2" +cp "$BUILD_DIR/PriType" "$MACOS_DIR/$EXEC_NAME" echo "Copying Info.plist..." cp Info.plist "$CONTENTS_DIR/" -# Copy menu bar icon -# Copy Resources directory (localization, etc) cp -R Resources/* "$RESOURCES_DIR/" 2>/dev/null || true - -# Copy App Icon cp "AppIcon.icns" "$RESOURCES_DIR/" 2>/dev/null || echo "No AppIcon.icns found" - -# Copy input source icons cp "icon.tiff" "$RESOURCES_DIR/" 2>/dev/null || echo "No icon.tiff found, skipping." cp "input-ko.tiff" "$RESOURCES_DIR/" 2>/dev/null || echo "No input-ko.tiff found, skipping." cp "input-en.tiff" "$RESOURCES_DIR/" 2>/dev/null || echo "No input-en.tiff found, skipping." -# Copy Swift Package Manager resource bundle (required for Bundle.module / L10n) if [ -d "$BUILD_DIR/PriType_PriTypeCore.bundle" ]; then cp -R "$BUILD_DIR/PriType_PriTypeCore.bundle" "$RESOURCES_DIR/" echo "Copied PriType_PriTypeCore.bundle" @@ -43,22 +150,21 @@ else echo "Warning: PriType_PriTypeCore.bundle not found" fi -# Code Signing -if [ -z "$SIGNING_IDENTITY" ]; then - # 1. 정식 Apple Development 인증서가 있는지 먼저 확인 - APPLE_DEV_CERT=$(security find-identity -v -p codesigning | grep "Apple Development:" | head -n 1 | awk -F'"' '{print $2}') - +if [ "$AS_PATCHTYPE" -eq 1 ]; then + echo "Applying PatchType identity (not official PriType 2.7.4)..." + apply_patchtype_identity "$CONTENTS_DIR/Info.plist" +fi + +if [ -z "${SIGNING_IDENTITY:-}" ]; then + APPLE_DEV_CERT=$(security find-identity -v -p codesigning | grep "Apple Development:" | head -n 1 | awk -F'"' '{print $2}' || true) if [ -n "$APPLE_DEV_CERT" ]; then SIGNING_IDENTITY="$APPLE_DEV_CERT" - else - # 2. 없다면 개발용 자체 서명 인증서(PriTypeDev) 확인 - if security find-certificate -c "PriTypeDev" > /dev/null 2>&1; then - SIGNING_IDENTITY="PriTypeDev" - fi + elif security find-certificate -c "PriTypeDev" > /dev/null 2>&1; then + SIGNING_IDENTITY="PriTypeDev" fi fi -if [ -n "$SIGNING_IDENTITY" ]; then +if [ -n "${SIGNING_IDENTITY:-}" ]; then echo "Signing with identity: $SIGNING_IDENTITY" codesign --force --sign "$SIGNING_IDENTITY" "$APP_BUNDLE" echo "Signing complete." @@ -68,12 +174,48 @@ else codesign --force --deep --sign - "$APP_BUNDLE" fi -echo "Installing to $INSTALL_DIR..." -mkdir -p "$INSTALL_DIR" -rm -rf "$INSTALL_DIR/PriType.app" -rm -rf "$INSTALL_DIR/$APP_BUNDLE" -mv "$APP_BUNDLE" "$INSTALL_DIR/" +killall PriTypeV2 PatchType PriType 2>/dev/null || true + +if [ "$SYSTEM_ONLY" -eq 1 ]; then + echo "Installing to $SYSTEM_BUNDLE (system copy only)..." + mkdir -p "$USER_INSTALL_DIR" + rm -rf \ + "$USER_INSTALL_DIR/PriType.app" \ + "$USER_INSTALL_DIR/PriTypeV2.app" \ + "$USER_INSTALL_DIR/PatchType.app" + copy_to_system "$APP_BUNDLE" "$SYSTEM_BUNDLE" + INSTALL_PATH="$SYSTEM_BUNDLE" +else + echo "Installing to $USER_BUNDLE..." + mkdir -p "$USER_INSTALL_DIR" + rm -rf "$USER_INSTALL_DIR/PriType.app" + rm -rf "$USER_INSTALL_DIR/PriTypeV2.app" + rm -rf "$USER_BUNDLE" + ditto "$APP_BUNDLE" "$USER_BUNDLE" + INSTALL_PATH="$USER_BUNDLE" + + if [ -d "$SYSTEM_INSTALL_DIR/${APP_NAME}.app" ] && [ -d "$USER_BUNDLE" ]; then + echo "" + echo "WARNING: ${APP_NAME} is installed in TWO places:" + echo " $USER_BUNDLE" + echo " $SYSTEM_INSTALL_DIR/${APP_NAME}.app" + echo "macOS then lists the IME many times in Input Sources (one row per" + echo "copy × parent/Korean/English). Keep a single copy." + fi +fi + +xattr -cr "$INSTALL_PATH" 2>/dev/null || true +killall TextInputMenuAgent TextInputSwitcher keyboardservicesd imklaunchagent PriTypeV2 PatchType 2>/dev/null || true -echo "Installation complete!" -echo "Please log out and log back in, or restart your computer." -echo "Then enable '$APP_NAME' in System Settings > Keyboard > Input Sources." +echo "Installation complete: $INSTALL_PATH" +if [ "$AS_PATCHTYPE" -eq 1 ]; then + echo "Name: PatchType / 패치타입" + echo "Version: 2.7.4-patch.1 (local patch) — not official PriType 2.7.4" + echo "Bundle ID: com.calvinjkim.patchtype" + echo "Log out and back in, then enable '패치 한글' (Patch Korean) under" + echo "System Settings > Keyboard > Input Sources. Do not add every row" + echo "from '모든 입력 소스'. Apple 2-Set Korean can stay as a fallback." +else + echo "Please log out and log back in, or restart your computer." + echo "Then enable '$APP_NAME' in System Settings > Keyboard > Input Sources." +fi diff --git a/scripts/tis-control.swift b/scripts/tis-control.swift new file mode 100755 index 00000000..cc8f1066 --- /dev/null +++ b/scripts/tis-control.swift @@ -0,0 +1,103 @@ +import Carbon +import Foundation + +private func stringProperty(_ source: TISInputSource, _ key: CFString) -> String? { + guard let ptr = TISGetInputSourceProperty(source, key) else { return nil } + return Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String +} + +private func boolProperty(_ source: TISInputSource, _ key: CFString) -> Bool { + guard let ptr = TISGetInputSourceProperty(source, key) else { return false } + return CFBooleanGetValue(Unmanaged.fromOpaque(ptr).takeUnretainedValue()) +} + +private func allSources(includeDisabled: Bool) -> [TISInputSource] { + let list = TISCreateInputSourceList(nil, includeDisabled) + return (list?.takeRetainedValue() as? [TISInputSource]) ?? [] +} + +private func findSource(id: String, includeDisabled: Bool) -> TISInputSource? { + allSources(includeDisabled: includeDisabled).first { stringProperty($0, kTISPropertyInputSourceID) == id } +} + +private func printSource(_ source: TISInputSource) { + let id = stringProperty(source, kTISPropertyInputSourceID) ?? "?" + let name = stringProperty(source, kTISPropertyLocalizedName) ?? "?" + let enabled = boolProperty(source, kTISPropertyInputSourceIsEnabled) + print("\(enabled ? "on " : "off")\t\(id)\t\(name)") +} + +private func interesting(_ id: String) -> Bool { + let keys = ["patchtype", "pritype", "Korean", "Hangul", "ABC", "US", "2Set"] + return keys.contains { id.localizedCaseInsensitiveContains($0) } +} + +let args = Array(CommandLine.arguments.dropFirst()) +guard let action = args.first else { + fputs("usage: tis-control list|enable|select|find \n", stderr) + exit(2) +} + +switch action { +case "list": + print("ENABLED:") + for source in allSources(includeDisabled: false) where interesting(stringProperty(source, kTISPropertyInputSourceID) ?? "") { + printSource(source) + } + print("CURRENT:") + if let current = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() { + printSource(current) + } + +case "find": + guard args.count > 1 else { + fputs("usage: tis-control find \n", stderr) + exit(2) + } + let id = args[1] + if let source = findSource(id: id, includeDisabled: true) { + printSource(source) + exit(0) + } + fputs("not found: \(id)\n", stderr) + exit(1) + +case "enable": + guard args.count > 1 else { + fputs("usage: tis-control enable \n", stderr) + exit(2) + } + let id = args[1] + guard let source = findSource(id: id, includeDisabled: true) else { + fputs("not found: \(id)\n", stderr) + exit(1) + } + let status = TISEnableInputSource(source) + print("TISEnableInputSource(\(id)) -> \(status)") + exit(status == noErr ? 0 : Int32(status == 0 ? 0 : 1)) + +case "select": + guard args.count > 1 else { + fputs("usage: tis-control select \n", stderr) + exit(2) + } + let id = args[1] + guard let source = findSource(id: id, includeDisabled: true) else { + fputs("not found: \(id)\n", stderr) + exit(1) + } + if !boolProperty(source, kTISPropertyInputSourceIsEnabled) { + let enableStatus = TISEnableInputSource(source) + print("TISEnableInputSource(\(id)) -> \(enableStatus)") + if enableStatus != noErr { + exit(1) + } + } + let status = TISSelectInputSource(source) + print("TISSelectInputSource(\(id)) -> \(status)") + exit(status == noErr ? 0 : 1) + +default: + fputs("unknown action: \(action)\n", stderr) + exit(2) +} From 252a4b2fb611985554fc76b0697e92b7c1d5536c Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Fri, 11 Sep 2026 16:34:43 +0900 Subject: [PATCH 03/12] Keep the official bundle id on the local overlay and stop rewriting live minted Korean. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new ad-hoc id never appears in Settings, migrating …v2.v2.korean can steal Hangul from 2-Set, and first-mark {caret,0} plus activateServer adapter rebuilds split web compositions. Co-authored-by: Cursor --- CHANGELOG.md | 7 +- Info.plist | 10 +++ Sources/PriTypeCore/Brand.swift | 8 ++- Sources/PriTypeCore/InputSession.swift | 12 +++- Sources/PriTypeCore/InputSourceManager.swift | 12 +++- .../PriTypeCore/PriTypeInputController.swift | 40 +++++++---- Sources/PriTypeCore/TextDelivery.swift | 24 ++++++- .../InputSourceManagerTests.swift | 25 +++++++ .../RegistrationContractTests.swift | 16 ++++- .../TextDeliveryPolicyTests.swift | 20 ++++++ install.sh | 71 ++++++++++--------- 11 files changed, 189 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3984f763..29e4794a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 수정 -- 로컬 설치 `./install.sh --as-patchtype`은 입력기를 **PatchType / 패치타입**으로 등록합니다. 번들 ID는 `com.calvinjkim.patchtype`, 버전은 `2.7.4-patch.1 (local patch)`라서 설정·정보창에서 공식 PriType 2.7.4와 구분됩니다. 소스 트리 `Info.plist`의 공식 identity는 PR용으로 유지합니다. +- `--as-patchtype`은 공식 번들 ID와 `PriTypeV2.app` 경로를 유지합니다. 새 ad-hoc 번들 ID로 갈아끼우며 공식 복사본을 지우면 Gatekeeper가 거절해 설정에 입력기가 안 나옵니다. +- TIS가 만든 `…v2.v2.korean` 자식 모드는 기동 시 `.korean`으로 다시 쓰지 않습니다. 그 ID를 레거시로 취급하면 HIToolbox에서 한글 모드가 빠지고 부모만 남아 두벌식 Hangul을 뺏습니다. +- 웹 호스트 첫 `setMarkedText`의 `{caret, 0}` 삽입은 선택 길이가 0~1일 때만입니다. 단어처럼 길이가 2 이상이면 `NSNotFound`로 교체합니다. +- Electron이 음절 중간에 `activateServer`를 다시 호출해도, 활성 조합이 있으면 새 어댑터가 첫 마크로 취급하지 않습니다. +- 로컬 설치 `./install.sh --as-patchtype`은 표시 이름만 **PatchType / 패치타입**, 버전만 `2.7.4-patch.1 (local patch)`로 바꿉니다. 번들 ID는 공식 `com.pritype.inputmethod.v2`를 유지합니다. +- 설정이 한글 언어 아래에서 입력기를 고를 수 있도록 `TISIntendedLanguage=ko`를 등록합니다. 최상위 `TISInputSourceID`는 넣지 않습니다. 번들 ID와 같아도 TIS가 `.v2.v2.korean`을 만들기 때문입니다. - Confluence 같은 웹 편집기 목록에서 `감사합니다.`를 치면 초성 `ㄱ`이 따로 한 항목이 되고 나머지가 다음 항목으로 들어가던 문제를 고쳤습니다. 웹 호스트는 첫 `setMarkedText`가 비어 있는 목록의 선택 영역을 통째로 갈아치우지 않도록 캐럿에만 삽입하고, 조합 중인 초성을 호환 자모(U+3131)가 아니라 초성 자모(U+1100)로 보내 조합이 첫 타건에서 끝나지 않게 합니다. 카카오톡 등 네이티브 호스트는 기존 `NSNotFound` + 호환 자모 경로를 유지합니다. - 설정에 PriType가 여러 개로 보이던 문제를 줄였습니다. 한글 모드 키를 번들 ID와 같게 두면 TIS가 `com.pritype.inputmethod.v2.v2`를 만들고 모든 행이 `PriType`으로 표시됩니다. 한글 모드를 `com.pritype.inputmethod.v2.korean`으로 구분해 `한글`/`영어`로 보이게 하고, 예전 모드 ID는 기동 시 마이그레이션합니다. `~/Library`와 `/Library`에 동시에 설치하면 여전히 복제가 생기므로 `install.sh`가 경고합니다. 문자 레퍼토리에서 `Latn`을 빼고 설정에 한글 모드만 노출합니다. `Hang`+`Latn`이면 입력 소스 추가 화면이 PriType를 모든 라틴/한글 자판과 짝지어 수백 줄로 보여 줍니다. - 한→영 전환 직후 첫 글자가 대문자로 들어가던 문제를 고쳤습니다. 영어 자동 대문자 폴백이 커서 앞 텍스트를 빈 문자열로 보면 문장 시작으로 취급했는데, Chromium/Electron은 전환 직후 그 조회가 자주 비어 있었습니다. 이제 빈 컨텍스트는 패스스루하고, `.!?` 뒤 공백처럼 문장 끝이 확인될 때만 대문자로 넣습니다. diff --git a/Info.plist b/Info.plist index 5ca8e20c..494dd02d 100644 --- a/Info.plist +++ b/Info.plist @@ -32,6 +32,10 @@ TISIconIsTemplate + TISIntendedLanguage + ko + tsInputModeIsVisibleKey + tsInputModeMenuIconFileKey input-ko.tiff tsInputModePaletteIconFileKey @@ -45,6 +49,10 @@ TISIconIsTemplate + TISIntendedLanguage + en + tsInputModeIsVisibleKey + tsInputModeMenuIconFileKey input-en.tiff tsInputModePaletteIconFileKey @@ -70,6 +78,8 @@ stable TICapsLockLanguageSwitchCapable + TISIntendedLanguage + ko tsInputMethodCharacterRepertoireKey Hang diff --git a/Sources/PriTypeCore/Brand.swift b/Sources/PriTypeCore/Brand.swift index b7e24833..7f1c00ee 100644 --- a/Sources/PriTypeCore/Brand.swift +++ b/Sources/PriTypeCore/Brand.swift @@ -54,9 +54,13 @@ public enum Brand: Sendable { return officialConnectionName } - /// PatchType builds must not offer official PriType GitHub updates. + /// Local patch overlays skip official PriType GitHub updates. public static var tracksUpstreamUpdates: Bool { - bundleID == officialBundleID + guard bundleID == officialBundleID else { return false } + let channel = (Bundle.main.object(forInfoDictionaryKey: "PriTypeReleaseChannel") as? String ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return channel != "local" && channel != "patch" } public static func koreanModeID(forBundleID bundleID: String) -> String { diff --git a/Sources/PriTypeCore/InputSession.swift b/Sources/PriTypeCore/InputSession.swift index 5ed0f0df..ba4838dc 100644 --- a/Sources/PriTypeCore/InputSession.swift +++ b/Sources/PriTypeCore/InputSession.swift @@ -54,7 +54,11 @@ final class InputSession: @unchecked Sendable { self.client = client self.context = context self.composer = composer - self.adapter = TextDeliveryPolicy.makeAdapter(for: client, context: context) + self.adapter = TextDeliveryPolicy.makeAdapter( + for: client, + context: context, + hasLiveMarkedText: composer.hasActiveComposition + ) } deinit { @@ -90,7 +94,11 @@ final class InputSession: @unchecked Sendable { func ensureAdapterMatchesPolicy() { let resolved = TextDeliveryPolicy.mode(for: context) guard adapter.deliveryMode != resolved else { return } - adapter = TextDeliveryPolicy.makeAdapter(for: client, context: context) + adapter = TextDeliveryPolicy.makeAdapter( + for: client, + context: context, + hasLiveMarkedText: composer.hasActiveComposition + ) } // MARK: Duplicate keyDown suppression diff --git a/Sources/PriTypeCore/InputSourceManager.swift b/Sources/PriTypeCore/InputSourceManager.swift index 74835760..b81d1486 100644 --- a/Sources/PriTypeCore/InputSourceManager.swift +++ b/Sources/PriTypeCore/InputSourceManager.swift @@ -31,14 +31,20 @@ public final class InputSourceManager: @unchecked Sendable { /// Modes belonging to `activeBundleID`. cleanupStaleInputSources must NOT /// strip the English mode (it is a real registered mode, not a leftover). private static func currentInputModes(forBundleID bundleID: String) -> Set { - [ + let minted = Brand.mintedCollisionModeID(forBundleID: bundleID) + return [ Brand.koreanModeID(forBundleID: bundleID), - Brand.englishModeID(forBundleID: bundleID) + Brand.englishModeID(forBundleID: bundleID), + "\(minted).korean", + "\(minted).english" ] } /// Old Korean mode key was the bundle id itself. TIS then minted - /// `.` and Settings showed every row as the app name. + /// `.` (`…v2.v2`). Do **not** treat + /// `…v2.v2.korean` as legacy — that is the live TIS child id on machines + /// that still have the minted row. Rewriting it to `.korean` drops Hangul + /// from HIToolbox while the parent IM stays enabled. private static func legacyKoreanModeIds(forBundleID bundleID: String) -> Set { [ bundleID, diff --git a/Sources/PriTypeCore/PriTypeInputController.swift b/Sources/PriTypeCore/PriTypeInputController.swift index ea622155..00f0fc83 100644 --- a/Sources/PriTypeCore/PriTypeInputController.swift +++ b/Sources/PriTypeCore/PriTypeInputController.swift @@ -31,13 +31,29 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { // displayed as the app name. private static let priTypeKoreanInputModeID = Brand.koreanModeID private static let priTypeEnglishInputModeID = Brand.englishModeID - private static let legacyKoreanInputModeIDs: Set = [ - Brand.bundleID, - Brand.mintedCollisionModeID(forBundleID: Brand.bundleID), - Brand.officialBundleID, - Brand.officialKoreanModeID, - Brand.mintedCollisionModeID(forBundleID: Brand.officialBundleID) - ] + private static let legacyKoreanInputModeIDs: Set = { + let minted = Brand.mintedCollisionModeID(forBundleID: Brand.bundleID) + let officialMinted = Brand.mintedCollisionModeID(forBundleID: Brand.officialBundleID) + return [ + Brand.bundleID, + minted, + "\(minted).korean", + Brand.officialBundleID, + Brand.officialKoreanModeID, + officialMinted, + "\(officialMinted).korean" + ] + }() + private static let englishInputModeIDs: Set = { + let minted = Brand.mintedCollisionModeID(forBundleID: Brand.bundleID) + let officialMinted = Brand.mintedCollisionModeID(forBundleID: Brand.officialBundleID) + return [ + Brand.englishModeID, + "\(minted).english", + Brand.officialEnglishModeID, + "\(officialMinted).english" + ] + }() // MARK: - Shared State // @@ -301,13 +317,13 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { // Korean and English modes reaches the composer — synchronously, so the // next keyDown already sees the new mode (no first-key race). let targetMode: InputMode? - switch inputModeID { - case Self.priTypeEnglishInputModeID: + if Self.englishInputModeIDs.contains(inputModeID) { targetMode = .english - case Self.priTypeKoreanInputModeID: + } else if inputModeID == Self.priTypeKoreanInputModeID + || Self.legacyKoreanInputModeIDs.contains(inputModeID) { targetMode = .korean - default: - targetMode = Self.legacyKoreanInputModeIDs.contains(inputModeID) ? .korean : nil + } else { + targetMode = nil } DebugLogger.log("PriTypeInputController: setValue inputMode='\(inputModeID)' target=\(String(describing: targetMode)) current=\(composer.inputMode)") guard let targetMode else { diff --git a/Sources/PriTypeCore/TextDelivery.swift b/Sources/PriTypeCore/TextDelivery.swift index 08866b43..5ec534c3 100644 --- a/Sources/PriTypeCore/TextDelivery.swift +++ b/Sources/PriTypeCore/TextDelivery.swift @@ -35,7 +35,11 @@ enum TextDeliveryPolicy { return .markedText } - static func makeAdapter(for client: IMKTextInput, context: ClientContext) -> BaseClientAdapter { + static func makeAdapter( + for client: IMKTextInput, + context: ClientContext, + hasLiveMarkedText: Bool = false + ) -> BaseClientAdapter { switch mode(for: context) { case .immediate: return ImmediateModeAdapter(client: client, bundleId: context.bundleId) @@ -43,7 +47,11 @@ enum TextDeliveryPolicy { DebugLogger.log("TextDeliveryPolicy: DirectInsertionAdapter (experimental) for \(context.bundleId)") return DirectInsertionAdapter(client: client, bundleId: context.bundleId) case .markedText: - return MarkedTextAdapter(client: client, bundleId: context.bundleId) + let adapter = MarkedTextAdapter(client: client, bundleId: context.bundleId) + if hasLiveMarkedText { + adapter.assumeLiveMarkedText() + } + return adapter } } } @@ -192,6 +200,12 @@ enum MarkedTextReplacement { selectedRange.location < maxLocation else { return notFound } + // Empty-list placeholders are length 0 or 1. A real selection (a word) + // must still be replaced via NSNotFound, or the first jamo inserts in + // front of the selected text in Chrome/Safari/Slack. + if selectedRange.length > 1 { + return notFound + } return NSRange(location: selectedRange.location, length: 0) } } @@ -203,8 +217,14 @@ final class MarkedTextAdapter: BaseClientAdapter { /// True after we have sent a non-empty marked string that has not yet been /// committed or cleared. Used instead of `client.markedRange()` because /// Chromium often reports NSNotFound for markedRange even during preedit. + /// Must survive adapter rebuild / activateServer: Electron often recreates + /// the IMK session mid-syllable. private var hasLiveMarkedText = false + func assumeLiveMarkedText() { + hasLiveMarkedText = true + } + override func insertText(_ text: String) { hasLiveMarkedText = false super.insertText(text) diff --git a/Tests/PriTypeCoreTests/InputSourceManagerTests.swift b/Tests/PriTypeCoreTests/InputSourceManagerTests.swift index dd20e5eb..8efb26cc 100644 --- a/Tests/PriTypeCoreTests/InputSourceManagerTests.swift +++ b/Tests/PriTypeCoreTests/InputSourceManagerTests.swift @@ -187,4 +187,29 @@ struct InputSourceManagerTests { #expect(sanitized.contains { ($0["Input Mode"] as? String) == Brand.patchKoreanModeID }) #expect(sanitized.contains { ($0["Bundle ID"] as? String) == "com.apple.inputmethod.Korean" }) } + + @Test("Live minted Korean child id is kept, not rewritten to .korean") + func keepsMintedV2KoreanChildId() { + let mintedKorean = Brand.mintedCollisionModeID(forBundleID: Brand.officialBundleID) + ".korean" + let sources: [[String: Any]] = [ + [ + "Bundle ID": Brand.officialBundleID, + "InputSourceKind": "Keyboard Input Method" + ], + [ + "Bundle ID": Brand.officialBundleID, + "InputSourceKind": "Input Mode", + "Input Mode": mintedKorean + ] + ] + let sanitized = InputSourceManager.sanitizedInputSources( + sources, + removeAppleKoreanInputModes: false, + allowsPriTypeParentEntry: true, + activeBundleID: Brand.officialBundleID + ) + #expect(sanitized.count == 2) + #expect(sanitized.contains { ($0["Input Mode"] as? String) == mintedKorean }) + #expect(!sanitized.contains { ($0["Input Mode"] as? String) == Brand.officialKoreanModeID }) + } } diff --git a/Tests/PriTypeCoreTests/RegistrationContractTests.swift b/Tests/PriTypeCoreTests/RegistrationContractTests.swift index 126f78c0..e2ff6592 100644 --- a/Tests/PriTypeCoreTests/RegistrationContractTests.swift +++ b/Tests/PriTypeCoreTests/RegistrationContractTests.swift @@ -73,7 +73,9 @@ struct RegistrationContractTests { @Test("Forbidden registration keys are absent (regression guard)") func noForbiddenKeys() throws { let info = try loadInfoPlist() - // The 030a035 regression: a top-level TISInputSourceID equal to a child mode id. + // 030a035 broke composition by setting top-level TISInputSourceID equal to a + // *child mode* id. Do not put TISInputSourceID at the top level at all — + // even parent-id == bundle id makes TIS mint `.v2.v2.korean`. #expect(info["TISInputSourceID"] == nil, "top-level TISInputSourceID must NOT be present") for (id, value) in modes(info) { @@ -83,6 +85,18 @@ struct RegistrationContractTests { } } + @Test("Declares Korean as the intended language so Settings lists it under 한글") + func intendedLanguageKorean() throws { + let info = try loadInfoPlist() + #expect(info["TISIntendedLanguage"] as? String == "ko") + let korean = modes(info)["com.pritype.inputmethod.v2.korean"] as? [String: Any] + let english = modes(info)["com.pritype.inputmethod.v2.english"] as? [String: Any] + #expect(korean?["TISIntendedLanguage"] as? String == "ko") + #expect(korean?["tsInputModeIsVisibleKey"] as? Bool == true) + #expect(english?["TISIntendedLanguage"] as? String == "en") + #expect(english?["tsInputModeIsVisibleKey"] as? Bool == false) + } + @Test("Core identity keys are correct") func coreIdentity() throws { let info = try loadInfoPlist() diff --git a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift index fbb107dd..c5385ac7 100644 --- a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift +++ b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift @@ -204,6 +204,26 @@ struct MarkedTextReplacementTests { ) == NSRange(location: 12, length: 0)) } + @Test("First mark on a web host with a real selection still replaces it") + func firstWebMarkRealSelectionReplaces() { + #expect(MarkedTextReplacement.range( + isClearing: false, + hasLiveMarkedText: false, + selectedRange: NSRange(location: 4, length: 5), + prefersCollapsedStart: true + ) == notFound) + } + + @Test("Composer-active / rebuilt adapter still uses NSNotFound") + func rebuiltAdapterWithLiveCompositionUsesNotFound() { + #expect(MarkedTextReplacement.range( + isClearing: false, + hasLiveMarkedText: true, + selectedRange: collapsedCaret, + prefersCollapsedStart: true + ) == notFound) + } + @Test("Invalid or Chromium-garbage selection falls back to NSNotFound") func garbageSelectionFallsBack() { #expect(MarkedTextReplacement.range( diff --git a/install.sh b/install.sh index 013314b9..748ecd7e 100755 --- a/install.sh +++ b/install.sh @@ -6,11 +6,11 @@ usage() { Usage: ./install.sh [--as-patchtype] (default) Install as PriTypeV2 using the source-tree identity. - --as-patchtype Install as PatchType / 패치타입 with a distinct bundle id - and version 2.7.4-patch.1 so it cannot be confused with - official PriType 2.7.4. Copies to /Library/Input Methods - only (never also ~/Library). A new bundle id usually - needs a logout before it appears in Input Sources. + --as-patchtype Install a local overlay named PatchType / 패치타입, + version 2.7.4-patch.1. Keeps the official bundle id + (com.pritype.inputmethod.v2) so Gatekeeper/TIS will list + it under 한글. Copies to /Library/Input Methods/PriTypeV2.app + only. Log out after install, then add 패치 한글. EOF } @@ -28,8 +28,10 @@ for arg in "$@"; do done if [ "$AS_PATCHTYPE" -eq 1 ]; then - APP_NAME="PatchType" - EXEC_NAME="PatchType" + # Same path + bundle id as the original notarized PriType. A new ad-hoc + # bundle id is rejected by Gatekeeper and never appears in Settings. + APP_NAME="PriTypeV2" + EXEC_NAME="PriTypeV2" SYSTEM_ONLY=1 else APP_NAME="PriTypeV2" @@ -55,27 +57,29 @@ import plistlib import sys path = sys.argv[1] -old = "com.pritype.inputmethod.v2" -new = "com.calvinjkim.patchtype" - with open(path, "rb") as handle: info = plistlib.load(handle) -modes = info.get("ComponentInputModeDict", {}).get("tsInputModeListKey", {}) -rewritten = {} -for key, value in modes.items(): - rewritten[key.replace(old, new)] = value -info.setdefault("ComponentInputModeDict", {})["tsInputModeListKey"] = rewritten -info["ComponentInputModeDict"]["tsVisibleInputModeOrderedArrayKey"] = [f"{new}.korean"] - info["CFBundleDisplayName"] = "PatchType" info["CFBundleName"] = "PatchType" -info["CFBundleExecutable"] = "PatchType" -info["CFBundleIdentifier"] = new info["CFBundleShortVersionString"] = "2.7.4-patch.1" info["CFBundleVersion"] = "51" -info["InputMethodConnectionName"] = "PatchType_InputString" info["PriTypeReleaseChannel"] = "local" +info["TISIntendedLanguage"] = "ko" + +# Never change the official bundle id. A new ad-hoc id is Gatekeeper-rejected +# and install then wiping PriTypeV2.app leaves the user with no IME. +bundle_id = info.get("CFBundleIdentifier") +if bundle_id != "com.pritype.inputmethod.v2": + raise SystemExit(f"refusing to install overlay with bundle id {bundle_id!r}") + +modes = info.get("ComponentInputModeDict", {}).get("tsInputModeListKey", {}) +if "com.pritype.inputmethod.v2.korean" in modes: + modes["com.pritype.inputmethod.v2.korean"]["TISIntendedLanguage"] = "ko" + modes["com.pritype.inputmethod.v2.korean"]["tsInputModeIsVisibleKey"] = True +if "com.pritype.inputmethod.v2.english" in modes: + modes["com.pritype.inputmethod.v2.english"]["TISIntendedLanguage"] = "en" + modes["com.pritype.inputmethod.v2.english"]["tsInputModeIsVisibleKey"] = False with open(path, "wb") as handle: plistlib.dump(info, handle, sort_keys=False) @@ -86,27 +90,28 @@ PY /* Localized versions of Info.plist keys */ "CFBundleName" = "PatchType"; "CFBundleDisplayName" = "PatchType"; -"com.calvinjkim.patchtype" = "PatchType"; -"com.calvinjkim.patchtype.korean" = "Patch Korean"; -"com.calvinjkim.patchtype.english" = "Patch English"; +"com.pritype.inputmethod.v2" = "PatchType"; +"com.pritype.inputmethod.v2.korean" = "Patch Korean"; +"com.pritype.inputmethod.v2.english" = "Patch English"; EOF cat > "$RESOURCES_DIR/ko.lproj/InfoPlist.strings" <<'EOF' /* Localized versions of Info.plist keys */ "CFBundleName" = "패치타입"; "CFBundleDisplayName" = "패치타입"; -"com.calvinjkim.patchtype" = "패치타입"; -"com.calvinjkim.patchtype.korean" = "패치 한글"; -"com.calvinjkim.patchtype.english" = "패치 영어"; +"com.pritype.inputmethod.v2" = "패치타입"; +"com.pritype.inputmethod.v2.korean" = "패치 한글"; +"com.pritype.inputmethod.v2.english" = "패치 영어"; EOF } copy_to_system() { local src="$1" local dest="$2" + # Never delete $dest after copying into it. Remove only leftover names. if sudo -n true 2>/dev/null; then sudo ditto "$src" "$dest" sudo rm -rf \ - "/Library/Input Methods/PriTypeV2.app" \ + "/Library/Input Methods/PatchType.app" \ "/Library/Input Methods/PriType.app" \ "/tmp/PriTypeV2.app.disabled" return 0 @@ -118,7 +123,7 @@ copy_to_system() { on run argv set src to item 1 of argv set dest to item 2 of argv - do shell script "ditto " & quoted form of src & " " & quoted form of dest & " && rm -rf '/Library/Input Methods/PriTypeV2.app' '/Library/Input Methods/PriType.app' '/tmp/PriTypeV2.app.disabled'" with administrator privileges + do shell script "ditto " & quoted form of src & " " & quoted form of dest & " && rm -rf '/Library/Input Methods/PatchType.app' '/Library/Input Methods/PriType.app' '/tmp/PriTypeV2.app.disabled'" with administrator privileges end run APPLESCRIPT } @@ -209,12 +214,12 @@ killall TextInputMenuAgent TextInputSwitcher keyboardservicesd imklaunchagent Pr echo "Installation complete: $INSTALL_PATH" if [ "$AS_PATCHTYPE" -eq 1 ]; then - echo "Name: PatchType / 패치타입" + echo "Name: PatchType / 패치타입 (shown in Settings; bundle is still PriType)" echo "Version: 2.7.4-patch.1 (local patch) — not official PriType 2.7.4" - echo "Bundle ID: com.calvinjkim.patchtype" - echo "Log out and back in, then enable '패치 한글' (Patch Korean) under" - echo "System Settings > Keyboard > Input Sources. Do not add every row" - echo "from '모든 입력 소스'. Apple 2-Set Korean can stay as a fallback." + echo "Bundle ID: com.pritype.inputmethod.v2" + echo "Log out and back in, then add 한국어 → 패치 한글 (Patch Korean)." + echo "Do not add every row from '모든 입력 소스'." + echo "Apple 2-Set Korean can stay as a fallback." else echo "Please log out and log back in, or restart your computer." echo "Then enable '$APP_NAME' in System Settings > Keyboard > Input Sources." From e9cdb475d7e3f2172ff35da658b8086a863b84e2 Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 10:14:07 +0900 Subject: [PATCH 04/12] Compose with direct insertion in Safari so Confluence keeps one block. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marked-text path ends a composition on every Hangul syllable. In a ProseMirror editor (Confluence) an empty block reconciles that composition end into two blocks, so the first syllable of a line was orphaned and the rest restarted in a new block: 라이브 arrived as 랑 + newline + 라이브. On-device logs showed the host caret drifting +2 at that commit, and the same input was clean in Chromium and under Apple's own Korean IME. Exempt WebKit browsers from the direct-insertion denial. That denial's evidence was gathered on Electron/Chromium, which report selection asynchronously; WebKit does not. Direct insertion never ends a composition, so ProseMirror has nothing to reconcile. Safari stays a web-content host, Blink and Gecko stay denied, and the adapter's caret guard still degrades to marked text when the host reports an unusable selection. Co-Authored-By: Claude Opus 5 --- .../PriTypeCore/ClientContextDetector.swift | 14 ++++++- .../DirectInsertionTests.swift | 10 ++++- .../TextDeliveryPolicyTests.swift | 42 +++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/Sources/PriTypeCore/ClientContextDetector.swift b/Sources/PriTypeCore/ClientContextDetector.swift index 59e7cc92..27ac57d2 100644 --- a/Sources/PriTypeCore/ClientContextDetector.swift +++ b/Sources/PriTypeCore/ClientContextDetector.swift @@ -113,6 +113,17 @@ public enum ClientCompatibilityPolicy { "com.nousresearch.hermes.setup" ] + /// WebKit browsers: on the denylist as web-content hosts, but exempt from the + /// direct-insertion denial. That denial's evidence ("every keystroke tripped the + /// caret-stability guard") was gathered on Electron/Chromium; WebKit reports a + /// usable selection. It matters because the marked-text path ends a composition + /// on every Hangul syllable, and ProseMirror (Confluence) reconciles an empty + /// block into two on that composition end. Direct insertion never ends one. + private static let webKitDirectInsertionAllowed: Set = [ + "com.apple.Safari", + "com.apple.SafariTechnologyPreview" + ] + /// Apps where experimental direct insertion is known to be IMPOSSIBLE, not just /// risky: Electron/Chromium and browser web-content fields report `selectedRange` /// and `attributedSubstring` asynchronously / inaccurately, so the in-place @@ -158,7 +169,7 @@ public enum ClientCompatibilityPolicy { /// Chromium has incorporated IMK marked text, dropping only the final Hangul /// syllable. Prefer real-text composition there when document access is usable. public static func prefersDirectInsertionForComposition(bundleId: String) -> Bool { - hermesBundleIds.contains(bundleId) + hermesBundleIds.contains(bundleId) || webKitDirectInsertionAllowed.contains(bundleId) } /// Whether `bundleId` is a browser / Electron / CEF / Atlassian web editor. @@ -179,6 +190,7 @@ public enum ClientCompatibilityPolicy { /// reliably support in-place real-text rewrites (Electron/Chromium/browsers). /// Explicit list + a keyword heuristic for unlisted Electron/Chromium wrappers. public static func directInsertionDenied(bundleId: String) -> Bool { + if webKitDirectInsertionAllowed.contains(bundleId) { return false } if directInsertionDenylist.contains(bundleId) { return true } let lower = bundleId.lowercased() return lower.contains("electron") diff --git a/Tests/PriTypeCoreTests/DirectInsertionTests.swift b/Tests/PriTypeCoreTests/DirectInsertionTests.swift index 4d21a693..e5e50d8a 100644 --- a/Tests/PriTypeCoreTests/DirectInsertionTests.swift +++ b/Tests/PriTypeCoreTests/DirectInsertionTests.swift @@ -122,13 +122,19 @@ struct DirectInsertionDenylistTests { "com.microsoft.VSCode", "com.tinyspeck.slackmacgap", "com.google.Chrome", - "org.mozilla.firefox", - "com.apple.Safari" + "org.mozilla.firefox" ] { #expect(ClientCompatibilityPolicy.directInsertionDenied(bundleId: id), "should deny \(id)") } } + @Test("WebKit browsers are exempt — the denial's evidence was Electron/Chromium") + func webKitExempt() { + for id in ["com.apple.Safari", "com.apple.SafariTechnologyPreview"] { + #expect(!ClientCompatibilityPolicy.directInsertionDenied(bundleId: id), "should allow \(id)") + } + } + @Test("Keyword heuristic catches unlisted Electron/Chromium wrappers") func keywordHeuristic() { #expect(ClientCompatibilityPolicy.directInsertionDenied(bundleId: "com.example.MyElectronApp")) diff --git a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift index c5385ac7..9c626c5d 100644 --- a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift +++ b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift @@ -240,3 +240,45 @@ struct MarkedTextReplacementTests { ) == notFound) } } + +// MARK: - Safari direct insertion + +/// The denylist's evidence ("every keystroke tripped the caret-stability guard") +/// was gathered on Electron/Chromium. WebKit reports a usable selection, and +/// Apple's own Korean IME composes cleanly in Confluence where PriType's +/// per-syllable marked-text commit splits the ProseMirror block. Direct insertion +/// never ends a composition, so there is nothing for ProseMirror to reconcile. +/// The adapter still bails to marked text at runtime if the caret misbehaves. +@Suite("SafariDirectInsertion") +struct SafariDirectInsertionTests { + private func context(_ bundleId: String, documentAccessSafe: Bool) -> ClientContext { + ClientContext( + bundleId: bundleId, + hasTextInputCapability: true, + isLikelyDesktopArea: false, + documentAccessSafe: documentAccessSafe + ) + } + + @Test("Safari with usable document access gets direct insertion") + func safariGetsDirectInsertion() { + #expect(TextDeliveryPolicy.mode(for: context("com.apple.Safari", documentAccessSafe: true)) == .directInsertion) + } + + @Test("Safari without usable document access stays on marked text") + func safariFallsBackWithoutDocumentAccess() { + #expect(TextDeliveryPolicy.mode(for: context("com.apple.Safari", documentAccessSafe: false)) == .markedText) + } + + @Test("Blink hosts stay denied — the denylist evidence was gathered there") + func blinkStaysDenied() { + for id in ["com.google.Chrome", "com.anthropic.claudefordesktop", "com.microsoft.VSCode"] { + #expect(TextDeliveryPolicy.mode(for: context(id, documentAccessSafe: true)) == .markedText, "\(id) must stay on marked text") + } + } + + @Test("Safari remains a web content host") + func safariStaysWebContentHost() { + #expect(ClientCompatibilityPolicy.isWebContentHost(bundleId: "com.apple.Safari")) + } +} From 90f0897269b3fb5b9a4742458f4c50bfddd3cac0 Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 10:31:50 +0900 Subject: [PATCH 05/12] Stop emitting conjoining jamo where composition is direct insertion. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw U+1100 jamo exists to keep a MARKED composition open in web editors past the first choseong. A host composing by direct insertion has no composition: the jamo is written into the document as real text and survives the rewrite. Typing 사파이어메모 inside existing text in Safari left a bare ᄉ (U+1109) at the caret. Gate it on the delivery mode rather than on being a web host. Chromium, Electron and Atlassian wrappers still compose via marked text and keep it. Co-Authored-By: Claude Opus 5 --- .../PriTypeCore/ClientContextDetector.swift | 6 ++++ .../PriTypeCoreTests/ClientContextTests.swift | 4 ++- .../TextDeliveryPolicyTests.swift | 32 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Sources/PriTypeCore/ClientContextDetector.swift b/Sources/PriTypeCore/ClientContextDetector.swift index 27ac57d2..6005685a 100644 --- a/Sources/PriTypeCore/ClientContextDetector.swift +++ b/Sources/PriTypeCore/ClientContextDetector.swift @@ -204,8 +204,14 @@ public enum ClientCompatibilityPolicy { /// the same syllable in the next item (`- ㄱ` / `- 감사합니다.`). Keep the /// engine's choseong jamo (U+1100) so the host leaves composition open. /// Native AppKit hosts keep compatibility jamo for display. + /// + /// Marked text only. A host that composes by direct insertion has no open + /// composition to protect: the conjoining jamo is written as REAL text and is + /// left behind when the syllable is rewritten (typing 사파이어메모 inside + /// existing text stranded a bare `ᄉ` U+1109 in Safari). public static func prefersRawJamoPreedit(bundleId: String) -> Bool { isWebContentHost(bundleId: bundleId) + && !prefersDirectInsertionForComposition(bundleId: bundleId) } /// Web editors' empty list items often have a non-collapsed / placeholder diff --git a/Tests/PriTypeCoreTests/ClientContextTests.swift b/Tests/PriTypeCoreTests/ClientContextTests.swift index afaf5cf9..d3c912d5 100644 --- a/Tests/PriTypeCoreTests/ClientContextTests.swift +++ b/Tests/PriTypeCoreTests/ClientContextTests.swift @@ -157,7 +157,9 @@ struct ClientContextTests { "com.example.MyElectronApp" ] { #expect(ClientCompatibilityPolicy.isWebContentHost(bundleId: id), "\(id) should be a web content host") - #expect(ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id), "\(id) should keep raw jamo") + // Raw jamo depends on the delivery mode, not on being a web host: + // direct-insertion hosts would strand it as real text. + // See RawJamoNeedsMarkedText. #expect(ClientCompatibilityPolicy.prefersCollapsedCompositionReplacement(bundleId: id), "\(id) should start composition without replacing host selection") } } diff --git a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift index 9c626c5d..9efa3edc 100644 --- a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift +++ b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift @@ -282,3 +282,35 @@ struct SafariDirectInsertionTests { #expect(ClientCompatibilityPolicy.isWebContentHost(bundleId: "com.apple.Safari")) } } + +// MARK: - Raw jamo is a marked-text-only workaround + +/// Conjoining U+1100 jamo exists so a web editor keeps the MARKED composition +/// open past the first choseong. A host composing by direct insertion has no +/// composition: the jamo is written as real text and stays there. Typing +/// 사파이어메모 inside existing text left a bare `ᄉ` (U+1109) in the document. +@Suite("RawJamoNeedsMarkedText") +struct RawJamoNeedsMarkedTextTests { + @Test("Direct-insertion hosts must not emit conjoining jamo") + func directInsertionHostsUseDisplayJamo() { + for id in ["com.apple.Safari", "com.apple.SafariTechnologyPreview"] { + #expect(!ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id), + "\(id) composes by direct insertion; conjoining jamo would be stranded as real text") + } + } + + @Test("Marked-text web hosts keep raw jamo") + func markedTextWebHostsKeepRawJamo() { + for id in ["com.google.Chrome", "com.anthropic.claudefordesktop", "com.atlassian.confluence"] { + #expect(ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id), + "\(id) still composes via marked text and needs the jamo") + } + } + + @Test("Native hosts keep compatibility jamo") + func nativeHostsUnchanged() { + for id in ["com.kakao.KakaoTalkMac", "com.apple.TextEdit"] { + #expect(!ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id)) + } + } +} From 20d7eb5b7b285704d6dbe01bc5d46a833b751f65 Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 10:31:50 +0900 Subject: [PATCH 06/12] Treat a non-collapsed selection as unusable for a direct-insertion rewrite. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter read only selectedRange().location and discarded the length. Safari reports a whole-field range such as {0, 42} during composition, which read as "caret at 0", so the in-place rewrite landed at the start of the field: typing 글자밀어넣기 mid-text duplicated 기 at position 0. A composition's selection is always collapsed, so a non-zero length means the host handed us a range and not a caret. Bail to marked text there, the same degradation the adapter already uses for an unreadable selection. Co-Authored-By: Claude Opus 5 --- .../PriTypeCore/DirectInsertionPlanner.swift | 6 ++++ Sources/PriTypeCore/TextDelivery.swift | 4 ++- .../DirectInsertionTests.swift | 35 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/Sources/PriTypeCore/DirectInsertionPlanner.swift b/Sources/PriTypeCore/DirectInsertionPlanner.swift index 191b9a49..d5c6e890 100644 --- a/Sources/PriTypeCore/DirectInsertionPlanner.swift +++ b/Sources/PriTypeCore/DirectInsertionPlanner.swift @@ -89,8 +89,13 @@ enum DirectInsertionPlanner { /// - keepingLive: true when the replacement text is itself a (new) live preedit /// (`setMarkedText`); false when it is a finalized commit (`insertText`) that /// becomes permanent and therefore tracks length 0. + /// - selectionLength: `client.selectedRange().length`. A composition's selection + /// is always collapsed; a non-zero length means the host handed us a range, not + /// a caret (Safari reports `{0, 42}` for a whole-field selection), and using its + /// location writes at the start of the field. static func plan( cursorLocation: Int, + selectionLength: Int = 0, livePreeditLength: Int, textUTF16Count: Int, keepingLive: Bool @@ -98,6 +103,7 @@ enum DirectInsertionPlanner { let newLive = keepingLive ? textUTF16Count : 0 let safe = cursorLocation != NSNotFound + && selectionLength == 0 && cursorLocation < maxReasonableLocation && cursorLocation >= livePreeditLength diff --git a/Sources/PriTypeCore/TextDelivery.swift b/Sources/PriTypeCore/TextDelivery.swift index 5ec534c3..9b0e49b4 100644 --- a/Sources/PriTypeCore/TextDelivery.swift +++ b/Sources/PriTypeCore/TextDelivery.swift @@ -333,7 +333,8 @@ final class DirectInsertionAdapter: BaseClientAdapter { } let tStart = CFAbsoluteTimeGetCurrent() - let caret = client.selectedRange().location + let selection = client.selectedRange() + let caret = selection.location let tAfterSel = CFAbsoluteTimeGetCurrent() var readbackMs = 0.0 @@ -373,6 +374,7 @@ final class DirectInsertionAdapter: BaseClientAdapter { let plan = DirectInsertionPlanner.plan( cursorLocation: caret, + selectionLength: selection.length, livePreeditLength: livePreeditLength, textUTF16Count: text.utf16.count, keepingLive: keepingLive diff --git a/Tests/PriTypeCoreTests/DirectInsertionTests.swift b/Tests/PriTypeCoreTests/DirectInsertionTests.swift index e5e50d8a..a4f873a1 100644 --- a/Tests/PriTypeCoreTests/DirectInsertionTests.swift +++ b/Tests/PriTypeCoreTests/DirectInsertionTests.swift @@ -326,3 +326,38 @@ struct DirectInsertionEndToEndTests { #expect(client.document == "가 나", "Got '\(client.document)'") } } + +// MARK: - Non-collapsed selection is not a caret + +/// The adapter used only `selectedRange().location`. When Safari reports a +/// non-collapsed range such as `{0, 42}` that reads as "caret at 0", so the +/// rewrite landed at the start of the field and duplicated the syllable there +/// (typing 글자밀어넣기 mid-text left a stray 기 at position 0). During a +/// composition the selection must be collapsed; anything else is unusable. +@Suite("DirectInsertion selection must be collapsed") +struct DirectInsertionSelectionTests { + @Test("A non-collapsed selection bails instead of writing at its location") + func nonCollapsedBails() { + let plan = DirectInsertionPlanner.plan( + cursorLocation: 0, + selectionLength: 42, + livePreeditLength: 1, + textUTF16Count: 1, + keepingLive: false + ) + #expect(plan.bailed, "a 42-long selection is not a caret at 0") + } + + @Test("A collapsed selection still plans the rewrite") + func collapsedPlansNormally() { + let plan = DirectInsertionPlanner.plan( + cursorLocation: 12, + selectionLength: 0, + livePreeditLength: 1, + textUTF16Count: 1, + keepingLive: false + ) + #expect(!plan.bailed) + #expect(plan.replaceRange == NSRange(location: 11, length: 1)) + } +} From 606e66675b65e5c62a9d57e9705dd80f7de31760 Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 11:40:57 +0900 Subject: [PATCH 07/12] Swap the installed bundle instead of merging into it, and stop the second relaunch. ditto merges: a file the build no longer produces stays in the installed bundle forever. PriType_PriTypeCore.bundle moved to a Contents/ layout and the old flat copy survived, so codesign reported "a sealed resource is missing or invalid". A broken seal voids the TCC match against the signing identity, which is what makes an Accessibility grant survive a rebuild. Stage beside the destination and rename, so it holds exactly the new bundle and is missing only for the rename rather than for the whole copy. Drop the IME from the second killall. It was already killed above; killing it again makes macOS relaunch it twice, and each launch without a grant opens its own Settings pane, so installing asked for Accessibility two times. Add --debug, which builds and installs the debug configuration. DebugLogger is compiled out of release builds, so a release install records nothing when an input bug needs tracing. Co-Authored-By: Claude Opus 5 --- install.sh | 58 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/install.sh b/install.sh index 748ecd7e..625c1031 100755 --- a/install.sh +++ b/install.sh @@ -15,9 +15,11 @@ EOF } AS_PATCHTYPE=0 +BUILD_CONFIG=release for arg in "$@"; do case "$arg" in --as-patchtype) AS_PATCHTYPE=1 ;; + --debug) BUILD_CONFIG=debug ;; -h|--help) usage; exit 0 ;; *) echo "Unknown argument: $arg" >&2 @@ -39,7 +41,7 @@ else SYSTEM_ONLY=0 fi -BUILD_DIR=".build/release" +BUILD_DIR=".build/$BUILD_CONFIG" STAGE_DIR=".build/ime-bundle" APP_BUNDLE="${STAGE_DIR}/${APP_NAME}.app" CONTENTS_DIR="${APP_BUNDLE}/Contents" @@ -104,32 +106,57 @@ EOF EOF } +# Stage beside the destination and swap, rather than ditto-ing into the live +# bundle. ditto MERGES: a file the build no longer produces stays behind forever. +# That happened when PriType_PriTypeCore.bundle moved to a Contents/ layout — the +# flat copy survived, so codesign reported "a sealed resource is missing or +# invalid" and the signature no longer validated. A broken seal voids the TCC +# match for the signing identity, which is what makes the Accessibility grant +# stick across rebuilds. Swapping keeps $dest exactly the new bundle, and it is +# absent only for the rename rather than for a whole copy. copy_to_system() { local src="$1" local dest="$2" - # Never delete $dest after copying into it. Remove only leftover names. + local helper + helper=$(mktemp "${TMPDIR:-/tmp}/pritype-install.XXXXXX") + cat > "$helper" <<'SH' +set -e +src="$1" +dest="$2" +rm -rf "$dest.new" "$dest.old" +ditto "$src" "$dest.new" +if [ -d "$dest" ]; then + mv "$dest" "$dest.old" +fi +mv "$dest.new" "$dest" +rm -rf "$dest.old" +# Leftover names from older installs. +rm -rf "/Library/Input Methods/PatchType.app" \ + "/Library/Input Methods/PriType.app" \ + "/tmp/PriTypeV2.app.disabled" +SH + if sudo -n true 2>/dev/null; then - sudo ditto "$src" "$dest" - sudo rm -rf \ - "/Library/Input Methods/PatchType.app" \ - "/Library/Input Methods/PriType.app" \ - "/tmp/PriTypeV2.app.disabled" + sudo /bin/bash "$helper" "$src" "$dest" + rm -f "$helper" return 0 fi echo "Administrator access is required to install into /Library/Input Methods." echo "A password dialog may appear." - osascript - "$src" "$dest" <<'APPLESCRIPT' + osascript - "$helper" "$src" "$dest" <<'APPLESCRIPT' on run argv - set src to item 1 of argv - set dest to item 2 of argv - do shell script "ditto " & quoted form of src & " " & quoted form of dest & " && rm -rf '/Library/Input Methods/PatchType.app' '/Library/Input Methods/PriType.app' '/tmp/PriTypeV2.app.disabled'" with administrator privileges + set helper to item 1 of argv + set src to item 2 of argv + set dest to item 3 of argv + do shell script "/bin/bash " & quoted form of helper & " " & quoted form of src & " " & quoted form of dest with administrator privileges end run APPLESCRIPT + rm -f "$helper" } -echo "Building release..." -swift build -c release --product PriType +echo "Building $BUILD_CONFIG..." +swift build -c "$BUILD_CONFIG" --product PriType echo "Creating bundle structure at $APP_BUNDLE..." rm -rf "$STAGE_DIR" @@ -210,7 +237,10 @@ else fi xattr -cr "$INSTALL_PATH" 2>/dev/null || true -killall TextInputMenuAgent TextInputSwitcher keyboardservicesd imklaunchagent PriTypeV2 PatchType 2>/dev/null || true +# System text-input agents only. The IME itself was killed above; killing it again +# makes macOS relaunch it a second time, and each launch with no Accessibility +# grant opens another Settings pane (main.swift requests it on startup). +killall TextInputMenuAgent TextInputSwitcher keyboardservicesd imklaunchagent 2>/dev/null || true echo "Installation complete: $INSTALL_PATH" if [ "$AS_PATCHTYPE" -eq 1 ]; then From 3f778598cd5b8faf36d79ecc0f82cf88010cfbf9 Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 12:03:41 +0900 Subject: [PATCH 08/12] Read the bound modifier from its own key, and refuse a binding that steals one. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modifierMask maps the left and right key of a modifier to the same CGEventFlags bit, so releasing the bound key while the other side is held never cleared toggleModifierIsDown: hold Left Command, tap Right Command, release both, and every later key event lost its Command — Cmd+C inserted a literal "c" until the toggle key was pressed again. Read press and release from the device-dependent NX_DEVICE*KEYMASK bits, which name one physical key, and strip both bits when passing a key through. The hanja binding had the same defect. The recorder also accepted a bare printable key. Since the tap consumes the bound key system-wide, binding Space stopped the space bar everywhere including in the settings window, so nothing short of `defaults write` could undo it. Reject those at capture time with a visible reason, and repair an already-saved one on read: a user who bound Space cannot type a space to fix it. A modifier key alone, a regular key with a modifier, and a bare function key all still work. Co-Authored-By: Claude Opus 5 --- .../PriTypeCore/ConfigurationManager.swift | 34 +++++++++- Sources/PriTypeCore/L10n.swift | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../PriTypeCore/RightCommandSuppressor.swift | 44 +++++++++--- .../SettingsWindowController.swift | 16 ++++- .../KeyBindingSafetyTests.swift | 67 +++++++++++++++++++ .../PriTypeCoreTests/ModifierMaskTests.swift | 58 ++++++++++++++++ install.sh | 22 +++--- 9 files changed, 219 insertions(+), 25 deletions(-) create mode 100644 Tests/PriTypeCoreTests/KeyBindingSafetyTests.swift create mode 100644 Tests/PriTypeCoreTests/ModifierMaskTests.swift diff --git a/Sources/PriTypeCore/ConfigurationManager.swift b/Sources/PriTypeCore/ConfigurationManager.swift index c799a5c6..3fe2edcd 100644 --- a/Sources/PriTypeCore/ConfigurationManager.swift +++ b/Sources/PriTypeCore/ConfigurationManager.swift @@ -60,6 +60,36 @@ public struct KeyBinding: Codable, Equatable, Sendable { modifiers == 0 } + /// Key codes that are safe to bind on their own. The event tap consumes the + /// bound key globally, so a bare printable or essential key (Space, Return, + /// a letter) disappears from the whole system — including from this app's own + /// settings window, leaving `defaults write` as the only way back. Function + /// keys type nothing, so binding one alone is fine. + private static let bareBindableKeyCodes: Set = [ + 122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111, // F1-F12 + 105, 107, 113, 106, 64, 79, 80, 90 // F13-F20 + ] + + /// Repair a persisted binding that would steal a key the user needs. Older + /// builds saved whatever the recorder captured, so an existing install can + /// hold one — and its owner cannot type that key to fix it. + public static func sanitizedToggle(_ decoded: KeyBinding) -> KeyBinding { + decoded.isSafeAsBinding ? decoded : .defaultToggle + } + + public static func sanitizedHanja(_ decoded: KeyBinding) -> KeyBinding { + decoded.isSafeAsBinding ? decoded : .defaultHanja + } + + /// Whether this binding can be installed without stealing a key the user needs. + public var isSafeAsBinding: Bool { + // Caps Lock and Fn are not deliverable as PriType toggles. + if keyCode == 57 || keyCode == 63 { return false } + if isModifierKey { return true } + if modifiers != 0 { return true } + return Self.bareBindableKeyCodes.contains(keyCode) + } + /// Whether the bound key is a modifier key (Command, Option, Control, Shift, CapsLock) /// Modifier keys generate `flagsChanged` events; regular keys generate `keyDown` events. public var isModifierKey: Bool { @@ -420,7 +450,7 @@ public final class ConfigurationManager: ConfigurationProviding, @unchecked Send if let data = defaults.data(forKey: Keys.toggleKeyBinding), let decoded = try? JSONDecoder().decode(KeyBinding.self, from: data) { // Fn and Caps Lock are not supported as PriType custom toggle keys. - binding = (decoded.keyCode == 63 || decoded.keyCode == 57) ? .defaultToggle : decoded + binding = KeyBinding.sanitizedToggle(decoded) } else { // Migrate from legacy toggleKey binding = toggleKey.asKeyBinding @@ -454,7 +484,7 @@ public final class ConfigurationManager: ConfigurationProviding, @unchecked Send if let data = defaults.data(forKey: Keys.hanjaKeyBinding), let decoded = try? JSONDecoder().decode(KeyBinding.self, from: data) { // Sanitize: Fn key (63) is not supported in CGEventTap - binding = decoded.keyCode == 63 ? .defaultHanja : decoded + binding = KeyBinding.sanitizedHanja(decoded) } else { binding = .defaultHanja } diff --git a/Sources/PriTypeCore/L10n.swift b/Sources/PriTypeCore/L10n.swift index 37dc261b..e6f55a8e 100644 --- a/Sources/PriTypeCore/L10n.swift +++ b/Sources/PriTypeCore/L10n.swift @@ -78,6 +78,7 @@ public enum L10n { public static var capsLockOnDescription: String { String(format: localized("keyBinding.capsLockOnDescription"), app.name) } public static var capsLockOffDescription: String { String(format: localized("keyBinding.capsLockOffDescription"), app.name) } public static var disabledByCapsLock: String { localized("keyBinding.disabledByCapsLock") } + public static var bareKeyRejected: String { localized("keyBinding.bareKeyRejected") } public static var managedByMacOS: String { localized("keyBinding.managedByMacOS") } public static var capsLockBlockedTitle: String { localized("keyBinding.capsLockBlockedTitle") } public static var capsLockBlockedMessage: String { String(format: localized("keyBinding.capsLockBlockedMessage"), app.name) } diff --git a/Sources/PriTypeCore/Resources/en.lproj/Localizable.strings b/Sources/PriTypeCore/Resources/en.lproj/Localizable.strings index d002ece0..e686f8f8 100644 --- a/Sources/PriTypeCore/Resources/en.lproj/Localizable.strings +++ b/Sources/PriTypeCore/Resources/en.lproj/Localizable.strings @@ -33,6 +33,7 @@ "keyBinding.capsLockOnDescription" = "Caps Lock switches Korean/English through macOS Input Source settings. %@'s toggle key is disabled to avoid duplicate switching."; "keyBinding.capsLockOffDescription" = "%@'s toggle key switches Korean/English. Enable Caps Lock in macOS Input Source settings if you prefer that key."; "keyBinding.disabledByCapsLock" = "Disabled while Caps Lock input-source switching is on"; +"keyBinding.bareKeyRejected" = "That key cannot be bound on its own. Add a modifier, or use a modifier key such as Right Command."; "keyBinding.managedByMacOS" = "macOS Caps Lock"; "keyBinding.capsLockBlockedTitle" = "Set Caps Lock in macOS"; "keyBinding.capsLockBlockedMessage" = "Caps Lock is not a %@ custom key. Enable Caps Lock switching in macOS Input Source settings."; diff --git a/Sources/PriTypeCore/Resources/ko.lproj/Localizable.strings b/Sources/PriTypeCore/Resources/ko.lproj/Localizable.strings index d6ba78f6..4455813e 100644 --- a/Sources/PriTypeCore/Resources/ko.lproj/Localizable.strings +++ b/Sources/PriTypeCore/Resources/ko.lproj/Localizable.strings @@ -33,6 +33,7 @@ "keyBinding.capsLockOnDescription" = "macOS 입력 소스 설정을 따라 Caps Lock으로 한/영을 전환합니다. %@ 전환키는 중복 동작을 막기 위해 비활성화됩니다."; "keyBinding.capsLockOffDescription" = "%@ 전환키로 한/영을 전환합니다. Caps Lock을 쓰려면 macOS 입력 소스 설정에서 켜 주세요."; "keyBinding.disabledByCapsLock" = "Caps Lock 입력 소스 전환이 켜져 있어 비활성화됨"; +"keyBinding.bareKeyRejected" = "그 키는 단독으로 지정할 수 없습니다. 수정자 키와 함께 누르거나 우측 Command 같은 수정자 키를 사용하세요."; "keyBinding.managedByMacOS" = "macOS Caps Lock"; "keyBinding.capsLockBlockedTitle" = "Caps Lock은 macOS에서 설정합니다"; "keyBinding.capsLockBlockedMessage" = "%@ 전환키로는 사용할 수 없습니다. macOS 입력 소스 설정에서 Caps Lock 전환을 켜 주세요."; diff --git a/Sources/PriTypeCore/RightCommandSuppressor.swift b/Sources/PriTypeCore/RightCommandSuppressor.swift index 75a4387e..012321f8 100644 --- a/Sources/PriTypeCore/RightCommandSuppressor.swift +++ b/Sources/PriTypeCore/RightCommandSuppressor.swift @@ -217,8 +217,11 @@ public final class RightCommandSuppressor: @unchecked Sendable { // Dynamic toggle key — modifier key, single-key binding if priTypeToggleEnabled && toggleBinding.isModifierKey && toggleBinding.isModifierOnly && keyCode == toggleBinding.keyCode { - let modifierMask = Self.modifierMask(for: keyCode) - let isPressed = flags.contains(modifierMask) + // One physical key: read the device-dependent bit so the other side + // being held cannot keep this one latched. + let deviceMask = Self.deviceModifierMask(for: keyCode) + let detectMask = deviceMask.rawValue != 0 ? deviceMask : Self.modifierMask(for: keyCode) + let isPressed = flags.contains(detectMask) if isPressed && !toggleModifierIsDown { // Toggle modifier pressed - toggle immediately! @@ -236,8 +239,11 @@ public final class RightCommandSuppressor: @unchecked Sendable { // Dynamic hanja key — modifier key, single-key binding (only if different from toggle key) if hanjaBinding.isModifierKey && hanjaBinding.isModifierOnly && keyCode == hanjaBinding.keyCode && keyCode != toggleBinding.keyCode { - let modifierMask = Self.modifierMask(for: keyCode) - let isPressed = flags.contains(modifierMask) + // One physical key: read the device-dependent bit so the other side + // being held cannot keep this one latched. + let deviceMask = Self.deviceModifierMask(for: keyCode) + let detectMask = deviceMask.rawValue != 0 ? deviceMask : Self.modifierMask(for: keyCode) + let isPressed = flags.contains(detectMask) if isPressed && !hanjaModifierIsDown { hanjaModifierIsDown = true @@ -297,9 +303,9 @@ public final class RightCommandSuppressor: @unchecked Sendable { // When toggle modifier is held, strip its modifier from key events // This makes keys act as regular character input, not shortcuts if priTypeToggleEnabled && toggleModifierIsDown && toggleBinding.isModifierKey { - let modifierMask = Self.modifierMask(for: toggleBinding.keyCode) var newFlags = event.flags - newFlags.remove(modifierMask) + newFlags.remove(Self.modifierMask(for: toggleBinding.keyCode)) + newFlags.remove(Self.deviceModifierMask(for: toggleBinding.keyCode)) event.flags = newFlags DebugLogger.log("RightCommandSuppressor: Key with toggle modifier - stripped modifier (normal input)") return Unmanaged.passUnretained(event) @@ -311,8 +317,10 @@ public final class RightCommandSuppressor: @unchecked Sendable { // MARK: - Helpers - /// Get the CGEventFlags modifier mask for a given keyCode - private static func modifierMask(for keyCode: Int64) -> CGEventFlags { + /// Side-agnostic mask for a keyCode. Use this to STRIP a modifier from a key + /// event; never to decide whether the bound key is down, because the left and + /// right key of one modifier share these bits. + static func modifierMask(for keyCode: Int64) -> CGEventFlags { switch keyCode { case 54, 55: return .maskCommand // Right/Left Command case 61, 58: return .maskAlternate // Right/Left Option @@ -322,6 +330,26 @@ public final class RightCommandSuppressor: @unchecked Sendable { default: return CGEventFlags(rawValue: 0) } } + + /// Device-dependent mask identifying ONE physical modifier key + /// (IOLLEvent.h NX_DEVICE*KEYMASK). The toggle is a single key, so press and + /// release must be read from these bits: with the shared bits, releasing the + /// bound key while the other side is still held leaves the "held" latch set + /// forever, and the strip branch then eats the modifier off every later key + /// event — Cmd+C types a literal "c". + static func deviceModifierMask(for keyCode: Int64) -> CGEventFlags { + switch keyCode { + case 55: return CGEventFlags(rawValue: 0x00000008) // Left Command + case 54: return CGEventFlags(rawValue: 0x00000010) // Right Command + case 58: return CGEventFlags(rawValue: 0x00000020) // Left Option + case 61: return CGEventFlags(rawValue: 0x00000040) // Right Option + case 59: return CGEventFlags(rawValue: 0x00000001) // Left Control + case 62: return CGEventFlags(rawValue: 0x00002000) // Right Control + case 56: return CGEventFlags(rawValue: 0x00000002) // Left Shift + case 60: return CGEventFlags(rawValue: 0x00000004) // Right Shift + default: return CGEventFlags(rawValue: 0) + } + } /// Check if event flags contain required modifier flags private static func hasRequiredModifiers(flags: CGEventFlags, required: CGEventFlags) -> Bool { diff --git a/Sources/PriTypeCore/SettingsWindowController.swift b/Sources/PriTypeCore/SettingsWindowController.swift index ba002ae0..8cd8bd74 100644 --- a/Sources/PriTypeCore/SettingsWindowController.swift +++ b/Sources/PriTypeCore/SettingsWindowController.swift @@ -965,6 +965,7 @@ struct KeyRecorderRow: View { let onCapsLockBlocked: () -> Void @State private var isRecording = false + @State private var rejectedBareKey = false @State private var isHovering = false @State private var monitor: Any? @State private var pulseAnimation = false @@ -1006,9 +1007,11 @@ struct KeyRecorderRow: View { .opacity(pulseAnimation ? 0.6 : 1.0) .animation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true), value: pulseAnimation) - Text(L10n.keyBinding.recording) + Text(rejectedBareKey ? L10n.keyBinding.bareKeyRejected : L10n.keyBinding.recording) .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.blue) + .foregroundStyle(rejectedBareKey ? .orange : .blue) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } else { Text(valueOverride ?? binding.displayName) .font(.system(size: 12, weight: .semibold)) @@ -1090,6 +1093,14 @@ struct KeyRecorderRow: View { modifiers: UInt64(modifiers), displayName: KeyBinding.generateDisplayName(keyCode: keyCode, modifiers: UInt64(modifiers)) ) + // The event tap consumes the bound key system-wide. Taking a bare + // printable key here would remove it everywhere, this window + // included, so keep recording instead of locking the user out. + guard newBinding.isSafeAsBinding else { + rejectedBareKey = true + return nil // Consume, stay in recording mode + } + rejectedBareKey = false binding = newBinding stopRecording() return nil // Consume event @@ -1099,6 +1110,7 @@ struct KeyRecorderRow: View { } private func stopRecording() { + rejectedBareKey = false isRecording = false pulseAnimation = false if let monitor = monitor { diff --git a/Tests/PriTypeCoreTests/KeyBindingSafetyTests.swift b/Tests/PriTypeCoreTests/KeyBindingSafetyTests.swift new file mode 100644 index 00000000..5a514043 --- /dev/null +++ b/Tests/PriTypeCoreTests/KeyBindingSafetyTests.swift @@ -0,0 +1,67 @@ +import Testing +import CoreGraphics +import Foundation +@testable import PriTypeCore + +// MARK: - A binding must not swallow a key the user needs + +/// The event tap consumes the bound key globally. A bare printable key therefore +/// removes that key from the whole system with no way back: binding Space stops +/// the space bar everywhere, including in this settings window, so the only +/// recovery is `defaults write`. A modifier key alone, or a regular key with a +/// modifier, or a function key, are all safe. +@Suite("KeyBindingSafety") +struct KeyBindingSafetyTests { + private func binding(_ keyCode: Int64, _ modifiers: UInt64 = 0) -> KeyBinding { + KeyBinding(keyCode: keyCode, modifiers: modifiers, displayName: "test") + } + + @Test("A bare printable or essential key is rejected") + func barePrintableRejected() { + for (code, name) in [(Int64(49), "Space"), (36, "Return"), (48, "Tab"), + (51, "Delete"), (0, "A"), (29, "0"), (53, "Escape")] { + #expect(!binding(code).isSafeAsBinding, "\(name) alone must be rejected") + } + } + + @Test("The same key with a modifier is fine") + func withModifierAccepted() { + #expect(binding(49, CGEventFlags.maskControl.rawValue).isSafeAsBinding) + #expect(binding(0, CGEventFlags.maskCommand.rawValue).isSafeAsBinding) + } + + @Test("A modifier key alone is fine") + func modifierAloneAccepted() { + for code in [Int64(54), 55, 58, 61, 59, 62, 56, 60] { + #expect(binding(code).isSafeAsBinding, "modifier keyCode \(code) must be allowed") + } + } + + @Test("A bare function key is fine") + func bareFunctionKeyAccepted() { + for code in [Int64(105), 107, 113, 106, 122, 120] { // F13-F16, F1, F2 + #expect(binding(code).isSafeAsBinding, "function keyCode \(code) must be allowed") + } + } + + @Test("Caps Lock and Fn stay unsupported") + func capsLockAndFnRejected() { + #expect(!binding(57).isSafeAsBinding) + #expect(!binding(63).isSafeAsBinding) + } + + @Test("An already-saved unsafe binding is replaced, not handed back") + func persistedUnsafeBindingIsSanitized() { + // A user who bound Space before this guard existed cannot type a space + // anywhere, so they cannot fix it in the UI either. Reading must repair it. + #expect(KeyBinding.sanitizedToggle(binding(49)) == .defaultToggle) + #expect(KeyBinding.sanitizedToggle(binding(36)) == .defaultToggle) + } + + @Test("A safe persisted binding is returned unchanged") + func persistedSafeBindingSurvives() { + let controlSpace = binding(49, CGEventFlags.maskControl.rawValue) + #expect(KeyBinding.sanitizedToggle(controlSpace) == controlSpace) + #expect(KeyBinding.sanitizedToggle(binding(54)) == binding(54)) + } +} diff --git a/Tests/PriTypeCoreTests/ModifierMaskTests.swift b/Tests/PriTypeCoreTests/ModifierMaskTests.swift new file mode 100644 index 00000000..1bb90b2e --- /dev/null +++ b/Tests/PriTypeCoreTests/ModifierMaskTests.swift @@ -0,0 +1,58 @@ +import Testing +import CoreGraphics +@testable import PriTypeCore + +// MARK: - Left vs right modifier identity + +/// The toggle key is one physical key. CGEventFlags' generic bits (.maskCommand +/// and friends) are shared by the left and right key, so a release of the bound +/// key cannot be told from the other side still being held: the "toggle held" +/// latch never clears and every later key event has its modifier stripped +/// (Cmd+C inserts a literal "c"). Detection needs the device-dependent bits. +@Suite("ModifierMask") +struct ModifierMaskTests { + // keyCodes: 54 right / 55 left Command, 61 right / 58 left Option, + // 62 right / 59 left Control, 60 right / 56 left Shift. + private let pairs: [(right: Int64, left: Int64, name: String)] = [ + (54, 55, "Command"), + (61, 58, "Option"), + (62, 59, "Control"), + (60, 56, "Shift") + ] + + @Test("Left and right of one modifier have distinct device masks") + func deviceMasksDiffer() { + for pair in pairs { + let r = RightCommandSuppressor.deviceModifierMask(for: pair.right) + let l = RightCommandSuppressor.deviceModifierMask(for: pair.left) + #expect(r.rawValue != 0, "\(pair.name): right device mask must exist") + #expect(l.rawValue != 0, "\(pair.name): left device mask must exist") + #expect(r != l, "\(pair.name): left and right must not share a device bit") + } + } + + @Test("A device mask isolates its own side") + func devicePressIsolated() { + // Left Command held, right Command not: the right key must read as up. + let leftCommandHeld = RightCommandSuppressor.deviceModifierMask(for: 55) + .union(.maskCommand) + #expect(!leftCommandHeld.contains(RightCommandSuppressor.deviceModifierMask(for: 54)), + "right Command must read as up while only left Command is held") + #expect(leftCommandHeld.contains(.maskCommand), + "the generic bit is still set, which is why it cannot be used for detection") + } + + @Test("Generic masks stay shared, for stripping the modifier off a key event") + func genericMasksShared() { + for pair in pairs { + #expect(RightCommandSuppressor.modifierMask(for: pair.right) + == RightCommandSuppressor.modifierMask(for: pair.left), + "\(pair.name): the generic mask is deliberately side-agnostic") + } + } + + @Test("An unbound key has no device mask") + func unknownKeyCode() { + #expect(RightCommandSuppressor.deviceModifierMask(for: 0).rawValue == 0) + } +} diff --git a/install.sh b/install.sh index 625c1031..12fb6836 100755 --- a/install.sh +++ b/install.sh @@ -117,10 +117,10 @@ EOF copy_to_system() { local src="$1" local dest="$2" - local helper - helper=$(mktemp "${TMPDIR:-/tmp}/pritype-install.XXXXXX") - cat > "$helper" <<'SH' -set -e + # Kept in argv, never on disk: a helper file written here and then run as root + # can be rewritten by any process running as this user in between, which turns + # an install into arbitrary root execution. + local body='set -e src="$1" dest="$2" rm -rf "$dest.new" "$dest.old" @@ -130,29 +130,25 @@ if [ -d "$dest" ]; then fi mv "$dest.new" "$dest" rm -rf "$dest.old" -# Leftover names from older installs. rm -rf "/Library/Input Methods/PatchType.app" \ "/Library/Input Methods/PriType.app" \ - "/tmp/PriTypeV2.app.disabled" -SH + "/tmp/PriTypeV2.app.disabled"' if sudo -n true 2>/dev/null; then - sudo /bin/bash "$helper" "$src" "$dest" - rm -f "$helper" + sudo /bin/bash -c "$body" swap "$src" "$dest" return 0 fi echo "Administrator access is required to install into /Library/Input Methods." echo "A password dialog may appear." - osascript - "$helper" "$src" "$dest" <<'APPLESCRIPT' + osascript - "$body" "$src" "$dest" <<'APPLESCRIPT' on run argv - set helper to item 1 of argv + set body to item 1 of argv set src to item 2 of argv set dest to item 3 of argv - do shell script "/bin/bash " & quoted form of helper & " " & quoted form of src & " " & quoted form of dest with administrator privileges + do shell script "/bin/bash -c " & quoted form of body & " swap " & quoted form of src & " " & quoted form of dest with administrator privileges end run APPLESCRIPT - rm -f "$helper" } echo "Building $BUILD_CONFIG..." From f7f186b977ab785338713b23f052b4b38e4973df Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 12:07:54 +0900 Subject: [PATCH 09/12] Decide by how composition is actually delivered, not by the adapter's type. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirectInsertionAdapter can degrade to marked text mid-session and stays the same object, so three decisions keyed off its type or its host's id were wrong after that happened. finalize skipped re-inserting the syllable because direct insertion writes it as real text. After a fallback it had not: the syllable was only in the host's marked range, the host dropped that on focus loss, and the typed character was gone. Bailing also forgot the real text it had already written and drew the same syllable again as marked text, so the host kept both; remove it first, using our own record of where the write landed rather than the selection that just proved unusable. Raw conjoining jamo is a marked-text device — under direct insertion it is left in the document as real text. It was gated on the bundle id, which gets both sides wrong: a host configured for direct insertion still composes with marked text when the activation probe failed or the adapter degraded, and needs the jamo then. Take the mode from the adapter in use. Also make a combo binding match its modifiers exactly. The subset test meant Control+Space swallowed Control+Command+Space, so the Emoji picker never opened. Co-Authored-By: Claude Opus 5 --- .../PriTypeCore/ClientContextDetector.swift | 13 ++- .../PriTypeCore/DirectInsertionPlanner.swift | 28 ++++++ Sources/PriTypeCore/HangulComposer.swift | 4 +- Sources/PriTypeCore/HangulComposerTypes.swift | 11 +++ Sources/PriTypeCore/InputSession.swift | 5 +- .../PriTypeCore/RightCommandSuppressor.swift | 19 ++-- Sources/PriTypeCore/TextDelivery.swift | 23 ++++- .../PriTypeCoreTests/ClientContextTests.swift | 2 +- .../DirectInsertionFallbackTests.swift | 88 +++++++++++++++++++ .../PriTypeCoreTests/ModifierMaskTests.swift | 36 ++++++++ .../TextDeliveryPolicyTests.swift | 31 ------- 11 files changed, 217 insertions(+), 43 deletions(-) create mode 100644 Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift diff --git a/Sources/PriTypeCore/ClientContextDetector.swift b/Sources/PriTypeCore/ClientContextDetector.swift index 6005685a..adb5d2f2 100644 --- a/Sources/PriTypeCore/ClientContextDetector.swift +++ b/Sources/PriTypeCore/ClientContextDetector.swift @@ -209,9 +209,16 @@ public enum ClientCompatibilityPolicy { /// composition to protect: the conjoining jamo is written as REAL text and is /// left behind when the syllable is rewritten (typing 사파이어메모 inside /// existing text stranded a bare `ᄉ` U+1109 in Safari). - public static func prefersRawJamoPreedit(bundleId: String) -> Bool { - isWebContentHost(bundleId: bundleId) - && !prefersDirectInsertionForComposition(bundleId: bundleId) + /// Whether the preedit should use conjoining U+1100 jamo. + /// + /// Marked text only. Under direct insertion the preedit is REAL text, so the + /// jamo is left in the document when the syllable is rewritten. The delivery + /// mode has to come from the adapter in use, not from the bundle id: a host + /// configured for direct insertion still composes with marked text when the + /// activation probe failed or the adapter degraded mid-session, and it needs + /// the jamo then. + public static func usesRawJamoPreedit(bundleId: String, deliveryMode: InputDeliveryMode) -> Bool { + deliveryMode == .markedText && isWebContentHost(bundleId: bundleId) } /// Web editors' empty list items often have a non-collapsed / placeholder diff --git a/Sources/PriTypeCore/DirectInsertionPlanner.swift b/Sources/PriTypeCore/DirectInsertionPlanner.swift index d5c6e890..584af9ee 100644 --- a/Sources/PriTypeCore/DirectInsertionPlanner.swift +++ b/Sources/PriTypeCore/DirectInsertionPlanner.swift @@ -53,6 +53,19 @@ struct DirectInsertionPlan: Equatable { let bailed: Bool } +/// Whether `InputSession.finalize` may skip re-inserting the composed syllable. +/// +/// Direct insertion writes the in-progress syllable into the document as real +/// text, so finalizing must not insert it a second time. A `DirectInsertionAdapter` +/// that degraded to marked text at runtime did NOT write it: the syllable exists +/// only in the host's marked range, and the host drops that on focus loss — so +/// keying this off the adapter's type loses the text. +enum CompositionFinalizePlan { + static func skipsReinsertion(deliveryMode: InputDeliveryMode, renderingMarkedFallback: Bool) -> Bool { + deliveryMode == .directInsertion && !renderingMarkedFallback + } +} + enum DirectInsertionPlanner { /// Same sanity ceiling used elsewhere to reject Chromium's garbage range values. static let maxReasonableLocation = 10_000_000 @@ -81,6 +94,21 @@ enum DirectInsertionPlanner { return actualSubstring == expectedText } + /// The range of already-written live preedit to delete before degrading to + /// marked text. Bailing used to leave that real text in the document and then + /// draw the same syllable again as marked text, so the host kept both. + /// + /// Uses our own record of where the last write landed, not the host's current + /// selection — the bail happens precisely because that selection is unusable. + /// Returns nil when there is nothing tracked or the record is not coherent. + static func removalRangeOnBail(livePreeditLength: Int, expectedCaret: Int) -> NSRange? { + guard livePreeditLength > 0, + expectedCaret != NSNotFound, + expectedCaret >= livePreeditLength, + expectedCaret < maxReasonableLocation else { return nil } + return NSRange(location: expectedCaret - livePreeditLength, length: livePreeditLength) + } + /// Compute the rewrite plan. /// - Parameters: /// - cursorLocation: `client.selectedRange().location` (UTF-16 offset of caret). diff --git a/Sources/PriTypeCore/HangulComposer.swift b/Sources/PriTypeCore/HangulComposer.swift index ea37bf1d..5545b329 100644 --- a/Sources/PriTypeCore/HangulComposer.swift +++ b/Sources/PriTypeCore/HangulComposer.swift @@ -497,7 +497,9 @@ public class HangulComposer: @unchecked Sendable { // Update preedit text (the single live syllable). if !preedit.isEmpty { let preeditStr: String - if ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: lastInputBundleId) { + if ClientCompatibilityPolicy.usesRawJamoPreedit( + bundleId: lastInputBundleId, + deliveryMode: delegate.effectiveDeliveryMode) { // Keep U+1100 choseong so web hosts do not compositionend after the // first jamo (compatibility U+3131 looks like a finished letter). preeditStr = CompositionHelpers.convertToString(preedit) diff --git a/Sources/PriTypeCore/HangulComposerTypes.swift b/Sources/PriTypeCore/HangulComposerTypes.swift index 6d5ae2c5..db979af9 100644 --- a/Sources/PriTypeCore/HangulComposerTypes.swift +++ b/Sources/PriTypeCore/HangulComposerTypes.swift @@ -26,6 +26,12 @@ public protocol HangulComposerDelegate: AnyObject { /// Called when the in-progress composition text should be displayed /// - Parameter text: The preedit text (incomplete Hangul being composed) func setMarkedText(_ text: String) + + /// How composition actually reaches the host right now. Distinct from the + /// adapter's configured `deliveryMode`, which the session compares against + /// policy: a direct-insertion adapter that degraded reports `.markedText` + /// here while still naming `.directInsertion` there. + var effectiveDeliveryMode: InputDeliveryMode { get } /// Returns the text immediately before the current cursor position /// - Parameter length: Maximum length of text to retrieve @@ -61,3 +67,8 @@ public enum InputMode: Sendable { } } } + +public extension HangulComposerDelegate { + /// Conformers that are not client adapters (helpers, tests) deliver as marked text. + var effectiveDeliveryMode: InputDeliveryMode { .markedText } +} diff --git a/Sources/PriTypeCore/InputSession.swift b/Sources/PriTypeCore/InputSession.swift index ba4838dc..cfb9c8f6 100644 --- a/Sources/PriTypeCore/InputSession.swift +++ b/Sources/PriTypeCore/InputSession.swift @@ -175,7 +175,10 @@ final class InputSession: @unchecked Sendable { // EXPERIMENTAL direct insertion: the in-progress syllable is ALREADY real text // in the document. Re-inserting it here would duplicate the character. Just end // the engine's composition and clear the adapter's live-preedit tracking. - if let direct = adapter as? DirectInsertionAdapter { + if let direct = adapter as? DirectInsertionAdapter, + CompositionFinalizePlan.skipsReinsertion( + deliveryMode: direct.deliveryMode, + renderingMarkedFallback: direct.isRenderingMarkedFallback) { _ = composer.flushCommitString() // flush engine + update buffer; do NOT insert direct.resetPreeditTracking() DebugLogger.log("InputSession: finalize[\(reason.rawValue)] direct-insertion (already in document, no re-insert)") diff --git a/Sources/PriTypeCore/RightCommandSuppressor.swift b/Sources/PriTypeCore/RightCommandSuppressor.swift index 012321f8..a4f1bda7 100644 --- a/Sources/PriTypeCore/RightCommandSuppressor.swift +++ b/Sources/PriTypeCore/RightCommandSuppressor.swift @@ -283,7 +283,7 @@ public final class RightCommandSuppressor: @unchecked Sendable { } else { // Combo toggle (e.g., Control+Space, Option+G) let requiredFlags = CGEventFlags(rawValue: toggleBinding.modifiers) - if Self.hasRequiredModifiers(flags: event.flags, required: requiredFlags) { + if Self.modifiersMatch(flags: event.flags, required: requiredFlags) { DebugLogger.log("RightCommandSuppressor: Combo toggle (\(toggleBinding.displayName)) - TOGGLE triggered") triggerToggle() return nil @@ -293,7 +293,7 @@ public final class RightCommandSuppressor: @unchecked Sendable { // Regular key (non-modifier) as hanja — single key or combo if keyCode == hanjaBinding.keyCode && !hanjaBinding.isModifierKey && keyCode != toggleBinding.keyCode { - if hanjaBinding.isModifierOnly || Self.hasRequiredModifiers(flags: event.flags, required: CGEventFlags(rawValue: hanjaBinding.modifiers)) { + if hanjaBinding.isModifierOnly || Self.modifiersMatch(flags: event.flags, required: CGEventFlags(rawValue: hanjaBinding.modifiers)) { DebugLogger.log("RightCommandSuppressor: Regular key hanja (\(hanjaBinding.displayName)) - HANJA") triggerHanjaLookup() return nil @@ -351,9 +351,18 @@ public final class RightCommandSuppressor: @unchecked Sendable { } } - /// Check if event flags contain required modifier flags - private static func hasRequiredModifiers(flags: CGEventFlags, required: CGEventFlags) -> Bool { - return flags.intersection(required) == required + /// Modifier bits a binding can name. Caps Lock, numeric-pad and function bits + /// ride along on real events and are not part of a binding. + private static let bindableModifiers: CGEventFlags = [ + .maskCommand, .maskAlternate, .maskControl, .maskShift + ] + + /// Whether `flags` names exactly the binding's modifiers. + /// + /// A subset test would let a richer combination match: with Control+Space bound, + /// Control+Command+Space (the macOS Emoji picker) matched too and was swallowed. + static func modifiersMatch(flags: CGEventFlags, required: CGEventFlags) -> Bool { + flags.intersection(bindableModifiers) == required.intersection(bindableModifiers) } private func triggerToggle() { diff --git a/Sources/PriTypeCore/TextDelivery.swift b/Sources/PriTypeCore/TextDelivery.swift index 9b0e49b4..dde697c5 100644 --- a/Sources/PriTypeCore/TextDelivery.swift +++ b/Sources/PriTypeCore/TextDelivery.swift @@ -4,7 +4,7 @@ import InputMethodKit // MARK: - InputDeliveryMode /// How composition output reaches the focused client. -enum InputDeliveryMode: Equatable { +public enum InputDeliveryMode: Equatable { case immediate // Finder desktop: defer, no marked window case directInsertion // EXPERIMENTAL: real-text in-place rewrite case markedText // Default: canonical marked-text composition @@ -124,6 +124,9 @@ class BaseClientAdapter: NSObject, HangulComposerDelegate { /// Host bundle id, for engine-tuned preedit styling. let bundleId: String + /// Configured mode unless a subclass degrades at runtime. + var effectiveDeliveryMode: InputDeliveryMode { deliveryMode } + /// Engine-tuned attributes for the composition preedit (effective only on /// macOS versions that honor IME attributes — see `PreeditUnderline`). let preeditAttributes: [NSAttributedString.Key: Any] @@ -303,6 +306,18 @@ final class DirectInsertionAdapter: BaseClientAdapter { /// degrade to marked text for the rest of the session rather than strand text. private var fellBackToMarked = false + /// True once this adapter degraded to marked text. The syllable then lives in + /// the host's marked range, not in the document, which changes what finalize + /// has to do. + var isRenderingMarkedFallback: Bool { fellBackToMarked } + + /// `deliveryMode` stays `.directInsertion` so the session does not rebuild this + /// adapter on every keystroke after a fallback; this reports what is really + /// happening to the text. + override var effectiveDeliveryMode: InputDeliveryMode { + fellBackToMarked ? .markedText : .directInsertion + } + /// Clear live-preedit tracking. Called by the session whenever composition /// ends out-of-band (focus loss, mouse-click commit, secure passthrough). Also /// re-arms direct insertion: a clean finalize lets a host that momentarily @@ -382,6 +397,12 @@ final class DirectInsertionAdapter: BaseClientAdapter { if plan.bailed { // Document access unreliable: degrade to marked text to avoid stranding // a half-jamo. (Should be rare — probe + denylist gate this.) + // The live preedit is REAL text we already wrote. Remove it before the + // marked-text path draws the same syllable, or the host keeps both. + if let stale = DirectInsertionPlanner.removalRangeOnBail( + livePreeditLength: livePreeditLength, expectedCaret: expectedCaret) { + client.insertText("", replacementRange: stale) + } fellBackToMarked = true livePreeditLength = 0 livePreeditText = "" diff --git a/Tests/PriTypeCoreTests/ClientContextTests.swift b/Tests/PriTypeCoreTests/ClientContextTests.swift index d3c912d5..ab202f00 100644 --- a/Tests/PriTypeCoreTests/ClientContextTests.swift +++ b/Tests/PriTypeCoreTests/ClientContextTests.swift @@ -173,7 +173,7 @@ struct ClientContextTests { "com.apple.dt.Xcode" ] { #expect(!ClientCompatibilityPolicy.isWebContentHost(bundleId: id), "\(id) should stay native") - #expect(!ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id)) + #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .markedText)) #expect(!ClientCompatibilityPolicy.prefersCollapsedCompositionReplacement(bundleId: id)) } } diff --git a/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift b/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift new file mode 100644 index 00000000..0b93efc0 --- /dev/null +++ b/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift @@ -0,0 +1,88 @@ +import Testing +import Foundation +@testable import PriTypeCore + +// MARK: - What happens when direct insertion degrades mid-composition + +/// DirectInsertionAdapter can degrade to marked text at runtime (fellBackToMarked) +/// while staying the same object. Two decisions keyed off "is this adapter direct +/// insertion?" are wrong once that happens. +@Suite("DirectInsertionFallback") +struct DirectInsertionFallbackTests { + + // finalize skips re-inserting the syllable because direct insertion already + // wrote it as real text. After a fallback it did NOT: it lives only in the + // host's marked range, and skipping the insert loses it on focus change. + @Test("Finalize re-inserts once the adapter has fallen back to marked text") + func fallbackMustReinsert() { + #expect(!CompositionFinalizePlan.skipsReinsertion( + deliveryMode: .directInsertion, renderingMarkedFallback: true), + "the syllable is only marked text now; skipping the insert drops it") + } + + @Test("Finalize still skips re-insertion while direct insertion is live") + func directInsertionStillSkips() { + #expect(CompositionFinalizePlan.skipsReinsertion( + deliveryMode: .directInsertion, renderingMarkedFallback: false)) + } + + @Test("Marked text and immediate modes always re-insert") + func otherModesReinsert() { + #expect(!CompositionFinalizePlan.skipsReinsertion( + deliveryMode: .markedText, renderingMarkedFallback: false)) + #expect(!CompositionFinalizePlan.skipsReinsertion( + deliveryMode: .immediate, renderingMarkedFallback: false)) + } + + // Bailing left the already-written real text in the document and then drew the + // same syllable again as marked text, so the host kept both. + @Test("Bailing removes the real text it already wrote") + func bailRemovesLivePreedit() { + let range = DirectInsertionPlanner.removalRangeOnBail(livePreeditLength: 1, expectedCaret: 40) + #expect(range == NSRange(location: 39, length: 1)) + } + + @Test("Nothing to remove when no live preedit is tracked") + func bailWithNoLivePreedit() { + #expect(DirectInsertionPlanner.removalRangeOnBail(livePreeditLength: 0, expectedCaret: 40) == nil) + } + + @Test("An unknown or impossible caret removes nothing rather than guessing") + func bailWithUnusableCaret() { + #expect(DirectInsertionPlanner.removalRangeOnBail(livePreeditLength: 1, expectedCaret: NSNotFound) == nil) + #expect(DirectInsertionPlanner.removalRangeOnBail(livePreeditLength: 3, expectedCaret: 2) == nil) + } +} + +// MARK: - Raw jamo depends on how the syllable is actually delivered + +/// Conjoining U+1100 jamo keeps a MARKED composition open in a web editor. It is +/// meaningless — and gets stranded as real text — under direct insertion. Keying +/// it off the bundle id alone gets both cases wrong: a direct-insertion host that +/// degrades to marked text at runtime, or whose activation probe failed, then +/// marks compatibility jamo in ProseMirror and splits the block. +@Suite("RawJamoByDeliveryMode") +struct RawJamoByDeliveryModeTests { + @Test("A web host composing with marked text gets raw jamo") + func webHostMarkedTextGetsRawJamo() { + for id in ["com.apple.Safari", "com.google.Chrome", "com.atlassian.confluence"] { + #expect(ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .markedText), + "\(id) is a web editor and its marked composition needs the jamo") + } + } + + @Test("The same host composing by direct insertion does not") + func directInsertionNeverGetsRawJamo() { + for id in ["com.apple.Safari", "com.google.Chrome"] { + #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .directInsertion), + "\(id) would leave the conjoining jamo in the document as real text") + } + } + + @Test("Native hosts keep compatibility jamo in every mode") + func nativeHostsUnaffected() { + for mode in [InputDeliveryMode.markedText, .directInsertion, .immediate] { + #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: "com.kakao.KakaoTalkMac", deliveryMode: mode)) + } + } +} diff --git a/Tests/PriTypeCoreTests/ModifierMaskTests.swift b/Tests/PriTypeCoreTests/ModifierMaskTests.swift index 1bb90b2e..0530eb8e 100644 --- a/Tests/PriTypeCoreTests/ModifierMaskTests.swift +++ b/Tests/PriTypeCoreTests/ModifierMaskTests.swift @@ -56,3 +56,39 @@ struct ModifierMaskTests { #expect(RightCommandSuppressor.deviceModifierMask(for: 0).rawValue == 0) } } + +// MARK: - A combo binding must not swallow richer combos + +/// hasRequiredModifiers was a subset test, so Control+Space also matched +/// Control+Command+Space (the macOS Emoji picker) and swallowed it. +@Suite("ComboModifierMatch") +struct ComboModifierMatchTests { + private let control = CGEventFlags.maskControl + private let command = CGEventFlags.maskCommand + private let shift = CGEventFlags.maskShift + + @Test("An exact modifier set matches") + func exactMatch() { + #expect(RightCommandSuppressor.modifiersMatch(flags: control, required: control)) + } + + @Test("A richer combo does not match a narrower binding") + func supersetRejected() { + #expect(!RightCommandSuppressor.modifiersMatch(flags: control.union(command), required: control), + "Control+Command+Space must reach the Emoji picker, not toggle the language") + #expect(!RightCommandSuppressor.modifiersMatch(flags: control.union(shift), required: control)) + } + + @Test("A missing modifier does not match") + func subsetRejected() { + #expect(!RightCommandSuppressor.modifiersMatch(flags: control, required: control.union(command))) + } + + @Test("Incidental non-modifier flags are ignored") + func nonModifierFlagsIgnored() { + // Caps Lock / numeric-pad / function bits ride along on real events and + // must not stop a binding from matching. + let noisy = control.union(.maskAlphaShift).union(.maskNumericPad).union(.maskSecondaryFn) + #expect(RightCommandSuppressor.modifiersMatch(flags: noisy, required: control)) + } +} diff --git a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift index 9efa3edc..a2b8f33c 100644 --- a/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift +++ b/Tests/PriTypeCoreTests/TextDeliveryPolicyTests.swift @@ -283,34 +283,3 @@ struct SafariDirectInsertionTests { } } -// MARK: - Raw jamo is a marked-text-only workaround - -/// Conjoining U+1100 jamo exists so a web editor keeps the MARKED composition -/// open past the first choseong. A host composing by direct insertion has no -/// composition: the jamo is written as real text and stays there. Typing -/// 사파이어메모 inside existing text left a bare `ᄉ` (U+1109) in the document. -@Suite("RawJamoNeedsMarkedText") -struct RawJamoNeedsMarkedTextTests { - @Test("Direct-insertion hosts must not emit conjoining jamo") - func directInsertionHostsUseDisplayJamo() { - for id in ["com.apple.Safari", "com.apple.SafariTechnologyPreview"] { - #expect(!ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id), - "\(id) composes by direct insertion; conjoining jamo would be stranded as real text") - } - } - - @Test("Marked-text web hosts keep raw jamo") - func markedTextWebHostsKeepRawJamo() { - for id in ["com.google.Chrome", "com.anthropic.claudefordesktop", "com.atlassian.confluence"] { - #expect(ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id), - "\(id) still composes via marked text and needs the jamo") - } - } - - @Test("Native hosts keep compatibility jamo") - func nativeHostsUnchanged() { - for id in ["com.kakao.KakaoTalkMac", "com.apple.TextEdit"] { - #expect(!ClientCompatibilityPolicy.prefersRawJamoPreedit(bundleId: id)) - } - } -} From 7d518c121c1883b55fb7894891644b366f9ee82b Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 12:12:35 +0900 Subject: [PATCH 10/12] Arm key monitoring in one place, and stop discarding off-primary caret rects. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launch and the Settings accessibility check each wired the toggle handlers separately, and only launch registered onTapFailed — a tap armed from Settings therefore ran with no IOKit fallback and died silently when the system disabled it. Move the wiring into KeyMonitoring and call it from both. Arming is idempotent, so activateServer can re-arm: granting Accessibility more than two minutes after launch used to leave the toggle key dead for the whole session while Settings showed it as granted, because the startup poll had already given up and nothing else started the tap. capsLockInputSourceSwitchEnabled hit cfprefsd on every event-tap and HID callback while the key bindings beside it were memoized for exactly that reason. Hold the answer for a second: a slow lookup there pushes the tap past its deadline, and three of those disable it for good. The caret-rect filter required positive coordinates, so a display arranged left of or below the primary — where screen coordinates are negative — had its valid rects thrown away and the Hanja window opened on the wrong monitor. Compare magnitude instead; the filter is for denormals like 1.6e-314, and the sign only says which display. Drop the recorder branch in the event tap. Nothing ever set isRecordingKey: the settings recorder uses an NSEvent local monitor, which sits behind the tap. Co-Authored-By: Claude Opus 5 --- Sources/PriType/main.swift | 41 +----------- .../PriTypeCore/ConfigurationManager.swift | 28 +++++++++ Sources/PriTypeCore/CursorRectResolver.swift | 27 ++++++-- Sources/PriTypeCore/KeyMonitoring.swift | 62 +++++++++++++++++++ .../PriTypeCore/PriTypeInputController.swift | 4 ++ .../PriTypeCore/RightCommandSuppressor.swift | 26 -------- .../SettingsWindowController.swift | 14 +---- Sources/PriTypeCore/TextDelivery.swift | 5 ++ .../CursorRectValidationTests.swift | 47 ++++++++++++++ 9 files changed, 172 insertions(+), 82 deletions(-) create mode 100644 Sources/PriTypeCore/KeyMonitoring.swift create mode 100644 Tests/PriTypeCoreTests/CursorRectValidationTests.swift diff --git a/Sources/PriType/main.swift b/Sources/PriType/main.swift index 1b0ed2a8..37371fe0 100644 --- a/Sources/PriType/main.swift +++ b/Sources/PriType/main.swift @@ -80,45 +80,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, @unchecked Sendable { return } - // Set callback for CGEventTap toggle handler (handles all toggle keys) - RightCommandSuppressor.shared.onToggle = { - InputModeCoordinator.shared.requestToggle(source: .customKey) - } - - // Set callback for Right Option key → Hanja lookup - RightCommandSuppressor.shared.onHanjaLookup = { - PriTypeInputController.sharedComposer.triggerHanjaLookup() - } - - // Track if CGEventTap started successfully - let eventTapStarted = RightCommandSuppressor.shared.start() - - // IOKit backup: Only start and activate actual toggle if CGEventTap failed - if eventTapStarted { - DebugLogger.log("Primary: CGEventTap started successfully") - // Register fallback: if CGEventTap dies repeatedly, switch to IOKit - RightCommandSuppressor.shared.onTapFailed = { - DebugLogger.log("CGEventTap failed repeatedly — activating IOKit fallback") - IOKitManager.shared.onRightCommandToggle = { - InputModeCoordinator.shared.requestToggle(source: .iokitFallback) - } - IOKitManager.shared.onRightOptionHanja = { - PriTypeInputController.sharedComposer.triggerHanjaLookup() - } - IOKitManager.shared.start() - } - } else { - DebugLogger.log("Primary: CGEventTap FAILED - IOKit taking over as primary") - // IOKit takes over as primary toggle handler - IOKitManager.shared.onRightCommandToggle = { - InputModeCoordinator.shared.requestToggle(source: .iokitFallback) - } - IOKitManager.shared.onRightOptionHanja = { - PriTypeInputController.sharedComposer.triggerHanjaLookup() - } - IOKitManager.shared.start() - } - + KeyMonitoring.arm() + DebugLogger.log("Toggle key monitoring initialized") } } diff --git a/Sources/PriTypeCore/ConfigurationManager.swift b/Sources/PriTypeCore/ConfigurationManager.swift index 3fe2edcd..aab0dff1 100644 --- a/Sources/PriTypeCore/ConfigurationManager.swift +++ b/Sources/PriTypeCore/ConfigurationManager.swift @@ -430,6 +430,9 @@ public final class ConfigurationManager: ConfigurationProviding, @unchecked Send // JSON decoding on every access is wasteful; cache in memory and invalidate on write. // Lock protects in-memory cache from races between CGEventTap thread and settings UI. + private let capsLockSwitchLock = NSLock() + private var cachedCapsLockSwitch: (value: Bool, readAt: TimeInterval)? + private var _cachedToggleBinding: KeyBinding? private var _cachedHanjaBinding: KeyBinding? private let keyBindingLock = NSLock() @@ -523,6 +526,31 @@ public final class ConfigurationManager: ConfigurationProviding, @unchecked Send /// When this is enabled, PriType should not also run its own language /// toggle key. The system input-source switch becomes the single owner. public var capsLockInputSourceSwitchEnabled: Bool { + // Read on EVERY event-tap and HID callback, so a cfprefsd round trip here + // sits on the keystroke path and can push the tap past its deadline — three + // of those disable it permanently. Hold the answer briefly so a change in + // System Settings is still picked up within a second. + let now = ProcessInfo.processInfo.systemUptime + capsLockSwitchLock.lock() + if let cached = cachedCapsLockSwitch, now - cached.readAt < Self.capsLockSwitchTTL { + capsLockSwitchLock.unlock() + return cached.value + } + capsLockSwitchLock.unlock() + + let value = Self.readCapsLockInputSourceSwitch() + capsLockSwitchLock.lock() + cachedCapsLockSwitch = (value: value, readAt: now) + capsLockSwitchLock.unlock() + return value + } + + /// How long a cached answer stays usable. Short enough that toggling the system + /// setting takes effect without a restart, long enough to take the lookup off + /// the per-keystroke path. + static let capsLockSwitchTTL: TimeInterval = 1.0 + + private static func readCapsLockInputSourceSwitch() -> Bool { if let value = CFPreferencesCopyValue( "TISRomanSwitchState" as CFString, kCFPreferencesAnyApplication, diff --git a/Sources/PriTypeCore/CursorRectResolver.swift b/Sources/PriTypeCore/CursorRectResolver.swift index 90afc290..a11895b0 100644 --- a/Sources/PriTypeCore/CursorRectResolver.swift +++ b/Sources/PriTypeCore/CursorRectResolver.swift @@ -100,18 +100,33 @@ public enum CursorRectResolver { /// Validate that a rect from firstRect is a usable cursor position /// Electron/Chromium apps can return garbage values (e.g. x=1.6e-314, y=19896) public static func isValidCursorRect(_ rect: NSRect) -> Bool { - // Reject zero origin (uninitialized) - guard rect.origin.x != 0 || rect.origin.y != 0 else { return false } - // Reject negative or zero height (malformed) - guard rect.size.height > 0 else { return false } - // Reject absurdly small coordinates (floating point garbage like 1.6e-314) - guard rect.origin.x > 1 && rect.origin.y > 1 else { return false } + guard !rectLooksLikeGarbage(rect) else { return false } // Check that the point is on any connected screen return NSScreen.screens.contains { screen in screen.frame.contains(NSPoint(x: rect.origin.x, y: rect.origin.y)) } } + /// Whether a rect is malformed rather than merely somewhere unexpected. + /// + /// Screen coordinates are signed: a display arranged left of or below the + /// primary has negative ones, so a positivity test threw away real caret + /// positions and sent the candidate window to the wrong monitor. What the + /// filter is for is Electron/Chromium garbage — denormals like 1.6e-314, + /// NaN, an uninitialized zero origin, a zero height. + static func rectLooksLikeGarbage(_ rect: NSRect) -> Bool { + guard rect.origin.x.isFinite, rect.origin.y.isFinite, rect.size.height.isFinite else { return true } + // Uninitialized + if rect.origin.x == 0 && rect.origin.y == 0 { return true } + // Malformed + if rect.size.height <= 0 { return true } + // Too close to the origin to be a caret: catches denormals like 1.6e-314 + // and uninitialized sub-pixel values. Compared by MAGNITUDE — the sign + // carries no information about validity, it only says which display. + if abs(rect.origin.x) <= 1 || abs(rect.origin.y) <= 1 { return true } + return false + } + // MARK: - Accessibility API Cursor Position /// Get cursor position via macOS Accessibility API diff --git a/Sources/PriTypeCore/KeyMonitoring.swift b/Sources/PriTypeCore/KeyMonitoring.swift new file mode 100644 index 00000000..ae2d55d6 --- /dev/null +++ b/Sources/PriTypeCore/KeyMonitoring.swift @@ -0,0 +1,62 @@ +import Foundation + +/// The one place that wires the toggle and hanja handlers and starts monitoring. +/// +/// Two call sites used to do this separately — app launch and the Settings +/// accessibility check — and only the launch one registered `onTapFailed`, so a +/// tap armed from Settings ran with no IOKit fallback and died silently when the +/// system disabled it. +/// +/// Arming is idempotent, which is what makes a late permission grant recoverable. +/// The launch-time poll gives up after two minutes; granting Accessibility after +/// that used to leave the toggle key dead for the rest of the session, with the +/// Settings UI reporting everything as fine. +public enum KeyMonitoring { + /// Wire every handler and start the event tap. Safe to call repeatedly. + /// - Returns: whether the CGEventTap itself started; the IOKit fallback takes + /// over when it did not. + @discardableResult + public static func arm() -> Bool { + guard IOKitManager.hasAccessibilityPermission() else { + DebugLogger.log("KeyMonitoring: no Accessibility permission, not arming") + return false + } + + RightCommandSuppressor.shared.onToggle = { + InputModeCoordinator.shared.requestToggle(source: .customKey) + } + RightCommandSuppressor.shared.onHanjaLookup = { + PriTypeInputController.sharedComposer.triggerHanjaLookup() + } + RightCommandSuppressor.shared.onTapFailed = { + DebugLogger.log("KeyMonitoring: CGEventTap failed repeatedly — activating IOKit fallback") + startIOKitFallback() + } + + let started = RightCommandSuppressor.shared.start() + if started { + DebugLogger.log("KeyMonitoring: CGEventTap started") + } else { + DebugLogger.log("KeyMonitoring: CGEventTap did not start — IOKit takes over as primary") + startIOKitFallback() + } + return started + } + + /// Re-arm when monitoring is not already running. Cheap enough for a focus + /// change, and the reason a permission granted after launch takes effect. + public static func armIfNeeded() { + guard !RightCommandSuppressor.shared.isRunning else { return } + arm() + } + + private static func startIOKitFallback() { + IOKitManager.shared.onRightCommandToggle = { + InputModeCoordinator.shared.requestToggle(source: .iokitFallback) + } + IOKitManager.shared.onRightOptionHanja = { + PriTypeInputController.sharedComposer.triggerHanjaLookup() + } + IOKitManager.shared.start() + } +} diff --git a/Sources/PriTypeCore/PriTypeInputController.swift b/Sources/PriTypeCore/PriTypeInputController.swift index 00f0fc83..b8878827 100644 --- a/Sources/PriTypeCore/PriTypeInputController.swift +++ b/Sources/PriTypeCore/PriTypeInputController.swift @@ -217,6 +217,10 @@ public class PriTypeInputController: IMKInputController, @unchecked Sendable { // 입력기가 활성화될 때 호출 - 새 세션 시작 override public func activateServer(_ sender: Any!) { + // Recover a permission granted after launch: the startup poll gives up + // after two minutes and nothing else re-armed the tap, so the toggle key + // stayed dead for the session while Settings reported it as granted. + KeyMonitoring.armIfNeeded() #if DEBUG assert(Thread.isMainThread, "IMK activateServer must run on main thread") #endif diff --git a/Sources/PriTypeCore/RightCommandSuppressor.swift b/Sources/PriTypeCore/RightCommandSuppressor.swift index a4f1bda7..1d44b24b 100644 --- a/Sources/PriTypeCore/RightCommandSuppressor.swift +++ b/Sources/PriTypeCore/RightCommandSuppressor.swift @@ -63,10 +63,8 @@ public final class RightCommandSuppressor: @unchecked Sendable { public var onTapFailed: (@Sendable () -> Void)? /// Whether recording mode is active (for Key Recorder in settings) - public var isRecordingKey = false /// Callback for key recording (settings UI) - public var onKeyRecorded: ((_ keyCode: Int64, _ modifiers: UInt64) -> Void)? private init() {} @@ -179,30 +177,6 @@ public final class RightCommandSuppressor: @unchecked Sendable { } // Key recording mode — capture the next key press for settings UI - if isRecordingKey { - if type == .flagsChanged { - let flags = event.flags - // Only fire on key DOWN (when a new modifier appears). - // Caps Lock is special: its flag is the toggled lock state, so - // record the keyCode itself even when the flag is transitioning off. - let isModifierDown = flags.rawValue & 0xFFFF0000 != 0 || keyCode == 57 - if isModifierDown { - let recordCallback = onKeyRecorded - DispatchQueue.main.async { - recordCallback?(keyCode, 0) // modifier-only binding - } - return nil // Suppress - } - } else if type == .keyDown { - let modifiers = event.flags.rawValue & 0xFFFF0000 // Keep only modifier flags - let recordCallback = onKeyRecorded - DispatchQueue.main.async { - recordCallback?(keyCode, modifiers) - } - return nil // Suppress - } - return Unmanaged.passUnretained(event) - } // Handle flagsChanged (modifier keys) if type == .flagsChanged { diff --git a/Sources/PriTypeCore/SettingsWindowController.swift b/Sources/PriTypeCore/SettingsWindowController.swift index 8cd8bd74..172b8141 100644 --- a/Sources/PriTypeCore/SettingsWindowController.swift +++ b/Sources/PriTypeCore/SettingsWindowController.swift @@ -664,17 +664,9 @@ struct SettingsView: View { DispatchQueue.main.async { self.isAccessibilityGranted = true - // Auto-start key monitoring that was skipped at launch - if !RightCommandSuppressor.shared.isRunning { - RightCommandSuppressor.shared.onToggle = { - InputModeCoordinator.shared.requestToggle(source: .customKey) - } - RightCommandSuppressor.shared.onHanjaLookup = { - PriTypeInputController.sharedComposer.triggerHanjaLookup() - } - let started = RightCommandSuppressor.shared.start() - DebugLogger.log("Accessibility granted: CGEventTap start = \(started)") - } + // Same wiring as launch, including the IOKit fallback this + // path used to leave unregistered. + KeyMonitoring.armIfNeeded() } } } diff --git a/Sources/PriTypeCore/TextDelivery.swift b/Sources/PriTypeCore/TextDelivery.swift index dde697c5..28ad2150 100644 --- a/Sources/PriTypeCore/TextDelivery.swift +++ b/Sources/PriTypeCore/TextDelivery.swift @@ -206,6 +206,11 @@ enum MarkedTextReplacement { // Empty-list placeholders are length 0 or 1. A real selection (a word) // must still be replaced via NSNotFound, or the first jamo inserts in // front of the selected text in Chrome/Safari/Slack. + // A one-character range is ambiguous: an empty Confluence/ProseMirror list + // item reports exactly that for its placeholder, and so does a shift-arrow + // over a single letter. The placeholder case is the one confirmed on-device, + // so it wins; the cost is that typing over a one-character selection in a + // web editor inserts in front of it instead of replacing it. if selectedRange.length > 1 { return notFound } diff --git a/Tests/PriTypeCoreTests/CursorRectValidationTests.swift b/Tests/PriTypeCoreTests/CursorRectValidationTests.swift new file mode 100644 index 00000000..f0f1f444 --- /dev/null +++ b/Tests/PriTypeCoreTests/CursorRectValidationTests.swift @@ -0,0 +1,47 @@ +import Testing +import AppKit +@testable import PriTypeCore + +// MARK: - Separating garbage coordinates from off-primary ones + +/// The garbage filter required positive coordinates, but a display arranged left +/// of or below the primary has negative ones: a perfectly good caret rect there +/// was discarded, both resolve strategies "failed", and the Hanja candidate +/// window fell back to the cached rect and opened on the wrong monitor. What the +/// filter is actually for is denormal/NaN values like 1.6e-314. +@Suite("CursorRectGarbage") +struct CursorRectGarbageTests { + @Test("Denormal floating-point garbage is rejected") + func denormalRejected() { + #expect(CursorRectResolver.rectLooksLikeGarbage(NSRect(x: 1.6e-314, y: 19896, width: 1, height: 18))) + #expect(CursorRectResolver.rectLooksLikeGarbage(NSRect(x: 100, y: 5e-320, width: 1, height: 18))) + } + + @Test("Non-finite coordinates are rejected") + func nonFiniteRejected() { + #expect(CursorRectResolver.rectLooksLikeGarbage(NSRect(x: CGFloat.nan, y: 100, width: 1, height: 18))) + #expect(CursorRectResolver.rectLooksLikeGarbage(NSRect(x: 100, y: CGFloat.infinity, width: 1, height: 18))) + } + + @Test("An uninitialized zero origin is rejected") + func zeroOriginRejected() { + #expect(CursorRectResolver.rectLooksLikeGarbage(NSRect(x: 0, y: 0, width: 1, height: 18))) + } + + @Test("A zero-height rect is rejected") + func zeroHeightRejected() { + #expect(CursorRectResolver.rectLooksLikeGarbage(NSRect(x: 100, y: 200, width: 1, height: 0))) + } + + @Test("Negative coordinates are a real position, not garbage") + func negativeCoordinatesAccepted() { + // Secondary display arranged to the left of, or below, the primary. + #expect(!CursorRectResolver.rectLooksLikeGarbage(NSRect(x: -1400, y: 500, width: 1, height: 18))) + #expect(!CursorRectResolver.rectLooksLikeGarbage(NSRect(x: 300, y: -800, width: 1, height: 18))) + } + + @Test("An ordinary caret rect passes") + func ordinaryRectAccepted() { + #expect(!CursorRectResolver.rectLooksLikeGarbage(NSRect(x: 640, y: 480, width: 1, height: 18))) + } +} From ed91325bfc566024273178d7cd7b08a3540b5e6d Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 13:34:23 +0900 Subject: [PATCH 11/12] Stop sending conjoining jamo as the preedit; it ate the next character. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into the middle of existing text lost the character right after the caret, the moment the syllable completed — reproducibly in Slack, Claude and Chrome, and never in Notes or in Safari. The one thing those three share is the conjoining U+1100 preedit: Notes never used it, and Safari stopped when it moved to direct insertion. A space after the caret survived, because a space cannot combine with a jamo. That is what conjoining jamo are defined to do. They were introduced so a web editor would not read the lone first consonant as a finished letter and commit it into its own ProseMirror block, splitting a list item. That split is real, but it needs an empty block and one editor; this loses a character during ordinary mid-text typing in every Blink host. Route every preedit through CompositionHelpers.preeditString, which hands the host the standalone compatibility letter that combines with nothing. The block split may return in Blink hosts as a result. Safari, where Confluence is actually used here, composes by direct insertion and is unaffected. Co-Authored-By: Claude Opus 5 --- .../PriTypeCore/ClientContextDetector.swift | 11 ----- Sources/PriTypeCore/CompositionHelpers.swift | 15 ++++++ Sources/PriTypeCore/HangulComposer.swift | 12 +---- .../PriTypeCoreTests/ClientContextTests.swift | 1 - .../DirectInsertionFallbackTests.swift | 48 +++++++++---------- .../HangulComposerTests.swift | 14 ++++-- 6 files changed, 49 insertions(+), 52 deletions(-) diff --git a/Sources/PriTypeCore/ClientContextDetector.swift b/Sources/PriTypeCore/ClientContextDetector.swift index adb5d2f2..79be8c00 100644 --- a/Sources/PriTypeCore/ClientContextDetector.swift +++ b/Sources/PriTypeCore/ClientContextDetector.swift @@ -209,17 +209,6 @@ public enum ClientCompatibilityPolicy { /// composition to protect: the conjoining jamo is written as REAL text and is /// left behind when the syllable is rewritten (typing 사파이어메모 inside /// existing text stranded a bare `ᄉ` U+1109 in Safari). - /// Whether the preedit should use conjoining U+1100 jamo. - /// - /// Marked text only. Under direct insertion the preedit is REAL text, so the - /// jamo is left in the document when the syllable is rewritten. The delivery - /// mode has to come from the adapter in use, not from the bundle id: a host - /// configured for direct insertion still composes with marked text when the - /// activation probe failed or the adapter degraded mid-session, and it needs - /// the jamo then. - public static func usesRawJamoPreedit(bundleId: String, deliveryMode: InputDeliveryMode) -> Bool { - deliveryMode == .markedText && isWebContentHost(bundleId: bundleId) - } /// Web editors' empty list items often have a non-collapsed / placeholder /// selection. `setMarkedText` with `replacementRange = NSNotFound` then diff --git a/Sources/PriTypeCore/CompositionHelpers.swift b/Sources/PriTypeCore/CompositionHelpers.swift index f4b0282b..1903581c 100644 --- a/Sources/PriTypeCore/CompositionHelpers.swift +++ b/Sources/PriTypeCore/CompositionHelpers.swift @@ -44,6 +44,21 @@ public struct CompositionHelpers: Sendable { return String(mapped.map { Character($0) }) } + /// The preedit string to hand the host for libhangul's current buffer. + /// + /// Always compatibility jamo for a lone letter. Conjoining U+1100 jamo were + /// used for web editors — they keep a composition open where a compatibility + /// jamo reads as a finished letter — but they are defined to combine with what + /// follows them. Typing into the middle of existing text therefore ate the next + /// character when the syllable completed, reproducibly in Slack, Claude and + /// Chrome, and only when a real character followed: a space cannot combine with + /// a jamo, so a space was safe. Losing a character during ordinary typing in + /// every Blink host outweighs the block split the conjoining form prevented in + /// one editor. + public static func preeditString(for codePoints: [UInt32]) -> String { + normalizeJamoForDisplay(codePoints) + } + /// Returns true when the string is a single standalone Jamo used as preedit. public static func isSingleStandaloneJamo(_ text: String) -> Bool { let scalars = Array(text.unicodeScalars) diff --git a/Sources/PriTypeCore/HangulComposer.swift b/Sources/PriTypeCore/HangulComposer.swift index 5545b329..807f0dec 100644 --- a/Sources/PriTypeCore/HangulComposer.swift +++ b/Sources/PriTypeCore/HangulComposer.swift @@ -496,17 +496,7 @@ public class HangulComposer: @unchecked Sendable { // Update preedit text (the single live syllable). if !preedit.isEmpty { - let preeditStr: String - if ClientCompatibilityPolicy.usesRawJamoPreedit( - bundleId: lastInputBundleId, - deliveryMode: delegate.effectiveDeliveryMode) { - // Keep U+1100 choseong so web hosts do not compositionend after the - // first jamo (compatibility U+3131 looks like a finished letter). - preeditStr = CompositionHelpers.convertToString(preedit) - } else { - preeditStr = CompositionHelpers.normalizeJamoForDisplay(preedit) - } - delegate.setMarkedText(preeditStr) + delegate.setMarkedText(CompositionHelpers.preeditString(for: preedit)) } else { delegate.setMarkedText("") } diff --git a/Tests/PriTypeCoreTests/ClientContextTests.swift b/Tests/PriTypeCoreTests/ClientContextTests.swift index ab202f00..4698036c 100644 --- a/Tests/PriTypeCoreTests/ClientContextTests.swift +++ b/Tests/PriTypeCoreTests/ClientContextTests.swift @@ -173,7 +173,6 @@ struct ClientContextTests { "com.apple.dt.Xcode" ] { #expect(!ClientCompatibilityPolicy.isWebContentHost(bundleId: id), "\(id) should stay native") - #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .markedText)) #expect(!ClientCompatibilityPolicy.prefersCollapsedCompositionReplacement(bundleId: id)) } } diff --git a/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift b/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift index 0b93efc0..035d2bec 100644 --- a/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift +++ b/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift @@ -54,35 +54,33 @@ struct DirectInsertionFallbackTests { } } -// MARK: - Raw jamo depends on how the syllable is actually delivered +// MARK: - Conjoining jamo must not reach a host -/// Conjoining U+1100 jamo keeps a MARKED composition open in a web editor. It is -/// meaningless — and gets stranded as real text — under direct insertion. Keying -/// it off the bundle id alone gets both cases wrong: a direct-insertion host that -/// degrades to marked text at runtime, or whose activation probe failed, then -/// marks compatibility jamo in ProseMirror and splits the block. -@Suite("RawJamoByDeliveryMode") -struct RawJamoByDeliveryModeTests { - @Test("A web host composing with marked text gets raw jamo") - func webHostMarkedTextGetsRawJamo() { - for id in ["com.apple.Safari", "com.google.Chrome", "com.atlassian.confluence"] { - #expect(ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .markedText), - "\(id) is a web editor and its marked composition needs the jamo") - } +/// Conjoining U+1100 jamo was sent as the lone-consonant preedit so a web editor +/// would not treat it as a finished letter and split the block. It also merges +/// with whatever follows it: typing into the middle of existing text ate the next +/// character when the syllable completed, in Slack, Claude and Chrome alike, and +/// only when a real character followed — a space was safe, because a space cannot +/// combine with a jamo. +@Suite("PreeditJamoForm") +struct PreeditJamoFormTests { + @Test("A lone consonant preedit uses compatibility jamo everywhere") + func loneConsonantUsesCompatibilityJamo() { + // libhangul hands back choseong U+1105; what reaches the host must be + // the standalone letter U+3139, which combines with nothing. + let preedit = CompositionHelpers.preeditString(for: [0x1105]) + #expect(preedit.unicodeScalars.map(\.value) == [0x3139]) } - @Test("The same host composing by direct insertion does not") - func directInsertionNeverGetsRawJamo() { - for id in ["com.apple.Safari", "com.google.Chrome"] { - #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .directInsertion), - "\(id) would leave the conjoining jamo in the document as real text") - } + @Test("A completed syllable is unchanged") + func completedSyllableUnchanged() { + // Complete syllables already arrive precomposed and must stay that way. + #expect(CompositionHelpers.preeditString(for: [0xB77C]) == "라") } - @Test("Native hosts keep compatibility jamo in every mode") - func nativeHostsUnaffected() { - for mode in [InputDeliveryMode.markedText, .directInsertion, .immediate] { - #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: "com.kakao.KakaoTalkMac", deliveryMode: mode)) - } + @Test("A lone vowel also uses compatibility jamo") + func loneVowelUsesCompatibilityJamo() { + let preedit = CompositionHelpers.preeditString(for: [0x1161]) // jungseong A + #expect(preedit.unicodeScalars.map(\.value) == [0x314F]) } } diff --git a/Tests/PriTypeCoreTests/HangulComposerTests.swift b/Tests/PriTypeCoreTests/HangulComposerTests.swift index 321c6ba2..448e2f12 100644 --- a/Tests/PriTypeCoreTests/HangulComposerTests.swift +++ b/Tests/PriTypeCoreTests/HangulComposerTests.swift @@ -43,14 +43,20 @@ struct HangulComposerTests { #expect(delegate.markedText == "안") } - @Test("Web hosts keep choseong jamo in preedit instead of compatibility jamo") - func webHostKeepsRawChoseongJamo() { + @Test("Web hosts get compatibility jamo too, so the preedit cannot merge") + func webHostUsesCompatibilityJamo() { + // Chrome used to receive choseong U+1100, which keeps a composition open + // where compatibility U+3131 reads as a finished letter. Conjoining jamo + // are also defined to combine with what follows: typing mid-text ate the + // next character when the syllable completed, in Chrome, Slack and Claude, + // and only when a real character followed. A space could not be eaten + // because it cannot combine with a jamo. let (composer, delegate, _) = makeComposer() composer.markKeystroke(bundleId: "com.google.Chrome") _ = composer.handle(TestEventFactory.keyEvent(char: "r", keyCode: 15)!, delegate: delegate) - #expect(delegate.markedText == "\u{1100}", - "Chrome preedit must stay U+1100 so the host keeps composition open; got '\(delegate.markedText)'") + #expect(delegate.markedText == "\u{3131}", + "preedit must be the standalone letter, which combines with nothing; got '\(delegate.markedText)'") #expect(delegate.insertedTexts.isEmpty, "First choseong must not commit") } From 2172bcefd579a5715fa08ee79b8e27d0010b1aaf Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Thu, 17 Sep 2026 13:39:11 +0900 Subject: [PATCH 12/12] Revert "Stop sending conjoining jamo as the preedit; it ate the next character." This reverts commit ed91325bfc566024273178d7cd7b08a3540b5e6d. --- .../PriTypeCore/ClientContextDetector.swift | 11 +++++ Sources/PriTypeCore/CompositionHelpers.swift | 15 ------ Sources/PriTypeCore/HangulComposer.swift | 12 ++++- .../PriTypeCoreTests/ClientContextTests.swift | 1 + .../DirectInsertionFallbackTests.swift | 48 ++++++++++--------- .../HangulComposerTests.swift | 14 ++---- 6 files changed, 52 insertions(+), 49 deletions(-) diff --git a/Sources/PriTypeCore/ClientContextDetector.swift b/Sources/PriTypeCore/ClientContextDetector.swift index 79be8c00..adb5d2f2 100644 --- a/Sources/PriTypeCore/ClientContextDetector.swift +++ b/Sources/PriTypeCore/ClientContextDetector.swift @@ -209,6 +209,17 @@ public enum ClientCompatibilityPolicy { /// composition to protect: the conjoining jamo is written as REAL text and is /// left behind when the syllable is rewritten (typing 사파이어메모 inside /// existing text stranded a bare `ᄉ` U+1109 in Safari). + /// Whether the preedit should use conjoining U+1100 jamo. + /// + /// Marked text only. Under direct insertion the preedit is REAL text, so the + /// jamo is left in the document when the syllable is rewritten. The delivery + /// mode has to come from the adapter in use, not from the bundle id: a host + /// configured for direct insertion still composes with marked text when the + /// activation probe failed or the adapter degraded mid-session, and it needs + /// the jamo then. + public static func usesRawJamoPreedit(bundleId: String, deliveryMode: InputDeliveryMode) -> Bool { + deliveryMode == .markedText && isWebContentHost(bundleId: bundleId) + } /// Web editors' empty list items often have a non-collapsed / placeholder /// selection. `setMarkedText` with `replacementRange = NSNotFound` then diff --git a/Sources/PriTypeCore/CompositionHelpers.swift b/Sources/PriTypeCore/CompositionHelpers.swift index 1903581c..f4b0282b 100644 --- a/Sources/PriTypeCore/CompositionHelpers.swift +++ b/Sources/PriTypeCore/CompositionHelpers.swift @@ -44,21 +44,6 @@ public struct CompositionHelpers: Sendable { return String(mapped.map { Character($0) }) } - /// The preedit string to hand the host for libhangul's current buffer. - /// - /// Always compatibility jamo for a lone letter. Conjoining U+1100 jamo were - /// used for web editors — they keep a composition open where a compatibility - /// jamo reads as a finished letter — but they are defined to combine with what - /// follows them. Typing into the middle of existing text therefore ate the next - /// character when the syllable completed, reproducibly in Slack, Claude and - /// Chrome, and only when a real character followed: a space cannot combine with - /// a jamo, so a space was safe. Losing a character during ordinary typing in - /// every Blink host outweighs the block split the conjoining form prevented in - /// one editor. - public static func preeditString(for codePoints: [UInt32]) -> String { - normalizeJamoForDisplay(codePoints) - } - /// Returns true when the string is a single standalone Jamo used as preedit. public static func isSingleStandaloneJamo(_ text: String) -> Bool { let scalars = Array(text.unicodeScalars) diff --git a/Sources/PriTypeCore/HangulComposer.swift b/Sources/PriTypeCore/HangulComposer.swift index 807f0dec..5545b329 100644 --- a/Sources/PriTypeCore/HangulComposer.swift +++ b/Sources/PriTypeCore/HangulComposer.swift @@ -496,7 +496,17 @@ public class HangulComposer: @unchecked Sendable { // Update preedit text (the single live syllable). if !preedit.isEmpty { - delegate.setMarkedText(CompositionHelpers.preeditString(for: preedit)) + let preeditStr: String + if ClientCompatibilityPolicy.usesRawJamoPreedit( + bundleId: lastInputBundleId, + deliveryMode: delegate.effectiveDeliveryMode) { + // Keep U+1100 choseong so web hosts do not compositionend after the + // first jamo (compatibility U+3131 looks like a finished letter). + preeditStr = CompositionHelpers.convertToString(preedit) + } else { + preeditStr = CompositionHelpers.normalizeJamoForDisplay(preedit) + } + delegate.setMarkedText(preeditStr) } else { delegate.setMarkedText("") } diff --git a/Tests/PriTypeCoreTests/ClientContextTests.swift b/Tests/PriTypeCoreTests/ClientContextTests.swift index 4698036c..ab202f00 100644 --- a/Tests/PriTypeCoreTests/ClientContextTests.swift +++ b/Tests/PriTypeCoreTests/ClientContextTests.swift @@ -173,6 +173,7 @@ struct ClientContextTests { "com.apple.dt.Xcode" ] { #expect(!ClientCompatibilityPolicy.isWebContentHost(bundleId: id), "\(id) should stay native") + #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .markedText)) #expect(!ClientCompatibilityPolicy.prefersCollapsedCompositionReplacement(bundleId: id)) } } diff --git a/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift b/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift index 035d2bec..0b93efc0 100644 --- a/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift +++ b/Tests/PriTypeCoreTests/DirectInsertionFallbackTests.swift @@ -54,33 +54,35 @@ struct DirectInsertionFallbackTests { } } -// MARK: - Conjoining jamo must not reach a host +// MARK: - Raw jamo depends on how the syllable is actually delivered -/// Conjoining U+1100 jamo was sent as the lone-consonant preedit so a web editor -/// would not treat it as a finished letter and split the block. It also merges -/// with whatever follows it: typing into the middle of existing text ate the next -/// character when the syllable completed, in Slack, Claude and Chrome alike, and -/// only when a real character followed — a space was safe, because a space cannot -/// combine with a jamo. -@Suite("PreeditJamoForm") -struct PreeditJamoFormTests { - @Test("A lone consonant preedit uses compatibility jamo everywhere") - func loneConsonantUsesCompatibilityJamo() { - // libhangul hands back choseong U+1105; what reaches the host must be - // the standalone letter U+3139, which combines with nothing. - let preedit = CompositionHelpers.preeditString(for: [0x1105]) - #expect(preedit.unicodeScalars.map(\.value) == [0x3139]) +/// Conjoining U+1100 jamo keeps a MARKED composition open in a web editor. It is +/// meaningless — and gets stranded as real text — under direct insertion. Keying +/// it off the bundle id alone gets both cases wrong: a direct-insertion host that +/// degrades to marked text at runtime, or whose activation probe failed, then +/// marks compatibility jamo in ProseMirror and splits the block. +@Suite("RawJamoByDeliveryMode") +struct RawJamoByDeliveryModeTests { + @Test("A web host composing with marked text gets raw jamo") + func webHostMarkedTextGetsRawJamo() { + for id in ["com.apple.Safari", "com.google.Chrome", "com.atlassian.confluence"] { + #expect(ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .markedText), + "\(id) is a web editor and its marked composition needs the jamo") + } } - @Test("A completed syllable is unchanged") - func completedSyllableUnchanged() { - // Complete syllables already arrive precomposed and must stay that way. - #expect(CompositionHelpers.preeditString(for: [0xB77C]) == "라") + @Test("The same host composing by direct insertion does not") + func directInsertionNeverGetsRawJamo() { + for id in ["com.apple.Safari", "com.google.Chrome"] { + #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: id, deliveryMode: .directInsertion), + "\(id) would leave the conjoining jamo in the document as real text") + } } - @Test("A lone vowel also uses compatibility jamo") - func loneVowelUsesCompatibilityJamo() { - let preedit = CompositionHelpers.preeditString(for: [0x1161]) // jungseong A - #expect(preedit.unicodeScalars.map(\.value) == [0x314F]) + @Test("Native hosts keep compatibility jamo in every mode") + func nativeHostsUnaffected() { + for mode in [InputDeliveryMode.markedText, .directInsertion, .immediate] { + #expect(!ClientCompatibilityPolicy.usesRawJamoPreedit(bundleId: "com.kakao.KakaoTalkMac", deliveryMode: mode)) + } } } diff --git a/Tests/PriTypeCoreTests/HangulComposerTests.swift b/Tests/PriTypeCoreTests/HangulComposerTests.swift index 448e2f12..321c6ba2 100644 --- a/Tests/PriTypeCoreTests/HangulComposerTests.swift +++ b/Tests/PriTypeCoreTests/HangulComposerTests.swift @@ -43,20 +43,14 @@ struct HangulComposerTests { #expect(delegate.markedText == "안") } - @Test("Web hosts get compatibility jamo too, so the preedit cannot merge") - func webHostUsesCompatibilityJamo() { - // Chrome used to receive choseong U+1100, which keeps a composition open - // where compatibility U+3131 reads as a finished letter. Conjoining jamo - // are also defined to combine with what follows: typing mid-text ate the - // next character when the syllable completed, in Chrome, Slack and Claude, - // and only when a real character followed. A space could not be eaten - // because it cannot combine with a jamo. + @Test("Web hosts keep choseong jamo in preedit instead of compatibility jamo") + func webHostKeepsRawChoseongJamo() { let (composer, delegate, _) = makeComposer() composer.markKeystroke(bundleId: "com.google.Chrome") _ = composer.handle(TestEventFactory.keyEvent(char: "r", keyCode: 15)!, delegate: delegate) - #expect(delegate.markedText == "\u{3131}", - "preedit must be the standalone letter, which combines with nothing; got '\(delegate.markedText)'") + #expect(delegate.markedText == "\u{1100}", + "Chrome preedit must stay U+1100 so the host keeps composition open; got '\(delegate.markedText)'") #expect(delegate.insertedTexts.isEmpty, "First choseong must not commit") }