feat(keyboard): rank pinyin by habit and persist user dictionaries

Erase dialect syllables before abbrev so wom prefers 我们, flush librime
on leave, and add a settings action to forget learned Chinese and English order.
This commit is contained in:
Rocky
2026-08-14 21:49:03 +08:00
parent 2c3a3f80f3
commit b13a1e904b
15 changed files with 588 additions and 5 deletions
+3
View File
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Pinyin abbreviation ranking**: drop dialect single-letter syllables (`m` / `n` / `ng` / `hm`) before abbrev so mixes like `wom` prefer 我们, matching rime-pinyin-simp; full pinyin also allows `zh` / `ch` / `sh` two-key abbrev (resource version `2.4.0`). / **拼音简拼排序**:在简拼前擦掉方言单字母音节(`m` / `n` / `ng` / `hm`),使 `wom` 一类混拼优先「我们」,对齐 rime-pinyin-simp;全拼同时支持 `zh` / `ch` / `sh` 两键简拼(资源版本 `2.4.0`)。
- **Clear typing habits**: Settings → Text Input can reset learned Chinese Rime user dictionaries and English boosts without deleting the personal dictionary. / **清除打字习惯**:设置 → 文本输入可重置中文 Rime 用户词库与英文加分,不删除个性词库。
- **Overlapping key presses**: the typing grid tracks multiple fingers, so the next key can go down before the previous lifts. Pending letters commit in press order (not release order); Shift can be held with one finger while another types. / **叠指连打**:打字网格跟踪多指,上一键未松开也可按下下一键。未提交的字母按按下顺序出字(而非抬手顺序);一只手指按住 Shift 时另一只可打字。
- **Period shortcut**: in English, a second Space shortly after a Space that follows a word becomes `. ` and arms sentence Shift, matching the system "." Shortcut. / **句号快捷**:英文下,在单词后的空格上短时间内再按一次空格会变成 `. ` 并点亮句首 Shift,对齐系统「句号快捷」。
- **Return key labels**: Go / Search / Send / Done / Next / Join and the other `UIReturnKeyType` values show their system captions on the green action key instead of collapsing to Send or a return arrow. / **回车键文案**:前往 / 搜索 / 发送 / 完成 / 下一项 / 加入等 `UIReturnKeyType` 在绿色动作键上显示系统对应文案,不再一律变成「发送」或换行箭头。
@@ -26,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Skill drag preview corners**: the lift preview clips to the cards continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。
### Changed
- **Chinese user-dict flush**: leaving the typing surface finalizes librime so user-frequency ticks persist; secure fields insert Latin and skip Rime so passwords are not learned. / **中文用户词落盘**:离开打字表面时 finalize librime,使用频度得以保存;安全输入框改为直接插入拉丁字母、不进 Rime,避免把密码写入用户词库。
- **Bundled Shortcut names**: Tasks and Events now install their signed in-app resources as `OSGExtractTodos` and `OSGExtractEvents`, matching `OSGSaveToNotes`; existing Chinese-named copies must be replaced from the Skills tab. / **内置捷径名称**:待办与日程改为安装 App 内签名资源 `OSGExtractTodos``OSGExtractEvents`,与 `OSGSaveToNotes` 保持一致;已有中文名称版本需从技能页重新安装。
- **Navigation icons**: Skills uses a wand; Styles uses a dial. The phone dock and iPad sidebar (except Home) use outline when idle and fill when selected. Mac Styles / Settings follow the same pairing. iPad and Mac Home stay the house icon. / **导航图标**:技能改为魔杖,风格改为旋钮。手机 Dock 与 iPad 侧栏(除首页外)未选中描边、选中填充;Mac 的风格 / 设置同样切换。iPad / Mac 首页仍用房子图标。
- **Phone dock size**: slightly smaller — 22 pt icons, 46 pt rows, 8 pt glass padding. / **手机 Dock 尺寸**:略缩小,图标 22 pt、行高 46 pt、玻璃内边距 8 pt。
@@ -90,4 +90,31 @@ final class RimeDeploymentController: ObservableObject {
}
}
}
/// Wipes implicit typing habits under the same hostHeavy gate as deploy,
/// so the keyboard extension cannot keep LevelDB open while files vanish.
func clearTypingHabits() {
guard activeTask == nil else { return }
OSGDiag.log("typing.habits.clear begin \(OSGDiag.memoryTag())", category: "flow")
status = .deploying
activeTask = Task { @MainActor in
defer { activeTask = nil }
FlowSessionBridge.setHostHeavy(true)
do {
try await TypingHabitStore.clearAll()
FlowSessionBridge.setHostHeavy(false)
AppGroupConfigDarwin.postConfigChanged()
status = RimeResourceInstaller.isReady ? .ready : .idle
OSGDiag.log("typing.habits.clear done \(OSGDiag.memoryTag())", category: "flow")
} catch {
FlowSessionBridge.setHostHeavy(false)
status = .failed(error.localizedDescription)
OSGDiag.log(
"typing.habits.clear failed error=\(error.localizedDescription)",
category: "flow"
)
}
}
}
}
@@ -12,6 +12,7 @@ struct TypingInputSettingsView: View {
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var configuration = TypingInputConfiguration.shared
@ObservedObject private var deployment = RimeDeploymentController.shared
@State private var showClearHabitsConfirmation = false
private var isDeploying: Bool { deployment.isDeploying }
@@ -69,11 +70,36 @@ struct TypingInputSettingsView: View {
}
.disabled(isDeploying)
}
Section {
Button(AppL10n.string("settings.typingInput.habits.clear", language: config.uiLanguage)) {
showClearHabitsConfirmation = true
}
.disabled(isDeploying)
.foregroundStyle(palette.danger)
} footer: {
Text(AppL10n.string("settings.typingInput.habits.footer", language: config.uiLanguage))
}
}
.scrollContentBackground(.hidden)
.background(palette.background)
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
AppL10n.string("settings.typingInput.habits.clear.title", language: config.uiLanguage),
isPresented: $showClearHabitsConfirmation,
titleVisibility: .visible
) {
Button(
AppL10n.string("settings.typingInput.habits.clear.confirm", language: config.uiLanguage),
role: .destructive
) {
deployment.clearTypingHabits()
}
Button(AppL10n.string("common.cancel", language: config.uiLanguage), role: .cancel) {}
} message: {
Text(AppL10n.string("settings.typingInput.habits.clear.message", language: config.uiLanguage))
}
}
private var hasDeploymentError: Bool {
+5
View File
@@ -238,6 +238,11 @@
"settings.typingInput.resources.ready" = "Ready";
"settings.typingInput.resources.pending" = "Not initialized";
"settings.typingInput.resources.redeploy" = "Redeploy Input Resources";
"settings.typingInput.habits.clear" = "Clear Typing Habits";
"settings.typingInput.habits.clear.title" = "Clear Typing Habits?";
"settings.typingInput.habits.clear.confirm" = "Clear";
"settings.typingInput.habits.clear.message" = "Resets learned word order for Chinese and English. Personal dictionary entries are kept.";
"settings.typingInput.habits.footer" = "Learned frequency only. Words you added to the personal dictionary stay.";
"settings.speechRecognition.title" = "Speech Recognition";
"settings.textPolish.title" = "Text Polish";
"settings.preferences.title" = "Preferences";
@@ -238,6 +238,11 @@
"settings.typingInput.resources.ready" = "已就绪";
"settings.typingInput.resources.pending" = "待初始化";
"settings.typingInput.resources.redeploy" = "重新部署输入法资源";
"settings.typingInput.habits.clear" = "清除打字习惯";
"settings.typingInput.habits.clear.title" = "清除打字习惯?";
"settings.typingInput.habits.clear.confirm" = "清除";
"settings.typingInput.habits.clear.message" = "将重置中文和英文的学习词序。个性词库中的词条会保留。";
"settings.typingInput.habits.footer" = "只清除使用频度。你手动加入个性词库的词不会受影响。";
"settings.speechRecognition.title" = "语音识别";
"settings.textPolish.title" = "文本润色";
"settings.preferences.title" = "偏好设置";
@@ -370,6 +370,19 @@ final class EnglishTypingTests: XCTestCase {
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello "))
}
func testLearningStoreClearRemovesBoosts() {
let suiteName = "EnglishLearningStore.clear.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = EnglishLearningStore(defaults: defaults)
store.recordAcceptance(of: "hello")
XCTAssertGreaterThan(store.boost(for: "hello"), 0)
store.clear()
XCTAssertEqual(store.boost(for: "hello"), 0)
XCTAssertTrue(store.snapshot().isEmpty)
}
@MainActor
private func apply(_ typing: TypingSessionController, _ output: TypingOutput) {
typing.syncAutocapitalization(
@@ -456,6 +456,24 @@ final class KeyboardSurfaceStateTests: XCTestCase {
XCTAssertEqual(engine.lastProcessedCharacter, "n")
XCTAssertEqual(typing.composition.preedit, "n")
}
func testSecureFieldInsertsLatinWithoutRime() {
let engine = TrackingStubRimeEngine()
let typing = TypingSessionController(engine: { engine })
_ = typing.handleKey("n")
XCTAssertEqual(engine.processCharacterCallCount, 1)
typing.suggestionsEnabled = false
XCTAssertTrue(typing.composition.preedit.isEmpty)
XCTAssertEqual(engine.clearCompositionCallCount, 1)
XCTAssertEqual(typing.handleKey("a"), .insert("a"))
XCTAssertEqual(engine.processCharacterCallCount, 1)
XCTAssertEqual(typing.handleSpace(), .insert(" "))
XCTAssertEqual(engine.processSpaceCallCount, 0)
XCTAssertEqual(typing.selectCandidate(at: 0), .none)
}
}
/// Stub that records `processCharacter` calls for Chinese Shift bypass tests.
@@ -465,6 +483,8 @@ private final class TrackingStubRimeEngine: RimeEngineBridging {
var isReady: Bool = true
var schema: TypingInputSchema = .fullPinyin
private(set) var processCharacterCallCount = 0
private(set) var processSpaceCallCount = 0
private(set) var clearCompositionCallCount = 0
private(set) var lastProcessedCharacter: Character?
func prepare() async throws {}
@@ -496,6 +516,7 @@ private final class TrackingStubRimeEngine: RimeEngineBridging {
}
func processSpace() -> String? {
processSpaceCallCount += 1
composition = .empty
return " "
}
@@ -517,6 +538,7 @@ private final class TrackingStubRimeEngine: RimeEngineBridging {
}
func clearComposition() {
clearCompositionCallCount += 1
composition = .empty
}
}
@@ -1,6 +1,7 @@
// LibrimeIntegrationTests.swift
// OSGKeyboard · Ext unit tests
import Darwin
import XCTest
@testable import OSGKeyboardShared
@@ -264,4 +265,365 @@ final class LibrimeIntegrationTests: XCTestCase {
)
bridge.finalizeRuntime()
}
func testAbbreviatedPinyinPrefersHighFrequencyPhrases() throws {
let env = try deployedRimeEnvironment()
defer { try? env.fileManager.removeItem(at: env.root) }
let bridge = OSGRimeBridge(
sharedDataDirectory: env.shared.path,
userDataDirectory: env.user.path,
distributionVersion: "tests-abbrev"
)
try bridge.start()
defer { bridge.finalizeRuntime() }
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
let cases: [(String, String)] = [
("wom", "我们"),
("wm", "我们"),
("women", "我们"),
("bn", "不能"),
("nh", "你好"),
("nihao", "你好")
]
for (keys, expected) in cases {
type(keys, on: bridge)
let snapshot = bridge.snapshot(withCandidateLimit: 40)
let texts = snapshot.candidates.map(\.text)
XCTAssertEqual(
texts.first,
expected,
"\(keys) top-1 \(texts.prefix(12))"
)
XCTAssertNotEqual(texts.first, "我呒")
XCTAssertFalse(texts.prefix(3).contains("我呒"))
}
type("zhangwei", on: bridge)
let zhangwei = bridge.snapshot(withCandidateLimit: 40)
XCTAssertTrue(
zhangwei.candidates.contains(where: { $0.text == "张伟" }),
"zhangwei: \(zhangwei.candidates.map(\.text).prefix(20))"
)
type("zhg", on: bridge)
let zhg = bridge.snapshot(withCandidateLimit: 40)
XCTAssertTrue(
zhg.candidates.contains(where: { $0.text == "中国" }),
"zhg should reach 中国 via zh abbrev: \(zhg.candidates.map(\.text).prefix(20))"
)
}
func testUserDictionaryLearnsSelectedPhraseAcrossFinalize() throws {
let env = try deployedRimeEnvironment()
defer { try? env.fileManager.removeItem(at: env.root) }
let bridge = OSGRimeBridge(
sharedDataDirectory: env.shared.path,
userDataDirectory: env.user.path,
distributionVersion: "tests-learn"
)
try bridge.start()
defer { bridge.finalizeRuntime() }
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
type("zhangwei", on: bridge)
let before = bridge.snapshot(withCandidateLimit: 80)
let startIndex = try XCTUnwrap(
before.candidates.firstIndex(where: { $0.text == "张伟" }),
"missing 张伟: \(before.candidates.map(\.text).prefix(20))"
)
for _ in 0..<8 {
type("zhangwei", on: bridge)
let snapshot = bridge.snapshot(withCandidateLimit: 80)
let current = try XCTUnwrap(snapshot.candidates.first(where: { $0.text == "张伟" }))
XCTAssertTrue(bridge.selectCandidate(at: current.index))
_ = bridge.snapshot(withCandidateLimit: 8)
}
bridge.finalizeRuntime()
let reopened = OSGRimeBridge(
sharedDataDirectory: env.shared.path,
userDataDirectory: env.user.path,
distributionVersion: "tests-learn"
)
try reopened.start()
defer { reopened.finalizeRuntime() }
XCTAssertTrue(reopened.selectSchema(TypingInputSchema.fullPinyin.rawValue))
type("zhangwei", on: reopened)
let after = reopened.snapshot(withCandidateLimit: 80)
let learnedIndex = try XCTUnwrap(
after.candidates.firstIndex(where: { $0.text == "张伟" })
)
XCTAssertLessThanOrEqual(learnedIndex, startIndex)
reopened.finalizeRuntime()
try RimeResourceInstaller.removeUserDictionaries(in: env.user)
let names = try env.fileManager.contentsOfDirectory(atPath: env.user.path)
XCTAssertFalse(names.contains { $0.lowercased().contains("userdb") })
XCTAssertTrue(
env.fileManager.fileExists(atPath: env.user.appendingPathComponent("build").path)
)
}
func testAssembledPhraseBecomesUserWord() throws {
let env = try deployedRimeEnvironment()
defer { try? env.fileManager.removeItem(at: env.root) }
let bridge = OSGRimeBridge(
sharedDataDirectory: env.shared.path,
userDataDirectory: env.user.path,
distributionVersion: "tests-encode"
)
try bridge.start()
defer { bridge.finalizeRuntime() }
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
type("chu", on: bridge)
let chuSnap = bridge.snapshot(withCandidateLimit: 80)
let chu = try XCTUnwrap(
chuSnap.candidates.first(where: { $0.text == "" }),
"missing 褚: \(chuSnap.candidates.map(\.text).prefix(20))"
)
XCTAssertTrue(bridge.selectCandidate(at: chu.index))
_ = bridge.snapshot(withCandidateLimit: 8)
type("han", on: bridge)
let hanSnap = bridge.snapshot(withCandidateLimit: 80)
let han = try XCTUnwrap(
hanSnap.candidates.first(where: { $0.text == "" }),
"missing 寒: \(hanSnap.candidates.map(\.text).prefix(20))"
)
XCTAssertTrue(bridge.selectCandidate(at: han.index))
_ = bridge.snapshot(withCandidateLimit: 8)
type("chuhan", on: bridge)
let learned = bridge.snapshot(withCandidateLimit: 80)
let assembled = learned.candidates.contains(where: { $0.text == "褚寒" })
if !assembled {
throw XCTSkip(
"script_translator did not persist 褚寒 after 褚+寒 commits: "
+ "\(learned.candidates.map(\.text).prefix(20)). "
+ "Auto-phrasing stays a follow-up, not a Swift overlay in this slice."
)
}
}
/// Host 2.4.0 + prepare/teardown phys_footprint
/// jetsam XCTest
func testHostDeployAndTypingCyclesKeepMemoryStable() throws {
let staged = try stagedRimeEnvironment()
defer { try? staged.fileManager.removeItem(at: staged.root) }
let beforeDeploy = MemoryProbe.capture()
let deployer = OSGRimeBridge(
sharedDataDirectory: staged.shared.path,
userDataDirectory: staged.user.path,
distributionVersion: "tests-memory-deploy"
)
try deployer.deploy(withFullCheck: true)
let afterDeploy = MemoryProbe.capture()
deployer.finalizeRuntime()
let afterHostFinalize = MemoryProbe.capture()
let deployFootprintGrowth = afterDeploy.physFootprintMB - beforeDeploy.physFootprintMB
let deployRSSGrowth = afterDeploy.rssMB - beforeDeploy.rssMB
XCTContext.runActivity(named: "host deploy footprint") { _ in
NSLog(
"[OSGDiag/rime-mem] deploy rss=%.1f→%.1fMB deltaRSS=%.1fMB foot=%.1f→%.1fMB deltaFoot=%.1fMB finalize rss=%.1fMB foot=%.1fMB",
beforeDeploy.rssMB,
afterDeploy.rssMB,
deployRSSGrowth,
beforeDeploy.physFootprintMB,
afterDeploy.physFootprintMB,
deployFootprintGrowth,
afterHostFinalize.rssMB,
afterHostFinalize.physFootprintMB
)
}
// Simulator phys_footprint is compressed and often stays flat; RSS
// still catches anonymous growth. Device jetsam follows footprint.
XCTAssertLessThan(
deployFootprintGrowth,
HostMemoryBudget.deferHeavyWorkAboveMB,
"host deploy footprint grew \(String(format: "%.1f", deployFootprintGrowth)) MB"
)
XCTAssertLessThan(
deployFootprintGrowth,
80,
"host deploy footprint grew \(String(format: "%.1f", deployFootprintGrowth)) MB (budget estimate is 24 MB)"
)
var peakTyping = afterHostFinalize
var lastIdle = afterHostFinalize
let cycleCount = 20
for cycle in 1...cycleCount {
try autoreleasepool {
let bridge = OSGRimeBridge(
sharedDataDirectory: staged.shared.path,
userDataDirectory: staged.user.path,
distributionVersion: "tests-memory-session"
)
try bridge.start()
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
// Production LibrimeEngine copies 160 candidates per keystroke.
for keys in ["nihao", "wom", "zhangwei", "zhongg"] {
type(keys, on: bridge)
_ = bridge.snapshot(withCandidateLimit: 160)
}
let during = MemoryProbe.capture()
if during.physFootprintMB > peakTyping.physFootprintMB {
peakTyping = during
}
bridge.finalizeRuntime()
}
lastIdle = MemoryProbe.capture()
NSLog(
"[OSGDiag/rime-mem] cycle=%d idleFoot=%.1fMB peakFoot=%.1fMB rss=%.1fMB",
cycle,
lastIdle.physFootprintMB,
peakTyping.physFootprintMB,
lastIdle.rssMB
)
}
let idleFootprintGrowth = lastIdle.physFootprintMB - afterHostFinalize.physFootprintMB
let idleRSSGrowth = lastIdle.rssMB - afterHostFinalize.rssMB
let sessionPeak = peakTyping.physFootprintMB - afterHostFinalize.physFootprintMB
XCTAssertLessThan(
idleFootprintGrowth,
16,
"\(cycleCount) prepare/finalize cycles leaked \(String(format: "%.1f", idleFootprintGrowth)) MB footprint"
)
// Debug malloc is noisy; a real initialize/finalize leak would climb each cycle.
XCTAssertLessThan(
idleRSSGrowth,
24,
"\(cycleCount) prepare/finalize cycles leaked \(String(format: "%.1f", idleRSSGrowth)) MB RSS"
)
XCTAssertLessThan(
sessionPeak,
40,
"typing session peak added \(String(format: "%.1f", sessionPeak)) MB above post-deploy idle"
)
try RimeResourceInstaller.removeUserDictionaries(in: staged.user)
let afterClear = MemoryProbe.capture()
XCTAssertLessThan(
afterClear.physFootprintMB - lastIdle.physFootprintMB,
8,
"clearing userdb while runtime is down should be a file-only wipe"
)
}
func testRemoveUserDictionariesKeepsBuildDirectory() throws {
let fileManager = FileManager.default
let user = fileManager.temporaryDirectory
.appendingPathComponent("OSGUserDB-\(UUID().uuidString)", isDirectory: true)
defer { try? fileManager.removeItem(at: user) }
try fileManager.createDirectory(at: user.appendingPathComponent("build"), withIntermediateDirectories: true)
try fileManager.createDirectory(
at: user.appendingPathComponent("osg_pinyin.userdb"),
withIntermediateDirectories: true
)
try "keep\n".write(
to: user.appendingPathComponent("user.yaml"),
atomically: true,
encoding: .utf8
)
try RimeResourceInstaller.removeUserDictionaries(in: user)
XCTAssertTrue(fileManager.fileExists(atPath: user.appendingPathComponent("build").path))
XCTAssertTrue(fileManager.fileExists(atPath: user.appendingPathComponent("user.yaml").path))
XCTAssertFalse(
fileManager.fileExists(atPath: user.appendingPathComponent("osg_pinyin.userdb").path)
)
}
private func stagedRimeEnvironment() throws -> (
root: URL,
shared: URL,
user: URL,
fileManager: FileManager
) {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("OSGRimeTests-\(UUID().uuidString)", isDirectory: true)
let shared = root.appendingPathComponent("SharedSupport", isDirectory: true)
let user = root.appendingPathComponent("UserData", isDirectory: true)
try fileManager.createDirectory(at: shared, withIntermediateDirectories: true)
try fileManager.createDirectory(at: user, withIntermediateDirectories: true)
let bundle = Bundle(for: LibrimeIntegrationTests.self)
let dictionary = try XCTUnwrap(
bundle.url(forResource: "osg_pinyin.dict", withExtension: "yaml")
)
try fileManager.copyItem(
at: dictionary,
to: shared.appendingPathComponent("osg_pinyin.dict.yaml")
)
try RimeSchemaGenerator.defaultConfiguration().write(
to: shared.appendingPathComponent("default.yaml"),
atomically: true,
encoding: .utf8
)
for schema in TypingInputSchema.allCases {
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: []).write(
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
atomically: true,
encoding: .utf8
)
}
return (root, shared, user, fileManager)
}
private func deployedRimeEnvironment() throws -> (
root: URL,
shared: URL,
user: URL,
fileManager: FileManager
) {
let staged = try stagedRimeEnvironment()
let deployer = OSGRimeBridge(
sharedDataDirectory: staged.shared.path,
userDataDirectory: staged.user.path,
distributionVersion: "tests"
)
try deployer.deploy(withFullCheck: true)
deployer.finalizeRuntime()
return staged
}
private func type(_ keys: String, on bridge: OSGRimeBridge) {
bridge.clearComposition()
for scalar in keys.utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0), keys)
}
}
}
/// RSS plus phys_footprint. Jetsam on device tracks footprint more closely
/// than `task_basic_info.resident_size`.
private struct MemoryProbe {
let rssMB: Double
let physFootprintMB: Double
static func capture() -> MemoryProbe {
MemoryProbe(rssMB: OSGDiag.memoryMB(), physFootprintMB: physFootprintMB())
}
private static func physFootprintMB() -> Double {
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.size
)
let kr = withUnsafeMutablePointer(to: &info) { ptr in
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { rebound in
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), rebound, &count)
}
}
guard kr == KERN_SUCCESS else { return -1 }
return Double(info.phys_footprint) / 1_048_576.0
}
}
@@ -41,6 +41,37 @@ final class RimeSchemaGeneratorTests: XCTestCase {
XCTAssertLessThan(fuzzy.lowerBound, transform.lowerBound)
}
func testDialectErasePrecedesFuzzyAndAbbrev() throws {
let yaml = RimeSchemaGenerator.schema(for: .fullPinyin, fuzzyPairs: [.nL])
let eraseN = try XCTUnwrap(yaml.range(of: "erase/^n$/"))
let fuzzy = try XCTUnwrap(yaml.range(of: "derive/^n/l/"))
let abbrev = try XCTUnwrap(yaml.range(of: "abbrev/^([a-z]).+$/$1/"))
XCTAssertLessThan(eraseN.lowerBound, fuzzy.lowerBound)
XCTAssertLessThan(fuzzy.lowerBound, abbrev.lowerBound)
}
func testAllSchemasEraseDialectSyllables() {
for schema in TypingInputSchema.allCases {
let yaml = RimeSchemaGenerator.schema(for: schema, fuzzyPairs: [])
XCTAssertTrue(yaml.contains("erase/^hm$/"), schema.rawValue)
XCTAssertTrue(yaml.contains("erase/^m$/"), schema.rawValue)
XCTAssertTrue(yaml.contains("erase/^n$/"), schema.rawValue)
XCTAssertTrue(yaml.contains("erase/^ng$/"), schema.rawValue)
}
}
func testZhAbbrevIsFullPinyinOnly() {
let full = RimeSchemaGenerator.schema(for: .fullPinyin, fuzzyPairs: [])
XCTAssertTrue(full.contains("abbrev/^([zcs]h).+$/$1/"))
for schema in [TypingInputSchema.microsoftDoublePinyin, .sogouDoublePinyin] {
let yaml = RimeSchemaGenerator.schema(for: schema, fuzzyPairs: [])
XCTAssertFalse(
yaml.contains("abbrev/^([zcs]h).+$/$1/"),
schema.rawValue
)
}
}
@MainActor
func testTypingConfigurationDefaultsToFullPinyinWithoutFuzzyPairs() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
@@ -35,6 +35,10 @@ public final class EnglishLearningStore: @unchecked Sendable {
(defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int]) ?? [:]
}
public func clear() {
defaults.removeObject(forKey: Self.defaultsKey)
}
private func mutate(word: String, delta: Int) {
let key = word.lowercased()
guard !key.isEmpty else { return }
+2 -1
View File
@@ -58,7 +58,8 @@ public final class LibrimeEngine: RimeEngineBridging {
public func teardown() {
bridge?.clearComposition()
bridge?.stopSession()
// Session destroy alone does not flush LevelDB user dictionaries.
bridge?.finalizeRuntime()
bridge = nil
composition = .empty
isReady = false
@@ -72,7 +72,7 @@ public struct RimeResourcePaths: Sendable {
public actor RimeResourceInstaller {
public static let shared = RimeResourceInstaller()
/// Bump when SharedSupport layout / schema / import_tables contract changes.
public static let resourceVersion = "2.3.0"
public static let resourceVersion = "2.4.0"
public init() {}
@@ -231,6 +231,28 @@ public actor RimeResourceInstaller {
// deployments and is intentionally not needed here.
bridge.finalizeRuntime()
}
/// Deletes librime user dictionaries under `UserData`, keeping `build/`
/// so `isReady` stays true. Host-only: the keyboard must not race LevelDB.
public func clearUserDictionary() throws {
guard Self.canDeployInCurrentProcess else {
throw RimeResourceError.hostAppRequired
}
let paths = try RimeResourcePaths.resolve()
try Self.removeUserDictionaries(in: paths.userData)
}
/// Testable file-level wipe. Matches LevelDB folders like `osg_pinyin.userdb`.
nonisolated public static func removeUserDictionaries(
in userData: URL,
fileManager: FileManager = .default
) throws {
guard fileManager.fileExists(atPath: userData.path) else { return }
let names = try fileManager.contentsOfDirectory(atPath: userData.path)
for name in names where name.lowercased().contains("userdb") {
try fileManager.removeItem(at: userData.appendingPathComponent(name))
}
}
}
extension RimeResourceInstaller {
@@ -43,7 +43,9 @@ public enum RimeSchemaGenerator {
let alphabet = inputSchema == .fullPinyin
? "zyxwvutsrqponmlkjihgfedcba"
: "zyxwvutsrqponmlkjihgfedcba;"
let algebra = fuzzyRules(fuzzyPairs) + algebraRules(for: inputSchema)
// Dialect single-letter syllables must be erased before fuzzy/abbrev
// so `n/l` cannot revive as `l`, and `wom` cannot exact-match .
let algebra = dialectEraseRules + fuzzyRules(fuzzyPairs) + algebraRules(for: inputSchema)
let algebraYAML = algebra.map { " - '\($0)'" }.joined(separator: "\n")
return """
@@ -112,6 +114,16 @@ public enum RimeSchemaGenerator {
"""
}
/// Drop Wu/dialect exact spellings (` m`, ` n/ng`, ` hm`) before
/// first-letter abbrev, matching rime-pinyin-simp. Keep the dictionary
/// rows; remains reachable as `en`, as `mu`.
public static let dialectEraseRules: [String] = [
"erase/^hm$/",
"erase/^m$/",
"erase/^n$/",
"erase/^ng$/"
]
/// Rules run against full-pinyin dictionary codes before double-pinyin
/// transforms, so fuzzy pairs work consistently in all three schemas.
public static func fuzzyRules(_ enabled: Set<PinyinFuzzyPair>) -> [String] {
@@ -144,7 +156,9 @@ public enum RimeSchemaGenerator {
case .fullPinyin:
return [
"derive/^([jqxy])u$/$1v/",
"abbrev/^([a-z]).+$/$1/"
"abbrev/^([a-z]).+$/$1/",
// Two-letter initials; do not add this to double pinyin.
"abbrev/^([zcs]h).+$/$1/"
]
case .microsoftDoublePinyin, .sogouDoublePinyin:
@@ -0,0 +1,18 @@
// TypingHabitStore.swift
// OSGKeyboard · Shared
//
// Cross-language "forget" for implicit typing habits. Ranking stays
// language-specific (EnglishLearningStore vs librime userdb).
import Foundation
public enum TypingHabitStore {
/// Clears English boosts and Chinese Rime user dictionaries.
/// Does not touch PersonalDictionary / osg_personal.
public static func clearAll(
englishStore: EnglishLearningStore = EnglishLearningStore()
) async throws {
englishStore.clear()
try await RimeResourceInstaller.shared.clearUserDictionary()
}
}
@@ -26,7 +26,13 @@ public final class TypingSessionController: ObservableObject {
@Published public private(set) var lastErrorNeedsHostDeployment: Bool = false
/// When true, English suggestions / autocorrect stay off (secure fields).
@Published public var suggestionsEnabled: Bool = true
/// Chinese composition is also skipped so passwords never enter Rime userdb.
@Published public var suggestionsEnabled: Bool = true {
didSet {
guard oldValue, !suggestionsEnabled else { return }
abandonChineseComposition()
}
}
/// Chevron appears only for Chinese composition with at least two candidates.
public var canExpandCandidatePanel: Bool {
@@ -306,6 +312,12 @@ public final class TypingSessionController: ObservableObject {
return handleEnglishCharacter(ch)
}
if !suggestionsEnabled {
clearOneShotShiftIfNeeded()
abandonChineseComposition()
return .insert(String(ch))
}
// Chinese + Shift: insert Latin directly (iOS-style mix-in), leave Rime
// composition untouched. Rime's alphabet is lowercase-only, so uppercase
// keycodes would otherwise be rejected with no output.
@@ -327,6 +339,10 @@ public final class TypingSessionController: ObservableObject {
return handleEnglishSpace()
}
clearPeriodShortcut()
if !suggestionsEnabled {
abandonChineseComposition()
return .insert(" ")
}
let text = engine.processSpace() ?? " "
composition = engine.composition
syncCandidatePanelVisibility()
@@ -338,6 +354,10 @@ public final class TypingSessionController: ObservableObject {
if language == .english {
return commitEnglishWord(suffix: "\n")
}
if !suggestionsEnabled {
abandonChineseComposition()
return .insert("\n")
}
let text = engine.processReturn() ?? "\n"
composition = engine.composition
syncCandidatePanelVisibility()
@@ -349,6 +369,9 @@ public final class TypingSessionController: ObservableObject {
if language == .english {
return selectEnglishCandidate(at: index)
}
if !suggestionsEnabled {
return .none
}
guard composition.candidates.indices.contains(index) else { return .none }
// Display order may put phrases before first-syllable chars; select by engine index.
let engineIndex = composition.candidates[index].engineIndex
@@ -360,6 +383,13 @@ public final class TypingSessionController: ObservableObject {
return text.isEmpty ? .none : .insert(text)
}
/// Drop in-flight pinyin so secure fields cannot commit into userdb.
private func abandonChineseComposition() {
engineStorage?.clearComposition()
composition = .empty
isCandidatePanelExpanded = false
}
// MARK: - English
private func handleEnglishSpace() -> TypingOutput {