merge: prepare English keyboard and 1.8 compliance
This commit is contained in:
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
|||||||
|
|
||||||
### Version format
|
### Version format
|
||||||
|
|
||||||
The current source-of-truth version is **1.7.5 (build 71)**. Releases use stable SemVer:
|
The current source-of-truth version is **1.7.5 (build 72)**. Releases use stable SemVer:
|
||||||
|
|
||||||
| Field | File | Rule |
|
| Field | File | Rule |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
|
|||||||
+14
-1
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **English QuickType bar**: while typing a word, three equal slots show the verbatim text (quoted when unknown), the unique Space correction, and a completion. The bar stays empty before typing and between committed words. / **英文 QuickType 栏**:输入单词时,三个等宽格显示原文(生词带引号)、空格会采用的唯一纠错和补全;尚未输入及单词提交后保持空白。
|
||||||
|
- **System English lexicon**: typing uses `UITextChecker` completions/guesses and `requestSupplementaryLexicon` contact names / text replacements. / **系统英文词库**:打字使用 `UITextChecker` 补全/猜测,以及 `requestSupplementaryLexicon` 的通讯录名与文本替换。
|
||||||
|
- **QWERTY proximity correction**: fat-finger substitutions on neighboring keys (for example `gppd` → `good`) outrank distant edit-distance neighbors. / **邻键纠错**:相邻键的胖手指替换(如 `gppd` → `good`)优先于远键编辑距离。
|
||||||
|
- **Larger English word list**: about 40k unigrams and truncated bigrams, derived from Peter Norvig’s public-domain n-gram counts, shipped as an mmap binary so the keyboard extension does not parse them into Swift dictionaries. / **更大英文词表**:约 4 万 unigram 与截断 bigram,来自 Peter Norvig 公有领域 n-gram 计数,以 mmap 二进制随扩展加载,避免解析进 Swift 字典。
|
||||||
|
- **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 时另一只可打字。
|
- **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,对齐系统「句号快捷」。
|
- **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` 在绿色动作键上显示系统对应文案,不再一律变成「发送」或换行箭头。
|
- **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` 在绿色动作键上显示系统对应文案,不再一律变成「发送」或换行箭头。
|
||||||
@@ -17,9 +23,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Navigate skill**: copying text and tapping Navigate asks the LLM for one address (or origin → destination) and the host app opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No companion Shortcut. No address stays in the current app with a keyboard tip. / **导航技能**:复制文字并点「导航」会抽取一条地址(或起点→终点),由 App 直接打开驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。不需要配套捷径。没有地址则留在当前 App,键盘上给出提示。
|
- **Navigate skill**: copying text and tapping Navigate asks the LLM for one address (or origin → destination) and the host app opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No companion Shortcut. No address stays in the current app with a keyboard tip. / **导航技能**:复制文字并点「导航」会抽取一条地址(或起点→终点),由 App 直接打开驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。不需要配套捷径。没有地址则留在当前 App,键盘上给出提示。
|
||||||
- **Save to Notes skill**: the Skills tab opens a ready-made companion Shortcut named `OSGSaveToNotes` on the system Add page (one new Apple Note with an explicit title and body). After you tap Add, copying text and tapping Notes asks the LLM for a short title from the time and content; the body stays the original clipboard. / **存入备忘录技能**:技能页会打开已做好的配套捷径 `OSGSaveToNotes` 的系统添加页(新建一条带标题和正文的苹果备忘录)。点添加后,复制文字并点「备忘录」会按时间和内容生成短标题;正文保持剪贴板原文。
|
- **Save to Notes skill**: the Skills tab opens a ready-made companion Shortcut named `OSGSaveToNotes` on the system Add page (one new Apple Note with an explicit title and body). After you tap Add, copying text and tapping Notes asks the LLM for a short title from the time and content; the body stays the original clipboard. / **存入备忘录技能**:技能页会打开已做好的配套捷径 `OSGSaveToNotes` 的系统添加页(新建一条带标题和正文的苹果备忘录)。点添加后,复制文字并点「备忘录」会按时间和内容生成短标题;正文保持剪贴板原文。
|
||||||
- **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。
|
- **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。
|
||||||
- **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, required iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、必填 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。
|
- **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, optional iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Without a link, the model result is reviewed and inserted directly; with a link, it keeps the Shortcut export flow. Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、可选 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。不填链接时,模型结果经确认后直接插入;填写链接时继续走捷径导出流程。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Keyboard input tabs**: the four-tab capsule is centered independently of the side controls, with equal 42 pt hit widths and a translucent-black light-mode track. AI and Voice use enlarged `sparkle` and `waveform.mid` symbols, and the leading logo is 16 pt tall. / **键盘输入标签**:四标签胶囊不受两侧控件影响并在键盘上独立居中,点击宽度统一为 42 pt,浅色模式轨道使用半透明黑色;AI 与语音使用放大的 `sparkle` 和 `waveform.mid` 图标,左侧 Logo 高度为 16 pt。
|
||||||
|
- **English lexicon mmap**: the 40k-word English table loads only in English and stays file-mapped; Chinese typing no longer pulls it in on keyboard appear. / **英文词表 mmap**:4 万词英文表仅在英文加载且走文件映射;中文打字不再在唤起键盘时一并灌入。
|
||||||
|
- **English autocorrect conservatism**: Title Case / short / ALL CAPS tokens are not replaced, except same-length transpositions (`Teh` → `The`). Machine-applied corrections no longer boost the replacement; rejecting them learns the original. / **英文自动更正更克制**:Title Case / 短词 / 全大写默认不改,仅保留同长换位(`Teh` → `The`)。机器改写不再给新词加分;拒绝纠错会学会原文。
|
||||||
|
- **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,避免把密码写入用户词库。
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Keyboard switch crash**: `requestSupplementaryLexicon` completion hops to the main actor before writing session state, so switching to OSG Keyboard no longer traps on `com.apple.TextInput.lexicon-request`. / **切换键盘崩溃**:`requestSupplementaryLexicon` 回调先回到主线程再写会话状态,切换到 OSG Keyboard 不再在 `com.apple.TextInput.lexicon-request` 上触发隔离断言。
|
||||||
- **Save to Notes Shortcut**: bind the combined first-line title and clipboard body through iPhone Create Note’s real `WFCreateNoteInput` field. The previous `contents` binding was ignored and left an enter-content sheet or a title-only note. / **存入备忘录捷径**:通过 iPhone「创建备忘录」真正的 `WFCreateNoteInput` 字段绑定首行标题与剪贴板正文;旧版 `contents` 绑定会被忽略,导致弹出内容填写框或只生成标题。
|
- **Save to Notes Shortcut**: bind the combined first-line title and clipboard body through iPhone Create Note’s real `WFCreateNoteInput` field. The previous `contents` binding was ignored and left an enter-content sheet or a title-only note. / **存入备忘录捷径**:通过 iPhone「创建备忘录」真正的 `WFCreateNoteInput` 字段绑定首行标题与剪贴板正文;旧版 `contents` 绑定会被忽略,导致弹出内容填写框或只生成标题。
|
||||||
- **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
|
- **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
|
||||||
- **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。
|
- **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。
|
||||||
|
|||||||
+35
-6
@@ -1,4 +1,8 @@
|
|||||||
# Third-party notices — Typing keyboard
|
# Third-party notices — OSGKeyboard
|
||||||
|
|
||||||
|
OSGKeyboard itself is source-available under the repository `LICENSE`; it is
|
||||||
|
not an open-source or MIT-licensed application. The notices below apply only
|
||||||
|
to the named third-party components or explicitly identified data subsets.
|
||||||
|
|
||||||
The generated Chinese source manifest is
|
The generated Chinese source manifest is
|
||||||
`OSGKeyboard/Resources/Typing/Rime/manifest.json`. Exact librime
|
`OSGKeyboard/Resources/Typing/Rime/manifest.json`. Exact librime
|
||||||
@@ -14,6 +18,7 @@ binary dependency licenses are bundled beside `NOTICE.txt`.
|
|||||||
| [fxsjy/jieba](https://github.com/fxsjy/jieba) | `67fa2e3…` | MIT | Modern Simplified-Chinese word frequencies |
|
| [fxsjy/jieba](https://github.com/fxsjy/jieba) | `67fa2e3…` | MIT | Modern Simplified-Chinese word frequencies |
|
||||||
| [phrase-pinyin-data](https://github.com/mozillazg/phrase-pinyin-data) | `cee0ed6…` | MIT | Phrase pronunciations |
|
| [phrase-pinyin-data](https://github.com/mozillazg/phrase-pinyin-data) | `cee0ed6…` | MIT | Phrase pronunciations |
|
||||||
| [pinyin-data](https://github.com/mozillazg/pinyin-data) | `923b108…` | MIT | Character-pronunciation fallback |
|
| [pinyin-data](https://github.com/mozillazg/pinyin-data) | `923b108…` | MIT | Character-pronunciation fallback |
|
||||||
|
| [Google Material Icons](https://github.com/google/material-design-icons) | Bundled font snapshot | Apache-2.0 | iOS Settings and navigation iconography |
|
||||||
|
|
||||||
`Scripts/typing/build_rime_dictionary.py` deterministically merges these
|
`Scripts/typing/build_rime_dictionary.py` deterministically merges these
|
||||||
sources into `osg_pinyin.dict.yaml`; its manifest records every source URL,
|
sources into `osg_pinyin.dict.yaml`; its manifest records every source URL,
|
||||||
@@ -28,13 +33,37 @@ commit, SHA-256 and output SHA-256.
|
|||||||
- Microsoft/Sogou mappings were independently encoded from public key-map
|
- Microsoft/Sogou mappings were independently encoded from public key-map
|
||||||
specifications; GPL schema files were not copied.
|
specifications; GPL schema files were not copied.
|
||||||
- Eight opt-in fuzzy-pinyin rule groups.
|
- Eight opt-in fuzzy-pinyin rule groups.
|
||||||
|
- Project-curated bilingual AI/technology speech phrases under
|
||||||
|
`Scripts/lexicon/seeds/ai_tech_brands_seed.tsv`. This data subset and its
|
||||||
|
generated CLM phrase list are MIT-licensed; that grant does not change the
|
||||||
|
source-available license of the app.
|
||||||
- Offline English typing data under
|
- Offline English typing data under
|
||||||
`OSGKeyboardShared/Resources/Typing/English/`:
|
`OSGKeyboardShared/Resources/Typing/English/`:
|
||||||
- `english_lexicon.tsv` — curated word list with synthetic relative
|
- `english_lexicon.bin` — mmap-friendly binary of the top 40k alphabetic
|
||||||
frequency ranks for autocomplete / autocorrect
|
unigrams (log-scaled ranks) plus truncated bigrams; this is what the
|
||||||
- `english_bigrams.tsv` — light next-word candidates
|
keyboard extension loads
|
||||||
- Not derived from GPL/LGPL dictionaries; ranks are ordering weights,
|
- `english_lexicon.tsv` / `english_bigrams.tsv` — build inputs derived from
|
||||||
not a single third-party corpus dump
|
Peter Norvig’s public-domain `count_1w.txt` / `count_2w.txt`
|
||||||
|
(https://norvig.com/ngrams/; not GPL/LGPL dictionaries). Not copied into
|
||||||
|
the app bundle.
|
||||||
|
- Rebuild with `python3 Scripts/typing/build_english_lexicon.py`
|
||||||
|
(add `--from-tsv` to compile the binary from existing TSV without network)
|
||||||
|
|
||||||
|
## macOS local speech stack
|
||||||
|
|
||||||
|
| Component | Pinned version | License | Purpose |
|
||||||
|
|-----------|----------------|---------|---------|
|
||||||
|
| [mlx-audio-swift](https://github.com/Blaizzy/mlx-audio-swift) | `d302a5c…` | MIT | Qwen3 streaming ASR integration |
|
||||||
|
| [mlx-swift](https://github.com/ml-explore/mlx-swift) | `0.31.3` | MIT | Apple MLX tensor runtime |
|
||||||
|
| [mlx-swift-lm](https://github.com/ml-explore/mlx-swift-lm) | `3.31.3` | MIT | MLX model utilities |
|
||||||
|
| [swift-transformers](https://github.com/huggingface/swift-transformers) | `1.1.9` | Apache-2.0 | Tokenizer and model utilities |
|
||||||
|
| [swift-huggingface](https://github.com/huggingface/swift-huggingface) | `0.8.1` | Apache-2.0 | Model download client |
|
||||||
|
| [Qwen3-ASR 0.6B / 1.7B MLX 4-bit](https://huggingface.co/mlx-community) | Runtime download | Apache-2.0 | Optional on-device speech-model weights |
|
||||||
|
|
||||||
|
The Qwen models are downloaded only after the user selects a local model on
|
||||||
|
macOS. They are not committed to this repository. Their model cards identify
|
||||||
|
the original Qwen3-ASR model and the mlx-community conversion.
|
||||||
|
|
||||||
|
|
||||||
## Reference only
|
## Reference only
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ struct OSGKeyboardApp: App {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
|
if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
|
||||||
// Approach A: Notes-like host only; real keyboard extension overlays it.
|
// Approach A: Notes-like host only; real keyboard extension overlays it.
|
||||||
|
// Also used by `--keyboard-appear-stress=` (pass both flags).
|
||||||
Self.makeWhatsNewHostView()
|
Self.makeWhatsNewHostView()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--edit-demo") {
|
} else if ProcessInfo.processInfo.arguments.contains("--edit-demo") {
|
||||||
EditDemoView()
|
EditDemoView()
|
||||||
@@ -79,8 +80,13 @@ struct OSGKeyboardApp: App {
|
|||||||
let scenario = whatsNewScenario(from: args) ?? .edit
|
let scenario = whatsNewScenario(from: args) ?? .edit
|
||||||
let language = whatsNewLanguage(from: args)
|
let language = whatsNewLanguage(from: args)
|
||||||
let seed = whatsNewSeedText(for: scenario, language: language)
|
let seed = whatsNewSeedText(for: scenario, language: language)
|
||||||
|
let appearStressCount = keyboardAppearStressCount(from: args)
|
||||||
WhatsNewDemoScenario.clear()
|
WhatsNewDemoScenario.clear()
|
||||||
WhatsNewDemoScenario.arm(scenario, seedText: seed, language: language)
|
// Stress must not arm What's New playback — that drives keys on the
|
||||||
|
// extension while we are tearing it down.
|
||||||
|
if appearStressCount == 0 {
|
||||||
|
WhatsNewDemoScenario.arm(scenario, seedText: seed, language: language)
|
||||||
|
}
|
||||||
if let defaults = AppGroup.defaultsIfAvailable {
|
if let defaults = AppGroup.defaultsIfAvailable {
|
||||||
defaults.set(true, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding)
|
defaults.set(true, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding)
|
||||||
// Force extension ExtL10n / SharedL10n into the demo language.
|
// Force extension ExtL10n / SharedL10n into the demo language.
|
||||||
@@ -98,13 +104,33 @@ struct OSGKeyboardApp: App {
|
|||||||
}
|
}
|
||||||
defaults.synchronize()
|
defaults.synchronize()
|
||||||
}
|
}
|
||||||
|
if appearStressCount > 0, let defaults = AppGroup.defaultsIfAvailable {
|
||||||
|
// Hit the crash path: typing surface + English supplementary lexicon.
|
||||||
|
defaults.set("english", forKey: "typing.input.defaultInputMode")
|
||||||
|
defaults.set(true, forKey: "typing.input.rememberLastSurface")
|
||||||
|
defaults.set("typing", forKey: "typing.input.lastSurface")
|
||||||
|
defaults.set("english", forKey: "typing.input.lastTypingLanguage")
|
||||||
|
defaults.synchronize()
|
||||||
|
}
|
||||||
return NotesHostDemoView(
|
return NotesHostDemoView(
|
||||||
scenario: scenario,
|
scenario: scenario,
|
||||||
seedText: seed,
|
seedText: seed,
|
||||||
language: language
|
language: language,
|
||||||
|
appearStressCount: appearStressCount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func keyboardAppearStressCount(from args: [String]) -> Int {
|
||||||
|
if let paired = args.first(where: { $0.hasPrefix("--keyboard-appear-stress=") }) {
|
||||||
|
return Int(paired.dropFirst("--keyboard-appear-stress=".count)) ?? 0
|
||||||
|
}
|
||||||
|
if let idx = args.firstIndex(of: "--keyboard-appear-stress"),
|
||||||
|
args.index(after: idx) < args.endIndex {
|
||||||
|
return Int(args[args.index(after: idx)]) ?? 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
private static func whatsNewScenario(from args: [String]) -> WhatsNewDemoScenario? {
|
private static func whatsNewScenario(from args: [String]) -> WhatsNewDemoScenario? {
|
||||||
if let paired = args.first(where: { $0.hasPrefix("--whats-new-scenario=") }) {
|
if let paired = args.first(where: { $0.hasPrefix("--whats-new-scenario=") }) {
|
||||||
let raw = String(paired.dropFirst("--whats-new-scenario=".count))
|
let raw = String(paired.dropFirst("--whats-new-scenario=".count))
|
||||||
|
|||||||
Binary file not shown.
@@ -1,14 +1,13 @@
|
|||||||
{
|
{
|
||||||
"bin_bytes" : 198494,
|
"bin_bytes" : 41456,
|
||||||
"bin_file" : "OSGKeyboardCLM.bin",
|
"bin_file" : "OSGKeyboardCLM.bin",
|
||||||
"export_seconds" : 0.035165071487426758,
|
"export_seconds" : 0.030717015266418457,
|
||||||
"generated_at" : "2026-07-06T13:16:27Z",
|
"generated_at" : "2026-08-14T15:07:23Z",
|
||||||
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
||||||
"locale" : "zh_CN",
|
"locale" : "zh_CN",
|
||||||
"phrase_count" : 13329,
|
"phrase_count" : 3040,
|
||||||
"sources" : {
|
"sources" : {
|
||||||
"ai_tech_seed" : 3040,
|
"ai_tech_seed" : 3040
|
||||||
"computer_terms" : 10300
|
|
||||||
},
|
},
|
||||||
"version" : "1.0.0"
|
"version" : "1.0.1"
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"version": "v1",
|
"entry_count" : 3040,
|
||||||
"generated_at": "2026-07-05T11:27:48.823095+00:00",
|
"files" : {
|
||||||
"locale": "zh-Hans",
|
"phrases" : "phrases.tsv"
|
||||||
"entry_count": 10300,
|
},
|
||||||
"sources": [
|
"generated_at" : "2026-08-14T15:07:23Z",
|
||||||
|
"locale" : "zh-Hans",
|
||||||
|
"notes" : [
|
||||||
|
"Project-curated bilingual AI brands, technology terms, companies, and names.",
|
||||||
|
"No third-party cell dictionaries or Sogou-derived data.",
|
||||||
|
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies."
|
||||||
|
],
|
||||||
|
"sources" : [
|
||||||
{
|
{
|
||||||
"key": "computer_terms",
|
"key" : "ai_tech_seed",
|
||||||
"label": "计算机词汇大全【官方推荐】",
|
"label" : "OSGKeyboard curated AI\/tech lexicon",
|
||||||
"weight": 5,
|
"license" : "MIT (curated seed; OSGKeyboard contributors)",
|
||||||
"raw_count": 10300
|
"raw_count" : 3040
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"notes": [
|
"version" : "v1"
|
||||||
"Domain-specific computer/IT vocabulary only; casual network slang and Sogou popular words removed.",
|
}
|
||||||
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
|
|
||||||
"Merged with ai-tech-brands seed at export time for the final .bin asset."
|
|
||||||
],
|
|
||||||
"files": {
|
|
||||||
"phrases": "phrases.tsv"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,14 +1,13 @@
|
|||||||
{
|
{
|
||||||
"bin_bytes" : 198494,
|
"bin_bytes" : 41456,
|
||||||
"bin_file" : "OSGKeyboardCLM.bin",
|
"bin_file" : "OSGKeyboardCLM.bin",
|
||||||
"export_seconds" : 0.035165071487426758,
|
"export_seconds" : 0.030717015266418457,
|
||||||
"generated_at" : "2026-07-06T13:16:27Z",
|
"generated_at" : "2026-08-14T15:07:23Z",
|
||||||
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
||||||
"locale" : "zh_CN",
|
"locale" : "zh_CN",
|
||||||
"phrase_count" : 13329,
|
"phrase_count" : 3040,
|
||||||
"sources" : {
|
"sources" : {
|
||||||
"ai_tech_seed" : 3040,
|
"ai_tech_seed" : 3040
|
||||||
"computer_terms" : 10300
|
|
||||||
},
|
},
|
||||||
"version" : "1.0.0"
|
"version" : "1.0.1"
|
||||||
}
|
}
|
||||||
@@ -22,27 +22,28 @@
|
|||||||
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
||||||
<div id="top">
|
<div id="top">
|
||||||
<h1>OSGKeyboard Privacy Policy</h1>
|
<h1>OSGKeyboard Privacy Policy</h1>
|
||||||
<p><strong>Last updated:</strong> August 12, 2026</p>
|
<p><strong>Last updated:</strong> August 14, 2026 · <em>v1.3</em></p>
|
||||||
<p>OSGKeyboard is a custom iOS keyboard that turns your voice into text. This policy explains what data the app processes and how it is used.</p>
|
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. You may instead choose a cloud recognition engine in Settings. After transcription, text may be polished, translated, or used by AI Agent features through a provider you configure.</p>
|
||||||
|
|
||||||
<h2>What we collect</h2>
|
<h2>What we collect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Voice audio</strong> — captured only while you actively record. The default on-device mode transcribes locally with Apple’s speech APIs and does not upload raw audio. If you explicitly enable cloud recognition, recordings are sent to the speech provider you configure for transcription; that provider’s privacy policy applies. OSGKeyboard does not store or proxy the audio on its own servers.</li>
|
<li><strong>Voice audio</strong> — captured only while you actively record. On a local engine, audio is transcribed on-device and raw audio is not uploaded. If you actively select a cloud engine in Settings, recordings are sent directly to the ASR provider you configure; that provider’s privacy policy applies. OSGKeyboard does not store or proxy audio on its own servers.</li>
|
||||||
<li><strong>Transcribed text</strong> — when AI polish is enabled, the final text (not audio) is sent to the LLM provider whose API key you configured (e.g. OpenAI, DeepSeek) for punctuation and formatting. Without an API key, raw ASR text is inserted and no polish request is sent.</li>
|
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you have configured an LLM API key. A small amount of text around the cursor may be included to continue naturally. Secure fields are never captured, and cursor context is not written to logs or voice history. Without an API key, raw ASR text is inserted.</li>
|
||||||
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the same configured LLM provider to generate an answer. When that provider supports server-side web search, the provider may retrieve public web results to answer time-sensitive questions. Search queries and retrieved snippets are processed by that provider under its own privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
|
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the same configured LLM provider to generate an answer. When that provider supports server-side web search, the provider may retrieve public web results to answer time-sensitive questions. Search queries and retrieved snippets are processed by that provider under its own privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
|
||||||
<li><strong>AI idle suggestions</strong> — the main app may periodically download public hint titles (e.g. hot topics) from OSGKeyboard’s hint feed and, using your configured polish LLM, compress them into short on-device suggestion labels. Suggestion packs are cached in the App Group for the keyboard; the keyboard extension does not fetch the feed itself.</li>
|
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included. Opening the update page requests public content from <code>download.osglab.com</code>. Those hosts may process ordinary connection metadata such as IP address and request time.</li>
|
||||||
<li><strong>Clipboard history (opt-in)</strong> — when you enable Clipboard History, the keyboard may read plain-text pasteboard content while it is visible and keep recent copies on device for the history panel and optional suggestion strip. Within about 30 seconds after a copy, AI mode may also offer clipboard-related idle suggestions; tapping one sends the clipboard text with that prompt to your configured LLM. Saying “clipboard” in an AI question does the same, because naming it is how you choose that text; every other AI question is sent without it. In both cases the clipboard body travels as separate quoted data, never as instructions. Clipboard history is local-only and not synced via iCloud.</li>
|
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. Reminders, Calendar, and Notes exports pass the generated result on-device to an Apple Shortcut you install. Navigation opens an address or route in Apple Maps, Amap, or Baidu Maps; the selected provider's policy applies.</li>
|
||||||
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
||||||
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
|
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
|
||||||
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this device’s host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
|
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this device’s host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
|
||||||
<li><strong>On-device typing learning</strong> — the Chinese keyboard stores selected words and candidate frequencies in the App Group on your device. OSGKeyboard does not upload this user dictionary.</li>
|
<li><strong>Personal dictionary and typing learning</strong> — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.</li>
|
||||||
|
<li><strong>macOS local speech models</strong> — when selected, Qwen3-ASR model files are downloaded from Hugging Face and remain in the Mac app's local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Clipboard sensitive-content filtering is applied to newly captured items. Existing history is retained until you use the confirmed clear action.</p>
|
<p>Clipboard sensitive-content filtering is applied to newly captured items. Existing history is retained until you use the confirmed clear action.</p>
|
||||||
|
|
||||||
<h2>What we do not collect</h2>
|
<h2>What we do not collect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
<li>We do <strong>not</strong> log or upload ordinary keystrokes.</li>
|
||||||
<li>We do <strong>not</strong> operate analytics or advertising SDKs.</li>
|
<li>We do <strong>not</strong> operate analytics, crash-reporting, or advertising SDKs.</li>
|
||||||
<li>We do <strong>not</strong> sell personal data.</li>
|
<li>We do <strong>not</strong> sell personal data.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -54,12 +55,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Third parties</h2>
|
<h2>Third parties</h2>
|
||||||
<p>When you enable cloud recognition, recordings are sent directly to the speech provider you configure. When you configure an LLM API key, transcribed text may be sent for polish, and AI-mode questions may be sent for answering (including optional provider-side web search). Those providers’ privacy policies apply to the requests.</p>
|
<p>Cloud ASR and LLM requests go directly to the provider you configure. AI skills may hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and updates use <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each third party's privacy policy applies; OSGKeyboard does not proxy, log, or aggregate provider requests.</p>
|
||||||
|
|
||||||
<h2>Data retention</h2>
|
<h2>Data retention</h2>
|
||||||
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; preferences, statistics, and history may sync via your private iCloud account.</p>
|
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.</p>
|
||||||
<p><strong>Voice history</strong> — successful transcripts may be saved in the main app’s History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
|
<p><strong>Voice history</strong> — successful transcripts may be saved in the main app’s History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
|
||||||
<p><strong>Clipboard history</strong> — stays in this device’s App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
|
<p><strong>Clipboard history</strong> — stays in this device’s App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
|
||||||
|
<p><strong>Typing learning and Mac models</strong> — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.</p>
|
||||||
|
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
|
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
|
||||||
@@ -67,26 +69,28 @@
|
|||||||
|
|
||||||
<hr id="zh">
|
<hr id="zh">
|
||||||
<h1>OSGKeyboard 隐私政策</h1>
|
<h1>OSGKeyboard 隐私政策</h1>
|
||||||
<p><strong>更新日期:</strong>2026 年 8 月 12 日</p>
|
<p><strong>更新日期:</strong>2026 年 8 月 14 日 · <em>v1.3</em></p>
|
||||||
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。</p>
|
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。你也可以在设置中主动选择云端识别引擎。转写后,文字可能通过你配置的服务商用于润色、翻译或 AI Agent 功能。</p>
|
||||||
|
|
||||||
<h2>我们处理的数据</h2>
|
<h2>我们处理的数据</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。默认本地模式通过 Apple 语音能力在设备端转写,不会上传原始录音。若你主动启用云端识别,录音会发送到你配置的语音服务商完成转写,并适用该服务商的隐私政策。OSGKeyboard 自身不会存储或中转音频。</li>
|
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。若你在设置中主动选择云端引擎,录音会直接发送到你配置的识别服务商。OSGKeyboard 自身不存储或中转音频。</li>
|
||||||
<li><strong>转写文字</strong> — 当你配置了 LLM API Key 并启用润色时,最终文字(非音频)会发送到该服务商以整理标点和格式。未填写 API Key 时直接插入原始识别结果,不会发起润色请求。</li>
|
<li><strong>转写文字与光标上下文</strong> — 配置 LLM API Key 后,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。未填写 API Key 时直接插入原始识别结果。</li>
|
||||||
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字,或你点选空闲建议后生成的提问,会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字,或你点选空闲建议后生成的提问,会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
||||||
<li><strong>AI 空闲建议</strong> — 主 App 可能定期从 OSGKeyboard 热点建议源下载公开标题,并使用你配置的润色 LLM 压缩为短标签,缓存在 App Group 供键盘读取;键盘扩展本身不会直接请求该源。</li>
|
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。相关主机可能处理 IP 地址、请求时间等普通连接元数据。</li>
|
||||||
<li><strong>剪贴板历史(可选)</strong> — 当你开启「剪贴板历史」后,键盘在可见期间可能读取纯文本粘贴板内容并在本机保存,供历史面板与可选建议条使用。复制后约 30 秒内,AI 模式也可能展示剪贴板相关空闲建议;点选后会将剪贴板正文与提示一并发送到你配置的 LLM。在 AI 提问中明确说出「剪贴板」同样如此——说出即代表你选择了这段材料;其余 AI 提问不会附带剪贴板。两种情况下剪贴板正文都作为单独引用的数据发送,绝不作为指令。剪贴板历史仅存本机,不经 iCloud 同步。</li>
|
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项、日历与备忘录导出会在设备端把生成结果交给你安装的 Apple 快捷指令;导航会在 Apple 地图、高德地图或百度地图中打开地址或路线。</li>
|
||||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
||||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
|
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
|
||||||
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
||||||
|
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
||||||
|
<li><strong>macOS 本地语音模型</strong> — 选择后,Qwen3-ASR 模型会从 Hugging Face 下载到 Mac 本地应用支持目录,直到你主动移除。本地推理不会把录音发送给 Hugging Face 或 OSGKeyboard。</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>剪贴板敏感内容过滤仅在新内容采集时执行;已有历史会继续保留,直到你使用带确认的清空操作。</p>
|
<p>剪贴板敏感内容过滤仅在新内容采集时执行;已有历史会继续保留,直到你使用带确认的清空操作。</p>
|
||||||
|
|
||||||
<h2>我们不收集的内容</h2>
|
<h2>我们不收集的内容</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
||||||
<li>我们<strong>不会</strong>集成广告或第三方分析 SDK。</li>
|
<li>我们<strong>不会</strong>集成分析、崩溃上报或广告 SDK。</li>
|
||||||
<li>我们<strong>不会</strong>出售个人数据。</li>
|
<li>我们<strong>不会</strong>出售个人数据。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -98,12 +102,13 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>第三方</h2>
|
<h2>第三方</h2>
|
||||||
<p>启用云端识别时,录音会直接发送到你配置的语音服务商;配置 LLM API Key 后,转写文字可能用于润色,AI 模式问题可能用于生成回答(含服务商侧可选联网搜索)。相关请求适用对应服务商的隐私政策。</p>
|
<p>云端 ASR 与 LLM 请求直接发送到你配置的服务商。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code>、<code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各第三方的隐私政策适用。</p>
|
||||||
|
|
||||||
<h2>数据保留</h2>
|
<h2>数据保留</h2>
|
||||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;偏好、统计与历史可能经私有 iCloud 账户同步。</p>
|
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
|
||||||
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
||||||
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
|
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
|
||||||
|
<p><strong>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
|
||||||
|
|
||||||
<h2>联系</h2>
|
<h2>联系</h2>
|
||||||
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
|
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
Google Material Icons
|
||||||
|
Copyright 2014 Google LLC
|
||||||
|
https://github.com/google/material-design-icons
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Peter Norvig’s n-gram count files (https://norvig.com/ngrams/)
|
||||||
|
|
||||||
|
Norvig states: “I hereby release all these files into the public domain.”
|
||||||
|
|
||||||
|
OSGKeyboard does not redistribute the raw count files. `Scripts/typing/build_english_lexicon.py`
|
||||||
|
derives compact log-scaled unigram ranks and a truncated bigram list, then
|
||||||
|
compiles `english_lexicon.bin` for the keyboard extension to mmap.
|
||||||
@@ -28,8 +28,23 @@ rime-ice, rime-double-pinyin, rime-luna-pinyin, rime-essay and KeyboardKit Pro.
|
|||||||
The Apache-2.0 and MIT license texts are available from the source links above;
|
The Apache-2.0 and MIT license texts are available from the source links above;
|
||||||
all required copyright and permission notices must remain with distributions.
|
all required copyright and permission notices must remain with distributions.
|
||||||
|
|
||||||
|
Interface iconography
|
||||||
|
---------------------
|
||||||
|
Google Material Icons are distributed under the Apache License 2.0.
|
||||||
|
Copyright 2014 Google LLC.
|
||||||
|
See LICENSE-MATERIAL-ICONS-APACHE.txt for the full attribution and terms.
|
||||||
|
|
||||||
English typing lexicon
|
English typing lexicon
|
||||||
----------------------
|
----------------------
|
||||||
english_lexicon.tsv and english_bigrams.tsv are OSG-curated word lists with
|
english_lexicon.bin (from english_lexicon.tsv / english_bigrams.tsv) is an
|
||||||
synthetic relative frequency ranks for offline autocomplete / autocorrect /
|
OSG-curated mmap ranking table with synthetic relative frequency ranks for
|
||||||
next-word ranking. They are not derived from GPL/LGPL dictionaries.
|
offline autocomplete / autocorrect / next-word ranking. It is not derived
|
||||||
|
from GPL/LGPL dictionaries.
|
||||||
|
|
||||||
|
Local speech lexicon
|
||||||
|
--------------------
|
||||||
|
The bundled custom-language-model phrase list is generated only from the
|
||||||
|
project-curated Scripts/lexicon/seeds/ai_tech_brands_seed.tsv. That data
|
||||||
|
subset is MIT-licensed by OSGKeyboard contributors. OSGKeyboard itself remains
|
||||||
|
source-available under the repository LICENSE and is not MIT-licensed or an
|
||||||
|
open-source application.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ enum OpenSourceLicenseCatalog {
|
|||||||
purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.",
|
purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.",
|
||||||
url: URL(string: "https://github.com/google/material-design-icons"),
|
url: URL(string: "https://github.com/google/material-design-icons"),
|
||||||
licenseText: resourceText(
|
licenseText: resourceText(
|
||||||
named: "LICENSE-PINYIN-SIMP-APACHE",
|
named: "LICENSE-MATERIAL-ICONS-APACHE",
|
||||||
fallback: apache2Text
|
fallback: apache2Text
|
||||||
),
|
),
|
||||||
platforms: [.iOS]
|
platforms: [.iOS]
|
||||||
@@ -105,11 +105,22 @@ enum OpenSourceLicenseCatalog {
|
|||||||
id: "english-typing-lexicon",
|
id: "english-typing-lexicon",
|
||||||
name: "OSG English typing lexicon",
|
name: "OSG English typing lexicon",
|
||||||
licenseName: "Project-owned notice",
|
licenseName: "Project-owned notice",
|
||||||
purpose: "Offline English autocomplete, autocorrect, and next-word ranking lists curated by OSGKeyboard (english_lexicon.tsv / english_bigrams.tsv). Not derived from GPL/LGPL dictionaries; relative ranks are ordering weights only.",
|
purpose: "Offline English autocomplete, autocorrect, and next-word ranking (english_lexicon.bin, compiled from TSV). Log-scaled ranks derived from Peter Norvig’s public-domain n-gram counts; not GPL/LGPL dictionaries.",
|
||||||
url: URL(string: "https://github.com/hkgood/OSGKeyboard/blob/main/NOTICE-TYPING.md"),
|
url: URL(string: "https://github.com/hkgood/OSGKeyboard/blob/main/NOTICE-TYPING.md"),
|
||||||
licenseText: englishLexiconNoticeText,
|
licenseText: englishLexiconNoticeText,
|
||||||
platforms: [.iOS]
|
platforms: [.iOS]
|
||||||
),
|
),
|
||||||
|
.init(
|
||||||
|
id: "osg-ai-tech-lexicon",
|
||||||
|
name: "OSG AI/technology speech lexicon",
|
||||||
|
licenseName: "MIT (data subset only)",
|
||||||
|
purpose: "Project-curated bilingual names and technology terms used by the iOS custom language model and Mac local-ASR prompt bias. This notice applies only to the lexicon data, not to OSGKeyboard as a whole.",
|
||||||
|
url: URL(
|
||||||
|
string: "https://github.com/hkgood/OSGKeyboard/tree/main/Scripts/lexicon/seeds"
|
||||||
|
),
|
||||||
|
licenseText: curatedLexiconNoticeText,
|
||||||
|
platforms: [.iOS, .macOS]
|
||||||
|
),
|
||||||
.init(
|
.init(
|
||||||
id: "mlx-audio-swift",
|
id: "mlx-audio-swift",
|
||||||
name: "mlx-audio-swift",
|
name: "mlx-audio-swift",
|
||||||
@@ -119,6 +130,51 @@ enum OpenSourceLicenseCatalog {
|
|||||||
licenseText: mlxAudioMITText,
|
licenseText: mlxAudioMITText,
|
||||||
platforms: [.macOS]
|
platforms: [.macOS]
|
||||||
),
|
),
|
||||||
|
.init(
|
||||||
|
id: "mlx-swift",
|
||||||
|
name: "mlx-swift 0.31.3",
|
||||||
|
licenseName: "MIT",
|
||||||
|
purpose: "Apple MLX tensor and neural-network runtime used transitively by mlx-audio-swift.",
|
||||||
|
url: URL(string: "https://github.com/ml-explore/mlx-swift"),
|
||||||
|
licenseText: mitLicenseText(copyright: "Copyright (c) 2023 ml-explore"),
|
||||||
|
platforms: [.macOS]
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: "mlx-swift-lm",
|
||||||
|
name: "mlx-swift-lm 3.31.3",
|
||||||
|
licenseName: "MIT",
|
||||||
|
purpose: "MLX language-model utilities used transitively by the Mac local speech runtime.",
|
||||||
|
url: URL(string: "https://github.com/ml-explore/mlx-swift-lm"),
|
||||||
|
licenseText: mitLicenseText(copyright: "Copyright (c) 2024 ml-explore"),
|
||||||
|
platforms: [.macOS]
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: "swift-transformers",
|
||||||
|
name: "swift-transformers 1.1.9",
|
||||||
|
licenseName: "Apache-2.0",
|
||||||
|
purpose: "Tokenizer and model utilities used transitively by mlx-audio-swift.",
|
||||||
|
url: URL(string: "https://github.com/huggingface/swift-transformers"),
|
||||||
|
licenseText: apacheLicenseText(copyright: "Copyright 2022 Hugging Face SAS."),
|
||||||
|
platforms: [.macOS]
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: "swift-huggingface",
|
||||||
|
name: "swift-huggingface 0.8.1",
|
||||||
|
licenseName: "Apache-2.0",
|
||||||
|
purpose: "Hugging Face Hub client used to download the user-selected Mac speech model.",
|
||||||
|
url: URL(string: "https://github.com/huggingface/swift-huggingface"),
|
||||||
|
licenseText: apacheLicenseText(copyright: "Copyright 2025 Hugging Face SAS."),
|
||||||
|
platforms: [.macOS]
|
||||||
|
),
|
||||||
|
.init(
|
||||||
|
id: "qwen3-asr-mlx",
|
||||||
|
name: "Qwen3-ASR 0.6B / 1.7B MLX 4-bit models",
|
||||||
|
licenseName: "Apache-2.0",
|
||||||
|
purpose: "Optional speech-model weights downloaded at runtime for on-device Mac transcription. The MLX conversions are published by mlx-community from Qwen/Qwen3-ASR.",
|
||||||
|
url: URL(string: "https://huggingface.co/collections/mlx-community/qwen3-asr"),
|
||||||
|
licenseText: qwenModelApacheText,
|
||||||
|
platforms: [.macOS]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
static func entries(for platform: Platform) -> [Entry] {
|
static func entries(for platform: Platform) -> [Entry] {
|
||||||
@@ -188,19 +244,62 @@ enum OpenSourceLicenseCatalog {
|
|||||||
\(mitText.components(separatedBy: "\n").dropFirst(2).joined(separator: "\n"))
|
\(mitText.components(separatedBy: "\n").dropFirst(2).joined(separator: "\n"))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
static let curatedLexiconNoticeText = """
|
||||||
|
OSG AI/technology speech lexicon
|
||||||
|
|
||||||
|
Copyright (c) OSGKeyboard contributors
|
||||||
|
|
||||||
|
The phrase list under Scripts/lexicon/seeds/ai_tech_brands_seed.tsv and
|
||||||
|
its generated CustomLanguageModel phrase data are made available under
|
||||||
|
the MIT License below. This grant covers only that curated data subset.
|
||||||
|
OSGKeyboard itself remains source-available under the repository LICENSE
|
||||||
|
and is not distributed under the MIT License.
|
||||||
|
|
||||||
|
\(mitText.components(separatedBy: "\n").dropFirst(2).joined(separator: "\n"))
|
||||||
|
"""
|
||||||
|
|
||||||
|
static let qwenModelApacheText = """
|
||||||
|
mlx-community/Qwen3-ASR-0.6B-4bit and Qwen3-ASR-1.7B-4bit
|
||||||
|
|
||||||
|
Converted to MLX format from Qwen/Qwen3-ASR using mlx-audio 0.3.1.
|
||||||
|
Both model cards declare license: apache-2.0. Model weights are optional
|
||||||
|
runtime downloads and are not stored in the OSGKeyboard repository.
|
||||||
|
|
||||||
|
\(apacheLicenseText(copyright: "Copyright Qwen and model contributors."))
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static func mitLicenseText(copyright: String) -> String {
|
||||||
|
"""
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
\(copyright)
|
||||||
|
|
||||||
|
\(mitText.components(separatedBy: "\n").dropFirst(2).joined(separator: "\n"))
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func apacheLicenseText(copyright: String) -> String {
|
||||||
|
let fullLicense = resourceText(
|
||||||
|
named: "LICENSE-PINYIN-SIMP-APACHE",
|
||||||
|
fallback: apache2Text
|
||||||
|
)
|
||||||
|
return "\(copyright)\n\n\(fullLicense)"
|
||||||
|
}
|
||||||
|
|
||||||
static let englishLexiconNoticeText = """
|
static let englishLexiconNoticeText = """
|
||||||
OSG English typing lexicon (project-owned notice)
|
OSG English typing lexicon (project-owned notice)
|
||||||
|
|
||||||
english_lexicon.tsv and english_bigrams.tsv are curated by OSGKeyboard for
|
english_lexicon.bin (compiled from english_lexicon.tsv / english_bigrams.tsv)
|
||||||
offline English autocomplete, autocorrect, and next-word ranking inside the
|
is the mmap ranking table for offline English autocomplete, autocorrect,
|
||||||
iOS keyboard extension.
|
and next-word prediction.
|
||||||
|
|
||||||
These lists are not derived from GPL or LGPL dictionaries. Relative
|
Unigram ranks and truncated bigrams are derived from Peter Norvig’s
|
||||||
frequency values are synthetic ordering weights for ranking only, not
|
public-domain n-gram count files (https://norvig.com/ngrams/). OSGKeyboard
|
||||||
verbatim counts from a single third-party corpus.
|
does not ship the raw corpus. Relative frequency values are log-scaled
|
||||||
|
ordering weights, not verbatim Google counts.
|
||||||
|
|
||||||
See NOTICE-TYPING.md in the OSGKeyboard repository for the full typing
|
See NOTICE-TYPING.md in the OSGKeyboard repository for the full typing
|
||||||
keyboard attribution map (Chinese Rime stack vs OSG-owned English data).
|
keyboard attribution map (Chinese Rime stack vs English data).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
static let bsd3Text = """
|
static let bsd3Text = """
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,8 +334,17 @@ struct AIAgentSkillsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveDraft(_ draft: SkillEditorDraft) throws {
|
private func saveDraft(_ draft: SkillEditorDraft) throws {
|
||||||
guard let url = AIShortcutShareLink.parse(draft.shortcutLink) else {
|
let rawShortcutLink = draft.shortcutLink.trimmingCharacters(
|
||||||
throw AIUserSkillValidationError.invalidShortcutLink
|
in: .whitespacesAndNewlines
|
||||||
|
)
|
||||||
|
let shortcutURL: URL?
|
||||||
|
if rawShortcutLink.isEmpty {
|
||||||
|
shortcutURL = nil
|
||||||
|
} else {
|
||||||
|
guard let parsedURL = AIShortcutShareLink.parse(rawShortcutLink) else {
|
||||||
|
throw AIUserSkillValidationError.invalidShortcutLink
|
||||||
|
}
|
||||||
|
shortcutURL = parsedURL
|
||||||
}
|
}
|
||||||
let skill = AIUserSkill(
|
let skill = AIUserSkill(
|
||||||
id: draft.id,
|
id: draft.id,
|
||||||
@@ -343,7 +352,7 @@ struct AIAgentSkillsView: View {
|
|||||||
summary: draft.summary,
|
summary: draft.summary,
|
||||||
systemImage: draft.systemImage,
|
systemImage: draft.systemImage,
|
||||||
prompt: draft.prompt,
|
prompt: draft.prompt,
|
||||||
shortcutICloudURL: url,
|
shortcutICloudURL: shortcutURL,
|
||||||
shortcutName: draft.shortcutName,
|
shortcutName: draft.shortcutName,
|
||||||
thinkingEnabled: draft.thinkingEnabled
|
thinkingEnabled: draft.thinkingEnabled
|
||||||
)
|
)
|
||||||
@@ -565,7 +574,7 @@ private struct SkillEditorDraft: Identifiable, Equatable {
|
|||||||
summary: skill.summary,
|
summary: skill.summary,
|
||||||
systemImage: skill.systemImage,
|
systemImage: skill.systemImage,
|
||||||
prompt: skill.prompt,
|
prompt: skill.prompt,
|
||||||
shortcutLink: skill.shortcutICloudURL.absoluteString,
|
shortcutLink: skill.shortcutICloudURL?.absoluteString ?? "",
|
||||||
shortcutName: skill.shortcutName,
|
shortcutName: skill.shortcutName,
|
||||||
thinkingEnabled: skill.thinkingEnabled
|
thinkingEnabled: skill.thinkingEnabled
|
||||||
)
|
)
|
||||||
@@ -957,11 +966,18 @@ private struct SkillEditorSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var canSave: Bool {
|
private var canSave: Bool {
|
||||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
let trimmedShortcutLink = shortcutLink.trimmingCharacters(
|
||||||
|
in: .whitespacesAndNewlines
|
||||||
|
)
|
||||||
|
let validShortcutConfiguration = trimmedShortcutLink.isEmpty
|
||||||
|
|| (
|
||||||
|
AIShortcutShareLink.parse(trimmedShortcutLink) != nil
|
||||||
|
&& !shortcutName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
)
|
||||||
|
return !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
&& !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
&& !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
&& prompt.count <= AIUserSkillLimits.maximumPromptCharacters
|
&& prompt.count <= AIUserSkillLimits.maximumPromptCharacters
|
||||||
&& !shortcutName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
&& validShortcutConfiguration
|
||||||
&& AIShortcutShareLink.parse(shortcutLink) != nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentDraft: SkillEditorDraft {
|
private var currentDraft: SkillEditorDraft {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct NotesHostDemoView: View {
|
|||||||
let scenario: WhatsNewDemoScenario
|
let scenario: WhatsNewDemoScenario
|
||||||
let seedText: String
|
let seedText: String
|
||||||
let language: WhatsNewDemoScenario.Language
|
let language: WhatsNewDemoScenario.Language
|
||||||
|
var appearStressCount: Int = 0
|
||||||
|
|
||||||
private var title: String {
|
private var title: String {
|
||||||
switch (scenario, language) {
|
switch (scenario, language) {
|
||||||
@@ -43,7 +44,7 @@ struct NotesHostDemoView: View {
|
|||||||
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
NotesHostTextView(text: seedText)
|
NotesHostTextView(text: seedText, appearStressCount: appearStressCount)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.background(
|
.background(
|
||||||
@@ -62,6 +63,7 @@ struct NotesHostDemoView: View {
|
|||||||
language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans")
|
language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans")
|
||||||
)
|
)
|
||||||
.task {
|
.task {
|
||||||
|
guard appearStressCount == 0 else { return }
|
||||||
// Refresh TTL while armed; stop once the extension consumes / plays.
|
// Refresh TTL while armed; stop once the extension consumes / plays.
|
||||||
WhatsNewDemoScenario.arm(scenario, seedText: seedText, language: language)
|
WhatsNewDemoScenario.arm(scenario, seedText: seedText, language: language)
|
||||||
for _ in 0..<25 {
|
for _ in 0..<25 {
|
||||||
@@ -78,6 +80,7 @@ struct NotesHostDemoView: View {
|
|||||||
/// the real custom keyboard extension.
|
/// the real custom keyboard extension.
|
||||||
private struct NotesHostTextView: UIViewRepresentable {
|
private struct NotesHostTextView: UIViewRepresentable {
|
||||||
let text: String
|
let text: String
|
||||||
|
var appearStressCount: Int = 0
|
||||||
|
|
||||||
func makeUIView(context: Context) -> UITextView {
|
func makeUIView(context: Context) -> UITextView {
|
||||||
let view = UITextView()
|
let view = UITextView()
|
||||||
@@ -91,8 +94,15 @@ private struct NotesHostTextView: UIViewRepresentable {
|
|||||||
view.textContainer.lineFragmentPadding = 0
|
view.textContainer.lineFragmentPadding = 0
|
||||||
view.returnKeyType = .default
|
view.returnKeyType = .default
|
||||||
view.delegate = context.coordinator
|
view.delegate = context.coordinator
|
||||||
|
view.accessibilityIdentifier = "notes.host.textView"
|
||||||
|
context.coordinator.appearStressCount = appearStressCount
|
||||||
|
context.coordinator.textView = view
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||||
view.becomeFirstResponder()
|
if context.coordinator.appearStressCount > 0 {
|
||||||
|
context.coordinator.startAppearStressIfNeeded()
|
||||||
|
} else {
|
||||||
|
view.becomeFirstResponder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
@@ -101,6 +111,8 @@ private struct NotesHostTextView: UIViewRepresentable {
|
|||||||
if uiView.text != text, !context.coordinator.userEdited {
|
if uiView.text != text, !context.coordinator.userEdited {
|
||||||
uiView.text = text
|
uiView.text = text
|
||||||
}
|
}
|
||||||
|
// Stress owns first-responder; don't fight resignFirstResponder.
|
||||||
|
guard appearStressCount == 0 else { return }
|
||||||
if !uiView.isFirstResponder {
|
if !uiView.isFirstResponder {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
_ = uiView.becomeFirstResponder()
|
_ = uiView.becomeFirstResponder()
|
||||||
@@ -112,10 +124,139 @@ private struct NotesHostTextView: UIViewRepresentable {
|
|||||||
|
|
||||||
final class Coordinator: NSObject, UITextViewDelegate {
|
final class Coordinator: NSObject, UITextViewDelegate {
|
||||||
var userEdited = false
|
var userEdited = false
|
||||||
|
var appearStressCount = 0
|
||||||
|
weak var textView: UITextView?
|
||||||
|
private var started = false
|
||||||
|
private var waitingForShow = false
|
||||||
|
private var waitingForHide = false
|
||||||
|
private var showWaiter: CheckedContinuation<Bool, Never>?
|
||||||
|
private var hideWaiter: CheckedContinuation<Bool, Never>?
|
||||||
|
/// Invalidates leftover timeout tasks from a finished wait.
|
||||||
|
private var waitGeneration = 0
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
userEdited = true
|
userEdited = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startAppearStressIfNeeded() {
|
||||||
|
guard appearStressCount > 0, !started else { return }
|
||||||
|
started = true
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(keyboardDidShow),
|
||||||
|
name: UIResponder.keyboardDidShowNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(keyboardDidHide),
|
||||||
|
name: UIResponder.keyboardDidHideNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
await self?.runAppearStress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardDidShow(_ notification: Notification) {
|
||||||
|
finishWait(show: true, success: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardDidHide(_ notification: Notification) {
|
||||||
|
finishWait(show: false, success: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishWait(show: Bool, success: Bool) {
|
||||||
|
if show {
|
||||||
|
guard waitingForShow, let pending = showWaiter else { return }
|
||||||
|
waitingForShow = false
|
||||||
|
showWaiter = nil
|
||||||
|
waitGeneration += 1
|
||||||
|
pending.resume(returning: success)
|
||||||
|
} else {
|
||||||
|
guard waitingForHide, let pending = hideWaiter else { return }
|
||||||
|
waitingForHide = false
|
||||||
|
hideWaiter = nil
|
||||||
|
waitGeneration += 1
|
||||||
|
pending.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func runAppearStress() async {
|
||||||
|
let total = appearStressCount
|
||||||
|
OSGDiag.log("keyboard.stress begin count=\(total)", category: "boot")
|
||||||
|
guard let textView else {
|
||||||
|
OSGDiag.log("keyboard.stress FAIL textView gone", category: "boot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard await becomeAndWaitForShow(textView, timeoutNanoseconds: 8_000_000_000) else {
|
||||||
|
OSGDiag.log("keyboard.stress FAIL first-show timeout", category: "boot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OSGDiag.log("keyboard.stress first-show ok", category: "boot")
|
||||||
|
var passed = 0
|
||||||
|
for cycle in 1...total {
|
||||||
|
guard await resignAndWaitForHide(textView, timeoutNanoseconds: 5_000_000_000) else {
|
||||||
|
OSGDiag.log("keyboard.stress FAIL cycle=\(cycle) hide timeout", category: "boot")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||||
|
guard await becomeAndWaitForShow(textView, timeoutNanoseconds: 8_000_000_000) else {
|
||||||
|
OSGDiag.log("keyboard.stress FAIL cycle=\(cycle) show timeout", category: "boot")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
passed += 1
|
||||||
|
OSGDiag.log("keyboard.stress cycle=\(passed)/\(total) ok", category: "boot")
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
OSGDiag.log("keyboard.stress done passed=\(passed)/\(total)", category: "boot")
|
||||||
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
|
exit(passed == total ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func becomeAndWaitForShow(_ textView: UITextView, timeoutNanoseconds: UInt64) async -> Bool {
|
||||||
|
await waitForKeyboard(show: true, timeoutNanoseconds: timeoutNanoseconds) {
|
||||||
|
textView.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func resignAndWaitForHide(_ textView: UITextView, timeoutNanoseconds: UInt64) async -> Bool {
|
||||||
|
await waitForKeyboard(show: false, timeoutNanoseconds: timeoutNanoseconds) {
|
||||||
|
textView.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func waitForKeyboard(
|
||||||
|
show: Bool,
|
||||||
|
timeoutNanoseconds: UInt64,
|
||||||
|
trigger: () -> Void
|
||||||
|
) async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
waitGeneration += 1
|
||||||
|
let generation = waitGeneration
|
||||||
|
if show {
|
||||||
|
waitingForShow = true
|
||||||
|
showWaiter = continuation
|
||||||
|
} else {
|
||||||
|
waitingForHide = true
|
||||||
|
hideWaiter = continuation
|
||||||
|
}
|
||||||
|
trigger()
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: timeoutNanoseconds)
|
||||||
|
guard let self, generation == waitGeneration else { return }
|
||||||
|
self.finishWait(show: show, success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct TypingInputSettingsView: View {
|
|||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
@ObservedObject private var configuration = TypingInputConfiguration.shared
|
@ObservedObject private var configuration = TypingInputConfiguration.shared
|
||||||
@ObservedObject private var deployment = RimeDeploymentController.shared
|
@ObservedObject private var deployment = RimeDeploymentController.shared
|
||||||
|
@State private var showClearHabitsConfirmation = false
|
||||||
|
|
||||||
private var isDeploying: Bool { deployment.isDeploying }
|
private var isDeploying: Bool { deployment.isDeploying }
|
||||||
|
|
||||||
@@ -69,11 +70,36 @@ struct TypingInputSettingsView: View {
|
|||||||
}
|
}
|
||||||
.disabled(isDeploying)
|
.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)
|
.scrollContentBackground(.hidden)
|
||||||
.background(palette.background)
|
.background(palette.background)
|
||||||
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
|
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
private var hasDeploymentError: Bool {
|
||||||
|
|||||||
@@ -238,6 +238,11 @@
|
|||||||
"settings.typingInput.resources.ready" = "Ready";
|
"settings.typingInput.resources.ready" = "Ready";
|
||||||
"settings.typingInput.resources.pending" = "Not initialized";
|
"settings.typingInput.resources.pending" = "Not initialized";
|
||||||
"settings.typingInput.resources.redeploy" = "Redeploy Input Resources";
|
"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.speechRecognition.title" = "Speech Recognition";
|
||||||
"settings.textPolish.title" = "Text Polish";
|
"settings.textPolish.title" = "Text Polish";
|
||||||
"settings.preferences.title" = "Preferences";
|
"settings.preferences.title" = "Preferences";
|
||||||
@@ -477,10 +482,10 @@
|
|||||||
"skills.editor.prompt" = "Processing prompt";
|
"skills.editor.prompt" = "Processing prompt";
|
||||||
"skills.editor.thinking" = "Thinking";
|
"skills.editor.thinking" = "Thinking";
|
||||||
"skills.editor.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning for this skill.";
|
"skills.editor.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning for this skill.";
|
||||||
"skills.editor.shortcut" = "Shortcut";
|
"skills.editor.shortcut" = "Shortcut (Optional)";
|
||||||
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
|
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
|
||||||
"skills.editor.shortcutNamePlaceholder" = "Shortcut name (can differ from the skill name)";
|
"skills.editor.shortcutNamePlaceholder" = "Shortcut name (required with a link)";
|
||||||
"skills.editor.shortcutHint" = "Paste an iCloud share link. The published Shortcut name is filled in automatically and you can change it. Don’t rename it in the Shortcuts app after adding.";
|
"skills.editor.shortcutHint" = "Leave the link empty to process text and insert the result after review. With a link, the Shortcut name is filled in automatically. Don’t rename it after adding.";
|
||||||
"skills.editor.lookingUp" = "Looking up Shortcut name…";
|
"skills.editor.lookingUp" = "Looking up Shortcut name…";
|
||||||
"skills.editor.resolvedName" = "Will run: %@";
|
"skills.editor.resolvedName" = "Will run: %@";
|
||||||
"skills.editor.lookupFailed" = "Couldn’t read the Shortcut name. Check the link, or type the name yourself.";
|
"skills.editor.lookupFailed" = "Couldn’t read the Shortcut name. Check the link, or type the name yourself.";
|
||||||
|
|||||||
@@ -238,6 +238,11 @@
|
|||||||
"settings.typingInput.resources.ready" = "已就绪";
|
"settings.typingInput.resources.ready" = "已就绪";
|
||||||
"settings.typingInput.resources.pending" = "待初始化";
|
"settings.typingInput.resources.pending" = "待初始化";
|
||||||
"settings.typingInput.resources.redeploy" = "重新部署输入法资源";
|
"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.speechRecognition.title" = "语音识别";
|
||||||
"settings.textPolish.title" = "文本润色";
|
"settings.textPolish.title" = "文本润色";
|
||||||
"settings.preferences.title" = "偏好设置";
|
"settings.preferences.title" = "偏好设置";
|
||||||
@@ -476,10 +481,10 @@
|
|||||||
"skills.editor.prompt" = "文本处理提示词";
|
"skills.editor.prompt" = "文本处理提示词";
|
||||||
"skills.editor.thinking" = "思考";
|
"skills.editor.thinking" = "思考";
|
||||||
"skills.editor.thinkingHint" = "默认关闭。仅在需要该技能更慢、更深的推理时开启。";
|
"skills.editor.thinkingHint" = "默认关闭。仅在需要该技能更慢、更深的推理时开启。";
|
||||||
"skills.editor.shortcut" = "捷径";
|
"skills.editor.shortcut" = "捷径(可选)";
|
||||||
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
|
"skills.editor.linkPlaceholder" = "https://www.icloud.com/shortcuts/…";
|
||||||
"skills.editor.shortcutNamePlaceholder" = "捷径名称(可与技能名称不同)";
|
"skills.editor.shortcutNamePlaceholder" = "捷径名称(填写链接时必填)";
|
||||||
"skills.editor.shortcutHint" = "粘贴 iCloud 分享链接。发布名称会自动填入,也可以自行修改。添加到「快捷指令」后请勿改名。";
|
"skills.editor.shortcutHint" = "不填链接时仅处理文字,结果确认后插入。填写链接时会自动读取捷径名称;添加到「快捷指令」后请勿改名。";
|
||||||
"skills.editor.lookingUp" = "正在读取捷径名称…";
|
"skills.editor.lookingUp" = "正在读取捷径名称…";
|
||||||
"skills.editor.resolvedName" = "将运行:%@";
|
"skills.editor.resolvedName" = "将运行:%@";
|
||||||
"skills.editor.lookupFailed" = "无法读取捷径名称。请检查链接,或手动填写名称。";
|
"skills.editor.lookupFailed" = "无法读取捷径名称。请检查链接,或手动填写名称。";
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private var typingSession: TypingSessionController {
|
private var typingSession: TypingSessionController {
|
||||||
if let typingSessionStorage { return typingSessionStorage }
|
if let typingSessionStorage { return typingSessionStorage }
|
||||||
let created = TypingSessionController()
|
let created = TypingSessionController()
|
||||||
|
created.systemLexicon = UIKitEnglishSystemLexicon()
|
||||||
typingSessionStorage = created
|
typingSessionStorage = created
|
||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
@@ -233,6 +234,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
if state.surface == .typing {
|
if state.surface == .typing {
|
||||||
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
||||||
typingSession.enterTypingMode()
|
typingSession.enterTypingMode()
|
||||||
|
refreshEnglishSupplementaryLexicon()
|
||||||
}
|
}
|
||||||
clipboardCapture.keyboardDidAppear()
|
clipboardCapture.keyboardDidAppear()
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
@@ -633,6 +635,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.surface = surface
|
state.surface = surface
|
||||||
if surface == .typing {
|
if surface == .typing {
|
||||||
typingSession.enterTypingMode()
|
typingSession.enterTypingMode()
|
||||||
|
refreshEnglishSupplementaryLexicon()
|
||||||
} else {
|
} else {
|
||||||
typingSession.leaveTypingMode()
|
typingSession.leaveTypingMode()
|
||||||
}
|
}
|
||||||
@@ -754,6 +757,33 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Contacts and user text replacements, without a Contacts permission.
|
||||||
|
///
|
||||||
|
/// `requestSupplementaryLexicon` replies on `com.apple.TextInput.lexicon-request`,
|
||||||
|
/// not the main actor. Touching `TypingSessionController` there traps in Swift 6
|
||||||
|
/// (`_dispatch_assert_queue_fail`) and the extension is killed on appear.
|
||||||
|
private func refreshEnglishSupplementaryLexicon() {
|
||||||
|
requestSupplementaryLexicon { @Sendable lexicon in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.applySupplementaryLexicon(lexicon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySupplementaryLexicon(_ lexicon: UILexicon) {
|
||||||
|
typingSessionStorage?.supplementaryWords = lexicon.entries.compactMap { entry -> String? in
|
||||||
|
let text = entry.documentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return nil }
|
||||||
|
let parts = text.split { $0.isWhitespace || $0 == "," }
|
||||||
|
guard parts.count == 1 else { return nil }
|
||||||
|
let token = String(parts[0])
|
||||||
|
guard token.allSatisfy({ $0.isLetter || $0 == "'" || $0 == "’" || $0 == "-" }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func typingAutocapitalizationMode(
|
private static func typingAutocapitalizationMode(
|
||||||
for type: UITextAutocapitalizationType
|
for type: UITextAutocapitalizationType
|
||||||
) -> TypingAutocapitalizationMode {
|
) -> TypingAutocapitalizationMode {
|
||||||
|
|||||||
@@ -142,29 +142,31 @@ struct TypingRootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var idleTopBar: some View {
|
private var idleTopBar: some View {
|
||||||
HStack(spacing: Spacing.xs) {
|
ZStack {
|
||||||
KeyboardBrandLogo(action: state.openSettings)
|
|
||||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
|
||||||
// iOS system layout); see the typingKeySurface ForEach.
|
|
||||||
|
|
||||||
if let err = typing.lastError {
|
|
||||||
typingErrorLabel(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// iOS-style editing cluster (undo / redo / copy / cut) — iPad only,
|
|
||||||
// where the top bar has room to mirror the system shortcut row.
|
|
||||||
if state.usesIPadLayoutMetrics {
|
|
||||||
editingToolbar
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
|
|
||||||
KeyboardTopControls(
|
KeyboardTopControls(
|
||||||
state: state,
|
state: state,
|
||||||
typing: typing,
|
typing: typing,
|
||||||
palette: palette,
|
palette: palette,
|
||||||
onInsert: onInsert
|
onInsert: onInsert
|
||||||
)
|
)
|
||||||
|
|
||||||
|
HStack(spacing: Spacing.xs) {
|
||||||
|
KeyboardBrandLogo(action: state.openSettings)
|
||||||
|
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||||
|
// iOS system layout); see the typingKeySurface ForEach.
|
||||||
|
|
||||||
|
if let err = typing.lastError {
|
||||||
|
typingErrorLabel(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS-style editing cluster (undo / redo / copy / cut) — iPad only,
|
||||||
|
// where the top bar has room to mirror the system shortcut row.
|
||||||
|
if state.usesIPadLayoutMetrics {
|
||||||
|
editingToolbar
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||||
}
|
}
|
||||||
@@ -206,47 +208,11 @@ struct TypingRootView: View {
|
|||||||
editingToolbar
|
editingToolbar
|
||||||
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||||
}
|
}
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
if typing.language == .english {
|
||||||
HStack(spacing: Spacing.xs) {
|
englishQuickTypeBar
|
||||||
if typing.composition.candidates.isEmpty {
|
} else {
|
||||||
selectedCandidateLabel(text: typing.composition.preedit)
|
chineseCandidateStrip
|
||||||
} else if typing.isCandidatePanelExpanded {
|
|
||||||
candidateChip(text: typing.composition.candidates[0].text) {
|
|
||||||
apply(typing.selectCandidate(at: 0))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ForEach(
|
|
||||||
Array(
|
|
||||||
typing.composition.candidates
|
|
||||||
.prefix(TypingLayoutMetrics.collapsedBarCandidateLimit)
|
|
||||||
.enumerated()
|
|
||||||
),
|
|
||||||
id: \.element.id
|
|
||||||
) { index, candidate in
|
|
||||||
if index == 0 {
|
|
||||||
candidateChip(text: candidate.text) {
|
|
||||||
apply(typing.selectCandidate(at: index))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(candidate.text)
|
|
||||||
.font(.system(size: 20, weight: .regular))
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.frame(height: 40)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
apply(typing.selectCandidate(at: index))
|
|
||||||
}
|
|
||||||
.accessibilityAddTraits(.isButton)
|
|
||||||
.accessibilityLabel(candidate.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
|
||||||
.padding(.trailing, Spacing.xs)
|
|
||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
|
||||||
|
|
||||||
if typing.canExpandCandidatePanel {
|
if typing.canExpandCandidatePanel {
|
||||||
expandChevronButton
|
expandChevronButton
|
||||||
@@ -256,6 +222,90 @@ struct TypingRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Three equal QuickType slots. Space applies only `role == .correction`.
|
||||||
|
private var englishQuickTypeBar: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(
|
||||||
|
Array(
|
||||||
|
typing.composition.candidates
|
||||||
|
.prefix(EnglishSuggestionEngine.slotCount)
|
||||||
|
.enumerated()
|
||||||
|
),
|
||||||
|
id: \.element.id
|
||||||
|
) { index, candidate in
|
||||||
|
if index > 0 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(palette.dividerStrong)
|
||||||
|
.frame(width: 1, height: 18)
|
||||||
|
}
|
||||||
|
englishQuickTypeSlot(candidate, index: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func englishQuickTypeSlot(_ candidate: TypingCandidate, index: Int) -> some View {
|
||||||
|
let label = candidate.isQuoted ? "\"\(candidate.text)\"" : candidate.text
|
||||||
|
let weight: Font.Weight = candidate.role == .correction ? .semibold : .regular
|
||||||
|
return Text(label)
|
||||||
|
.font(.system(size: 17, weight: weight))
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 40)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
apply(typing.selectCandidate(at: index))
|
||||||
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(candidate.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chineseCandidateStrip: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: Spacing.xs) {
|
||||||
|
if typing.composition.candidates.isEmpty {
|
||||||
|
selectedCandidateLabel(text: typing.composition.preedit)
|
||||||
|
} else if typing.isCandidatePanelExpanded {
|
||||||
|
candidateChip(text: typing.composition.candidates[0].text) {
|
||||||
|
apply(typing.selectCandidate(at: 0))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(
|
||||||
|
Array(
|
||||||
|
typing.composition.candidates
|
||||||
|
.prefix(TypingLayoutMetrics.collapsedBarCandidateLimit)
|
||||||
|
.enumerated()
|
||||||
|
),
|
||||||
|
id: \.element.id
|
||||||
|
) { index, candidate in
|
||||||
|
if index == 0 {
|
||||||
|
candidateChip(text: candidate.text) {
|
||||||
|
apply(typing.selectCandidate(at: index))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(candidate.text)
|
||||||
|
.font(.system(size: 20, weight: .regular))
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(height: 40)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
apply(typing.selectCandidate(at: index))
|
||||||
|
}
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.accessibilityLabel(candidate.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||||
|
.padding(.trailing, Spacing.xs)
|
||||||
|
}
|
||||||
|
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
/// Opaque chip like the translation control so ▼ never shares pixels with text.
|
/// Opaque chip like the translation control so ▼ never shares pixels with text.
|
||||||
private var expandChevronButton: some View {
|
private var expandChevronButton: some View {
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@@ -132,15 +132,17 @@ struct AIKeyboardView: View {
|
|||||||
)
|
)
|
||||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: Spacing.xs) {
|
ZStack {
|
||||||
KeyboardBrandLogo(action: state.openSettings)
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
KeyboardTopControls(
|
KeyboardTopControls(
|
||||||
state: state,
|
state: state,
|
||||||
typing: typing,
|
typing: typing,
|
||||||
palette: palette,
|
palette: palette,
|
||||||
onInsert: onInsert
|
onInsert: onInsert
|
||||||
)
|
)
|
||||||
|
HStack {
|
||||||
|
KeyboardBrandLogo(action: state.openSettings)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,17 +238,19 @@ public struct KeyboardRootView: View {
|
|||||||
onDismiss: state.dismissClipboardSuggestion
|
onDismiss: state.dismissClipboardSuggestion
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: Spacing.xs) {
|
ZStack {
|
||||||
KeyboardBrandLogo(action: state.openSettings)
|
|
||||||
// Globe key now lives at the bottom-left of the keyboard (matching
|
|
||||||
// iOS system layout); see micActionRow's bottom HStack.
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
KeyboardTopControls(
|
KeyboardTopControls(
|
||||||
state: state,
|
state: state,
|
||||||
typing: typing,
|
typing: typing,
|
||||||
palette: palette,
|
palette: palette,
|
||||||
onInsert: onInsert
|
onInsert: onInsert
|
||||||
)
|
)
|
||||||
|
HStack {
|
||||||
|
KeyboardBrandLogo(action: state.openSettings)
|
||||||
|
// Globe key now lives at the bottom-left of the keyboard (matching
|
||||||
|
// iOS system layout); see micActionRow's bottom HStack.
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ enum KeyboardTopBarMetrics {
|
|||||||
static let horizontalInset: CGFloat = 12
|
static let horizontalInset: CGFloat = 12
|
||||||
/// TypingRootView already contributes 8 pt around the entire key surface.
|
/// TypingRootView already contributes 8 pt around the entire key surface.
|
||||||
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
||||||
static let logoHeight: CGFloat = 22
|
static let logoHeight: CGFloat = 16
|
||||||
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
||||||
|
/// Equal hit width for AI / Voice / Chinese / English input tabs.
|
||||||
|
static let inputTabWidth: CGFloat = 42
|
||||||
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
|
/// Shared footprint for top-trailing chips (clipboard, cancel/X, translation).
|
||||||
static let trailingChipSize: CGFloat = 34
|
static let trailingChipSize: CGFloat = 34
|
||||||
static let trailingChipIconSize: CGFloat = 15
|
static let trailingChipIconSize: CGFloat = 15
|
||||||
@@ -113,39 +115,45 @@ struct KeyboardTopControls: View {
|
|||||||
let onInsert: (String) -> Void
|
let onInsert: (String) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 6) {
|
ZStack {
|
||||||
// 分段轨道:不透明灰底;选中项用白/升高键面滑动,避免半透明发淡。
|
inputTabSwitcher
|
||||||
HStack(spacing: 2) {
|
if state.canShowClipboardEntry {
|
||||||
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
|
HStack {
|
||||||
tabButton(tab)
|
Spacer(minLength: 0)
|
||||||
|
KeyboardClipboardMenuButton(
|
||||||
|
palette: palette,
|
||||||
|
action: state.openClipboardPanel
|
||||||
|
)
|
||||||
|
.equatable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(2)
|
}
|
||||||
.background(tabTrackFill, in: Capsule())
|
.frame(maxWidth: .infinity)
|
||||||
.overlay(
|
}
|
||||||
Capsule().stroke(palette.divider, lineWidth: 0.5)
|
|
||||||
)
|
|
||||||
|
|
||||||
if state.canShowClipboardEntry {
|
private var inputTabSwitcher: some View {
|
||||||
KeyboardClipboardMenuButton(
|
// 分段轨道固定在键盘水平中心,不受两侧 Logo / 剪贴板入口影响。
|
||||||
palette: palette,
|
HStack(spacing: 2) {
|
||||||
action: state.openClipboardPanel
|
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
|
||||||
)
|
tabButton(tab)
|
||||||
.equatable()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(2)
|
||||||
|
.background(tabTrackFill, in: Capsule())
|
||||||
|
.overlay(
|
||||||
|
Capsule().stroke(palette.divider, lineWidth: 0.5)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tabButton(_ tab: KeyboardInputTab) -> some View {
|
private func tabButton(_ tab: KeyboardInputTab) -> some View {
|
||||||
let selected = isSelected(tab)
|
let selected = isSelected(tab)
|
||||||
let width: CGFloat = tab == .english || tab == .ai ? 34 : 42
|
|
||||||
|
|
||||||
return Button {
|
return Button {
|
||||||
withAnimation(Motion.soft) {
|
withAnimation(Motion.soft) {
|
||||||
select(tab)
|
select(tab)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
tabLabel(tab, selected: selected, width: width)
|
tabLabel(tab, selected: selected)
|
||||||
}
|
}
|
||||||
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
|
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
|
||||||
.disabled(tab != .voice && !state.canEnterTypingSurface)
|
.disabled(tab != .voice && !state.canEnterTypingSurface)
|
||||||
@@ -157,13 +165,11 @@ struct KeyboardTopControls: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func tabLabel(
|
private func tabLabel(
|
||||||
_ tab: KeyboardInputTab,
|
_ tab: KeyboardInputTab,
|
||||||
selected: Bool,
|
selected: Bool
|
||||||
width: CGFloat
|
|
||||||
) -> some View {
|
) -> some View {
|
||||||
let label = Text(tab.title)
|
let label = tabContent(tab, selected: selected)
|
||||||
.font(.system(size: 12, weight: selected ? .semibold : .medium))
|
|
||||||
.foregroundStyle(selected ? palette.textPrimary : palette.textSecondary)
|
.foregroundStyle(selected ? palette.textPrimary : palette.textSecondary)
|
||||||
.frame(width: width, height: 30)
|
.frame(width: KeyboardTopBarMetrics.inputTabWidth, height: 30)
|
||||||
|
|
||||||
if selected {
|
if selected {
|
||||||
let namespace = sharedSelectionNamespace ?? fallbackSelectionNamespace
|
let namespace = sharedSelectionNamespace ?? fallbackSelectionNamespace
|
||||||
@@ -179,6 +185,20 @@ struct KeyboardTopControls: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tabContent(_ tab: KeyboardInputTab, selected: Bool) -> some View {
|
||||||
|
if tab == .ai {
|
||||||
|
Image(systemName: "sparkle")
|
||||||
|
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
||||||
|
} else if tab == .voice {
|
||||||
|
Image(systemName: "waveform.mid")
|
||||||
|
.font(.system(size: 15, weight: selected ? .semibold : .medium))
|
||||||
|
} else {
|
||||||
|
Text(tab.title)
|
||||||
|
.font(.system(size: 12, weight: selected ? .semibold : .medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
|
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
|
||||||
guard tab != .voice, !state.canEnterTypingSurface else { return 1 }
|
guard tab != .voice, !state.canEnterTypingSurface else { return 1 }
|
||||||
if case .recording = state.phase {
|
if case .recording = state.phase {
|
||||||
@@ -191,10 +211,10 @@ struct KeyboardTopControls: View {
|
|||||||
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
|
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 分段轨道底色:不透明,且与选中键面(NativeKeyboardKeyColors.fill)拉开明度,
|
/// 分段轨道底色与选中键面(NativeKeyboardKeyColors.fill)拉开明度;
|
||||||
/// 深色下压暗、浅色下提亮,让滑动的选中项始终清晰可辨。
|
/// 浅色模式叠加半透明黑色,在不同宿主键盘底色上维持可见对比。
|
||||||
private var tabTrackFill: Color {
|
private var tabTrackFill: Color {
|
||||||
colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.87)
|
colorScheme == .dark ? Color(white: 0.12) : Color.black.opacity(0.12)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
||||||
|
|||||||
@@ -10,18 +10,41 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
func testLexiconLoadsAndCompletesPrefix() {
|
func testLexiconLoadsAndCompletesPrefix() {
|
||||||
let lexicon = EnglishLexicon()
|
let lexicon = EnglishLexicon()
|
||||||
lexicon.prepare()
|
lexicon.prepare()
|
||||||
XCTAssertGreaterThan(lexicon.wordCount, 1_000)
|
XCTAssertTrue(lexicon.isLoaded)
|
||||||
|
XCTAssertGreaterThan(lexicon.wordCount, 10_000)
|
||||||
let hits = lexicon.completions(prefix: "hel", limit: 5)
|
let hits = lexicon.completions(prefix: "hel", limit: 5)
|
||||||
XCTAssertTrue(hits.contains("hello") || hits.contains("help") || hits.contains("held"))
|
XCTAssertTrue(hits.contains("hello") || hits.contains("help") || hits.contains("held"))
|
||||||
|
lexicon.unload()
|
||||||
|
XCTAssertFalse(lexicon.isLoaded)
|
||||||
|
XCTAssertEqual(lexicon.wordCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testChineseTypingDoesNotLoadEnglishLexicon() {
|
||||||
|
EnglishLexicon.shared.unload()
|
||||||
|
let typing = makeTypingSession(language: .chinese)
|
||||||
|
typing.enterTypingMode()
|
||||||
|
XCTAssertFalse(EnglishLexicon.shared.isLoaded)
|
||||||
|
_ = typing.setLanguage(.english)
|
||||||
|
XCTAssertTrue(EnglishLexicon.shared.isLoaded)
|
||||||
|
_ = typing.setLanguage(.chinese)
|
||||||
|
XCTAssertFalse(EnglishLexicon.shared.isLoaded)
|
||||||
|
typing.leaveTypingMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCorrectionFindsNearbyWord() {
|
func testCorrectionFindsNearbyWord() {
|
||||||
let lexicon = EnglishLexicon()
|
let engine = EnglishSuggestionEngine()
|
||||||
lexicon.prepare()
|
engine.prepare()
|
||||||
// "teh" is a classic typo for "the".
|
// "teh" leaks into web unigrams; the engine must still treat it as a typo.
|
||||||
let correction = lexicon.bestCorrection(for: "teh")
|
let decision = engine.correctionDecision(
|
||||||
XCTAssertEqual(correction, "the")
|
for: "teh",
|
||||||
XCTAssertNil(lexicon.bestCorrection(for: "the"))
|
personalTerms: [],
|
||||||
|
learnedBoosts: [:]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(decision?.replacement, "the")
|
||||||
|
XCTAssertNil(
|
||||||
|
engine.correctionDecision(for: "the", personalTerms: [], learnedBoosts: [:])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSuggestionEngineSkipsPersonalDictionaryTypos() {
|
func testSuggestionEngineSkipsPersonalDictionaryTypos() {
|
||||||
@@ -45,7 +68,22 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
learnedBoosts: [:]
|
learnedBoosts: [:]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
XCTAssertEqual(composition.candidates.first?.text, "OSGKeyboard")
|
XCTAssertEqual(composition.candidates.first?.role, .verbatim)
|
||||||
|
XCTAssertEqual(composition.candidates.first?.text, "osg")
|
||||||
|
XCTAssertTrue(composition.candidates.contains { $0.text == "OSGKeyboard" })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSuggestionEngineReturnsNoCandidatesWithoutCurrentWord() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
let composition = engine.compositionWhileTyping(
|
||||||
|
EnglishSuggestionContext(
|
||||||
|
previousWord: "hello",
|
||||||
|
personalTerms: ["OSGKeyboard"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(composition, .empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testAutocapitalizationAtFieldStartAndAfterSentence() {
|
func testAutocapitalizationAtFieldStartAndAfterSentence() {
|
||||||
@@ -85,10 +123,10 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
func testEnglishShiftArmsAfterReturnLikeNotes() {
|
func testEnglishShiftArmsAfterReturnLikeNotes() {
|
||||||
// Simulates Notes: proxy preceding text gains a trailing newline after Return.
|
// Simulates Notes: proxy preceding text gains a trailing newline after Return.
|
||||||
var preceding = "Hello"
|
var preceding = "Hello"
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.precedingTextProvider = { preceding }
|
typing.precedingTextProvider = { preceding }
|
||||||
typing.autocapitalizationModeProvider = { .sentences }
|
typing.autocapitalizationModeProvider = { .sentences }
|
||||||
_ = typing.setLanguage(.english)
|
typing.syncAutocapitalization()
|
||||||
XCTAssertFalse(typing.shiftActive, "mid-word should not arm Shift")
|
XCTAssertFalse(typing.shiftActive, "mid-word should not arm Shift")
|
||||||
|
|
||||||
_ = typing.handleReturn()
|
_ = typing.handleReturn()
|
||||||
@@ -105,10 +143,10 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
func testEnglishShiftArmsWhenProxyLagsAfterReturn() {
|
func testEnglishShiftArmsWhenProxyLagsAfterReturn() {
|
||||||
// Notes often still reports pre-Return context right after insertText("\n").
|
// Notes often still reports pre-Return context right after insertText("\n").
|
||||||
var preceding = "Hello"
|
var preceding = "Hello"
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.precedingTextProvider = { preceding }
|
typing.precedingTextProvider = { preceding }
|
||||||
typing.autocapitalizationModeProvider = { .sentences }
|
typing.autocapitalizationModeProvider = { .sentences }
|
||||||
_ = typing.setLanguage(.english)
|
typing.syncAutocapitalization()
|
||||||
|
|
||||||
_ = typing.handleReturn()
|
_ = typing.handleReturn()
|
||||||
// Proxy intentionally stale — still "Hello" without "\n".
|
// Proxy intentionally stale — still "Hello" without "\n".
|
||||||
@@ -120,10 +158,10 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func testEnglishShiftArmsWhenProxyLagsAfterPeriod() {
|
func testEnglishShiftArmsWhenProxyLagsAfterPeriod() {
|
||||||
var preceding = "Hello"
|
var preceding = "Hello"
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.precedingTextProvider = { preceding }
|
typing.precedingTextProvider = { preceding }
|
||||||
typing.autocapitalizationModeProvider = { .sentences }
|
typing.autocapitalizationModeProvider = { .sentences }
|
||||||
_ = typing.setLanguage(.english)
|
typing.syncAutocapitalization()
|
||||||
|
|
||||||
_ = typing.handleKey(".")
|
_ = typing.handleKey(".")
|
||||||
typing.syncAutocapitalization(accountingForInsert: ".")
|
typing.syncAutocapitalization(accountingForInsert: ".")
|
||||||
@@ -132,9 +170,7 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testEnglishIdleShowsNoCandidatesUntilLetterTyped() {
|
func testEnglishIdleShowsNoCandidatesUntilLetterTyped() {
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.suggestionsEnabled = true
|
|
||||||
_ = typing.setLanguage(.english)
|
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||||
|
|
||||||
@@ -163,9 +199,7 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testEnglishTypingEmitsCompletionsIntoComposition() {
|
func testEnglishTypingEmitsCompletionsIntoComposition() {
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.suggestionsEnabled = true
|
|
||||||
_ = typing.setLanguage(.english)
|
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
|
|
||||||
_ = typing.handleKey("h")
|
_ = typing.handleKey("h")
|
||||||
@@ -180,11 +214,9 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
func testOldWordBackspaceRehydratesSuggestionsFromDocumentContext() {
|
func testOldWordBackspaceRehydratesSuggestionsFromDocumentContext() {
|
||||||
var preceding = "board"
|
var preceding = "board"
|
||||||
let following = "\n"
|
let following = "\n"
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.suggestionsEnabled = true
|
|
||||||
typing.precedingTextProvider = { preceding }
|
typing.precedingTextProvider = { preceding }
|
||||||
typing.followingTextProvider = { following }
|
typing.followingTextProvider = { following }
|
||||||
_ = typing.setLanguage(.english)
|
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
typing.synchronizeEnglishDocumentContext(caretMoved: true)
|
typing.synchronizeEnglishDocumentContext(caretMoved: true)
|
||||||
|
|
||||||
@@ -208,7 +240,7 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
XCTAssertEqual(typing.composition.preedit, "boa")
|
XCTAssertEqual(typing.composition.preedit, "boa")
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
typing.composition.candidates.contains {
|
typing.composition.candidates.contains {
|
||||||
$0.text.compare("boat", options: .caseInsensitive) == .orderedSame
|
$0.text.compare("board", options: .caseInsensitive) == .orderedSame
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -220,11 +252,9 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func testCandidateReplacementRejectsStaleDocumentAnchor() {
|
func testCandidateReplacementRejectsStaleDocumentAnchor() {
|
||||||
var preceding = ""
|
var preceding = ""
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.suggestionsEnabled = true
|
|
||||||
typing.precedingTextProvider = { preceding }
|
typing.precedingTextProvider = { preceding }
|
||||||
typing.followingTextProvider = { "" }
|
typing.followingTextProvider = { "" }
|
||||||
_ = typing.setLanguage(.english)
|
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
|
|
||||||
for key in ["b", "o", "a"] {
|
for key in ["b", "o", "a"] {
|
||||||
@@ -232,14 +262,14 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
preceding += output.text
|
preceding += output.text
|
||||||
typing.syncAutocapitalization(accountingForInsert: output.text)
|
typing.syncAutocapitalization(accountingForInsert: output.text)
|
||||||
}
|
}
|
||||||
guard let boatIndex = typing.composition.candidates.firstIndex(where: {
|
guard let boardIndex = typing.composition.candidates.firstIndex(where: {
|
||||||
$0.text.compare("boat", options: .caseInsensitive) == .orderedSame
|
$0.text.compare("board", options: .caseInsensitive) == .orderedSame
|
||||||
}) else {
|
}) else {
|
||||||
return XCTFail("expected boat completion")
|
return XCTFail("expected board completion")
|
||||||
}
|
}
|
||||||
|
|
||||||
preceding = "board"
|
preceding = "board"
|
||||||
let output = typing.selectCandidate(at: boatIndex)
|
let output = typing.selectCandidate(at: boardIndex)
|
||||||
|
|
||||||
XCTAssertEqual(output, .none)
|
XCTAssertEqual(output, .none)
|
||||||
XCTAssertEqual(typing.composition.preedit.lowercased(), "board")
|
XCTAssertEqual(typing.composition.preedit.lowercased(), "board")
|
||||||
@@ -247,11 +277,9 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testMidWordCaretSuppressesUnsafeBackwardOnlyReplacement() {
|
func testMidWordCaretSuppressesUnsafeBackwardOnlyReplacement() {
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.suggestionsEnabled = true
|
|
||||||
typing.precedingTextProvider = { "boa" }
|
typing.precedingTextProvider = { "boa" }
|
||||||
typing.followingTextProvider = { "rd" }
|
typing.followingTextProvider = { "rd" }
|
||||||
_ = typing.setLanguage(.english)
|
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
|
|
||||||
typing.synchronizeEnglishDocumentContext(caretMoved: true)
|
typing.synchronizeEnglishDocumentContext(caretMoved: true)
|
||||||
@@ -263,11 +291,9 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func testStaleHostCallbackDoesNotDiscardLocalEnglishWord() {
|
func testStaleHostCallbackDoesNotDiscardLocalEnglishWord() {
|
||||||
var preceding = ""
|
var preceding = ""
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
typing.suggestionsEnabled = true
|
|
||||||
typing.precedingTextProvider = { preceding }
|
typing.precedingTextProvider = { preceding }
|
||||||
typing.followingTextProvider = { "" }
|
typing.followingTextProvider = { "" }
|
||||||
_ = typing.setLanguage(.english)
|
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
|
|
||||||
let output = typing.handleKey("h")
|
let output = typing.handleKey("h")
|
||||||
@@ -283,27 +309,22 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testAutocorrectUndoRestoresOriginal() {
|
func testAutocorrectUndoRestoresOriginal() {
|
||||||
let typing = TypingSessionController()
|
let typing = makeIsolatedEnglishSession(suite: "english.undo.test")
|
||||||
typing.suggestionsEnabled = true
|
|
||||||
_ = typing.setLanguage(.english)
|
|
||||||
typing.enterTypingMode()
|
|
||||||
|
|
||||||
for ch in ["t", "e", "h"] {
|
for ch in ["t", "e", "h"] {
|
||||||
_ = typing.handleKey(ch)
|
_ = typing.handleKey(ch)
|
||||||
}
|
}
|
||||||
let spaced = typing.handleSpace()
|
let spaced = typing.handleSpace()
|
||||||
// Either corrected to "the " or left as-is if lexicon missing in test bundle.
|
XCTAssertEqual(spaced.deleteCount, 3)
|
||||||
if spaced.deleteCount > 0 {
|
XCTAssertTrue(spaced.text.hasPrefix("the"))
|
||||||
XCTAssertTrue(spaced.text.hasPrefix("the"))
|
let undone = typing.handleKey("⌫")
|
||||||
let undone = typing.handleKey("⌫")
|
XCTAssertEqual(undone.text, "teh")
|
||||||
XCTAssertEqual(undone.text, "teh")
|
XCTAssertEqual(undone.deleteCount, spaced.text.count)
|
||||||
XCTAssertEqual(undone.deleteCount, spaced.text.count)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testPeriodShortcutReplacesDoubleSpace() {
|
func testPeriodShortcutReplacesDoubleSpace() {
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
_ = typing.setLanguage(.english)
|
_ = typing.setLanguage(.english)
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
|
|
||||||
@@ -322,7 +343,7 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testPeriodShortcutDoesNotFireAfterAnotherLetter() {
|
func testPeriodShortcutDoesNotFireAfterAnotherLetter() {
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
_ = typing.setLanguage(.english)
|
_ = typing.setLanguage(.english)
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
|
|
||||||
@@ -335,8 +356,7 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testPeriodShortcutDoesNotFireAfterSentenceTerminator() {
|
func testPeriodShortcutDoesNotFireAfterSentenceTerminator() {
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession()
|
||||||
_ = typing.setLanguage(.english)
|
|
||||||
typing.precedingTextProvider = { "Hello." }
|
typing.precedingTextProvider = { "Hello." }
|
||||||
typing.enterTypingMode()
|
typing.enterTypingMode()
|
||||||
typing.syncAutocapitalization()
|
typing.syncAutocapitalization()
|
||||||
@@ -348,8 +368,7 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testChineseSpaceDoesNotUsePeriodShortcut() {
|
func testChineseSpaceDoesNotUsePeriodShortcut() {
|
||||||
let typing = TypingSessionController()
|
let typing = makeTypingSession(language: .chinese)
|
||||||
_ = typing.setLanguage(.chinese)
|
|
||||||
let first = typing.handleSpace()
|
let first = typing.handleSpace()
|
||||||
let second = typing.handleSpace()
|
let second = typing.handleSpace()
|
||||||
XCTAssertEqual(first.text, " ")
|
XCTAssertEqual(first.text, " ")
|
||||||
@@ -370,6 +389,201 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
XCTAssertFalse(PeriodShortcut.shouldArm(afterSpaceFollowing: "hello "))
|
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
|
||||||
|
func testEnglishQuickTypePutsVerbatimFirstAndMarksCorrection() {
|
||||||
|
let typing = makeIsolatedEnglishSession(suite: "english.quicktype.bar.test")
|
||||||
|
|
||||||
|
for character in ["t", "e", "h"] {
|
||||||
|
_ = typing.handleKey(character)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(typing.composition.candidates.first?.role, .verbatim)
|
||||||
|
XCTAssertEqual(
|
||||||
|
typing.composition.candidates.first?.text.lowercased(),
|
||||||
|
"teh"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
typing.composition.candidates.contains {
|
||||||
|
$0.role == .correction && $0.text.lowercased() == "the"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertLessThanOrEqual(typing.composition.candidates.count, EnglishSuggestionEngine.slotCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testEnglishSpaceAppliesCorrectionSlotOnly() {
|
||||||
|
let typing = makeIsolatedEnglishSession(suite: "english.quicktype.space.test")
|
||||||
|
|
||||||
|
for character in ["t", "e", "h"] {
|
||||||
|
_ = typing.handleKey(character)
|
||||||
|
}
|
||||||
|
let spaced = typing.handleSpace()
|
||||||
|
XCTAssertEqual(spaced.deleteCount, 3)
|
||||||
|
XCTAssertTrue(spaced.text.lowercased().hasPrefix("the"))
|
||||||
|
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testEnglishSpaceKeepsVerbatimWhenNoCorrection() {
|
||||||
|
let typing = makeIsolatedEnglishSession(suite: "english.quicktype.verbatim.test")
|
||||||
|
|
||||||
|
for character in ["h", "e", "l"] {
|
||||||
|
_ = typing.handleKey(character)
|
||||||
|
}
|
||||||
|
let spaced = typing.handleSpace()
|
||||||
|
XCTAssertEqual(spaced, .insert(" "))
|
||||||
|
XCTAssertTrue(typing.composition.candidates.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTitleCaseNamesAreNotAutocorrected() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertNil(engine.correctionDecision(for: "Rocky", personalTerms: [], learnedBoosts: [:]))
|
||||||
|
XCTAssertNil(engine.correctionDecision(for: "Wang", personalTerms: [], learnedBoosts: [:]))
|
||||||
|
XCTAssertNil(engine.correctionDecision(for: "Chen", personalTerms: [], learnedBoosts: [:]))
|
||||||
|
XCTAssertNil(engine.correctionDecision(for: "Li", personalTerms: [], learnedBoosts: [:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTitleCaseTranspositionStillCorrects() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertEqual(
|
||||||
|
engine.correctionDecision(for: "Teh", personalTerms: [], learnedBoosts: [:])?.replacement,
|
||||||
|
"The"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProximityCorrectsAdjacentKeyTypos() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertEqual(
|
||||||
|
engine.correctionDecision(for: "gppd", personalTerms: [], learnedBoosts: [:])?.replacement,
|
||||||
|
"good"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRealWordFormIsNotCorrectedToFrom() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertNil(engine.correctionDecision(for: "form", personalTerms: [], learnedBoosts: [:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupplementaryLexiconBlocksAutocorrect() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertNil(
|
||||||
|
engine.correctionDecision(
|
||||||
|
for: "teh",
|
||||||
|
personalTerms: [],
|
||||||
|
learnedBoosts: [:],
|
||||||
|
systemWords: ["teh"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLearnedDefenseBlocksAutocorrect() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertNil(
|
||||||
|
engine.correctionDecision(
|
||||||
|
for: "teh",
|
||||||
|
personalTerms: [],
|
||||||
|
learnedBoosts: ["teh": 5]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testAutocorrectDoesNotBoostReplacement() {
|
||||||
|
let suite = "english.learning.polarity.test"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
let store = EnglishLearningStore(defaults: defaults)
|
||||||
|
let typing = makeTypingSession(learningStore: store)
|
||||||
|
typing.enterTypingMode()
|
||||||
|
|
||||||
|
for character in ["t", "e", "h"] {
|
||||||
|
_ = typing.handleKey(character)
|
||||||
|
}
|
||||||
|
_ = typing.handleSpace()
|
||||||
|
XCTAssertEqual(store.boost(for: "the"), 0)
|
||||||
|
XCTAssertEqual(store.boost(for: "teh"), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testRejectingAutocorrectLearnsOriginal() {
|
||||||
|
let suite = "english.learning.defense.test"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
let store = EnglishLearningStore(defaults: defaults)
|
||||||
|
let typing = makeTypingSession(learningStore: store)
|
||||||
|
typing.enterTypingMode()
|
||||||
|
|
||||||
|
for character in ["t", "e", "h"] {
|
||||||
|
_ = typing.handleKey(character)
|
||||||
|
}
|
||||||
|
let spaced = typing.handleSpace()
|
||||||
|
if spaced.deleteCount > 0 {
|
||||||
|
_ = typing.handleKey("⌫")
|
||||||
|
XCTAssertGreaterThanOrEqual(store.boost(for: "teh"), 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQWERTYNeighborsIncludeDiagonals() {
|
||||||
|
let aroundG = EnglishQWERTYProximity.neighbors(of: "g", includingSelf: true)
|
||||||
|
XCTAssertTrue(aroundG.contains("t"))
|
||||||
|
XCTAssertTrue(aroundG.contains("f"))
|
||||||
|
XCTAssertTrue(aroundG.contains("h"))
|
||||||
|
XCTAssertTrue(aroundG.contains("b"))
|
||||||
|
XCTAssertFalse(aroundG.contains("q"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBigramsPredictNextWords() {
|
||||||
|
let lexicon = EnglishLexicon()
|
||||||
|
lexicon.prepare()
|
||||||
|
let next = lexicon.nextWords(after: "thank", limit: 4)
|
||||||
|
XCTAssertTrue(next.contains("you"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeIsolatedEnglishSession(suite: String) -> TypingSessionController {
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
let store = EnglishLearningStore(defaults: defaults)
|
||||||
|
let typing = makeTypingSession(learningStore: store)
|
||||||
|
typing.enterTypingMode()
|
||||||
|
return typing
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeTypingSession(
|
||||||
|
learningStore: EnglishLearningStore? = nil,
|
||||||
|
language: TypingInputLanguage = .english
|
||||||
|
) -> TypingSessionController {
|
||||||
|
let store = learningStore ?? EnglishLearningStore(
|
||||||
|
defaults: UserDefaults(suiteName: "english.tests.\(UUID().uuidString)")!
|
||||||
|
)
|
||||||
|
let typing = TypingSessionController(
|
||||||
|
engine: { NoopRimeEngine() },
|
||||||
|
learningStore: store
|
||||||
|
)
|
||||||
|
typing.suggestionsEnabled = true
|
||||||
|
_ = typing.setLanguage(language)
|
||||||
|
return typing
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func apply(_ typing: TypingSessionController, _ output: TypingOutput) {
|
private func apply(_ typing: TypingSessionController, _ output: TypingOutput) {
|
||||||
typing.syncAutocapitalization(
|
typing.syncAutocapitalization(
|
||||||
@@ -378,3 +592,26 @@ final class EnglishTypingTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class NoopRimeEngine: RimeEngineBridging {
|
||||||
|
var composition: TypingComposition = .empty
|
||||||
|
var isReady = true
|
||||||
|
var schema: TypingInputSchema = .fullPinyin
|
||||||
|
|
||||||
|
func prepare() async throws {}
|
||||||
|
func teardown() { composition = .empty }
|
||||||
|
func setLanguage(_ language: TypingInputLanguage) {}
|
||||||
|
@discardableResult
|
||||||
|
func setSchema(_ schema: TypingInputSchema) -> Bool {
|
||||||
|
self.schema = schema
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
func processCharacter(_ character: Character) -> String? { nil }
|
||||||
|
func processBackspace() -> String? { nil }
|
||||||
|
func processSpace() -> String? { " " }
|
||||||
|
func processReturn() -> String? { "\n" }
|
||||||
|
func selectCandidate(at index: Int) -> String { "" }
|
||||||
|
func flushPreedit() -> String { "" }
|
||||||
|
func clearComposition() { composition = .empty }
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testKeyRowsFollowTypingLanguageOnNumberPage() {
|
func testKeyRowsFollowTypingLanguageOnNumberPage() {
|
||||||
let typing = TypingSessionController()
|
let typing = TypingSessionController(engine: { TrackingStubRimeEngine() })
|
||||||
typing.setPage(.numbers)
|
typing.setPage(.numbers)
|
||||||
XCTAssertEqual(typing.keyRows[1], ["-", "/", ":", ";", "(", ")", "¥", "@", "“", "”"])
|
XCTAssertEqual(typing.keyRows[1], ["-", "/", ":", ";", "(", ")", "¥", "@", "“", "”"])
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testResourceRetryIsSkippedWhenNoErrorIsPending() {
|
func testResourceRetryIsSkippedWhenNoErrorIsPending() {
|
||||||
let typing = TypingSessionController()
|
let typing = TypingSessionController(engine: { TrackingStubRimeEngine() })
|
||||||
XCTAssertNil(typing.lastError)
|
XCTAssertNil(typing.lastError)
|
||||||
XCTAssertFalse(typing.lastErrorNeedsHostDeployment)
|
XCTAssertFalse(typing.lastErrorNeedsHostDeployment)
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSharedCapsuleCanSelectSpecificTypingLanguage() {
|
func testSharedCapsuleCanSelectSpecificTypingLanguage() {
|
||||||
let typing = TypingSessionController()
|
let typing = TypingSessionController(engine: { TrackingStubRimeEngine() })
|
||||||
XCTAssertEqual(typing.language, .chinese)
|
XCTAssertEqual(typing.language, .chinese)
|
||||||
|
|
||||||
XCTAssertEqual(typing.setLanguage(.english), .none)
|
XCTAssertEqual(typing.setLanguage(.english), .none)
|
||||||
@@ -349,7 +349,7 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testShiftStateProducesUppercaseKeyRows() {
|
func testShiftStateProducesUppercaseKeyRows() {
|
||||||
let typing = TypingSessionController()
|
let typing = TypingSessionController(engine: { TrackingStubRimeEngine() })
|
||||||
XCTAssertFalse(typing.shiftActive)
|
XCTAssertFalse(typing.shiftActive)
|
||||||
|
|
||||||
_ = typing.handleKey("⇧")
|
_ = typing.handleKey("⇧")
|
||||||
@@ -360,7 +360,7 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testManualShiftSurvivesAutocapitalizationSync() {
|
func testManualShiftSurvivesAutocapitalizationSync() {
|
||||||
let typing = TypingSessionController()
|
let typing = TypingSessionController(engine: { TrackingStubRimeEngine() })
|
||||||
// Providers must be set before language switch (which syncs autocap).
|
// Providers must be set before language switch (which syncs autocap).
|
||||||
typing.precedingTextProvider = { "hello " } // mid-sentence: auto stays off
|
typing.precedingTextProvider = { "hello " } // mid-sentence: auto stays off
|
||||||
typing.autocapitalizationModeProvider = { .sentences }
|
typing.autocapitalizationModeProvider = { .sentences }
|
||||||
@@ -376,7 +376,7 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testShiftHoldTypesUppercaseWithoutEnteringCapsLock() {
|
func testShiftHoldTypesUppercaseWithoutEnteringCapsLock() {
|
||||||
let typing = TypingSessionController()
|
let typing = TypingSessionController(engine: { TrackingStubRimeEngine() })
|
||||||
_ = typing.setLanguage(.english)
|
_ = typing.setLanguage(.english)
|
||||||
typing.precedingTextProvider = { "hello " }
|
typing.precedingTextProvider = { "hello " }
|
||||||
typing.autocapitalizationModeProvider = { .sentences }
|
typing.autocapitalizationModeProvider = { .sentences }
|
||||||
@@ -396,7 +396,7 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testShiftHoldWithoutTypingActsAsTap() {
|
func testShiftHoldWithoutTypingActsAsTap() {
|
||||||
let typing = TypingSessionController()
|
let typing = TypingSessionController(engine: { TrackingStubRimeEngine() })
|
||||||
typing.beginShiftHold()
|
typing.beginShiftHold()
|
||||||
typing.endShiftHold()
|
typing.endShiftHold()
|
||||||
XCTAssertTrue(typing.shiftActive)
|
XCTAssertTrue(typing.shiftActive)
|
||||||
@@ -456,6 +456,24 @@ final class KeyboardSurfaceStateTests: XCTestCase {
|
|||||||
XCTAssertEqual(engine.lastProcessedCharacter, "n")
|
XCTAssertEqual(engine.lastProcessedCharacter, "n")
|
||||||
XCTAssertEqual(typing.composition.preedit, "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.
|
/// Stub that records `processCharacter` calls for Chinese Shift bypass tests.
|
||||||
@@ -465,6 +483,8 @@ private final class TrackingStubRimeEngine: RimeEngineBridging {
|
|||||||
var isReady: Bool = true
|
var isReady: Bool = true
|
||||||
var schema: TypingInputSchema = .fullPinyin
|
var schema: TypingInputSchema = .fullPinyin
|
||||||
private(set) var processCharacterCallCount = 0
|
private(set) var processCharacterCallCount = 0
|
||||||
|
private(set) var processSpaceCallCount = 0
|
||||||
|
private(set) var clearCompositionCallCount = 0
|
||||||
private(set) var lastProcessedCharacter: Character?
|
private(set) var lastProcessedCharacter: Character?
|
||||||
|
|
||||||
func prepare() async throws {}
|
func prepare() async throws {}
|
||||||
@@ -496,6 +516,7 @@ private final class TrackingStubRimeEngine: RimeEngineBridging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func processSpace() -> String? {
|
func processSpace() -> String? {
|
||||||
|
processSpaceCallCount += 1
|
||||||
composition = .empty
|
composition = .empty
|
||||||
return " "
|
return " "
|
||||||
}
|
}
|
||||||
@@ -517,6 +538,7 @@ private final class TrackingStubRimeEngine: RimeEngineBridging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func clearComposition() {
|
func clearComposition() {
|
||||||
|
clearCompositionCallCount += 1
|
||||||
composition = .empty
|
composition = .empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// LibrimeIntegrationTests.swift
|
// LibrimeIntegrationTests.swift
|
||||||
// OSGKeyboard · Ext unit tests
|
// OSGKeyboard · Ext unit tests
|
||||||
|
|
||||||
|
import Darwin
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import OSGKeyboardShared
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ final class LibrimeIntegrationTests: XCTestCase {
|
|||||||
distributionVersion: "tests"
|
distributionVersion: "tests"
|
||||||
)
|
)
|
||||||
try bridge.start()
|
try bridge.start()
|
||||||
|
defer { bridge.finalizeRuntime() }
|
||||||
|
|
||||||
let vectors: [(TypingInputSchema, String)] = [
|
let vectors: [(TypingInputSchema, String)] = [
|
||||||
(.fullPinyin, "nihao"),
|
(.fullPinyin, "nihao"),
|
||||||
@@ -140,40 +142,34 @@ final class LibrimeIntegrationTests: XCTestCase {
|
|||||||
let userFiles = fileManager.enumerator(atPath: user.path)?
|
let userFiles = fileManager.enumerator(atPath: user.path)?
|
||||||
.compactMap { $0 as? String } ?? []
|
.compactMap { $0 as? String } ?? []
|
||||||
XCTAssertTrue(userFiles.contains(where: { $0.contains("userdb") }))
|
XCTAssertTrue(userFiles.contains(where: { $0.contains("userdb") }))
|
||||||
|
}
|
||||||
|
|
||||||
// Redeploy with one fuzzy pair and verify it affects actual Rime
|
func testNLFuzzyMapsLihaoToNihao() throws {
|
||||||
// candidates rather than just generated YAML.
|
let env = try stagedRimeEnvironment(fuzzyPairs: [.nL])
|
||||||
for schema in TypingInputSchema.allCases {
|
defer { try? env.fileManager.removeItem(at: env.root) }
|
||||||
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: [.nL]).write(
|
|
||||||
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
|
|
||||||
atomically: true,
|
|
||||||
encoding: .utf8
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let fuzzyDeployer = OSGRimeBridge(
|
|
||||||
sharedDataDirectory: shared.path,
|
|
||||||
userDataDirectory: user.path,
|
|
||||||
distributionVersion: "tests-fuzzy"
|
|
||||||
)
|
|
||||||
try fuzzyDeployer.deploy(withFullCheck: true)
|
|
||||||
fuzzyDeployer.finalizeRuntime()
|
|
||||||
|
|
||||||
let fuzzyBridge = OSGRimeBridge(
|
let deployer = OSGRimeBridge(
|
||||||
sharedDataDirectory: shared.path,
|
sharedDataDirectory: env.shared.path,
|
||||||
userDataDirectory: user.path,
|
userDataDirectory: env.user.path,
|
||||||
distributionVersion: "tests-fuzzy"
|
distributionVersion: "tests-fuzzy-nl"
|
||||||
)
|
)
|
||||||
try fuzzyBridge.start()
|
try deployer.deploy(withFullCheck: true)
|
||||||
XCTAssertTrue(fuzzyBridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
|
deployer.finalizeRuntime()
|
||||||
for scalar in "lihao".utf8 {
|
|
||||||
XCTAssertTrue(fuzzyBridge.processKeyCode(Int32(scalar), modifiers: 0))
|
let bridge = OSGRimeBridge(
|
||||||
}
|
sharedDataDirectory: env.shared.path,
|
||||||
let fuzzySnapshot = fuzzyBridge.snapshot(withCandidateLimit: 100)
|
userDataDirectory: env.user.path,
|
||||||
|
distributionVersion: "tests-fuzzy-nl"
|
||||||
|
)
|
||||||
|
try bridge.start()
|
||||||
|
defer { bridge.finalizeRuntime() }
|
||||||
|
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
|
||||||
|
type("lihao", on: bridge)
|
||||||
|
let snapshot = bridge.snapshot(withCandidateLimit: 100)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
fuzzySnapshot.candidates.contains(where: { $0.text == "你好" }),
|
snapshot.candidates.contains(where: { $0.text == "你好" }),
|
||||||
"n/l fuzzy candidates: \(fuzzySnapshot.candidates.map(\.text).prefix(20))"
|
"n/l fuzzy candidates: \(snapshot.candidates.map(\.text).prefix(20))"
|
||||||
)
|
)
|
||||||
fuzzyBridge.finalizeRuntime()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPersonalDictionarySidecarPinsSameCodeCandidates() throws {
|
func testPersonalDictionarySidecarPinsSameCodeCandidates() throws {
|
||||||
@@ -264,4 +260,367 @@ final class LibrimeIntegrationTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
bridge.finalizeRuntime()
|
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(
|
||||||
|
fuzzyPairs: Set<PinyinFuzzyPair> = []
|
||||||
|
) 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: 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)
|
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
|
@MainActor
|
||||||
func testTypingConfigurationDefaultsToFullPinyinWithoutFuzzyPairs() {
|
func testTypingConfigurationDefaultsToFullPinyinWithoutFuzzyPairs() {
|
||||||
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
|
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public var summary: String
|
public var summary: String
|
||||||
public var systemImage: String
|
public var systemImage: String
|
||||||
public var prompt: String
|
public var prompt: String
|
||||||
public var shortcutICloudURL: URL
|
/// Optional iCloud share URL. Nil means the skill only transforms text.
|
||||||
|
public var shortcutICloudURL: URL?
|
||||||
/// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
|
/// Name used by `shortcuts://run-shortcut?name=`. Independent of `name`.
|
||||||
public var shortcutName: String
|
public var shortcutName: String
|
||||||
/// Per-skill reasoning. Built-in skills are always off; custom defaults off.
|
/// Per-skill reasoning. Built-in skills are always off; custom defaults off.
|
||||||
@@ -28,8 +29,8 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
|
|||||||
summary: String = "",
|
summary: String = "",
|
||||||
systemImage: String = AIUserSkillLimits.defaultSystemImage,
|
systemImage: String = AIUserSkillLimits.defaultSystemImage,
|
||||||
prompt: String,
|
prompt: String,
|
||||||
shortcutICloudURL: URL,
|
shortcutICloudURL: URL? = nil,
|
||||||
shortcutName: String,
|
shortcutName: String = "",
|
||||||
thinkingEnabled: Bool = false,
|
thinkingEnabled: Bool = false,
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
updatedAt: Date? = nil
|
updatedAt: Date? = nil
|
||||||
@@ -49,15 +50,16 @@ public struct AIUserSkill: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
public var isUserCreated: Bool { id.hasPrefix("user.") }
|
||||||
|
|
||||||
public func asClipboardSkill() -> AIClipboardSkill {
|
public func asClipboardSkill() -> AIClipboardSkill {
|
||||||
AIClipboardSkill(
|
let exportsToShortcut = shortcutICloudURL != nil
|
||||||
|
return AIClipboardSkill(
|
||||||
id: id,
|
id: id,
|
||||||
systemImage: systemImage,
|
systemImage: systemImage,
|
||||||
titleKey: "",
|
titleKey: "",
|
||||||
cardTitleKey: "",
|
cardTitleKey: "",
|
||||||
descriptionKey: "",
|
descriptionKey: "",
|
||||||
kind: .export,
|
kind: exportsToShortcut ? .export : .transform,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
shortcutName: shortcutName,
|
shortcutName: exportsToShortcut ? shortcutName : nil,
|
||||||
shortcutICloudURL: shortcutICloudURL,
|
shortcutICloudURL: shortcutICloudURL,
|
||||||
customName: name,
|
customName: name,
|
||||||
customSummary: summary,
|
customSummary: summary,
|
||||||
@@ -149,9 +151,13 @@ public struct AIUserSkillCatalog: Codable, Equatable, Sendable {
|
|||||||
maximum: AIUserSkillLimits.maximumPromptCharacters
|
maximum: AIUserSkillLimits.maximumPromptCharacters
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
guard !shortcutName.isEmpty else { throw AIUserSkillValidationError.emptyShortcutName }
|
if let shortcutURL = skill.shortcutICloudURL {
|
||||||
guard AIShortcutShareLink.isValid(skill.shortcutICloudURL) else {
|
guard !shortcutName.isEmpty else {
|
||||||
throw AIUserSkillValidationError.invalidShortcutLink
|
throw AIUserSkillValidationError.emptyShortcutName
|
||||||
|
}
|
||||||
|
guard AIShortcutShareLink.isValid(shortcutURL) else {
|
||||||
|
throw AIUserSkillValidationError.invalidShortcutLink
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
|
guard !icon.isEmpty else { throw AIUserSkillValidationError.emptyIcon }
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -130,13 +130,26 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func saveUserSkill(_ skill: AIUserSkill) throws {
|
public func saveUserSkill(_ skill: AIUserSkill) throws {
|
||||||
let previousURL = userCatalog.skill(id: skill.id)?.shortcutICloudURL
|
let previousSkill = userCatalog.skill(id: skill.id)
|
||||||
|
let previousURL = previousSkill?.shortcutICloudURL
|
||||||
|
let previousLayout = layout.sanitized(catalog: mergedCatalog)
|
||||||
var catalog = userCatalog
|
var catalog = userCatalog
|
||||||
try catalog.upsert(skill)
|
try catalog.upsert(skill)
|
||||||
commitUserCatalog(catalog)
|
commitUserCatalog(catalog)
|
||||||
if previousURL != nil, previousURL != skill.shortcutICloudURL {
|
guard previousSkill != nil, previousURL != skill.shortcutICloudURL else {
|
||||||
dropShortcutConfirmation(for: skill.id)
|
return
|
||||||
}
|
}
|
||||||
|
let keepsKeyboardSlot = skill.shortcutICloudURL == nil
|
||||||
|
commitLayout(
|
||||||
|
AIAgentSkillLayout(
|
||||||
|
enabledIDs: keepsKeyboardSlot
|
||||||
|
? previousLayout.enabledIDs
|
||||||
|
: previousLayout.enabledIDs.filter { $0 != skill.id },
|
||||||
|
confirmedShortcutIDs: previousLayout.confirmedShortcutIDs.filter {
|
||||||
|
$0 != skill.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func deleteUserSkill(id: String) {
|
public func deleteUserSkill(id: String) {
|
||||||
@@ -152,16 +165,6 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dropShortcutConfirmation(for id: String) {
|
|
||||||
let current = layout.sanitized(catalog: mergedCatalog)
|
|
||||||
commitLayout(
|
|
||||||
AIAgentSkillLayout(
|
|
||||||
enabledIDs: current.enabledIDs.filter { $0 != id },
|
|
||||||
confirmedShortcutIDs: current.confirmedShortcutIDs.filter { $0 != id }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func commitLayout(_ layout: AIAgentSkillLayout) {
|
private func commitLayout(_ layout: AIAgentSkillLayout) {
|
||||||
persistLayout(layout)
|
persistLayout(layout)
|
||||||
self.layout = loadLayout()
|
self.layout = loadLayout()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// BuiltinLexiconIndex.swift
|
// BuiltinLexiconIndex.swift
|
||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// In-memory index over bundled `phrases.tsv` (~10k computer terms).
|
// In-memory index over the bundled project-curated AI/technology `phrases.tsv`.
|
||||||
// macOS local ASR consumes a Top-N subset; the full index also backs
|
// macOS local ASR consumes a Top-N subset; the full index also backs
|
||||||
// polish supplements and future retrieval.
|
// polish supplements and future retrieval.
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Foundation
|
|||||||
|
|
||||||
public enum LocalASRBiasAdapter {
|
public enum LocalASRBiasAdapter {
|
||||||
|
|
||||||
/// Bundle IDs where computer-science vocabulary is especially likely.
|
/// Bundle IDs where the curated AI/technology vocabulary is especially likely.
|
||||||
private static let codeEditorBundleIDs: Set<String> = [
|
private static let codeEditorBundleIDs: Set<String> = [
|
||||||
"com.apple.dt.Xcode",
|
"com.apple.dt.Xcode",
|
||||||
"com.microsoft.VSCode",
|
"com.microsoft.VSCode",
|
||||||
@@ -119,7 +119,7 @@ public enum LocalASRBiasAdapter {
|
|||||||
|
|
||||||
private static func preferredLexiconSources(for bundleId: String?) -> Set<String>? {
|
private static func preferredLexiconSources(for bundleId: String?) -> Set<String>? {
|
||||||
guard let bundleId, codeEditorBundleIDs.contains(bundleId) else { return nil }
|
guard let bundleId, codeEditorBundleIDs.contains(bundleId) else { return nil }
|
||||||
return ["computer_terms"]
|
return ["ai_tech_seed"]
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hardHotwordList(from terms: [String], maxCount: Int) -> [String] {
|
private static func hardHotwordList(from terms: [String], maxCount: Int) -> [String] {
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ public final class EnglishLearningStore: @unchecked Sendable {
|
|||||||
(defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int]) ?? [:]
|
(defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int]) ?? [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func clear() {
|
||||||
|
defaults.removeObject(forKey: Self.defaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
private func mutate(word: String, delta: Int) {
|
private func mutate(word: String, delta: Int) {
|
||||||
let key = word.lowercased()
|
let key = word.lowercased()
|
||||||
guard !key.isEmpty else { return }
|
guard !key.isEmpty else { return }
|
||||||
|
|||||||
@@ -2,180 +2,359 @@
|
|||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Offline English word list + bigrams for the typing extension.
|
// Offline English word list + bigrams for the typing extension.
|
||||||
// Loaded once, kept compact for the keyboard RSS budget.
|
// The 40k-word table is a mmap'd binary (`english_lexicon.bin`); dirty heap
|
||||||
|
// stays near zero until a lookup materializes a handful of result strings.
|
||||||
|
// TSV files in the repo are the build input, not the runtime format.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
public struct EnglishScoredCorrection: Equatable, Sendable {
|
||||||
|
public var word: String
|
||||||
|
public var spatialCost: Int
|
||||||
|
public var frequency: Int
|
||||||
|
public var isTransposition: Bool
|
||||||
|
public var isShortening: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
word: String,
|
||||||
|
spatialCost: Int,
|
||||||
|
frequency: Int,
|
||||||
|
isTransposition: Bool,
|
||||||
|
isShortening: Bool
|
||||||
|
) {
|
||||||
|
self.word = word
|
||||||
|
self.spatialCost = spatialCost
|
||||||
|
self.frequency = frequency
|
||||||
|
self.isTransposition = isTransposition
|
||||||
|
self.isShortening = isShortening
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ranked English lexicon used by autocomplete / autocorrect / next-word.
|
/// Ranked English lexicon used by autocomplete / autocorrect / next-word.
|
||||||
public final class EnglishLexicon: @unchecked Sendable {
|
public final class EnglishLexicon: @unchecked Sendable {
|
||||||
public static let shared = EnglishLexicon()
|
public static let shared = EnglishLexicon()
|
||||||
|
|
||||||
/// Lowercased word → relative frequency (higher is more common).
|
private var mapped: Data?
|
||||||
private var frequencies: [String: Int] = [:]
|
private var header: FileHeader?
|
||||||
/// Sorted lowercased words for prefix binary search.
|
|
||||||
private var sortedWords: [String] = []
|
|
||||||
/// previous(lower) → next-word candidates (lower).
|
|
||||||
private var bigrams: [String: [String]] = [:]
|
|
||||||
private var loaded = false
|
private var loaded = false
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
|
/// True after a successful mmap. Tests use this to prove Chinese typing
|
||||||
|
/// does not pull the English table into the extension.
|
||||||
|
public var isLoaded: Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
public func prepare() {
|
public func prepare() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
guard !loaded else { return }
|
guard !loaded else { return }
|
||||||
loadLexicon()
|
loadMappedLexicon()
|
||||||
loadBigrams()
|
|
||||||
loaded = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release in-memory tables when leaving the typing surface (jetsam recovery).
|
/// Release the mapped file when leaving English / the typing surface.
|
||||||
public func unload() {
|
public func unload() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
frequencies.removeAll(keepingCapacity: false)
|
mapped = nil
|
||||||
sortedWords.removeAll(keepingCapacity: false)
|
header = nil
|
||||||
bigrams.removeAll(keepingCapacity: false)
|
|
||||||
loaded = false
|
loaded = false
|
||||||
}
|
}
|
||||||
|
|
||||||
public var wordCount: Int {
|
public var wordCount: Int {
|
||||||
prepareIfNeeded()
|
lock.lock()
|
||||||
return sortedWords.count
|
defer { lock.unlock() }
|
||||||
|
return header?.unigramCount ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
public func frequency(of word: String) -> Int {
|
public func frequency(of word: String) -> Int {
|
||||||
prepareIfNeeded()
|
withMap { buf, header in
|
||||||
return frequencies[word.lowercased()] ?? 0
|
guard let index = lookupIndex(asciiLowered(word), header: header, buf: buf) else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return frequency(at: index, header: header, buf: buf)
|
||||||
|
} ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
public func contains(_ word: String) -> Bool {
|
public func contains(_ word: String) -> Bool {
|
||||||
prepareIfNeeded()
|
withMap { buf, header in
|
||||||
return frequencies[word.lowercased()] != nil
|
lookupIndex(asciiLowered(word), header: header, buf: buf) != nil
|
||||||
|
} ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest-frequency unigrams, for next-word fallback when no bigram hits.
|
||||||
|
public func topWords(limit: Int = 6) -> [String] {
|
||||||
|
guard limit > 0 else { return [] }
|
||||||
|
return withMap { buf, header in
|
||||||
|
let count = min(limit, header.unigramCount)
|
||||||
|
var words: [String] = []
|
||||||
|
words.reserveCapacity(count)
|
||||||
|
for rank in 0..<count {
|
||||||
|
let index = Int(
|
||||||
|
readU16(buf, header.freqRankOffset + rank * 2)
|
||||||
|
)
|
||||||
|
guard index < header.unigramCount else { continue }
|
||||||
|
if let word = string(at: index, header: header, buf: buf) {
|
||||||
|
words.append(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words
|
||||||
|
} ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefix completions, highest frequency first.
|
/// Prefix completions, highest frequency first.
|
||||||
public func completions(prefix: String, limit: Int = 8) -> [String] {
|
public func completions(prefix: String, limit: Int = 8) -> [String] {
|
||||||
prepareIfNeeded()
|
let needle = asciiLowered(prefix)
|
||||||
let needle = prefix.lowercased()
|
|
||||||
guard !needle.isEmpty, limit > 0 else { return [] }
|
guard !needle.isEmpty, limit > 0 else { return [] }
|
||||||
|
return withMap { buf, header in
|
||||||
var results: [(String, Int)] = []
|
var scored: [(Int, Int)] = []
|
||||||
var index = lowerBound(needle)
|
var index = lowerBound(needle, header: header, buf: buf)
|
||||||
while index < sortedWords.count {
|
while index < header.unigramCount {
|
||||||
let word = sortedWords[index]
|
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { break }
|
||||||
guard word.hasPrefix(needle) else { break }
|
guard hasPrefix(bytes, needle) else { break }
|
||||||
if word != needle {
|
if !bytesEqual(bytes, needle) {
|
||||||
results.append((word, frequencies[word] ?? 0))
|
scored.append((index, frequency(at: index, header: header, buf: buf)))
|
||||||
|
}
|
||||||
|
index += 1
|
||||||
|
// Soft cap scan to keep keystroke path cheap.
|
||||||
|
if scored.count >= limit * 8 { break }
|
||||||
}
|
}
|
||||||
index += 1
|
scored.sort { lhs, rhs in
|
||||||
// Soft cap scan to keep keystroke path cheap.
|
if lhs.1 != rhs.1 { return lhs.1 > rhs.1 }
|
||||||
if results.count >= limit * 8 { break }
|
return lhs.0 < rhs.0
|
||||||
}
|
}
|
||||||
results.sort { lhs, rhs in
|
return scored.prefix(limit).compactMap { pair in
|
||||||
if lhs.1 != rhs.1 { return lhs.1 > rhs.1 }
|
string(at: pair.0, header: header, buf: buf)
|
||||||
return lhs.0 < rhs.0
|
}
|
||||||
}
|
} ?? []
|
||||||
return Array(results.prefix(limit).map(\.0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best edit-distance ≤ 2 correction, or nil when the typed word is fine.
|
/// Nearby words scored by QWERTY proximity + frequency. Does not decide
|
||||||
/// Uses Damerau–Levenshtein so adjacent swaps (teh → the) count as 1.
|
/// whether autocorrect should fire — the suggestion engine does.
|
||||||
/// Scans only same-initial-letter candidates (not the full frequency table).
|
public func scoredCorrections(for typed: String, limit: Int = 6) -> [EnglishScoredCorrection] {
|
||||||
public func bestCorrection(for typed: String) -> String? {
|
let needle = asciiLowered(typed)
|
||||||
prepareIfNeeded()
|
guard needle.count >= 3, let firstByte = needle.first, limit > 0 else { return [] }
|
||||||
let needle = typed.lowercased()
|
let first = Character(UnicodeScalar(firstByte))
|
||||||
guard needle.count >= 2, let first = needle.first else { return nil }
|
var initials = Set(EnglishQWERTYProximity.neighbors(of: first, includingSelf: true))
|
||||||
if frequencies[needle] != nil { return nil }
|
initials.insert(first)
|
||||||
|
|
||||||
var best: (word: String, distance: Int, freq: Int)?
|
return withMap { buf, header in
|
||||||
var index = lowerBound(String(first))
|
var best: [ScoredIndex] = []
|
||||||
while index < sortedWords.count {
|
best.reserveCapacity(limit)
|
||||||
let word = sortedWords[index]
|
for initial in initials {
|
||||||
guard word.first == first else { break }
|
guard let letter = initial.asciiLetterIndex else { continue }
|
||||||
defer { index += 1 }
|
let rangeOffset = header.initialOffset + letter * 4
|
||||||
guard abs(word.count - needle.count) <= 2 else { continue }
|
let start = Int(readU16(buf, rangeOffset))
|
||||||
let freq = frequencies[word] ?? 0
|
let count = Int(readU16(buf, rangeOffset + 2))
|
||||||
let distance = damerauLevenshtein(needle, word, max: 2)
|
guard start >= 0, count >= 0, start + count <= header.unigramCount else { continue }
|
||||||
guard distance > 0, distance <= 2 else { continue }
|
for index in start..<(start + count) {
|
||||||
if let current = best {
|
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { continue }
|
||||||
if distance < current.distance
|
let delta = abs(bytes.count - needle.count)
|
||||||
|| (distance == current.distance && freq > current.freq) {
|
guard delta <= 2, !bytesEqual(bytes, needle) else { continue }
|
||||||
best = (word, distance, freq)
|
guard let alignment = EnglishQWERTYProximity.align(
|
||||||
|
typedASCII: needle,
|
||||||
|
candidateASCII: bytes
|
||||||
|
) else { continue }
|
||||||
|
guard alignment.cost > 0 else { continue }
|
||||||
|
insertBest(
|
||||||
|
ScoredIndex(
|
||||||
|
index: index,
|
||||||
|
spatialCost: alignment.cost,
|
||||||
|
frequency: frequency(at: index, header: header, buf: buf),
|
||||||
|
isTransposition: alignment.isTransposition,
|
||||||
|
isShortening: alignment.isShortening
|
||||||
|
),
|
||||||
|
into: &best,
|
||||||
|
limit: limit
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
best = (word, distance, freq)
|
|
||||||
}
|
}
|
||||||
}
|
return best.compactMap { scored in
|
||||||
guard let best else { return nil }
|
guard let word = string(at: scored.index, header: header, buf: buf) else {
|
||||||
// Distance-2 corrections need a common word so rare near-misses don't win.
|
return nil
|
||||||
if best.distance == 2, best.freq < 200 { return nil }
|
}
|
||||||
return best.word
|
return EnglishScoredCorrection(
|
||||||
|
word: word,
|
||||||
|
spatialCost: scored.spatialCost,
|
||||||
|
frequency: scored.frequency,
|
||||||
|
isTransposition: scored.isTransposition,
|
||||||
|
isShortening: scored.isShortening
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best proximity correction, or nil when the typed word is already known.
|
||||||
|
public func bestCorrection(for typed: String) -> String? {
|
||||||
|
if contains(typed) { return nil }
|
||||||
|
return scoredCorrections(for: typed, limit: 1).first?.word
|
||||||
}
|
}
|
||||||
|
|
||||||
public func nextWords(after previous: String, limit: Int = 6) -> [String] {
|
public func nextWords(after previous: String, limit: Int = 6) -> [String] {
|
||||||
prepareIfNeeded()
|
guard limit > 0 else { return [] }
|
||||||
let key = previous.lowercased()
|
let needle = asciiLowered(previous)
|
||||||
guard let list = bigrams[key] else { return [] }
|
return withMap { buf, header in
|
||||||
return Array(list.prefix(limit))
|
guard let prevIndex = lookupIndex(needle, header: header, buf: buf) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
guard let group = lookupBigramGroup(prevIndex: prevIndex, header: header, buf: buf) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let count = min(limit, group.nextCount)
|
||||||
|
var words: [String] = []
|
||||||
|
words.reserveCapacity(count)
|
||||||
|
for offset in 0..<count {
|
||||||
|
let index = Int(readU16(buf, header.bigramNextOffset + (group.firstNext + offset) * 2))
|
||||||
|
if let word = string(at: index, header: header, buf: buf) {
|
||||||
|
words.append(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words
|
||||||
|
} ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Mapped file
|
||||||
|
|
||||||
private func prepareIfNeeded() {
|
private struct FileHeader {
|
||||||
if !loaded { prepare() }
|
var unigramCount: Int
|
||||||
|
var bigramGroupCount: Int
|
||||||
|
var stringPoolOffset: Int
|
||||||
|
var stringPoolSize: Int
|
||||||
|
var unigramOffset: Int
|
||||||
|
var freqRankOffset: Int
|
||||||
|
var initialOffset: Int
|
||||||
|
var bigramIndexOffset: Int
|
||||||
|
var bigramNextOffset: Int
|
||||||
|
var fileSize: Int
|
||||||
|
|
||||||
|
static let magic = "OSGENG01"
|
||||||
|
static let version = 1
|
||||||
|
static let headerSize = 64
|
||||||
|
static let initialCount = 26
|
||||||
|
|
||||||
|
static func parse(_ data: Data) -> FileHeader? {
|
||||||
|
guard data.count >= headerSize else { return nil }
|
||||||
|
return data.withUnsafeBytes { buf -> FileHeader? in
|
||||||
|
let magicBytes = UnsafeRawBufferPointer(rebasing: buf[0..<8])
|
||||||
|
let magic = String(bytes: magicBytes, encoding: .ascii)
|
||||||
|
guard magic == Self.magic else { return nil }
|
||||||
|
guard Int(readU32(buf, 8)) == version else { return nil }
|
||||||
|
let unigramCount = Int(readU32(buf, 12))
|
||||||
|
let bigramGroupCount = Int(readU32(buf, 16))
|
||||||
|
let stringPoolOffset = Int(readU32(buf, 20))
|
||||||
|
let stringPoolSize = Int(readU32(buf, 24))
|
||||||
|
let unigramOffset = Int(readU32(buf, 28))
|
||||||
|
let freqRankOffset = Int(readU32(buf, 32))
|
||||||
|
let initialOffset = Int(readU32(buf, 36))
|
||||||
|
let bigramIndexOffset = Int(readU32(buf, 40))
|
||||||
|
let bigramNextOffset = Int(readU32(buf, 44))
|
||||||
|
let fileSize = data.count
|
||||||
|
|
||||||
|
guard unigramCount >= 0, unigramCount <= 200_000 else { return nil }
|
||||||
|
guard bigramGroupCount >= 0, bigramGroupCount <= 100_000 else { return nil }
|
||||||
|
guard region(unigramOffset, unigramCount * 8, in: fileSize),
|
||||||
|
region(freqRankOffset, unigramCount * 2, in: fileSize),
|
||||||
|
region(initialOffset, initialCount * 4, in: fileSize),
|
||||||
|
region(bigramIndexOffset, bigramGroupCount * 8, in: fileSize),
|
||||||
|
region(stringPoolOffset, stringPoolSize, in: fileSize)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileHeader(
|
||||||
|
unigramCount: unigramCount,
|
||||||
|
bigramGroupCount: bigramGroupCount,
|
||||||
|
stringPoolOffset: stringPoolOffset,
|
||||||
|
stringPoolSize: stringPoolSize,
|
||||||
|
unigramOffset: unigramOffset,
|
||||||
|
freqRankOffset: freqRankOffset,
|
||||||
|
initialOffset: initialOffset,
|
||||||
|
bigramIndexOffset: bigramIndexOffset,
|
||||||
|
bigramNextOffset: bigramNextOffset,
|
||||||
|
fileSize: fileSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func region(_ offset: Int, _ size: Int, in fileSize: Int) -> Bool {
|
||||||
|
offset >= 0 && size >= 0 && offset <= fileSize && size <= fileSize - offset
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadLexicon() {
|
private struct ScoredIndex {
|
||||||
|
var index: Int
|
||||||
|
var spatialCost: Int
|
||||||
|
var frequency: Int
|
||||||
|
var isTransposition: Bool
|
||||||
|
var isShortening: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BigramGroup {
|
||||||
|
var nextCount: Int
|
||||||
|
var firstNext: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMappedLexicon() {
|
||||||
guard let url = Bundle(for: EnglishLexicon.self)
|
guard let url = Bundle(for: EnglishLexicon.self)
|
||||||
.url(forResource: "english_lexicon", withExtension: "tsv", subdirectory: nil)
|
.url(forResource: "english_lexicon", withExtension: "bin")
|
||||||
?? Bundle(for: EnglishLexicon.self)
|
?? Bundle.main.url(forResource: "english_lexicon", withExtension: "bin")
|
||||||
.url(forResource: "english_lexicon", withExtension: "tsv")
|
|
||||||
?? Bundle.main.url(forResource: "english_lexicon", withExtension: "tsv")
|
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = try? String(contentsOf: url, encoding: .utf8) else { return }
|
// `.mappedIfSafe` keeps the 40k table on file-backed pages. Jetsam
|
||||||
var map: [String: Int] = [:]
|
// charges dirty heap, not these clean mapped pages.
|
||||||
for line in data.split(whereSeparator: \.isNewline) {
|
guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]),
|
||||||
let parts = line.split(separator: "\t", maxSplits: 1)
|
let parsed = FileHeader.parse(data)
|
||||||
guard parts.count == 2,
|
|
||||||
let freq = Int(parts[1]) else { continue }
|
|
||||||
let word = String(parts[0]).lowercased()
|
|
||||||
guard !word.isEmpty else { continue }
|
|
||||||
map[word] = freq
|
|
||||||
}
|
|
||||||
frequencies = map
|
|
||||||
sortedWords = map.keys.sorted()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadBigrams() {
|
|
||||||
guard let url = Bundle(for: EnglishLexicon.self)
|
|
||||||
.url(forResource: "english_bigrams", withExtension: "tsv")
|
|
||||||
?? Bundle.main.url(forResource: "english_bigrams", withExtension: "tsv")
|
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = try? String(contentsOf: url, encoding: .utf8) else { return }
|
mapped = data
|
||||||
var map: [String: [String]] = [:]
|
header = parsed
|
||||||
for line in data.split(whereSeparator: \.isNewline) {
|
loaded = true
|
||||||
let parts = line.split(separator: "\t", maxSplits: 1)
|
|
||||||
guard parts.count == 2 else { continue }
|
|
||||||
let prev = String(parts[0]).lowercased()
|
|
||||||
let nexts = parts[1].split(whereSeparator: \.isWhitespace).map { String($0).lowercased() }
|
|
||||||
guard !prev.isEmpty, !nexts.isEmpty else { continue }
|
|
||||||
map[prev] = nexts
|
|
||||||
}
|
|
||||||
bigrams = map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lowerBound(_ prefix: String) -> Int {
|
private func withMap<T>(_ body: (UnsafeRawBufferPointer, FileHeader) -> T) -> T? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
guard loaded, let data = mapped, let header else { return nil }
|
||||||
|
return data.withUnsafeBytes { buf in
|
||||||
|
body(buf, header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lookupIndex(
|
||||||
|
_ needle: [UInt8],
|
||||||
|
header: FileHeader,
|
||||||
|
buf: UnsafeRawBufferPointer
|
||||||
|
) -> Int? {
|
||||||
|
let index = lowerBound(needle, header: header, buf: buf)
|
||||||
|
guard index < header.unigramCount,
|
||||||
|
let bytes = wordBytes(at: index, header: header, buf: buf),
|
||||||
|
bytesEqual(bytes, needle)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lowerBound(
|
||||||
|
_ needle: [UInt8],
|
||||||
|
header: FileHeader,
|
||||||
|
buf: UnsafeRawBufferPointer
|
||||||
|
) -> Int {
|
||||||
var low = 0
|
var low = 0
|
||||||
var high = sortedWords.count
|
var high = header.unigramCount
|
||||||
while low < high {
|
while low < high {
|
||||||
let mid = (low + high) / 2
|
let mid = (low + high) / 2
|
||||||
if sortedWords[mid] < prefix {
|
guard let bytes = wordBytes(at: mid, header: header, buf: buf) else {
|
||||||
|
high = mid
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if compare(bytes, needle) < 0 {
|
||||||
low = mid + 1
|
low = mid + 1
|
||||||
} else {
|
} else {
|
||||||
high = mid
|
high = mid
|
||||||
@@ -184,40 +363,140 @@ public final class EnglishLexicon: @unchecked Sendable {
|
|||||||
return low
|
return low
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Damerau–Levenshtein with early exit when distance would exceed `max`.
|
private func lookupBigramGroup(
|
||||||
private func damerauLevenshtein(_ a: String, _ b: String, max: Int) -> Int {
|
prevIndex: Int,
|
||||||
let aChars = Array(a)
|
header: FileHeader,
|
||||||
let bChars = Array(b)
|
buf: UnsafeRawBufferPointer
|
||||||
let aCount = aChars.count
|
) -> BigramGroup? {
|
||||||
let bCount = bChars.count
|
var low = 0
|
||||||
if abs(aCount - bCount) > max { return max + 1 }
|
var high = header.bigramGroupCount
|
||||||
|
while low < high {
|
||||||
var prevPrev = [Int](repeating: 0, count: bCount + 1)
|
let mid = (low + high) / 2
|
||||||
var prev = Array(0...bCount)
|
let midPrev = Int(readU16(buf, header.bigramIndexOffset + mid * 8))
|
||||||
for i in 1...aCount {
|
if midPrev < prevIndex {
|
||||||
var current = [Int](repeating: 0, count: bCount + 1)
|
low = mid + 1
|
||||||
current[0] = i
|
} else {
|
||||||
var rowMin = current[0]
|
high = mid
|
||||||
for j in 1...bCount {
|
|
||||||
let cost = aChars[i - 1] == bChars[j - 1] ? 0 : 1
|
|
||||||
var value = min(
|
|
||||||
prev[j] + 1,
|
|
||||||
current[j - 1] + 1,
|
|
||||||
prev[j - 1] + cost
|
|
||||||
)
|
|
||||||
// Adjacent transposition
|
|
||||||
if i > 1, j > 1,
|
|
||||||
aChars[i - 1] == bChars[j - 2],
|
|
||||||
aChars[i - 2] == bChars[j - 1] {
|
|
||||||
value = min(value, prevPrev[j - 2] + 1)
|
|
||||||
}
|
|
||||||
current[j] = value
|
|
||||||
rowMin = min(rowMin, value)
|
|
||||||
}
|
}
|
||||||
if rowMin > max { return max + 1 }
|
|
||||||
prevPrev = prev
|
|
||||||
prev = current
|
|
||||||
}
|
}
|
||||||
return prev[bCount]
|
guard low < header.bigramGroupCount else { return nil }
|
||||||
|
let offset = header.bigramIndexOffset + low * 8
|
||||||
|
guard Int(readU16(buf, offset)) == prevIndex else { return nil }
|
||||||
|
return BigramGroup(
|
||||||
|
nextCount: Int(readU16(buf, offset + 2)),
|
||||||
|
firstNext: Int(readU32(buf, offset + 4))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func frequency(at index: Int, header: FileHeader, buf: UnsafeRawBufferPointer) -> Int {
|
||||||
|
Int(readU16(buf, header.unigramOffset + index * 8 + 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wordBytes(
|
||||||
|
at index: Int,
|
||||||
|
header: FileHeader,
|
||||||
|
buf: UnsafeRawBufferPointer
|
||||||
|
) -> UnsafeBufferPointer<UInt8>? {
|
||||||
|
guard index >= 0, index < header.unigramCount else { return nil }
|
||||||
|
let record = header.unigramOffset + index * 8
|
||||||
|
let poolOff = Int(readU32(buf, record))
|
||||||
|
let length = Int(buf[record + 4])
|
||||||
|
let start = header.stringPoolOffset + poolOff
|
||||||
|
guard length >= 0,
|
||||||
|
start >= header.stringPoolOffset,
|
||||||
|
start + length <= header.stringPoolOffset + header.stringPoolSize,
|
||||||
|
start + length <= header.fileSize,
|
||||||
|
let base = buf.baseAddress
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UnsafeBufferPointer(
|
||||||
|
start: base.advanced(by: start).assumingMemoryBound(to: UInt8.self),
|
||||||
|
count: length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func string(
|
||||||
|
at index: Int,
|
||||||
|
header: FileHeader,
|
||||||
|
buf: UnsafeRawBufferPointer
|
||||||
|
) -> String? {
|
||||||
|
guard let bytes = wordBytes(at: index, header: header, buf: buf) else { return nil }
|
||||||
|
return String(bytes: bytes, encoding: .ascii)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insertBest(_ scored: ScoredIndex, into best: inout [ScoredIndex], limit: Int) {
|
||||||
|
if let existing = best.firstIndex(where: { $0.index == scored.index }) {
|
||||||
|
if isOrderedBefore(scored, best[existing]) {
|
||||||
|
best[existing] = scored
|
||||||
|
best.sort(by: isOrderedBefore)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if best.count < limit {
|
||||||
|
best.append(scored)
|
||||||
|
best.sort(by: isOrderedBefore)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let last = best.last, isOrderedBefore(scored, last) {
|
||||||
|
best[best.count - 1] = scored
|
||||||
|
best.sort(by: isOrderedBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isOrderedBefore(_ lhs: ScoredIndex, _ rhs: ScoredIndex) -> Bool {
|
||||||
|
if lhs.spatialCost != rhs.spatialCost { return lhs.spatialCost < rhs.spatialCost }
|
||||||
|
if lhs.frequency != rhs.frequency { return lhs.frequency > rhs.frequency }
|
||||||
|
return lhs.index < rhs.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readU16(_ buf: UnsafeRawBufferPointer, _ offset: Int) -> UInt16 {
|
||||||
|
UInt16(littleEndian: buf.loadUnaligned(fromByteOffset: offset, as: UInt16.self))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readU32(_ buf: UnsafeRawBufferPointer, _ offset: Int) -> UInt32 {
|
||||||
|
UInt32(littleEndian: buf.loadUnaligned(fromByteOffset: offset, as: UInt32.self))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func asciiLowered(_ string: String) -> [UInt8] {
|
||||||
|
string.utf8.map { byte in
|
||||||
|
(byte >= 65 && byte <= 90) ? byte + 32 : byte
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func compare(_ word: UnsafeBufferPointer<UInt8>, _ needle: [UInt8]) -> Int {
|
||||||
|
let count = min(word.count, needle.count)
|
||||||
|
for index in 0..<count {
|
||||||
|
let left = word[index]
|
||||||
|
let right = needle[index]
|
||||||
|
if left < right { return -1 }
|
||||||
|
if left > right { return 1 }
|
||||||
|
}
|
||||||
|
if word.count < needle.count { return -1 }
|
||||||
|
if word.count > needle.count { return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasPrefix(_ word: UnsafeBufferPointer<UInt8>, _ prefix: [UInt8]) -> Bool {
|
||||||
|
guard word.count >= prefix.count else { return false }
|
||||||
|
for index in prefix.indices where word[index] != prefix[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bytesEqual(_ word: UnsafeBufferPointer<UInt8>, _ needle: [UInt8]) -> Bool {
|
||||||
|
guard word.count == needle.count else { return false }
|
||||||
|
for index in needle.indices where word[index] != needle[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Character {
|
||||||
|
var asciiLetterIndex: Int? {
|
||||||
|
guard let value = utf8.first, value >= 97, value <= 122 else { return nil }
|
||||||
|
return Int(value - 97)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// EnglishQWERTYProximity.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Spatial cost for English autocorrect. Adjacent (including diagonal) keys
|
||||||
|
// are cheap; far substitutions are expensive. Inspired by AOSP LatinIME's
|
||||||
|
// proximity weighting — formula only, no Android code.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct EnglishAlignment: Equatable, Sendable {
|
||||||
|
/// Weighted edit cost. `0` means identical.
|
||||||
|
public var cost: Int
|
||||||
|
public var isTransposition: Bool
|
||||||
|
public var isShortening: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EnglishQWERTYProximity: Sendable {
|
||||||
|
/// Two adjacent substitutions, or one farther miss, still eligible.
|
||||||
|
public static let maxAutocorrectCost = 34
|
||||||
|
public static let adjacentCost = 10
|
||||||
|
public static let nearCost = 22
|
||||||
|
public static let farCost = 34
|
||||||
|
public static let insDelCost = 18
|
||||||
|
public static let transpositionCost = 10
|
||||||
|
|
||||||
|
/// US QWERTY, staggered rows matching the on-screen letter grid.
|
||||||
|
private static let coordinates: [Character: (x: Double, y: Double)] = {
|
||||||
|
let rows: [[Character]] = [
|
||||||
|
Array("qwertyuiop"),
|
||||||
|
Array("asdfghjkl"),
|
||||||
|
Array("zxcvbnm")
|
||||||
|
]
|
||||||
|
let offsets: [Double] = [0, 0.5, 1.5]
|
||||||
|
var map: [Character: (x: Double, y: Double)] = [:]
|
||||||
|
for (rowIndex, row) in rows.enumerated() {
|
||||||
|
let origin = offsets[rowIndex]
|
||||||
|
for (column, letter) in row.enumerated() {
|
||||||
|
map[letter] = (origin + Double(column), Double(rowIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}()
|
||||||
|
|
||||||
|
public static func neighbors(of letter: Character, includingSelf: Bool) -> [Character] {
|
||||||
|
let needle = Character(letter.lowercased())
|
||||||
|
guard let origin = coordinates[needle] else {
|
||||||
|
return includingSelf ? [needle] : []
|
||||||
|
}
|
||||||
|
var hits: [Character] = []
|
||||||
|
for (candidate, point) in coordinates {
|
||||||
|
let distance = chebyshev(origin, point)
|
||||||
|
if distance == 0 {
|
||||||
|
if includingSelf { hits.append(candidate) }
|
||||||
|
} else if distance <= 1.01 {
|
||||||
|
hits.append(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func keyDistance(_ a: Character, _ b: Character) -> Int {
|
||||||
|
let left = Character(a.lowercased())
|
||||||
|
let right = Character(b.lowercased())
|
||||||
|
if left == right { return 0 }
|
||||||
|
guard let origin = coordinates[left], let other = coordinates[right] else {
|
||||||
|
return farCost
|
||||||
|
}
|
||||||
|
let distance = chebyshev(origin, other)
|
||||||
|
if distance <= 1.01 { return adjacentCost }
|
||||||
|
if distance <= 2.01 { return nearCost }
|
||||||
|
return farCost
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func align(typed: String, candidate: String) -> EnglishAlignment? {
|
||||||
|
let source = asciiLowered(typed)
|
||||||
|
let targetBytes = asciiLowered(candidate)
|
||||||
|
return targetBytes.withUnsafeBufferPointer { pointer in
|
||||||
|
align(typedASCII: source, candidateASCII: pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same cost model as `align(typed:candidate:)`, but the candidate stays in
|
||||||
|
/// a mapped file — no Swift `String` per scanned word.
|
||||||
|
public static func align(
|
||||||
|
typedASCII: [UInt8],
|
||||||
|
candidateASCII: UnsafeBufferPointer<UInt8>
|
||||||
|
) -> EnglishAlignment? {
|
||||||
|
let source = typedASCII
|
||||||
|
let target = candidateASCII
|
||||||
|
let delta = abs(source.count - target.count)
|
||||||
|
guard delta <= 2 else { return nil }
|
||||||
|
if delta == 0, bytesEqual(source, target) {
|
||||||
|
return EnglishAlignment(cost: 0, isTransposition: false, isShortening: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if source.count == target.count, isAdjacentTransposition(source, target) {
|
||||||
|
return EnglishAlignment(
|
||||||
|
cost: transpositionCost,
|
||||||
|
isTransposition: true,
|
||||||
|
isShortening: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if source.count == target.count {
|
||||||
|
var cost = 0
|
||||||
|
for index in source.indices {
|
||||||
|
cost += keyDistance(source[index], target[index])
|
||||||
|
if cost > maxAutocorrectCost { return nil }
|
||||||
|
}
|
||||||
|
return EnglishAlignment(
|
||||||
|
cost: cost,
|
||||||
|
isTransposition: false,
|
||||||
|
isShortening: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let cost = bandedEditCost(source, target)
|
||||||
|
guard cost <= maxAutocorrectCost else { return nil }
|
||||||
|
return EnglishAlignment(
|
||||||
|
cost: cost,
|
||||||
|
isTransposition: false,
|
||||||
|
isShortening: target.count < source.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func keyDistance(_ a: UInt8, _ b: UInt8) -> Int {
|
||||||
|
if a == b { return 0 }
|
||||||
|
guard a >= 97, a <= 122, b >= 97, b <= 122 else { return farCost }
|
||||||
|
return keyDistance(Character(UnicodeScalar(a)), Character(UnicodeScalar(b)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isAdjacentTransposition(
|
||||||
|
_ source: [UInt8],
|
||||||
|
_ target: UnsafeBufferPointer<UInt8>
|
||||||
|
) -> Bool {
|
||||||
|
guard source.count == target.count, source.count >= 2 else { return false }
|
||||||
|
var mismatch = -1
|
||||||
|
for index in source.indices where source[index] != target[index] {
|
||||||
|
if mismatch == -1 {
|
||||||
|
mismatch = index
|
||||||
|
} else if index == mismatch + 1,
|
||||||
|
source[mismatch] == target[index],
|
||||||
|
source[index] == target[mismatch] {
|
||||||
|
for rest in (index + 1)..<source.count where source[rest] != target[rest] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Banded Levenshtein with proximity substitutions and a Damerau swap.
|
||||||
|
private static func bandedEditCost(
|
||||||
|
_ source: [UInt8],
|
||||||
|
_ target: UnsafeBufferPointer<UInt8>
|
||||||
|
) -> Int {
|
||||||
|
let aCount = source.count
|
||||||
|
let bCount = target.count
|
||||||
|
var previous = Array(0...bCount).map { $0 * insDelCost }
|
||||||
|
var older = previous
|
||||||
|
for i in 1...aCount {
|
||||||
|
var current = [Int](repeating: 0, count: bCount + 1)
|
||||||
|
current[0] = i * insDelCost
|
||||||
|
var rowMin = current[0]
|
||||||
|
for j in 1...bCount {
|
||||||
|
let substitution = previous[j - 1] + keyDistance(source[i - 1], target[j - 1])
|
||||||
|
var value = min(
|
||||||
|
previous[j] + insDelCost,
|
||||||
|
current[j - 1] + insDelCost,
|
||||||
|
substitution
|
||||||
|
)
|
||||||
|
if i > 1, j > 1,
|
||||||
|
source[i - 1] == target[j - 2],
|
||||||
|
source[i - 2] == target[j - 1] {
|
||||||
|
value = min(value, older[j - 2] + transpositionCost)
|
||||||
|
}
|
||||||
|
current[j] = value
|
||||||
|
rowMin = min(rowMin, value)
|
||||||
|
}
|
||||||
|
if rowMin > maxAutocorrectCost { return maxAutocorrectCost + 1 }
|
||||||
|
older = previous
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
return previous[bCount]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func asciiLowered(_ string: String) -> [UInt8] {
|
||||||
|
string.utf8.map { byte in
|
||||||
|
(byte >= 65 && byte <= 90) ? byte + 32 : byte
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func bytesEqual(_ source: [UInt8], _ target: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||||
|
guard source.count == target.count else { return false }
|
||||||
|
for index in source.indices where source[index] != target[index] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func chebyshev(
|
||||||
|
_ a: (x: Double, y: Double),
|
||||||
|
_ b: (x: Double, y: Double)
|
||||||
|
) -> Double {
|
||||||
|
max(abs(a.x - b.x), abs(a.y - b.y))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// EnglishSuggestionEngine.swift
|
// EnglishSuggestionEngine.swift
|
||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Builds TypingComposition for English: completions while composing,
|
// Builds a 3-slot English QuickType board: verbatim / correction / completion
|
||||||
// high-confidence corrections on commit, next-word predictions after.
|
// (or next-word after commit). Space applies only the correction slot.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -12,19 +12,29 @@ public struct EnglishSuggestionContext: Sendable {
|
|||||||
public var personalTerms: [String]
|
public var personalTerms: [String]
|
||||||
public var learnedBoosts: [String: Int]
|
public var learnedBoosts: [String: Int]
|
||||||
public var includeOriginalAfterCorrection: String?
|
public var includeOriginalAfterCorrection: String?
|
||||||
|
/// Contacts / text replacements from `UILexicon`.
|
||||||
|
public var systemWords: [String]
|
||||||
|
public var systemCompletions: [String]
|
||||||
|
public var systemGuesses: [String]
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
currentWord: String = "",
|
currentWord: String = "",
|
||||||
previousWord: String = "",
|
previousWord: String = "",
|
||||||
personalTerms: [String] = [],
|
personalTerms: [String] = [],
|
||||||
learnedBoosts: [String: Int] = [:],
|
learnedBoosts: [String: Int] = [:],
|
||||||
includeOriginalAfterCorrection: String? = nil
|
includeOriginalAfterCorrection: String? = nil,
|
||||||
|
systemWords: [String] = [],
|
||||||
|
systemCompletions: [String] = [],
|
||||||
|
systemGuesses: [String] = []
|
||||||
) {
|
) {
|
||||||
self.currentWord = currentWord
|
self.currentWord = currentWord
|
||||||
self.previousWord = previousWord
|
self.previousWord = previousWord
|
||||||
self.personalTerms = personalTerms
|
self.personalTerms = personalTerms
|
||||||
self.learnedBoosts = learnedBoosts
|
self.learnedBoosts = learnedBoosts
|
||||||
self.includeOriginalAfterCorrection = includeOriginalAfterCorrection
|
self.includeOriginalAfterCorrection = includeOriginalAfterCorrection
|
||||||
|
self.systemWords = systemWords
|
||||||
|
self.systemCompletions = systemCompletions
|
||||||
|
self.systemGuesses = systemGuesses
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +57,10 @@ public struct EnglishCorrectionDecision: Equatable, Sendable {
|
|||||||
|
|
||||||
/// Pure ranking / candidate builder — no UITextDocumentProxy access.
|
/// Pure ranking / candidate builder — no UITextDocumentProxy access.
|
||||||
public struct EnglishSuggestionEngine: Sendable {
|
public struct EnglishSuggestionEngine: Sendable {
|
||||||
|
public static let slotCount = 3
|
||||||
|
/// In-vocabulary words only yield to a much more common transposition / neighbor.
|
||||||
|
public static let inVocabularyFrequencyGap = 250
|
||||||
|
|
||||||
private let lexicon: EnglishLexicon
|
private let lexicon: EnglishLexicon
|
||||||
|
|
||||||
public init(lexicon: EnglishLexicon = .shared) {
|
public init(lexicon: EnglishLexicon = .shared) {
|
||||||
@@ -57,112 +71,238 @@ public struct EnglishSuggestionEngine: Sendable {
|
|||||||
lexicon.prepare()
|
lexicon.prepare()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Suggestions while the user is mid-word.
|
/// Suggestions only while the user is actively typing an English word.
|
||||||
public func compositionWhileTyping(_ context: EnglishSuggestionContext) -> TypingComposition {
|
public func compositionWhileTyping(_ context: EnglishSuggestionContext) -> TypingComposition {
|
||||||
let prefix = context.currentWord
|
let prefix = context.currentWord
|
||||||
guard !prefix.isEmpty else {
|
guard !prefix.isEmpty else { return .empty }
|
||||||
return nextWordComposition(context)
|
return makeBoard(context).composition
|
||||||
}
|
|
||||||
|
|
||||||
var ranked: [(text: String, score: Int, id: String)] = []
|
|
||||||
var seen = Set<String>()
|
|
||||||
|
|
||||||
func append(_ raw: String, baseScore: Int, tag: String, preserveCase: Bool = false) {
|
|
||||||
let display = preserveCase ? raw : matchCase(of: prefix, to: raw)
|
|
||||||
let key = display.lowercased()
|
|
||||||
guard seen.insert(key).inserted else { return }
|
|
||||||
let boost = context.learnedBoosts[key] ?? 0
|
|
||||||
let personalBoost = context.personalTerms.contains { $0.lowercased() == key } ? 5_000 : 0
|
|
||||||
ranked.append((display, baseScore + boost + personalBoost, "\(tag)|\(key)"))
|
|
||||||
}
|
|
||||||
|
|
||||||
for term in context.personalTerms where term.lowercased().hasPrefix(prefix.lowercased())
|
|
||||||
&& term.lowercased() != prefix.lowercased() {
|
|
||||||
append(term, baseScore: 8_000 + term.count, tag: "personal", preserveCase: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
for word in lexicon.completions(prefix: prefix, limit: 12) {
|
|
||||||
append(word, baseScore: lexicon.frequency(of: word), tag: "complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
ranked.sort { lhs, rhs in
|
|
||||||
if lhs.score != rhs.score { return lhs.score > rhs.score }
|
|
||||||
return lhs.text.count < rhs.text.count
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidates = ranked.prefix(8).map {
|
|
||||||
TypingCandidate(id: $0.id, text: $0.text, engineIndex: 0)
|
|
||||||
}
|
|
||||||
return TypingComposition(preedit: prefix, candidates: Array(candidates))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether to autocorrect on space / punctuation.
|
/// Decide whether to autocorrect on space / punctuation.
|
||||||
public func correctionDecision(
|
public func correctionDecision(
|
||||||
for typed: String,
|
for typed: String,
|
||||||
personalTerms: [String],
|
personalTerms: [String],
|
||||||
learnedBoosts: [String: Int]
|
learnedBoosts: [String: Int],
|
||||||
|
previousWord: String = "",
|
||||||
|
systemWords: [String] = [],
|
||||||
|
systemGuesses: [String] = []
|
||||||
) -> EnglishCorrectionDecision? {
|
) -> EnglishCorrectionDecision? {
|
||||||
let trimmed = typed
|
let context = EnglishSuggestionContext(
|
||||||
guard trimmed.count >= 2 else { return nil }
|
currentWord: typed,
|
||||||
let lower = trimmed.lowercased()
|
previousWord: previousWord,
|
||||||
|
personalTerms: personalTerms,
|
||||||
if personalTerms.contains(where: { $0.lowercased() == lower }) { return nil }
|
learnedBoosts: learnedBoosts,
|
||||||
if (learnedBoosts[lower] ?? 0) >= 5 { return nil }
|
systemWords: systemWords,
|
||||||
if shouldSkipAutocorrect(trimmed) { return nil }
|
systemGuesses: systemGuesses
|
||||||
if lexicon.contains(lower) { return nil }
|
|
||||||
|
|
||||||
guard let correction = lexicon.bestCorrection(for: lower) else { return nil }
|
|
||||||
// Personal dictionary wins over lexicon corrections.
|
|
||||||
if personalTerms.contains(where: { $0.lowercased() == correction }) {
|
|
||||||
return EnglishCorrectionDecision(original: trimmed, replacement: matchCase(of: trimmed, to: correction))
|
|
||||||
}
|
|
||||||
let typedBoost = learnedBoosts[lower] ?? 0
|
|
||||||
let correctionFreq = lexicon.frequency(of: correction) + (learnedBoosts[correction] ?? 0)
|
|
||||||
// High-confidence gate: correction must clearly beat defending the typo.
|
|
||||||
guard correctionFreq >= 80, correctionFreq > typedBoost + 40 else { return nil }
|
|
||||||
return EnglishCorrectionDecision(
|
|
||||||
original: trimmed,
|
|
||||||
replacement: matchCase(of: trimmed, to: correction)
|
|
||||||
)
|
)
|
||||||
|
return makeBoard(context).decision
|
||||||
}
|
}
|
||||||
|
|
||||||
public func nextWordComposition(_ context: EnglishSuggestionContext) -> TypingComposition {
|
public func nextWordComposition(_ context: EnglishSuggestionContext) -> TypingComposition {
|
||||||
var ranked: [(text: String, score: Int, id: String)] = []
|
var ranked: [(text: String, score: Int, role: TypingCandidateRole, quoted: Bool)] = []
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
|
|
||||||
func append(_ raw: String, baseScore: Int, tag: String) {
|
func append(_ raw: String, baseScore: Int, role: TypingCandidateRole, quoted: Bool = false) {
|
||||||
let key = raw.lowercased()
|
let key = raw.lowercased()
|
||||||
guard seen.insert(key).inserted else { return }
|
guard seen.insert(key).inserted else { return }
|
||||||
let boost = context.learnedBoosts[key] ?? 0
|
let boost = context.learnedBoosts[key] ?? 0
|
||||||
let personalBoost = context.personalTerms.contains { $0.lowercased() == key } ? 2_000 : 0
|
let personalBoost = isPersonal(key, in: context) ? 2_000 : 0
|
||||||
ranked.append((raw, baseScore + boost + personalBoost, "\(tag)|\(key)"))
|
ranked.append((raw, baseScore + boost + personalBoost, role, quoted))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let original = context.includeOriginalAfterCorrection {
|
if let original = context.includeOriginalAfterCorrection {
|
||||||
append(original, baseScore: 20_000, tag: "original")
|
append(original, baseScore: 20_000, role: .verbatim, quoted: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !context.previousWord.isEmpty {
|
if !context.previousWord.isEmpty {
|
||||||
for (index, word) in lexicon.nextWords(after: context.previousWord, limit: 8).enumerated() {
|
for (index, word) in lexicon.nextWords(after: context.previousWord, limit: 8).enumerated() {
|
||||||
append(word, baseScore: 1_000 - index * 10, tag: "next")
|
append(word, baseScore: 1_200 - index * 10, role: .nextWord)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for term in context.personalTerms.prefix(4) {
|
for term in context.personalTerms.prefix(4) {
|
||||||
append(term, baseScore: 500, tag: "personal")
|
append(term, baseScore: 500, role: .nextWord)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ranked.filter({ $0.role == .nextWord }).isEmpty {
|
||||||
|
for (index, word) in lexicon.topWords(limit: 6).enumerated() {
|
||||||
|
append(word, baseScore: 200 - index, role: .nextWord)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ranked.sort { $0.score > $1.score }
|
ranked.sort { $0.score > $1.score }
|
||||||
let candidates = ranked.prefix(8).map {
|
let candidates = ranked.prefix(Self.slotCount).map {
|
||||||
TypingCandidate(id: $0.id, text: $0.text, engineIndex: 0)
|
TypingCandidate(
|
||||||
|
id: "\($0.role.rawValue)|\($0.text.lowercased())",
|
||||||
|
text: $0.text,
|
||||||
|
role: $0.role,
|
||||||
|
isQuoted: $0.quoted
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return TypingComposition(preedit: "", candidates: Array(candidates))
|
return TypingComposition(preedit: "", candidates: Array(candidates))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
public func isKnownWord(_ word: String, personalTerms: [String], systemWords: [String]) -> Bool {
|
||||||
|
let lower = word.lowercased()
|
||||||
|
if lexicon.contains(lower) { return true }
|
||||||
|
if personalTerms.contains(where: { $0.lowercased() == lower }) { return true }
|
||||||
|
if systemWords.contains(where: { $0.lowercased() == lower }) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private func shouldSkipAutocorrect(_ typed: String) -> Bool {
|
// MARK: - Board
|
||||||
if typed.count <= 1 { return true }
|
|
||||||
|
private struct Board {
|
||||||
|
var composition: TypingComposition
|
||||||
|
var decision: EnglishCorrectionDecision?
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeBoard(_ context: EnglishSuggestionContext) -> Board {
|
||||||
|
let typed = context.currentWord
|
||||||
|
let decision = makeCorrectionDecision(context)
|
||||||
|
var slots: [TypingCandidate] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
func add(_ text: String, role: TypingCandidateRole, quoted: Bool = false) {
|
||||||
|
let key = text.lowercased()
|
||||||
|
guard seen.insert(key).inserted else { return }
|
||||||
|
slots.append(
|
||||||
|
TypingCandidate(
|
||||||
|
id: "\(role.rawValue)|\(key)",
|
||||||
|
text: text,
|
||||||
|
role: role,
|
||||||
|
isQuoted: quoted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let known = isKnownWord(
|
||||||
|
typed,
|
||||||
|
personalTerms: context.personalTerms,
|
||||||
|
systemWords: context.systemWords
|
||||||
|
)
|
||||||
|
add(typed, role: .verbatim, quoted: !known)
|
||||||
|
|
||||||
|
if let decision {
|
||||||
|
add(decision.replacement, role: .correction)
|
||||||
|
}
|
||||||
|
|
||||||
|
for term in context.personalTerms where term.lowercased().hasPrefix(typed.lowercased())
|
||||||
|
&& term.lowercased() != typed.lowercased() {
|
||||||
|
add(term, role: .completion)
|
||||||
|
if slots.count >= Self.slotCount { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
for word in context.systemCompletions {
|
||||||
|
let display = matchCase(of: typed, to: word)
|
||||||
|
add(display, role: .completion)
|
||||||
|
if slots.count >= Self.slotCount { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
for word in lexicon.completions(prefix: typed, limit: 8) {
|
||||||
|
add(matchCase(of: typed, to: word), role: .completion)
|
||||||
|
if slots.count >= Self.slotCount { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
let composition = TypingComposition(
|
||||||
|
preedit: typed,
|
||||||
|
candidates: Array(slots.prefix(Self.slotCount))
|
||||||
|
)
|
||||||
|
return Board(composition: composition, decision: decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCorrectionDecision(_ context: EnglishSuggestionContext) -> EnglishCorrectionDecision? {
|
||||||
|
let typed = context.currentWord
|
||||||
|
guard typed.count >= 3 else { return nil }
|
||||||
|
let lower = typed.lowercased()
|
||||||
|
|
||||||
|
if isProtectedToken(typed) { return nil }
|
||||||
|
if isPersonal(lower, in: context) { return nil }
|
||||||
|
if context.systemWords.contains(where: { $0.lowercased() == lower }) { return nil }
|
||||||
|
if (context.learnedBoosts[lower] ?? 0) >= 5 { return nil }
|
||||||
|
|
||||||
|
let inLexicon = lexicon.contains(lower)
|
||||||
|
let typedFreq = lexicon.frequency(of: lower) + (context.learnedBoosts[lower] ?? 0)
|
||||||
|
|
||||||
|
var pool = lexicon.scoredCorrections(for: lower, limit: 8)
|
||||||
|
for guess in context.systemGuesses {
|
||||||
|
let word = guess.lowercased()
|
||||||
|
guard word != lower else { continue }
|
||||||
|
if pool.contains(where: { $0.word == word }) { continue }
|
||||||
|
guard let alignment = EnglishQWERTYProximity.align(typed: lower, candidate: word) else { continue }
|
||||||
|
pool.append(
|
||||||
|
EnglishScoredCorrection(
|
||||||
|
word: word,
|
||||||
|
spatialCost: alignment.cost,
|
||||||
|
frequency: max(lexicon.frequency(of: word), 1),
|
||||||
|
isTransposition: alignment.isTransposition,
|
||||||
|
isShortening: alignment.isShortening
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var best: (EnglishScoredCorrection, Int)?
|
||||||
|
for candidate in pool {
|
||||||
|
guard allowsAutocorrect(
|
||||||
|
typed: typed,
|
||||||
|
replacement: candidate.word,
|
||||||
|
inLexicon: inLexicon,
|
||||||
|
typedFreq: typedFreq,
|
||||||
|
candidate: candidate
|
||||||
|
) else { continue }
|
||||||
|
var score = candidate.frequency * 2 - candidate.spatialCost
|
||||||
|
if isPersonal(candidate.word, in: context) { score += 5_000 }
|
||||||
|
score += context.learnedBoosts[candidate.word] ?? 0
|
||||||
|
if lexicon.nextWords(after: context.previousWord).contains(candidate.word) {
|
||||||
|
score += 80
|
||||||
|
}
|
||||||
|
if let current = best {
|
||||||
|
if score > current.1 { best = (candidate, score) }
|
||||||
|
} else {
|
||||||
|
best = (candidate, score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let best else { return nil }
|
||||||
|
let keepScore = inLexicon ? typedFreq * 2 : 0
|
||||||
|
guard best.1 > keepScore + 40 else { return nil }
|
||||||
|
return EnglishCorrectionDecision(
|
||||||
|
original: typed,
|
||||||
|
replacement: matchCase(of: typed, to: best.0.word)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func allowsAutocorrect(
|
||||||
|
typed: String,
|
||||||
|
replacement: String,
|
||||||
|
inLexicon: Bool,
|
||||||
|
typedFreq: Int,
|
||||||
|
candidate: EnglishScoredCorrection
|
||||||
|
) -> Bool {
|
||||||
|
if isTitleCase(typed) {
|
||||||
|
// Teh → The is a same-length transposition. Rocky → Rock is not.
|
||||||
|
guard candidate.isTransposition, !candidate.isShortening else { return false }
|
||||||
|
}
|
||||||
|
if inLexicon {
|
||||||
|
let gap = candidate.frequency - typedFreq
|
||||||
|
// Web-corpus dumps leak typos (`teh`, `adn`) at the floor of the
|
||||||
|
// list. Real words like `form` sit much higher and must not yield
|
||||||
|
// to `from`.
|
||||||
|
let looksLikeLeakedTypo = typedFreq <= 680
|
||||||
|
if candidate.isTransposition {
|
||||||
|
return looksLikeLeakedTypo && gap >= 40
|
||||||
|
}
|
||||||
|
if typed.count == replacement.count,
|
||||||
|
candidate.spatialCost <= EnglishQWERTYProximity.adjacentCost {
|
||||||
|
return looksLikeLeakedTypo && gap >= Self.inVocabularyFrequencyGap
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return candidate.frequency > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isProtectedToken(_ typed: String) -> Bool {
|
||||||
|
if typed.count <= 2 { return true }
|
||||||
if typed.allSatisfy(\.isUppercase) { return true }
|
if typed.allSatisfy(\.isUppercase) { return true }
|
||||||
if typed.contains(where: \.isNumber) { return true }
|
if typed.contains(where: \.isNumber) { return true }
|
||||||
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
|
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
|
||||||
@@ -170,6 +310,16 @@ public struct EnglishSuggestionEngine: Sendable {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isTitleCase(_ typed: String) -> Bool {
|
||||||
|
guard let first = typed.first, first.isUppercase else { return false }
|
||||||
|
let rest = typed.dropFirst()
|
||||||
|
return !rest.isEmpty && rest.allSatisfy(\.isLowercase)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isPersonal(_ key: String, in context: EnglishSuggestionContext) -> Bool {
|
||||||
|
context.personalTerms.contains { $0.lowercased() == key }
|
||||||
|
}
|
||||||
|
|
||||||
private func matchCase(of sample: String, to word: String) -> String {
|
private func matchCase(of sample: String, to word: String) -> String {
|
||||||
if sample.allSatisfy(\.isUppercase) {
|
if sample.allSatisfy(\.isUppercase) {
|
||||||
return word.uppercased()
|
return word.uppercased()
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// EnglishSystemLexicon.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Apple's sanctioned English sources for a custom keyboard: UITextChecker
|
||||||
|
// completions / guesses, plus UILexicon names from
|
||||||
|
// `requestSupplementaryLexicon`. The engine stays pure; the keyboard
|
||||||
|
// extension fills these fields on each refresh.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public protocol EnglishSystemLexiconProviding: AnyObject {
|
||||||
|
func completions(prefix: String, limit: Int) -> [String]
|
||||||
|
func guesses(for word: String, limit: Int) -> [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class EmptyEnglishSystemLexicon: EnglishSystemLexiconProviding {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func completions(prefix: String, limit: Int) -> [String] {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func guesses(for word: String, limit: Int) -> [String] {
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
/// System spellchecker. Always called from `TypingSessionController` (@MainActor).
|
||||||
|
@MainActor
|
||||||
|
public final class UIKitEnglishSystemLexicon: EnglishSystemLexiconProviding {
|
||||||
|
public var language: String
|
||||||
|
|
||||||
|
public init(language: String = "en_US") {
|
||||||
|
self.language = language
|
||||||
|
}
|
||||||
|
|
||||||
|
public func completions(prefix: String, limit: Int) -> [String] {
|
||||||
|
guard !prefix.isEmpty, limit > 0 else { return [] }
|
||||||
|
let checker = UITextChecker()
|
||||||
|
let range = NSRange(location: 0, length: (prefix as NSString).length)
|
||||||
|
let hits = checker.completions(forPartialWordRange: range, in: prefix, language: language) ?? []
|
||||||
|
return Array(hits.prefix(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func guesses(for word: String, limit: Int) -> [String] {
|
||||||
|
guard word.count >= 3, limit > 0 else { return [] }
|
||||||
|
let checker = UITextChecker()
|
||||||
|
let range = NSRange(location: 0, length: (word as NSString).length)
|
||||||
|
let hits = checker.guesses(forWordRange: range, in: word, language: language) ?? []
|
||||||
|
return Array(hits.prefix(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func learnWord(_ word: String) {
|
||||||
|
let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
if !UITextChecker.hasLearnedWord(trimmed) {
|
||||||
|
UITextChecker.learnWord(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -58,7 +58,8 @@ public final class LibrimeEngine: RimeEngineBridging {
|
|||||||
|
|
||||||
public func teardown() {
|
public func teardown() {
|
||||||
bridge?.clearComposition()
|
bridge?.clearComposition()
|
||||||
bridge?.stopSession()
|
// Session destroy alone does not flush LevelDB user dictionaries.
|
||||||
|
bridge?.finalizeRuntime()
|
||||||
bridge = nil
|
bridge = nil
|
||||||
composition = .empty
|
composition = .empty
|
||||||
isReady = false
|
isReady = false
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ public enum TypingInputLanguage: String, CaseIterable, Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Role of an English QuickType slot. Chinese candidates stay `.completion`.
|
||||||
|
public enum TypingCandidateRole: String, Equatable, Sendable {
|
||||||
|
/// The word currently being typed. Space does not replace it.
|
||||||
|
case verbatim
|
||||||
|
/// The unique slot Space will apply when autocorrect is armed.
|
||||||
|
case correction
|
||||||
|
/// Prefix completion; tap to accept, Space ignores it.
|
||||||
|
case completion
|
||||||
|
/// Next-word prediction after a committed word; tap to insert.
|
||||||
|
case nextWord
|
||||||
|
}
|
||||||
|
|
||||||
/// One candidate row item after composing.
|
/// One candidate row item after composing.
|
||||||
public struct TypingCandidate: Identifiable, Equatable, Sendable {
|
public struct TypingCandidate: Identifiable, Equatable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
@@ -28,17 +40,24 @@ public struct TypingCandidate: Identifiable, Equatable, Sendable {
|
|||||||
public let annotation: String?
|
public let annotation: String?
|
||||||
/// Absolute engine index for Chinese selection (may differ from display order).
|
/// Absolute engine index for Chinese selection (may differ from display order).
|
||||||
public let engineIndex: Int
|
public let engineIndex: Int
|
||||||
|
public let role: TypingCandidateRole
|
||||||
|
/// Unknown verbatim shown in quotes, matching the system / KeyboardKit contract.
|
||||||
|
public let isQuoted: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
text: String,
|
text: String,
|
||||||
annotation: String? = nil,
|
annotation: String? = nil,
|
||||||
engineIndex: Int = 0
|
engineIndex: Int = 0,
|
||||||
|
role: TypingCandidateRole = .completion,
|
||||||
|
isQuoted: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.text = text
|
self.text = text
|
||||||
self.annotation = annotation
|
self.annotation = annotation
|
||||||
self.engineIndex = engineIndex
|
self.engineIndex = engineIndex
|
||||||
|
self.role = role
|
||||||
|
self.isQuoted = isQuoted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ public struct RimeResourcePaths: Sendable {
|
|||||||
public actor RimeResourceInstaller {
|
public actor RimeResourceInstaller {
|
||||||
public static let shared = RimeResourceInstaller()
|
public static let shared = RimeResourceInstaller()
|
||||||
/// Bump when SharedSupport layout / schema / import_tables contract changes.
|
/// 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() {}
|
public init() {}
|
||||||
|
|
||||||
@@ -231,6 +231,28 @@ public actor RimeResourceInstaller {
|
|||||||
// deployments and is intentionally not needed here.
|
// deployments and is intentionally not needed here.
|
||||||
bridge.finalizeRuntime()
|
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 {
|
extension RimeResourceInstaller {
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ public enum RimeSchemaGenerator {
|
|||||||
let alphabet = inputSchema == .fullPinyin
|
let alphabet = inputSchema == .fullPinyin
|
||||||
? "zyxwvutsrqponmlkjihgfedcba"
|
? "zyxwvutsrqponmlkjihgfedcba"
|
||||||
: "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")
|
let algebraYAML = algebra.map { " - '\($0)'" }.joined(separator: "\n")
|
||||||
|
|
||||||
return """
|
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
|
/// Rules run against full-pinyin dictionary codes before double-pinyin
|
||||||
/// transforms, so fuzzy pairs work consistently in all three schemas.
|
/// transforms, so fuzzy pairs work consistently in all three schemas.
|
||||||
public static func fuzzyRules(_ enabled: Set<PinyinFuzzyPair>) -> [String] {
|
public static func fuzzyRules(_ enabled: Set<PinyinFuzzyPair>) -> [String] {
|
||||||
@@ -144,7 +156,9 @@ public enum RimeSchemaGenerator {
|
|||||||
case .fullPinyin:
|
case .fullPinyin:
|
||||||
return [
|
return [
|
||||||
"derive/^([jqxy])u$/$1v/",
|
"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:
|
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,17 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
@Published public private(set) var lastErrorNeedsHostDeployment: Bool = false
|
@Published public private(set) var lastErrorNeedsHostDeployment: Bool = false
|
||||||
|
|
||||||
/// When true, English suggestions / autocorrect stay off (secure fields).
|
/// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// `UITextChecker` completions / guesses. Empty in unit tests.
|
||||||
|
public var systemLexicon: EnglishSystemLexiconProviding = EmptyEnglishSystemLexicon()
|
||||||
|
/// Names and text replacements from `requestSupplementaryLexicon`.
|
||||||
|
public var supplementaryWords: [String] = []
|
||||||
|
|
||||||
/// Chevron appears only for Chinese composition with at least two candidates.
|
/// Chevron appears only for Chinese composition with at least two candidates.
|
||||||
public var canExpandCandidatePanel: Bool {
|
public var canExpandCandidatePanel: Bool {
|
||||||
@@ -128,9 +138,15 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
)
|
)
|
||||||
TypingInputConfiguration.shared.reload()
|
TypingInputConfiguration.shared.reload()
|
||||||
refreshPersonalTerms()
|
refreshPersonalTerms()
|
||||||
// English lexicon is small; load when entering typing (not at KVC init).
|
// mmap the English table only while English is active. Chinese typing
|
||||||
englishEngine.prepare()
|
// already has Rime; loading both on appear is what jetsams the extension.
|
||||||
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
|
if language == .english {
|
||||||
|
englishEngine.prepare()
|
||||||
|
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
|
||||||
|
} else {
|
||||||
|
EnglishLexicon.shared.unload()
|
||||||
|
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
|
||||||
|
}
|
||||||
syncAutocapitalization()
|
syncAutocapitalization()
|
||||||
if FlowSessionBridge.isHostHeavy() {
|
if FlowSessionBridge.isHostHeavy() {
|
||||||
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
|
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
|
||||||
@@ -216,6 +232,7 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
synchronizeEnglishDocumentContext(caretMoved: true)
|
synchronizeEnglishDocumentContext(caretMoved: true)
|
||||||
} else {
|
} else {
|
||||||
clearEnglishWordState(keepPrevious: false)
|
clearEnglishWordState(keepPrevious: false)
|
||||||
|
EnglishLexicon.shared.unload()
|
||||||
composition = engine.composition
|
composition = engine.composition
|
||||||
}
|
}
|
||||||
return output
|
return output
|
||||||
@@ -306,6 +323,12 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
return handleEnglishCharacter(ch)
|
return handleEnglishCharacter(ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !suggestionsEnabled {
|
||||||
|
clearOneShotShiftIfNeeded()
|
||||||
|
abandonChineseComposition()
|
||||||
|
return .insert(String(ch))
|
||||||
|
}
|
||||||
|
|
||||||
// Chinese + Shift: insert Latin directly (iOS-style mix-in), leave Rime
|
// Chinese + Shift: insert Latin directly (iOS-style mix-in), leave Rime
|
||||||
// composition untouched. Rime's alphabet is lowercase-only, so uppercase
|
// composition untouched. Rime's alphabet is lowercase-only, so uppercase
|
||||||
// keycodes would otherwise be rejected with no output.
|
// keycodes would otherwise be rejected with no output.
|
||||||
@@ -327,6 +350,10 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
return handleEnglishSpace()
|
return handleEnglishSpace()
|
||||||
}
|
}
|
||||||
clearPeriodShortcut()
|
clearPeriodShortcut()
|
||||||
|
if !suggestionsEnabled {
|
||||||
|
abandonChineseComposition()
|
||||||
|
return .insert(" ")
|
||||||
|
}
|
||||||
let text = engine.processSpace() ?? " "
|
let text = engine.processSpace() ?? " "
|
||||||
composition = engine.composition
|
composition = engine.composition
|
||||||
syncCandidatePanelVisibility()
|
syncCandidatePanelVisibility()
|
||||||
@@ -338,6 +365,10 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
if language == .english {
|
if language == .english {
|
||||||
return commitEnglishWord(suffix: "\n")
|
return commitEnglishWord(suffix: "\n")
|
||||||
}
|
}
|
||||||
|
if !suggestionsEnabled {
|
||||||
|
abandonChineseComposition()
|
||||||
|
return .insert("\n")
|
||||||
|
}
|
||||||
let text = engine.processReturn() ?? "\n"
|
let text = engine.processReturn() ?? "\n"
|
||||||
composition = engine.composition
|
composition = engine.composition
|
||||||
syncCandidatePanelVisibility()
|
syncCandidatePanelVisibility()
|
||||||
@@ -349,6 +380,9 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
if language == .english {
|
if language == .english {
|
||||||
return selectEnglishCandidate(at: index)
|
return selectEnglishCandidate(at: index)
|
||||||
}
|
}
|
||||||
|
if !suggestionsEnabled {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
guard composition.candidates.indices.contains(index) else { return .none }
|
guard composition.candidates.indices.contains(index) else { return .none }
|
||||||
// Display order may put phrases before first-syllable chars; select by engine index.
|
// Display order may put phrases before first-syllable chars; select by engine index.
|
||||||
let engineIndex = composition.candidates[index].engineIndex
|
let engineIndex = composition.candidates[index].engineIndex
|
||||||
@@ -360,6 +394,13 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
return text.isEmpty ? .none : .insert(text)
|
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
|
// MARK: - English
|
||||||
|
|
||||||
private func handleEnglishSpace() -> TypingOutput {
|
private func handleEnglishSpace() -> TypingOutput {
|
||||||
@@ -427,6 +468,9 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
pendingAutocorrection = nil
|
pendingAutocorrection = nil
|
||||||
englishCurrentWord = pending.original
|
englishCurrentWord = pending.original
|
||||||
learningStore.recordDefense(of: pending.original)
|
learningStore.recordDefense(of: pending.original)
|
||||||
|
#if canImport(UIKit)
|
||||||
|
UIKitEnglishSystemLexicon.learnWord(pending.original)
|
||||||
|
#endif
|
||||||
refreshEnglishSuggestions()
|
refreshEnglishSuggestions()
|
||||||
return .replace(deleteCount: deleteCount, with: pending.original)
|
return .replace(deleteCount: deleteCount, with: pending.original)
|
||||||
}
|
}
|
||||||
@@ -461,15 +505,18 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
var decision = englishEngine.correctionDecision(
|
var decision = englishEngine.correctionDecision(
|
||||||
for: word,
|
for: word,
|
||||||
personalTerms: personalTermsCache,
|
personalTerms: personalTermsCache,
|
||||||
learnedBoosts: learningStore.snapshot()
|
learnedBoosts: learningStore.snapshot(),
|
||||||
|
previousWord: englishPreviousWord,
|
||||||
|
systemWords: supplementaryWords,
|
||||||
|
systemGuesses: systemLexicon.guesses(for: word, limit: 6)
|
||||||
) {
|
) {
|
||||||
decision.appliedSuffix = suffix
|
decision.appliedSuffix = suffix
|
||||||
pendingAutocorrection = decision
|
pendingAutocorrection = decision
|
||||||
englishPreviousWord = decision.replacement
|
englishPreviousWord = decision.replacement
|
||||||
englishCurrentWord = ""
|
englishCurrentWord = ""
|
||||||
learningStore.recordAcceptance(of: decision.replacement)
|
// Machine-applied correction does not count as the user accepting
|
||||||
// Suggestions stay hidden until the user starts the next word.
|
// the replacement — otherwise names train the wrong word.
|
||||||
composition = .empty
|
refreshEnglishSuggestions(afterCommittedWord: decision.replacement)
|
||||||
return .replace(
|
return .replace(
|
||||||
deleteCount: word.count,
|
deleteCount: word.count,
|
||||||
with: decision.replacement + suffix
|
with: decision.replacement + suffix
|
||||||
@@ -479,7 +526,17 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
englishPreviousWord = word
|
englishPreviousWord = word
|
||||||
englishCurrentWord = ""
|
englishCurrentWord = ""
|
||||||
pendingAutocorrection = nil
|
pendingAutocorrection = nil
|
||||||
learningStore.recordAcceptance(of: word, amount: 1)
|
// Learn OOV / names the user actually committed; skip common words.
|
||||||
|
if !englishEngine.isKnownWord(
|
||||||
|
word,
|
||||||
|
personalTerms: personalTermsCache,
|
||||||
|
systemWords: supplementaryWords
|
||||||
|
) {
|
||||||
|
learningStore.recordDefense(of: word, amount: 2)
|
||||||
|
#if canImport(UIKit)
|
||||||
|
UIKitEnglishSystemLexicon.learnWord(word)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
refreshEnglishSuggestions(afterCommittedWord: word)
|
refreshEnglishSuggestions(afterCommittedWord: word)
|
||||||
return suffix.isEmpty ? .none : .insert(suffix)
|
return suffix.isEmpty ? .none : .insert(suffix)
|
||||||
}
|
}
|
||||||
@@ -487,7 +544,8 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
private func selectEnglishCandidate(at index: Int) -> TypingOutput {
|
private func selectEnglishCandidate(at index: Int) -> TypingOutput {
|
||||||
guard composition.candidates.indices.contains(index) else { return .none }
|
guard composition.candidates.indices.contains(index) else { return .none }
|
||||||
guard englishCandidateAnchorMatchesDocument() else { return .none }
|
guard englishCandidateAnchorMatchesDocument() else { return .none }
|
||||||
let chosen = composition.candidates[index].text
|
let candidate = composition.candidates[index]
|
||||||
|
let chosen = candidate.text
|
||||||
|
|
||||||
// Restoring original after autocorrect (no current word).
|
// Restoring original after autocorrect (no current word).
|
||||||
if englishCurrentWord.isEmpty,
|
if englishCurrentWord.isEmpty,
|
||||||
@@ -498,16 +556,27 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
englishPreviousWord = pending.original
|
englishPreviousWord = pending.original
|
||||||
englishCurrentWord = ""
|
englishCurrentWord = ""
|
||||||
learningStore.recordDefense(of: pending.original)
|
learningStore.recordDefense(of: pending.original)
|
||||||
|
#if canImport(UIKit)
|
||||||
|
UIKitEnglishSystemLexicon.learnWord(pending.original)
|
||||||
|
#endif
|
||||||
refreshEnglishSuggestions(afterCommittedWord: pending.original)
|
refreshEnglishSuggestions(afterCommittedWord: pending.original)
|
||||||
return .replace(deleteCount: deleteCount, with: pending.original + " ")
|
return .replace(deleteCount: deleteCount, with: pending.original + " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if candidate.role == .verbatim {
|
||||||
|
learningStore.recordDefense(of: chosen)
|
||||||
|
#if canImport(UIKit)
|
||||||
|
UIKitEnglishSystemLexicon.learnWord(chosen)
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
learningStore.recordAcceptance(of: chosen)
|
||||||
|
}
|
||||||
|
|
||||||
if !englishCurrentWord.isEmpty {
|
if !englishCurrentWord.isEmpty {
|
||||||
let deleteCount = englishCurrentWord.count
|
let deleteCount = englishCurrentWord.count
|
||||||
englishPreviousWord = chosen
|
englishPreviousWord = chosen
|
||||||
englishCurrentWord = ""
|
englishCurrentWord = ""
|
||||||
pendingAutocorrection = nil
|
pendingAutocorrection = nil
|
||||||
learningStore.recordAcceptance(of: chosen)
|
|
||||||
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
||||||
return .replace(deleteCount: deleteCount, with: chosen + " ")
|
return .replace(deleteCount: deleteCount, with: chosen + " ")
|
||||||
}
|
}
|
||||||
@@ -516,7 +585,6 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
englishPreviousWord = chosen
|
englishPreviousWord = chosen
|
||||||
englishCurrentWord = ""
|
englishCurrentWord = ""
|
||||||
pendingAutocorrection = nil
|
pendingAutocorrection = nil
|
||||||
learningStore.recordAcceptance(of: chosen)
|
|
||||||
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
refreshEnglishSuggestions(afterCommittedWord: chosen)
|
||||||
return .insert(chosen + " ")
|
return .insert(chosen + " ")
|
||||||
}
|
}
|
||||||
@@ -537,19 +605,23 @@ public final class TypingSessionController: ObservableObject {
|
|||||||
composition = .empty
|
composition = .empty
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Idle / between words: no candidate bar. Completions start after
|
// With no active English word, keep the candidate bar empty. This also
|
||||||
// the first letter of the current word.
|
// prevents next-word predictions from appearing between committed words.
|
||||||
guard !englishCurrentWord.isEmpty else {
|
guard !englishCurrentWord.isEmpty else {
|
||||||
composition = .empty
|
composition = .empty
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let previous = word ?? englishPreviousWord
|
let previous = word ?? englishPreviousWord
|
||||||
|
let typed = englishCurrentWord
|
||||||
let context = EnglishSuggestionContext(
|
let context = EnglishSuggestionContext(
|
||||||
currentWord: englishCurrentWord,
|
currentWord: typed,
|
||||||
previousWord: previous,
|
previousWord: previous,
|
||||||
personalTerms: personalTermsCache,
|
personalTerms: personalTermsCache,
|
||||||
learnedBoosts: learningStore.snapshot(),
|
learnedBoosts: learningStore.snapshot(),
|
||||||
includeOriginalAfterCorrection: nil
|
includeOriginalAfterCorrection: pendingAutocorrection?.original,
|
||||||
|
systemWords: supplementaryWords,
|
||||||
|
systemCompletions: typed.isEmpty ? [] : systemLexicon.completions(prefix: typed, limit: 6),
|
||||||
|
systemGuesses: typed.count >= 3 ? systemLexicon.guesses(for: typed, limit: 6) : []
|
||||||
)
|
)
|
||||||
composition = englishEngine.compositionWhileTyping(context)
|
composition = englishEngine.compositionWhileTyping(context)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// AIUserSkillStoreTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AIUserSkillStoreTests: XCTestCase {
|
||||||
|
private func makeDefaults() -> UserDefaults {
|
||||||
|
let suite = "group.com.osgkeyboard.shared.tests.userSkills.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChangingShortcutLinkDropsConfirmation() throws {
|
||||||
|
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||||
|
let firstURL = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")!
|
||||||
|
let secondURL = URL(string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf")!
|
||||||
|
var skill = AIUserSkill(
|
||||||
|
name: "Custom",
|
||||||
|
prompt: "Do it",
|
||||||
|
shortcutICloudURL: firstURL,
|
||||||
|
shortcutName: "One"
|
||||||
|
)
|
||||||
|
try store.saveUserSkill(skill)
|
||||||
|
XCTAssertEqual(store.confirmShortcutAndEnable(skill.id), .enabled)
|
||||||
|
XCTAssertTrue(store.layout.isEnabled(skill.id))
|
||||||
|
|
||||||
|
skill.shortcutICloudURL = secondURL
|
||||||
|
skill.shortcutName = "Two"
|
||||||
|
try store.saveUserSkill(skill)
|
||||||
|
XCTAssertFalse(store.layout.hasConfirmedShortcut(skill.id))
|
||||||
|
XCTAssertFalse(store.layout.isEnabled(skill.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemovingShortcutLinkKeepsEnabledTextSkill() throws {
|
||||||
|
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||||
|
var skill = AIUserSkill(
|
||||||
|
name: "Custom",
|
||||||
|
prompt: "Do it",
|
||||||
|
shortcutICloudURL: URL(
|
||||||
|
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
|
||||||
|
),
|
||||||
|
shortcutName: "Run Me"
|
||||||
|
)
|
||||||
|
try store.saveUserSkill(skill)
|
||||||
|
XCTAssertEqual(store.confirmShortcutAndEnable(skill.id), .enabled)
|
||||||
|
|
||||||
|
skill.shortcutICloudURL = nil
|
||||||
|
try store.saveUserSkill(skill)
|
||||||
|
|
||||||
|
XCTAssertTrue(store.layout.isEnabled(skill.id))
|
||||||
|
XCTAssertFalse(store.layout.hasConfirmedShortcut(skill.id))
|
||||||
|
XCTAssertEqual(store.userSkill(id: skill.id)?.asClipboardSkill().kind, .transform)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAddingShortcutLinkDisablesTextSkillUntilConfirmed() throws {
|
||||||
|
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||||
|
var skill = AIUserSkill(
|
||||||
|
name: "Custom",
|
||||||
|
prompt: "Do it"
|
||||||
|
)
|
||||||
|
try store.saveUserSkill(skill)
|
||||||
|
XCTAssertEqual(store.enable(skill.id), .enabled)
|
||||||
|
|
||||||
|
skill.shortcutICloudURL = URL(
|
||||||
|
string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44"
|
||||||
|
)
|
||||||
|
skill.shortcutName = "Run Me"
|
||||||
|
try store.saveUserSkill(skill)
|
||||||
|
|
||||||
|
XCTAssertFalse(store.layout.isEnabled(skill.id))
|
||||||
|
XCTAssertFalse(store.layout.hasConfirmedShortcut(skill.id))
|
||||||
|
XCTAssertEqual(store.enable(skill.id), .needsShortcut)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,86 @@ final class AIUserSkillTests: XCTestCase {
|
|||||||
XCTAssertEqual(catalog.entries.first?.shortcutName, "My Tasks")
|
XCTAssertEqual(catalog.entries.first?.shortcutName, "My Tasks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTextOnlySkillAllowsEmptyShortcutConfiguration() throws {
|
||||||
|
var catalog = AIUserSkillCatalog()
|
||||||
|
let skill = AIUserSkill(
|
||||||
|
name: "Rewrite",
|
||||||
|
prompt: "Rewrite the clipboard"
|
||||||
|
)
|
||||||
|
|
||||||
|
try catalog.upsert(skill)
|
||||||
|
|
||||||
|
let saved = try XCTUnwrap(catalog.entries.first)
|
||||||
|
let clipboardSkill = saved.asClipboardSkill()
|
||||||
|
XCTAssertNil(saved.shortcutICloudURL)
|
||||||
|
XCTAssertEqual(saved.shortcutName, "")
|
||||||
|
XCTAssertEqual(clipboardSkill.kind, .transform)
|
||||||
|
XCTAssertFalse(clipboardSkill.requiresShortcut)
|
||||||
|
XCTAssertNil(clipboardSkill.shortcutName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShortcutNameWithoutLinkDoesNotExport() throws {
|
||||||
|
var catalog = AIUserSkillCatalog()
|
||||||
|
let skill = AIUserSkill(
|
||||||
|
name: "Rewrite",
|
||||||
|
prompt: "Rewrite the clipboard",
|
||||||
|
shortcutName: "Ignored without a link"
|
||||||
|
)
|
||||||
|
|
||||||
|
try catalog.upsert(skill)
|
||||||
|
|
||||||
|
let saved = try XCTUnwrap(catalog.entries.first)
|
||||||
|
XCTAssertEqual(saved.shortcutName, "Ignored without a link")
|
||||||
|
XCTAssertEqual(saved.asClipboardSkill().kind, .transform)
|
||||||
|
XCTAssertNil(saved.asClipboardSkill().shortcutName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShortcutSkillRequiresNameAndValidShareLink() {
|
||||||
|
var catalog = AIUserSkillCatalog()
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try catalog.upsert(
|
||||||
|
AIUserSkill(
|
||||||
|
name: "Export",
|
||||||
|
prompt: "Export it",
|
||||||
|
shortcutICloudURL: sampleURL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(error as? AIUserSkillValidationError, .emptyShortcutName)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try catalog.upsert(
|
||||||
|
AIUserSkill(
|
||||||
|
name: "Export",
|
||||||
|
prompt: "Export it",
|
||||||
|
shortcutICloudURL: URL(string: "https://example.com/not-a-shortcut"),
|
||||||
|
shortcutName: "Run Me"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) { error in
|
||||||
|
XCTAssertEqual(error as? AIUserSkillValidationError, .invalidShortcutLink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExistingShortcutSkillEncodingDecodesWithOptionalURL() throws {
|
||||||
|
let original = AIUserSkill(
|
||||||
|
name: "Export",
|
||||||
|
prompt: "Export it",
|
||||||
|
shortcutICloudURL: sampleURL,
|
||||||
|
shortcutName: "Run Me"
|
||||||
|
)
|
||||||
|
|
||||||
|
let decoded = try JSONDecoder().decode(
|
||||||
|
AIUserSkill.self,
|
||||||
|
from: JSONEncoder().encode(original)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(decoded.shortcutICloudURL, sampleURL)
|
||||||
|
XCTAssertEqual(decoded.asClipboardSkill().kind, .export)
|
||||||
|
XCTAssertTrue(decoded.asClipboardSkill().requiresShortcut)
|
||||||
|
}
|
||||||
|
|
||||||
func testThinkingDefaultsOffAndBuiltinCannotEnable() {
|
func testThinkingDefaultsOffAndBuiltinCannotEnable() {
|
||||||
let user = AIUserSkill(
|
let user = AIUserSkill(
|
||||||
name: "Custom",
|
name: "Custom",
|
||||||
@@ -156,34 +236,3 @@ final class AIUserSkillTests: XCTestCase {
|
|||||||
XCTAssertEqual(catalog.entries.count, 12)
|
XCTAssertEqual(catalog.entries.count, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class AIUserSkillStoreTests: XCTestCase {
|
|
||||||
private func makeDefaults() -> UserDefaults {
|
|
||||||
let suite = "group.com.osgkeyboard.shared.tests.userSkills.\(UUID().uuidString)"
|
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
|
||||||
defaults.removePersistentDomain(forName: suite)
|
|
||||||
return defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
func testChangingShortcutLinkDropsConfirmation() throws {
|
|
||||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
|
||||||
let firstURL = URL(string: "https://www.icloud.com/shortcuts/65bf33ba4206484ba78d582eaf1e9c44")!
|
|
||||||
let secondURL = URL(string: "https://www.icloud.com/shortcuts/1f4afcf7ee22400cbf84e319d969aadf")!
|
|
||||||
var skill = AIUserSkill(
|
|
||||||
name: "Custom",
|
|
||||||
prompt: "Do it",
|
|
||||||
shortcutICloudURL: firstURL,
|
|
||||||
shortcutName: "One"
|
|
||||||
)
|
|
||||||
try store.saveUserSkill(skill)
|
|
||||||
XCTAssertEqual(store.confirmShortcutAndEnable(skill.id), .enabled)
|
|
||||||
XCTAssertTrue(store.layout.isEnabled(skill.id))
|
|
||||||
|
|
||||||
skill.shortcutICloudURL = secondURL
|
|
||||||
skill.shortcutName = "Two"
|
|
||||||
try store.saveUserSkill(skill)
|
|
||||||
XCTAssertFalse(store.layout.hasConfirmedShortcut(skill.id))
|
|
||||||
XCTAssertFalse(store.layout.isEnabled(skill.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// EnglishTypingOnDeviceTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
//
|
||||||
|
// Hosted in the main app so these can run on a physical iPhone.
|
||||||
|
// ExtTests stay tool-hosted (simulator-only).
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
final class EnglishTypingOnDeviceTests: XCTestCase {
|
||||||
|
func testLexiconLoadsFortyThousandWords() {
|
||||||
|
let lexicon = EnglishLexicon()
|
||||||
|
lexicon.prepare()
|
||||||
|
XCTAssertTrue(lexicon.isLoaded)
|
||||||
|
XCTAssertGreaterThanOrEqual(lexicon.wordCount, 30_000)
|
||||||
|
XCTAssertTrue(lexicon.contains("the"))
|
||||||
|
XCTAssertTrue(lexicon.contains("hello"))
|
||||||
|
XCTAssertTrue(lexicon.contains("definitely"))
|
||||||
|
lexicon.unload()
|
||||||
|
XCTAssertFalse(lexicon.isLoaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTehAutocorrectsToThe() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertEqual(
|
||||||
|
engine.correctionDecision(for: "teh", personalTerms: [], learnedBoosts: [:])?.replacement,
|
||||||
|
"the"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTitleCaseNamesStay() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertNil(engine.correctionDecision(for: "Rocky", personalTerms: [], learnedBoosts: [:]))
|
||||||
|
XCTAssertNil(engine.correctionDecision(for: "Wang", personalTerms: [], learnedBoosts: [:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProximityGppdBecomesGood() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertEqual(
|
||||||
|
engine.correctionDecision(for: "gppd", personalTerms: [], learnedBoosts: [:])?.replacement,
|
||||||
|
"good"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormIsNotCorrectedToFrom() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
XCTAssertNil(engine.correctionDecision(for: "form", personalTerms: [], learnedBoosts: [:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testThankYouBigram() {
|
||||||
|
let lexicon = EnglishLexicon()
|
||||||
|
lexicon.prepare()
|
||||||
|
XCTAssertTrue(lexicon.nextWords(after: "thank").contains("you"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testUITextCheckerCompletionsAvailable() {
|
||||||
|
let system = UIKitEnglishSystemLexicon()
|
||||||
|
let hits = system.completions(prefix: "hel", limit: 6)
|
||||||
|
XCTAssertFalse(hits.isEmpty, "device UITextChecker should complete hel")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testQuickTypeBoardMarksCorrection() {
|
||||||
|
let engine = EnglishSuggestionEngine()
|
||||||
|
engine.prepare()
|
||||||
|
let composition = engine.compositionWhileTyping(
|
||||||
|
EnglishSuggestionContext(currentWord: "teh")
|
||||||
|
)
|
||||||
|
XCTAssertEqual(composition.candidates.first?.role, .verbatim)
|
||||||
|
XCTAssertTrue(composition.candidates.contains { $0.role == .correction && $0.text == "the" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ final class LLMClientTests: XCTestCase {
|
|||||||
try? Keychain.deleteAPIKey(for: "qwen")
|
try? Keychain.deleteAPIKey(for: "qwen")
|
||||||
try? Keychain.deleteAPIKey(for: "openai")
|
try? Keychain.deleteAPIKey(for: "openai")
|
||||||
try? Keychain.deleteAPIKey(for: "deepseek")
|
try? Keychain.deleteAPIKey(for: "deepseek")
|
||||||
|
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: true)
|
||||||
|
try? Keychain.deleteAPIKey(for: "openai", useICloudSync: true)
|
||||||
|
try? Keychain.deleteAPIKey(for: "deepseek", useICloudSync: true)
|
||||||
StubURLProtocolStorage.config = nil
|
StubURLProtocolStorage.config = nil
|
||||||
StubURLProtocolStorage.delaySeconds = 0
|
StubURLProtocolStorage.delaySeconds = 0
|
||||||
StubURLProtocolStorage.lastRequest = nil
|
StubURLProtocolStorage.lastRequest = nil
|
||||||
@@ -31,6 +34,9 @@ final class LLMClientTests: XCTestCase {
|
|||||||
try? Keychain.deleteAPIKey(for: "qwen")
|
try? Keychain.deleteAPIKey(for: "qwen")
|
||||||
try? Keychain.deleteAPIKey(for: "openai")
|
try? Keychain.deleteAPIKey(for: "openai")
|
||||||
try? Keychain.deleteAPIKey(for: "deepseek")
|
try? Keychain.deleteAPIKey(for: "deepseek")
|
||||||
|
try? Keychain.deleteAPIKey(for: "qwen", useICloudSync: true)
|
||||||
|
try? Keychain.deleteAPIKey(for: "openai", useICloudSync: true)
|
||||||
|
try? Keychain.deleteAPIKey(for: "deepseek", useICloudSync: true)
|
||||||
Keychain.resetTestMemoryStore()
|
Keychain.resetTestMemoryStore()
|
||||||
StubURLProtocolStorage.config = nil
|
StubURLProtocolStorage.config = nil
|
||||||
StubURLProtocolStorage.delaySeconds = 0
|
StubURLProtocolStorage.delaySeconds = 0
|
||||||
@@ -65,6 +71,7 @@ final class LLMClientTests: XCTestCase {
|
|||||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||||
|
|
||||||
let config = ProviderConfig(defaults: defaults)
|
let config = ProviderConfig(defaults: defaults)
|
||||||
|
config.apiKey = ""
|
||||||
config.engineMode = "cloud"
|
config.engineMode = "cloud"
|
||||||
XCTAssertFalse(config.isConfigured)
|
XCTAssertFalse(config.isConfigured)
|
||||||
config.engineMode = "local"
|
config.engineMode = "local"
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ final class LocalASRBiasAdapterTests: XCTestCase {
|
|||||||
let url = dir.appendingPathComponent("phrases.tsv")
|
let url = dir.appendingPathComponent("phrases.tsv")
|
||||||
let tsv = """
|
let tsv = """
|
||||||
word\tpinyin\tsource\tweight
|
word\tpinyin\tsource\tweight
|
||||||
SwiftUI\tswift ui\tcomputer_terms\t5
|
SwiftUI\tswift ui\tai_tech_seed\t90
|
||||||
Kubernetes\tku bo ne si\tcomputer_terms\t5
|
Kubernetes\tku bo ne si\tai_tech_seed\t90
|
||||||
一致性\tyi zhi xing\tcomputer_terms\t5
|
一致性\tyi zhi xing\tai_tech_seed\t80
|
||||||
"""
|
"""
|
||||||
try tsv.write(to: url, atomically: true, encoding: .utf8)
|
try tsv.write(to: url, atomically: true, encoding: .utf8)
|
||||||
addTeardownBlock {
|
addTeardownBlock {
|
||||||
@@ -103,11 +103,12 @@ final class LocalASRBiasAdapterTests: XCTestCase {
|
|||||||
|
|
||||||
func testBuiltinLexiconParsesTSV() {
|
func testBuiltinLexiconParsesTSV() {
|
||||||
let terms = BuiltinLexiconIndex.parseTSV(
|
let terms = BuiltinLexiconIndex.parseTSV(
|
||||||
"word\tpinyin\tsource\tweight\nFoo\tfoo\tcomputer_terms\t5\n"
|
"word\tpinyin\tsource\tweight\nFoo\tfoo\tai_tech_seed\t80\n"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(terms.count, 1)
|
XCTAssertEqual(terms.count, 1)
|
||||||
XCTAssertEqual(terms[0].word, "Foo")
|
XCTAssertEqual(terms[0].word, "Foo")
|
||||||
XCTAssertEqual(terms[0].weight, 5)
|
XCTAssertEqual(terms[0].source, "ai_tech_seed")
|
||||||
|
XCTAssertEqual(terms[0].weight, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPolishingServiceMergesDictionarySupplement() {
|
func testPolishingServiceMergesDictionarySupplement() {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ final class LocalASRModelCatalogTests: XCTestCase {
|
|||||||
func testMLXAdapterProducesPromptBiasNotHardHotwords() throws {
|
func testMLXAdapterProducesPromptBiasNotHardHotwords() throws {
|
||||||
let fixtureURL = FileManager.default.temporaryDirectory
|
let fixtureURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("phrases-\(UUID().uuidString).tsv")
|
.appendingPathComponent("phrases-\(UUID().uuidString).tsv")
|
||||||
try "word\tpinyin\tsource\tweight\nSwiftUI\tswift ui\tcomputer_terms\t5\n"
|
try "word\tpinyin\tsource\tweight\nSwiftUI\tswift ui\tai_tech_seed\t90\n"
|
||||||
.write(to: fixtureURL, atomically: true, encoding: .utf8)
|
.write(to: fixtureURL, atomically: true, encoding: .utf8)
|
||||||
defer { try? FileManager.default.removeItem(at: fixtureURL) }
|
defer { try? FileManager.default.removeItem(at: fixtureURL) }
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ import XCTest
|
|||||||
@testable import OSGKeyboard
|
@testable import OSGKeyboard
|
||||||
|
|
||||||
final class OpenSourceLicenseCatalogTests: XCTestCase {
|
final class OpenSourceLicenseCatalogTests: XCTestCase {
|
||||||
func testIOSCatalogIncludesRimeAndDictionaryDataOnly() {
|
func testIOSCatalogIncludesRimeDictionariesIconsAndCuratedSpeechData() {
|
||||||
let entries = OpenSourceLicenseCatalog.entries(for: .iOS)
|
let entries = OpenSourceLicenseCatalog.entries(for: .iOS)
|
||||||
let ids = Set(entries.map(\.id))
|
let ids = Set(entries.map(\.id))
|
||||||
|
|
||||||
|
XCTAssertTrue(ids.contains("material-icons"))
|
||||||
XCTAssertTrue(ids.contains("librime-static"))
|
XCTAssertTrue(ids.contains("librime-static"))
|
||||||
XCTAssertTrue(ids.contains("rime-pinyin-simp"))
|
XCTAssertTrue(ids.contains("rime-pinyin-simp"))
|
||||||
XCTAssertTrue(ids.contains("jieba"))
|
XCTAssertTrue(ids.contains("jieba"))
|
||||||
XCTAssertTrue(ids.contains("phrase-pinyin-data"))
|
XCTAssertTrue(ids.contains("phrase-pinyin-data"))
|
||||||
XCTAssertTrue(ids.contains("pinyin-data"))
|
XCTAssertTrue(ids.contains("pinyin-data"))
|
||||||
XCTAssertTrue(ids.contains("english-typing-lexicon"))
|
XCTAssertTrue(ids.contains("english-typing-lexicon"))
|
||||||
|
XCTAssertTrue(ids.contains("osg-ai-tech-lexicon"))
|
||||||
XCTAssertFalse(ids.contains("mlx-audio-swift"))
|
XCTAssertFalse(ids.contains("mlx-audio-swift"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +24,15 @@ final class OpenSourceLicenseCatalogTests: XCTestCase {
|
|||||||
let entries = OpenSourceLicenseCatalog.entries(for: .macOS)
|
let entries = OpenSourceLicenseCatalog.entries(for: .macOS)
|
||||||
let ids = Set(entries.map(\.id))
|
let ids = Set(entries.map(\.id))
|
||||||
|
|
||||||
XCTAssertEqual(ids, ["mlx-audio-swift"])
|
XCTAssertEqual(ids, [
|
||||||
|
"mlx-audio-swift",
|
||||||
|
"mlx-swift",
|
||||||
|
"mlx-swift-lm",
|
||||||
|
"osg-ai-tech-lexicon",
|
||||||
|
"qwen3-asr-mlx",
|
||||||
|
"swift-huggingface",
|
||||||
|
"swift-transformers",
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBundledDictionaryLicensesKeepCopyrightNotices() throws {
|
func testBundledDictionaryLicensesKeepCopyrightNotices() throws {
|
||||||
@@ -46,4 +56,39 @@ final class OpenSourceLicenseCatalogTests: XCTestCase {
|
|||||||
XCTAssertTrue(entry.licenseText.contains("leveldb.txt"))
|
XCTAssertTrue(entry.licenseText.contains("leveldb.txt"))
|
||||||
XCTAssertTrue(entry.licenseText.contains("yaml-cpp.txt"))
|
XCTAssertTrue(entry.licenseText.contains("yaml-cpp.txt"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMaterialIconsUseGoogleAttributionAndFullApacheLicense() throws {
|
||||||
|
let entry = try XCTUnwrap(
|
||||||
|
OpenSourceLicenseCatalog.entries(for: .iOS)
|
||||||
|
.first { $0.id == "material-icons" }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(entry.licenseText.contains("Copyright 2014 Google LLC"))
|
||||||
|
XCTAssertTrue(entry.licenseText.contains("TERMS AND CONDITIONS FOR USE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMacMLXAndQwenNoticesContainUpstreamAttribution() throws {
|
||||||
|
let entries = OpenSourceLicenseCatalog.entries(for: .macOS)
|
||||||
|
let mlxAudio = try XCTUnwrap(entries.first { $0.id == "mlx-audio-swift" })
|
||||||
|
let mlxSwift = try XCTUnwrap(entries.first { $0.id == "mlx-swift" })
|
||||||
|
let transformers = try XCTUnwrap(entries.first { $0.id == "swift-transformers" })
|
||||||
|
let qwen = try XCTUnwrap(entries.first { $0.id == "qwen3-asr-mlx" })
|
||||||
|
|
||||||
|
XCTAssertTrue(mlxAudio.licenseText.contains("Copyright (c) 2025 Prince Canuma"))
|
||||||
|
XCTAssertTrue(mlxSwift.licenseText.contains("Copyright (c) 2023 ml-explore"))
|
||||||
|
XCTAssertTrue(transformers.licenseText.contains("Copyright 2022 Hugging Face SAS."))
|
||||||
|
XCTAssertTrue(qwen.licenseText.contains("Qwen3-ASR-0.6B-4bit"))
|
||||||
|
XCTAssertTrue(qwen.licenseText.contains("Apache License"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCuratedLexiconNoticeDoesNotRelicenseApplication() throws {
|
||||||
|
let entry = try XCTUnwrap(
|
||||||
|
OpenSourceLicenseCatalog.entries(for: .iOS)
|
||||||
|
.first { $0.id == "osg-ai-tech-lexicon" }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(entry.licenseText.contains("data subset"))
|
||||||
|
XCTAssertTrue(entry.licenseText.contains("source-available"))
|
||||||
|
XCTAssertTrue(entry.licenseText.contains("not distributed under the MIT License"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// EnglishKeyboardDeviceUITests.swift
|
||||||
|
// OSGKeyboardUITests
|
||||||
|
//
|
||||||
|
// Physical-device pass: Notes-like host + the real keyboard extension.
|
||||||
|
// Skips if OSGKeyboard is not enabled as the current keyboard.
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class EnglishKeyboardDeviceUITests: XCTestCase {
|
||||||
|
func testOSGKeyboardAppearsOnNotesHost() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = [
|
||||||
|
"--whats-new-host",
|
||||||
|
"--whats-new-lang=en",
|
||||||
|
"--whats-new-scenario=edit"
|
||||||
|
]
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
let textView = app.textViews["notes.host.textView"]
|
||||||
|
XCTAssertTrue(
|
||||||
|
textView.waitForExistence(timeout: 12),
|
||||||
|
"Notes host text view should appear"
|
||||||
|
)
|
||||||
|
if !textView.exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
textView.tap()
|
||||||
|
|
||||||
|
let keyboard = XCUIApplication(bundleIdentifier: "com.osgkeyboard.ios.keyboard")
|
||||||
|
let appeared = keyboard.wait(for: .runningForeground, timeout: 8)
|
||||||
|
|| keyboard.windows.firstMatch.waitForExistence(timeout: 8)
|
||||||
|
if !appeared {
|
||||||
|
throw XCTSkip(
|
||||||
|
"OSGKeyboard extension is not the active keyboard on this device. Enable it in Settings ▸ Keyboard, then re-run."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
keyboard.windows.firstMatch.exists,
|
||||||
|
"OSGKeyboard extension window should be on screen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -63,7 +63,7 @@ Voice input for iPhone, iPad, and Mac. Speak in any app — polished text lands
|
|||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
Speech is transcribed on-device by default. Polish sends **text only** — the transcript and a small amount of nearby cursor text for continuity, never raw audio. Chinese candidate learning and English suggestion/autocorrect learning stay in the on-device App Group and are not uploaded. Secure fields disable English suggestions and autocorrect. Cursor context is not logged or saved to voice history. See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/).
|
Speech is transcribed on-device by default. Audio or text leaves the device only when you actively choose a cloud engine, polish, AI, or a skill, and it goes directly to your configured provider. Clipboard history is off by default, capped at 15 local items, and sent only after an explicit clipboard action. Chinese and English typing learning stays local. API keys use Keychain (optionally iCloud Keychain), while downloaded Mac speech models and local inference remain on the Mac. See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,10 @@
|
|||||||
|
|
||||||
- **默认本地识别** — 录音在设备上转写,不经过我们的服务器
|
- **默认本地识别** — 录音在设备上转写,不经过我们的服务器
|
||||||
- **润色只发文字** — 发给 LLM 的是转写文本,以及用于衔接的少量光标附近文字,不是原始音频
|
- **润色只发文字** — 发给 LLM 的是转写文本,以及用于衔接的少量光标附近文字,不是原始音频
|
||||||
|
- **服务商由你选择** — 只有你在设置中主动选择云端识别、润色、AI 或技能时,相关音频或文字才会直接发给你配置的服务商
|
||||||
|
- **剪贴板默认关闭** — 历史最多 15 条、仅保存在本机;只有你主动运行剪贴板技能、在 AI 提问中点名剪贴板或粘贴后润色时,正文才会离开设备
|
||||||
- **不上传击键** — 中文候选学习与英文补全/纠错学习仅留在设备 App Group;密码框关闭英文建议与纠错,普通击键不会上传
|
- **不上传击键** — 中文候选学习与英文补全/纠错学习仅留在设备 App Group;密码框关闭英文建议与纠错,普通击键不会上传
|
||||||
|
- **密钥与模型归你控制** — API Key 保存在 Keychain,可选经 iCloud 钥匙串同步;Mac 本地模型下载后保存在本机,端侧推理不上传录音
|
||||||
- 详见 [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)
|
- 详见 [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Executable
+131
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Physical-device keyboard appear/hide stress.
|
||||||
|
# Shows and dismisses the real OSGKeyboard extension N times, then checks
|
||||||
|
# crash reports.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./Scripts/keyboard-appear-stress.sh [COUNT=50] [UDID]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
COUNT="${1:-50}"
|
||||||
|
UDID="${2:-00008130-001C249C0E52001C}"
|
||||||
|
BUNDLE="com.osgkeyboard.ios"
|
||||||
|
OUT_DIR="${ROOT}/.tmp/keyboard-appear-stress-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
CONSOLE="$OUT_DIR/console.log"
|
||||||
|
SUMMARY="$OUT_DIR/summary.txt"
|
||||||
|
DERIVED="${ROOT}/.derivedData-device-stress"
|
||||||
|
APP="$DERIVED/Build/Products/Debug-iphoneos/OSGKeyboard.app"
|
||||||
|
|
||||||
|
echo "==> Out: $OUT_DIR"
|
||||||
|
echo "==> Count: $COUNT Device: $UDID"
|
||||||
|
|
||||||
|
echo "==> Building Debug-iphoneos"
|
||||||
|
xcodebuild build \
|
||||||
|
-project "$ROOT/OSGKeyboard.xcodeproj" \
|
||||||
|
-scheme OSGKeyboard \
|
||||||
|
-destination "platform=iOS,id=$UDID" \
|
||||||
|
-configuration Debug \
|
||||||
|
-derivedDataPath "$DERIVED" \
|
||||||
|
-allowProvisioningUpdates \
|
||||||
|
-onlyUsePackageVersionsFromResolvedFile \
|
||||||
|
CODE_SIGNING_ALLOWED=YES \
|
||||||
|
>/dev/null
|
||||||
|
|
||||||
|
echo "==> Installing"
|
||||||
|
xcrun devicectl device install app --device "$UDID" "$APP" --timeout 180 >/dev/null
|
||||||
|
|
||||||
|
crash_list() {
|
||||||
|
xcrun devicectl device info files \
|
||||||
|
--device "$UDID" \
|
||||||
|
--domain-type systemCrashLogs \
|
||||||
|
--timeout 30 2>/dev/null \
|
||||||
|
| awk '/OSGKeyboardExt-/{print $1}'
|
||||||
|
}
|
||||||
|
|
||||||
|
BEFORE="$OUT_DIR/crashes-before.txt"
|
||||||
|
AFTER="$OUT_DIR/crashes-after.txt"
|
||||||
|
crash_list | sort >"$BEFORE"
|
||||||
|
|
||||||
|
echo "==> Launching appear-stress count=$COUNT"
|
||||||
|
# 50 hide/show cycles plus first show; ~0.5s each + timeouts.
|
||||||
|
TIMEOUT_SECS=$((COUNT * 3 + 40))
|
||||||
|
set +e
|
||||||
|
python3 - "$TIMEOUT_SECS" "$UDID" "$BUNDLE" "$COUNT" "$CONSOLE" <<'PY'
|
||||||
|
import subprocess, sys, time, os, signal
|
||||||
|
timeout, udid, bundle, count, console = sys.argv[1:6]
|
||||||
|
cmd = [
|
||||||
|
"xcrun", "devicectl", "device", "process", "launch",
|
||||||
|
"--device", udid,
|
||||||
|
"--console",
|
||||||
|
"--terminate-existing",
|
||||||
|
bundle,
|
||||||
|
"--whats-new-host",
|
||||||
|
"--whats-new-lang=en",
|
||||||
|
"--whats-new-scenario=edit",
|
||||||
|
f"--keyboard-appear-stress={count}",
|
||||||
|
]
|
||||||
|
with open(console, "w") as out:
|
||||||
|
proc = subprocess.Popen(cmd, stdout=out, stderr=subprocess.STDOUT)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=int(timeout))
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.send_signal(signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=8)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
sys.exit(124)
|
||||||
|
sys.exit(proc.returncode or 0)
|
||||||
|
PY
|
||||||
|
LAUNCH_STATUS=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
crash_list | sort >"$AFTER"
|
||||||
|
NEW_CRASHES="$OUT_DIR/crashes-new.txt"
|
||||||
|
comm -13 "$BEFORE" "$AFTER" >"$NEW_CRASHES"
|
||||||
|
|
||||||
|
PASSED="$(python3 - "$CONSOLE" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
text = open(sys.argv[1], errors="replace").read()
|
||||||
|
hits = re.findall(r"keyboard\.stress done passed=(\d+)/(\d+)", text)
|
||||||
|
print(hits[-1][0] if hits else "")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
TOTAL="$(python3 - "$CONSOLE" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
text = open(sys.argv[1], errors="replace").read()
|
||||||
|
hits = re.findall(r"keyboard\.stress done passed=(\d+)/(\d+)", text)
|
||||||
|
print(hits[-1][1] if hits else "")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
FAIL_LINE="$(grep 'keyboard.stress FAIL' "$CONSOLE" | tail -1 || true)"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "Keyboard appear/hide stress"
|
||||||
|
echo "device=$UDID count=$COUNT"
|
||||||
|
echo "launch_exit=$LAUNCH_STATUS"
|
||||||
|
echo "passed=${PASSED:-0}/${TOTAL:-$COUNT}"
|
||||||
|
echo "fail_line=${FAIL_LINE:-none}"
|
||||||
|
echo "new_OSGKeyboardExt_crashes:"
|
||||||
|
if [[ -s "$NEW_CRASHES" ]]; then
|
||||||
|
cat "$NEW_CRASHES"
|
||||||
|
else
|
||||||
|
echo " (none)"
|
||||||
|
fi
|
||||||
|
echo "console=$CONSOLE"
|
||||||
|
} | tee "$SUMMARY"
|
||||||
|
|
||||||
|
if [[ -s "$NEW_CRASHES" ]]; then
|
||||||
|
echo "FAIL: new OSGKeyboardExt crash reports" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "${PASSED:-0}" != "$COUNT" ]]; then
|
||||||
|
echo "FAIL: expected $COUNT cycles, got ${PASSED:-0}" >&2
|
||||||
|
tail -40 "$CONSOLE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PASS: $COUNT/$COUNT appear-hide cycles, no new extension crashes"
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""Build OSGKeyboard domain ASR lexicon v1 from the computer-terms scel source.
|
|
||||||
|
|
||||||
Sources:
|
|
||||||
Local 计算机词汇大全【官方推荐】.scel (IT / computer vocabulary only)
|
|
||||||
|
|
||||||
Casual network slang and Sogou popular-word dumps are intentionally excluded —
|
|
||||||
they dilute custom LM phrase biasing without improving domain ASR accuracy.
|
|
||||||
|
|
||||||
Output:
|
|
||||||
OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
|
|
||||||
OSGKeyboard/Resources/CustomLanguageModel/v1/manifest.json
|
|
||||||
|
|
||||||
The compiled .bin asset is exported separately to
|
|
||||||
OSGKeyboard/Resources/CustomLanguageModel/v1/ via export_clm.swift.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from scel_parser import get_scel_info, parse_scel_file
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
||||||
DEFAULT_OUTPUT_DIR = REPO_ROOT / "OSGKeyboard/Resources/CustomLanguageModel/v1"
|
|
||||||
DEFAULT_COMPUTER_SCEL = Path("/Users/rocky/Downloads/计算机词汇大全【官方推荐】.scel")
|
|
||||||
|
|
||||||
SOURCE_KEY = "computer_terms"
|
|
||||||
SOURCE_LABEL = "计算机词汇大全【官方推荐】"
|
|
||||||
SOURCE_WEIGHT = 5
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LexiconEntry:
|
|
||||||
word: str
|
|
||||||
pinyin: str
|
|
||||||
source: str
|
|
||||||
weight: int
|
|
||||||
|
|
||||||
|
|
||||||
def merge_entries(entries: list[tuple[str, str]]) -> list[LexiconEntry]:
|
|
||||||
merged: dict[str, LexiconEntry] = {}
|
|
||||||
|
|
||||||
for word, pinyin in entries:
|
|
||||||
current = merged.get(word)
|
|
||||||
candidate = LexiconEntry(
|
|
||||||
word=word,
|
|
||||||
pinyin=pinyin,
|
|
||||||
source=SOURCE_KEY,
|
|
||||||
weight=SOURCE_WEIGHT,
|
|
||||||
)
|
|
||||||
if current is None:
|
|
||||||
merged[word] = candidate
|
|
||||||
elif not current.pinyin and pinyin:
|
|
||||||
merged[word] = candidate
|
|
||||||
|
|
||||||
return sorted(merged.values(), key=lambda item: item.word)
|
|
||||||
|
|
||||||
|
|
||||||
def write_outputs(entries: list[LexiconEntry], output_dir: Path, raw_count: int) -> None:
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
phrases_path = output_dir / "phrases.tsv"
|
|
||||||
with phrases_path.open("w", encoding="utf-8") as handle:
|
|
||||||
handle.write("word\tpinyin\tsource\tweight\n")
|
|
||||||
for entry in entries:
|
|
||||||
handle.write(f"{entry.word}\t{entry.pinyin}\t{entry.source}\t{entry.weight}\n")
|
|
||||||
|
|
||||||
manifest = {
|
|
||||||
"version": "v1",
|
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
"locale": "zh-Hans",
|
|
||||||
"entry_count": len(entries),
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"key": SOURCE_KEY,
|
|
||||||
"label": SOURCE_LABEL,
|
|
||||||
"weight": SOURCE_WEIGHT,
|
|
||||||
"raw_count": raw_count,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"notes": [
|
|
||||||
"Domain-specific computer/IT vocabulary only; casual network slang removed.",
|
|
||||||
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
|
|
||||||
"Merged with ai-tech-brands seed at export time for the final .bin asset.",
|
|
||||||
],
|
|
||||||
"files": {
|
|
||||||
"phrases": phrases_path.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest_path = output_dir / "manifest.json"
|
|
||||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def build(*, computer_scel: Path, output_dir: Path) -> int:
|
|
||||||
if not computer_scel.exists():
|
|
||||||
print(f"Missing computer scel: {computer_scel}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
computer_info = get_scel_info(computer_scel)
|
|
||||||
print(f"Computer dict: {computer_info.name} ({computer_info.word_count} header count)")
|
|
||||||
|
|
||||||
raw_entries = parse_scel_file(computer_scel)
|
|
||||||
merged = merge_entries(raw_entries)
|
|
||||||
write_outputs(merged, output_dir, raw_count=len(raw_entries))
|
|
||||||
|
|
||||||
print(f"Raw count: {len(raw_entries)}")
|
|
||||||
print(f"Merged unique entries: {len(merged)}")
|
|
||||||
print(f"Wrote {output_dir / 'phrases.tsv'}")
|
|
||||||
print(f"Wrote {output_dir / 'manifest.json'}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description="Build OSGKeyboard domain ASR lexicon v1")
|
|
||||||
parser.add_argument("--computer-scel", type=Path, default=DEFAULT_COMPUTER_SCEL)
|
|
||||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
|
||||||
args = parser.parse_args()
|
|
||||||
return build(computer_scel=args.computer_scel, output_dir=args.output_dir)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
// export_clm.swift
|
// export_clm.swift
|
||||||
// OSGKeyboard · offline SFCustomLanguageModelData exporter (macOS 14+)
|
// OSGKeyboard · offline SFCustomLanguageModelData exporter (macOS 14+)
|
||||||
//
|
//
|
||||||
// Reads merged phrase TSVs and writes a .bin training asset via Speech framework.
|
// Reads the project-curated AI/tech TSV and writes a .bin training asset via
|
||||||
|
// Speech framework. The normalized four-column TSV beside the binary also
|
||||||
|
// powers the Mac runtime bias index.
|
||||||
// Usage:
|
// Usage:
|
||||||
// swift Scripts/lexicon/export_clm.swift
|
// swift Scripts/lexicon/export_clm.swift
|
||||||
// swift Scripts/lexicon/export_clm.swift --max-entries 30000
|
// swift Scripts/lexicon/export_clm.swift --max-entries 30000
|
||||||
@@ -15,7 +17,6 @@ import Speech
|
|||||||
// MARK: - CLI
|
// MARK: - CLI
|
||||||
|
|
||||||
struct CLIOptions {
|
struct CLIOptions {
|
||||||
var domainTSV: URL
|
|
||||||
var aiTechTSV: URL
|
var aiTechTSV: URL
|
||||||
var outputBin: URL
|
var outputBin: URL
|
||||||
var localeID: String
|
var localeID: String
|
||||||
@@ -29,9 +30,6 @@ struct CLIOptions {
|
|||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
|
|
||||||
var domain = repoRoot.appendingPathComponent(
|
|
||||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv"
|
|
||||||
)
|
|
||||||
var aiTech = repoRoot.appendingPathComponent(
|
var aiTech = repoRoot.appendingPathComponent(
|
||||||
"OSGKeyboard/Resources/CustomLanguageModel/ai-tech-brands/v1/phrases.tsv"
|
"OSGKeyboard/Resources/CustomLanguageModel/ai-tech-brands/v1/phrases.tsv"
|
||||||
)
|
)
|
||||||
@@ -40,14 +38,12 @@ struct CLIOptions {
|
|||||||
)
|
)
|
||||||
var localeID = "zh_CN"
|
var localeID = "zh_CN"
|
||||||
var modelID = "com.osgkeyboard.custom-lm.v1"
|
var modelID = "com.osgkeyboard.custom-lm.v1"
|
||||||
var modelVersion = "1.0.0"
|
var modelVersion = "1.0.1"
|
||||||
var maxEntries: Int?
|
var maxEntries: Int?
|
||||||
|
|
||||||
var iterator = CommandLine.arguments.dropFirst().makeIterator()
|
var iterator = CommandLine.arguments.dropFirst().makeIterator()
|
||||||
while let flag = iterator.next() {
|
while let flag = iterator.next() {
|
||||||
switch flag {
|
switch flag {
|
||||||
case "--domain-tsv", "--sogou-tsv":
|
|
||||||
domain = URL(fileURLWithPath: iterator.next() ?? "")
|
|
||||||
case "--ai-tech-tsv":
|
case "--ai-tech-tsv":
|
||||||
aiTech = URL(fileURLWithPath: iterator.next() ?? "")
|
aiTech = URL(fileURLWithPath: iterator.next() ?? "")
|
||||||
case "--output":
|
case "--output":
|
||||||
@@ -71,7 +67,6 @@ struct CLIOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return CLIOptions(
|
return CLIOptions(
|
||||||
domainTSV: domain,
|
|
||||||
aiTechTSV: aiTech,
|
aiTechTSV: aiTech,
|
||||||
outputBin: output,
|
outputBin: output,
|
||||||
localeID: localeID,
|
localeID: localeID,
|
||||||
@@ -86,8 +81,7 @@ struct CLIOptions {
|
|||||||
export_clm.swift — build SFCustomLanguageModelData .bin on macOS
|
export_clm.swift — build SFCustomLanguageModelData .bin on macOS
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--domain-tsv <path> Domain phrases TSV (computer/IT terms)
|
--ai-tech-tsv <path> Project-curated AI/tech phrases TSV
|
||||||
--ai-tech-tsv <path> AI/tech seed phrases TSV
|
|
||||||
--output <path> Output .bin path
|
--output <path> Output .bin path
|
||||||
--locale <id> Locale identifier (default: zh_CN)
|
--locale <id> Locale identifier (default: zh_CN)
|
||||||
--identifier <id> Custom LM identifier
|
--identifier <id> Custom LM identifier
|
||||||
@@ -102,6 +96,7 @@ struct CLIOptions {
|
|||||||
|
|
||||||
struct PhraseEntry: Hashable {
|
struct PhraseEntry: Hashable {
|
||||||
let phrase: String
|
let phrase: String
|
||||||
|
let pinyin: String
|
||||||
let weight: Int
|
let weight: Int
|
||||||
let source: String
|
let source: String
|
||||||
}
|
}
|
||||||
@@ -126,7 +121,7 @@ enum TSVLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Formats:
|
// Formats:
|
||||||
// domain: word, pinyin, source, weight
|
// normalized: word, pinyin, source, weight
|
||||||
// ai-tech: word, pinyin, source, category, weight, canonical
|
// ai-tech: word, pinyin, source, category, weight, canonical
|
||||||
let weight: Int
|
let weight: Int
|
||||||
if parts.count >= 6, let parsed = Int(parts[4]) {
|
if parts.count >= 6, let parsed = Int(parts[4]) {
|
||||||
@@ -137,8 +132,16 @@ enum TSVLoader {
|
|||||||
weight = 1
|
weight = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pinyin = parts.count >= 2 ? parts[1] : ""
|
||||||
let source = parts.count >= 3 ? parts[2] : sourceLabel
|
let source = parts.count >= 3 ? parts[2] : sourceLabel
|
||||||
entries.append(PhraseEntry(phrase: word, weight: max(1, weight), source: source))
|
entries.append(
|
||||||
|
PhraseEntry(
|
||||||
|
phrase: word,
|
||||||
|
pinyin: pinyin,
|
||||||
|
weight: max(1, weight),
|
||||||
|
source: source
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
@@ -171,17 +174,13 @@ enum ExportCLM {
|
|||||||
let options = CLIOptions.parse()
|
let options = CLIOptions.parse()
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
|
|
||||||
guard fm.fileExists(atPath: options.domainTSV.path) else {
|
|
||||||
throw ExportError.missingInput(options.domainTSV.path)
|
|
||||||
}
|
|
||||||
guard fm.fileExists(atPath: options.aiTechTSV.path) else {
|
guard fm.fileExists(atPath: options.aiTechTSV.path) else {
|
||||||
throw ExportError.missingInput(options.aiTechTSV.path)
|
throw ExportError.missingInput(options.aiTechTSV.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fputs("Loading phrases…\n", stderr)
|
fputs("Loading phrases…\n", stderr)
|
||||||
let domain = try TSVLoader.load(from: options.domainTSV, sourceLabel: "computer_terms")
|
|
||||||
let aiTech = try TSVLoader.load(from: options.aiTechTSV, sourceLabel: "ai_tech_seed")
|
let aiTech = try TSVLoader.load(from: options.aiTechTSV, sourceLabel: "ai_tech_seed")
|
||||||
var merged = TSVLoader.merge([domain, aiTech])
|
var merged = TSVLoader.merge([aiTech])
|
||||||
|
|
||||||
if let cap = options.maxEntries, merged.count > cap {
|
if let cap = options.maxEntries, merged.count > cap {
|
||||||
merged = Array(merged.prefix(cap))
|
merged = Array(merged.prefix(cap))
|
||||||
@@ -189,7 +188,7 @@ enum ExportCLM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fputs(
|
fputs(
|
||||||
"Merged \(merged.count) unique phrases (domain=\(domain.count), ai-tech=\(aiTech.count))\n",
|
"Loaded \(merged.count) unique project-curated AI/tech phrases\n",
|
||||||
stderr
|
stderr
|
||||||
)
|
)
|
||||||
fputs("Locale=\(options.localeID) identifier=\(options.modelID) version=\(options.modelVersion)\n", stderr)
|
fputs("Locale=\(options.localeID) identifier=\(options.modelID) version=\(options.modelVersion)\n", stderr)
|
||||||
@@ -218,6 +217,15 @@ enum ExportCLM {
|
|||||||
try fm.removeItem(at: outputURL)
|
try fm.removeItem(at: outputURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let phrasesURL = parent.appendingPathComponent("phrases.tsv")
|
||||||
|
let normalizedLines = merged.map { entry in
|
||||||
|
"\(entry.phrase)\t\(entry.pinyin)\t\(entry.source)\t\(entry.weight)"
|
||||||
|
}
|
||||||
|
let normalizedTSV = (["word\tpinyin\tsource\tweight"] + normalizedLines)
|
||||||
|
.joined(separator: "\n") + "\n"
|
||||||
|
try normalizedTSV.write(to: phrasesURL, atomically: true, encoding: .utf8)
|
||||||
|
fputs("Wrote \(phrasesURL.path)\n", stderr)
|
||||||
|
|
||||||
fputs("Exporting to \(outputURL.path)…\n", stderr)
|
fputs("Exporting to \(outputURL.path)…\n", stderr)
|
||||||
try await data.export(to: outputURL)
|
try await data.export(to: outputURL)
|
||||||
|
|
||||||
@@ -236,7 +244,6 @@ enum ExportCLM {
|
|||||||
"version": options.modelVersion,
|
"version": options.modelVersion,
|
||||||
"phrase_count": merged.count,
|
"phrase_count": merged.count,
|
||||||
"sources": [
|
"sources": [
|
||||||
"computer_terms": domain.count,
|
|
||||||
"ai_tech_seed": aiTech.count,
|
"ai_tech_seed": aiTech.count,
|
||||||
],
|
],
|
||||||
"bin_file": outputURL.lastPathComponent,
|
"bin_file": outputURL.lastPathComponent,
|
||||||
@@ -246,6 +253,32 @@ enum ExportCLM {
|
|||||||
let manifestData = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys])
|
let manifestData = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys])
|
||||||
try manifestData.write(to: manifestURL)
|
try manifestData.write(to: manifestURL)
|
||||||
fputs("Wrote \(manifestURL.path)\n", stderr)
|
fputs("Wrote \(manifestURL.path)\n", stderr)
|
||||||
|
|
||||||
|
let sourceManifestURL = parent.appendingPathComponent("manifest.json")
|
||||||
|
let sourceManifest: [String: Any] = [
|
||||||
|
"version": "v1",
|
||||||
|
"generated_at": ISO8601DateFormatter().string(from: Date()),
|
||||||
|
"locale": "zh-Hans",
|
||||||
|
"entry_count": merged.count,
|
||||||
|
"sources": [[
|
||||||
|
"key": "ai_tech_seed",
|
||||||
|
"label": "OSGKeyboard curated AI/tech lexicon",
|
||||||
|
"license": "MIT (curated seed; OSGKeyboard contributors)",
|
||||||
|
"raw_count": aiTech.count,
|
||||||
|
]],
|
||||||
|
"notes": [
|
||||||
|
"Project-curated bilingual AI brands, technology terms, companies, and names.",
|
||||||
|
"No third-party cell dictionaries or Sogou-derived data.",
|
||||||
|
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
|
||||||
|
],
|
||||||
|
"files": ["phrases": "phrases.tsv"],
|
||||||
|
]
|
||||||
|
let sourceManifestData = try JSONSerialization.data(
|
||||||
|
withJSONObject: sourceManifest,
|
||||||
|
options: [.prettyPrinted, .sortedKeys]
|
||||||
|
)
|
||||||
|
try sourceManifestData.write(to: sourceManifestURL)
|
||||||
|
fputs("Wrote \(sourceManifestURL.path)\n", stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""Parse Sogou .scel cell dictionaries into (word, pinyin) entries.
|
|
||||||
|
|
||||||
Layout follows the classic SCEL format used by imewlconverter / SogouPopularDict.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ScelInfo:
|
|
||||||
word_count: int
|
|
||||||
name: str
|
|
||||||
type_name: str
|
|
||||||
description: str
|
|
||||||
|
|
||||||
|
|
||||||
def _read_uint16(handle) -> int:
|
|
||||||
data = handle.read(2)
|
|
||||||
if not data or len(data) < 2:
|
|
||||||
return 0
|
|
||||||
return struct.unpack("<H", data)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_uint32(handle) -> int:
|
|
||||||
data = handle.read(4)
|
|
||||||
if not data or len(data) < 4:
|
|
||||||
return 0
|
|
||||||
return struct.unpack("<I", data)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_utf16_str(handle, *, offset: int = -1, length: int = 0) -> str:
|
|
||||||
if offset >= 0:
|
|
||||||
handle.seek(offset)
|
|
||||||
if length > 0:
|
|
||||||
data = handle.read(length)
|
|
||||||
end = 0
|
|
||||||
for index in range(0, len(data), 2):
|
|
||||||
if index + 1 < len(data) and data[index] == 0 and data[index + 1] == 0:
|
|
||||||
end = index
|
|
||||||
break
|
|
||||||
if end > 0:
|
|
||||||
data = data[:end]
|
|
||||||
return data.decode("utf-16le", errors="ignore")
|
|
||||||
|
|
||||||
result = bytearray()
|
|
||||||
while True:
|
|
||||||
char = handle.read(2)
|
|
||||||
if not char or len(char) < 2 or (char[0] == 0 and char[1] == 0):
|
|
||||||
break
|
|
||||||
result.extend(char)
|
|
||||||
return result.decode("utf-16le", errors="ignore")
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_word(word: str) -> bool:
|
|
||||||
if not word or not (1 <= len(word) <= 10):
|
|
||||||
return False
|
|
||||||
allowed_punct = ",。:;?!()【】《》""''、"
|
|
||||||
return all("\u4e00" <= char <= "\u9fff" or char.isdigit() or char in allowed_punct for char in word)
|
|
||||||
|
|
||||||
|
|
||||||
def get_scel_info(scel_path: Path) -> ScelInfo:
|
|
||||||
with scel_path.open("rb") as handle:
|
|
||||||
handle.seek(0x124)
|
|
||||||
word_count = _read_uint32(handle)
|
|
||||||
handle.seek(0x130)
|
|
||||||
name = _read_utf16_str(handle, length=64)
|
|
||||||
handle.seek(0x338)
|
|
||||||
type_name = _read_utf16_str(handle, length=64)
|
|
||||||
handle.seek(0x540)
|
|
||||||
description = _read_utf16_str(handle, length=1024)
|
|
||||||
return ScelInfo(word_count=word_count, name=name, type_name=type_name, description=description)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_scel_file(scel_path: Path) -> list[tuple[str, str]]:
|
|
||||||
"""Return ordered (word, pinyin) pairs from a .scel file."""
|
|
||||||
entries: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
with scel_path.open("rb") as handle:
|
|
||||||
handle.seek(0x1540)
|
|
||||||
pinyin_count = _read_uint32(handle)
|
|
||||||
pinyin_dict: dict[int, str] = {}
|
|
||||||
for _ in range(pinyin_count):
|
|
||||||
pinyin_idx = _read_uint16(handle)
|
|
||||||
pinyin_len = _read_uint16(handle)
|
|
||||||
pinyin = handle.read(pinyin_len).decode("utf-16le", errors="ignore").strip().lower()
|
|
||||||
pinyin_dict[pinyin_idx] = pinyin
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
same_pinyin_count = _read_uint16(handle)
|
|
||||||
pinyin_index_len = _read_uint16(handle)
|
|
||||||
if pinyin_index_len <= 0 or same_pinyin_count <= 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
pinyin_parts: list[str] = []
|
|
||||||
for _ in range(pinyin_index_len // 2):
|
|
||||||
idx = _read_uint16(handle)
|
|
||||||
part = pinyin_dict.get(idx, "")
|
|
||||||
if part:
|
|
||||||
pinyin_parts.append(part)
|
|
||||||
joined_pinyin = " ".join(pinyin_parts).strip()
|
|
||||||
|
|
||||||
for _ in range(same_pinyin_count):
|
|
||||||
word_len = _read_uint16(handle)
|
|
||||||
word = handle.read(word_len).decode("utf-16le", errors="ignore")
|
|
||||||
_ = _read_uint16(handle)
|
|
||||||
_ = _read_uint32(handle)
|
|
||||||
_ = handle.read(6)
|
|
||||||
if is_valid_word(word):
|
|
||||||
entries.append((word, joined_pinyin))
|
|
||||||
except (struct.error, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return entries
|
|
||||||
|
|
||||||
|
|
||||||
def load_pinyin_tsv(tsv_path: Path) -> list[tuple[str, str]]:
|
|
||||||
entries: list[tuple[str, str]] = []
|
|
||||||
with tsv_path.open(encoding="utf-8") as handle:
|
|
||||||
for raw_line in handle:
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
word, _, pinyin = line.partition("\t")
|
|
||||||
if word and pinyin:
|
|
||||||
entries.append((word, pinyin.strip()))
|
|
||||||
return entries
|
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build compact English unigram + bigram TSVs from Peter Norvig's public-domain
|
||||||
|
n-gram counts (https://norvig.com/ngrams/).
|
||||||
|
|
||||||
|
Norvig: “I hereby release all these files into the public domain.”
|
||||||
|
We store log-scaled ranks (not raw counts) so the keyboard extension stays small
|
||||||
|
and we are not redistributing the full Google Web Trillion Word Corpus dump.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
OUT_DIR = ROOT / "OSGKeyboardShared" / "Resources" / "Typing" / "English"
|
||||||
|
UNIGRAM_URL = "https://norvig.com/ngrams/count_1w.txt"
|
||||||
|
BIGRAM_URL = "https://norvig.com/ngrams/count_2w.txt"
|
||||||
|
|
||||||
|
WORD_RE = re.compile(r"^[a-z]+(?:'[a-z]+)?$")
|
||||||
|
MAX_UNIGRAMS = 40_000
|
||||||
|
MAX_BIGRAMS = 8_000
|
||||||
|
MAX_WORD_LEN = 20
|
||||||
|
# Stop reading the 2-gram file once we have enough accepted rows; the file is
|
||||||
|
# already sorted by descending count.
|
||||||
|
BIGRAM_SCAN_LIMIT = 80_000
|
||||||
|
|
||||||
|
# Seed collocations so next-word still works if the 2-gram download fails.
|
||||||
|
FALLBACK_BIGRAMS: list[tuple[str, list[str]]] = [
|
||||||
|
("the", "of and to in is for that with on a".split()),
|
||||||
|
("of", "the a this my our their these those course them".split()),
|
||||||
|
("to", "the be a do make see get go have my".split()),
|
||||||
|
("and", "the a then also other more so we you".split()),
|
||||||
|
("a", "lot few new good great little bit way time person".split()),
|
||||||
|
("in", "the a my this order fact front time case addition".split()),
|
||||||
|
("is", "a the not to that this it one more also".split()),
|
||||||
|
("for", "the a example me you us this that now sure".split()),
|
||||||
|
("that", "the is was I you it we they this are".split()),
|
||||||
|
("i", "am have will would can do think know want was".split()),
|
||||||
|
("it", "is was would will can be has had to not".split()),
|
||||||
|
("on", "the a my this time top of it you that".split()),
|
||||||
|
("you", "are can will would have do know want to should".split()),
|
||||||
|
("with", "the a my you it this that him her them".split()),
|
||||||
|
("as", "a the well much soon far long if of to".split()),
|
||||||
|
("this", "is was the a time one way thing point case".split()),
|
||||||
|
("we", "are have will can would do need want should were".split()),
|
||||||
|
("have", "a the been to been a been the time been".split()),
|
||||||
|
("be", "a the able to in on there here with as".split()),
|
||||||
|
("are", "a the not you we they going to in on".split()),
|
||||||
|
("not", "a the be to sure only yet even really the".split()),
|
||||||
|
("but", "the I a it is also then we you not".split()),
|
||||||
|
("from", "the a my this that it you now here there".split()),
|
||||||
|
("at", "the a my this time least home work school night".split()),
|
||||||
|
("by", "the a this that now then far me you email".split()),
|
||||||
|
("or", "the a not so to it you we they this".split()),
|
||||||
|
("an", "hour example email idea issue update account apple app".split()),
|
||||||
|
("if", "you the I we it that this not so a".split()),
|
||||||
|
("will", "be you I we the not have to a get".split()),
|
||||||
|
("can", "be you I we not the a help see get".split()),
|
||||||
|
("would", "be you I we like have not the a to".split()),
|
||||||
|
("do", "you not the I we it that this a".split()),
|
||||||
|
("there", "is are was were a the no not been have".split()),
|
||||||
|
("their", "own new first last time way work house car".split()),
|
||||||
|
("what", "is the a you I we do time about if".split()),
|
||||||
|
("when", "the I you we it is a this that not".split()),
|
||||||
|
("which", "is the a you we they of in to that".split()),
|
||||||
|
("who", "is are was were the a you I we".split()),
|
||||||
|
("how", "to much many long about is the a you".split()),
|
||||||
|
("about", "the a this that it you to time me".split()),
|
||||||
|
("into", "the a this that my it you a new".split()),
|
||||||
|
("just", "a the like to be now want wanted got".split()),
|
||||||
|
("like", "a the to this that it you I we".split()),
|
||||||
|
("so", "I the a you we that this much many".split()),
|
||||||
|
("than", "the a I you we this that it to".split()),
|
||||||
|
("then", "the I you we a it to is was".split()),
|
||||||
|
("them", "to a the in on with for and I".split()),
|
||||||
|
("these", "are is the a days things people ones two".split()),
|
||||||
|
("those", "are is the a who were days people ones".split()),
|
||||||
|
("my", "own new first last time way email phone name".split()),
|
||||||
|
("your", "own new email phone name time way account".split()),
|
||||||
|
("our", "own new first last time team way house".split()),
|
||||||
|
("going", "to be the a in on for with".split()),
|
||||||
|
("want", "to a the you I we it".split()),
|
||||||
|
("need", "to a the you I we it".split()),
|
||||||
|
("let", "me you us the a".split()),
|
||||||
|
("please", "let me you the a".split()),
|
||||||
|
("thank", "you so much".split()),
|
||||||
|
("thanks", "for so much".split()),
|
||||||
|
("looking", "forward to for at".split()),
|
||||||
|
("let", "me you us know".split()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_lines(url: str, max_lines: int | None = None) -> list[str]:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "OSGKeyboard-lexicon-builder/1.0"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as response:
|
||||||
|
lines: list[str] = []
|
||||||
|
for raw in response:
|
||||||
|
line = raw.decode("utf-8", errors="ignore").strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
lines.append(line)
|
||||||
|
if max_lines is not None and len(lines) >= max_lines:
|
||||||
|
break
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def parse_count_line(line: str) -> tuple[str, int] | None:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
token = parts[0].lower()
|
||||||
|
try:
|
||||||
|
count = int(parts[-1])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return token, count
|
||||||
|
|
||||||
|
|
||||||
|
def log_rank(count: int) -> int:
|
||||||
|
return max(1, int(round(math.log10(count) * 100)))
|
||||||
|
|
||||||
|
|
||||||
|
def build_unigrams(lines: list[str]) -> dict[str, int]:
|
||||||
|
ranked: list[tuple[str, int]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for line in lines:
|
||||||
|
parsed = parse_count_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
word, count = parsed
|
||||||
|
if word in seen:
|
||||||
|
continue
|
||||||
|
if not WORD_RE.match(word) or len(word) > MAX_WORD_LEN:
|
||||||
|
continue
|
||||||
|
seen.add(word)
|
||||||
|
ranked.append((word, count))
|
||||||
|
if len(ranked) >= MAX_UNIGRAMS:
|
||||||
|
break
|
||||||
|
return {word: log_rank(count) for word, count in ranked}
|
||||||
|
|
||||||
|
|
||||||
|
def build_bigrams(
|
||||||
|
lines: list[str],
|
||||||
|
unigrams: dict[str, int],
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
grouped: dict[str, list[tuple[str, int]]] = {}
|
||||||
|
accepted = 0
|
||||||
|
for line in lines:
|
||||||
|
parsed = parse_count_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
token, count = parsed
|
||||||
|
parts = token.split("_")
|
||||||
|
if len(parts) != 2:
|
||||||
|
# Norvig 2-grams are "word1 word2 count"
|
||||||
|
bits = line.lower().split()
|
||||||
|
if len(bits) < 3:
|
||||||
|
continue
|
||||||
|
left, right, count_s = bits[0], bits[1], bits[-1]
|
||||||
|
try:
|
||||||
|
count = int(count_s)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
left, right = parts
|
||||||
|
if left not in unigrams or right not in unigrams:
|
||||||
|
continue
|
||||||
|
if left == right:
|
||||||
|
continue
|
||||||
|
bucket = grouped.setdefault(left, [])
|
||||||
|
if any(word == right for word, _ in bucket):
|
||||||
|
continue
|
||||||
|
bucket.append((right, count))
|
||||||
|
accepted += 1
|
||||||
|
if accepted >= MAX_BIGRAMS * 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
used = 0
|
||||||
|
for left, pairs in grouped.items():
|
||||||
|
pairs.sort(key=lambda item: item[1], reverse=True)
|
||||||
|
nxt = [word for word, _ in pairs[:8]]
|
||||||
|
if not nxt:
|
||||||
|
continue
|
||||||
|
result[left] = nxt
|
||||||
|
used += len(nxt)
|
||||||
|
if used >= MAX_BIGRAMS:
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def merge_fallback(bigrams: dict[str, list[str]]) -> dict[str, list[str]]:
|
||||||
|
merged = dict(bigrams)
|
||||||
|
for left, rights in FALLBACK_BIGRAMS:
|
||||||
|
existing = merged.get(left, [])
|
||||||
|
seen = set(existing)
|
||||||
|
for word in rights:
|
||||||
|
if word not in seen:
|
||||||
|
existing.append(word)
|
||||||
|
seen.add(word)
|
||||||
|
merged[left] = existing[:10]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def write_unigrams(path: Path, unigrams: dict[str, int]) -> None:
|
||||||
|
rows = sorted(unigrams.items(), key=lambda item: (-item[1], item[0]))
|
||||||
|
path.write_text("".join(f"{word}\t{freq}\n" for word, freq in rows), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def write_bigrams(path: Path, bigrams: dict[str, list[str]]) -> None:
|
||||||
|
rows = sorted(bigrams.items(), key=lambda item: item[0])
|
||||||
|
path.write_text(
|
||||||
|
"".join(f"{left}\t{' '.join(rights)}\n" for left, rights in rows),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# mmap binary (`english_lexicon.bin`), little-endian. Layout:
|
||||||
|
# 64-byte header, then unigram records, freq-rank indices, 26 initial
|
||||||
|
# ranges, bigram groups, packed next-word indices, ASCII string pool.
|
||||||
|
# The keyboard maps this file; it must not parse TSV into Swift dictionaries.
|
||||||
|
BIN_MAGIC = b"OSGENG01"
|
||||||
|
BIN_VERSION = 1
|
||||||
|
BIN_HEADER_SIZE = 64
|
||||||
|
BIN_INITIAL_COUNT = 26
|
||||||
|
|
||||||
|
|
||||||
|
def _align4(offset: int) -> int:
|
||||||
|
return (offset + 3) & ~3
|
||||||
|
|
||||||
|
|
||||||
|
def write_binary(
|
||||||
|
path: Path,
|
||||||
|
unigrams: dict[str, int],
|
||||||
|
bigrams: dict[str, list[str]],
|
||||||
|
) -> None:
|
||||||
|
words = sorted(unigrams.keys())
|
||||||
|
index_of = {word: index for index, word in enumerate(words)}
|
||||||
|
|
||||||
|
pool = bytearray()
|
||||||
|
records: list[tuple[int, int, int]] = []
|
||||||
|
for word in words:
|
||||||
|
encoded = word.encode("ascii")
|
||||||
|
if len(encoded) > 255:
|
||||||
|
continue
|
||||||
|
freq = min(int(unigrams[word]), 65_535)
|
||||||
|
records.append((len(pool), len(encoded), freq))
|
||||||
|
pool.extend(encoded)
|
||||||
|
|
||||||
|
initials = [(0, 0)] * BIN_INITIAL_COUNT
|
||||||
|
cursor = 0
|
||||||
|
while cursor < len(words):
|
||||||
|
first = words[cursor][0]
|
||||||
|
if "a" <= first <= "z":
|
||||||
|
start = cursor
|
||||||
|
while cursor < len(words) and words[cursor][0] == first:
|
||||||
|
cursor += 1
|
||||||
|
initials[ord(first) - ord("a")] = (start, cursor - start)
|
||||||
|
else:
|
||||||
|
cursor += 1
|
||||||
|
|
||||||
|
freq_order = sorted(
|
||||||
|
range(len(words)),
|
||||||
|
key=lambda index: (-unigrams[words[index]], words[index]),
|
||||||
|
)
|
||||||
|
|
||||||
|
groups: list[tuple[int, int, int]] = []
|
||||||
|
next_indices: list[int] = []
|
||||||
|
for left in sorted(bigrams.keys()):
|
||||||
|
prev_index = index_of.get(left)
|
||||||
|
if prev_index is None:
|
||||||
|
continue
|
||||||
|
rights = [index_of[word] for word in bigrams[left] if word in index_of]
|
||||||
|
if not rights:
|
||||||
|
continue
|
||||||
|
groups.append((prev_index, len(rights), len(next_indices)))
|
||||||
|
next_indices.extend(rights)
|
||||||
|
|
||||||
|
unigram_offset = _align4(BIN_HEADER_SIZE)
|
||||||
|
freq_offset = _align4(unigram_offset + len(records) * 8)
|
||||||
|
initial_offset = _align4(freq_offset + len(freq_order) * 2)
|
||||||
|
bigram_index_offset = _align4(initial_offset + BIN_INITIAL_COUNT * 4)
|
||||||
|
bigram_next_offset = _align4(bigram_index_offset + len(groups) * 8)
|
||||||
|
pool_offset = _align4(bigram_next_offset + len(next_indices) * 2)
|
||||||
|
total = pool_offset + len(pool)
|
||||||
|
|
||||||
|
blob = bytearray(total)
|
||||||
|
struct.pack_into(
|
||||||
|
"<8s14I",
|
||||||
|
blob,
|
||||||
|
0,
|
||||||
|
BIN_MAGIC,
|
||||||
|
BIN_VERSION,
|
||||||
|
len(records),
|
||||||
|
len(groups),
|
||||||
|
pool_offset,
|
||||||
|
len(pool),
|
||||||
|
unigram_offset,
|
||||||
|
freq_offset,
|
||||||
|
initial_offset,
|
||||||
|
bigram_index_offset,
|
||||||
|
bigram_next_offset,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
for index, (pool_off, length, freq) in enumerate(records):
|
||||||
|
struct.pack_into(
|
||||||
|
"<IBBH",
|
||||||
|
blob,
|
||||||
|
unigram_offset + index * 8,
|
||||||
|
pool_off,
|
||||||
|
length,
|
||||||
|
0,
|
||||||
|
freq,
|
||||||
|
)
|
||||||
|
for index, word_index in enumerate(freq_order):
|
||||||
|
struct.pack_into("<H", blob, freq_offset + index * 2, word_index)
|
||||||
|
for letter, (start, count) in enumerate(initials):
|
||||||
|
struct.pack_into("<HH", blob, initial_offset + letter * 4, start, count)
|
||||||
|
for index, (prev_index, count, first_next) in enumerate(groups):
|
||||||
|
struct.pack_into(
|
||||||
|
"<HHI",
|
||||||
|
blob,
|
||||||
|
bigram_index_offset + index * 8,
|
||||||
|
prev_index,
|
||||||
|
count,
|
||||||
|
first_next,
|
||||||
|
)
|
||||||
|
for index, word_index in enumerate(next_indices):
|
||||||
|
struct.pack_into("<H", blob, bigram_next_offset + index * 2, word_index)
|
||||||
|
blob[pool_offset : pool_offset + len(pool)] = pool
|
||||||
|
path.write_bytes(blob)
|
||||||
|
|
||||||
|
|
||||||
|
def read_unigrams_tsv(path: Path) -> dict[str, int]:
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
word, freq_s = line.split("\t", 1)
|
||||||
|
result[word.lower()] = int(freq_s)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read_bigrams_tsv(path: Path) -> dict[str, list[str]]:
|
||||||
|
result: dict[str, list[str]] = {}
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
left, rights = line.split("\t", 1)
|
||||||
|
result[left.lower()] = [word.lower() for word in rights.split() if word]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def emit_outputs(unigrams: dict[str, int], bigrams: dict[str, list[str]]) -> None:
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_unigrams(OUT_DIR / "english_lexicon.tsv", unigrams)
|
||||||
|
write_bigrams(OUT_DIR / "english_bigrams.tsv", bigrams)
|
||||||
|
write_binary(OUT_DIR / "english_lexicon.bin", unigrams, bigrams)
|
||||||
|
print(f"Wrote {OUT_DIR / 'english_lexicon.tsv'}", file=sys.stderr)
|
||||||
|
print(f"Wrote {OUT_DIR / 'english_bigrams.tsv'}", file=sys.stderr)
|
||||||
|
print(
|
||||||
|
f"Wrote {OUT_DIR / 'english_lexicon.bin'} "
|
||||||
|
f"({(OUT_DIR / 'english_lexicon.bin').stat().st_size} bytes)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--from-tsv",
|
||||||
|
action="store_true",
|
||||||
|
help="Compile english_lexicon.bin from existing TSV files (no network).",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if args.from_tsv:
|
||||||
|
unigram_path = OUT_DIR / "english_lexicon.tsv"
|
||||||
|
bigram_path = OUT_DIR / "english_bigrams.tsv"
|
||||||
|
if not unigram_path.is_file() or not bigram_path.is_file():
|
||||||
|
print("Missing english_lexicon.tsv / english_bigrams.tsv", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
unigrams = read_unigrams_tsv(unigram_path)
|
||||||
|
bigrams = read_bigrams_tsv(bigram_path)
|
||||||
|
write_binary(OUT_DIR / "english_lexicon.bin", unigrams, bigrams)
|
||||||
|
print(
|
||||||
|
f"Wrote {OUT_DIR / 'english_lexicon.bin'} "
|
||||||
|
f"({(OUT_DIR / 'english_lexicon.bin').stat().st_size} bytes) "
|
||||||
|
f"from {len(unigrams)} unigrams / {len(bigrams)} bigram keys",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"Fetching unigrams from {UNIGRAM_URL}", file=sys.stderr)
|
||||||
|
unigram_lines = fetch_lines(UNIGRAM_URL, max_lines=200_000)
|
||||||
|
unigrams = build_unigrams(unigram_lines)
|
||||||
|
print(f"Kept {len(unigrams)} unigrams", file=sys.stderr)
|
||||||
|
|
||||||
|
bigrams: dict[str, list[str]] = {}
|
||||||
|
try:
|
||||||
|
print(f"Fetching bigrams from {BIGRAM_URL}", file=sys.stderr)
|
||||||
|
bigram_lines = fetch_lines(BIGRAM_URL, max_lines=BIGRAM_SCAN_LIMIT)
|
||||||
|
bigrams = build_bigrams(bigram_lines, unigrams)
|
||||||
|
print(f"Kept {sum(len(v) for v in bigrams.values())} bigram edges", file=sys.stderr)
|
||||||
|
except Exception as exc: # noqa: BLE001 — fallback is intentional
|
||||||
|
print(f"Bigram download failed ({exc}); using fallback collocations", file=sys.stderr)
|
||||||
|
|
||||||
|
bigrams = merge_fallback(bigrams)
|
||||||
|
emit_outputs(unigrams, bigrams)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
"platform": "ios",
|
"platform": "ios",
|
||||||
"tests": [
|
"tests": [
|
||||||
"OSGKeyboardExtTests/EnglishTypingTests",
|
"OSGKeyboardExtTests/EnglishTypingTests",
|
||||||
|
"OSGKeyboardTests/EnglishTypingOnDeviceTests",
|
||||||
"OSGKeyboardExtTests/CandidatePanelExpandTests",
|
"OSGKeyboardExtTests/CandidatePanelExpandTests",
|
||||||
"OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests",
|
"OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests",
|
||||||
"OSGKeyboardExtTests/KeyboardStateTests",
|
"OSGKeyboardExtTests/KeyboardStateTests",
|
||||||
@@ -129,6 +130,7 @@
|
|||||||
"OSGKeyboardExtTests/PinyinNextKeyResolverTests",
|
"OSGKeyboardExtTests/PinyinNextKeyResolverTests",
|
||||||
"OSGKeyboardExtTests/RimeSchemaGeneratorTests",
|
"OSGKeyboardExtTests/RimeSchemaGeneratorTests",
|
||||||
"OSGKeyboardExtTests/RimePersonalDictionaryExporterTests",
|
"OSGKeyboardExtTests/RimePersonalDictionaryExporterTests",
|
||||||
|
"OSGKeyboardExtTests/TypingTouchTrackerTests",
|
||||||
"OSGKeyboardTests/CursorNavigationTests",
|
"OSGKeyboardTests/CursorNavigationTests",
|
||||||
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
|
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
|
||||||
"OSGKeyboardTests/AIHintPoolTests",
|
"OSGKeyboardTests/AIHintPoolTests",
|
||||||
@@ -138,6 +140,7 @@
|
|||||||
"OSGKeyboardTests/AIUserSkillStoreTests",
|
"OSGKeyboardTests/AIUserSkillStoreTests",
|
||||||
"OSGKeyboardTests/AIEventExtractionTests",
|
"OSGKeyboardTests/AIEventExtractionTests",
|
||||||
"OSGKeyboardTests/AIAddressExtractionTests",
|
"OSGKeyboardTests/AIAddressExtractionTests",
|
||||||
|
"OSGKeyboardTests/AINoteExportTests",
|
||||||
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
||||||
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
||||||
]
|
]
|
||||||
|
|||||||
Vendored
+10
@@ -13,3 +13,13 @@ Before `xcodegen generate` or opening the project, run:
|
|||||||
This clones the package (if missing) and applies OSG's `StreamingConfig.context` patch for vocabulary prompts.
|
This clones the package (if missing) and applies OSG's `StreamingConfig.context` patch for vocabulary prompts.
|
||||||
|
|
||||||
The clone lives at `ThirdParty/mlx-audio-swift/` (gitignored). SPM resolves mlx-audio-swift's own dependencies on first Mac build.
|
The clone lives at `ThirdParty/mlx-audio-swift/` (gitignored). SPM resolves mlx-audio-swift's own dependencies on first Mac build.
|
||||||
|
|
||||||
|
The resolved Mac speech stack also includes:
|
||||||
|
|
||||||
|
- `mlx-swift` and `mlx-swift-lm` (MIT)
|
||||||
|
- `swift-transformers` and `swift-huggingface` (Apache-2.0)
|
||||||
|
- optional Qwen3-ASR 0.6B / 1.7B MLX 4-bit model downloads
|
||||||
|
(Apache-2.0)
|
||||||
|
|
||||||
|
See `NOTICE-TYPING.md` and the in-app Third-Party Licenses screen for pinned
|
||||||
|
versions, purposes, upstream links, and license texts.
|
||||||
|
|||||||
+32
-22
@@ -1,4 +1,4 @@
|
|||||||
# App Store Connect — OSGKeyboard 1.7.5 (build 66)
|
# App Store Connect — OSGKeyboard 1.8.0 (build 72)
|
||||||
|
|
||||||
> Current metadata baseline for the iOS/iPadOS App Store build. Version and build
|
> Current metadata baseline for the iOS/iPadOS App Store build. Version and build
|
||||||
> numbers come from `project.yml`. The repository also contains a separate
|
> numbers come from `project.yml`. The repository also contains a separate
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
| App name | `OSGKeyboard` | ≤ 30 characters |
|
| App name | `OSGKeyboard` | ≤ 30 characters |
|
||||||
| Subtitle | `Voice input, everywhere` | ≤ 30 characters |
|
| Subtitle | `Voice input, everywhere` | ≤ 30 characters |
|
||||||
| Bundle ID | `com.osgkeyboard.ios` | iOS host target |
|
| Bundle ID | `com.osgkeyboard.ios` | iOS host target |
|
||||||
| Version / build | `1.7.5` / `66` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` |
|
| Version / build | `1.8.0` / `72` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` |
|
||||||
| Minimum system | iOS/iPadOS 26 | iPhone and iPad |
|
| Minimum system | iOS/iPadOS 26 | iPhone and iPad |
|
||||||
| Primary locale | `en-US` | Simplified Chinese is also bundled |
|
| Primary locale | `en-US` | Simplified Chinese is also bundled |
|
||||||
| Primary category | Utilities | |
|
| Primary category | Utilities | |
|
||||||
@@ -101,25 +101,31 @@ Voice input anywhere, with on-device recognition by default. Add your own AI key
|
|||||||
keyboard,voice,dictation,speech,transcribe,AI,pinyin,Chinese,English,polish,typing,productivity
|
keyboard,voice,dictation,speech,transcribe,AI,pinyin,Chinese,English,polish,typing,productivity
|
||||||
```
|
```
|
||||||
|
|
||||||
## What's new in 1.7.5
|
## What's new in 1.8.0
|
||||||
|
|
||||||
```text
|
```text
|
||||||
NEW
|
NEW
|
||||||
• Optional clipboard history (latest 15 plain-text items) with a top-bar
|
• Skills center with Reply, Summarize, Translate, custom skills, and
|
||||||
panel and suggestion strip; AI idle hints can use a fresh copy for ~30s.
|
exports to Reminders, Calendar, Notes, and Maps.
|
||||||
• AI mode empty state rotates one-line suggestions; tap sends the card
|
• English QuickType-style suggestions with smarter system, contact,
|
||||||
prompt without using the mic.
|
text-replacement, and neighbor-key corrections.
|
||||||
• Spoken clipboard requests in AI mode attach stored clipboard text when
|
• Improved Pinyin abbreviations and on-device typing-habit learning.
|
||||||
you name the clipboard out loud.
|
• Overlapping key presses, double-space period, contextual Return labels,
|
||||||
• Settings → AI Agent adds Response length (Short / Medium / Detailed).
|
and a full-width iPad keyboard with editing controls.
|
||||||
• AI answers stream into the keyboard as the model writes.
|
• AI keyboard answers stream as they are generated and can use provider
|
||||||
|
web search for current information.
|
||||||
|
• Optional on-device clipboard history and one-tap clipboard skills.
|
||||||
|
|
||||||
CHANGED
|
CHANGED
|
||||||
• History and Personal dictionary move from the tab bar onto Home preview
|
• Hold the microphone to edit the last verified input, then preview,
|
||||||
cards; engine status scrolls with the page.
|
replace, or append the result.
|
||||||
• Translation control sits beside the mic (mirrored with Undo); the former
|
• Undo now covers dictation, AI answers, edits, and clipboard pastes.
|
||||||
top-bar slot is the Clipboard button.
|
• Home cards and navigation make History, Personal Dictionary, Skills,
|
||||||
• Undo covers clipboard pastes as well as dictation, AI answers, and edits.
|
Styles, and Settings easier to find.
|
||||||
|
|
||||||
|
FIXED
|
||||||
|
• Improved Chinese input setup, Universal Clipboard responsiveness,
|
||||||
|
keyboard switching, recording cancellation, and AI session recovery.
|
||||||
```
|
```
|
||||||
|
|
||||||
## App Review information
|
## App Review information
|
||||||
@@ -183,10 +189,14 @@ credential.
|
|||||||
- Linked to the user: Yes
|
- Linked to the user: Yes
|
||||||
- Used for tracking: No
|
- Used for tracking: No
|
||||||
|
|
||||||
This covers transcripts, polish/translation text, AI questions, optional
|
This covers transcripts and nearby cursor context used for polish/translation,
|
||||||
provider search requests, dictionary terms included in provider prompts, and
|
AI questions and skill prompts, optional provider search requests, dictionary
|
||||||
clipboard text only after the user inserts it and actively invokes polish.
|
terms included in provider prompts, and clipboard text only after the user
|
||||||
Device-local clipboard history by itself is not collected by the developer.
|
actively invokes a clipboard skill, names the clipboard in AI mode, or inserts
|
||||||
|
it and requests polish. Skill results may also be handed on-device to an Apple
|
||||||
|
Shortcut, while navigation addresses may be opened in the selected map app.
|
||||||
|
Device-local clipboard history and typing-learning data by themselves are not
|
||||||
|
collected by the developer.
|
||||||
|
|
||||||
### Do not select
|
### Do not select
|
||||||
|
|
||||||
@@ -201,10 +211,10 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added.
|
|||||||
|
|
||||||
## Submission checklist
|
## Submission checklist
|
||||||
|
|
||||||
- [ ] Confirm `project.yml` still reads version 1.7.5 / build 66
|
- [ ] Confirm `project.yml` still reads version 1.8.0 / build 72
|
||||||
- [ ] Open the existing Xcode project (do not regenerate unless needed)
|
- [ ] Open the existing Xcode project (do not regenerate unless needed)
|
||||||
- [ ] Run the release build and test suites on macOS with Xcode 26
|
- [ ] Run the release build and test suites on macOS with Xcode 26
|
||||||
- [ ] Replace screenshots with captures from the submitted build
|
- [ ] Replace screenshots with captures from the submitted build
|
||||||
- [ ] Verify the privacy answers against the submitted provider features
|
- [ ] Verify the privacy answers against the submitted provider features
|
||||||
- [ ] Confirm the tip product remains optional and unlocks no feature
|
- [ ] Confirm the tip product remains optional and unlocks no feature
|
||||||
- [ ] Upload, select build 66, add review notes, and submit
|
- [ ] Upload, select build 72, add review notes, and submit
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ LocalASRBiasAdapter.adapt(
|
|||||||
| `hardHotwords` | 为具备 hard-hotword capability 的 backend 保留;当前 Qwen3 MLX 不使用 |
|
| `hardHotwords` | 为具备 hard-hotword capability 的 backend 保留;当前 Qwen3 MLX 不使用 |
|
||||||
|
|
||||||
个人词优先;`BuiltinLexiconIndex` 从 `phrases.tsv` 选择 `weight >= 4` 的 Top-N,
|
个人词优先;`BuiltinLexiconIndex` 从 `phrases.tsv` 选择 `weight >= 4` 的 Top-N,
|
||||||
代码编辑器/终端前台场景优先 `computer_terms`。默认最多考虑 300 个内置 ASR 词,
|
代码编辑器/终端前台场景优先项目维护的 `ai_tech_seed`。默认最多考虑 300 个内置 ASR 词,
|
||||||
Qwen3 soft prompt 最长 800 字符,润色补充最多 40 个内置词。
|
Qwen3 soft prompt 最长 800 字符,润色补充最多 40 个内置词。
|
||||||
|
|
||||||
### 2.3 Apple Speech fallback
|
### 2.3 Apple Speech fallback
|
||||||
|
|||||||
@@ -176,6 +176,135 @@
|
|||||||
<p class="empty" id="empty-zh">暂无与当前版本匹配的更新说明。</p>
|
<p class="empty" id="empty-zh">暂无与当前版本匹配的更新说明。</p>
|
||||||
<p class="empty" id="empty-en">No release notes match this app version.</p>
|
<p class="empty" id="empty-en">No release notes match this app version.</p>
|
||||||
|
|
||||||
|
<!-- ========== 1.8.0 ========== -->
|
||||||
|
<article class="version-block" data-version="1.8.0">
|
||||||
|
<div class="version-chip">1.8.0</div>
|
||||||
|
|
||||||
|
<div class="lang-block" data-lang="zh">
|
||||||
|
<section class="section">
|
||||||
|
<h2>新功能</h2>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<p class="group-title">AI 与语音助手</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>AI 键盘模式</strong>:新增独立 AI 输入面,可通过语音提问、查看回答,并在确认后插入或发送</li>
|
||||||
|
<li><strong>AI 联网搜索</strong>:支持服务商侧联网搜索,可回答天气、新闻等需要最新资料的问题,搜索失败时自动重试</li>
|
||||||
|
<li><strong>AI 流式回答</strong>:模型生成内容时,回答会实时显示在键盘中,无需等待整段完成</li>
|
||||||
|
<li><strong>AI 空闲建议</strong>:AI 面板会轮播常用提问和热点建议,点选后可直接生成回答</li>
|
||||||
|
<li><strong>回复篇幅设置</strong>:新增简短、中等、详细三档 AI 回复长度,并支持随设置同步</li>
|
||||||
|
<li><strong>编辑上次输入</strong>:长按麦克风即可口述修改要求,预览后替换上次输入或追加新内容</li>
|
||||||
|
<li><strong>问句保护</strong>:润色会保留用户原本的提问语气,不再把「你能听到吗」等问句改写成回答</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<p class="group-title">剪贴板与技能</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>剪贴板历史</strong>:可选保存最近 15 条纯文本,支持查看、插入和单独清空,默认关闭且仅保存在本机</li>
|
||||||
|
<li><strong>剪贴板建议条</strong>:开启后,最新复制内容会显示在键盘上方,可一键插入</li>
|
||||||
|
<li><strong>粘贴权限引导</strong>:剪贴板设置页可直接打开系统设置,方便将「从其他 App 粘贴」设为允许</li>
|
||||||
|
<li><strong>口述剪贴板请求</strong>:在 AI 模式中说出「回复剪贴板」或「翻译剪贴板」,即可处理已保存的复制内容</li>
|
||||||
|
<li><strong>剪贴板快捷技能</strong>:复制后可直接选择回复、总结或翻译,无需先粘贴到输入框</li>
|
||||||
|
<li><strong>技能中心</strong>:首页新增技能页,可管理最多 8 个键盘技能,并通过长按拖动调整顺序</li>
|
||||||
|
<li><strong>自定义技能</strong>:可自定义名称、图标和提示词,也可连接自己的 iCloud 快捷指令</li>
|
||||||
|
<li><strong>提取待办</strong>:可从复制内容识别待办事项,并通过快捷指令写入提醒事项</li>
|
||||||
|
<li><strong>提取日程</strong>:可识别日期、时间、地点和结束时间,并写入系统日历</li>
|
||||||
|
<li><strong>存入备忘录</strong>:可根据复制内容生成简短标题,并将原文保存到 Apple 备忘录</li>
|
||||||
|
<li><strong>地址导航</strong>:可识别地址或起点终点,并直接打开高德、百度或 Apple 地图开始驾车导航</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>中英文输入</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>英文 QuickType 候选栏</strong>:输入英文时同时显示原词、纠错和补全,提交后自动清空</li>
|
||||||
|
<li><strong>英文词汇覆盖扩充</strong>:加入约 4 万词的离线词表,并结合系统词库、通讯录名称和文本替换提供建议</li>
|
||||||
|
<li><strong>英文邻键纠错</strong>:按错相邻按键时,会优先推荐更符合真实键盘位置的单词</li>
|
||||||
|
<li><strong>英文自动更正更克制</strong>:减少对专名、短词和全大写词的误改;拒绝纠错后会学习保留原词</li>
|
||||||
|
<li><strong>拼音简拼改进</strong>:优化 <code>wom</code> 等混合简拼排序,并支持 <code>zh</code>、<code>ch</code>、<code>sh</code> 两键简拼</li>
|
||||||
|
<li><strong>输入习惯学习</strong>:中文候选频率和英文选择偏好会保存在本机,设置中可单独清除,不影响个性词库</li>
|
||||||
|
<li><strong>叠指连打</strong>:支持上一键尚未抬起时按下下一键,并按按下顺序输出文字</li>
|
||||||
|
<li><strong>双空格句号</strong>:英文单词后连续按两次空格,会自动转换为句号和空格,并开启句首大写</li>
|
||||||
|
<li><strong>回车键场景文案</strong>:回车键会根据当前输入框显示前往、搜索、发送、完成、下一项等系统文案</li>
|
||||||
|
<li><strong>iPad 键盘适配</strong>:键盘横竖屏均铺满可用宽度,底行加入逗号、句号和系统地球键,并提供撤销、重做、复制和剪切</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>界面、稳定性与隐私</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>统一撤销上次输入</strong>:听写、AI 回答、编辑结果和剪贴板粘贴均可一次撤销</li>
|
||||||
|
<li><strong>首页与导航调整</strong>:历史记录和个性词库改为首页资料卡;技能、风格和设置入口重新整理</li>
|
||||||
|
<li><strong>输入稳定性改进</strong>:修复中文输入初始化失败、跨设备剪贴板导致键盘卡住、切换键盘崩溃、录音取消不及时及 AI 会话结束后麦克风不可用等问题</li>
|
||||||
|
<li><strong>数据控制更新</strong>:默认使用设备端语音识别;AI 使用用户自己的 API Key;剪贴板功能默认关闭,内容仅在用户主动触发时交给所配置的服务商</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lang-block" data-lang="en">
|
||||||
|
<section class="section">
|
||||||
|
<h2>New Features</h2>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<p class="group-title">AI and voice assistant</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>AI keyboard mode</strong>: ask by voice, review the answer on the keyboard, then insert or send it when ready</li>
|
||||||
|
<li><strong>AI web search</strong>: supported providers can search for current information such as weather and news, with automatic fallback when search fails</li>
|
||||||
|
<li><strong>Streaming AI answers</strong>: answers appear on the keyboard as the model writes instead of waiting for the full response</li>
|
||||||
|
<li><strong>AI idle suggestions</strong>: the AI panel rotates useful prompts and current topics that can be sent with one tap</li>
|
||||||
|
<li><strong>Response length</strong>: choose Short, Medium, or Detailed AI answers and sync the preference with settings</li>
|
||||||
|
<li><strong>Edit last input</strong>: hold the microphone, describe the change, preview it, then replace the last input or append the result</li>
|
||||||
|
<li><strong>Question protection</strong>: polish preserves the user's question instead of rewriting prompts such as “Can you hear me?” into answers</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<p class="group-title">Clipboard and Skills</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Clipboard history</strong>: optionally keep the latest 15 plain-text copies on this device, with separate view, insert, and clear controls</li>
|
||||||
|
<li><strong>Clipboard suggestion strip</strong>: show the newest copy above the keyboard for one-tap insertion</li>
|
||||||
|
<li><strong>Paste permission guide</strong>: open system Settings directly to set Paste from Other Apps to Allow</li>
|
||||||
|
<li><strong>Spoken clipboard requests</strong>: say “reply to the clipboard” or “translate my clipboard” in AI mode to use the saved copy</li>
|
||||||
|
<li><strong>Clipboard quick skills</strong>: choose Reply, Summarize, or Translate after copying without first pasting into a field</li>
|
||||||
|
<li><strong>Skills center</strong>: manage up to eight keyboard skills and reorder them with a long-press drag</li>
|
||||||
|
<li><strong>Custom skills</strong>: create a name, icon, and prompt, with an optional iCloud Shortcut connection</li>
|
||||||
|
<li><strong>Extract to-dos</strong>: recognize tasks in copied text and add them to Reminders through a companion Shortcut</li>
|
||||||
|
<li><strong>Extract events</strong>: recognize dates, times, locations, and end times, then add them to Calendar</li>
|
||||||
|
<li><strong>Save to Notes</strong>: generate a short title and save the original copied text to Apple Notes</li>
|
||||||
|
<li><strong>Navigate to an address</strong>: recognize an address or route and open driving directions in Amap, Baidu Maps, or Apple Maps</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Chinese and English Typing</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>English QuickType bar</strong>: see the typed word, correction, and completion together; the bar clears after commit</li>
|
||||||
|
<li><strong>Broader English vocabulary</strong>: an offline list of about 40,000 words works with the system lexicon, contact names, and text replacements</li>
|
||||||
|
<li><strong>Neighbor-key correction</strong>: mistypes on nearby keys prioritize words that match the physical keyboard layout</li>
|
||||||
|
<li><strong>More conservative autocorrect</strong>: fewer unwanted changes to names, short words, and all-caps text; rejecting a correction teaches the original</li>
|
||||||
|
<li><strong>Pinyin abbreviation improvements</strong>: mixed forms such as <code>wom</code> rank better, with two-key <code>zh</code>, <code>ch</code>, and <code>sh</code> abbreviations</li>
|
||||||
|
<li><strong>Typing habit learning</strong>: Chinese candidate frequency and English boosts stay on device and can be cleared without deleting the personal dictionary</li>
|
||||||
|
<li><strong>Overlapping key presses</strong>: press the next key before lifting the previous finger while characters still commit in press order</li>
|
||||||
|
<li><strong>Double-space period</strong>: a second Space after an English word becomes a period and space, then enables sentence capitalization</li>
|
||||||
|
<li><strong>Contextual Return labels</strong>: the action key shows Go, Search, Send, Done, Next, and other system labels for the current field</li>
|
||||||
|
<li><strong>iPad keyboard layout</strong>: fills portrait and landscape width, adds comma, period, and the system globe key, plus undo, redo, copy, and cut</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Interface, Stability & Privacy</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Unified undo</strong>: roll back dictation, AI answers, edit results, and clipboard pastes in one step</li>
|
||||||
|
<li><strong>Home and navigation updates</strong>: History and Personal Dictionary become Home cards, with reorganized Skills, Styles, and Settings navigation</li>
|
||||||
|
<li><strong>Input stability</strong>: fixes Chinese setup failures, Universal Clipboard keyboard freezes, keyboard-switch crashes, delayed recording cancellation, and a stuck microphone after AI sessions</li>
|
||||||
|
<li><strong>Data controls</strong>: on-device speech recognition remains the default; AI uses your own API key; Clipboard features stay off until enabled and send text only after your action</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<!-- ========== 1.7.5 ========== -->
|
<!-- ========== 1.7.5 ========== -->
|
||||||
<article class="version-block" data-version="1.7.5">
|
<article class="version-block" data-version="1.7.5">
|
||||||
<div class="version-chip">1.7.5</div>
|
<div class="version-chip">1.7.5</div>
|
||||||
|
|||||||
+20
-13
@@ -25,19 +25,22 @@
|
|||||||
<p class="top-nav"><a href="./">← OSGKeyboard</a> · <a href="https://apps.apple.com/app/osgkeyboard/id6781553267">App Store</a></p>
|
<p class="top-nav"><a href="./">← OSGKeyboard</a> · <a href="https://apps.apple.com/app/osgkeyboard/id6781553267">App Store</a></p>
|
||||||
<p class="lang"><a href="#zh">中文</a></p>
|
<p class="lang"><a href="#zh">中文</a></p>
|
||||||
<h1>OSGKeyboard Privacy Policy</h1>
|
<h1>OSGKeyboard Privacy Policy</h1>
|
||||||
<p><strong>Last updated:</strong> August 11, 2026 · <em>v1.2</em></p>
|
<p><strong>Last updated:</strong> August 14, 2026 · <em>v1.3</em></p>
|
||||||
<p>OSGKeyboard is a custom iOS keyboard that turns your voice into text. It runs as a Custom Keyboard Extension on iOS 26 and later, and uses Apple's on-device <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> for transcription by default. An optional cloud ASR engine (explicit opt-in) uploads recordings to the provider you configure. After transcription, text may be polished or translated via a cloud LLM. This policy explains what data the app processes and how it is used.</p>
|
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> by default; macOS can use a downloaded local Qwen3-ASR model. You may instead choose a cloud recognition engine in Settings. After transcription, text may be polished, translated, or used by AI Agent features through a provider you configure. This policy explains what data the apps process and how it is used.</p>
|
||||||
|
|
||||||
<h2>What we collect</h2>
|
<h2>What we collect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Voice audio</strong> — captured only while you actively record. On the default <strong>local engine</strong>, audio is transcribed on-device with Apple's <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> and raw audio is not uploaded. If you explicitly enable the <strong>cloud engine</strong> (a confirmation dialog is shown first), your recordings are uploaded to the ASR provider you configure (e.g. OpenAI, Qwen DashScope, Zhipu) for transcription; that provider's privacy policy applies. OSGKeyboard never stores or proxies your audio on its own servers.</li>
|
<li><strong>Voice audio</strong> — captured only while you actively record. On a <strong>local engine</strong>, audio is transcribed on-device and raw audio is not uploaded. If you actively select a <strong>cloud engine</strong> in Settings, recordings are uploaded directly to the ASR provider you configure (e.g. OpenAI, Qwen DashScope, Zhipu); that provider's privacy policy applies. OSGKeyboard never stores or proxies your audio on its own servers.</li>
|
||||||
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you have configured an LLM API key. To continue naturally at the insertion point, a small amount of text immediately before and after the cursor may be included. Secure fields are never captured; cursor context is not written to logs or voice history. Without an API key, raw ASR text is inserted and no polish request is sent. Polish and optional translation use the OpenAI-compatible (or Anthropic) API you configure (e.g. OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).</li>
|
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you have configured an LLM API key. To continue naturally at the insertion point, a small amount of text immediately before and after the cursor may be included. Secure fields are never captured; cursor context is not written to logs or voice history. Without an API key, raw ASR text is inserted and no polish request is sent. Polish and optional translation use the OpenAI-compatible (or Anthropic) API you configure (e.g. OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).</li>
|
||||||
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text is sent to the same configured LLM provider. When that provider supports server-side web search, it may retrieve public web results for time-sensitive answers. Search queries and snippets are handled by that provider under its privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
|
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text is sent to the same configured LLM provider. When that provider supports server-side web search, it may retrieve public web results for time-sensitive answers. Search queries and snippets are handled by that provider under its privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
|
||||||
|
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included in that request. Opening the update page requests public content from <code>download.osglab.com</code>. As with ordinary HTTPS sites, those hosts and their infrastructure may process connection metadata such as IP address and request time.</li>
|
||||||
|
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. For Reminders, Calendar, or Notes exports, the generated result is passed on-device to the Apple Shortcut you install; OSGKeyboard does not receive the resulting item. For navigation, an address or route is placed in a URL opened by Apple Maps, Amap, or Baidu Maps, and the selected map provider's policy applies.</li>
|
||||||
<li><strong>API credentials</strong> — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults. When you enable <strong>iCloud settings sync</strong>, API keys replicate through Apple's <strong>iCloud Keychain</strong> to your other signed-in devices — not through iCloud Key-Value Store JSON.</li>
|
<li><strong>API credentials</strong> — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults. When you enable <strong>iCloud settings sync</strong>, API keys replicate through Apple's <strong>iCloud Keychain</strong> to your other signed-in devices — not through iCloud Key-Value Store JSON.</li>
|
||||||
<li><strong>App preferences</strong> — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group <code>UserDefaults</code> on your device so the main app and keyboard extension stay in sync. When iCloud settings sync is enabled, eligible preferences (excluding API keys and the device-local clipboard consent switches) may also be mirrored in your private iCloud Key-Value Store account.</li>
|
<li><strong>App preferences</strong> — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group <code>UserDefaults</code> on your device so the main app and keyboard extension stay in sync. When iCloud settings sync is enabled, eligible preferences (excluding API keys and the device-local clipboard consent switches) may also be mirrored in your private iCloud Key-Value Store account.</li>
|
||||||
<li><strong>Optional clipboard history</strong> — off by default. When enabled, the keyboard may read text copied on this device or received through Universal Clipboard; iOS cannot reliably identify which source supplied an item. Up to 15 accepted items are stored only in this device’s App Group for the host app and keyboard extension. Turning history off stops capture, disables the suggestion strip, and keeps existing items. Reset Settings also keeps them; deletion requires the separate confirmed “Clear clipboard history” action. There is no fixed expiry. Secure fields immediately clear the current suggestion, close the history panel, and hide the clipboard entry point. Conservative filters reject common OTP shapes, PEM private-key headers, long three-part JWTs, Bearer tokens, recognizable provider-key prefixes of safe lengths, and common Luhn-valid 16-digit card numbers; they do not claim to detect every password. Normal order numbers, years, and short labeled account numbers remain eligible. Rejected text can still be pasted once through iOS; it is simply not recorded. Clipboard text is never sent to AI on its own. It leaves the device only when you deliberately ask for it — tapping a clipboard suggestion in AI mode, saying “clipboard” in an AI question, or invoking polish after inserting an item — and it is then sent to your configured provider as separate quoted data, never as instructions.</li>
|
<li><strong>Optional clipboard history</strong> — off by default. When enabled, the keyboard may read text copied on this device or received through Universal Clipboard; iOS cannot reliably identify which source supplied an item. Up to 15 accepted items are stored only in this device’s App Group for the host app and keyboard extension. Turning history off stops capture, disables the suggestion strip, and keeps existing items. Reset Settings also keeps them; deletion requires the separate confirmed “Clear clipboard history” action. There is no fixed expiry. Secure fields immediately clear the current suggestion, close the history panel, and hide the clipboard entry point. Conservative filters reject common OTP shapes, PEM private-key headers, long three-part JWTs, Bearer tokens, recognizable provider-key prefixes of safe lengths, and common Luhn-valid 16-digit card numbers; they do not claim to detect every password. Normal order numbers, years, and short labeled account numbers remain eligible. Rejected text can still be pasted once through iOS; it is simply not recorded. Clipboard text is never sent to AI on its own. It leaves the device only when you deliberately ask for it — tapping a clipboard suggestion in AI mode, saying “clipboard” in an AI question, or invoking polish after inserting an item — and it is then sent to your configured provider as separate quoted data, never as instructions.</li>
|
||||||
<li><strong>Personal dictionary</strong> — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved. Optional <strong>iCloud dictionary sync</strong> mirrors your dictionary through your private iCloud Key-Value Store; OSGKeyboard does not operate a separate dictionary server.</li>
|
<li><strong>Personal dictionary</strong> — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved. Optional <strong>iCloud dictionary sync</strong> mirrors your dictionary through your private iCloud Key-Value Store; OSGKeyboard does not operate a separate dictionary server.</li>
|
||||||
<li><strong>On-device typing learning</strong> — the Chinese keyboard stores selected words and candidate frequencies in the App Group on your device so future candidates improve. This user dictionary is not uploaded by OSGKeyboard.</li>
|
<li><strong>On-device typing learning</strong> — Chinese candidate frequencies and English suggestion choices are stored in the local App Group. You can clear this learning separately in typing settings without deleting the personal dictionary. OSGKeyboard does not upload it.</li>
|
||||||
|
<li><strong>macOS local speech models</strong> — when you choose a Qwen3-ASR local model, the Mac app downloads model files from Hugging Face and stores them in its local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
|
||||||
<li><strong>Voice history</strong> — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.</li>
|
<li><strong>Voice history</strong> — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.</li>
|
||||||
<li><strong>Usage statistics</strong> — cumulative dictation time, dictation characters, and translation characters are computed for the home screen stats card. When iCloud settings sync is enabled, per-device totals may merge through your private iCloud Key-Value Store.</li>
|
<li><strong>Usage statistics</strong> — cumulative dictation time, dictation characters, and translation characters are computed for the home screen stats card. When iCloud settings sync is enabled, per-device totals may merge through your private iCloud Key-Value Store.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
||||||
<li>We do <strong>not</strong> operate analytics, crash reporting, or advertising SDKs.</li>
|
<li>We do <strong>not</strong> operate analytics, crash reporting, or advertising SDKs.</li>
|
||||||
<li>We do <strong>not</strong> upload raw audio anywhere on the default local engine. The only exception is the optional cloud ASR engine: if you explicitly enable it (a confirmation dialog is shown first), recordings go to the ASR provider <em>you</em> configure — never to servers of ours.</li>
|
<li>We do <strong>not</strong> upload raw audio on a local engine. If you actively select a cloud ASR engine in Settings, recordings go to the ASR provider <em>you</em> configure — never to servers of ours.</li>
|
||||||
<li>We do <strong>not</strong> sell personal data.</li>
|
<li>We do <strong>not</strong> sell personal data.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -62,10 +65,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Third parties</h2>
|
<h2>Third parties</h2>
|
||||||
<p>After ASR, transcribed text may be sent for polish and optional translation when you configure an LLM API key. AI-mode questions may also be sent to that provider, which may perform server-side web search. That provider's privacy policy applies. OSGKeyboard does not proxy, log, or aggregate your requests.</p>
|
<p>Cloud ASR and LLM requests go directly to the provider you configure. AI skills may also hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and update pages are served by <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each third party's privacy policy applies; OSGKeyboard does not proxy, log, or aggregate your provider requests.</p>
|
||||||
|
|
||||||
<h2>Data retention</h2>
|
<h2>Data retention</h2>
|
||||||
<p>Settings remain on your device until you delete the app or reset settings. When iCloud settings sync is enabled, API keys replicate through iCloud Keychain and eligible preferences, statistics, and voice history may sync through your private iCloud account. Transcription results are passed to the host app you are typing in and are not stored long-term by OSGKeyboard. Voice history is capped at 300 entries; you can clear it from the History tab or by resetting settings. Clipboard history remains device-local, is capped at 15 entries, has no fixed expiry, and is retained when capture is turned off or settings are reset; use the separate confirmed clear action to delete it.</p>
|
<p>Settings remain on your device until you delete the app or reset settings. When iCloud settings sync is enabled, API keys replicate through iCloud Keychain and eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account. Voice history is capped at 300 entries and can be cleared from History or by resetting settings. Clipboard history remains device-local, is capped at 15 entries, has no fixed expiry, and remains when capture is turned off or settings are reset; use the separate confirmed clear action to delete it. Chinese and English typing-learning data stays local and can be cleared from typing settings without deleting the personal dictionary. Downloaded Mac speech models remain until you remove the model in the app or delete its local files.</p>
|
||||||
|
|
||||||
<h2>Children's privacy</h2>
|
<h2>Children's privacy</h2>
|
||||||
<p>OSGKeyboard is not directed to children under 13 and does not knowingly collect personal data from children.</p>
|
<p>OSGKeyboard is not directed to children under 13 and does not knowingly collect personal data from children.</p>
|
||||||
@@ -81,18 +84,22 @@
|
|||||||
|
|
||||||
<hr id="zh">
|
<hr id="zh">
|
||||||
<h1>OSGKeyboard 隐私政策</h1>
|
<h1>OSGKeyboard 隐私政策</h1>
|
||||||
<p><strong>更新日期:</strong>2026 年 8 月 11 日 · <em>v1.2</em></p>
|
<p><strong>更新日期:</strong>2026 年 8 月 14 日 · <em>v1.3</em></p>
|
||||||
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。它以自定义键盘扩展的形式运行,需要 iOS 26 及以上系统,默认使用 Apple 端侧的 <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> 转写;可选的云端识别引擎(需显式二次确认开启)会把录音上传到你配置的服务商。转写完成后,文字可能经云端 LLM 润色或翻译。本政策说明应用处理哪些数据及用途。</p>
|
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧的 <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code>;macOS 可使用下载到本机的 Qwen3-ASR 模型。你也可以在设置中主动选择云端识别引擎。转写后,文字可能通过你配置的服务商用于润色、翻译或 AI Agent 功能。本政策说明应用处理哪些数据及用途。</p>
|
||||||
|
|
||||||
<h2>我们处理的数据</h2>
|
<h2>我们处理的数据</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。默认<strong>本地引擎</strong>下,音频在设备端通过 <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> 转写,原始录音不会上传。若你显式开启<strong>云端引擎</strong>(会先弹出确认对话框),录音会上传到你配置的识别服务商(如 OpenAI、通义 DashScope、智谱)完成转写,适用该服务商的隐私政策。OSGKeyboard 自身绝不存储或中转你的音频。</li>
|
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用<strong>本地引擎</strong>时,音频在设备端完成转写,原始录音不会上传。若你在设置中主动选择<strong>云端引擎</strong>,录音会直接上传到你配置的识别服务商(如 OpenAI、通义 DashScope、智谱),适用该服务商的隐私政策。OSGKeyboard 自身不存储或中转音频。</li>
|
||||||
<li><strong>转写文字与光标上下文</strong> — ASR 完成后,若你已配置 LLM API Key,转写文字(非音频)可能发送润色。为了在插入点自然衔接,请求可能同时包含光标前后的少量文字。密码框绝不采集,光标上下文不会写入日志或语音历史。未填写 API Key 时直接插入原始识别结果。润色与可选翻译使用你配置的 OpenAI 兼容(或 Anthropic)API(OpenAI / DeepSeek / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。</li>
|
<li><strong>转写文字与光标上下文</strong> — ASR 完成后,若你已配置 LLM API Key,转写文字(非音频)可能发送润色。为了在插入点自然衔接,请求可能同时包含光标前后的少量文字。密码框绝不采集,光标上下文不会写入日志或语音历史。未填写 API Key 时直接插入原始识别结果。润色与可选翻译使用你配置的 OpenAI 兼容(或 Anthropic)API(OpenAI / DeepSeek / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。</li>
|
||||||
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字会发送到同一套已配置的 LLM 服务商。若该服务商支持服务端联网搜索,可能为时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字会发送到同一套已配置的 LLM 服务商。若该服务商支持服务端联网搜索,可能为时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
||||||
|
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;该请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。与普通 HTTPS 网站相同,相关主机与基础设施可能处理 IP 地址、请求时间等连接元数据。</li>
|
||||||
|
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制的文字及技能提示词才会发送到你配置的 LLM 服务商。导出提醒事项、日历或备忘录时,生成结果会在设备端交给你安装的 Apple 快捷指令;OSGKeyboard 不会收到最终创建的项目。导航时,地址或路线会写入 URL 并交给 Apple 地图、高德地图或百度地图,相应地图服务商的政策适用。</li>
|
||||||
<li><strong>API 凭证</strong> — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,<strong>不会</strong>写入 <code>UserDefaults</code>。开启<strong>iCloud 设置同步</strong>后,API 密钥经 Apple <strong>iCloud 钥匙串</strong>同步到你其他已登录设备,<strong>不会</strong>写入 iCloud 键值存储 JSON。</li>
|
<li><strong>API 凭证</strong> — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,<strong>不会</strong>写入 <code>UserDefaults</code>。开启<strong>iCloud 设置同步</strong>后,API 密钥经 Apple <strong>iCloud 钥匙串</strong>同步到你其他已登录设备,<strong>不会</strong>写入 iCloud 键值存储 JSON。</li>
|
||||||
<li><strong>应用偏好</strong> — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group <code>UserDefaults</code>,用于主 App 与键盘扩展之间的状态同步。开启 iCloud 设置同步后,可同步的偏好(不含 API 密钥与本机剪贴板许可开关)也可能镜像到你私有的 iCloud 键值存储账户。</li>
|
<li><strong>应用偏好</strong> — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group <code>UserDefaults</code>,用于主 App 与键盘扩展之间的状态同步。开启 iCloud 设置同步后,可同步的偏好(不含 API 密钥与本机剪贴板许可开关)也可能镜像到你私有的 iCloud 键值存储账户。</li>
|
||||||
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机复制或经通用剪贴板收到的文字;iOS 无法可靠判断具体来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置也会保留,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即清除当前建议、关闭历史面板并隐藏剪贴板入口。保守过滤会拒绝常见 OTP 形态、PEM 私钥头、三段较长的 JWT、Bearer Token、达到安全长度的明确服务商密钥前缀,以及常见的通过 Luhn 校验的 16 位卡号;不宣称识别所有密码。普通订单号、年份与带标签的短账号仍可进入历史。被拒绝的文本仍可经 iOS 一次性粘贴,只是不被记录。剪贴板正文不会自行发送给 AI。只有你主动要求时它才会离开设备——在 AI 模式点选剪贴板建议、在 AI 提问中明确说出「剪贴板」,或插入后主动使用润色——此时会作为单独引用的数据发送给你配置的服务商,绝不作为指令。</li>
|
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机复制或经通用剪贴板收到的文字;iOS 无法可靠判断具体来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置也会保留,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即清除当前建议、关闭历史面板并隐藏剪贴板入口。保守过滤会拒绝常见 OTP 形态、PEM 私钥头、三段较长的 JWT、Bearer Token、达到安全长度的明确服务商密钥前缀,以及常见的通过 Luhn 校验的 16 位卡号;不宣称识别所有密码。普通订单号、年份与带标签的短账号仍可进入历史。被拒绝的文本仍可经 iOS 一次性粘贴,只是不被记录。剪贴板正文不会自行发送给 AI。只有你主动要求时它才会离开设备——在 AI 模式点选剪贴板建议、在 AI 提问中明确说出「剪贴板」,或插入后主动使用润色——此时会作为单独引用的数据发送给你配置的服务商,绝不作为指令。</li>
|
||||||
<li><strong>个性词库</strong> — 你在「词库」Tab 添加的词条与别名保存在本机,润色时会写入 LLM 提示词。可选的<strong>iCloud 词库同步</strong>经私有 iCloud 键值存储在多设备间镜像;OSGKeyboard 不运营独立词库服务器。</li>
|
<li><strong>个性词库</strong> — 你在「词库」Tab 添加的词条与别名保存在本机,润色时会写入 LLM 提示词。可选的<strong>iCloud 词库同步</strong>经私有 iCloud 键值存储在多设备间镜像;OSGKeyboard 不运营独立词库服务器。</li>
|
||||||
|
<li><strong>本地输入学习</strong> — 中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除,不会因此删除个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
||||||
|
<li><strong>macOS 本地语音模型</strong> — 选择 Qwen3-ASR 本地模型后,Mac App 会从 Hugging Face 下载模型文件,并保存在本机应用支持目录,直到你主动移除。本地推理不会把录音发送到 Hugging Face 或 OSGKeyboard。</li>
|
||||||
<li><strong>语音历史</strong> — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。</li>
|
<li><strong>语音历史</strong> — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。</li>
|
||||||
<li><strong>用量统计</strong> — 首页统计卡片的累计听写时长、听写字数、翻译字数在本地计算。开启 iCloud 设置同步后,各设备分量可能经私有 iCloud 键值存储合并。</li>
|
<li><strong>用量统计</strong> — 首页统计卡片的累计听写时长、听写字数、翻译字数在本地计算。开启 iCloud 设置同步后,各设备分量可能经私有 iCloud 键值存储合并。</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -102,7 +109,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
||||||
<li>我们<strong>不会</strong>集成分析、崩溃上报或广告 SDK。</li>
|
<li>我们<strong>不会</strong>集成分析、崩溃上报或广告 SDK。</li>
|
||||||
<li>默认本地引擎下,我们<strong>不会</strong>将原始录音上传至任何服务器。唯一例外是可选的云端识别引擎:你显式开启后(会先弹出确认对话框),录音会发送到<em>你</em>配置的识别服务商——绝不会发送到我们的服务器。</li>
|
<li>使用本地引擎时,我们<strong>不会</strong>上传原始录音。若你在设置中主动选择云端 ASR,引擎会将录音发送到<em>你</em>配置的识别服务商,而不是我们的服务器。</li>
|
||||||
<li>我们<strong>不会</strong>出售个人数据。</li>
|
<li>我们<strong>不会</strong>出售个人数据。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -117,10 +124,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>第三方</h2>
|
<h2>第三方</h2>
|
||||||
<p>ASR 完成后,若你配置了 LLM API Key,转写文字可能用于润色与可选翻译;AI 模式问题也可能发送至同一服务商(含服务商侧可选联网搜索)。该服务商的隐私政策适用于相关请求。OSGKeyboard 不代理、不记录、不聚合这些请求。</p>
|
<p>云端 ASR 与 LLM 请求直接发送到你配置的服务商。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code>、<code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各第三方的隐私政策适用;OSGKeyboard 不代理、记录或聚合你的服务商请求。</p>
|
||||||
|
|
||||||
<h2>数据保留</h2>
|
<h2>数据保留</h2>
|
||||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,可同步的偏好、统计与语音历史可能经私有 iCloud 账户同步。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。语音历史上限 300 条,可随时在「历史」页清空或通过重置设置清除。剪贴板历史仅保存在本机,上限 15 条,没有固定过期时间;关闭采集或重置设置均会保留,需使用单独确认的清空操作删除。</p>
|
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,可同步的偏好、统计、词库和语音历史可能经私有 iCloud 账户同步。语音历史上限 300 条,可在「历史」页清空或通过重置设置清除。剪贴板历史仅保存在本机,上限 15 条,没有固定过期时间;关闭采集或重置设置均会保留,需使用单独确认的清空操作删除。中英文输入学习数据仅留本机,可在输入设置中单独清除且不影响个性词库。下载的 Mac 语音模型会保留到你在应用中移除模型或删除本地文件。</p>
|
||||||
|
|
||||||
<h2>儿童隐私</h2>
|
<h2>儿童隐私</h2>
|
||||||
<p>OSGKeyboard 不面向 13 岁以下儿童,亦不会明知地从儿童处收集个人信息。</p>
|
<p>OSGKeyboard 不面向 13 岁以下儿童,亦不会明知地从儿童处收集个人信息。</p>
|
||||||
|
|||||||
+20
-13
@@ -25,19 +25,22 @@
|
|||||||
<p class="top-nav"><a href="../">← OSGKeyboard</a> · <a href="https://apps.apple.com/app/osgkeyboard/id6781553267">App Store</a></p>
|
<p class="top-nav"><a href="../">← OSGKeyboard</a> · <a href="https://apps.apple.com/app/osgkeyboard/id6781553267">App Store</a></p>
|
||||||
<p class="lang"><a href="#zh">中文</a></p>
|
<p class="lang"><a href="#zh">中文</a></p>
|
||||||
<h1>OSGKeyboard Privacy Policy</h1>
|
<h1>OSGKeyboard Privacy Policy</h1>
|
||||||
<p><strong>Last updated:</strong> August 11, 2026 · <em>v1.2</em></p>
|
<p><strong>Last updated:</strong> August 14, 2026 · <em>v1.3</em></p>
|
||||||
<p>OSGKeyboard is a custom iOS keyboard that turns your voice into text. It runs as a Custom Keyboard Extension on iOS 26 and later, and uses Apple's on-device <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> for transcription by default. An optional cloud ASR engine (explicit opt-in) uploads recordings to the provider you configure. After transcription, text may be polished or translated via a cloud LLM. This policy explains what data the app processes and how it is used.</p>
|
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> by default; macOS can use a downloaded local Qwen3-ASR model. You may instead choose a cloud recognition engine in Settings. After transcription, text may be polished, translated, or used by AI Agent features through a provider you configure. This policy explains what data the apps process and how it is used.</p>
|
||||||
|
|
||||||
<h2>What we collect</h2>
|
<h2>What we collect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Voice audio</strong> — captured only while you actively record. On the default <strong>local engine</strong>, audio is transcribed on-device with Apple's <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> and raw audio is not uploaded. If you explicitly enable the <strong>cloud engine</strong> (a confirmation dialog is shown first), your recordings are uploaded to the ASR provider you configure (e.g. OpenAI, Qwen DashScope, Zhipu) for transcription; that provider's privacy policy applies. OSGKeyboard never stores or proxies your audio on its own servers.</li>
|
<li><strong>Voice audio</strong> — captured only while you actively record. On a <strong>local engine</strong>, audio is transcribed on-device and raw audio is not uploaded. If you actively select a <strong>cloud engine</strong> in Settings, recordings are uploaded directly to the ASR provider you configure (e.g. OpenAI, Qwen DashScope, Zhipu); that provider's privacy policy applies. OSGKeyboard never stores or proxies your audio on its own servers.</li>
|
||||||
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you have configured an LLM API key. To continue naturally at the insertion point, a small amount of text immediately before and after the cursor may be included. Secure fields are never captured; cursor context is not written to logs or voice history. Without an API key, raw ASR text is inserted and no polish request is sent. Polish and optional translation use the OpenAI-compatible (or Anthropic) API you configure (e.g. OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).</li>
|
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you have configured an LLM API key. To continue naturally at the insertion point, a small amount of text immediately before and after the cursor may be included. Secure fields are never captured; cursor context is not written to logs or voice history. Without an API key, raw ASR text is inserted and no polish request is sent. Polish and optional translation use the OpenAI-compatible (or Anthropic) API you configure (e.g. OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).</li>
|
||||||
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text is sent to the same configured LLM provider. When that provider supports server-side web search, it may retrieve public web results for time-sensitive answers. Search queries and snippets are handled by that provider under its privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
|
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text is sent to the same configured LLM provider. When that provider supports server-side web search, it may retrieve public web results for time-sensitive answers. Search queries and snippets are handled by that provider under its privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
|
||||||
|
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included in that request. Opening the update page requests public content from <code>download.osglab.com</code>. As with ordinary HTTPS sites, those hosts and their infrastructure may process connection metadata such as IP address and request time.</li>
|
||||||
|
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. For Reminders, Calendar, or Notes exports, the generated result is passed on-device to the Apple Shortcut you install; OSGKeyboard does not receive the resulting item. For navigation, an address or route is placed in a URL opened by Apple Maps, Amap, or Baidu Maps, and the selected map provider's policy applies.</li>
|
||||||
<li><strong>API credentials</strong> — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults. When you enable <strong>iCloud settings sync</strong>, API keys replicate through Apple's <strong>iCloud Keychain</strong> to your other signed-in devices — not through iCloud Key-Value Store JSON.</li>
|
<li><strong>API credentials</strong> — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults. When you enable <strong>iCloud settings sync</strong>, API keys replicate through Apple's <strong>iCloud Keychain</strong> to your other signed-in devices — not through iCloud Key-Value Store JSON.</li>
|
||||||
<li><strong>App preferences</strong> — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group <code>UserDefaults</code> on your device so the main app and keyboard extension stay in sync. Device-local clipboard consent switches are not activated by iCloud settings sync.</li>
|
<li><strong>App preferences</strong> — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group <code>UserDefaults</code> on your device so the main app and keyboard extension stay in sync. Device-local clipboard consent switches are not activated by iCloud settings sync.</li>
|
||||||
<li><strong>Optional clipboard history</strong> — off by default. When enabled, the keyboard may read text copied on this device or received through Universal Clipboard; iOS cannot reliably identify which source supplied an item. Up to 15 accepted items are stored only in this device’s App Group for the host app and keyboard extension. Turning history off stops capture, disables the suggestion strip, and keeps existing items. Reset Settings also keeps them; deletion requires the separate confirmed “Clear clipboard history” action. There is no fixed expiry. Secure fields immediately clear the current suggestion, close the history panel, and hide the clipboard entry point. Conservative filters reject common OTP shapes, PEM private-key headers, long three-part JWTs, Bearer tokens, recognizable provider-key prefixes of safe lengths, and common Luhn-valid 16-digit card numbers; they do not claim to detect every password. Normal order numbers, years, and short labeled account numbers remain eligible. Rejected text can still be pasted once through iOS; it is simply not recorded. Clipboard text is never sent to AI on its own. It leaves the device only when you deliberately ask for it — tapping a clipboard suggestion in AI mode, saying “clipboard” in an AI question, or invoking polish after inserting an item — and it is then sent to your configured provider as separate quoted data, never as instructions.</li>
|
<li><strong>Optional clipboard history</strong> — off by default. When enabled, the keyboard may read text copied on this device or received through Universal Clipboard; iOS cannot reliably identify which source supplied an item. Up to 15 accepted items are stored only in this device’s App Group for the host app and keyboard extension. Turning history off stops capture, disables the suggestion strip, and keeps existing items. Reset Settings also keeps them; deletion requires the separate confirmed “Clear clipboard history” action. There is no fixed expiry. Secure fields immediately clear the current suggestion, close the history panel, and hide the clipboard entry point. Conservative filters reject common OTP shapes, PEM private-key headers, long three-part JWTs, Bearer tokens, recognizable provider-key prefixes of safe lengths, and common Luhn-valid 16-digit card numbers; they do not claim to detect every password. Normal order numbers, years, and short labeled account numbers remain eligible. Rejected text can still be pasted once through iOS; it is simply not recorded. Clipboard text is never sent to AI on its own. It leaves the device only when you deliberately ask for it — tapping a clipboard suggestion in AI mode, saying “clipboard” in an AI question, or invoking polish after inserting an item — and it is then sent to your configured provider as separate quoted data, never as instructions.</li>
|
||||||
<li><strong>Personal dictionary</strong> — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved. Optional <strong>iCloud dictionary sync</strong> mirrors your dictionary through your private iCloud Key-Value Store; OSGKeyboard does not operate a separate dictionary server.</li>
|
<li><strong>Personal dictionary</strong> — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved. Optional <strong>iCloud dictionary sync</strong> mirrors your dictionary through your private iCloud Key-Value Store; OSGKeyboard does not operate a separate dictionary server.</li>
|
||||||
<li><strong>On-device typing learning</strong> — the Chinese keyboard stores selected words and candidate frequencies in the App Group on your device so future candidates improve. This user dictionary is not uploaded by OSGKeyboard.</li>
|
<li><strong>On-device typing learning</strong> — Chinese candidate frequencies and English suggestion choices are stored in the local App Group. You can clear this learning separately in typing settings without deleting the personal dictionary. OSGKeyboard does not upload it.</li>
|
||||||
|
<li><strong>macOS local speech models</strong> — when you choose a Qwen3-ASR local model, the Mac app downloads model files from Hugging Face and stores them in its local application-support directory until you remove them. Local inference does not send your audio to Hugging Face or OSGKeyboard.</li>
|
||||||
<li><strong>Voice history</strong> — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.</li>
|
<li><strong>Voice history</strong> — the host app may keep a list of recent successful transcripts in its History tab. History is capped at 300 entries. When iCloud settings sync is enabled, history may also sync through your private iCloud Key-Value Store.</li>
|
||||||
<li><strong>Usage statistics</strong> — cumulative dictation time, dictation characters, translation characters, and dictionary entry count are computed and stored locally on the home screen stats card.</li>
|
<li><strong>Usage statistics</strong> — cumulative dictation time, dictation characters, translation characters, and dictionary entry count are computed and stored locally on the home screen stats card.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
<li>We do <strong>not</strong> log or upload ordinary keystrokes. Only the on-device Chinese candidate-learning data described above is retained locally.</li>
|
||||||
<li>We do <strong>not</strong> operate analytics, crash reporting, or advertising SDKs.</li>
|
<li>We do <strong>not</strong> operate analytics, crash reporting, or advertising SDKs.</li>
|
||||||
<li>We do <strong>not</strong> upload raw audio anywhere on the default local engine. The only exception is the optional cloud ASR engine: if you explicitly enable it (a confirmation dialog is shown first), recordings go to the ASR provider <em>you</em> configure — never to servers of ours.</li>
|
<li>We do <strong>not</strong> upload raw audio on a local engine. If you actively select a cloud ASR engine in Settings, recordings go to the ASR provider <em>you</em> configure — never to servers of ours.</li>
|
||||||
<li>We do <strong>not</strong> sell personal data.</li>
|
<li>We do <strong>not</strong> sell personal data.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -62,10 +65,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Third parties</h2>
|
<h2>Third parties</h2>
|
||||||
<p>After ASR, transcribed text may be sent for polish and optional translation when you configure an LLM API key. AI-mode questions may also be sent to that provider, which may perform server-side web search. That provider's privacy policy applies. OSGKeyboard does not proxy, log, or aggregate your requests.</p>
|
<p>Cloud ASR and LLM requests go directly to the provider you configure. AI skills may also hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and update pages are served by <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each third party's privacy policy applies; OSGKeyboard does not proxy, log, or aggregate your provider requests.</p>
|
||||||
|
|
||||||
<h2>Data retention</h2>
|
<h2>Data retention</h2>
|
||||||
<p>Settings remain on your device until you delete the app or reset settings. When iCloud settings sync is enabled, API keys replicate through iCloud Keychain and eligible preferences, statistics, and voice history may sync through your private iCloud account. Transcription results are passed to the host app you are typing in and are not stored long-term by OSGKeyboard. Voice history is capped at 300 entries; you can clear it from the History tab or by resetting settings. Clipboard history remains device-local, is capped at 15 entries, has no fixed expiry, and is retained when capture is turned off or settings are reset; use the separate confirmed clear action to delete it.</p>
|
<p>Settings remain on your device until you delete the app or reset settings. When iCloud settings sync is enabled, API keys replicate through iCloud Keychain and eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account. Voice history is capped at 300 entries and can be cleared from History or by resetting settings. Clipboard history remains device-local, is capped at 15 entries, has no fixed expiry, and remains when capture is turned off or settings are reset; use the separate confirmed clear action to delete it. Chinese and English typing-learning data stays local and can be cleared from typing settings without deleting the personal dictionary. Downloaded Mac speech models remain until you remove the model in the app or delete its local files.</p>
|
||||||
|
|
||||||
<h2>Children's privacy</h2>
|
<h2>Children's privacy</h2>
|
||||||
<p>OSGKeyboard is not directed to children under 13 and does not knowingly collect personal data from children.</p>
|
<p>OSGKeyboard is not directed to children under 13 and does not knowingly collect personal data from children.</p>
|
||||||
@@ -81,18 +84,22 @@
|
|||||||
|
|
||||||
<hr id="zh">
|
<hr id="zh">
|
||||||
<h1>OSGKeyboard 隐私政策</h1>
|
<h1>OSGKeyboard 隐私政策</h1>
|
||||||
<p><strong>更新日期:</strong>2026 年 8 月 11 日 · <em>v1.2</em></p>
|
<p><strong>更新日期:</strong>2026 年 8 月 14 日 · <em>v1.3</em></p>
|
||||||
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。它以自定义键盘扩展的形式运行,需要 iOS 26 及以上系统,默认使用 Apple 端侧的 <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> 转写;可选的云端识别引擎(需显式二次确认开启)会把录音上传到你配置的服务商。转写完成后,文字可能经云端 LLM 润色或翻译。本政策说明应用处理哪些数据及用途。</p>
|
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧的 <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code>;macOS 可使用下载到本机的 Qwen3-ASR 模型。你也可以在设置中主动选择云端识别引擎。转写后,文字可能通过你配置的服务商用于润色、翻译或 AI Agent 功能。本政策说明应用处理哪些数据及用途。</p>
|
||||||
|
|
||||||
<h2>我们处理的数据</h2>
|
<h2>我们处理的数据</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。默认<strong>本地引擎</strong>下,音频在设备端通过 <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> 转写,原始录音不会上传。若你显式开启<strong>云端引擎</strong>(会先弹出确认对话框),录音会上传到你配置的识别服务商(如 OpenAI、通义 DashScope、智谱)完成转写,适用该服务商的隐私政策。OSGKeyboard 自身绝不存储或中转你的音频。</li>
|
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用<strong>本地引擎</strong>时,音频在设备端完成转写,原始录音不会上传。若你在设置中主动选择<strong>云端引擎</strong>,录音会直接上传到你配置的识别服务商(如 OpenAI、通义 DashScope、智谱),适用该服务商的隐私政策。OSGKeyboard 自身不存储或中转音频。</li>
|
||||||
<li><strong>转写文字与光标上下文</strong> — ASR 完成后,若你已配置 LLM API Key,转写文字(非音频)可能发送润色。为了在插入点自然衔接,请求可能同时包含光标前后的少量文字。密码框绝不采集,光标上下文不会写入日志或语音历史。未填写 API Key 时直接插入原始识别结果。润色与可选翻译使用你配置的 OpenAI 兼容(或 Anthropic)API(OpenAI / DeepSeek / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。</li>
|
<li><strong>转写文字与光标上下文</strong> — ASR 完成后,若你已配置 LLM API Key,转写文字(非音频)可能发送润色。为了在插入点自然衔接,请求可能同时包含光标前后的少量文字。密码框绝不采集,光标上下文不会写入日志或语音历史。未填写 API Key 时直接插入原始识别结果。润色与可选翻译使用你配置的 OpenAI 兼容(或 Anthropic)API(OpenAI / DeepSeek / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。</li>
|
||||||
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字会发送到同一套已配置的 LLM 服务商。若该服务商支持服务端联网搜索,可能为时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字会发送到同一套已配置的 LLM 服务商。若该服务商支持服务端联网搜索,可能为时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
||||||
|
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;该请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。与普通 HTTPS 网站相同,相关主机与基础设施可能处理 IP 地址、请求时间等连接元数据。</li>
|
||||||
|
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制的文字及技能提示词才会发送到你配置的 LLM 服务商。导出提醒事项、日历或备忘录时,生成结果会在设备端交给你安装的 Apple 快捷指令;OSGKeyboard 不会收到最终创建的项目。导航时,地址或路线会写入 URL 并交给 Apple 地图、高德地图或百度地图,相应地图服务商的政策适用。</li>
|
||||||
<li><strong>API 凭证</strong> — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,<strong>不会</strong>写入 <code>UserDefaults</code>。开启<strong>iCloud 设置同步</strong>后,API 密钥经 Apple <strong>iCloud 钥匙串</strong>同步到你其他已登录设备,<strong>不会</strong>写入 iCloud 键值存储 JSON。</li>
|
<li><strong>API 凭证</strong> — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,<strong>不会</strong>写入 <code>UserDefaults</code>。开启<strong>iCloud 设置同步</strong>后,API 密钥经 Apple <strong>iCloud 钥匙串</strong>同步到你其他已登录设备,<strong>不会</strong>写入 iCloud 键值存储 JSON。</li>
|
||||||
<li><strong>应用偏好</strong> — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group <code>UserDefaults</code>,用于主 App 与键盘扩展之间的状态同步。本机剪贴板许可开关不会被 iCloud 设置同步开启。</li>
|
<li><strong>应用偏好</strong> — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group <code>UserDefaults</code>,用于主 App 与键盘扩展之间的状态同步。本机剪贴板许可开关不会被 iCloud 设置同步开启。</li>
|
||||||
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机复制或经通用剪贴板收到的文字;iOS 无法可靠判断具体来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置也会保留,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即清除当前建议、关闭历史面板并隐藏剪贴板入口。保守过滤会拒绝常见 OTP 形态、PEM 私钥头、三段较长的 JWT、Bearer Token、达到安全长度的明确服务商密钥前缀,以及常见的通过 Luhn 校验的 16 位卡号;不宣称识别所有密码。普通订单号、年份与带标签的短账号仍可进入历史。被拒绝的文本仍可经 iOS 一次性粘贴,只是不被记录。剪贴板正文不会自行发送给 AI。只有你主动要求时它才会离开设备——在 AI 模式点选剪贴板建议、在 AI 提问中明确说出「剪贴板」,或插入后主动使用润色——此时会作为单独引用的数据发送给你配置的服务商,绝不作为指令。</li>
|
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机复制或经通用剪贴板收到的文字;iOS 无法可靠判断具体来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置也会保留,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即清除当前建议、关闭历史面板并隐藏剪贴板入口。保守过滤会拒绝常见 OTP 形态、PEM 私钥头、三段较长的 JWT、Bearer Token、达到安全长度的明确服务商密钥前缀,以及常见的通过 Luhn 校验的 16 位卡号;不宣称识别所有密码。普通订单号、年份与带标签的短账号仍可进入历史。被拒绝的文本仍可经 iOS 一次性粘贴,只是不被记录。剪贴板正文不会自行发送给 AI。只有你主动要求时它才会离开设备——在 AI 模式点选剪贴板建议、在 AI 提问中明确说出「剪贴板」,或插入后主动使用润色——此时会作为单独引用的数据发送给你配置的服务商,绝不作为指令。</li>
|
||||||
<li><strong>个性词库</strong> — 你在「词库」Tab 添加的词条与别名保存在本机,润色时会写入 LLM 提示词。可选的<strong>iCloud 词库同步</strong>经私有 iCloud 键值存储在多设备间镜像;OSGKeyboard 不运营独立词库服务器。</li>
|
<li><strong>个性词库</strong> — 你在「词库」Tab 添加的词条与别名保存在本机,润色时会写入 LLM 提示词。可选的<strong>iCloud 词库同步</strong>经私有 iCloud 键值存储在多设备间镜像;OSGKeyboard 不运营独立词库服务器。</li>
|
||||||
|
<li><strong>本地输入学习</strong> — 中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除,不会因此删除个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
||||||
|
<li><strong>macOS 本地语音模型</strong> — 选择 Qwen3-ASR 本地模型后,Mac App 会从 Hugging Face 下载模型文件,并保存在本机应用支持目录,直到你主动移除。本地推理不会把录音发送到 Hugging Face 或 OSGKeyboard。</li>
|
||||||
<li><strong>语音历史</strong> — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。</li>
|
<li><strong>语音历史</strong> — 主 App 可在「历史」页保留近期成功转写,上限 300 条。开启 iCloud 设置同步后,历史也可能经私有 iCloud 键值存储同步。</li>
|
||||||
<li><strong>用量统计</strong> — 首页统计卡片的累计听写时长、听写字数、翻译字数、词库词条数均在本地计算与保存。</li>
|
<li><strong>用量统计</strong> — 首页统计卡片的累计听写时长、听写字数、翻译字数、词库词条数均在本地计算与保存。</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -102,7 +109,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
||||||
<li>我们<strong>不会</strong>集成分析、崩溃上报或广告 SDK。</li>
|
<li>我们<strong>不会</strong>集成分析、崩溃上报或广告 SDK。</li>
|
||||||
<li>默认本地引擎下,我们<strong>不会</strong>将原始录音上传至任何服务器。唯一例外是可选的云端识别引擎:你显式开启后(会先弹出确认对话框),录音会发送到<em>你</em>配置的识别服务商——绝不会发送到我们的服务器。</li>
|
<li>使用本地引擎时,我们<strong>不会</strong>上传原始录音。若你在设置中主动选择云端 ASR,引擎会将录音发送到<em>你</em>配置的识别服务商,而不是我们的服务器。</li>
|
||||||
<li>我们<strong>不会</strong>出售个人数据。</li>
|
<li>我们<strong>不会</strong>出售个人数据。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -117,10 +124,10 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>第三方</h2>
|
<h2>第三方</h2>
|
||||||
<p>ASR 完成后,若你配置了 LLM API Key,转写文字可能用于润色与可选翻译;AI 模式问题也可能发送至同一服务商(含服务商侧可选联网搜索)。该服务商的隐私政策适用于相关请求。OSGKeyboard 不代理、不记录、不聚合这些请求。</p>
|
<p>云端 ASR 与 LLM 请求直接发送到你配置的服务商。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code>、<code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各第三方的隐私政策适用;OSGKeyboard 不代理、记录或聚合你的服务商请求。</p>
|
||||||
|
|
||||||
<h2>数据保留</h2>
|
<h2>数据保留</h2>
|
||||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,可同步的偏好、统计与语音历史可能经私有 iCloud 账户同步。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。语音历史上限 300 条,可随时在「历史」页清空或通过重置设置清除。剪贴板历史仅保存在本机,上限 15 条,没有固定过期时间;关闭采集或重置设置均会保留,需使用单独确认的清空操作删除。</p>
|
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 设置同步后,API 密钥经 iCloud 钥匙串同步,可同步的偏好、统计、词库和语音历史可能经私有 iCloud 账户同步。语音历史上限 300 条,可在「历史」页清空或通过重置设置清除。剪贴板历史仅保存在本机,上限 15 条,没有固定过期时间;关闭采集或重置设置均会保留,需使用单独确认的清空操作删除。中英文输入学习数据仅留本机,可在输入设置中单独清除且不影响个性词库。下载的 Mac 语音模型会保留到你在应用中移除模型或删除本地文件。</p>
|
||||||
|
|
||||||
<h2>儿童隐私</h2>
|
<h2>儿童隐私</h2>
|
||||||
<p>OSGKeyboard 不面向 13 岁以下儿童,亦不会明知地从儿童处收集个人信息。</p>
|
<p>OSGKeyboard 不面向 13 岁以下儿童,亦不会明知地从儿童处收集个人信息。</p>
|
||||||
|
|||||||
+8
-4
@@ -49,7 +49,7 @@ settings:
|
|||||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||||
MARKETING_VERSION: "1.7.5"
|
MARKETING_VERSION: "1.7.5"
|
||||||
CURRENT_PROJECT_VERSION: "71"
|
CURRENT_PROJECT_VERSION: "72"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
@@ -136,9 +136,11 @@ targets:
|
|||||||
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PINYIN-SIMP-APACHE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PINYIN-SIMP-APACHE.txt
|
||||||
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-MATERIAL-ICONS-APACHE.txt
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-JIEBA-MIT.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-JIEBA-MIT.txt
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PHRASE-PINYIN-DATA-MIT.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PHRASE-PINYIN-DATA-MIT.txt
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PINYIN-DATA-MIT.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PINYIN-DATA-MIT.txt
|
||||||
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-NORVIG-PD.txt
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/THIRD_PARTY_NOTICES.md
|
- path: OSGKeyboard/Resources/Typing/Licenses/THIRD_PARTY_NOTICES.md
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/third-party-notices.zip
|
- path: OSGKeyboard/Resources/Typing/Licenses/third-party-notices.zip
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LIBRIME-COMBINED-NOTICES.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LIBRIME-COMBINED-NOTICES.txt
|
||||||
@@ -348,9 +350,7 @@ targets:
|
|||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
# Prefer sources+buildPhase over target-level `resources:` — the latter
|
# Prefer sources+buildPhase over target-level `resources:` — the latter
|
||||||
# was not emitting Copy Bundle Resources entries for this framework.
|
# was not emitting Copy Bundle Resources entries for this framework.
|
||||||
- path: OSGKeyboardShared/Resources/Typing/English/english_lexicon.tsv
|
- path: OSGKeyboardShared/Resources/Typing/English/english_lexicon.bin
|
||||||
buildPhase: resources
|
|
||||||
- path: OSGKeyboardShared/Resources/Typing/English/english_bigrams.tsv
|
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: OSGKeyboardShared/Resources/PolishStyles
|
- path: OSGKeyboardShared/Resources/PolishStyles
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
@@ -576,12 +576,16 @@ targets:
|
|||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PINYIN-SIMP-APACHE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PINYIN-SIMP-APACHE.txt
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-MATERIAL-ICONS-APACHE.txt
|
||||||
|
buildPhase: resources
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-JIEBA-MIT.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-JIEBA-MIT.txt
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PHRASE-PINYIN-DATA-MIT.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PHRASE-PINYIN-DATA-MIT.txt
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PINYIN-DATA-MIT.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-PINYIN-DATA-MIT.txt
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE-NORVIG-PD.txt
|
||||||
|
buildPhase: resources
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
|
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
|
||||||
|
|||||||
Reference in New Issue
Block a user