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:
@@ -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 card’s 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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user