From 5a0fd9f0e1916adec9ef47453a6e3370196720f0 Mon Sep 17 00:00:00 2001 From: calvinjkim Date: Wed, 2 Sep 2026 13:26:32 +0900 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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."