checkpoint before checking out main

This commit is contained in:
Rocky
2026-08-25 13:42:00 +08:00
parent 57844ce615
commit dccafe6f9e
105 changed files with 4302 additions and 1323 deletions
@@ -60,6 +60,9 @@ public struct EnglishSuggestionEngine: Sendable {
public static let slotCount = 3
/// In-vocabulary words only yield to a much more common transposition / neighbor.
public static let inVocabularyFrequencyGap = 250
/// Rare words benefit from explicit personal-dictionary treatment; common
/// words are already covered well by the bundled lexicon.
public static let personalTermFrequencyCeiling = 680
private let lexicon: EnglishLexicon
@@ -150,6 +153,36 @@ public struct EnglishSuggestionEngine: Sendable {
return false
}
/// Whether repeated use of this word would add value to PersonalDictionary.
/// Existing personal/system terms and common lexicon words need no prompt.
func isPersonalTermCandidate(
_ word: String,
personalTerms: [String],
systemWords: [String]
) -> Bool {
guard FrequentTermStore.normalizedCandidate(from: word) != nil,
PersonalDictionary.isEnglishTypingHotword(word) else {
return false
}
let lower = word.lowercased()
guard !personalTerms.contains(where: { $0.lowercased() == lower }),
!systemWords.contains(where: { $0.lowercased() == lower }) else {
return false
}
// Preserve intentional product spelling even when the base word is
// common enough to exist in the bundled lexicon (OpenAI, iOS26, GPT-5).
let hasDistinctiveShape = word.dropFirst().contains(where: \.isUppercase)
|| word.contains(where: \.isNumber)
|| word.contains(where: { "-.+#".contains($0) })
if hasDistinctiveShape {
return true
}
return lexicon.frequency(of: lower) <= Self.personalTermFrequencyCeiling
}
// MARK: - Board
private struct Board {
@@ -304,9 +337,11 @@ public struct EnglishSuggestionEngine: Sendable {
private func isProtectedToken(_ typed: String) -> Bool {
if typed.count <= 2 { return true }
if typed.allSatisfy(\.isUppercase) { return true }
if typed.dropFirst().contains(where: \.isUppercase) { return true }
if typed.contains(where: \.isNumber) { return true }
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
if typed.contains("-") || typed.contains("_") { return true }
if typed.contains("'") || typed.contains("+") || typed.contains("#") { return true }
return false
}
@@ -1,13 +1,14 @@
// RimeFrequentTermStore.swift
// FrequentTermStore.swift
// OSGKeyboard · Shared
//
// Small App Group sidecar for Rime commits. Reading librime's LevelDB userdb
// while the keyboard owns it can race the engine, so the extension records
// eligible committed terms here and the host app ranks them for suggestions.
// Small App Group sidecar for committed typing terms. Reading librime's
// LevelDB userdb while the keyboard owns it can race the engine, so the
// extension records eligible Chinese and English terms here and the host app
// ranks them for personal-dictionary suggestions.
import Foundation
public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable {
public struct FrequentTerm: Codable, Equatable, Identifiable, Sendable {
public var id: String { term.lowercased() }
public let term: String
@@ -28,10 +29,11 @@ public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable {
}
}
/// Captures repeated Rime candidate commits without writing to the curated
/// Captures repeated eligible commits without writing to the curated
/// PersonalDictionary until the user explicitly confirms a suggestion.
public final class RimeFrequentTermStore: @unchecked Sendable {
public static let defaultsKey = "rimeTyping.frequentTerms.v1"
public final class FrequentTermStore: @unchecked Sendable {
public static let defaultsKey = "typing.frequentTerms.v2"
public static let legacyRimeDefaultsKey = "rimeTyping.frequentTerms.v1"
public static let minimumSuggestionCount = 2
public static let maximumTrackedTerms = 256
@@ -52,7 +54,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
let key = term.lowercased()
if let index = terms.firstIndex(where: { $0.term.lowercased() == key }) {
let existing = terms[index]
terms[index] = RimeFrequentTerm(
terms[index] = FrequentTerm(
term: term,
commitCount: min(10_000, existing.commitCount + 1),
firstSeenAt: existing.firstSeenAt,
@@ -60,7 +62,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
)
} else {
terms.append(
RimeFrequentTerm(
FrequentTerm(
term: term,
commitCount: 1,
firstSeenAt: date,
@@ -78,11 +80,11 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
saveLocked(Array(terms.prefix(Self.maximumTrackedTerms)))
}
/// Repeated, recent Rime commits that are not already curated.
/// Repeated, recent commits that are not already curated.
public func suggestions(
excludingPersonalTerms personalTerms: Set<String>,
limit: Int = 5
) -> [RimeFrequentTerm] {
) -> [FrequentTerm] {
guard limit > 0 else { return [] }
let excluded = Set(personalTerms.map { $0.lowercased() })
@@ -111,13 +113,14 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
public func clear() {
lock.lock()
defaults.removeObject(forKey: Self.defaultsKey)
defaults.removeObject(forKey: Self.legacyRimeDefaultsKey)
lock.unlock()
}
static func normalizedCandidate(from text: String) -> String? {
let term = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard (2...12).contains(term.count),
!commonTerms.contains(term),
guard (2...32).contains(term.count),
!commonTerms.contains(term.lowercased()),
!term.unicodeScalars.contains(where: {
CharacterSet.whitespacesAndNewlines.contains($0)
}) else {
@@ -131,7 +134,8 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
semanticCharacterCount += 1
continue
}
// Product names and proper nouns commonly contain these separators.
// Product names, proper nouns, and English contractions commonly
// contain these separators.
guard allowedSeparators.contains(Character(String(scalar))) else {
return nil
}
@@ -139,21 +143,40 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
return semanticCharacterCount >= 2 ? term : nil
}
private func loadLocked() -> [RimeFrequentTerm] {
guard let data = defaults.data(forKey: Self.defaultsKey),
let terms = try? JSONDecoder().decode([RimeFrequentTerm].self, from: data) else {
private func loadLocked() -> [FrequentTerm] {
if let data = defaults.data(forKey: Self.defaultsKey),
let terms = try? JSONDecoder().decode([FrequentTerm].self, from: data) {
return terms
}
guard let legacyData = defaults.data(forKey: Self.legacyRimeDefaultsKey),
let legacyTerms = try? JSONDecoder().decode([FrequentTerm].self, from: legacyData) else {
return []
}
return terms
// The legacy value has the same Codable shape, so migration only
// changes its key and keeps every user's existing Chinese history.
saveLocked(legacyTerms)
defaults.removeObject(forKey: Self.legacyRimeDefaultsKey)
return legacyTerms
}
private func saveLocked(_ terms: [RimeFrequentTerm]) {
private func saveLocked(_ terms: [FrequentTerm]) {
guard let data = try? JSONEncoder().encode(terms) else { return }
defaults.set(data, forKey: Self.defaultsKey)
}
/// Avoid recommending ubiquitous conversational glue as a personal term.
private static let commonTerms: Set<String> = [
"a", "about", "after", "all", "also", "am", "an", "and", "any", "are",
"as", "at", "be", "because", "been", "but", "by", "can", "could", "did",
"do", "does", "for", "from", "get", "got", "had", "has", "have", "he",
"her", "here", "him", "his", "how", "i", "if", "in", "is", "it", "its",
"just", "me", "more", "my", "no", "not", "now", "of", "on", "one", "or",
"our", "out", "she", "so", "some", "than", "that", "the", "their", "them",
"then", "there", "they", "this", "to", "too", "up", "us", "was", "we",
"were", "what", "when", "where", "which", "who", "why", "will", "with",
"would", "you", "your",
"一个", "一下", "不会", "不是", "什么", "他们", "但是", "你们", "你好",
"可能", "可以", "因为", "好的", "如果", "已经", "应该", "怎么", "我们",
"所以", "时候", "明天", "昨天", "有点", "没有", "然后", "现在", "知道",
@@ -161,5 +184,5 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
"那里", "那样", "需要", "今天", "就是"
]
private static let allowedSeparators: Set<Character> = ["-", ".", "+", "#", "·"]
private static let allowedSeparators: Set<Character> = ["-", "'", ".", "+", "#", "·"]
}
@@ -7,14 +7,14 @@
import Foundation
public enum TypingHabitStore {
/// Clears English boosts and Chinese Rime user dictionaries.
/// Clears English boosts, cross-language frequent terms, and Rime user dictionaries.
/// Does not touch PersonalDictionary / osg_personal.
public static func clearAll(
englishStore: EnglishLearningStore = EnglishLearningStore(),
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
frequentTermStore: FrequentTermStore = FrequentTermStore()
) async throws {
englishStore.clear()
rimeFrequentTermStore.clear()
frequentTermStore.clear()
try await RimeResourceInstaller.shared.clearUserDictionary()
}
}
@@ -15,8 +15,16 @@ public final class TypingSessionController: ObservableObject {
@Published public private(set) var capsLock: Bool = false
/// Finger is down on Shift (iOS: hold for continuous uppercase; release ends).
@Published public private(set) var shiftHeld: Bool = false
@Published public private(set) var composition: TypingComposition = .empty
@Published public private(set) var candidateRevision: UInt64 = 0
@Published public private(set) var composition: TypingComposition = .empty {
didSet {
if oldValue != composition {
candidateRevision &+= 1
}
}
}
@Published public private(set) var engineReady: Bool = false
@Published public private(set) var isPreparingEngine: Bool = false
@Published public private(set) var schema: TypingInputSchema
/// Chinese-only: key grid replaced by a same-height candidate grid.
@Published public private(set) var isCandidatePanelExpanded: Bool = false
@@ -29,8 +37,10 @@ public final class TypingSessionController: ObservableObject {
/// Chinese composition is also skipped so passwords never enter Rime userdb.
@Published public var suggestionsEnabled: Bool = true {
didSet {
guard oldValue, !suggestionsEnabled else { return }
abandonChineseComposition()
guard oldValue != suggestionsEnabled else { return }
if !suggestionsEnabled {
clearDocumentScopedState()
}
}
}
/// `UITextChecker` completions / guesses. Empty in unit tests.
@@ -54,12 +64,16 @@ public final class TypingSessionController: ObservableObject {
public let layout: TypingLayoutProviding
private let engineFactory: @MainActor () -> RimeEngineBridging
private let englishFactory: @MainActor () -> EnglishSuggestionEngine
private let hostHeavyProvider: @MainActor () -> Bool
private let learningStore: EnglishLearningStore
private let rimeFrequentTermStore: RimeFrequentTermStore
private let frequentTermStore: FrequentTermStore
private var engineStorage: RimeEngineBridging?
private var englishStorage: EnglishSuggestionEngine?
private var prepared = false
private var prepareTask: Task<Void, Never>?
private var engineEpoch: UInt64 = 0
private var documentPresentationCounter: UInt64 = 0
private var activeDocumentPresentationID: UInt64?
private var engine: RimeEngineBridging {
if let engineStorage { return engineStorage }
@@ -99,13 +113,17 @@ public final class TypingSessionController: ObservableObject {
layout: TypingLayoutProviding = StandardTypingLayout(),
englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil,
learningStore: EnglishLearningStore = EnglishLearningStore(),
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore()
frequentTermStore: FrequentTermStore = FrequentTermStore(),
hostHeavyProvider: @escaping @MainActor () -> Bool = {
FlowSessionBridge.isHostHeavy()
}
) {
self.engineFactory = engine ?? { LibrimeEngine() }
self.layout = layout
self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() }
self.hostHeavyProvider = hostHeavyProvider
self.learningStore = learningStore
self.rimeFrequentTermStore = rimeFrequentTermStore
self.frequentTermStore = frequentTermStore
// Avoid constructing librime until the first Chinese keystroke / prepare.
schema = TypingInputConfiguration.shared.schema
}
@@ -134,7 +152,7 @@ public final class TypingSessionController: ObservableObject {
}
public func enterTypingMode() {
let hostHeavy = FlowSessionBridge.isHostHeavy()
let hostHeavy = hostHeavyProvider()
KeyboardExtensionMemoryTelemetry.record(
"typing.enter.begin",
details: "language=\(language.rawValue) schema=\(schema.rawValue) "
@@ -165,6 +183,7 @@ public final class TypingSessionController: ObservableObject {
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
}
syncAutocapitalization()
isPreparingEngine = !prepared
if hostHeavy {
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.deferred",
@@ -172,24 +191,28 @@ public final class TypingSessionController: ObservableObject {
)
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
prepareTask?.cancel()
engineEpoch &+= 1
let epoch = engineEpoch
prepareTask = Task { [weak self] in
for _ in 0..<40 {
while self?.hostHeavyProvider() == true {
try? await Task.sleep(nanoseconds: 250_000_000)
guard let self, !Task.isCancelled else { return }
if !FlowSessionBridge.isHostHeavy() {
await self.prepareIfNeeded()
self.prepareTask = nil
return
}
guard !Task.isCancelled else { return }
}
guard let self, self.engineEpoch == epoch, !Task.isCancelled else { return }
await self.prepareIfNeeded(engineEpoch: epoch)
if self.engineEpoch == epoch {
self.prepareTask = nil
}
self?.prepareTask = nil
}
return
}
if prepareTask == nil, !prepared {
engineEpoch &+= 1
let epoch = engineEpoch
prepareTask = Task { [weak self] in
await self?.prepareIfNeeded()
self?.prepareTask = nil
await self?.prepareIfNeeded(engineEpoch: epoch)
guard let self, self.engineEpoch == epoch else { return }
self.prepareTask = nil
}
}
}
@@ -200,17 +223,14 @@ public final class TypingSessionController: ObservableObject {
details: "language=\(language.rawValue)"
)
OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot")
engineEpoch &+= 1
prepareTask?.cancel()
prepareTask = nil
endDocumentPresentation()
engineStorage?.teardown()
prepared = false
engineReady = false
composition = .empty
isCandidatePanelExpanded = false
page = .letters
resetShiftState()
clearEnglishWordState(keepPrevious: false)
clearPeriodShortcut()
isPreparingEngine = false
// Drop English lexicon pages when leaving typing (jetsam recovery).
EnglishLexicon.shared.unload()
englishStorage = nil
@@ -220,6 +240,32 @@ public final class TypingSessionController: ObservableObject {
)
}
/// Starts a new host-document scope without tearing down dictionaries or
/// the Rime runtime. A keyboard presentation can move between apps while
/// the extension process and controller remain alive.
@discardableResult
public func beginDocumentPresentation() -> UInt64 {
documentPresentationCounter &+= 1
let presentationID = documentPresentationCounter
activeDocumentPresentationID = presentationID
candidateRevision &+= 1
clearDocumentScopedState()
return presentationID
}
/// Invalidates all candidates and input shadows owned by the outgoing
/// document. Heavy engines stay warm until typing mode itself is left.
public func endDocumentPresentation() {
documentPresentationCounter &+= 1
activeDocumentPresentationID = nil
candidateRevision &+= 1
clearDocumentScopedState()
}
public func isCurrentDocumentPresentation(_ presentationID: UInt64) -> Bool {
activeDocumentPresentationID == presentationID
}
public func toggleCandidatePanelExpanded() {
guard canExpandCandidatePanel else {
isCandidatePanelExpanded = false
@@ -248,7 +294,7 @@ public final class TypingSessionController: ObservableObject {
if !raw.isEmpty { output = .insert(raw) }
}
language = newLanguage
engine.setLanguage(newLanguage)
engineStorage?.setLanguage(newLanguage)
page = .letters
isCandidatePanelExpanded = false
clearPeriodShortcut()
@@ -258,7 +304,9 @@ public final class TypingSessionController: ObservableObject {
englishEngine.prepare()
refreshEnglishSuggestions()
syncAutocapitalization()
synchronizeEnglishDocumentContext(caretMoved: true)
if activeDocumentPresentationID != nil {
synchronizeEnglishDocumentContext(caretMoved: true)
}
} else {
clearEnglishWordState(keepPrevious: false)
EnglishLexicon.shared.unload()
@@ -267,6 +315,14 @@ public final class TypingSessionController: ObservableObject {
return output
}
/// Reloads host-curated terms after App Group configuration changes.
public func reloadPersonalDictionaryTerms() {
refreshPersonalTerms()
if language == .english {
refreshEnglishSuggestions()
}
}
/// Flushes raw preedit, selects the next built-in scheme, and returns the
/// text that the caller should insert before switching.
public func cycleSchema() -> TypingOutput {
@@ -371,7 +427,7 @@ public final class TypingSessionController: ObservableObject {
composition = engine.composition
syncCandidatePanelVisibility()
clearOneShotShiftIfNeeded()
recordRimeCommit(committed)
recordChineseCommit(committed)
return committed.isEmpty ? .none : .insert(committed)
}
@@ -387,7 +443,7 @@ public final class TypingSessionController: ObservableObject {
let text = engine.processSpace() ?? " "
composition = engine.composition
syncCandidatePanelVisibility()
recordRimeCommit(text)
recordChineseCommit(text)
return .insert(text)
}
@@ -403,7 +459,7 @@ public final class TypingSessionController: ObservableObject {
let text = engine.processReturn() ?? "\n"
composition = engine.composition
syncCandidatePanelVisibility()
recordRimeCommit(text)
recordChineseCommit(text)
return .insert(text)
}
@@ -423,13 +479,28 @@ public final class TypingSessionController: ObservableObject {
// Selecting always collapses; follow-up composition may reopen .
isCandidatePanelExpanded = false
syncCandidatePanelVisibility()
recordRimeCommit(text)
recordChineseCommit(text)
return text.isEmpty ? .none : .insert(text)
}
private func recordRimeCommit(_ text: String) {
/// Candidate taps originate from a rendered snapshot. Reject the tap when
/// that snapshot has already been replaced by a newer composition.
public func selectCandidate(
id candidateID: String,
candidateRevision expectedRevision: UInt64? = nil
) -> TypingOutput {
if let expectedRevision, expectedRevision != candidateRevision {
return .none
}
guard let index = composition.candidates.firstIndex(where: { $0.id == candidateID }) else {
return .none
}
return selectCandidate(at: index)
}
private func recordChineseCommit(_ text: String) {
guard language == .chinese, suggestionsEnabled, !text.isEmpty else { return }
rimeFrequentTermStore.recordCommittedText(text)
frequentTermStore.recordCommittedText(text)
}
/// Drop in-flight pinyin so secure fields cannot commit into userdb.
@@ -575,6 +646,7 @@ public final class TypingSessionController: ObservableObject {
UIKitEnglishSystemLexicon.learnWord(word)
#endif
}
recordEnglishCommit(word)
refreshEnglishSuggestions(afterCommittedWord: word)
return suffix.isEmpty ? .none : .insert(suffix)
}
@@ -597,6 +669,7 @@ public final class TypingSessionController: ObservableObject {
#if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(pending.original)
#endif
recordEnglishCommit(pending.original)
refreshEnglishSuggestions(afterCommittedWord: pending.original)
return .replace(deleteCount: deleteCount, with: pending.original + " ")
}
@@ -609,6 +682,7 @@ public final class TypingSessionController: ObservableObject {
} else {
learningStore.recordAcceptance(of: chosen)
}
recordEnglishCommit(chosen)
if !englishCurrentWord.isEmpty {
let deleteCount = englishCurrentWord.count
@@ -627,6 +701,19 @@ public final class TypingSessionController: ObservableObject {
return .insert(chosen + " ")
}
private func recordEnglishCommit(_ text: String) {
guard language == .english,
suggestionsEnabled,
englishEngine.isPersonalTermCandidate(
text,
personalTerms: personalTermsCache,
systemWords: supplementaryWords
) else {
return
}
frequentTermStore.recordCommittedText(text)
}
private func commitEnglishWordIfNeededBeforeNonLetter() -> TypingOutput {
guard language == .english, !englishCurrentWord.isEmpty else { return .none }
return commitEnglishWord(suffix: "")
@@ -696,8 +783,15 @@ public final class TypingSessionController: ObservableObject {
/// Rebuilds English suggestion state from the real caret context.
/// At the document end, callbacks keep the local shadow when a host
/// briefly reports the immediately preceding edit (common in Notes).
public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) {
public func synchronizeEnglishDocumentContext(
caretMoved: Bool = false,
presentationID: UInt64? = nil
) {
guard language == .english else { return }
if let presentationID,
activeDocumentPresentationID != presentationID {
return
}
if caretMoved {
clearPeriodShortcut()
}
@@ -843,6 +937,16 @@ public final class TypingSessionController: ObservableObject {
pendingAutocorrection = nil
}
private func clearDocumentScopedState() {
engineStorage?.clearComposition()
composition = .empty
isCandidatePanelExpanded = false
page = .letters
resetShiftState()
clearEnglishWordState(keepPrevious: false)
clearPeriodShortcut()
}
private func synchronizeEnglishWordState(
precedingText: String,
followingText: String?
@@ -942,15 +1046,19 @@ public final class TypingSessionController: ObservableObject {
}
}
private func prepareIfNeeded() async {
private func prepareIfNeeded(engineEpoch expectedEpoch: UInt64) async {
guard !prepared else { return }
if FlowSessionBridge.isHostHeavy() {
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
if hostHeavyProvider() {
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.deferred",
details: "hostHeavy=1"
)
OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot")
return
while hostHeavyProvider() {
try? await Task.sleep(nanoseconds: 250_000_000)
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
}
}
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.begin",
@@ -963,9 +1071,11 @@ public final class TypingSessionController: ObservableObject {
)
do {
try await engine.prepare()
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
engine.setLanguage(language)
prepared = true
engineReady = engine.isReady
isPreparingEngine = false
schema = engine.schema
lastError = nil
lastErrorNeedsHostDeployment = false
@@ -981,10 +1091,12 @@ public final class TypingSessionController: ObservableObject {
category: "boot"
)
} catch {
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
lastError = error.localizedDescription
lastErrorNeedsHostDeployment =
(error as? RimeResourceError)?.isResolvedByHostDeployment ?? false
engineReady = false
isPreparingEngine = false
KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.failed",
details: "language=\(language.rawValue) errorType=\(String(describing: type(of: error)))"
@@ -1005,9 +1117,13 @@ public final class TypingSessionController: ObservableObject {
guard prepareTask == nil else { return }
guard RimeResourceInstaller.isReady else { return }
OSGDiag.log("rime.prepare retry after deployment", category: "boot")
engineEpoch &+= 1
let epoch = engineEpoch
isPreparingEngine = true
prepareTask = Task { [weak self] in
await self?.prepareIfNeeded()
self?.prepareTask = nil
await self?.prepareIfNeeded(engineEpoch: epoch)
guard let self, self.engineEpoch == epoch else { return }
self.prepareTask = nil
}
}
}