chore(typing): snapshot local pinyin WIP before syncing cloud branch

Preserve the in-progress local typing/pinyin implementation so feat/pinyin
can safely reset to origin/feat/pinyin (cloud English + Chinese typing).
This commit is contained in:
Rocky
2026-08-03 13:14:01 +08:00
parent c037be3654
commit d1e5fed964
58 changed files with 370539 additions and 121 deletions
+4
View File
@@ -54,6 +54,8 @@ PreconfiguredKeys.local.swift
# Local DerivedData (when using -derivedDataPath in repo)
.derivedData/
.derivedData-*/
.spm-*/
# Lexicon build cache (downloaded SogouPopularDict TSV)
.cache/
@@ -78,6 +80,8 @@ ThirdParty/mlx-audio-swift/
AudioIntentProbe/
# Local crash-log pulls and one-off diff/working notes (not for the repo)
.refs/
.crash-pull/
all-changes-vs-main.patch
CHANGES.md
+7 -1
View File
@@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.5.0] - 2026-08-03
### Added
- **Rime typing surface**: keyboard extension adds Chinese/English QWERTY with phrase candidates, full pinyin, Microsoft/Sogou double pinyin, user learning, and opt-in fuzzy pairs; the top-right tab switches back to the voice-first surface. / **Rime 打字表面**:键盘扩展新增中英 QWERTY、词组候选、全拼、微软/搜狗双拼、用户词频学习与可选模糊音;右上角按钮可切回语音主界面。
- **macOS direct download**: bilingual README and GitHub Pages download areas now offer a signed, notarized macOS DMG alongside the iPhone / iPad App Store badge, using matching two-line badge designs. / **macOS 直接下载**:中英文 README 与 GitHub Pages 下载区在 iPhone / iPad App Store 徽章旁新增已签名、公证的 macOS DMG,并统一采用双行徽章样式。
- **Beams hero**: radiant noise-warped beam array (React Bitsstyle, vanilla Three.js) with the shared demo preset (26 beams, 208° rotation) and OSG green light. / **Beams Hero**:放射状噪声光束阵列(React Bits 风格,原生 Three.js),采用分享的演示预设(26 束、208° 旋转)与 OSG 绿色灯光。
- **Platform download badges**: matching App Store and macOS badges on bilingual READMEs and the website, with localized copy and platform-specific links. / **平台下载徽章**:中英文 README 与官网采用统一样式的 App Store、macOS 徽章,并提供本地化文案与平台专属链接。
@@ -22,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Mac Settings translation target**: polish-provider section includes “Polish then translate” with the same locale picker as Home / menu bar. / **Mac 设置翻译目标**:润色(LLM)分区新增「润色后翻译」,与首页 / 菜单栏同一套目标语言选择。
### Changed
- **Unified keyboard chrome**: voice, Chinese, and English now share a 281-point height, aligned native action keys, a tappable OSG wordmark that opens the host app, a compact translation menu, and an adaptive green Send key. / **统一键盘外观**:语音、中文与英文现统一为 281 点高度及对齐的原生操作键;OSG 横向标志可点击打开主 App,翻译入口改为紧凑菜单按钮,发送键使用自适应绿色。
- **Native-style typing keyboard**: voice, Chinese, and English now share one top-right segmented control that yields to active candidates; keys are taller, rounder, adapt to light/dark mode, use filled Shift state and native press feedback, while only the first candidate is highlighted. / **原生风格打字键盘**:语音、中文与英文共用右上角分段切换,组词时让位给候选栏;键帽更高、更圆并适配深浅模式,Shift 选中改为实心并增加原生按压反馈,仅首候选高亮。
- **Platform-aware third-party licenses**: About now lists the complete iOS Rime/dictionary stack with readable bundled dependency notices, while iOS and macOS hide components they do not ship. / **分平台第三方许可**:「关于」现在完整列出 iOS Rime 与词库依赖及可阅读的传递依赖许可,并在 iOS、macOS 分别隐藏未随该平台分发的组件。
- **Landing page single surface**: remove separate install / compare / Mac / English content pages from navigation; keep one bilingual home page with in-page anchors, and leave thin redirects for old URLs. / **官网收成单页**:导航去掉独立的安装 / 对比 / Mac / 英文专题页,保留双语首页与页内锚点,旧 URL 仅保留轻量跳转。
- **Hero stays dark**: the beams hero background, overlay, and light text stay on the dark stage in light mode too, matching night mode. / **Hero 固定暗色**:白天模式下 Beams Hero 底色、遮罩与浅色文字仍保持与黑夜一致的暗色舞台。
- **Website download actions**: App Store and source buttons now share a compact rounded-rectangle shape and matching height instead of mixing badge and pill silhouettes. / **官网操作按钮**:App Store 与源码按钮统一为等高的紧凑圆角矩形,不再混用徽章与胶囊轮廓。
@@ -31,7 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Practical polish prompts**: Light Clean / Structured / Formal / Daily Chat share a “transcript-only, not a chatbot” boundary; Structured gains active itemization, light semantic reorder, and paragraphing hard rules inspired by high-readability polish patterns. / **实用润色提示词**:轻度清理 / 清晰结构 / 正式表达 / 日常聊天统一「只整理转写、非聊天助手」边界;清晰结构加强积极分项、轻度语义重排与分段硬规则,提升长口述可读性。
- **RED Note keeps the draft's audience**: the Xiaohongshu style no longer opens with 姐妹们/集美们 or adds comment CTAs unless the draft already addresses a group, and a positive draft can no longer be rewritten with an 避雷-style hook. / **小红书不再擅自加受众**:除非原文本身在对一群人说话,否则不再添加「姐妹们/集美们」开场与评论区互动话术;正面体验也不会被写成「真诚避雷」式钩子。
- **Style-specific forbidden-items chapters**: every built-in polish prompt now has a dedicated `# 禁止事项` section modeled on Daily Chat—no interlocutor replies, no answering question drafts—with per-style bans (e.g. dating must not turn asks into verdicts; flex/corp must not answer as the other party; XHS must not invent product claims). / **风格专属禁止事项**:全部内置润色提示词均新增「# 禁止事项」章节,结构对齐日常聊天(禁接话、禁代答问句),并按风格补充专属禁令(如直男癌不得把征求意见改成评价;装逼/黑话不得替对方作答;小红书不得编造功效细节)。
- **Settings hierarchy**: voice-session options join Daily, while ASR and LLM configuration links sit directly below the transcription-mode choices; General and About remain secondary pages. / **设置层级**:语音会话选项并入「日常」,ASR 与 LLM 配置入口紧跟转写模式选择;通用与关于保留为二级页
- **Settings hierarchy**: General is now the first Daily entry; Text Input and its opt-in default-surface switch live under Keyboard & Gestures, while ASR and LLM configuration links remain below the transcription choices. / **设置层级**:「通用」现为「日常」首项;「文本输入」及默认进入文字键盘的开关移入「键盘与操作」,ASR 与 LLM 配置入口仍位于转写选项下方
- **Transcription option rows**: local and cloud choices now use the same text-first list-row style as the rest of Settings, without leading icons. / **转写选项行**:本地与云端选项移除前置图标,统一采用设置页的文字优先列表样式。
- **Simplified style cards and summaries**: polish-style cards drop decorative badges, and speech-configuration summaries show only the active engine or provider/model without redundant status prefixes. / **简化风格卡与摘要**:润色风格卡移除装饰图标;语音配置摘要仅显示引擎或服务商/模型,不再附加冗余状态前缀。
- **Mac polish style grid**: cards drop decorative icons and use a denser adaptive grid (about three columns at the default window; two when narrower, four+ when wider). / **Mac 润色风格网格**:卡片去掉装饰图标,并以更密的自适应网格排布(默认窗口约三列;变窄两列、变宽四列及以上)。
+42
View File
@@ -0,0 +1,42 @@
# Third-party notices — Typing keyboard
The generated source manifest is
`OSGKeyboardShared/Resources/Typing/Rime/manifest.json`. Exact librime
binary dependency licenses are bundled beside `NOTICE.txt`.
## Distributed components
| Component | Pinned version / commit | License | Purpose |
|-----------|-------------------------|---------|---------|
| [librime-xcframework](https://github.com/ghostflyby/librime-xcframework) | `1.17.0-pack.1`, package `d578192…` | BSD-3-Clause + bundled dependency notices | Static iOS librime runtime |
| [rime/librime](https://github.com/rime/librime) | `1.17.0` | BSD-3-Clause | Input-method engine |
| [rime-pinyin-simp](https://github.com/rime/rime-pinyin-simp) | `0c6861e…` | Apache-2.0 | Permissive baseline dictionary |
| [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 |
| [pinyin-data](https://github.com/mozillazg/pinyin-data) | `923b108…` | MIT | Character-pronunciation fallback |
`Scripts/typing/build_rime_dictionary.py` deterministically merges these
sources into `osg_pinyin.dict.yaml`; its manifest records every source URL,
commit, SHA-256 and output SHA-256.
## OSG-owned pieces
- SwiftUI QWERTY / candidate UI and `RimeEngineBridging`.
- Objective-C++ C-API bridge.
- Full-pinyin, Microsoft-double-pinyin and Sogou-double-pinyin schemas.
- Microsoft/Sogou mappings were independently encoded from public key-map
specifications; GPL schema files were not copied.
- Eight opt-in fuzzy-pinyin rule groups.
## Reference only
NanoMouse commit `a6177d898a01662ce551b43a01cf82a9f84ca54c`
(MIT) was used to understand iOS Rime lifecycle boundaries. Its KeyboardKit,
UI, data packages and application features are not linked into OSGKeyboard.
## Explicitly excluded
- rime-ice / 雾凇拼音 — GPL-3.0
- rime-double-pinyin schemas — GPL-3.0
- rime-luna-pinyin and rime-essay — LGPL-3.0
- KeyboardKit Pro — proprietary
+2 -1
View File
@@ -31,11 +31,12 @@
<li><strong>Transcribed text</strong> — in Cloud polish mode, the final text (not audio) may be sent to the LLM provider you configure (e.g. OpenAI) for punctuation and formatting.</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 preferences, usage statistics, and voice history through your private iCloud account.</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>
</ul>
<h2>What we do not collect</h2>
<ul>
<li>We do <strong>not</strong> log or upload ordinary keystrokes you type with the keyboard.</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 or advertising SDKs.</li>
<li>We do <strong>not</strong> sell personal data.</li>
</ul>
@@ -1,18 +1,20 @@
// OpenSourceLicenseCatalog.swift
// OSGKeyboard · Main App
//
// Single source of truth for third-party open-source components shipped
// with OSGKeyboard. Consumed by Settings About Third-Party Licenses.
// Single source of truth for third-party components distributed by each
// OSGKeyboard platform. Consumed by Settings About Third-Party Licenses.
//
// Keep this list aligned with bundled resources and runtime downloads.
//
// iOS targets remain zero-SPM. macOS local ASR links mlx-audio-swift (vendored
// under ThirdParty/) for Qwen3 MLX streaming; model weights download via catalog.
import Foundation
enum OpenSourceLicenseCatalog {
enum Platform: Hashable {
case iOS
case macOS
}
struct Entry: Identifiable, Hashable {
let id: String
let name: String
@@ -22,17 +24,82 @@ enum OpenSourceLicenseCatalog {
let url: URL?
/// Verbatim license body for the long-scroll disclosure page.
let licenseText: String
let platforms: Set<Platform>
}
/// Bundled libraries and runtime components referenced by the app.
static let entries: [Entry] = [
private static let allEntries: [Entry] = [
.init(
id: "material-icons",
name: "Google Material Icons",
licenseName: "Apache-2.0",
purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.",
url: URL(string: "https://github.com/google/material-design-icons"),
licenseText: apache2Text
licenseText: resourceText(
named: "LICENSE-PINYIN-SIMP-APACHE",
fallback: apache2Text
),
platforms: [.iOS]
),
.init(
id: "librime-static",
name: "librime 1.17.0 + static dependencies",
licenseName: "BSD-3-Clause and others",
purpose: "Chinese input runtime packaged by librime-xcframework 1.17.0-pack.1. Includes complete notices for Boost, OpenCC, LevelDB, yaml-cpp, glog, marisa-trie, RapidJSON, Darts and other statically linked components.",
url: URL(string: "https://github.com/ghostflyby/librime-xcframework"),
licenseText: resourceText(
named: "LIBRIME-COMBINED-NOTICES",
fallback: bsd3Text
),
platforms: [.iOS]
),
.init(
id: "rime-pinyin-simp",
name: "rime-pinyin-simp",
licenseName: "Apache-2.0",
purpose: "Permissive Simplified-Chinese baseline dictionary used to generate OSGKeyboard's Rime lexicon.",
url: URL(string: "https://github.com/rime/rime-pinyin-simp"),
licenseText: resourceText(
named: "LICENSE-PINYIN-SIMP-APACHE",
fallback: apache2Text
),
platforms: [.iOS]
),
.init(
id: "jieba",
name: "Jieba",
licenseName: "MIT",
purpose: "Modern Simplified-Chinese words and frequency weights used by the generated typing dictionary.",
url: URL(string: "https://github.com/fxsjy/jieba"),
licenseText: resourceText(
named: "LICENSE-JIEBA-MIT",
fallback: mitText
),
platforms: [.iOS]
),
.init(
id: "phrase-pinyin-data",
name: "phrase-pinyin-data",
licenseName: "MIT",
purpose: "Phrase pronunciation data used to resolve polyphonic Chinese words.",
url: URL(string: "https://github.com/mozillazg/phrase-pinyin-data"),
licenseText: resourceText(
named: "LICENSE-PHRASE-PINYIN-DATA-MIT",
fallback: mitText
),
platforms: [.iOS]
),
.init(
id: "pinyin-data",
name: "pinyin-data",
licenseName: "MIT",
purpose: "Per-character Mandarin pronunciation fallback for generated dictionary entries.",
url: URL(string: "https://github.com/mozillazg/pinyin-data"),
licenseText: resourceText(
named: "LICENSE-PINYIN-DATA-MIT",
fallback: mitText
),
platforms: [.iOS]
),
.init(
id: "mlx-audio-swift",
@@ -40,10 +107,28 @@ enum OpenSourceLicenseCatalog {
licenseName: "MIT",
purpose: "macOS local Qwen3 MLX streaming ASR (MLXAudioSTT), linked only in the Mac app target.",
url: URL(string: "https://github.com/Blaizzy/mlx-audio-swift"),
licenseText: mitText
licenseText: mlxAudioMITText,
platforms: [.macOS]
),
]
static func entries(for platform: Platform) -> [Entry] {
allEntries.filter { $0.platforms.contains(platform) }
}
private static func resourceText(named name: String, fallback: String) -> String {
let bundles = Bundle.allFrameworks + Bundle.allBundles + [Bundle.main]
for bundle in bundles {
guard let url = bundle.url(forResource: name, withExtension: "txt"),
let text = try? String(contentsOf: url, encoding: .utf8),
!text.isEmpty else {
continue
}
return text
}
return fallback
}
// MARK: - License bodies
static let apache2Text = """
@@ -85,4 +170,36 @@ enum OpenSourceLicenseCatalog {
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
static let mlxAudioMITText = """
MIT License
Copyright (c) 2025 Prince Canuma
\(mitText.components(separatedBy: "\n").dropFirst(2).joined(separator: "\n"))
"""
static let bsd3Text = """
BSD 3-Clause License
Copyright (c) 2014, RIME Developers
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DAMAGES ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE.
"""
}
+6
View File
@@ -68,6 +68,12 @@ struct MainAppRoot: View {
Task {
await AppCloudSync.shared.pullAllIfEnabled()
}
Task {
let typingConfig = TypingInputConfiguration.shared.snapshot
try? await RimeResourceInstaller.shared.installIfNeeded(
configuration: typingConfig
)
}
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
config.reloadFromPersistedStorage()
@@ -11,6 +11,8 @@ import OSGKeyboardShared
struct OpenSourceLicensesView: View {
@Environment(\.themePalette) private var palette: ThemePalette
private let entries = OpenSourceLicenseCatalog.entries(for: .iOS)
var body: some View {
ScrollView {
CardPageContent(spacing: SettingsListMetrics.sectionLabelSpacing) {
@@ -21,7 +23,7 @@ struct OpenSourceLicensesView: View {
.padding(.horizontal, Spacing.xs)
VStack(spacing: 0) {
ForEach(Array(OpenSourceLicenseCatalog.entries.enumerated()), id: \.element.id) { index, entry in
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
NavigationLink {
OpenSourceLicenseDetailView(entry: entry)
} label: {
@@ -29,7 +31,7 @@ struct OpenSourceLicensesView: View {
}
.buttonStyle(.plain)
if index < OpenSourceLicenseCatalog.entries.count - 1 {
if index < entries.count - 1 {
Divider().background(palette.divider)
}
}
@@ -151,6 +151,29 @@ struct PolishIntensityPickerRow: View {
}
}
// MARK: - Default input surface toggle
struct DefaultTypingInputToggleRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Binding var isOn: Bool
var body: some View {
Toggle(isOn: $isOn) {
VStack(alignment: .leading, spacing: 3) {
Text("settings.typingInput.default.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.typingInput.default.description")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.settingsListRow()
}
}
// MARK: - Cursor drag navigation toggle
struct CursorDragNavigationToggleRow: View {
@@ -221,6 +221,7 @@ struct VoiceSessionSettingsRows: View {
struct GeneralSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@ObservedObject private var typingConfiguration = TypingInputConfiguration.shared
var body: some View {
ScrollView {
@@ -241,6 +242,21 @@ struct GeneralSettingsView: View {
CardSection("settings.general.keyboard.title") {
VStack(spacing: 0) {
DefaultTypingInputToggleRow(
isOn: $typingConfiguration.defaultToTyping
)
Divider().background(palette.divider)
NavigationLink {
TypingInputSettingsView()
} label: {
SettingsNavigationRow(title: "settings.typingInput.title")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
+9 -8
View File
@@ -50,7 +50,9 @@ struct SettingsView: View {
}
dailySection
transcriptionAndPolishSection
moreEntriesSection
if presentation == .tab {
moreEntriesSection
}
}
.modifier(SettingsScrollBottomPadding(presentation: presentation))
}
@@ -114,6 +116,10 @@ struct SettingsView: View {
private var dailySection: some View {
CardSection("settings.daily.title") {
VStack(spacing: 0) {
settingsRouteButton(.general, title: "settings.general.title")
Divider().background(palette.divider)
LocalePickerRow(
locales: effectiveLocales,
selection: Binding(
@@ -160,16 +166,11 @@ struct SettingsView: View {
}
}
// MARK: - General / About
// MARK: - About
private var moreEntriesSection: some View {
VStack(spacing: 0) {
settingsRouteButton(.general, title: "settings.general.title")
if presentation == .tab {
Divider().background(palette.divider)
settingsRouteButton(.about, title: "settings.about.title")
}
settingsRouteButton(.about, title: "settings.about.title")
}
.surfaceCard()
}
@@ -0,0 +1,96 @@
// TypingInputSettingsView.swift
// OSGKeyboard · Main App
//
// Schema and opt-in fuzzy-pinyin settings. Changing fuzzy pairs triggers
// host-side redeployment; the keyboard extension never compiles schemas.
import SwiftUI
import OSGKeyboardShared
struct TypingInputSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var configuration = TypingInputConfiguration.shared
@State private var isDeploying = false
@State private var deploymentError: String?
var body: some View {
List {
Section("输入方案") {
Picker("拼音方案", selection: $configuration.schema) {
ForEach(TypingInputSchema.allCases) { schema in
Text(schema.displayName).tag(schema)
}
}
.pickerStyle(.inline)
}
Section {
ForEach(PinyinFuzzyPair.allCases) { pair in
Toggle(
pair.displayName,
isOn: Binding(
get: { configuration.fuzzyPairs.contains(pair) },
set: { enabled in
configuration.setFuzzyPair(pair, enabled: enabled)
deployUpdatedSchemas()
}
)
)
}
} header: {
Text("模糊音")
} footer: {
Text("默认全部关闭。只开启你需要的组合,避免候选噪音。")
}
Section("输入法资源") {
HStack {
Text("状态")
Spacer()
if isDeploying {
ProgressView()
.controlSize(.small)
} else {
Text(statusText)
.foregroundStyle(
deploymentError == nil ? palette.textSecondary : palette.danger
)
}
}
Button("重新部署输入法资源") {
deployUpdatedSchemas()
}
.disabled(isDeploying)
}
}
.scrollContentBackground(.hidden)
.background(palette.background)
.navigationTitle("settings.typingInput.title")
.navigationBarTitleDisplayMode(.inline)
}
private var statusText: String {
if let deploymentError { return deploymentError }
return RimeResourceInstaller.isReady ? "已就绪" : "待初始化"
}
private func deployUpdatedSchemas() {
guard !isDeploying else { return }
let snapshot = configuration.snapshot
isDeploying = true
deploymentError = nil
Task {
do {
try await RimeResourceInstaller.shared.installIfNeeded(
configuration: snapshot,
force: true
)
} catch {
deploymentError = error.localizedDescription
}
isDeploying = false
}
}
}
+3
View File
@@ -184,6 +184,9 @@
"settings.general.appearanceLanguage.title" = "Appearance & Language";
"settings.general.keyboard.title" = "Keyboard & Gestures";
"settings.general.sync.title" = "Sync";
"settings.typingInput.title" = "Text Input";
"settings.typingInput.default.title" = "Default to Text Input";
"settings.typingInput.default.description" = "Open the text input keyboard instead of voice input by default";
"settings.speechRecognition.title" = "Speech Recognition";
"settings.textPolish.title" = "Text Polish";
"settings.preferences.title" = "Preferences";
@@ -184,6 +184,9 @@
"settings.general.appearanceLanguage.title" = "外观与语言";
"settings.general.keyboard.title" = "键盘与操作";
"settings.general.sync.title" = "同步";
"settings.typingInput.title" = "文本输入";
"settings.typingInput.default.title" = "默认进行文字输入";
"settings.typingInput.default.description" = "打开键盘时,默认使用文字输入键盘而非语音输入";
"settings.speechRecognition.title" = "语音识别配置";
"settings.textPolish.title" = "文本润色配置";
"settings.preferences.title" = "偏好设置";
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "OSGLogoWide.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,7 @@
<svg width="952" height="291" viewBox="36 367 952 291" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+86 -4
View File
@@ -16,9 +16,10 @@
import UIKit
import SwiftUI
import Combine
import OSGKeyboardShared
private final class KeyboardHostingController: UIHostingController<KeyboardRootView> {
private final class KeyboardHostingController: UIHostingController<KeyboardSurfaceRoot> {
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
[.left, .right]
}
@@ -35,11 +36,13 @@ public final class KeyboardViewController: UIInputViewController {
public typealias State = KeyboardState
private let state = State()
private let typingSession = TypingSessionController()
private let persistor = AppGroupPersistor()
private var hosting: UIHostingController<KeyboardRootView>?
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
private var keyboardHeightConstraint: NSLayoutConstraint?
private var systemEncapsulatedHeight: CGFloat = 228
private var cancellables = Set<AnyCancellable>()
private var textInserter: KeyboardTextInserter!
private var flowCoordinator: KeyboardFlowCoordinator!
@@ -47,7 +50,7 @@ public final class KeyboardViewController: UIInputViewController {
private var cursorDrag: CursorDragController!
private var targetKeyboardHeight: CGFloat {
KeyboardRootView.totalHeight
KeyboardSurfaceRoot.height(for: state.surface)
}
// MARK: - Lifecycle
@@ -67,6 +70,7 @@ public final class KeyboardViewController: UIInputViewController {
configureDictationBehavior()
installServices()
installStateActions()
installSurfaceObservers()
installSwiftUI()
_ = configSync.loadPersistedConfig()
configSync.installDarwinObservers()
@@ -76,6 +80,9 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
flowCoordinator.stopSessionMonitor()
if state.surface == .typing {
typingSession.leaveTypingMode()
}
if flowCoordinator.preservesLifecycleOnDisappear {
return
}
@@ -93,6 +100,10 @@ public final class KeyboardViewController: UIInputViewController {
flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup()
configSync.refreshConfigFromAppGroup()
applyPreferredSurfaceOnOpen()
if state.surface == .typing {
typingSession.enterTypingMode()
}
configSync.autoAdvancePastKeyboardSetupStepIfNeeded()
}
@@ -124,6 +135,12 @@ public final class KeyboardViewController: UIInputViewController {
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
flowCoordinator.cancelPipelineUnlessAwaitingResult()
if state.surface == .typing {
typingSession.leaveTypingMode()
applySurface(.voice)
} else {
typingSession.leaveTypingMode()
}
}
public override func viewDidLayoutSubviews() {
@@ -200,6 +217,62 @@ public final class KeyboardViewController: UIInputViewController {
state.setCursorDragActive = { [weak self] active in
self?.cursorDrag.setCursorDragActive(active)
}
state.setSurface = { [weak self] surface in
self?.applySurface(surface)
}
}
private func installSurfaceObservers() {
state.$phase
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
if self.state.locksTypingSurface, self.state.surface == .typing {
self.applySurface(.voice)
}
}
.store(in: &cancellables)
state.$surface
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.refreshKeyboardHeight()
}
.store(in: &cancellables)
}
private func applySurface(_ surface: State.Surface) {
if surface == .typing, state.locksTypingSurface {
return
}
guard state.surface != surface else {
refreshKeyboardHeight()
return
}
state.surface = surface
if surface == .voice {
typingSession.leaveTypingMode()
}
refreshKeyboardHeight()
}
private func applyPreferredSurfaceOnOpen() {
let preferredSurface: State.Surface = TypingInputConfiguration.prefersTypingOnOpen()
? .typing
: .voice
applySurface(preferredSurface)
}
private func refreshKeyboardHeight() {
// `applyPresentationHeightOffset()` is only a one-time presentation
// primer used before `viewDidAppear`. Reusing it after a surface
// switch subtracts the system's ~228 pt encapsulated height from the
// requested typing height and collapses the keyboard to a thin strip.
// Once presented, update our height constraint directly, matching the
// final assignment in `viewDidAppear`.
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
view.layoutIfNeeded()
}
private func refreshReturnKeyRole() {
@@ -283,7 +356,16 @@ public final class KeyboardViewController: UIInputViewController {
}
private func installSwiftUI() {
let root = KeyboardRootView(state: state)
let root = KeyboardSurfaceRoot(
state: state,
typing: typingSession,
onInsert: { [weak self] text in
self?.textDocumentProxy.insertText(text)
},
onDeleteBackward: { [weak self] in
self?.textDocumentProxy.deleteBackward()
}
)
let host = KeyboardHostingController(rootView: root)
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
@@ -0,0 +1,51 @@
// KeyboardSurfaceRoot.swift
// OSGKeyboard · Keyboard Extension
//
// Switches between voice and typing surfaces driven by KeyboardState.surface.
import SwiftUI
import OSGKeyboardShared
struct KeyboardSurfaceRoot: View {
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
static var voiceHeight: CGFloat { KeyboardRootView.totalHeight }
static var typingHeight: CGFloat { TypingRootView.totalHeight }
static func height(for surface: KeyboardState.Surface) -> CGFloat {
switch surface {
case .voice: return voiceHeight
case .typing: return typingHeight
}
}
var body: some View {
Group {
switch state.surface {
case .voice:
KeyboardRootView(
state: state,
typing: typing,
onInsert: onInsert
)
case .typing:
TypingRootView(
state: state,
typing: typing,
onInsert: onInsert,
onDeleteBackward: onDeleteBackward
)
}
}
.animation(.easeInOut(duration: 0.15), value: state.surface)
.onChange(of: state.surface) { _, newSurface in
if newSurface == .voice {
typing.leaveTypingMode()
}
}
}
}
+343
View File
@@ -0,0 +1,343 @@
// TypingRootView.swift
// OSGKeyboard · Keyboard Extension
//
// Typing surface (Phase 1): candidate bar + QWERTY / 123 / symbols.
// Top-leading control returns to the voice surface.
import SwiftUI
import OSGKeyboardShared
enum TypingLayoutMetrics {
static let outerPaddingTop: CGFloat = 4
static let outerPaddingBottom: CGFloat = 4
static let topRegionHeight: CGFloat = KeyboardTopBarMetrics.height
static let keyRowHeight: CGFloat = 50
static let keyRowSpacing: CGFloat = 7
static let keyHorizontalSpacing: CGFloat = 6
static let bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let verticalKeySpacing: CGFloat = 8
static let secondRowInset: CGFloat = 18
static let keyCornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
/// Extends the bottom corner keys to the same outer edges as Shift / Delete.
static let bottomLeadingKeyWidth: CGFloat = 70
static let bottomTrailingKeyWidth: CGFloat = 86
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
}
struct TypingRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
VStack(spacing: 0) {
topRegion
.frame(height: TypingLayoutMetrics.topRegionHeight)
keyGrid
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
bottomRow
.frame(height: TypingLayoutMetrics.bottomRowHeight)
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
}
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight)
.background(Color.clear)
.environment(\.themePalette, palette)
.onAppear { typing.enterTypingMode() }
}
// MARK: - Shared top region
@ViewBuilder
private var topRegion: some View {
if hasCandidateContent {
candidateBar
} else {
idleTopBar
}
}
private var hasCandidateContent: Bool {
!typing.composition.preedit.isEmpty || !typing.composition.candidates.isEmpty
}
private var idleTopBar: some View {
HStack(spacing: Spacing.xs) {
KeyboardBrandLogo(action: state.openSettings)
if let err = typing.lastError {
Text(err)
.font(.system(size: 11))
.foregroundStyle(palette.danger)
.lineLimit(1)
}
Spacer(minLength: 0)
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
// MARK: - Candidates
private var candidateBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Spacing.xs) {
if typing.composition.candidates.isEmpty {
selectedCandidateLabel(text: typing.composition.preedit)
} else {
ForEach(
Array(typing.composition.candidates.enumerated()),
id: \.element.id
) { index, candidate in
Button {
let text = typing.selectCandidate(at: index)
if !text.isEmpty { onInsert(text) }
} label: {
if index == 0 {
selectedCandidateLabel(text: candidate.text)
} else {
Text(candidate.text)
.font(.system(size: 20, weight: .regular))
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, 10)
.frame(height: 40)
}
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
}
private func selectedCandidateLabel(text: String) -> some View {
VStack(spacing: 0) {
Text(text)
.font(.system(size: 19, weight: .medium))
.lineLimit(1)
Text(typing.composition.preedit)
.font(.system(size: 9, weight: .regular, design: .monospaced))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
.foregroundStyle(keyTextColor)
.padding(.horizontal, 12)
.frame(minWidth: 56, minHeight: 40)
.background(
selectedCandidateFill,
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
)
}
// MARK: - Keys
private var keyGrid: some View {
VStack(spacing: TypingLayoutMetrics.keyRowSpacing) {
ForEach(Array(typing.keyRows.enumerated()), id: \.offset) { rowIndex, row in
GeometryReader { proxy in
let inset = rowIndex == 1 ? TypingLayoutMetrics.secondRowInset : 0
let weights = row.enumerated().map {
keyWeight(label: $0.element, index: $0.offset, rowIndex: rowIndex)
}
let spacing = TypingLayoutMetrics.keyHorizontalSpacing
* CGFloat(max(0, row.count - 1))
let availableWidth = proxy.size.width - inset * 2 - spacing
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
ForEach(Array(row.enumerated()), id: \.offset) { keyIndex, key in
keyButton(key)
.frame(width: unitWidth * weights[keyIndex])
}
}
.padding(.horizontal, inset)
}
.frame(height: TypingLayoutMetrics.keyRowHeight)
}
}
}
private func keyButton(_ label: String) -> some View {
let isSpecial = ["", "", "123", "#+=", "ABC"].contains(label)
return Button {
let result = typing.handleKey(label)
if result == "\u{8}" {
onDeleteBackward()
} else if !result.isEmpty {
onInsert(result)
}
} label: {
keyLabel(label, isSpecial: isSpecial)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.buttonStyle(nativeKeyStyle)
}
@ViewBuilder
private func keyLabel(_ label: String, isSpecial: Bool) -> some View {
switch label {
case "":
Image(systemName: typing.shiftActive || typing.capsLock ? "shift.fill" : "shift")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(keyTextColor)
case "":
Image(systemName: "delete.left")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(keyTextColor)
default:
Text(label)
.font(
.system(
size: isSpecial ? 15 : 22,
weight: isSpecial ? .semibold : .regular
)
)
.foregroundStyle(keyTextColor)
}
}
private var bottomRow: some View {
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
Button {
typing.setPage(typing.page == .letters ? .numbers : .letters)
} label: {
Text(typing.page == .letters ? "123" : "ABC")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(keyTextColor)
.frame(
width: TypingLayoutMetrics.bottomLeadingKeyWidth,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.buttonStyle(nativeKeyStyle)
Button {
let text = typing.handleSpace()
if !text.isEmpty { onInsert(text) }
} label: {
Text(typing.language == .chinese ? "空格" : "space")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(keyTextColor)
.frame(maxWidth: .infinity)
.frame(height: TypingLayoutMetrics.bottomRowHeight)
}
.buttonStyle(nativeKeyStyle)
Button {
let text = typing.handleReturn()
if !text.isEmpty { onInsert(text) }
} label: {
returnKeyLabel
.foregroundStyle(returnKeyTextColor)
.frame(
width: TypingLayoutMetrics.bottomTrailingKeyWidth,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.buttonStyle(returnKeyStyle)
}
}
@ViewBuilder
private var returnKeyLabel: some View {
switch state.returnKeyRole {
case .newline:
Image(systemName: "arrow.turn.down.left")
.font(.system(size: 21, weight: .medium))
case .send:
ExtL10n.text(state.returnKeyRole.titleKey)
.font(.system(size: 15, weight: .semibold))
}
}
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
if label == "" || label == "" || label == "#+=" {
return 1.35
}
if rowIndex == 2 && index == 0 {
// Double-pinyin semicolon occupies the normal Shift footprint.
return 1.35
}
return 1
}
private var keyFill: Color {
NativeKeyboardKeyColors.fill(for: colorScheme)
}
private var keyPressedFill: Color {
NativeKeyboardKeyColors.pressedFill(for: colorScheme)
}
private var keyTextColor: Color {
NativeKeyboardKeyColors.text(for: colorScheme)
}
private var selectedCandidateFill: Color {
colorScheme == .dark ? Color(white: 0.36) : .white
}
private var nativeKeyStyle: NativeKeyboardKeyStyle {
NativeKeyboardKeyStyle(
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
)
}
private var returnKeyStyle: NativeKeyboardKeyStyle {
switch state.returnKeyRole {
case .newline:
return nativeKeyStyle
case .send:
return NativeKeyboardKeyStyle(
fill: sendKeyFill,
pressedFill: sendKeyPressedFill,
border: Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08),
cornerRadius: TypingLayoutMetrics.keyCornerRadius
)
}
}
private var returnKeyTextColor: Color {
switch state.returnKeyRole {
case .newline:
return keyTextColor
case .send:
return .white
}
}
/// The send key stays recognizable in both appearances without becoming neon.
private var sendKeyFill: Color {
NativeKeyboardKeyColors.sendFill(for: colorScheme)
}
private var sendKeyPressedFill: Color {
NativeKeyboardKeyColors.sendPressedFill(for: colorScheme)
}
}
+41 -57
View File
@@ -8,7 +8,7 @@
// in `applyPresentationHeightOffset()`.
//
//
// [polish] [] header band (top)
// [OSG] EN header band (top)
// (transcript preview)
//
// mic (centred) action cluster:
@@ -22,17 +22,14 @@ import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let micSize: CGFloat = 121
static let micToButtonGap: CGFloat = 8
static let bottomActionRowHeight: CGFloat = 48
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let bottomActionFixedWidth: CGFloat = 86
static let bottomActionSpacing: CGFloat = Spacing.xs
/// Gap between the top chip row and the transcript / hint line.
/// Tightened (8 4) so the "" line hugs the chip row. The
/// space reclaimed here and from `actionClusterTopGap` is added back
/// into `actionClusterBottomGap`, keeping `totalHeight` constant while
/// nudging the mic up toward the vertical centre.
/// Gap between the top control row and the transcript / hint line.
/// Four points keeps the "" line visually attached to the controls.
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
/// Outer inset for the bottom action row from screen edges (8 pt 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
/// Match the typing key grid's outer edge.
static let sideActionHorizontalInset: CGFloat = KeyboardChromeLayout.horizontalInset
/// iPad: cap the content column. A full-width (~1180 pt) keyboard would
/// park delete/return at the far screen edges and turn each cursor-drag
/// pad into a ~450 pt runway capping keeps the reach ergonomics of the
@@ -40,41 +37,40 @@ private enum KeyboardLayoutMetrics {
static let contentMaxWidth: CGFloat = 700
// MARK: - Content-driven keyboard height (single source of truth)
static let outerPaddingTop: CGFloat = 2
static let outerPaddingBottom: CGFloat = 1
static let topBarHeight: CGFloat = 38
static let outerPaddingTop: CGFloat = 4
static let outerPaddingBottom: CGFloat = 4
static let topBarHeight: CGFloat = KeyboardTopBarMetrics.height
static let transcriptLineHeight: CGFloat = 22
/// mic (121) + gap (8) + bottom row (48) = 177 pt
/// mic (121) + gap (8) + bottom row (50) = 179 pt
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
/// Gap between transcript line and mic. Tightened (11.2 4) to pull
/// the mic up; the reclaimed space moves to `actionClusterBottomGap`.
static let actionClusterTopGap: CGFloat = Spacing.xs / 2
/// Gap below the bottom action row.
static let actionClusterBottomGap: CGFloat = 6
/// Moves the action cluster down so its keys share the typing row's baseline.
static let actionClusterTopGap: CGFloat = Spacing.xl
/// The shared 4 pt outer padding is the complete bottom inset.
static let actionClusterBottomGap: CGFloat = 0
static var headerBandHeight: CGFloat {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 2 + 64 + 4 + 177 + 15.2 + 1 = 263.2 pt (unchanged; the mic cluster
/// just sits higher now that the top gaps moved to the bottom gap).
static var totalHeight: CGFloat {
outerPaddingTop
+ headerBandHeight
+ actionClusterTopGap
+ actionClusterHeight
+ actionClusterBottomGap
+ outerPaddingBottom
}
/// 4 + 70 + 24 + 179 + 0 + 4 = 281 pt, matching Chinese / English.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
}
public struct KeyboardRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: State
@ObservedObject var typing: TypingSessionController
let onInsert: (String) -> Void
public init(state: KeyboardViewController.State) {
public init(
state: KeyboardViewController.State,
typing: TypingSessionController = TypingSessionController(),
onInsert: @escaping (String) -> Void = { _ in }
) {
self.state = state
self.typing = typing
self.onInsert = onInsert
}
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
@@ -136,7 +132,7 @@ public struct KeyboardRootView: View {
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
}
/// Top chip row + transcript / hint line.
/// Top brand / mode row + transcript / hint line.
private var headerBand: some View {
VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) {
topBar
@@ -158,35 +154,18 @@ public struct KeyboardRootView: View {
private var topBar: some View {
HStack(spacing: Spacing.xs) {
if state.isLocalEngine {
LocalEngineChip()
} else {
CloudEngineChip()
}
KeyboardBrandLogo(action: state.openSettings)
// Engine controls remain available in the host app.
// App context is auto-detected on each mic press no UI.
if state.isTranslationChipVisible {
TranslationChip(
palette: palette,
targetLocaleId: state.translationTargetLocaleId,
onSelect: state.setTranslationTargetLocaleId
)
// Decouple the open picker from the keyboard's 1 Hz App
// Group poll so scrolling doesn't reset / dismiss it.
.equatable()
}
Spacer(minLength: 0)
Button(action: state.openSettings) {
Image(systemName: "gearshape.fill")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 34, height: 34)
.background(palette.surface, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(ExtL10n.text("keyboard.openSettingsA11y"))
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: onInsert
)
}
.padding(.horizontal, Spacing.md)
.padding(.horizontal, KeyboardTopBarMetrics.horizontalInset)
}
// MARK: - Action cluster
@@ -273,7 +252,12 @@ public struct KeyboardRootView: View {
private func bottomReturnButton(disabled: Bool) -> some View {
let title = ExtL10n.string(state.returnKeyRole.titleKey)
return RectangularToolbarButton(title: title, label: title, disabled: disabled) {
return RectangularToolbarButton(
title: title,
label: title,
disabled: disabled,
isSend: state.returnKeyRole == .send
) {
state.insertNewline()
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
@@ -0,0 +1,241 @@
// KeyboardTopControls.swift
// OSGKeyboard · Keyboard Extension
//
// Shared top-right input switcher used by both voice and typing surfaces.
// The same footprint is replaced by candidates while Chinese is composing.
import SwiftUI
import OSGKeyboardShared
enum KeyboardTopBarMetrics {
static let height: CGFloat = 44
static let horizontalInset: CGFloat = 12
/// TypingRootView already contributes 8 pt around the entire key surface.
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
static let logoHeight: CGFloat = 22
static let logoWidth: CGFloat = logoHeight * 952 / 291
}
struct KeyboardBrandLogo: View {
@Environment(\.colorScheme) private var colorScheme
let action: () -> Void
var body: some View {
Button(action: action) {
Image("OSGLogoWide")
.resizable()
.renderingMode(.template)
.scaledToFit()
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
.frame(
width: KeyboardTopBarMetrics.logoWidth,
height: KeyboardTopBarMetrics.logoHeight
)
.contentShape(Rectangle())
.accessibilityHidden(true)
}
.buttonStyle(BrandLogoPressStyle())
.accessibilityLabel(ExtL10n.text("keyboard.onboarding.api.openHostApp"))
}
}
private enum KeyboardInputTab: CaseIterable {
case voice
case chinese
case english
var title: String {
switch self {
case .voice: return "语音"
case .chinese: return "中文"
case .english: return "EN"
}
}
}
struct KeyboardTopControls: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
let palette: ThemePalette
let onInsert: (String) -> Void
var body: some View {
HStack(spacing: 6) {
HStack(spacing: 2) {
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
Button {
select(tab)
} label: {
Text(tab.title)
.font(.system(size: 12, weight: isSelected(tab) ? .semibold : .medium))
.foregroundStyle(
isSelected(tab) ? palette.textPrimary : palette.textSecondary
)
.frame(width: tab == .english ? 34 : 42, height: 30)
.background {
if isSelected(tab) {
Capsule()
.fill(selectedFill)
.shadow(
color: Color.black.opacity(colorScheme == .dark ? 0.22 : 0.10),
radius: 1.5,
y: 1
)
}
}
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tab != .voice && !state.canEnterTypingSurface ? 0.42 : 1)
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
}
}
.padding(2)
.background(trackFill, in: Capsule())
KeyboardTranslationMenuButton(
palette: palette,
targetLocaleId: state.translationTargetLocaleId,
onSelect: state.setTranslationTargetLocaleId
)
// Decouple the open picker from the keyboard's 1 Hz App Group
// poll so scrolling does not reset or dismiss the menu.
.equatable()
}
}
private var selectedFill: Color {
colorScheme == .dark ? Color(white: 0.38) : .white
}
private var trackFill: Color {
colorScheme == .dark ? Color(white: 0.18) : Color.black.opacity(0.08)
}
private var pressedFill: Color {
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
}
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
switch tab {
case .voice:
return state.surface == .voice
case .chinese:
return state.surface == .typing && typing.language == .chinese
case .english:
return state.surface == .typing && typing.language == .english
}
}
private func select(_ tab: KeyboardInputTab) {
switch tab {
case .voice:
state.setSurface(.voice)
case .chinese:
let raw = typing.setLanguage(.chinese)
if !raw.isEmpty { onInsert(raw) }
state.setSurface(.typing)
case .english:
let raw = typing.setLanguage(.english)
if !raw.isEmpty { onInsert(raw) }
state.setSurface(.typing)
}
}
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
switch tab {
case .voice: return "切换到语音输入"
case .chinese: return "切换到中文输入"
case .english: return "切换到英文输入"
}
}
}
private struct KeyboardTranslationMenuButton: View, Equatable {
@Environment(\.colorScheme) private var colorScheme
let palette: ThemePalette
let targetLocaleId: String
let onSelect: (String) -> Void
nonisolated static func == (
lhs: KeyboardTranslationMenuButton,
rhs: KeyboardTranslationMenuButton
) -> Bool {
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
}
private var isEnabled: Bool {
targetLocaleId != TranslationLanguageCatalog.offLocaleId
}
var body: some View {
Menu {
ForEach(TranslationLanguageCatalog.all) { language in
Button {
onSelect(language.id)
} label: {
if language.id == targetLocaleId {
Label(displayLabel(for: language), systemImage: "checkmark")
} else {
Text(displayLabel(for: language))
}
}
}
} label: {
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(isEnabled ? palette.accent : palette.textSecondary)
.frame(width: 34, height: 34)
.background(buttonFill, in: Circle())
.overlay(Circle().stroke(buttonStroke, lineWidth: 0.5))
}
.menuStyle(.button)
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
}
private var buttonFill: Color {
if isEnabled {
return palette.accent.opacity(colorScheme == .dark ? 0.28 : 0.16)
}
return colorScheme == .dark ? Color(white: 0.30) : .white
}
private var buttonStroke: Color {
isEnabled ? palette.accent.opacity(0.35) : palette.divider
}
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return SharedL10n.string("keyboard.translation.offMenu")
}
return language.nativeName
}
}
private struct BrandLogoPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.opacity(configuration.isPressed ? 0.62 : 1)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
}
}
private struct TopControlPressStyle: ButtonStyle {
let pressedFill: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(configuration.isPressed ? pressedFill.opacity(0.55) : .clear)
.clipShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
}
}
@@ -0,0 +1,81 @@
// NativeKeyboardKeyStyle.swift
// OSGKeyboard · Keyboard Extension
//
// Shared native-like key surface used by voice and typing action rows.
import SwiftUI
enum NativeKeyboardKeyColors {
static func fill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? Color(white: 0.32) : .white
}
static func pressedFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? Color(white: 0.23) : Color(white: 0.84)
}
static func text(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? .white : Color(red: 0.06, green: 0.06, blue: 0.08)
}
/// Adaptive brand green: brighter in dark mode and deeper in light mode.
static func sendFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark
? Color(red: 0.286, green: 0.725, blue: 0.416)
: Color(red: 0.196, green: 0.549, blue: 0.298)
}
static func sendPressedFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark
? Color(red: 0.227, green: 0.627, blue: 0.353)
: Color(red: 0.157, green: 0.447, blue: 0.247)
}
}
struct NativeKeyboardKeySurface<Content: View>: View {
let isPressed: Bool
let fill: Color
let pressedFill: Color
let border: Color
let cornerRadius: CGFloat
@ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(isPressed ? pressedFill : fill)
)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(border, lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(isPressed ? 0.04 : 0.13),
radius: isPressed ? 0.5 : 1,
y: isPressed ? 0 : 1
)
.scaleEffect(isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: isPressed)
}
}
struct NativeKeyboardKeyStyle: ButtonStyle {
let fill: Color
let pressedFill: Color
let border: Color
let cornerRadius: CGFloat
func makeBody(configuration: Configuration) -> some View {
NativeKeyboardKeySurface(
isPressed: configuration.isPressed,
fill: fill,
pressedFill: pressedFill,
border: border,
cornerRadius: cornerRadius
) {
configuration.label
}
}
}
+70 -31
View File
@@ -11,10 +11,13 @@ import OSGKeyboardShared
private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14
static let titleSize: CGFloat = 16
static let cornerRadius: CGFloat = 12
static let cornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
static let spaceBarCapsuleWidth: CGFloat = 31
static let pressScale: CGFloat = 0.94
static let pressOverlayOpacity: CGFloat = 0.18
}
private enum ToolbarKeyEmphasis {
case standard
case send
}
// MARK: - Press styling
@@ -25,31 +28,45 @@ private struct ToolbarKeySurface<Content: View>: View {
let isPressed: Bool
let cornerRadius: CGFloat
let emphasis: ToolbarKeyEmphasis
@ViewBuilder let content: () -> Content
var body: some View {
content()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(buttonFill, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
}
.overlay {
if isPressed {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
}
}
.scaleEffect(isPressed ? ToolbarButtonMetrics.pressScale : 1)
.animation(.easeOut(duration: 0.1), value: isPressed)
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: buttonFill,
pressedFill: buttonPressedFill,
border: buttonBorder,
cornerRadius: cornerRadius,
content: content
)
}
private var buttonFill: Color {
let base = colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
return isPressed ? base.opacity(0.82) : base
switch emphasis {
case .standard:
return NativeKeyboardKeyColors.fill(for: colorScheme)
case .send:
return NativeKeyboardKeyColors.sendFill(for: colorScheme)
}
}
private var buttonPressedFill: Color {
switch emphasis {
case .standard:
return NativeKeyboardKeyColors.pressedFill(for: colorScheme)
case .send:
return NativeKeyboardKeyColors.sendPressedFill(for: colorScheme)
}
}
private var buttonBorder: Color {
switch emphasis {
case .standard:
return palette.divider
case .send:
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
}
}
@@ -57,7 +74,7 @@ private struct ToolbarKeySurface<Content: View>: View {
/// Tap deletes once; hold repeats with tiered acceleration after 5 s.
struct RepeatingDeleteButton: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let disabled: Bool
let action: () -> Void
@@ -73,10 +90,14 @@ struct RepeatingDeleteButton: View {
private let accelTier4: TimeInterval = 0.015
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: .standard
) {
Image(systemName: "delete.left")
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
}
.contentShape(Rectangle())
.gesture(pressGesture)
@@ -135,13 +156,14 @@ struct RepeatingDeleteButton: View {
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let systemName: String?
let spaceStyle: Bool
let title: String?
let label: String
let disabled: Bool
let isSend: Bool
let action: () -> Void
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
@@ -150,14 +172,22 @@ struct RectangularToolbarButton: View {
self.title = nil
self.label = label
self.disabled = disabled
self.isSend = false
self.action = action
}
init(title: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
init(
title: String,
label: String,
disabled: Bool = false,
isSend: Bool = false,
action: @escaping () -> Void
) {
self.systemName = nil
self.spaceStyle = false
self.label = label
self.disabled = disabled
self.isSend = isSend
self.action = action
self.title = title
}
@@ -168,25 +198,30 @@ struct RectangularToolbarButton: View {
self.title = nil
self.label = label
self.disabled = disabled
self.isSend = false
self.action = action
}
@State private var isPressing = false
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: isSend ? .send : .standard
) {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.fill(buttonForeground)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.foregroundStyle(buttonForeground)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.foregroundStyle(buttonForeground)
}
}
.contentShape(Rectangle())
@@ -197,6 +232,10 @@ struct RectangularToolbarButton: View {
.accessibilityAddTraits(.isButton)
}
private var buttonForeground: Color {
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
}
// Button
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
@@ -0,0 +1,53 @@
// KeyboardSurfaceStateTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class KeyboardSurfaceStateTests: XCTestCase {
func testRecordingLocksTyping() {
let state = KeyboardState()
state.phase = .idle
XCTAssertTrue(state.canEnterTypingSurface)
state.phase = .recording
XCTAssertTrue(state.locksTypingSurface)
XCTAssertFalse(state.canEnterTypingSurface)
}
func testStandardLayoutHasQwertyTopRow() {
let layout = StandardTypingLayout()
let rows = layout.rows(for: .letters, shiftActive: false)
XCTAssertEqual(rows.first, ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"])
let shifted = layout.rows(for: .letters, shiftActive: true)
XCTAssertEqual(shifted.first?.first, "Q")
}
func testVoiceAndTypingChromeShareStableDimensions() {
XCTAssertEqual(KeyboardChromeLayout.totalHeight, 281)
XCTAssertEqual(KeyboardChromeLayout.actionKeyHeight, 50)
XCTAssertEqual(KeyboardChromeLayout.actionKeyCornerRadius, 10)
XCTAssertEqual(KeyboardChromeLayout.horizontalInset, 8)
}
func testSharedCapsuleCanSelectSpecificTypingLanguage() {
let typing = TypingSessionController()
XCTAssertEqual(typing.language, .chinese)
XCTAssertEqual(typing.setLanguage(.english), "")
XCTAssertEqual(typing.language, .english)
XCTAssertEqual(typing.setLanguage(.chinese), "")
XCTAssertEqual(typing.language, .chinese)
}
func testShiftStateProducesUppercaseKeyRows() {
let typing = TypingSessionController()
XCTAssertFalse(typing.shiftActive)
_ = typing.handleKey("")
XCTAssertTrue(typing.shiftActive)
XCTAssertEqual(typing.keyRows.first?.first, "Q")
}
}
@@ -0,0 +1,145 @@
// LibrimeIntegrationTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
final class LibrimeIntegrationTests: XCTestCase {
func testFullPinyinAndBothDoublePinyinSchemasProducePhraseCandidates() throws {
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)
defer { try? fileManager.removeItem(at: root) }
try fileManager.createDirectory(at: shared, withIntermediateDirectories: true)
try fileManager.createDirectory(at: user, withIntermediateDirectories: true)
let bundle = try XCTUnwrap(Bundle(identifier: "com.osgkeyboard.ios.shared"))
let dictionary = try XCTUnwrap(
bundle.url(forResource: "osg_pinyin.dict", withExtension: "yaml")
)
try fileManager.copyItem(
at: dictionary,
to: shared.appendingPathComponent("osg_pinyin.dict.yaml")
)
try RimeSchemaGenerator.defaultConfiguration().write(
to: shared.appendingPathComponent("default.yaml"),
atomically: true,
encoding: .utf8
)
for schema in TypingInputSchema.allCases {
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: []).write(
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
atomically: true,
encoding: .utf8
)
}
let deployer = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests"
)
try deployer.deploy(withFullCheck: true)
deployer.finalizeRuntime()
let bridge = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests"
)
try bridge.start()
let vectors: [(TypingInputSchema, String)] = [
(.fullPinyin, "nihao"),
(.microsoftDoublePinyin, "nihk"),
(.sogouDoublePinyin, "nihk")
]
for (schema, keys) in vectors {
bridge.clearComposition()
XCTAssertTrue(bridge.selectSchema(schema.rawValue), schema.displayName)
let startedAt = ProcessInfo.processInfo.systemUptime
for scalar in keys.utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let snapshot = bridge.snapshot(withCandidateLimit: 100)
XCTAssertLessThan(
ProcessInfo.processInfo.systemUptime - startedAt,
0.15,
"\(schema.displayName) first candidates exceeded 150 ms"
)
XCTAssertFalse(snapshot.preedit.isEmpty, schema.displayName)
XCTAssertTrue(
snapshot.candidates.contains(where: { $0.text == "你好" }),
"\(schema.displayName) candidates: \(snapshot.candidates.map(\.text).prefix(20))"
)
if schema == .fullPinyin,
let helloIndex = snapshot.candidates.firstIndex(where: { $0.text == "你好" }) {
XCTAssertTrue(bridge.selectCandidate(at: helloIndex))
XCTAssertEqual(
bridge.snapshot(withCandidateLimit: 10).commitText,
"你好"
)
}
}
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
let phraseVectors = [
("rengongzhineng", "人工智能"),
("yuyinshuru", "语音输入"),
("zhonghuarenmingongheguo", "中华人民共和国")
]
for (keys, expected) in phraseVectors {
bridge.clearComposition()
for scalar in keys.utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let snapshot = bridge.snapshot(withCandidateLimit: 100)
XCTAssertTrue(
snapshot.candidates.contains(where: { $0.text == expected }),
"\(keys) candidates: \(snapshot.candidates.map(\.text).prefix(20))"
)
}
bridge.finalizeRuntime()
let userFiles = fileManager.enumerator(atPath: user.path)?
.compactMap { $0 as? String } ?? []
XCTAssertTrue(userFiles.contains(where: { $0.contains("userdb") }))
// Redeploy with one fuzzy pair and verify it affects actual Rime
// candidates rather than just generated YAML.
for schema in TypingInputSchema.allCases {
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(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests-fuzzy"
)
try fuzzyBridge.start()
XCTAssertTrue(fuzzyBridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
for scalar in "lihao".utf8 {
XCTAssertTrue(fuzzyBridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let fuzzySnapshot = fuzzyBridge.snapshot(withCandidateLimit: 100)
XCTAssertTrue(
fuzzySnapshot.candidates.contains(where: { $0.text == "你好" }),
"n/l fuzzy candidates: \(fuzzySnapshot.candidates.map(\.text).prefix(20))"
)
fuzzyBridge.finalizeRuntime()
}
}
@@ -0,0 +1,70 @@
// RimeSchemaGeneratorTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
final class RimeSchemaGeneratorTests: XCTestCase {
func testFuzzyRulesDefaultToOff() {
XCTAssertTrue(RimeSchemaGenerator.fuzzyRules([]).isEmpty)
}
func testFuzzyRulesAreOptInAndSymmetric() {
let rules = RimeSchemaGenerator.fuzzyRules([.nL, .anAng])
XCTAssertTrue(rules.contains("derive/^n/l/"))
XCTAssertTrue(rules.contains("derive/^l/n/"))
XCTAssertTrue(rules.contains("derive/ang$/an/"))
XCTAssertTrue(rules.contains("derive/an$/ang/"))
XCTAssertFalse(rules.contains { $0.contains("eng") })
}
func testMicrosoftAndSogouExposeIngSemicolonMapping() {
for schema in [
TypingInputSchema.microsoftDoublePinyin,
TypingInputSchema.sogouDoublePinyin
] {
let yaml = RimeSchemaGenerator.schema(for: schema, fuzzyPairs: [])
XCTAssertTrue(yaml.contains("xform/ing$/;/"))
XCTAssertTrue(yaml.contains("xform/^sh/U/"))
XCTAssertTrue(yaml.contains("xform/^ch/I/"))
XCTAssertTrue(yaml.contains("xform/^zh/V/"))
}
}
func testFuzzyRulesPrecedeDoublePinyinTransforms() throws {
let yaml = RimeSchemaGenerator.schema(
for: .microsoftDoublePinyin,
fuzzyPairs: [.nL]
)
let fuzzy = try XCTUnwrap(yaml.range(of: "derive/^n/l/"))
let transform = try XCTUnwrap(yaml.range(of: "xform/iu$/Q/"))
XCTAssertLessThan(fuzzy.lowerBound, transform.lowerBound)
}
@MainActor
func testTypingConfigurationDefaultsToFullPinyinWithoutFuzzyPairs() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let configuration = TypingInputConfiguration(defaults: defaults)
XCTAssertEqual(configuration.schema, .fullPinyin)
XCTAssertTrue(configuration.fuzzyPairs.isEmpty)
XCTAssertFalse(configuration.defaultToTyping)
}
@MainActor
func testDefaultTypingPreferencePersistsAndDefaultsToOff() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertFalse(TypingInputConfiguration.prefersTypingOnOpen(defaults: defaults))
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
XCTAssertTrue(TypingInputConfiguration.prefersTypingOnOpen(defaults: defaults))
XCTAssertTrue(TypingInputConfiguration(defaults: defaults).defaultToTyping)
}
}
+3 -1
View File
@@ -34,6 +34,8 @@ struct MacOpenSourceLicensesView: View {
let uiLanguage: AppUILanguage
@Environment(\.themePalette) private var palette
private let entries = OpenSourceLicenseCatalog.entries(for: .macOS)
var body: some View {
List {
Section {
@@ -44,7 +46,7 @@ struct MacOpenSourceLicensesView: View {
}
Section {
ForEach(OpenSourceLicenseCatalog.entries) { entry in
ForEach(entries) { entry in
NavigationLink {
MacOpenSourceLicenseDetailView(entry: entry, uiLanguage: uiLanguage)
} label: {
@@ -0,0 +1,13 @@
// KeyboardChromeLayout.swift
// OSGKeyboard · Shared
//
// Cross-surface dimensions that must stay identical in voice and typing modes.
import CoreGraphics
public enum KeyboardChromeLayout {
public static let totalHeight: CGFloat = 281
public static let actionKeyHeight: CGFloat = 50
public static let actionKeyCornerRadius: CGFloat = 10
public static let horizontalInset: CGFloat = 8
}
@@ -0,0 +1,155 @@
// TypingInputConfiguration.swift
// OSGKeyboard · Shared
//
// App Group-backed Chinese input settings shared by the host app and
// keyboard extension. Fuzzy pairs are opt-in to avoid noisy candidates.
import Foundation
import Combine
public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Sendable {
case fullPinyin = "osg_pinyin"
case microsoftDoublePinyin = "osg_double_pinyin_mspy"
case sogouDoublePinyin = "osg_double_pinyin_sogou"
public var id: String { rawValue }
public var shortLabel: String {
switch self {
case .fullPinyin: return ""
case .microsoftDoublePinyin: return ""
case .sogouDoublePinyin: return ""
}
}
public var displayName: String {
switch self {
case .fullPinyin: return "全拼"
case .microsoftDoublePinyin: return "微软双拼"
case .sogouDoublePinyin: return "搜狗双拼"
}
}
}
public enum PinyinFuzzyPair: String, CaseIterable, Identifiable, Codable, Sendable {
case zhZ
case chC
case shS
case nL
case fH
case anAng
case enEng
case inIng
public var id: String { rawValue }
public var displayName: String {
switch self {
case .zhZ: return "zh ↔ z"
case .chC: return "ch ↔ c"
case .shS: return "sh ↔ s"
case .nL: return "n ↔ l"
case .fH: return "f ↔ h"
case .anAng: return "an ↔ ang"
case .enEng: return "en ↔ eng"
case .inIng: return "in ↔ ing"
}
}
}
public struct TypingInputConfigurationSnapshot: Equatable, Sendable {
public let schema: TypingInputSchema
public let fuzzyPairs: Set<PinyinFuzzyPair>
public init(schema: TypingInputSchema, fuzzyPairs: Set<PinyinFuzzyPair>) {
self.schema = schema
self.fuzzyPairs = fuzzyPairs
}
}
@MainActor
public final class TypingInputConfiguration: ObservableObject {
public static let shared = TypingInputConfiguration()
private enum Key {
static let schema = "typing.input.schema"
static let fuzzyPairs = "typing.input.fuzzyPairs"
static let defaultToTyping = "typing.input.defaultToTyping"
static let resourceVersion = "typing.rime.resourceVersion"
}
private let defaults: UserDefaults
private var isHydrating = true
@Published public var schema: TypingInputSchema {
didSet { persistIfReady() }
}
@Published public var fuzzyPairs: Set<PinyinFuzzyPair> {
didSet { persistIfReady() }
}
/// Selects the text keyboard whenever the extension becomes visible.
@Published public var defaultToTyping: Bool {
didSet { persistIfReady() }
}
public init(defaults: UserDefaults? = nil) {
self.defaults = defaults ?? AppGroup.defaults
let schemaId = self.defaults.string(forKey: Key.schema) ?? ""
schema = TypingInputSchema(rawValue: schemaId) ?? .fullPinyin
let fuzzyIds = self.defaults.stringArray(forKey: Key.fuzzyPairs) ?? []
fuzzyPairs = Set(fuzzyIds.compactMap(PinyinFuzzyPair.init(rawValue:)))
defaultToTyping = self.defaults.bool(forKey: Key.defaultToTyping)
isHydrating = false
}
public var snapshot: TypingInputConfigurationSnapshot {
TypingInputConfigurationSnapshot(schema: schema, fuzzyPairs: fuzzyPairs)
}
public func setFuzzyPair(_ pair: PinyinFuzzyPair, enabled: Bool) {
if enabled {
fuzzyPairs.insert(pair)
} else {
fuzzyPairs.remove(pair)
}
}
public func reload() {
isHydrating = true
schema = TypingInputSchema(rawValue: defaults.string(forKey: Key.schema) ?? "")
?? .fullPinyin
let fuzzyIds = defaults.stringArray(forKey: Key.fuzzyPairs) ?? []
fuzzyPairs = Set(fuzzyIds.compactMap(PinyinFuzzyPair.init(rawValue:)))
defaultToTyping = defaults.bool(forKey: Key.defaultToTyping)
isHydrating = false
}
nonisolated public static func prefersTypingOnOpen(
defaults: UserDefaults? = nil
) -> Bool {
(defaults ?? AppGroup.defaultsIfAvailable)?.bool(forKey: Key.defaultToTyping) ?? false
}
nonisolated public static func installedResourceVersion(
defaults: UserDefaults? = nil
) -> String? {
(defaults ?? AppGroup.defaultsIfAvailable)?.string(forKey: Key.resourceVersion)
}
nonisolated public static func setInstalledResourceVersion(
_ value: String,
defaults: UserDefaults? = nil
) {
(defaults ?? AppGroup.defaultsIfAvailable)?.set(value, forKey: Key.resourceVersion)
}
private func persistIfReady() {
guard !isHydrating else { return }
defaults.set(schema.rawValue, forKey: Key.schema)
defaults.set(fuzzyPairs.map(\.rawValue).sorted(), forKey: Key.fuzzyPairs)
defaults.set(defaultToTyping, forKey: Key.defaultToTyping)
AppGroupConfigDarwin.postConfigChanged()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2013 Sun Junyi
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 mozillazg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 mozillazg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,202 @@
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,32 @@
BSD 3-Clause License
Copyright (c) 2026, librime-xcframework contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,29 @@
Typing keyboard third-party notices
===================================
Runtime
-------
librime-xcframework 1.17.0-pack.1 and librime 1.17.0 (BSD-3-Clause).
See LICENSE.txt, THIRD_PARTY_NOTICES.md and third-party-notices.zip bundled
with this file for full binary dependency license texts.
Dictionary data
---------------
- rime-pinyin-simp @ 0c6861ef7420ee780270ca6d993d18d4101049d0
Apache License 2.0
- fxsjy/jieba @ 67fa2e36e72f69d9134b8a1037b83fbb070b9775
MIT License
- mozillazg/phrase-pinyin-data @ cee0ed6e6e4898580cafd2bd5e3723e20b214aa0
MIT License
- mozillazg/pinyin-data @ 923b108dc5d45dee061324c011b478fb649f8b73
MIT License
The generated manifest.json records exact source URLs and SHA-256 values.
OSGKeyboard's schema and transformation code is project-owned.
Explicitly not distributed
--------------------------
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;
all required copyright and permission notices must remain with distributions.
@@ -0,0 +1,45 @@
{
"formatVersion": 1,
"sources": {
"pinyin_simp": {
"license": "Apache-2.0",
"commit": "0c6861ef7420ee780270ca6d993d18d4101049d0",
"url": "https://raw.githubusercontent.com/rime/rime-pinyin-simp/0c6861ef7420ee780270ca6d993d18d4101049d0/pinyin_simp.dict.yaml",
"sha256": "e341598343a0f0f2035bb1aafc34a7f3bb7887deeecb3f60796262aaa2983e6b"
},
"jieba": {
"license": "MIT",
"commit": "67fa2e36e72f69d9134b8a1037b83fbb070b9775",
"url": "https://raw.githubusercontent.com/fxsjy/jieba/67fa2e36e72f69d9134b8a1037b83fbb070b9775/jieba/dict.txt",
"sha256": "7197c3211ddd98962b036cdf40324d1ea2bfaa12bd028e68faa70111a88e12a8"
},
"phrase_pinyin": {
"license": "MIT",
"commit": "cee0ed6e6e4898580cafd2bd5e3723e20b214aa0",
"url": "https://raw.githubusercontent.com/mozillazg/phrase-pinyin-data/cee0ed6e6e4898580cafd2bd5e3723e20b214aa0/pinyin.txt",
"sha256": "dcc769607c220b312fea3e71cb63421298b4b891b1f7356a95ab58f2c96fff81"
},
"character_pinyin": {
"license": "MIT",
"commit": "923b108dc5d45dee061324c011b478fb649f8b73",
"url": "https://raw.githubusercontent.com/mozillazg/pinyin-data/923b108dc5d45dee061324c011b478fb649f8b73/pinyin.txt",
"sha256": "621f8ca9eff8519f47e2b17b564fd318161e13bca07eea8c8e04993cd5d3b52e"
}
},
"statistics": {
"baselineEntries": 65125,
"jiebaWordsAccepted": 337338,
"jiebaWordsUsingCharacterFallback": 293794,
"outputEntries": 364926
},
"output": {
"file": "osg_pinyin.dict.yaml",
"sha256": "35b0664df8906712e4051392d83be35bf76587e4c9a7b068a71e0d6f7b989425"
},
"excluded": [
"rime-ice (GPL-3.0)",
"rime-double-pinyin (GPL-3.0)",
"rime-essay (LGPL-3.0)",
"rime-luna-pinyin (LGPL-3.0)"
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
# Third-Party Notices
This repository packages upstream `librime` and its build dependencies into
macOS and iOS XCFramework release artifacts.
The wrapper scripts, manifests, and documentation in this repository are
licensed under the BSD 3-Clause License. See `LICENSE`.
Binary release artifacts include upstream `librime` and may include statically
linked third-party dependency code resolved by vcpkg. Keep these notices with
any redistributed binary artifacts.
Release assets include:
- `LICENSE.txt`: the license for this packaging wrapper.
- `THIRD_PARTY_NOTICES.md`: this overview and the upstream `librime` notice.
- `third-party-notices.zip`: vcpkg-provided license texts for bundled
third-party dependencies.
## Upstream librime
Upstream project: <https://github.com/rime/librime>
License: BSD 3-Clause License
```text
Copyright (c) 2014, RIME Developers
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
@@ -68,7 +68,18 @@ public final class KeyboardState: ObservableObject {
public var labelKey: String { "mode.polish" }
}
/// Keyboard chrome surface. Voice is the default product mode; typing is
/// a secondary QWERTY / pinyin surface for quick corrections.
public enum Surface: String, CaseIterable, Identifiable, Sendable {
case voice
case typing
public var id: String { rawValue }
}
@Published public var phase: Phase = .idle
/// Active chrome. Forced to `.voice` while recording / processing.
@Published public var surface: Surface = .voice
@Published public var level: Double = 0
@Published public var mode: InputMode = .polish
@Published public var localeId: String = "auto"
@@ -232,6 +243,20 @@ public final class KeyboardState: ObservableObject {
/// Cursor-drag pad press lifecycle updates `cursorDragActive` and
/// lets the view controller reset vertical-navigation stickiness.
public var setCursorDragActive: (Bool) -> Void = { _ in }
/// Switch voice typing. No-ops when voice pipeline is active.
public var setSurface: (Surface) -> Void = { _ in }
/// Recording / processing must stay on the voice surface.
public var locksTypingSurface: Bool {
switch phase {
case .requestingPermissions, .recording, .processing:
return true
default:
return false
}
}
public var canEnterTypingSurface: Bool { !locksTypingSurface }
// MARK: - Preview helpers (DEBUG only)
@@ -0,0 +1,181 @@
// LibrimeEngine.swift
// OSGKeyboard · Shared
//
// Production Chinese IME backed by librime. Runtime access stays on the
// main actor because UIInputViewController and its text proxy are main-only;
// expensive schema deployment is performed by the host app beforehand.
import Foundation
@MainActor
public final class LibrimeEngine: RimeEngineBridging {
public private(set) var composition: TypingComposition = .empty
public private(set) var isReady = false
public private(set) var schema: TypingInputSchema
private var language: TypingInputLanguage = .chinese
private var bridge: OSGRimeBridge?
private let configurationProvider: () -> TypingInputConfigurationSnapshot
private let candidateLimit: Int
public init(
schema: TypingInputSchema = .fullPinyin,
candidateLimit: Int = 50,
configurationProvider: @escaping () -> TypingInputConfigurationSnapshot = {
TypingInputConfiguration.shared.snapshot
}
) {
self.schema = schema
self.candidateLimit = candidateLimit
self.configurationProvider = configurationProvider
}
public func prepare() async throws {
if isReady { return }
guard RimeResourceInstaller.isReady else {
throw RimeResourceError.resourcesNotInstalled
}
let paths = try RimeResourcePaths.resolve()
let runtime = OSGRimeBridge(
sharedDataDirectory: paths.sharedData.path,
userDataDirectory: paths.userData.path,
distributionVersion: RimeResourceInstaller.resourceVersion
)
try runtime.start()
let configured = configurationProvider()
schema = configured.schema
guard runtime.selectSchema(schema.rawValue) else {
runtime.stopSession()
throw LibrimeEngineError.schemaUnavailable(schema.rawValue)
}
_ = runtime.setASCIIMode(language == .english)
bridge = runtime
isReady = true
_ = refresh()
}
public func teardown() {
bridge?.clearComposition()
bridge?.stopSession()
bridge = nil
composition = .empty
isReady = false
}
public func setLanguage(_ language: TypingInputLanguage) {
self.language = language
_ = bridge?.setASCIIMode(language == .english)
if language == .english {
bridge?.clearComposition()
composition = .empty
} else {
_ = refresh()
}
}
@discardableResult
public func setSchema(_ schema: TypingInputSchema) -> Bool {
guard let bridge else {
self.schema = schema
return false
}
bridge.clearComposition()
guard bridge.selectSchema(schema.rawValue) else { return false }
self.schema = schema
composition = .empty
return true
}
public func processCharacter(_ character: Character) -> String? {
guard language == .chinese,
let scalar = character.asciiValue,
bridge?.processKeyCode(Int32(scalar), modifiers: 0) == true else {
return nil
}
return refresh()
}
public func processBackspace() -> String? {
guard bridge?.processKeyCode(OSGRimeKeyBackSpace, modifiers: 0) == true else {
return nil
}
return refresh()
}
public func processSpace() -> String? {
guard bridge?.processKeyCode(32, modifiers: 0) == true else {
return " "
}
return refresh()
}
public func processReturn() -> String? {
guard bridge?.processKeyCode(OSGRimeKeyReturn, modifiers: 0) == true else {
return "\n"
}
return refresh()
}
public func selectCandidate(at index: Int) -> String {
guard bridge?.selectCandidate(at: index) == true else { return "" }
return refresh() ?? ""
}
public func flushPreedit() -> String {
let raw = bridge?.rawInput() ?? ""
bridge?.clearComposition()
composition = .empty
return raw
}
public func clearComposition() {
bridge?.clearComposition()
composition = .empty
}
/// Copies librime-owned memory into Sendable Swift value types and returns
/// any commit emitted by the preceding key operation.
@discardableResult
private func refresh() -> String? {
guard let snapshot = bridge?.snapshot(withCandidateLimit: candidateLimit) else {
composition = .empty
return nil
}
let preedit = snapshot.preedit
composition = TypingComposition(
preedit: preedit,
candidates: snapshot.candidates.enumerated().map { index, candidate in
TypingCandidate(
id: "\(preedit)|\(index)|\(candidate.text)",
text: candidate.text,
annotation: candidate.comment.isEmpty ? nil : candidate.comment
)
}
)
return snapshot.commitText.isEmpty ? nil : snapshot.commitText
}
}
public enum LibrimeEngineError: LocalizedError {
case schemaUnavailable(String)
public var errorDescription: String? {
switch self {
case .schemaUnavailable(let id):
return "输入方案不可用:\(id)"
}
}
}
private extension Character {
var asciiValue: UInt8? {
guard let scalar = unicodeScalars.first,
unicodeScalars.count == 1,
scalar.value <= UInt8.max else {
return nil
}
return UInt8(scalar.value)
}
}
@@ -0,0 +1,76 @@
// RimeEngineBridging.swift
// OSGKeyboard · Shared
//
// Engine façade for the typing surface. `LibrimeEngine` is the production
// implementation; the protocol keeps SwiftUI independent of the C runtime.
import Foundation
/// Input language for the typing surface (not ASR locale).
public enum TypingInputLanguage: String, CaseIterable, Identifiable, Sendable {
case chinese
case english
public var id: String { rawValue }
public var shortLabel: String {
switch self {
case .chinese: return ""
case .english: return ""
}
}
}
/// One candidate row item after composing.
public struct TypingCandidate: Identifiable, Equatable, Sendable {
public let id: String
public let text: String
public let annotation: String?
public init(id: String = UUID().uuidString, text: String, annotation: String? = nil) {
self.id = id
self.text = text
self.annotation = annotation
}
}
/// Snapshot the UI observes while composing.
public struct TypingComposition: Equatable, Sendable {
public var preedit: String
public var candidates: [TypingCandidate]
public init(preedit: String = "", candidates: [TypingCandidate] = []) {
self.preedit = preedit
self.candidates = candidates
}
public static let empty = TypingComposition()
}
/// Bridge between key events and IME state. Keep this small so Phase 2
/// can swap KeyboardKit-style shells or librime without UI rewrites.
@MainActor
public protocol RimeEngineBridging: AnyObject {
var composition: TypingComposition { get }
var isReady: Bool { get }
var schema: TypingInputSchema { get }
/// Load dictionaries / open session. Safe to call repeatedly.
func prepare() async throws
/// Drop heavy caches (leave typing mode / memory warning).
func teardown()
func setLanguage(_ language: TypingInputLanguage)
@discardableResult
func setSchema(_ schema: TypingInputSchema) -> Bool
/// Process one key and return newly committed text, if any.
func processCharacter(_ character: Character) -> String?
func processBackspace() -> String?
func processSpace() -> String?
func processReturn() -> String?
/// Commit candidate at index; returns text to insert (empty if invalid).
func selectCandidate(at index: Int) -> String
/// Force-commit current preedit as raw latin (or empty).
func flushPreedit() -> String
func clearComposition()
}
@@ -0,0 +1,169 @@
// RimeResourceInstaller.swift
// OSGKeyboard · Shared
//
// The host app owns Rime deployment. The keyboard extension only opens
// an already-built session, keeping expensive maintenance work out of
// the extension's constrained lifecycle.
import Foundation
import Darwin
public enum RimeResourceError: LocalizedError {
case appGroupUnavailable
case bundledResourceMissing(String)
case lockUnavailable
case deploymentFailed
case resourcesNotInstalled
public var errorDescription: String? {
switch self {
case .appGroupUnavailable:
return "App Group 不可用"
case .bundledResourceMissing(let name):
return "缺少输入法资源:\(name)"
case .lockUnavailable:
return "输入法资源正在被其他进程更新"
case .deploymentFailed:
return "输入法资源部署失败"
case .resourcesNotInstalled:
return "请先打开 OSGKeyboard 完成输入法初始化"
}
}
}
public struct RimeResourcePaths: Sendable {
public let root: URL
public let sharedData: URL
public let userData: URL
public let lockFile: URL
public static func resolve() throws -> RimeResourcePaths {
guard let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: AppGroup.identifier
) else {
throw RimeResourceError.appGroupUnavailable
}
let root = container.appendingPathComponent("Rime", isDirectory: true)
return RimeResourcePaths(
root: root,
sharedData: root.appendingPathComponent("SharedSupport", isDirectory: true),
userData: root.appendingPathComponent("UserData", isDirectory: true),
lockFile: root.appendingPathComponent(".deployment.lock")
)
}
}
public actor RimeResourceInstaller {
public static let shared = RimeResourceInstaller()
public static let resourceVersion = "2.0.0"
public init() {}
public static var isReady: Bool {
guard TypingInputConfiguration.installedResourceVersion() == resourceVersion,
let paths = try? RimeResourcePaths.resolve() else {
return false
}
return FileManager.default.fileExists(
atPath: paths.userData.appendingPathComponent("build").path
)
}
/// Installs source data and asks librime to prebuild schemas. Call only
/// from the host app, never from the keyboard extension.
public func installIfNeeded(
configuration: TypingInputConfigurationSnapshot,
force: Bool = false
) throws {
if !force, Self.isReady { return }
let paths = try RimeResourcePaths.resolve()
let fileManager = FileManager.default
try fileManager.createDirectory(at: paths.root, withIntermediateDirectories: true)
try fileManager.createDirectory(at: paths.userData, withIntermediateDirectories: true)
let descriptor = open(paths.lockFile.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
guard descriptor >= 0 else { throw RimeResourceError.lockUnavailable }
defer {
flock(descriptor, LOCK_UN)
close(descriptor)
}
guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else {
throw RimeResourceError.lockUnavailable
}
let staging = paths.root.appendingPathComponent(
"SharedSupport.staging-\(UUID().uuidString)",
isDirectory: true
)
defer { try? fileManager.removeItem(at: staging) }
try fileManager.createDirectory(at: staging, withIntermediateDirectories: true)
for resource in ["osg_pinyin.dict", "manifest"] {
let ext = resource == "manifest" ? "json" : "yaml"
guard let source = Bundle(for: RimeResourceBundleToken.self).url(
forResource: resource,
withExtension: ext
) else {
throw RimeResourceError.bundledResourceMissing("\(resource).\(ext)")
}
try fileManager.copyItem(
at: source,
to: staging.appendingPathComponent("\(resource).\(ext)")
)
}
try RimeSchemaGenerator.defaultConfiguration().write(
to: staging.appendingPathComponent("default.yaml"),
atomically: true,
encoding: .utf8
)
for schema in TypingInputSchema.allCases {
try RimeSchemaGenerator.schema(
for: schema,
fuzzyPairs: configuration.fuzzyPairs
).write(
to: staging.appendingPathComponent("\(schema.rawValue).schema.yaml"),
atomically: true,
encoding: .utf8
)
}
if fileManager.fileExists(atPath: paths.sharedData.path) {
try fileManager.removeItem(at: paths.sharedData)
}
try fileManager.moveItem(at: staging, to: paths.sharedData)
let bridge = OSGRimeBridge(
sharedDataDirectory: paths.sharedData.path,
userDataDirectory: paths.userData.path,
distributionVersion: Self.resourceVersion
)
do {
try bridge.deploy(withFullCheck: true)
} catch {
bridge.finalizeRuntime()
throw error
}
bridge.finalizeRuntime()
TypingInputConfiguration.setInstalledResourceVersion(Self.resourceVersion)
AppGroupConfigDarwin.postConfigChanged()
}
public func syncUserData() throws {
let paths = try RimeResourcePaths.resolve()
let bridge = OSGRimeBridge(
sharedDataDirectory: paths.sharedData.path,
userDataDirectory: paths.userData.path,
distributionVersion: Self.resourceVersion
)
try bridge.start()
// Destroying the session and finalizing librime flushes LevelDB
// user dictionaries. `sync_user_data` is for external Rime sync
// deployments and is intentionally not needed here.
bridge.finalizeRuntime()
}
}
private final class RimeResourceBundleToken: NSObject {}
@@ -0,0 +1,187 @@
// RimeSchemaGenerator.swift
// OSGKeyboard · Shared
//
// Generates OSG-owned schemas from public Microsoft/Sogou key maps.
// No GPL Rime schema files are copied or distributed.
import Foundation
public enum RimeSchemaGenerator {
public static func defaultConfiguration() -> String {
"""
# Generated by OSGKeyboard.
config_version: "1.0"
schema_list:
- schema: \(TypingInputSchema.fullPinyin.rawValue)
- schema: \(TypingInputSchema.microsoftDoublePinyin.rawValue)
- schema: \(TypingInputSchema.sogouDoublePinyin.rawValue)
switcher:
caption: 输入方案
hotkeys: []
menu:
page_size: 9
ascii_composer:
good_old_caps_lock: true
switch_key:
Shift_L: noop
Shift_R: noop
Control_L: noop
Control_R: noop
key_binder:
bindings: []
recognizer:
patterns: {}
"""
}
public static func schema(
for inputSchema: TypingInputSchema,
fuzzyPairs: Set<PinyinFuzzyPair>
) -> String {
let alphabet = inputSchema == .fullPinyin
? "zyxwvutsrqponmlkjihgfedcba"
: "zyxwvutsrqponmlkjihgfedcba;"
let algebra = fuzzyRules(fuzzyPairs) + algebraRules(for: inputSchema)
let algebraYAML = algebra.map { " - '\($0)'" }.joined(separator: "\n")
return """
# Generated by OSGKeyboard. Do not edit; change settings in the host app.
schema:
schema_id: \(inputSchema.rawValue)
name: \(inputSchema.displayName)
version: "1.0"
author:
- OSGKeyboard contributors
description: |
Commercially permissive OSG schema backed by osg_pinyin.
switches:
- name: ascii_mode
reset: 0
states: [中, 英]
engine:
processors:
- ascii_composer
- recognizer
- key_binder
- speller
- punctuator
- selector
- navigator
- express_editor
segmentors:
- ascii_segmentor
- matcher
- abc_segmentor
- punct_segmentor
- fallback_segmentor
translators:
- punct_translator
- script_translator
speller:
alphabet: "\(alphabet)"
initials: "\(alphabet.replacingOccurrences(of: ";", with: ""))"
delimiter: " '"
algebra:
\(algebraYAML)
translator:
dictionary: osg_pinyin
prism: \(inputSchema.rawValue)
enable_sentence: true
enable_completion: true
enable_user_dict: true
initial_quality: 1.2
punctuator:
half_shape:
",": ""
".": ""
"?": ""
"!": ""
key_binder:
import_preset: default
recognizer:
import_preset: default
"""
}
/// Rules run against full-pinyin dictionary codes before double-pinyin
/// transforms, so fuzzy pairs work consistently in all three schemas.
public static func fuzzyRules(_ enabled: Set<PinyinFuzzyPair>) -> [String] {
var rules: [String] = []
for pair in PinyinFuzzyPair.allCases where enabled.contains(pair) {
switch pair {
case .zhZ:
rules += ["derive/^zh/z/", "derive/^z([^h])/zh$1/"]
case .chC:
rules += ["derive/^ch/c/", "derive/^c([^h])/ch$1/"]
case .shS:
rules += ["derive/^sh/s/", "derive/^s([^h])/sh$1/"]
case .nL:
rules += ["derive/^n/l/", "derive/^l/n/"]
case .fH:
rules += ["derive/^f/h/", "derive/^h/f/"]
case .anAng:
rules += ["derive/ang$/an/", "derive/an$/ang/"]
case .enEng:
rules += ["derive/eng$/en/", "derive/en$/eng/"]
case .inIng:
rules += ["derive/ing$/in/", "derive/in$/ing/"]
}
}
return rules
}
private static func algebraRules(for schema: TypingInputSchema) -> [String] {
switch schema {
case .fullPinyin:
return [
"derive/^([jqxy])u$/$1v/",
"abbrev/^([a-z]).+$/$1/"
]
case .microsoftDoublePinyin, .sogouDoublePinyin:
// Microsoft and Sogou's commonly shipped layouts are key-compatible.
// Uppercase markers prevent transforms from matching each other.
return [
"erase/^xx$/",
"derive/^([jqxy])u$/$1v/",
"derive/^([aoe].*)$/o$1/",
"xform/^([ae])(.*)$/$1$1$2/",
"xform/iu$/Q/",
"xform/[iu]a$/W/",
"xform/er$|[uv]an$/R/",
"xform/[uv]e$/T/",
"xform/v$|uai$/Y/",
"xform/^sh/U/",
"xform/^ch/I/",
"xform/^zh/V/",
"xform/uo$/O/",
"xform/[uv]n$/P/",
"xform/(.)i?ong$/$1S/",
"xform/[iu]ang$/D/",
"xform/(.)en$/$1F/",
"xform/(.)eng$/$1G/",
"xform/(.)ang$/$1H/",
"xform/ian$/M/",
"xform/(.)an$/$1J/",
"xform/iao$/C/",
"xform/(.)ao$/$1K/",
"xform/(.)ai$/$1L/",
"xform/(.)ei$/$1Z/",
"xform/ie$/X/",
"xform/ui$/V/",
"derive/T$/V/",
"xform/(.)ou$/$1B/",
"xform/in$/N/",
"xform/ing$/;/",
"xlit/QWRTYUIOPSDFGHMJCKLZXVBN/qwrtyuiopsdfghmjcklzxvbn/"
]
}
}
}
@@ -0,0 +1,52 @@
// TypingLayoutProviding.swift
// OSGKeyboard · Shared
//
// Phase 2 escape hatch: replace the in-repo SwiftUI key shell with a
// KeyboardKit-based (or other) layout without changing the engine bridge.
import Foundation
/// Page shown by the typing key shell.
public enum TypingKeyPage: String, CaseIterable, Sendable {
case letters
case numbers
case symbols
}
/// Abstraction over which characters the current page shows.
/// The Phase 1 SwiftUI keyboard reads this; a future Kit-backed shell
/// can feed the same consumer.
public protocol TypingLayoutProviding: Sendable {
func rows(for page: TypingKeyPage, shiftActive: Bool) -> [[String]]
}
/// Standard phone QWERTY + 123 + light symbols (NanoMouse / system-like).
public struct StandardTypingLayout: TypingLayoutProviding {
public init() {}
public func rows(for page: TypingKeyPage, shiftActive: Bool) -> [[String]] {
switch page {
case .letters:
let upper = shiftActive
let map: (String) -> String = { upper ? $0.uppercased() : $0 }
return [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"].map(map),
["a", "s", "d", "f", "g", "h", "j", "k", "l"].map(map),
["", "z", "x", "c", "v", "b", "n", "m", ""].map { $0.count == 1 ? map($0) : $0 }
]
case .numbers:
return [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
["#+=", ".", ",", "?", "!", "'", ""]
]
case .symbols:
return [
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
["_", "\\", "|", "~", "<", ">", "", "£", "¥", "·"],
["123", ".", ",", "?", "!", "'", ""],
["", "", "", "", "", "", ""]
]
}
}
}
@@ -0,0 +1,191 @@
// TypingSessionController.swift
// OSGKeyboard · Shared
//
// Owns the typing-surface engine + layout provider. Injected into the
// keyboard extension; torn down when leaving typing mode.
import Foundation
import Combine
@MainActor
public final class TypingSessionController: ObservableObject {
@Published public private(set) var language: TypingInputLanguage = .chinese
@Published public private(set) var page: TypingKeyPage = .letters
@Published public private(set) var shiftActive: Bool = false
@Published public private(set) var capsLock: Bool = false
@Published public private(set) var composition: TypingComposition = .empty
@Published public private(set) var engineReady: Bool = false
@Published public private(set) var schema: TypingInputSchema
@Published public var lastError: String?
public let layout: TypingLayoutProviding
private let engine: RimeEngineBridging
private var prepared = false
public init(
engine: RimeEngineBridging = LibrimeEngine(),
layout: TypingLayoutProviding = StandardTypingLayout()
) {
self.engine = engine
self.layout = layout
schema = engine.schema
}
public var keyRows: [[String]] {
var rows = layout.rows(for: page, shiftActive: shiftActive || capsLock)
if page == .letters,
language == .chinese,
schema != .fullPinyin,
rows.indices.contains(2),
rows[2].first == "" {
// Microsoft/Sogou use semicolon for "ing"; Chinese composition
// does not need Shift, so keep the standard row width stable.
rows[2][0] = ";"
}
return rows
}
public func enterTypingMode() {
TypingInputConfiguration.shared.reload()
Task { await prepareIfNeeded() }
}
public func leaveTypingMode() {
engine.teardown()
prepared = false
engineReady = false
composition = .empty
page = .letters
shiftActive = false
capsLock = false
}
public func toggleLanguage() -> String {
let next: TypingInputLanguage = language == .chinese ? .english : .chinese
return setLanguage(next)
}
/// Selects a specific language for the shared voice / Chinese / English
/// capsule. Any active preedit is returned so callers can commit it
/// before switching modes.
public func setLanguage(_ newLanguage: TypingInputLanguage) -> String {
guard language != newLanguage else { return "" }
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
language = newLanguage
engine.setLanguage(newLanguage)
composition = engine.composition
page = .letters
return raw
}
/// Flushes raw preedit, selects the next built-in scheme, and returns the
/// raw text that the caller should insert before switching.
public func cycleSchema() -> String {
let raw = composition.preedit.isEmpty ? "" : engine.flushPreedit()
let schemas = TypingInputSchema.allCases
let current = schemas.firstIndex(of: schema) ?? 0
let next = schemas[(current + 1) % schemas.count]
if engine.setSchema(next) {
schema = next
TypingInputConfiguration.shared.schema = next
}
composition = engine.composition
return raw
}
public func setPage(_ page: TypingKeyPage) {
self.page = page
shiftActive = false
}
/// Handle a visible key label. Returns text the proxy should insert now
/// (may be empty when composing Chinese).
public func handleKey(_ label: String) -> String {
switch label {
case "":
if shiftActive {
capsLock = true
shiftActive = false
} else if capsLock {
capsLock = false
} else {
shiftActive = true
}
return ""
case "":
if language == .chinese, !engine.composition.preedit.isEmpty {
let committed = engine.processBackspace() ?? ""
composition = engine.composition
return committed
}
return "\u{8}" // sentinel: caller deletes backward
case "123":
setPage(.numbers)
return ""
case "#+=":
setPage(.symbols)
return ""
case "ABC", "abc":
setPage(.letters)
return ""
default:
break
}
if page != .letters {
// Number/symbol: insert directly
let out = label
if !capsLock { shiftActive = false }
return out
}
guard let ch = label.first else { return "" }
if language == .english {
let out = String(ch)
if !capsLock { shiftActive = false }
return out
}
// Chinese letters compose
let committed = engine.processCharacter(ch) ?? ""
composition = engine.composition
if !capsLock { shiftActive = false }
return committed
}
public func handleSpace() -> String {
if language == .english { return " " }
let text = engine.processSpace() ?? " "
composition = engine.composition
return text
}
public func handleReturn() -> String {
if language == .english { return "\n" }
let text = engine.processReturn() ?? "\n"
composition = engine.composition
return text
}
public func selectCandidate(at index: Int) -> String {
let text = engine.selectCandidate(at: index)
composition = engine.composition
return text
}
private func prepareIfNeeded() async {
guard !prepared else { return }
do {
try await engine.prepare()
engine.setLanguage(language)
prepared = true
engineReady = engine.isReady
schema = engine.schema
lastError = nil
} catch {
lastError = error.localizedDescription
engineReady = false
}
}
}
@@ -0,0 +1,48 @@
// OpenSourceLicenseCatalogTests.swift
// OSGKeyboard · Unit Tests
import XCTest
@testable import OSGKeyboard
final class OpenSourceLicenseCatalogTests: XCTestCase {
func testIOSCatalogIncludesRimeAndDictionaryDataOnly() {
let entries = OpenSourceLicenseCatalog.entries(for: .iOS)
let ids = Set(entries.map(\.id))
XCTAssertTrue(ids.contains("librime-static"))
XCTAssertTrue(ids.contains("rime-pinyin-simp"))
XCTAssertTrue(ids.contains("jieba"))
XCTAssertTrue(ids.contains("phrase-pinyin-data"))
XCTAssertTrue(ids.contains("pinyin-data"))
XCTAssertFalse(ids.contains("mlx-audio-swift"))
}
func testMacCatalogExcludesIOSRimeStack() {
let entries = OpenSourceLicenseCatalog.entries(for: .macOS)
let ids = Set(entries.map(\.id))
XCTAssertEqual(ids, ["mlx-audio-swift"])
}
func testBundledDictionaryLicensesKeepCopyrightNotices() throws {
let entries = OpenSourceLicenseCatalog.entries(for: .iOS)
let jieba = try XCTUnwrap(entries.first { $0.id == "jieba" })
let phrase = try XCTUnwrap(entries.first { $0.id == "phrase-pinyin-data" })
let pinyin = try XCTUnwrap(entries.first { $0.id == "pinyin-data" })
XCTAssertTrue(jieba.licenseText.contains("Copyright (c) 2013 Sun Junyi"))
XCTAssertTrue(phrase.licenseText.contains("Copyright (c) 2017 mozillazg"))
XCTAssertTrue(pinyin.licenseText.contains("Copyright (c) 2016 mozillazg"))
}
func testLibrimeEntryContainsTransitiveDependencyNotices() throws {
let entry = try XCTUnwrap(
OpenSourceLicenseCatalog.entries(for: .iOS)
.first { $0.id == "librime-static" }
)
XCTAssertTrue(entry.licenseText.contains("opencc.txt"))
XCTAssertTrue(entry.licenseText.contains("leveldb.txt"))
XCTAssertTrue(entry.licenseText.contains("yaml-cpp.txt"))
}
}
+250
View File
@@ -0,0 +1,250 @@
#import "include/OSGRimeBridge.h"
@import RimeStatic;
const int OSGRimeKeyBackSpace = 0xff08;
const int OSGRimeKeyReturn = 0xff0d;
static NSString *const OSGRimeErrorDomain = @"com.osgkeyboard.rime";
static RimeApi_stdbool *OSGRimeAPI(void) {
return rime_get_api_stdbool();
}
@implementation OSGRimeCandidate
- (instancetype)initWithText:(NSString *)text comment:(NSString *)comment {
self = [super init];
if (self) {
_text = [text copy];
_comment = [comment copy];
}
return self;
}
@end
@implementation OSGRimeSnapshot
- (instancetype)initWithPreedit:(NSString *)preedit
candidates:(NSArray<OSGRimeCandidate *> *)candidates
commitText:(NSString *)commitText
composing:(BOOL)composing {
self = [super init];
if (self) {
_preedit = [preedit copy];
_candidates = [candidates copy];
_commitText = [commitText copy];
_composing = composing;
}
return self;
}
@end
@interface OSGRimeBridge ()
@property(nonatomic, copy) NSString *sharedDataDirectory;
@property(nonatomic, copy) NSString *userDataDirectory;
@property(nonatomic, copy) NSString *distributionVersion;
@property(nonatomic) RimeSessionId session;
@end
@implementation OSGRimeBridge
static BOOL sSetupComplete = NO;
static BOOL sInitialized = NO;
- (instancetype)initWithSharedDataDirectory:(NSString *)sharedDataDirectory
userDataDirectory:(NSString *)userDataDirectory
distributionVersion:(NSString *)distributionVersion {
self = [super init];
if (self) {
_sharedDataDirectory = [sharedDataDirectory copy];
_userDataDirectory = [userDataDirectory copy];
_distributionVersion = [distributionVersion copy];
_session = 0;
}
return self;
}
- (BOOL)running {
return self.session != 0 && OSGRimeAPI()->find_session(self.session);
}
- (void)populateTraits:(RimeTraits *)traits {
traits->shared_data_dir = self.sharedDataDirectory.UTF8String;
traits->user_data_dir = self.userDataDirectory.UTF8String;
traits->distribution_name = "OSGKeyboard";
traits->distribution_code_name = "OSGKeyboard";
traits->distribution_version = self.distributionVersion.UTF8String;
traits->app_name = "rime.OSGKeyboard";
traits->min_log_level = 2;
}
- (BOOL)prepareRuntime:(NSError **)error {
RimeApi_stdbool *api = OSGRimeAPI();
if (api == NULL) {
if (error) {
*error = [NSError errorWithDomain:OSGRimeErrorDomain
code:1
userInfo:@{NSLocalizedDescriptionKey: @"librime API unavailable"}];
}
return NO;
}
@synchronized([OSGRimeBridge class]) {
RIME_STRUCT(RimeTraits, traits);
[self populateTraits:&traits];
if (!sSetupComplete) {
api->setup(&traits);
sSetupComplete = YES;
}
if (!sInitialized) {
api->initialize(&traits);
sInitialized = YES;
}
}
return YES;
}
- (BOOL)start:(NSError **)error {
if (![self prepareRuntime:error]) {
return NO;
}
if ([self running]) {
return YES;
}
self.session = OSGRimeAPI()->create_session();
if (self.session == 0) {
if (error) {
*error = [NSError errorWithDomain:OSGRimeErrorDomain
code:2
userInfo:@{NSLocalizedDescriptionKey: @"Unable to create Rime session"}];
}
return NO;
}
return YES;
}
- (void)stopSession {
if (self.session != 0) {
OSGRimeAPI()->destroy_session(self.session);
self.session = 0;
}
}
- (void)finalizeRuntime {
[self stopSession];
@synchronized([OSGRimeBridge class]) {
if (sInitialized) {
OSGRimeAPI()->finalize();
sInitialized = NO;
}
}
}
- (BOOL)deployWithFullCheck:(BOOL)fullCheck error:(NSError **)error {
if (![self prepareRuntime:error]) {
return NO;
}
RimeApi_stdbool *api = OSGRimeAPI();
Bool started = api->start_maintenance(fullCheck ? True : False);
if (started && api->is_maintenance_mode()) {
api->join_maintenance_thread();
}
return YES;
}
- (BOOL)selectSchema:(NSString *)schemaIdentifier {
if (![self running]) {
return NO;
}
return OSGRimeAPI()->select_schema(self.session, schemaIdentifier.UTF8String);
}
- (BOOL)setASCIIMode:(BOOL)enabled {
if (![self running]) {
return NO;
}
OSGRimeAPI()->set_option(self.session, "ascii_mode", enabled ? True : False);
return YES;
}
- (BOOL)processKeyCode:(int)keyCode modifiers:(int)modifiers {
if (![self running]) {
return NO;
}
return OSGRimeAPI()->process_key(self.session, keyCode, modifiers);
}
- (BOOL)selectCandidateAtIndex:(NSInteger)index {
if (![self running] || index < 0) {
return NO;
}
return OSGRimeAPI()->select_candidate(self.session, (size_t)index);
}
- (BOOL)commitComposition {
if (![self running]) {
return NO;
}
return OSGRimeAPI()->commit_composition(self.session);
}
- (void)clearComposition {
if ([self running]) {
OSGRimeAPI()->clear_composition(self.session);
}
}
- (NSString *)rawInput {
if (![self running]) {
return @"";
}
const char *input = OSGRimeAPI()->get_input(self.session);
return input ? [NSString stringWithUTF8String:input] : @"";
}
- (OSGRimeSnapshot *)snapshotWithCandidateLimit:(NSInteger)limit {
if (![self running]) {
return [[OSGRimeSnapshot alloc] initWithPreedit:@""
candidates:@[]
commitText:@""
composing:NO];
}
RimeApi_stdbool *api = OSGRimeAPI();
NSString *commitText = @"";
RIME_STRUCT(RimeCommit, commit);
if (api->get_commit(self.session, &commit)) {
if (commit.text) {
commitText = [NSString stringWithUTF8String:commit.text];
}
api->free_commit(&commit);
}
NSString *preedit = @"";
BOOL composing = NO;
NSMutableArray<OSGRimeCandidate *> *candidates = [NSMutableArray array];
RIME_STRUCT(RimeContext_stdbool, context);
if (api->get_context(self.session, &context)) {
composing = context.composition.length > 0;
if (context.composition.preedit) {
preedit = [NSString stringWithUTF8String:context.composition.preedit];
}
NSInteger count = MIN((NSInteger)context.menu.num_candidates, MAX((NSInteger)0, limit));
for (NSInteger index = 0; index < count; index++) {
RimeCandidate item = context.menu.candidates[index];
NSString *text = item.text ? [NSString stringWithUTF8String:item.text] : @"";
NSString *comment = item.comment ? [NSString stringWithUTF8String:item.comment] : @"";
[candidates addObject:[[OSGRimeCandidate alloc] initWithText:text comment:comment]];
}
api->free_context(&context);
}
return [[OSGRimeSnapshot alloc] initWithPreedit:preedit
candidates:candidates
commitText:commitText
composing:composing];
}
@end
+57
View File
@@ -0,0 +1,57 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Immutable candidate copied out of librime-owned memory.
@interface OSGRimeCandidate : NSObject
@property(nonatomic, copy, readonly) NSString *text;
@property(nonatomic, copy, readonly) NSString *comment;
- (instancetype)initWithText:(NSString *)text comment:(NSString *)comment;
@end
/// Immutable runtime snapshot consumed by SwiftUI.
@interface OSGRimeSnapshot : NSObject
@property(nonatomic, copy, readonly) NSString *preedit;
@property(nonatomic, copy, readonly) NSArray<OSGRimeCandidate *> *candidates;
@property(nonatomic, copy, readonly) NSString *commitText;
@property(nonatomic, readonly) BOOL composing;
- (instancetype)initWithPreedit:(NSString *)preedit
candidates:(NSArray<OSGRimeCandidate *> *)candidates
commitText:(NSString *)commitText
composing:(BOOL)composing;
@end
/// Minimal Objective-C++ façade over librime's C API.
///
/// Callers must serialize access. A bridge owns one session; setup and
/// initialization are process-global inside librime.
@interface OSGRimeBridge : NSObject
@property(nonatomic, readonly) BOOL running;
- (instancetype)initWithSharedDataDirectory:(NSString *)sharedDataDirectory
userDataDirectory:(NSString *)userDataDirectory
distributionVersion:(NSString *)distributionVersion;
- (BOOL)start:(NSError * _Nullable * _Nullable)error;
- (void)stopSession;
- (void)finalizeRuntime;
- (BOOL)deployWithFullCheck:(BOOL)fullCheck
error:(NSError * _Nullable * _Nullable)error;
- (BOOL)selectSchema:(NSString *)schemaIdentifier;
- (BOOL)setASCIIMode:(BOOL)enabled;
- (BOOL)processKeyCode:(int)keyCode modifiers:(int)modifiers;
- (BOOL)selectCandidateAtIndex:(NSInteger)index;
- (BOOL)commitComposition;
- (void)clearComposition;
- (NSString *)rawInput;
- (OSGRimeSnapshot *)snapshotWithCandidateLimit:(NSInteger)limit;
@end
/// X11-compatible key symbols used by librime.
FOUNDATION_EXPORT const int OSGRimeKeyBackSpace;
FOUNDATION_EXPORT const int OSGRimeKeyReturn;
NS_ASSUME_NONNULL_END
+18 -1
View File
@@ -47,6 +47,7 @@ Voice input for iPhone, iPad, and Mac. Speak in any app — polished text lands
| | iOS / iPadOS | macOS |
|---|:---:|:---:|
| Keyboard / global hotkey | ✅ | ✅ hold Option |
| Full / Microsoft / Sogou double pinyin | ✅ optional fuzzy pairs | — |
| Local speech recognition | ✅ SpeechAnalyzer | ✅ SenseVoice / Qwen3 |
| AI polish | ✅ | ✅ |
| Post-polish translation | ✅ | ✅ |
@@ -58,7 +59,7 @@ Voice input for iPhone, iPad, and Mac. Speak in any app — polished text lands
## 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. Cursor context is not logged or saved to voice history, and secure fields are never captured. See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/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 stays in the on-device App Group and is not uploaded. Cursor context is not logged or saved to voice history, and secure fields are never captured. See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/).
---
@@ -122,6 +123,22 @@ Append a preset in `OSGKeyboardShared/Models/LLMProvider.swift` — any OpenAI-c
---
## Acknowledgements
OSGKeyboard's voice and keyboard features build on these projects and platforms:
- [Typeless](https://typeless.com) — product inspiration for voice-first input
- [Apple SpeechAnalyzer](https://developer.apple.com/documentation/speech) — on-device iOS speech recognition
- [librime](https://github.com/rime/librime) and [librime-xcframework](https://github.com/ghostflyby/librime-xcframework) — Chinese input engine and static iOS packaging
- [NanoMouse](https://github.com/xjwhnxjwhn/nanomouse) and [Hamster](https://github.com/imfuxiao/Hamster) — references for iOS Rime lifecycle and architecture
- [rime-pinyin-simp](https://github.com/rime/rime-pinyin-simp), [Jieba](https://github.com/fxsjy/jieba), [phrase-pinyin-data](https://github.com/mozillazg/phrase-pinyin-data), and [pinyin-data](https://github.com/mozillazg/pinyin-data) — pinyin, phrase, and frequency data
- [mlx-audio-swift](https://github.com/Blaizzy/mlx-audio-swift) — local Qwen3 MLX streaming ASR on macOS
- [Google Material Icons](https://github.com/google/material-design-icons) — icon font used by the iOS app
See [Third-Party Notices](./NOTICE-TYPING.md) for exact versions and licenses, or open Settings → About → Third-Party Licenses in the app.
---
## License
[Source Available License](./LICENSE) — personal, non-commercial use only. Commercial licensing: [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com).
+19 -2
View File
@@ -49,6 +49,7 @@
| | iOS / iPadOS | macOS |
|---|:---:|:---:|
| 自定义键盘 / 全局热键 | ✅ | ✅ Option 按住说话 |
| 全拼 / 微软双拼 / 搜狗双拼 | ✅ 可选模糊音 | — |
| 本地语音识别 | ✅ Apple SpeechAnalyzer | ✅ SenseVoice / Qwen3 |
| AI 文本润色 | ✅ | ✅ |
| 润色后翻译 | ✅ | ✅ |
@@ -62,7 +63,7 @@
- **默认本地识别** — 录音在设备上转写,不经过我们的服务器
- **润色只发文字** — 发给 LLM 的是转写文本,以及用于衔接的少量光标附近文字,不是原始音频
- **不记录击键** — 光标上下文仅在润色时临时使用,不写入日志或语音历史;密码框完全不采集
- **不上传击键** — 中文候选仅在设备 App Group 中学习词频;普通击键和本地用户词库不会上传,密码框完全不采集
- 详见 [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)
---
@@ -97,6 +98,22 @@ open OSGKeyboard.xcodeproj
---
## 鸣谢
OSGKeyboard 的语音与键盘能力建立在这些优秀项目和平台之上:
- [Typeless](https://typeless.com) — voice-first 输入体验的产品灵感
- [Apple SpeechAnalyzer](https://developer.apple.com/documentation/speech) — iOS 端侧语音识别
- [librime](https://github.com/rime/librime) 与 [librime-xcframework](https://github.com/ghostflyby/librime-xcframework) — iOS 中文输入引擎及静态框架
- [NanoMouse](https://github.com/xjwhnxjwhn/nanomouse) 与 [Hamster](https://github.com/imfuxiao/Hamster) — iOS Rime 生命周期和架构参考
- [rime-pinyin-simp](https://github.com/rime/rime-pinyin-simp)、[Jieba](https://github.com/fxsjy/jieba)、[phrase-pinyin-data](https://github.com/mozillazg/phrase-pinyin-data) 与 [pinyin-data](https://github.com/mozillazg/pinyin-data) — 拼音、词语与词频数据
- [mlx-audio-swift](https://github.com/Blaizzy/mlx-audio-swift) — macOS 本地 Qwen3 MLX 流式识别
- [Google Material Icons](https://github.com/google/material-design-icons) — iOS App 图标字体
具体版本和许可证见 [第三方许可说明](./NOTICE-TYPING.md),也可在 App 的「设置 → 关于 → 第三方许可」中查看。
---
## 许可
[源码可见许可](./LICENSE) — 个人学习与非商用本地使用;商用请联系 [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com)。
@@ -104,5 +121,5 @@ open OSGKeyboard.xcodeproj
---
<p align="center">
灵感来自 <a href="https://typeless.com">Typeless</a> · 端侧识别基于 Apple SpeechAnalyzer · Mac 本地模型基于 Sherpa-ONNX
开口即文字 · Speak it. It's typed.
</p>
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""Build OSG's commercially permissive Simplified Chinese Rime dictionary.
Sources are pinned and independently redistributable:
- rime-pinyin-simp (Apache-2.0): baseline entries
- jieba (MIT): modern word frequencies
- phrase-pinyin-data (MIT): phrase pronunciations
- pinyin-data (MIT): per-character pronunciation fallback
The script deliberately does not consume rime-ice, rime-double-pinyin,
Luna, or Essay.
"""
from __future__ import annotations
import hashlib
import json
import re
import unicodedata
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUTPUT_DIR = ROOT / "OSGKeyboardShared" / "Resources" / "Typing" / "Rime"
CACHE_DIR = ROOT / ".cache" / "typing-rime"
SOURCES = {
"pinyin_simp": {
"license": "Apache-2.0",
"commit": "0c6861ef7420ee780270ca6d993d18d4101049d0",
"url": (
"https://raw.githubusercontent.com/rime/rime-pinyin-simp/"
"0c6861ef7420ee780270ca6d993d18d4101049d0/pinyin_simp.dict.yaml"
),
},
"jieba": {
"license": "MIT",
"commit": "67fa2e36e72f69d9134b8a1037b83fbb070b9775",
"url": (
"https://raw.githubusercontent.com/fxsjy/jieba/"
"67fa2e36e72f69d9134b8a1037b83fbb070b9775/jieba/dict.txt"
),
},
"phrase_pinyin": {
"license": "MIT",
"commit": "cee0ed6e6e4898580cafd2bd5e3723e20b214aa0",
"url": (
"https://raw.githubusercontent.com/mozillazg/phrase-pinyin-data/"
"cee0ed6e6e4898580cafd2bd5e3723e20b214aa0/pinyin.txt"
),
},
"character_pinyin": {
"license": "MIT",
"commit": "923b108dc5d45dee061324c011b478fb649f8b73",
"url": (
"https://raw.githubusercontent.com/mozillazg/pinyin-data/"
"923b108dc5d45dee061324c011b478fb649f8b73/pinyin.txt"
),
},
}
CJK_RE = re.compile(r"^[\u3400-\u9fff\uf900-\ufaff]+$")
def download(name: str, source: dict[str, str]) -> tuple[Path, str]:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
path = CACHE_DIR / f"{name}.txt"
if not path.exists():
request = urllib.request.Request(
source["url"],
headers={"User-Agent": "OSGKeyboard-rime-builder/1"},
)
with urllib.request.urlopen(request, timeout=90) as response:
path.write_bytes(response.read())
digest = hashlib.sha256(path.read_bytes()).hexdigest()
return path, digest
def strip_tones(value: str) -> str:
normalized = unicodedata.normalize("NFD", value.lower())
return "".join(
char
for char in normalized
if unicodedata.category(char) != "Mn" and ("a" <= char <= "z" or char == " ")
)
def parse_phrase_pinyin(path: Path) -> dict[str, str]:
result: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
if not line or line.startswith("#") or ": " not in line:
continue
phrase, pinyin = line.split(": ", 1)
code = " ".join(strip_tones(pinyin).split())
if phrase and code:
result[phrase] = code
return result
def parse_character_pinyin(path: Path) -> dict[str, str]:
result: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
if not line or line.startswith("#") or " # " not in line or ": " not in line:
continue
encoded, character = line.split(" # ", 1)
pinyin = encoded.split(": ", 1)[1].split(",", 1)[0]
code = strip_tones(pinyin).strip()
if character and code:
result[character[0]] = code
return result
def parse_baseline(path: Path) -> dict[tuple[str, str], int]:
entries: dict[tuple[str, str], int] = {}
in_body = False
for line in path.read_text(encoding="utf-8").splitlines():
if line == "...":
in_body = True
continue
if not in_body or not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 2:
continue
text = parts[0]
weight = int(parts[-1]) if parts[-1].isdigit() else 1
code_parts = parts[1:-1] if parts[-1].isdigit() else parts[1:]
code = " ".join(code_parts)
entries[(text, code)] = max(entries.get((text, code), 0), weight)
return entries
def merge_jieba(
path: Path,
entries: dict[tuple[str, str], int],
phrase_pinyin: dict[str, str],
character_pinyin: dict[str, str],
) -> tuple[int, int]:
accepted = 0
inferred = 0
for line in path.read_text(encoding="utf-8").splitlines():
parts = line.rsplit(" ", 2)
if len(parts) != 3:
continue
word, frequency, _ = parts
if not frequency.isdigit() or not (2 <= len(word) <= 12) or not CJK_RE.fullmatch(word):
continue
code = phrase_pinyin.get(word)
if code is None:
syllables = [character_pinyin.get(char) for char in word]
if any(item is None for item in syllables):
continue
code = " ".join(item for item in syllables if item)
inferred += 1
weight = max(1, int(frequency))
key = (word, code)
entries[key] = max(entries.get(key, 0), weight)
accepted += 1
return accepted, inferred
def write_dictionary(entries: dict[tuple[str, str], int]) -> Path:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output = OUTPUT_DIR / "osg_pinyin.dict.yaml"
header = """# Rime dictionary
# encoding: utf-8
# Generated by Scripts/typing/build_rime_dictionary.py — DO NOT EDIT.
---
name: osg_pinyin
version: "1.0"
sort: by_weight
use_preset_vocabulary: false
columns:
- text
- code
- weight
...
"""
ordered = sorted(entries.items(), key=lambda item: (item[0][1], -item[1], item[0][0]))
body = "".join(f"{text}\t{code}\t{weight}\n" for (text, code), weight in ordered)
output.write_text(header + body, encoding="utf-8")
return output
def main() -> None:
downloaded: dict[str, Path] = {}
manifest_sources: dict[str, dict[str, str]] = {}
for name, source in SOURCES.items():
path, digest = download(name, source)
downloaded[name] = path
manifest_sources[name] = {**source, "sha256": digest}
entries = parse_baseline(downloaded["pinyin_simp"])
baseline_count = len(entries)
phrase_pinyin = parse_phrase_pinyin(downloaded["phrase_pinyin"])
character_pinyin = parse_character_pinyin(downloaded["character_pinyin"])
jieba_count, inferred_count = merge_jieba(
downloaded["jieba"],
entries,
phrase_pinyin,
character_pinyin,
)
output = write_dictionary(entries)
output_digest = hashlib.sha256(output.read_bytes()).hexdigest()
manifest = {
"formatVersion": 1,
"sources": manifest_sources,
"statistics": {
"baselineEntries": baseline_count,
"jiebaWordsAccepted": jieba_count,
"jiebaWordsUsingCharacterFallback": inferred_count,
"outputEntries": len(entries),
},
"output": {
"file": output.name,
"sha256": output_digest,
},
"excluded": [
"rime-ice (GPL-3.0)",
"rime-double-pinyin (GPL-3.0)",
"rime-essay (LGPL-3.0)",
"rime-luna-pinyin (LGPL-3.0)",
],
}
(OUTPUT_DIR / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps(manifest["statistics"], ensure_ascii=False))
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Combine librime release notices into one in-app readable text file."""
from pathlib import Path
from zipfile import ZipFile
ROOT = Path(__file__).resolve().parents[2]
RESOURCE_DIR = ROOT / "OSGKeyboardShared" / "Resources" / "Typing"
OUTPUT = RESOURCE_DIR / "LIBRIME-COMBINED-NOTICES.txt"
def main() -> None:
sections: list[str] = []
for name in ("LICENSE.txt", "THIRD_PARTY_NOTICES.md"):
path = RESOURCE_DIR / name
sections.append(f"{'=' * 72}\n{name}\n{'=' * 72}\n\n{path.read_text(encoding='utf-8')}")
with ZipFile(RESOURCE_DIR / "third-party-notices.zip") as archive:
for name in sorted(item for item in archive.namelist() if item.endswith(".txt")):
text = archive.read(name).decode("utf-8", errors="replace")
sections.append(f"{'=' * 72}\n{name}\n{'=' * 72}\n\n{text}")
OUTPUT.write_text("\n\n".join(sections) + "\n", encoding="utf-8")
print(f"Wrote {OUTPUT} ({OUTPUT.stat().st_size} bytes)")
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
# Typing Keyboard Baseline (Phase 0)
Frozen reference for NanoMouse × OSGKeyboard fusion.
## NanoMouse reference
| Field | Value |
|-------|--------|
| Repo | https://github.com/xjwhnxjwhn/nanomouse |
| Frozen commit | `a6177d898a01662ce551b43a01cf82a9f84ca54c` (2026-07-29 tip at clone) |
| Local clone (gitignored) | `.refs/nanomouse/` |
## KeyboardKit / key shell
NanoMouse does **not** SPM-pin upstream KeyboardKit Pro. It vendors an MIT KeyboardKit tree inside:
`ios/Packages/HamsterKeyboardKit/Sources/KeyboardKit/` (see that folders `LICENSE`).
OSG ships a **lean SwiftUI key shell** in-repo (`OSGKeyboardExt/Typing/`)
behind `TypingLayoutProviding`; KeyboardKit is not linked.
**KeyboardKit Pro is not used.**
## Chinese schema / lexicon
| Choice | Detail |
|--------|--------|
| Product intent | Full pinyin + Microsoft/Sogou double pinyin + opt-in fuzzy pairs |
| Engine | librime 1.17.0 via static XCFramework (BSD-3-Clause) |
| Binary package | `ghostflyby/librime-xcframework` `1.17.0-pack.1`, checksum `0f0fc13b…1164` |
| Baseline | rime-pinyin-simp (Apache-2.0) |
| Modern words | Jieba frequencies + phrase-pinyin-data + pinyin-data (MIT) |
| Generated lexicon | `Resources/Typing/Rime/osg_pinyin.dict.yaml` (~365K entries) |
| User learning | librime userdb in App Group |
| Explicitly excluded | rime-ice / rime-double-pinyin (GPL), Luna / Essay (LGPL) |
The dictionary is rebuilt deterministically by
`Scripts/typing/build_rime_dictionary.py`; `manifest.json` pins every source
commit and SHA-256.
## Memory / height budget
| Mode | Target height | Memory notes |
|------|---------------|--------------|
| Voice | 281 pt (`KeyboardRootView.totalHeight`) | Matches typing height; no typing engine loaded |
| Typing | 281 pt (`TypingRootView.totalHeight`) | Host-prebuilt Rime data; extension opens one session |
| RSS goal | Typing peak &lt; 50 MB | Session closes on voice switch / memory warning |
## Success criteria (Phase 1)
- Top-right tab switches voice ↔ typing; recording/processing locks voice.
- English QWERTY + 123 / basic symbols.
- Full pinyin and Microsoft/Sogou double pinyin produce phrase candidates.
- Fuzzy pairs default off and are enabled individually in Settings.
- No GPL/LGPL input data in the app; NOTICE lists exact source licenses.
+2 -1
View File
@@ -35,13 +35,14 @@
<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, these preferences (excluding API keys) may also be mirrored in your private iCloud Key-Value Store account.</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>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>
</ul>
<h2>What we do not collect</h2>
<ul>
<li>We do <strong>not</strong> log or upload ordinary keystrokes you type with the keyboard.</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> 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> sell personal data.</li>
+2 -1
View File
@@ -35,13 +35,14 @@
<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.</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.</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; dictionary data is not uploaded to a separate 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>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>
</ul>
<h2>What we do not collect</h2>
<ul>
<li>We do <strong>not</strong> log or upload ordinary keystrokes you type with the keyboard.</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> 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> sell personal data.</li>
+25 -3
View File
@@ -32,6 +32,9 @@ options:
packages:
MLXAudio:
path: ThirdParty/mlx-audio-swift
Librime:
url: https://github.com/ghostflyby/librime-xcframework.git
exactVersion: 1.17.0-pack.1
settings:
base:
@@ -48,8 +51,8 @@ settings:
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "1.1"
CURRENT_PROJECT_VERSION: "32"
MARKETING_VERSION: "1.5.0"
CURRENT_PROJECT_VERSION: "33"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -233,6 +236,7 @@ targets:
- path: OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
buildPhase: resources
resources:
- path: OSGKeyboardExt/Assets.xcassets
- path: OSGKeyboardExt/PrivacyInfo.xcprivacy
settings:
base:
@@ -312,7 +316,6 @@ targets:
- sdk: SwiftUI.framework
- sdk: ActivityKit.framework
# =========================================================
# Shared Framework (主 App 与扩展共用)
# =========================================================
OSGKeyboardShared:
@@ -328,10 +331,24 @@ targets:
buildPhase: resources
- path: OSGKeyboardShared/zh-Hans.lproj/Shared.strings
buildPhase: resources
- path: OSGRimeBridge
excludes:
- "include/**"
resources:
- path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin
- path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/compiled-manifest.json
- path: OSGKeyboardShared/Resources/LocalASR/local-asr-catalog.json
- path: OSGKeyboardShared/Resources/Typing/NOTICE.txt
- path: OSGKeyboardShared/Resources/Typing/LICENSE.txt
- path: OSGKeyboardShared/Resources/Typing/LICENSE-PINYIN-SIMP-APACHE.txt
- path: OSGKeyboardShared/Resources/Typing/LICENSE-JIEBA-MIT.txt
- path: OSGKeyboardShared/Resources/Typing/LICENSE-PHRASE-PINYIN-DATA-MIT.txt
- path: OSGKeyboardShared/Resources/Typing/LICENSE-PINYIN-DATA-MIT.txt
- path: OSGKeyboardShared/Resources/Typing/THIRD_PARTY_NOTICES.md
- path: OSGKeyboardShared/Resources/Typing/third-party-notices.zip
- path: OSGKeyboardShared/Resources/Typing/LIBRIME-COMBINED-NOTICES.txt
- path: OSGKeyboardShared/Resources/Typing/Rime/osg_pinyin.dict.yaml
- path: OSGKeyboardShared/Resources/Typing/Rime/manifest.json
info:
path: OSGKeyboardShared/Info.plist
settings:
@@ -343,6 +360,9 @@ targets:
BUILD_LIBRARY_FOR_DISTRIBUTION: NO
DYLIB_INSTALL_NAME_BASE: "@rpath"
APPLICATION_EXTENSION_API_ONLY: YES
SWIFT_OBJC_BRIDGING_HEADER: OSGRimeBridge/include/OSGRimeBridge.h
CLANG_ENABLE_MODULES: YES
OTHER_CPLUSPLUSFLAGS: "$(inherited) -fmodules -fcxx-modules"
ENABLE_MODULE_VERIFIER: YES
# Embedded frameworks are re-signed by the containing app at package
# time. Signing this framework as a standalone build product is noisy
@@ -351,6 +371,8 @@ targets:
CODE_SIGNING_REQUIRED: NO
CODE_SIGN_IDENTITY: ""
dependencies:
- package: Librime
product: RimeStatic
- sdk: Speech.framework
- sdk: AVFoundation.framework
- sdk: Charts.framework