Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 수정
- `--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은 전환 직후 그 조회가 자주 비어 있었습니다. 이제 빈 컨텍스트는 패스스루하고, `.!?` 뒤 공백처럼 문장 끝이 확인될 때만 대문자로 넣습니다.
- 설정에서 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` 주석에 기록했습니다.

Expand Down
16 changes: 12 additions & 4 deletions Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@
<dict>
<key>tsInputModeListKey</key>
<dict>
<key>com.pritype.inputmethod.v2</key>
<key>com.pritype.inputmethod.v2.korean</key>
<dict>
<key>TISIconIsTemplate</key>
<true/>
<key>TISIntendedLanguage</key>
<string>ko</string>
<key>tsInputModeIsVisibleKey</key>
<true/>
<key>tsInputModeMenuIconFileKey</key>
<string>input-ko.tiff</string>
<key>tsInputModePaletteIconFileKey</key>
Expand All @@ -45,6 +49,10 @@
<dict>
<key>TISIconIsTemplate</key>
<true/>
<key>TISIntendedLanguage</key>
<string>en</string>
<key>tsInputModeIsVisibleKey</key>
<false/>
<key>tsInputModeMenuIconFileKey</key>
<string>input-en.tiff</string>
<key>tsInputModePaletteIconFileKey</key>
Expand All @@ -57,8 +65,7 @@
</dict>
<key>tsVisibleInputModeOrderedArrayKey</key>
<array>
<string>com.pritype.inputmethod.v2</string>
<string>com.pritype.inputmethod.v2.english</string>
<string>com.pritype.inputmethod.v2.korean</string>
</array>
</dict>
<key>InputMethodConnectionName</key>
Expand All @@ -71,10 +78,11 @@
<string>stable</string>
<key>TICapsLockLanguageSwitchCapable</key>
<true/>
<key>TISIntendedLanguage</key>
<string>ko</string>
<key>tsInputMethodCharacterRepertoireKey</key>
<array>
<string>Hang</string>
<string>Latn</string>
</array>
<key>TISIconIsTemplate</key>
<true/>
Expand Down
22 changes: 15 additions & 7 deletions Sources/PriType/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import InputMethodKit
import Cocoa
import PriTypeCore

let kConnectionName = "PriType_InputString_v2"
let kConnectionName = Brand.connectionName

// MARK: - App Delegate

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/PriTypeCore/AboutInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
79 changes: 79 additions & 0 deletions Sources/PriTypeCore/Brand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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
}

/// Local patch overlays skip official PriType GitHub updates.
public static var tracksUpstreamUpdates: Bool {
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 {
"\(bundleID).korean"
}

public static func englishModeID(forBundleID bundleID: String) -> String {
"\(bundleID).english"
}

/// TIS mints `<bundleID>.<last-component>` 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)"
}
}
55 changes: 44 additions & 11 deletions Sources/PriTypeCore/ClientContextDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,23 @@ struct SecureInputPolicy: Sendable {
return true
}

guard signals.hasInvalidSelection || signals.hasGlobalSecureInput else {
return false
}

if signals.hasInvalidSelection && !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.hasMarkedTextSupport || !signals.hasTextInputCapability {
// 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
}

if signals.hasInvalidSelection {
return true
}

return signals.hasGlobalSecureInput
return false
}
}

Expand Down Expand Up @@ -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.
Expand All @@ -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`),
Expand Down
41 changes: 34 additions & 7 deletions Sources/PriTypeCore/ConfigurationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading