diff --git a/CHANGELOG.md b/CHANGELOG.md index 2253082..bf1b9bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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。 diff --git a/OSGKeyboard/Services/RimeDeploymentController.swift b/OSGKeyboard/Services/RimeDeploymentController.swift index 98fa0a0..c9c1dfd 100644 --- a/OSGKeyboard/Services/RimeDeploymentController.swift +++ b/OSGKeyboard/Services/RimeDeploymentController.swift @@ -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" + ) + } + } + } } diff --git a/OSGKeyboard/Views/TypingInputSettingsView.swift b/OSGKeyboard/Views/TypingInputSettingsView.swift index cb7864b..f7a7698 100644 --- a/OSGKeyboard/Views/TypingInputSettingsView.swift +++ b/OSGKeyboard/Views/TypingInputSettingsView.swift @@ -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 { diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index d684e4b..f4810ca 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -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"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 122ed85..d443994 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -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" = "偏好设置"; diff --git a/OSGKeyboardExtTests/EnglishTypingTests.swift b/OSGKeyboardExtTests/EnglishTypingTests.swift index fbb9c2c..602c746 100644 --- a/OSGKeyboardExtTests/EnglishTypingTests.swift +++ b/OSGKeyboardExtTests/EnglishTypingTests.swift @@ -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( diff --git a/OSGKeyboardExtTests/KeyboardSurfaceStateTests.swift b/OSGKeyboardExtTests/KeyboardSurfaceStateTests.swift index c91ae52..e961e98 100644 --- a/OSGKeyboardExtTests/KeyboardSurfaceStateTests.swift +++ b/OSGKeyboardExtTests/KeyboardSurfaceStateTests.swift @@ -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 } } diff --git a/OSGKeyboardExtTests/LibrimeIntegrationTests.swift b/OSGKeyboardExtTests/LibrimeIntegrationTests.swift index 0510e87..0adc73c 100644 --- a/OSGKeyboardExtTests/LibrimeIntegrationTests.swift +++ b/OSGKeyboardExtTests/LibrimeIntegrationTests.swift @@ -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.size / MemoryLayout.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 + } } diff --git a/OSGKeyboardExtTests/RimeSchemaGeneratorTests.swift b/OSGKeyboardExtTests/RimeSchemaGeneratorTests.swift index 07ac8b3..4bf56a3 100644 --- a/OSGKeyboardExtTests/RimeSchemaGeneratorTests.swift +++ b/OSGKeyboardExtTests/RimeSchemaGeneratorTests.swift @@ -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)" diff --git a/OSGKeyboardShared/Typing/EnglishLearningStore.swift b/OSGKeyboardShared/Typing/EnglishLearningStore.swift index e15451f..1ef2899 100644 --- a/OSGKeyboardShared/Typing/EnglishLearningStore.swift +++ b/OSGKeyboardShared/Typing/EnglishLearningStore.swift @@ -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 } diff --git a/OSGKeyboardShared/Typing/LibrimeEngine.swift b/OSGKeyboardShared/Typing/LibrimeEngine.swift index fe93022..6b68737 100644 --- a/OSGKeyboardShared/Typing/LibrimeEngine.swift +++ b/OSGKeyboardShared/Typing/LibrimeEngine.swift @@ -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 diff --git a/OSGKeyboardShared/Typing/RimeResourceInstaller.swift b/OSGKeyboardShared/Typing/RimeResourceInstaller.swift index 20b7e6b..cc62feb 100644 --- a/OSGKeyboardShared/Typing/RimeResourceInstaller.swift +++ b/OSGKeyboardShared/Typing/RimeResourceInstaller.swift @@ -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 { diff --git a/OSGKeyboardShared/Typing/RimeSchemaGenerator.swift b/OSGKeyboardShared/Typing/RimeSchemaGenerator.swift index a3ce2c1..7d742dd 100644 --- a/OSGKeyboardShared/Typing/RimeSchemaGenerator.swift +++ b/OSGKeyboardShared/Typing/RimeSchemaGenerator.swift @@ -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) -> [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: diff --git a/OSGKeyboardShared/Typing/TypingHabitStore.swift b/OSGKeyboardShared/Typing/TypingHabitStore.swift new file mode 100644 index 0000000..8e39bf8 --- /dev/null +++ b/OSGKeyboardShared/Typing/TypingHabitStore.swift @@ -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() + } +} diff --git a/OSGKeyboardShared/Typing/TypingSessionController.swift b/OSGKeyboardShared/Typing/TypingSessionController.swift index 2156d2c..147ea35 100644 --- a/OSGKeyboardShared/Typing/TypingSessionController.swift +++ b/OSGKeyboardShared/Typing/TypingSessionController.swift @@ -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 {