Merge pull request #38 from hkgood/1.7

1.7
This commit is contained in:
Rocky
2026-08-05 22:26:59 +08:00
committed by GitHub
215 changed files with 384322 additions and 3907 deletions
+6 -9
View File
@@ -84,14 +84,11 @@ jobs:
run: brew install xcodegen
- name: Generate project
run: ./Scripts/generate-xcodeproj.sh
- name: Run tests
- name: Validate test suite manifest
run: ./Scripts/run-tests.sh validate
- name: Run tests (pr preset)
# pr = critical-path groups including OSGKeyboardExtTests.
# See Tests/suite-manifest.json and docs/TESTING.md.
run: |
set -o pipefail
xcodebuild test \
-project OSGKeyboard.xcodeproj \
-scheme OSGKeyboard \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-configuration Debug \
-only-testing:OSGKeyboardTests \
CODE_SIGNING_ALLOWED=NO \
| xcpretty
SKIP_GENERATE=1 ./Scripts/run-tests.sh pr
+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
+87 -3
View File
@@ -8,6 +8,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Personal dictionary → Chinese Pinyin**: adding or deleting terms redeploys an `osg_personal` Rime sidecar (local pinyin from the bundled dict; same-code pin-to-top; Latin names also surface in Chinese mode); English typing and ASR keep using the same dictionary automatically. Takes effect the next time the keyboard opens. / **个性词库 → 中文拼音**:增删词条会重部署 `osg_personal` Rime 旁路词表(复用打包词典本地注音;同码置顶;拉丁专名在中文键盘也可出候选);英文打字与 ASR 继续自动共用同一词库。下次打开键盘生效。
- **Typing key sound & haptics**: letter / modifier / space / return / delete keys play system click sounds on press-down; Settings → General → Haptics offers Off / Light (default) / Strong role-based feedback. / **打字按键音效与震动**:字母 / 修饰 / 空格 / 回车 / 删除键按下即播系统咔嗒音;设置 → 通用 → 震动提供关 / 轻(默认) / 强 的角色分层触感。
### Changed
- **Rime personal-dict import**: SharedSupport now imports `osg_personal` into `osg_pinyin` (resource version `2.3.0`). / **Rime 个性词导入**SharedSupport 将 `osg_personal` 导入 `osg_pinyin`(资源版本 `2.3.0`)。
- **Two-level polish intensity**: Light is the default and restores full fidelity, question, and insertion-context safeguards for fun styles; Heavy keeps the formatting-only creative path for Dating, Flex, Corp, Diba, and XHS. / **两档润色强度**:默认轻度,为趣味风格启用完整保真、问句与落点上下文守卫;重度保持直男癌、装逼、大厂、帝吧和小红书仅格式化后执行人格的创意链路。
- **Built-in polish style JSON**: ship each built-in personality as `Resources/PolishStyles/*.json` plus a manifest; the loader strips the retired fun-foundation placeholder while the composer owns the single shared formatting layer. / **内置润色风格 JSON**:每个内置人格改为 `Resources/PolishStyles/*.json` + manifest;加载器移除已退役的趣味共享占位符,唯一共享格式化层由 Composer 负责。
- **Dating V6 alignment**: restore heartbeat goals, action definitions, chat paragraphing, and the “吃饭了吗” example in `builtin.dating.json`; practical safeguards no longer override those Dating instructions. / **直男癌对齐 V6**:在 `builtin.dating.json` 恢复终极目标、心动动作定义、聊天分段与「吃饭了吗」示例;实用润色守卫不再覆盖 Dating 指令。
- **Polish prompt pipeline**: restore builtin style personalities and per-style intensity guidelines; inject custom packs with priority over generic cleanup; keep sparse hard-brakes only in conservative/fallback modes; strengthen Clear Structure paragraphing guidance; remove unused `globalContract` wiring. / **润色提示词管线**:恢复内置风格人格与分风格力度细则;自定义包优先于泛化清理口吻;稀疏硬刹车仅在保守/降级模式注入;加强清晰结构分段指引;移除未使用的 `globalContract` 假接线。
- **Bounded Flow latency**: ASR drain, batch recovery, and polish now use finite stage budgets; available raw transcription is inserted when optional polish cannot finish in time. / **Flow 有界延迟**:ASR 收尾、整段补偿与润色现采用分阶段时限;可选润色未及时完成时直接上屏已有原始转写。
- **Dating polish quality**: route care, availability checks, concrete invites, praise, longing, apologies, and pressure separately; add controlled per-utterance variation and absolute short-message length budgets, with deterministic local fallbacks when the model violates intent. / **直男癌润色质量**:分别路由关心、档期询问、明确邀约、赞美、想念、道歉与施压场景;增加按单次语音变化的受控多样性和短消息绝对长度预算,模型改意图时由本地确定性兜底。
### Fixed
- **Polish homophone repair**: strengthen shared T3 for contextual 同音/近音 fixes, enforce correct-then-style ordering in one LLM pass, and keep dictionary hints from replacing general correction rules. / **润色同音纠错**:加厚共享 T3 的上下文同音/近音修复,在单次 LLM 内强制先纠后润,并避免词典段顶掉一般纠错规则。
- **Translation paragraph structure**: translation prompts enforce hard structure rules with few-shot correct/wrong examples so flat ASR enumerations become blank-line paragraphs and `1. 2. 3.` lists instead of one collapsed block. / **翻译段落结构**:翻译提示词改为硬结构规则 + 正误 few-shot 示例,使平铺 ASR 列举重建为空行分段与 `1. 2. 3.` 列表,避免压成单段。
- **Bluetooth Flow capture**: serialize PiP/recording audio work off the main thread, wait for a stable input and first frame before ASR, and ignore format-stable route notifications instead of rebuilding every utterance. / **蓝牙 Flow 录音**:在主线程外串行执行 PiP/录音音频操作,等待输入路由与首帧稳定后再进入 ASR,并忽略格式未变化的路由通知,避免每次语音都重建引擎。
- **Reliable Flow delivery**: preserve rapid start/stop commands, make utterance termination idempotent, and recover unacknowledged final results after keyboard-extension recreation. / **Flow 可靠交付**:保留快速按下/松开命令,确保单次语音仅有一个终态,并在键盘扩展重建后恢复未确认的最终结果。
## [1.6.0] - 2026-08-04
### Removed
- **In-keyboard onboarding overlay**: first-run setup lives only in the host app; the keyboard no longer mounts the multi-step enable/permission guide. / **键盘内引导遮罩**:首次设置仅在主 App 完成;键盘不再挂载多步启用/权限引导。
### Changed
- **Voice setup gate**: until host onboarding finishes, typing stays available; tapping mic (or Flow cold-start) shows a hint and opens OSGKeyboard. / **语音设置门禁**:主 App 引导完成前仍可打字;点麦克风(或 Flow 冷启动)会提示并跳转 OSGKeyboard。
- **Host memory hybrid Flow**: after onboarding, Rime then CLM run serially under an RSS gate (~120 MB); ASR warms on first mic press instead of session activate. / **宿主混合 Flow 内存策略**:引导完成后按序执行 Rime→CLM 并受 RSS 门闩(约 120 MB)约束;ASR 改在首次按麦时预热,不再随会话激活叠峰。
- **HostSupport framework split**: ASR / CLM / CloudASR / Charts / StoreKit move to `OSGKeyboardHostSupport`; the keyboard extension no longer links those SDKs or embeds the heavy Rime/CLM/license assets. / **HostSupport 框架拆分**ASR / CLM / CloudASR / Charts / StoreKit 迁入 `OSGKeyboardHostSupport`;键盘扩展不再链接这些 SDK,也不再内嵌大型 Rime/CLM/许可证资源。
- **Typing stack lazy load**: voice surface skips `TypingSessionController` until typing; engines and English lexicon load on enter and unload on leave. / **打字栈懒加载**:语音面不提前创建 `TypingSessionController`;引擎与英文词表进入打字面再加载,离开时卸载。
- **Host↔extension coexistence**: App Group `hostHeavy` blocks typing prepare while the host is doing heavy work; Rime redeploy uses full integrity check only when forced. / **宿主与扩展共存**:宿主重活期间 App Group `hostHeavy` 阻止打字 prepare;Rime 仅在强制重部署时做完整校验。
### Fixed
- **Volcengine ASR validate**: settings connection check handshakes only (no silence transcribe), so valid APP ID / Token no longer fail with “Socket is not connected”. / **火山 ASR 验证**:设置页连接检查仅握手鉴权(不再推静音转写),有效 APP ID / Token 不再误报「Socket 未连接」。
- **Cold-start overlay above keyboard**: the bottom gradient and status copy now track keyboard overlap so “Voice could not start” stays on the opaque band instead of floating over Home content. / **冷启动浮层跟键盘**:底部渐变与状态文案随键盘 overlap 上移,「语音暂时无法启动」留在不透明渐变带上,不再裸叠首页内容。
- **App Group under XCTest**: unsigned test hosts no longer `fatalError` on bare `AppGroupStore()`; speech-history cloud push skips when the suite is missing so one trap cannot abort the whole process. / **XCTest 下 App Group**:未签名测试宿主上裸构 `AppGroupStore()` 不再 `fatalError`;语音历史云推送在 suite 缺失时直接跳过,避免一次 trap 拖垮整进程。
- **Keychain under unsigned XCTest**: when SecItem returns `errSecMissingEntitlement` (-34018) with XCTest loaded, fall back to a process-local map so Keychain/API-key tests stay hermetic. / **未签名 XCTest 下 Keychain**SecItem 返回 `-34018` 且已加载 XCTest 时回退进程内映射,保证 Keychain/API Key 用例可稳定跑通。
- **Polish output validation**: re-enable hard-violation retry + `minimalPolish` fallback (temporary diagnostic bypass removed). / **润色输出校验**:恢复硬违规重试与 `minimalPolish` 回退(移除临时诊断旁路)。
- **English manual Shift priority**: autocapitalization no longer clears a user-armed Shift, so mid-sentence names stay capitalizable; letter keys show uppercase while Shift / Caps / hold is active. / **英文手动 Shift 优先**:自动大写不再清掉用户点亮的 Shift,句中人名可正常大写;Shift / Caps / 按住时字母键显示大写。
- **Dual enterTypingMode**: typing prepare runs once from the keyboard controller (not again from `TypingRootView.onAppear`). / **双重 enterTypingMode**:打字 prepare 仅由键盘控制器触发一次,不再在 `TypingRootView.onAppear` 重复执行。
- **Keyboard startup layout crash**: initialize cursor-layout dependencies before applying the preferred surface, make early layout callbacks safe, and remove startup `layoutIfNeeded()` re-entry that caused a deterministic `SIGTRAP`. / **键盘启动布局崩溃**:在应用首选界面前初始化光标布局依赖,安全处理提前到来的布局回调,并移除启动阶段 `layoutIfNeeded()` 重入,修复必现的 `SIGTRAP`
- **Foreground capture jetsam**: opening the host no longer auto-starts continuous mic capture; idle background capture is released so the keyboard can cold-start (`KVC.init`). / **前台录音 jetsam**:打开宿主不再自动开启连续麦克风采集;后台空闲时释放采集,让键盘能冷启动(出现 `KVC.init`)。
- **Host CLM packaging + deferred warmup**: ship Custom Language Model assets in the iOS app bundle (folder reference) and delay Rime/CLM ~45s so they no longer race keyboard cold start. / **宿主 CLM 打包与延后预热**:CLM 资源以文件夹方式打进 iOS App,并将 Rime/CLM 延后约 45 秒,避免与键盘冷启动抢内存。
### Added
- **Voice pipeline performance tests**: hermetic PCM→ASR→guard→polish→bridge stage timings (`./Scripts/run-tests.sh perf`); included in `all`, not in `pr`. / **语音管线性能测试**:合成 PCM→ASR→guard→润色→bridge 分阶段耗时(`./Scripts/run-tests.sh perf`);纳入 `all`,不进 `pr`
- **Grouped hermetic test suite**: `Tests/suite-manifest.json` plus `./Scripts/run-tests.sh` run presets (`pr` / `all` / `api` / `keyboard` / …) or atomic groups without duplicating XCTest classes; CI uses `pr` (includes ExtTests). / **分组固定测试集**:用 `Tests/suite-manifest.json``./Scripts/run-tests.sh` 按预设(`pr` / `all` / `api` / `keyboard` / …)或原子组运行,测试类不重复归属;CI 默认跑 `pr`(含 ExtTests)。
- **Cloud ASR + Flow policy regressions**: streaming/HTTP fixtures, Alibaba vocabulary cache, moonshot local-fallback routing, and Shared Flow keyboard decision helpers (warming / re-adopt / command gate). / **云端 ASR 与 Flow 策略回归**:流式/HTTP fixture、阿里词汇缓存、Moonshot 本地回退路由,以及 Shared 层键盘 Flow 决策(warming / 重附着 / 命令门控)。
- **Streaming ASR event parsers**: Bailian / OpenAI realtime reducers and Volcengine binary frame round-trip fixtures; translation chip 2.5s poll-protection policy is Shared-tested. / **流式 ASR 事件解析**:百炼 / OpenAI Realtime reducer 与火山二进制帧 round-trip fixture;翻译芯片 2.5 秒轮询保护策略下沉 Shared 并单测。
- **Shift hold continuous caps**: press and hold Shift to type uppercase until release (does not enter Caps Lock). / **按住 Shift 持续大写**:按住 Shift 可持续输入大写,松手结束(不进入 Caps Lock)。
- **English autocomplete & prediction**: English mode reuses the candidate bar for prefix completions, high-confidence autocorrect (with ⌫ / original-candidate undo), and next-word suggestions backed by an offline lexicon plus personal dictionary boosts. / **英文补全与预测**:英文模式复用候选栏,提供前缀补全、高置信自动纠错(⌫ / 原文候选可撤销)以及下一词建议;离线词表结合个人词典加权。
- **Chinese more-candidates panel**: when composing Chinese with ≥2 candidates, a ▼ expands a same-height scrollable grid (~80 candidates) over the key area; selecting or ▲ collapses without growing the keyboard. / **中文更多候选面板**:中文组词且候选 ≥2 时,▼ 在同高度内展开可滚动候选网格(约 80 条)覆盖按键区;点选或 ▲ 收起,不增高键盘。
- **Remember last input surface**: optional setting under Default to Text Input; when on, the keyboard reopens on the voice or typing surface left last time. / **记住上次输入界面**:在「默认进行文字输入」下新增可选开关;开启后,下次打开键盘保持上次离开时的语音或文字界面。
### Changed
- **Chinese curly quotes on 123**: Simplified Chinese numbers page uses `“ ”`; corner brackets `「」` moved to `#+=`. / **中文弯引号**:简体中文 123 页使用 `“ ”`;直角引号 `「」` 移至 `#+=`
- **123 / #+= punctuation by language**: Chinese mode uses iOS Simplified Chinese punctuation (e.g. `:;()¥“”。,、?!【】「」《》`); English mode keeps the iOS US set and drops the extra Chinese-only fourth row. / **123 / #+= 标点按语言区分**:中文模式对齐 iOS 简体中文标点(如 `:;()¥“”。,、?!【】「」《》`);英文模式保持 iOS 美式键位,并去掉多余的中文第四行。
- **Expanded candidate chrome**: drop per-cell fills for row hairlines, enlarge the ▼/▲ hit target, and disable layout animation on expand/collapse to reduce tap lag. / **展开候选样式**:去掉每格白底改行间细分割线,加大 ▼/▲ 热区,并关闭展开/收起布局动画以减轻点击卡顿。
- **Expand/collapse responsiveness**: collapsed bar shows at most 10 candidates; ▼ sits in a `safeAreaInset` outside the horizontal ScrollView; QWERTY stays mounted under an opacity toggle so expand does not rebuild the key grid. / **展开/收起跟手**:收起顶栏最多 10 个候选;▼ 用 `safeAreaInset` 放在横滑外;QWERTY 以透明度显隐保留,避免展开时重建键区。
- **Candidate expand UX**: ▼ uses a translation-style opaque white/dark chip in an HStack (no overlay on text); the expand panel is a recycled UIKit `UICollectionView` of plain labels instead of SwiftUI Buttons. / **候选展开体验**:▼ 改为翻译按钮式不透明白底圆片并放在 HStack 内(不再盖住文字);展开面板改用可复用的 UIKit 纯文字网格,替代大量 SwiftUI Button。
- **Product docs & licenses**: README, GitHub Pages, and in-app third-party licenses now describe Chinese/English typing capabilities and disclose the OSG-owned English lexicon notice. / **产品文档与许可**README、GitHub Pages 与 App 内第三方许可现说明中英打字能力,并披露 OSG 自有英文词表说明。
- **Typing delete hold-repeat**: Chinese/English ⌫ shares the voice toolbars repeating delete engine (tap once, hold to accelerate). / **打字删除连按**:中英文 ⌫ 与语音工具栏共用连删引擎(点按一次,长按加速连续删除)。
- **English personal hotwords**: English suggestions only load Latin personal-dictionary terms (and English aliases), filtering out Chinese entries. / **英文个性热词**:英文建议仅加载拉丁文个人词条(及英文别名),自动过滤中文词。
- **Unified bottom key widths**: Chinese, English, and voice surfaces now use matching compact side keys with a wider flexible center key. / **统一底栏键宽**:中文、英文与语音界面现统一为两侧窄键、中间宽键。
- **Rime candidate page size**: menu `page_size` / snapshot limit raised to 80 so the expand panel can show a mobile-scale candidate pool (redeploy via resource version `2.1.0`). / **Rime 候选页大小**`page_size` / 快照上限提升至 80,供展开面板展示移动端规模候选池(资源版本 `2.1.0` 触发重新部署)。
- **Rime candidate depth & progressive chars**: bridge iterates the full candidate list (up to ~160 shown), and for incomplete multi-syllable input places phrase completions before first-syllable characters (e.g. 中国 then 中 for `zhongg`); resource version `2.2.0`. / **Rime 候选深度与渐进单字**:桥接遍历完整候选列表(展示约 160 条),未完成多音节输入时词组补全排在首音节单字前(如 `zhongg` 先中国后中);资源版本 `2.2.0`
### Removed
- **English long-press accents**: removed grey key-cap hints and the accent popup strip after poor feel in practice. / **英文长按扩展字符**:实测手感不佳,已移除键帽灰字提示与长按弹出条。
- **English idle next-word bar**: candidate suggestions no longer appear until at least one letter of the current word is typed. / **英文空闲下一词栏**:未输入字母时不再显示候选;至少输入一个字母后才出现补全。
### Fixed
- **Defer CLM/Rime until after onboarding**: Custom LM compile and Rime deploy no longer run from `App.init` / welcome flow (host was hitting ~150 MB then signal 9). / **引导后再做 CLM/Rime**Custom LM 编译与 Rime 部署不再在 `App.init`/欢迎流程执行(此前宿主约 150 MB 后被 signal 9 杀掉)。
- **Keyboard idle cold-start**: opening the keyboard no longer auto-launches `startflow` when a stale Flow session dies without a mic press (host ASR warmup was jetsamming the extension). / **键盘空闲冷启动**:无麦克风意图时,残留 Flow 会话失效不再自动跳 `startflow`(避免宿主 ASR 预热把扩展 jetsam)。
- **Gesture-delay scan scope**: `disableSystemGestureDelays` only walks the extensions own view tree, not the host window. / **手势延迟扫描范围**:仅遍历扩展自身视图树,不再扫宿主 window。
- **English autocapitalization**: Shift arms automatically at field start and after sentence terminators, respecting the host fields autocapitalization type. / **英文自动大写**:在输入框开头与句末标点后自动点亮 Shift,并遵循宿主输入框的自动大写类型。
- **Default text-input flash**: apply the preferred surface before mounting SwiftUI so “Default to Text Input” no longer briefly shows the voice UI first. / **默认文字输入闪跳**:在挂载 SwiftUI 前应用首选界面,开启「默认进行文字输入」后不再先短暂闪过语音界面。
## [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 徽章,并提供本地化文案与平台专属链接。
@@ -17,21 +97,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **True streaming cloud ASR**: Bailian, Volcengine, and OpenAI Realtime use one utterance-level WebSocket with live partials; Volcengine enables official two-pass (`enable_nonstream`) so interim text stays on-screen while definite ASR feeds polish. / **真流式云端 ASR**:百炼、火山与 OpenAI Realtime 按整句长连接推流并实时上屏;火山开启官方二遍识别(`enable_nonstream`),interim 仅上屏,definite 再送润色。
- **Streaming ASR badge**: settings ASR provider chip shows 【流式识别】 for Bailian, Volcengine, and OpenAI. / **流式识别标签**:设置里 ASR 供应商对百炼、火山、OpenAI 显示【流式识别】。
- **Fun polish styles**: new subcategory with Flex Guide, Corp Speak, and DiBa Logic alongside Dating Coach. / **趣味润色风格**:新增小分类,含装逼指南、大厂黑话、帝吧大神,并与直男癌拯救器同组。
- **Xiaohongshu Sisters style**: fun polish pack that rewrites drafts into sisterly RED note body with Light/Medium/Heavy hooks (轻安利 / 种草感 / 爆款感). / **小红书集美风格**:趣味润色包,将草稿改写成姐妹向小红书笔记正文,并按轻安利 / 种草感 / 爆款感三档跳变
- **Xiaohongshu Sisters style**: fun polish pack that rewrites drafts into a strong, fact-grounded sisterly RED note with a topic hook and scannable short paragraphs. / **小红书集美风格**:趣味润色包,将草稿直接改写为强风格、事实保真的姐妹向小红书笔记,并使用贴题钩子与易扫读短段
- **Delete day in History**: each day header has a Delete action with confirmation to clear that day's transcripts only (iOS and Mac). / **历史按天删除**:日期行右侧提供删除按钮,确认后仅清除当天记录(iOS 与 Mac)。
- **Mac Settings translation target**: polish-provider section includes “Polish then translate” with the same locale picker as Home / menu bar. / **Mac 设置翻译目标**:润色(LLM)分区新增「润色后翻译」,与首页 / 菜单栏同一套目标语言选择。
### Changed
- **Strong polish by default**: remove the Light / Medium / Heavy setting and its persisted/cloud-synced state; every style now executes its strongest fact-safe interpretation in one prompt. / **默认强润色**:移除轻度 / 中度 / 深度设置及其本地与云同步状态;每种风格现在都在单个 Prompt 中执行事实安全范围内的最强版本。
- **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 与源码按钮统一为等高的紧凑圆角矩形,不再混用徽章与胶囊轮廓。
- **Layered bilingual polish prompts**: transcripts are sent once as user data; stable Chinese/English core rules, style policies, dictionaries, and runtime context now have explicit responsibilities for better consistency and provider prefix caching. / **分层双语润色提示词**:转写仅作为用户消息发送一次;稳定的中英文核心规则、风格策略、词典和运行时上下文职责明确,提升一致性并支持服务商前缀缓存。
- **Two-tier short polish skip**: ultra-short (≤4 CJK) still skips the LLM; 510 CJK now skips only low-value acks/closings (e.g. “好的我知道了”), while questions and contentful shorts still polish. / **两级短句跳过润色**:≤4 字仍跳过 LLM;5–10 字仅对低价值确认/收束语跳过(如「好的我知道了」),问句与有内容短句仍走润色。
- **ABE polish routing**: fun styles and daily chat use a local information-density gate, prompt hard-brakes, and style-specific degrade (e.g. DiBa without an opponent quote falls back to chat cleanup) without a second LLM call. / **ABE 润色路由**:趣味风格与日常聊天增加本地信息密度闸、提示词硬刹车与风格专属降级(如帝吧无对方原话时降级日常清理),不增加第二次 LLM 调用
- **Single-pass polish pipeline**: each selected style now contributes only its personality to one composed prompt; the obsolete router file, duplicated style-card prompts, embedded Core boilerplate, soft validation telemetry, style remapping, dedicated degrade blocks, and second LLM retries are removed. Ultra-short practical/chat acknowledgements may still use deterministic local cleanup without calling the model. / **单次润色链路**:用户选择的风格现在只向单个组合 Prompt 提供人格;移除废弃 Router 文件、重复风格卡 Prompt、内嵌 Core 样板、软校验遥测、风格重映射、专属降级块与第二次 LLM 重试。实用/日常风格的极短确认语仍可直接使用确定性本地清理,不调用模型
- **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 润色风格网格**:卡片去掉装饰图标,并以更密的自适应网格排布(默认窗口约三列;变窄两列、变宽四列及以上)。
+6 -6
View File
@@ -32,13 +32,11 @@ Open an issue using the **Feature request** template. Briefly describe:
xcodegen generate # or: ./Scripts/generate-xcodeproj.sh
```
3. **Code style.** SwiftLint config lives in `.swiftlint.yml` — keep it green. We use Swift 6 strict concurrency, no `Sendable` shims where avoidable.
4. **Tests.** Add XCTest coverage in `OSGKeyboardTests/` for any non-trivial logic.
4. **Tests.** Add XCTest coverage under `OSGKeyboardTests/` (or Ext/Mac targets) for any non-trivial logic, and register the class in **exactly one** group in `Tests/suite-manifest.json`. See [`docs/TESTING.md`](docs/TESTING.md).
5. **Build & test before pushing:**
```bash
xcodebuild -project OSGKeyboard.xcodeproj \
-scheme OSGKeyboard \
-destination 'platform=iOS Simulator,name=iPhone 17' \
test
./Scripts/run-tests.sh validate
./Scripts/run-tests.sh pr # default CI gate; or: all / api / keyboard / …
```
6. **Commit messages.** Short imperative summary (`fix: handle empty transcript`), longer body if needed.
7. **Open the PR** against `main`. The CI pipeline (`.github/workflows/ci.yml`) will lint + build + test automatically.
@@ -49,7 +47,9 @@ Open an issue using the **Feature request** template. Briefly describe:
OSGKeyboard/ Main iOS app target
OSGKeyboardExt/ Custom Keyboard Extension target
OSGKeyboardShared/ Framework shared by app + extension
OSGKeyboardTests/ XCTest unit tests
OSGKeyboardTests/ XCTest unit tests (host / shared)
OSGKeyboardExtTests/ Keyboard / typing XCTest
Tests/ Suite manifest (grouped presets — see docs/TESTING.md)
project.yml XcodeGen project definition (source of truth)
.github/workflows/ CI
```
+50
View File
@@ -0,0 +1,50 @@
# Third-party notices — Typing keyboard
The generated Chinese source manifest is
`OSGKeyboard/Resources/Typing/Rime/manifest.json`. Exact librime
binary dependency licenses are bundled beside `NOTICE.txt`.
## Distributed components (Chinese IME)
| 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 | Chinese 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, `RimeEngineBridging`, and the English
suggestion / autocorrect / next-word session layer.
- Objective-C++ C-API bridge for librime.
- 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.
- Offline English typing data under
`OSGKeyboardShared/Resources/Typing/English/`:
- `english_lexicon.tsv` — curated word list with synthetic relative
frequency ranks for autocomplete / autocorrect
- `english_bigrams.tsv` — light next-word candidates
- Not derived from GPL/LGPL dictionaries; ranks are ordering weights,
not a single third-party corpus dump
## 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
+6 -3
View File
@@ -3,6 +3,7 @@
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
@main
struct OSGKeyboardApp: App {
@@ -18,9 +19,11 @@ struct OSGKeyboardApp: App {
init() {
MaterialIconsFont.registerIfNeeded()
if AppGroup.isAvailable {
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
}
// Deliberately do NOT prepare Custom LM here. App launch already sits
// near ~150 MB RSS in Debug; compiling CLM during onboarding races the
// keyboard extension and gets the host jetsammed (signal 9). Warmup
// runs from MainAppRoot only after onboarding completes.
OSGDiag.log("OSGKeyboardApp.init \(OSGDiag.memoryTag())", category: "flow")
}
var body: some Scene {
Binary file not shown.
@@ -0,0 +1,14 @@
{
"bin_bytes" : 198494,
"bin_file" : "OSGKeyboardCLM.bin",
"export_seconds" : 0.035165071487426758,
"generated_at" : "2026-07-06T13:16:27Z",
"identifier" : "com.osgkeyboard.custom-lm.v1",
"locale" : "zh_CN",
"phrase_count" : 13329,
"sources" : {
"ai_tech_seed" : 3040,
"computer_terms" : 10300
},
"version" : "1.0.0"
}
+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>
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,35 @@
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.
English typing lexicon
----------------------
english_lexicon.tsv and english_bigrams.tsv are OSG-curated word lists with
synthetic relative frequency ranks for offline autocomplete / autocorrect /
next-word ranking. They are not derived from GPL/LGPL dictionaries.
@@ -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.
```
@@ -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
+4 -3
View File
@@ -1,15 +1,16 @@
// FlowDiagnostics.swift
// OSGKeyboard · Main App
//
// Structured logging for the Flow dictation pipeline. Delegates to
// `OSGLog.flow` for Console.app visibility.
// Structured logging for the Flow dictation pipeline. Dual-writes to NSLog
// (`[OSGDiag/flow]`) and `OSGLog.flow` so Console shows lines even when the
// keyboard extension process is selected.
import Foundation
import OSGKeyboardShared
enum FlowDiagnostics {
static func log(_ message: String) {
OSGLog.flow.info("\(message, privacy: .public)")
OSGDiag.log(message, category: "flow")
}
static func logDrain(_ report: FlowCaptureDrainReport) {
@@ -9,6 +9,7 @@ import AVFoundation
import AVKit
import CoreMedia
import UIKit
import OSGKeyboardHostSupport
/// Why `startAndWait` could not prove an active PiP window.
enum FlowPiPStartFailure: Equatable, Sendable {
@@ -95,7 +96,7 @@ final class FlowPictureInPictureController: NSObject {
// MARK: - Lifecycle
@discardableResult
func start() -> Bool {
func start() async -> Bool {
lastSystemStartFailure = nil
guard AVPictureInPictureController.isPictureInPictureSupported() else {
return false
@@ -104,7 +105,7 @@ final class FlowPictureInPictureController: NSObject {
// Required before constructing the controller; without an active
// session, `isPictureInPicturePossible` stays false forever.
guard activateAudioSessionForPiP() else {
guard await activateAudioSessionForPiP() else {
return false
}
@@ -150,7 +151,7 @@ final class FlowPictureInPictureController: NSObject {
}
lastSystemStartFailure = nil
guard start() else {
guard await start() else {
stopFramePump()
if lastSystemStartFailure != nil {
return .failed(.systemRejected)
@@ -211,9 +212,9 @@ final class FlowPictureInPictureController: NSObject {
/// Nudge the sample-buffer source right before resigning active so
/// `canStartPictureInPictureAutomaticallyFromInline` can take over.
func prepareForBackgroundAutoStart() {
func prepareForBackgroundAutoStart() async {
guard isPictureInPictureActive || pipController != nil else { return }
_ = activateAudioSessionForPiP()
_ = await activateAudioSessionForPiP()
startFramePump()
enqueueGuideFrame()
pipController?.invalidatePlaybackState()
@@ -222,12 +223,12 @@ final class FlowPictureInPictureController: NSObject {
}
}
/// Re-activate the playback session after utterance capture releases the
/// mic (`setActive(false)`). Without this, PiP can lose eligibility between
/// utterances even though the floating window is still visible.
/// Keep the shared audio session eligible for PiP after capture stops its
/// engine. After the first utterance the coordinator intentionally retains
/// the stable playAndRecord category instead of flipping back to playback.
@discardableResult
func reassertKeepAliveAudioSession() -> Bool {
activateAudioSessionForPiP()
func reassertKeepAliveAudioSession() async -> Bool {
await activateAudioSessionForPiP()
}
/// Kept for FlowSessionManager call sites; guide animation ignores live levels.
@@ -241,19 +242,15 @@ final class FlowPictureInPictureController: NSObject {
private var didActivateAudioSessionBeforeController = false
@discardableResult
private func activateAudioSessionForPiP() -> Bool {
do {
let session = AVAudioSession.sharedInstance()
// Playback (not record) keeps PiP eligible between utterances without
// holding the mic. Utterance capture later switches to playAndRecord.
try session.setCategory(.playback, mode: .moviePlayback, options: [.mixWithOthers])
try session.setActive(true)
FlowDiagnostics.log("PiP audio session active category=playback")
private func activateAudioSessionForPiP() async -> Bool {
// The first PiP start uses playback. After capture establishes a
// playAndRecord route, the coordinator keeps that category active and
// only stops the engine/tap between utterances.
if await FlowAudioSessionCoordinator.shared.activatePlayback() {
FlowDiagnostics.log("PiP audio session ready")
return true
} catch {
FlowDiagnostics.log("PiP audio session failed: \(error.localizedDescription)")
return false
}
return false
}
private func waitForHostInWindow(timeout: TimeInterval) async -> Bool {
File diff suppressed because it is too large Load Diff
@@ -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,91 @@ 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 typing runtime packaged by librime-xcframework 1.17.0-pack.1 (full pinyin and double pinyin). 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 Chinese typing 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 Chinese 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 in the typing dictionary.",
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 Chinese dictionary entries.",
url: URL(string: "https://github.com/mozillazg/pinyin-data"),
licenseText: resourceText(
named: "LICENSE-PINYIN-DATA-MIT",
fallback: mitText
),
platforms: [.iOS]
),
.init(
id: "english-typing-lexicon",
name: "OSG English typing lexicon",
licenseName: "Project-owned notice",
purpose: "Offline English autocomplete, autocorrect, and next-word ranking lists curated by OSGKeyboard (english_lexicon.tsv / english_bigrams.tsv). Not derived from GPL/LGPL dictionaries; relative ranks are ordering weights only.",
url: URL(string: "https://github.com/hkgood/OSGKeyboard/blob/main/NOTICE-TYPING.md"),
licenseText: englishLexiconNoticeText,
platforms: [.iOS]
),
.init(
id: "mlx-audio-swift",
@@ -40,10 +116,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 +179,51 @@ 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 englishLexiconNoticeText = """
OSG English typing lexicon (project-owned notice)
english_lexicon.tsv and english_bigrams.tsv are curated by OSGKeyboard for
offline English autocomplete, autocorrect, and next-word ranking inside the
iOS keyboard extension.
These lists are not derived from GPL or LGPL dictionaries. Relative
frequency values are synthetic ordering weights for ranking only, not
verbatim counts from a single third-party corpus.
See NOTICE-TYPING.md in the OSGKeyboard repository for the full typing
keyboard attribution map (Chinese Rime stack vs OSG-owned English data).
"""
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.
"""
}
+1
View File
@@ -5,6 +5,7 @@
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
struct ASRSettingsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@@ -6,6 +6,7 @@
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
struct HomeUsageStatsSection: View {
let layout: UsageStatsCluster.Layout
@@ -9,6 +9,7 @@
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
// MARK: - Layout metrics
+172 -13
View File
@@ -6,8 +6,14 @@
// ready. Failure states reuse the same minimal layout and only change the
// text permission issues are handled with a single "open Settings" link,
// never a second in-app permission flow.
//
// The overlay ignores the keyboard safe area (full-bleed over Home), so it
// manually tracks keyboard overlap and lifts the gradient + copy together
// to stay glued to the keyboard's top edge. MainTabView also ignores the
// keyboard inset, so system safe-area push cannot be relied on here.
import SwiftUI
import UIKit
import OSGKeyboardShared
struct FlowColdStartContext: Equatable {
@@ -32,6 +38,7 @@ enum FlowColdStartFailure: Equatable {
struct FlowColdStartOverlay: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
let context: FlowColdStartContext
let onReturnToHost: () -> Void
@@ -39,9 +46,12 @@ struct FlowColdStartOverlay: View {
let onRetry: () -> Void
let onOpenSettings: () -> Void
/// Fraction of the screen height the bottom gradient occupies.
/// Fraction of the *visible* height (above the keyboard) the gradient occupies.
private let gradientHeightFraction: CGFloat = 0.50
/// Distance from the screen bottom to the keyboard's top edge.
@State private var keyboardOverlap: CGFloat = 0
/// Ready and failure states dismiss on blank tap; preparing stays
/// informational only (no accidental dismiss while proving audio).
private var allowsBlankTapDismiss: Bool {
@@ -55,7 +65,30 @@ struct FlowColdStartOverlay: View {
var body: some View {
GeometryReader { geo in
// Keep gradient proportions relative to the canvas above the keyboard
// so the near-opaque band stays behind the title when the keyboard is up.
let visibleHeight = max(geo.size.height - keyboardOverlap, 1)
let gradientHeight = visibleHeight * gradientHeightFraction
// Above the keyboard the home-indicator inset is already consumed by
// `keyboardOverlap`; only apply it when the keyboard is hidden.
let contentBottomPad = keyboardOverlap > 0
? Spacing.sm
: max(geo.safeAreaInsets.bottom, Spacing.sm)
ZStack(alignment: .bottom) {
// Full-screen hit sink: must expand explicitly a bare Color.clear
// in a bottom-aligned ZStack can collapse and let taps reach Home
// (e.g. focusing the preview field while this overlay is visible).
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.onTapGesture {
if allowsBlankTapDismiss {
onDismiss()
}
}
.allowsHitTesting(true)
// Full-width bottom gradient: transparent at the top of the
// band, nearly opaque at the bottom so hint text stays readable.
LinearGradient(
@@ -67,35 +100,161 @@ struct FlowColdStartOverlay: View {
startPoint: .top,
endPoint: .bottom
)
.frame(height: geo.size.height * gradientHeightFraction)
.frame(height: gradientHeight)
.frame(maxWidth: .infinity, alignment: .bottom)
.allowsHitTesting(false)
if allowsBlankTapDismiss {
// Captures taps on empty overlay space (dismiss) and blocks
// pass-through to the host shell underneath. Action buttons in
// `content` sit above this layer and remain tappable.
Color.clear
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
.ignoresSafeArea()
}
VStack(spacing: Spacing.lg) {
content
.padding(.horizontal, Spacing.xl)
homeIndicator
.padding(.bottom, max(geo.safeAreaInsets.bottom, Spacing.sm))
.padding(.bottom, contentBottomPad)
}
}
// Lift gradient + copy as one unit so the opaque band stays glued
// to the keyboard top edge (or the home indicator when idle).
.padding(.bottom, keyboardOverlap)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.ignoresSafeArea()
}
.onAppear {
// Cold-start copy asks the user to swipe back dismiss any in-app
// keyboard first so Home's preview field cannot steal the scene.
Self.resignEditingFocus()
syncKeyboardOverlap(animated: false)
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
syncKeyboardOverlap(animated: false)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification)) { notification in
applyKeyboardOverlap(from: notification)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidChangeFrameNotification)) { notification in
// Catches frames missed between mount and the first WillChange
// (keyboard already visible when the overlay appears).
applyKeyboardOverlap(from: notification)
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { notification in
applyKeyboardOverlap(0, from: notification)
}
.animation(.easeInOut(duration: 0.2), value: context.state)
.accessibilityElement(children: .contain)
}
/// Reads the keyboard end frame and animates `keyboardOverlap` with the
/// system keyboard curve so the gradient rides the same motion.
private func applyKeyboardOverlap(from notification: Notification) {
let overlap = Self.keyboardOverlap(from: notification)
applyKeyboardOverlap(overlap, from: notification)
}
private func applyKeyboardOverlap(_ overlap: CGFloat, from notification: Notification) {
let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?
.doubleValue ?? 0.25
withAnimation(.easeOut(duration: duration)) {
keyboardOverlap = overlap
}
}
private func syncKeyboardOverlap(animated: Bool) {
let overlap = Self.probedKeyboardOverlap()
if animated {
withAnimation(.easeOut(duration: 0.2)) {
keyboardOverlap = overlap
}
} else {
keyboardOverlap = overlap
}
}
/// Screen-bottom keyboard-top distance in the key window.
private static func keyboardOverlap(from notification: Notification) -> CGFloat {
guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
return 0
}
guard let window = keyWindow else {
let bounds = UIScreen.main.bounds
return max(0, bounds.maxY - frame.minY)
}
let frameInWindow = window.convert(frame, from: nil)
return max(0, window.bounds.maxY - frameInWindow.minY)
}
/// Best-effort read when we may have missed keyboard notifications
/// (overlay mounted while the keyboard was already up).
private static func probedKeyboardOverlap() -> CGFloat {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first
else {
return 0
}
let reference = keyWindow ?? scene.windows.first
let bounds = reference?.bounds ?? scene.screen.bounds
// UITextEffectsWindow / UIRemoteKeyboardWindow host the keyboard chrome.
for window in scene.windows {
let name = String(describing: type(of: window))
guard name.contains("Keyboard") || name.contains("TextEffects") else { continue }
let overlap = max(0, bounds.maxY - window.frame.minY)
// Full-screen effects windows are not themselves the keyboard
// walk for a bottom-docked subview that looks like the input host.
if overlap >= bounds.height - 1 {
if let docked = deepestBottomDockedSubview(in: window, referenceBounds: bounds) {
return max(0, bounds.maxY - docked.minY)
}
continue
}
if overlap > 0 {
return overlap
}
}
return 0
}
private static func deepestBottomDockedSubview(
in window: UIWindow,
referenceBounds: CGRect
) -> CGRect? {
var best: CGRect?
func visit(_ view: UIView) {
let frame = view.convert(view.bounds, to: nil)
let touchesBottom = abs(frame.maxY - referenceBounds.maxY) < 1.5
let tallEnough = frame.height > 120
let notFullScreen = frame.height < referenceBounds.height * 0.92
if touchesBottom, tallEnough, notFullScreen {
if best == nil || frame.minY < best!.minY {
best = frame
}
}
for child in view.subviews {
visit(child)
}
}
visit(window)
return best
}
private static var keyWindow: UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first(where: \.isKeyWindow)
}
private static func resignEditingFocus() {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
@ViewBuilder
private var content: some View {
VStack(spacing: Spacing.md) {
+20 -4
View File
@@ -76,8 +76,18 @@ struct HomeView: View {
}
.onChange(of: previewFocused) { _, focused in
guard focused else { return }
// Cold-start overlay owns the scene (swipe-back hint); don't let
// the preview field summon the keyboard underneath it.
if flowManager.coldStartContext != nil {
previewFocused = false
return
}
Task { await flowManager.refreshForInlineKeyboardFocus() }
}
.onChange(of: flowManager.coldStartContext != nil) { _, showingOverlay in
guard showingOverlay else { return }
previewFocused = false
}
}
// MARK: - Phone layout
@@ -333,10 +343,13 @@ struct HomeView: View {
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
} else if canManuallyStartSession {
// Sessions auto-start on foreground, but after a manual stop /
// expiry the user needs a reliable, no-jump way back in.
// Sessions no longer auto-start on foreground (keyboard survival).
// After a manual stop / expiry, Start is the explicit re-entry.
Button {
flowManager.activateOnForeground()
flowManager.activateOnForeground(
reason: "HomeView.startButton",
startCapture: true
)
} label: {
Text("home.flow.startShort")
.font(TypeStyle.caption2)
@@ -481,7 +494,10 @@ struct HomeView: View {
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.onTapGesture { previewFocused = true }
.onTapGesture {
guard flowManager.coldStartContext == nil else { return }
previewFocused = true
}
}
private var previewFieldContent: some View {
@@ -10,6 +10,7 @@
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
struct KeyboardPreviewSheet: View {
@Environment(\.themePalette) private var palette: ThemePalette
+85 -7
View File
@@ -7,6 +7,7 @@
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
struct MainAppRoot: View {
@Environment(\.scenePhase) private var scenePhase
@@ -15,6 +16,7 @@ struct MainAppRoot: View {
// `@ObservedObject` keeps subscriptions correct across Settings replay.
@ObservedObject private var config = ProviderConfig.shared
@StateObject private var flowManager = FlowSessionManager()
@State private var postOnboardingWarmupTask: Task<Void, Never>?
var body: some View {
Group {
@@ -63,10 +65,25 @@ struct MainAppRoot: View {
AppOpenURLRouter.shared.register { url in
handleIncomingURL(url)
}
flowManager.activateOnForeground()
OSGDiag.log(
"MainAppRoot.onAppear scene=\(String(describing: scenePhase)) "
+ "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())",
category: "flow"
)
AppCloudSync.shared.startObservingExternalChanges()
Task {
await AppCloudSync.shared.pullAllIfEnabled()
// Heavy work (Flow / CLM / Rime) only after onboarding. Doing it
// earlier jetsams the host (~150 MB+) and the keyboard dies with it.
if config.hasCompletedOnboarding {
// Light path only: never auto-start continuous capture here.
// Capture starts on Home Start / keyboard startflow / mic press.
flowManager.activateOnForeground(reason: "MainAppRoot.onAppear")
schedulePostOnboardingWarmup(reason: "MainAppRoot.onAppear")
} else {
OSGDiag.log(
"MainAppRoot.onAppear skip Flow/CLM/Rime (onboarding incomplete)",
category: "flow"
)
}
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
@@ -74,14 +91,22 @@ struct MainAppRoot: View {
}
.onChange(of: config.hasCompletedOnboarding) { _, done in
if done {
flowManager.activateOnForeground()
flowManager.activateOnForeground(reason: "onboardingCompleted")
schedulePostOnboardingWarmup(reason: "onboardingCompleted")
}
}
.onChange(of: scenePhase) { _, phase in
flowManager.handleScenePhase(phase)
guard phase == .active else { return }
guard phase == .active else {
postOnboardingWarmupTask?.cancel()
postOnboardingWarmupTask = nil
FlowSessionBridge.setHostHeavy(false)
return
}
if config.hasCompletedOnboarding {
flowManager.activateOnForeground()
flowManager.activateOnForeground(reason: "scenePhase.active")
// Retry deferred Rime/CLM after a jetsam-prone launch.
schedulePostOnboardingWarmup(reason: "scenePhase.active.retry")
}
Task {
await AppCloudSync.shared.pullAllIfEnabled()
@@ -89,11 +114,64 @@ struct MainAppRoot: View {
}
}
/// Serial host warmup: Rime deploy CLM. Never parallel with ASR.
/// ASR warms on first mic press (`FlowSessionManager.beginUtterance`).
///
/// Intentionally delayed: running Rime/CLM on `onAppear` kept the host at
/// ~175 MB while the user switched to the keyboard, and the extension died
/// before `KVC.init` (no dyld breadcrumb).
private func schedulePostOnboardingWarmup(reason: String) {
OSGDiag.log(
"postOnboardingWarmup scheduled reason=\(reason) delay=45s \(OSGDiag.memoryTag())",
category: "flow"
)
postOnboardingWarmupTask?.cancel()
postOnboardingWarmupTask = Task { @MainActor in
// Let the user leave the host / cold-start the keyboard first.
try? await Task.sleep(nanoseconds: 45_000_000_000)
guard !Task.isCancelled else { return }
guard scenePhase == .active else {
OSGDiag.log(
"postOnboardingWarmup skip reason=notActive \(OSGDiag.memoryTag())",
category: "flow"
)
return
}
guard !flowManager.shouldDeferHostHeavyWork else {
OSGDiag.log(
"postOnboardingWarmup skip reason=flowBusy \(OSGDiag.memoryTag())",
category: "flow"
)
return
}
await AppCloudSync.shared.pullAllIfEnabled()
// hostHeavy only while heavy work runs never leave it stuck at 1
// just because RSS is above the soft gate (that blocked typing).
guard HostMemoryBudget.gate("rime.installIfNeeded") else { return }
FlowSessionBridge.setHostHeavy(true)
defer { FlowSessionBridge.setHostHeavy(false) }
let typingConfig = TypingInputConfiguration.shared.snapshot
OSGDiag.log("rime.installIfNeeded begin \(OSGDiag.memoryTag())", category: "flow")
try? await RimeResourceInstaller.shared.installIfNeeded(
configuration: typingConfig
)
OSGDiag.log("rime.installIfNeeded done \(OSGDiag.memoryTag())", category: "flow")
guard HostMemoryBudget.gate("clm.prepare", category: "asr") else { return }
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
OSGDiag.log("clm.prepare scheduled \(OSGDiag.memoryTag())", category: "asr")
}
}
private func handleIncomingURL(_ url: URL) {
guard url.scheme == "osgkeyboard" else { return }
switch url.host {
case "startflow":
flowManager.startSession(coldStart: true)
flowManager.startSession(coldStart: true, reason: "url.startflow")
#if DEBUG
case "seed-demo":
DemoDataSeeder.seedRichPlaceholderData()
+4 -1
View File
@@ -224,7 +224,10 @@ private struct WideStatusFooter: View {
.buttonStyle(.plain)
} else if canManuallyStartSession && !needsCloudSetup {
Button {
flowManager.activateOnForeground()
flowManager.activateOnForeground(
reason: "MainSplitView.startButton",
startCapture: true
)
} label: {
Text("home.flow.startShort")
.foregroundStyle(palette.accent)
@@ -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)
}
}
@@ -5,5 +5,6 @@
// lives in `OSGKeyboardShared` as `LiveDictationController`.
import OSGKeyboardShared
import OSGKeyboardHostSupport
typealias PreviewASRController = LiveDictationController
+75 -4
View File
@@ -129,28 +129,99 @@ struct HandednessPickerRow: View {
}
}
// MARK: - Keyboard haptic picker row
struct KeyboardHapticPickerRow: View {
@Binding var selection: KeyboardHapticIntensity
private var options: [(id: String, label: String)] {
KeyboardHapticIntensity.allCases.map { intensity in
(intensity.rawValue, AppL10n.string(intensity.labelKey))
}
}
var body: some View {
SettingsMenuPickerRow(
title: AppL10n.string("settings.keyboardHaptic.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = KeyboardHapticIntensity(rawValue: newValue) ?? .default
}
)
)
}
}
// MARK: - Polish intensity picker row
struct PolishIntensityPickerRow: View {
@ObservedObject var config: ProviderConfig
var body: some View {
//
SettingsMenuPickerRow(
title: AppL10n.string("settings.polishIntensity.title"),
options: PolishIntensity.allCases.map { intensity in
(intensity.rawValue, SharedL10n.string(intensity.labelKey, language: config.uiLanguage))
(
intensity.rawValue,
SharedL10n.string(intensity.labelKey, language: config.uiLanguage)
)
},
selection: Binding(
get: { config.polishIntensity.rawValue },
set: { newValue in
config.polishIntensity = PolishIntensity(rawValue: newValue) ?? .medium
set: { rawValue in
config.polishIntensity = PolishIntensity(rawValue: rawValue) ?? .default
}
)
)
}
}
// 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()
}
}
struct RememberLastSurfaceToggleRow: 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.rememberLast.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("settings.typingInput.rememberLast.description")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.settingsListRow()
}
}
// MARK: - Cursor drag navigation toggle
struct CursorDragNavigationToggleRow: View {
@@ -39,6 +39,26 @@ struct SettingsNavigationRow: View {
}
}
/// Read-only version / build row for Settings home (below About).
struct SettingsVersionRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(spacing: Spacing.sm) {
Text("settings.version.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.xs)
Text(AppVersionDisplay.detailedLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.trailing)
}
.settingsListRow()
.accessibilityElement(children: .combine)
}
}
// MARK: - Config entry summaries (shown on Settings home)
enum SettingsConfigSummary {
@@ -221,6 +241,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 +262,27 @@ struct GeneralSettingsView: View {
CardSection("settings.general.keyboard.title") {
VStack(spacing: 0) {
DefaultTypingInputToggleRow(
isOn: $typingConfiguration.defaultToTyping
)
Divider().background(palette.divider)
RememberLastSurfaceToggleRow(
isOn: $typingConfiguration.rememberLastSurface
)
Divider().background(palette.divider)
NavigationLink {
TypingInputSettingsView()
} label: {
SettingsNavigationRow(title: "settings.typingInput.title")
}
.buttonStyle(.plain)
Divider().background(palette.divider)
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
@@ -248,6 +290,13 @@ struct GeneralSettingsView: View {
)
)
Divider().background(palette.divider)
KeyboardHapticPickerRow(
selection: Binding(
get: { config.keyboardHapticIntensity },
set: { config.keyboardHapticIntensity = $0 }
)
)
Divider().background(palette.divider)
CursorDragNavigationToggleRow(
isOn: $config.cursorDragNavigationEnabled
)
+14 -7
View File
@@ -6,6 +6,7 @@
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
enum SettingsPresentation {
case tab
@@ -50,7 +51,9 @@ struct SettingsView: View {
}
dailySection
transcriptionAndPolishSection
moreEntriesSection
if presentation == .tab {
moreEntriesSection
}
}
.modifier(SettingsScrollBottomPadding(presentation: presentation))
}
@@ -114,6 +117,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 +167,16 @@ struct SettingsView: View {
}
}
// MARK: - General / About
// MARK: - About
private var moreEntriesSection: some View {
VStack(spacing: 0) {
settingsRouteButton(.general, title: "settings.general.title")
settingsRouteButton(.about, title: "settings.about.title")
if presentation == .tab {
Divider().background(palette.divider)
settingsRouteButton(.about, title: "settings.about.title")
}
Divider().background(palette.divider)
// Read-only version row not tappable.
SettingsVersionRow()
}
.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
}
}
}
+13 -2
View File
@@ -178,12 +178,18 @@
"settings.systemPrompt.edit" = "Edit system prompt";
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
"settings.about.title" = "About";
"settings.version.title" = "Version";
"settings.daily.title" = "Daily";
"settings.config.title" = "Configuration";
"settings.general.title" = "General";
"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.typingInput.rememberLast.title" = "Remember Last Choice";
"settings.typingInput.rememberLast.description" = "Reopen on the voice or text surface you left last time";
"settings.speechRecognition.title" = "Speech Recognition";
"settings.textPolish.title" = "Text Polish";
"settings.preferences.title" = "Preferences";
@@ -192,6 +198,10 @@
"settings.handedness.title" = "Handedness";
"settings.handedness.left" = "Left hand";
"settings.handedness.right" = "Right hand";
"settings.keyboardHaptic.title" = "Haptics";
"settings.keyboardHaptic.off" = "Off";
"settings.keyboardHaptic.light" = "Light";
"settings.keyboardHaptic.strong" = "Strong";
"settings.cursorDragNavigation.title" = "Drag beside mic to move cursor";
"settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale";
@@ -395,7 +405,7 @@
"polishStyles.editor.name" = "Name";
"polishStyles.editor.namePlaceholder" = "Style name";
"polishStyles.editor.prompt" = "Complete prompt";
"polishStyles.editor.hint" = "Use {{DICTIONARY}} where the personal dictionary should be inserted. System safety, rewrite intensity, and output rules are appended automatically.";
"polishStyles.editor.hint" = "Describe only the role, tone, style boundaries, and examples. The system appends the personal dictionary, safety contract, and output rules.";
"polishStyles.error.title" = "Couldnt save style";
"polishStyles.error.emptyName" = "Enter a style name.";
"polishStyles.error.emptyPrompt" = "The prompt cannot be empty.";
@@ -462,7 +472,8 @@
"settings.appSettings.iCloudSync.synced" = "Synced";
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
/* v0.3.0: Polish intensity */
/* Polish intensity */
"settings.polishIntensity.title" = "Polish intensity";
/* Flow session policy */
+13 -2
View File
@@ -178,12 +178,18 @@
"settings.systemPrompt.edit" = "编辑系统提示";
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
"settings.about.title" = "关于";
"settings.version.title" = "版本";
"settings.daily.title" = "日常";
"settings.config.title" = "配置";
"settings.general.title" = "通用";
"settings.general.appearanceLanguage.title" = "外观与语言";
"settings.general.keyboard.title" = "键盘与操作";
"settings.general.sync.title" = "同步";
"settings.typingInput.title" = "文本输入";
"settings.typingInput.default.title" = "默认进行文字输入";
"settings.typingInput.default.description" = "打开键盘时,默认使用文字输入键盘而非语音输入";
"settings.typingInput.rememberLast.title" = "记住上次选择";
"settings.typingInput.rememberLast.description" = "下次打开键盘时,保持你上次离开时的语音或文字输入界面";
"settings.speechRecognition.title" = "语音识别配置";
"settings.textPolish.title" = "文本润色配置";
"settings.preferences.title" = "偏好设置";
@@ -192,6 +198,10 @@
"settings.handedness.title" = "握持偏好";
"settings.handedness.left" = "左手";
"settings.handedness.right" = "右手";
"settings.keyboardHaptic.title" = "震动";
"settings.keyboardHaptic.off" = "关";
"settings.keyboardHaptic.light" = "轻";
"settings.keyboardHaptic.strong" = "强";
"settings.cursorDragNavigation.title" = "麦克风旁拖动移动光标";
"settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言";
@@ -394,7 +404,7 @@
"polishStyles.editor.name" = "名称";
"polishStyles.editor.namePlaceholder" = "风格名称";
"polishStyles.editor.prompt" = "完整提示词";
"polishStyles.editor.hint" = "使用 {{DICTIONARY}} 指定个人词典的插入位置。系统会自动追加安全边界、润色力度和输出契约。";
"polishStyles.editor.hint" = "只需描述角色、语气、风格边界和示例。系统会自动追加个人词典、安全边界和输出契约。";
"polishStyles.error.title" = "无法保存风格";
"polishStyles.error.emptyName" = "请输入风格名称。";
"polishStyles.error.emptyPrompt" = "提示词不能为空。";
@@ -461,7 +471,8 @@
"settings.appSettings.iCloudSync.synced" = "已同步";
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
/* v0.3.0: 润色强度 */
/* 润色强度 */
"settings.polishIntensity.title" = "润色强度";
/* Flow 会话策略 */
@@ -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

+15
View File
@@ -0,0 +1,15 @@
// BootProbe.m
// OSGKeyboard · Keyboard Extension
//
// Runs at dyld load before any Swift `KeyboardViewController` init.
// If Console shows this line but never `KVC.init`, Swift/Shared init is the
// killer. If even this line is missing, the extension was jetsammed during
// dyld (usually host coexistence or an oversized Shared+Rime mapping).
#import <Foundation/Foundation.h>
#include <unistd.h>
__attribute__((constructor))
static void OSGKeyboardExtBootProbe(void) {
NSLog(@"[OSGDiag/boot] dyld.constructor pid=%d", getpid());
}
+238 -39
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,19 +36,50 @@ public final class KeyboardViewController: UIInputViewController {
public typealias State = KeyboardState
private let state = State()
/// Created on first typing use so the default voice surface never pays
/// for `TypingSessionController` / engine factories at KVC init.
private var typingSessionStorage: TypingSessionController?
private var typingSession: TypingSessionController {
if let typingSessionStorage { return typingSessionStorage }
let created = TypingSessionController()
typingSessionStorage = created
return created
}
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!
private var configSync: KeyboardConfigSync!
private var cursorDrag: CursorDragController!
/// UIKit may synchronously lay out the view during `viewDidLoad`.
/// Keep this optional so an early layout pass is harmless.
private var cursorDrag: CursorDragController?
private var targetKeyboardHeight: CGFloat {
KeyboardRootView.totalHeight
KeyboardSurfaceRoot.height(for: state.surface)
}
// MARK: - Init
public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
OSGDiag.log("KVC.init(nib) begin \(OSGDiag.memoryTag())", category: "boot")
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
OSGDiag.log("KVC.init(nib) done \(OSGDiag.memoryTag())", category: "boot")
}
public required init?(coder: NSCoder) {
OSGDiag.log("KVC.init(coder) begin \(OSGDiag.memoryTag())", category: "boot")
super.init(coder: coder)
OSGDiag.log("KVC.init(coder) done \(OSGDiag.memoryTag())", category: "boot")
}
deinit {
// Intentionally NSLog-only: deinit is nonisolated.
NSLog("%@", "[OSGDiag/boot] KVC.deinit")
}
// MARK: - Lifecycle
@@ -56,26 +88,59 @@ public final class KeyboardViewController: UIInputViewController {
super.viewDidLoad()
// Voice-first keyboard hide the misleading "English" subtitle in Settings.
primaryLanguage = "mis"
OSGLog.keyboardExt.info("viewDidLoad — extension booted")
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
OSGDiag.log(
"KVC.viewDidLoad begin preferredSurface=\(preferred.rawValue) "
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
category: "boot"
)
// Deliberately NO CustomLanguageModelManager prewarm here: the
// extension never runs ASR (the host app owns the microphone and
// the SpeechAnalyzer pipeline), and compiling/caching an LM inside
// the keyboard's ~60 MB jetsam budget risks the system killing the
// keyboard outright. The host app prewarms it on session start.
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
// Establish layout dependencies before applying the preferred surface.
// `applySurface` updates height and UIKit may lay out synchronously.
installKeyboardHeight()
configureDictationBehavior()
installServices()
OSGDiag.log("KVC.viewDidLoad after installServices \(OSGDiag.memoryTag())", category: "boot")
// Apply open preference before mounting SwiftUI so the first frame is
// already voice or typing avoids a visible surface flash.
applyPreferredSurfaceOnOpen()
OSGDiag.log("KVC.viewDidLoad after preferredSurface surface=\(state.surface.rawValue)", category: "boot")
installTypingContextProviders()
installStateActions()
installSurfaceObservers()
installSwiftUI()
OSGDiag.log("KVC.viewDidLoad after installSwiftUI \(OSGDiag.memoryTag())", category: "boot")
_ = configSync.loadPersistedConfig()
configSync.installDarwinObservers()
flowCoordinator.refreshSessionState()
OSGDiag.log(
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
+ "hostReady=\(FlowSessionBridge.isHostReady()) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
OSGDiag.log(
"KVC.viewWillDisappear surface=\(state.surface.rawValue) "
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
category: "boot"
)
flowCoordinator.stopSessionMonitor()
// Remember what the user left on, then pre-position a reused
// extension instance for the next open policy (no first-frame jump).
TypingInputConfiguration.persistLastSurface(state.surface)
prepareSurfaceForNextPresentation()
if state.surface == .typing {
typingSession.leaveTypingMode()
}
if flowCoordinator.preservesLifecycleOnDisappear {
return
}
@@ -85,6 +150,11 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
OSGDiag.log(
"KVC.viewWillAppear begin surface=\(state.surface.rawValue) "
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
category: "boot"
)
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
@@ -93,19 +163,41 @@ public final class KeyboardViewController: UIInputViewController {
flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup()
configSync.refreshConfigFromAppGroup()
configSync.autoAdvancePastKeyboardSetupStepIfNeeded()
// Settings may have changed while the extension stayed alive.
applyPreferredSurfaceOnOpen()
if state.surface == .typing {
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
typingSession.enterTypingMode()
}
OSGDiag.log(
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
applyPresentationHeightOffset()
OSGDiag.log(
"KVC.viewIsAppearing height=\(keyboardHeightConstraint?.constant ?? -1) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
OSGDiag.log(
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
disableSystemGestureDelays()
keyboardHeightConstraint?.constant = targetKeyboardHeight
refreshReturnKeyRole()
OSGDiag.log(
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func textDidChange(_ textInput: UITextInput?) {
@@ -123,12 +215,22 @@ public final class KeyboardViewController: UIInputViewController {
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
OSGDiag.log(
"KVC.didReceiveMemoryWarning surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
flowCoordinator.cancelPipelineUnlessAwaitingResult()
if state.surface == .typing {
typingSession.leaveTypingMode()
applySurface(.voice)
} else {
typingSession.leaveTypingMode()
}
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
cursorDrag.layoutChrome()
cursorDrag?.layoutChrome()
}
// MARK: - Services
@@ -183,27 +285,134 @@ public final class KeyboardViewController: UIInputViewController {
state.setTranslationTargetLocaleId = { [weak self] id in
self?.configSync.persistTranslationTargetLocaleId(id)
}
state.advanceOnboarding = { [weak self] in self?.configSync.advanceOnboarding() }
state.completeOnboarding = { [weak self] in self?.configSync.completeOnboarding() }
state.requestMicPermission = { [weak self] in self?.requestMicPermissionFromExtension() }
state.requestSpeechPermission = { [weak self] in self?.requestSpeechPermissionFromExtension() }
state.openSystemSettings = { [weak self] in self?.openSystemSettingsFromExtension() }
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
state.moveCursorHorizontal = { [weak self] steps in
self?.cursorDrag.moveCursorHorizontally(by: steps)
self?.cursorDrag?.moveCursorHorizontally(by: steps)
}
state.moveCursorVertical = { [weak self] steps in
self?.cursorDrag.moveCursorVertically(by: steps)
self?.cursorDrag?.moveCursorVertically(by: steps)
}
state.setCursorDragActive = { [weak self] active in
self?.cursorDrag.setCursorDragActive(active)
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 {
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
return
}
if surface == .typing, FlowSessionBridge.isHostHeavy() {
OSGDiag.log("applySurface stay voice hostHeavy=1", category: "boot")
return
}
guard state.surface != surface else {
refreshKeyboardHeight()
return
}
OSGDiag.log(
"applySurface \(state.surface.rawValue)\(surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
state.surface = surface
if surface == .voice {
typingSession.leaveTypingMode()
} else {
typingSession.enterTypingMode()
}
refreshKeyboardHeight()
}
private func applyPreferredSurfaceOnOpen() {
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
OSGDiag.log(
"applyPreferredSurfaceOnOpen preferred=\(preferred.rawValue) "
+ "remember=\(TypingInputConfiguration.remembersLastSurface()) "
+ "defaultTyping=\(TypingInputConfiguration.prefersTypingOnOpen())",
category: "boot"
)
applySurface(preferred)
}
/// When not remembering, snap to the static open preference while hidden
/// so a reused keyboard instance does not animate voice typing on show.
private func prepareSurfaceForNextPresentation() {
guard !TypingInputConfiguration.remembersLastSurface() else { return }
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
guard state.surface != preferred else { return }
state.surface = preferred
}
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`. Avoid synchronous layout here:
// this is also called during `viewDidLoad`, where re-entrant layout can
// observe partially initialized controller dependencies.
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
}
private func refreshReturnKeyRole() {
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
// Secure fields must not run English autocomplete / autocorrect / learning.
typingSession.suggestionsEnabled = !(textDocumentProxy.isSecureTextEntry ?? false)
typingSession.syncAutocapitalization()
}
private func installTypingContextProviders() {
typingSession.precedingTextProvider = { [weak self] in
self?.textDocumentProxy.documentContextBeforeInput
}
typingSession.autocapitalizationModeProvider = { [weak self] in
Self.typingAutocapitalizationMode(
for: self?.textDocumentProxy.autocapitalizationType ?? .sentences
)
}
}
private static func typingAutocapitalizationMode(
for type: UITextAutocapitalizationType
) -> TypingAutocapitalizationMode {
switch type {
case .none:
return .none
case .words:
return .words
case .sentences:
return .sentences
case .allCharacters:
return .allCharacters
@unknown default:
return .sentences
}
}
private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole {
@@ -223,19 +432,11 @@ public final class KeyboardViewController: UIInputViewController {
hasDictationKey = true
}
/// Only walk our own input-view subtree. Recursing into the host window /
/// root VC previously risked "System gesture gate timed out" and the
/// system killing the keyboard plugin.
private func disableSystemGestureDelays() {
disableGestureDelays(in: view)
var parent = view.superview
while let current = parent {
disableGestureDelays(in: current)
parent = current.superview
}
if let window = view.window {
disableGestureDelays(in: window)
if let rootView = window.rootViewController?.view {
disableGestureDelays(in: rootView)
}
}
}
private func disableGestureDelays(in targetView: UIView) {
@@ -250,17 +451,6 @@ public final class KeyboardViewController: UIInputViewController {
targetView.subviews.forEach(disableGestureDelays)
}
// MARK: - Permission / settings stubs (v0.3.0)
private func requestMicPermissionFromExtension() {}
private func requestSpeechPermissionFromExtension() {}
private func openSystemSettingsFromExtension() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
HostAppLauncher.open(url: url, from: self) { _ in }
}
// MARK: - Layout
private func installKeyboardHeight() {
@@ -283,7 +473,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
@@ -300,7 +499,7 @@ public final class KeyboardViewController: UIInputViewController {
])
host.didMove(toParent: self)
hosting = host
cursorDrag.install(on: view)
cursorDrag?.install(on: view)
}
// MARK: - App context
@@ -41,6 +41,7 @@ public struct AppGroupPersistor {
state.translationTargetLocaleId = store.translationTargetLocaleId
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.keyboardHapticIntensity = store.keyboardHapticIntensity
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
@@ -85,12 +86,15 @@ public struct AppGroupPersistor {
guard AppGroup.isAvailable else { return }
let store = AppGroupStore()
state.engineMode = store.engineMode
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
let shouldProtectTranslation = KeyboardTranslationConfigProtection.shouldProtect(
until: protectTranslationUntil
)
if !shouldProtectTranslation {
state.translationTargetLocaleId = store.translationTargetLocaleId
}
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.keyboardHapticIntensity = store.keyboardHapticIntensity
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
@@ -1,7 +1,8 @@
// KeyboardConfigSync.swift
// OSGKeyboard · Keyboard Extension
//
// App Group config hydration, Darwin observers, and onboarding mirroring.
// App Group config hydration, Darwin observers, and onboarding-complete
// mirroring (mic gate only full setup lives in the host app).
import Foundation
import OSGKeyboardShared
@@ -74,39 +75,17 @@ final class KeyboardConfigSync {
into: state,
protectTranslationUntil: translationConfigProtectedUntil
)
// Host may complete (or reset) onboarding while the extension stays alive.
syncOnboardingStateFromAppGroup()
}
/// Mirrors host-app onboarding completion for the mic gate only.
/// Does not sync `onboardingPage` page flow is host-app exclusive.
func syncOnboardingStateFromAppGroup() {
let store = AppGroupStore()
// Fall back to the reboot-durable Keychain marker so a device restart
// does not resurrect the in-keyboard onboarding overlay when the App
// Group value transiently reads empty.
// Keychain fallback: a reboot must not resurrect the mic gate when
// App Group transiently reads empty.
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
state.onboardingPage = store.onboardingPage
}
func autoAdvancePastKeyboardSetupStepIfNeeded() {
guard !state.hasCompletedOnboarding else { return }
guard state.onboardingPage == 3 else { return }
guard KeyboardSetupBridge.isReadyForOnboardingSkip else { return }
let store = AppGroupStore()
store.setOnboardingPage(4)
state.onboardingPage = 4
}
func advanceOnboarding() {
let store = AppGroupStore()
let nextPage = min(4, store.onboardingPage + 1)
store.setOnboardingPage(nextPage)
state.onboardingPage = nextPage
}
func completeOnboarding() {
let store = AppGroupStore()
store.setHasCompletedOnboarding(true)
store.setOnboardingPage(4)
state.hasCompletedOnboarding = true
state.onboardingPage = 4
}
func persistLocale(_ id: String) {
@@ -122,7 +101,7 @@ final class KeyboardConfigSync {
func persistTranslationTargetLocaleId(_ id: String) {
let resolved = TranslationLanguageCatalog.resolve(id).id
state.translationTargetLocaleId = resolved
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
translationConfigProtectedUntil = KeyboardTranslationConfigProtection.protectionDeadline()
persistor.persist(translationTargetLocaleId: resolved)
}
@@ -117,6 +117,7 @@ final class KeyboardFlowCoordinator {
FlowSessionBridge.reloadFromDisk()
refreshConfigFromAppGroup()
refreshFlowPartialIfNeeded()
adoptPendingResultIfNeeded()
consumePendingFlowDeliveryIfNeeded()
recoverFromDeadHostIfNeeded()
@@ -179,20 +180,19 @@ final class KeyboardFlowCoordinator {
// Host busy (recording/processing) is NOT "still starting". Treating
// it as preparingSession was the orange-stuck bug after cold start:
// host utt.rec=1 ready=false keyboard forever "".
let hostBusy = readySnapshot?.reason == .recording
|| readySnapshot?.reason == .processing
let hostBusy = FlowKeyboardHostWarming.isHostBusy(reason: readySnapshot?.reason)
// PiP sessions publish `reason=.starting` while the small window is
// coming up treat that as warming so the mic stays orange (wait)
// instead of jumping into another cold start.
let hostWarming = !hostReady
&& !hostBusy
&& FlowSessionBridge.isSessionActive()
&& (
FlowSessionBridge.isHostReachable()
|| isPendingFlowStart
|| withinReadyGrace
|| readySnapshot?.reason == .starting
)
let hostWarming = FlowKeyboardHostWarming.isHostWarming(
hostReady: hostReady,
hostBusy: hostBusy,
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
isPendingFlowStart: isPendingFlowStart,
withinReadyGrace: withinReadyGrace,
snapshotReason: readySnapshot?.reason
)
state.flowSessionActive = FlowSessionBridge.isSessionActive()
state.debugPendingFlowStart = isPendingFlowStart
state.debugFlowRecording = isFlowRecording
@@ -204,7 +204,8 @@ final class KeyboardFlowCoordinator {
hasFullAccess: hasFullAccess(),
appGroupAvailable: AppGroup.isAvailable,
hostReady: hostReady,
isPreparingSession: isPendingFlowStart || hostWarming
isPreparingSession: isPendingFlowStart || hostWarming,
hasCompletedOnboarding: state.hasCompletedOnboarding
)
let signature = [
"phase=\(String(describing: state.phase))",
@@ -224,30 +225,22 @@ final class KeyboardFlowCoordinator {
/// Re-attach to a host utterance this keyboard process no longer owns.
private func adoptHostBusyStateIfNeeded(snapshot: FlowReadySnapshot?) {
guard let snapshot, let sessionId = snapshot.sessionId else { return }
// Ignore snapshots from a dead host generation.
if let snapGen = snapshot.hostGeneration,
let liveGen = FlowSessionBridge.currentHostGeneration(),
snapGen != liveGen {
let action = FlowKeyboardAdoptBusyPolicy.decide(
snapshot: snapshot,
currentHostGeneration: FlowSessionBridge.currentHostGeneration(),
isFlowRecording: isFlowRecording,
isAwaitingFlowResult: isAwaitingFlowResult,
lastConsumedUtteranceId: lastConsumedUtteranceId,
lastStoppedUtteranceId: lastStoppedUtteranceId
)
switch action {
case .none:
return
}
// Host already finished never re-adopt a consumed utterance, and
// clear sticky local processing left behind by a stale busy snapshot.
if snapshot.reason != .recording, snapshot.reason != .processing {
clearStickyProcessingIfNeeded(hostReady: snapshot.ready)
return
}
switch snapshot.reason {
case .recording:
guard !isFlowRecording else { return }
guard !isAwaitingFlowResult else { return }
case .clearStickyProcessing:
clearStickyProcessingIfNeeded(hostReady: snapshot?.ready ?? false)
case .adoptRecording(let sessionId, let busyId):
// Require the host's utterance id inventing one makes matchingResult
// forever miss the real delivery and leaves the mic white forever.
guard let busyId = snapshot.busyUtteranceId else { return }
guard busyId != lastConsumedUtteranceId else { return }
guard busyId != lastStoppedUtteranceId else { return }
activeSessionId = sessionId
currentUtteranceId = busyId
isPendingFlowStart = false
@@ -264,10 +257,7 @@ final class KeyboardFlowCoordinator {
startUtteranceCountdown()
startFlowLevelWatchdog()
traceState("adoptHostBusy.recording", extra: "session=\(sessionId)")
case .processing:
guard !isAwaitingFlowResult else { return }
guard let busyId = snapshot.busyUtteranceId else { return }
guard busyId != lastConsumedUtteranceId else { return }
case .adoptProcessing(let sessionId, let busyId):
activeSessionId = sessionId
currentUtteranceId = busyId
isPendingFlowStart = false
@@ -281,8 +271,6 @@ final class KeyboardFlowCoordinator {
}
startFlowResultWatchdog()
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
default:
break
}
}
@@ -304,6 +292,10 @@ final class KeyboardFlowCoordinator {
/// Session is live but the ready contract has not landed yet poll
/// quickly instead of sticking on "session inactive" orange.
///
/// Cold-start (`osgkeyboard://startflow`) is allowed only when the user
/// explicitly pressed the mic (`recordWhenHostReady`). An idle open must
/// never relaunch the host: Flow + ASR warmup then jetsams the keyboard.
private func startHostReadyWaitIfNeeded() {
guard !isPendingFlowStart else { return }
guard FlowSessionBridge.isSessionActive() else {
@@ -329,6 +321,13 @@ final class KeyboardFlowCoordinator {
return
}
// No mic intent + host already dead leave cleanup to clearIfHostStale.
// Starting a wait poll here previously ended in an unprompted startflow.
if !recordWhenHostReady, isHostTrulyDeadForColdStart() {
stopHostReadyWait()
return
}
guard hostReadyWaitTask == nil else { return }
hostReadyWaitTask = Task { @MainActor [weak self] in
defer { self?.hostReadyWaitTask = nil }
@@ -345,18 +344,22 @@ final class KeyboardFlowCoordinator {
self.recordWhenHostReady = false
return
}
// Host died mid-wait only cold-start after debounced dead samples.
let dead = FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: false
)
// Host died mid-wait cold-start only after debounced dead
// samples AND an explicit mic-driven record intent.
let dead = self.isHostTrulyDeadForColdStart()
if self.coldStartDebouncer.observe(hostTrulyDead: dead) {
let shouldRecord = self.recordWhenHostReady
self.recordWhenHostReady = false
self.coldStartDebouncer.reset()
self.beginFlowStart(recordAfterHandoff: shouldRecord)
if shouldRecord {
self.beginFlowStart(recordAfterHandoff: true)
} else {
self.traceState(
"hostReadyWait.deadWithoutIntent",
extra: "skipColdStart=1"
)
self.stopHostReadyWait()
}
return
}
try? await Task.sleep(nanoseconds: 150_000_000)
@@ -371,6 +374,15 @@ final class KeyboardFlowCoordinator {
}
}
private func isHostTrulyDeadForColdStart() -> Bool {
FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: false
)
}
private func finishHostReadyWaitIfNeeded() {
coldStartDebouncer.reset()
guard recordWhenHostReady else { return }
@@ -408,6 +420,9 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability()
switch state.micVoiceAvailability {
case .unavailable(.onboardingIncomplete):
promptFinishSetupInApp()
return
case .unavailable(.missingAPIKey):
return
case .unavailable(.noFullAccess):
@@ -476,6 +491,10 @@ final class KeyboardFlowCoordinator {
}
func beginFlowStart(recordAfterHandoff: Bool = false) {
guard state.hasCompletedOnboarding else {
promptFinishSetupInApp()
return
}
guard !isPendingFlowStart else {
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
return
@@ -492,6 +511,11 @@ final class KeyboardFlowCoordinator {
: "keyboard.flow.startingSession"
)
recomputeMicVoiceAvailability()
OSGDiag.log(
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
openHostApp("startflow")
startFlowStartWatchdog()
traceState(
@@ -580,14 +604,19 @@ final class KeyboardFlowCoordinator {
if let result = matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
isAwaitingFlowResult = false
stopFlowWatchdog()
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.clearResult()
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
@@ -597,9 +626,6 @@ final class KeyboardFlowCoordinator {
"utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "commandSeq=\(result.commandSeq) warning=\(result.warning == nil ? 0 : 1)"
)
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
return
}
if let result = matchingResult(), isTerminalFailure(result) {
@@ -612,7 +638,16 @@ final class KeyboardFlowCoordinator {
)
isAwaitingFlowResult = false
stopFlowWatchdog()
FlowSessionBridge.clearResult()
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
@@ -635,18 +670,64 @@ final class KeyboardFlowCoordinator {
}
}
private func matchingResult() -> FlowResult? {
guard let result = FlowSessionBridge.latestResult() else { return nil }
guard let activeSessionId, let currentUtteranceId else { return nil }
guard result.sessionId == activeSessionId,
result.utteranceId == currentUtteranceId else {
return nil
private func adoptPendingResultIfNeeded() {
guard !isAwaitingFlowResult, currentUtteranceId == nil,
let pendingId = FlowSessionBridge.pendingKeyboardUtteranceId(),
let result = FlowSessionBridge.latestResult(),
result.utteranceId == pendingId,
result.status == .final || isTerminalFailure(result) else {
return
}
return result
let currentField = fieldContextProvider()
if let expected = result.fieldFingerprint,
let current = currentField?.deliveryFingerprint,
expected != current {
if let text = result.text,
currentField?.precedingText?.hasSuffix(text) == true {
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
traceState(
"pendingResult.acknowledged",
extra: "reason=textAlreadyPresent"
)
return
}
traceState(
"pendingResult.deferred",
extra: "reason=fieldFingerprintMismatch"
)
return
}
activeSessionId = result.sessionId
currentUtteranceId = result.utteranceId
isAwaitingFlowResult = true
state.phase = .processing
traceState(
"pendingResult.adopted",
extra: "utterance=\(pendingId.uuidString.prefix(8))"
)
}
private func matchingResult() -> FlowResult? {
FlowKeyboardResultMatcher.matchingResult(
latest: FlowSessionBridge.latestResult(),
activeSessionId: activeSessionId,
currentUtteranceId: currentUtteranceId,
currentHostGeneration: FlowSessionBridge.currentHostGeneration()
)
}
private func isTerminalFailure(_ result: FlowResult) -> Bool {
result.status == .error || result.status == .timeout || result.status == .aborted
FlowKeyboardResultMatcher.isTerminalFailure(result)
}
/// When the host process died mid-utterance, abort local recording / waiting
@@ -676,6 +757,9 @@ final class KeyboardFlowCoordinator {
}
private func failHostDisconnected() {
if deliverRawFallbackIfAvailable(reason: "hostDisconnected") {
return
}
traceState("hostDisconnected.fail")
isAwaitingFlowResult = false
isFlowRecording = false
@@ -695,6 +779,49 @@ final class KeyboardFlowCoordinator {
debug("host disconnected while awaiting Flow result")
}
@discardableResult
private func deliverRawFallbackIfAvailable(reason: String) -> Bool {
FlowSessionBridge.reloadFromDisk()
guard let result = matchingResult(),
result.status == .partial
|| result.status == .rawReady
|| (result.status == .final && result.rawText != nil),
let raw = (result.rawText ?? result.text)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
return false
}
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: raw, polishWarning: nil)
)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: nil
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
isAwaitingFlowResult = false
isFlowRecording = false
stopFlowWatchdog()
state.level = 0
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
FlowTrace.transcript(
"keyboard.insert",
raw,
"via=rawFallback reason=\(reason) utterance=\(result.utteranceId.uuidString.prefix(8))"
)
return true
}
private func showFlowSessionExpiredHint() {
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.flowSessionExpired, message: message)
@@ -718,6 +845,16 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability()
}
/// Scheme C: voice needs host-app setup; typing stays available.
private func promptFinishSetupInApp() {
let msg = ExtL10n.string("keyboard.hint.finishSetupInApp")
state.phase = .error(.manualOpenRequired, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
openHostApp("settings")
traceState("onboarding.incomplete", extra: "action=openHostApp(settings)")
}
private func startFlowRecording() {
recomputeMicVoiceAvailability()
let withinReadyGrace = lastHostReadyAt > 0
@@ -767,6 +904,7 @@ final class KeyboardFlowCoordinator {
}
activeSessionId = sessionId
currentUtteranceId = UUID()
FlowSessionBridge.setPendingKeyboardUtteranceId(currentUtteranceId)
lastStoppedUtteranceId = nil
writeCommand(.startRecording)
isFlowRecording = true
@@ -884,7 +1022,7 @@ final class KeyboardFlowCoordinator {
switch state.phase {
case .recording, .processing:
if let result = matchingResult(),
result.status == .partial,
result.status == .partial || result.status == .rawReady,
let partial = result.text,
!partial.isEmpty {
state.lastTranscript = partial
@@ -902,17 +1040,23 @@ final class KeyboardFlowCoordinator {
debug("resultWatchdog started timeout=\(Int(resultTimeout))s engine=\(state.engineMode)")
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
FlowSessionBridge.reloadFromDisk()
if let result = self.matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.clearResult()
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil
@@ -924,15 +1068,21 @@ final class KeyboardFlowCoordinator {
+ "commandSeq=\(result.commandSeq) "
+ "waitedSeconds=\(String(format: "%.2f", Date().timeIntervalSince1970 - startedAt))"
)
self.textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
return
}
if let result = self.matchingResult(), self.isTerminalFailure(result) {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
FlowSessionBridge.clearResult()
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil
@@ -976,6 +1126,9 @@ final class KeyboardFlowCoordinator {
return
}
if now - startedAt > resultTimeout {
if self.deliverRawFallbackIfAvailable(reason: "resultTimeout") {
return
}
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.currentUtteranceId = nil
@@ -1,81 +0,0 @@
// PermissionManager.swift
// OSGKeyboard · Keyboard Extension
//
// Extracted from KeyboardViewController so the view controller doesn't
// need to know about AVAudioApplication vs AVAudioSession branching
// or SFSpeechRecognizer.requestAuthorization callback bridging.
//
// Contract:
// `requestMicPermission()` returns true if the user has authorised
// or *just* authorised; false otherwise. Idempotent within a
// process the second call will not prompt again if the user has
// already answered.
// `requestSpeechPermission()` mirrors the same shape but for
// SFSpeechRecognizer.
import Foundation
import AVFoundation
import Speech
@MainActor
public final class PermissionManager: @unchecked Sendable {
public init() {}
private var didRequestMicOnce: Bool = false
/// Request microphone access. Returns true if granted (already or
/// after this call). Uses the iOS 17+ `AVAudioApplication` API.
public func requestMicPermission() async -> Bool {
switch AVAudioApplication.shared.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined:
if !didRequestMicOnce {
didRequestMicOnce = true
return await AVAudioApplication.requestRecordPermission()
}
return false
@unknown default: return false
}
}
/// Request Speech Recognition permission. Returns true if granted
/// (already or after this call). The `SFSpeechRecognizer` plist
/// key + this call are still required even on iOS 26 the
/// `SpeechAnalyzer` API does not expose an explicit request
/// method of its own and the framework checks the same TCC
/// entry on first use.
public func requestSpeechPermission() async -> Bool {
await Self.requestSpeechPermissionNonisolated()
}
// MARK: - Nonisolated permission bridge
//
// `SFSpeechRecognizer.requestAuthorization` callback is not guaranteed
// to run on main queue. Building the callback inline inside a
// `@MainActor` method can trigger runtime actor/isolation assertions.
// Keep the continuation + callback creation in nonisolated helpers.
private nonisolated static func requestSpeechPermissionNonisolated() async -> Bool {
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
SFSpeechRecognizer.requestAuthorization(
makeSpeechAuthHandler(continuation: cont)
)
}
}
private nonisolated static func makeSpeechAuthHandler(
continuation: CheckedContinuation<Bool, Never>
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
return { status in
switch status {
case .authorized:
continuation.resume(returning: true)
case .denied, .restricted, .notDetermined:
continuation.resume(returning: false)
@unknown default:
continuation.resume(returning: false)
}
}
}
}
@@ -0,0 +1,184 @@
// CandidateExpandGridView.swift
// OSGKeyboard · Keyboard Extension
//
// UIKit grid for the expanded Chinese candidate panel. UILabel cells +
// UICollectionView recycling stay far cheaper than hundreds of SwiftUI Buttons.
import SwiftUI
import UIKit
import OSGKeyboardShared
struct CandidateExpandGridView: UIViewRepresentable {
var candidates: [TypingCandidate]
var textColor: UIColor
var dividerColor: UIColor
var onSelect: (Int) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onSelect: onSelect)
}
func makeUIView(context: Context) -> UICollectionView {
let view = UICollectionView(frame: .zero, collectionViewLayout: Self.makeLayout())
view.backgroundColor = .clear
view.alwaysBounceVertical = true
view.showsVerticalScrollIndicator = true
view.delaysContentTouches = false
view.canCancelContentTouches = true
view.dataSource = context.coordinator
view.delegate = context.coordinator
view.register(Cell.self, forCellWithReuseIdentifier: Cell.reuseID)
context.coordinator.collectionView = view
return view
}
func updateUIView(_ uiView: UICollectionView, context: Context) {
let coordinator = context.coordinator
let idsChanged = coordinator.candidates.map(\.id) != candidates.map(\.id)
let styleChanged =
coordinator.textColor != textColor || coordinator.dividerColor != dividerColor
coordinator.onSelect = onSelect
coordinator.textColor = textColor
coordinator.dividerColor = dividerColor
coordinator.candidates = candidates
if idsChanged || styleChanged {
uiView.reloadData()
}
}
private static func makeLayout() -> UICollectionViewCompositionalLayout {
let columns = TypingLayoutMetrics.expandGridColumns
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0 / CGFloat(columns)),
heightDimension: .absolute(TypingLayoutMetrics.expandCellHeight)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(TypingLayoutMetrics.expandCellHeight)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
repeatingSubitem: item,
count: columns
)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = .zero
return UICollectionViewCompositionalLayout(section: section)
}
final class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate {
var candidates: [TypingCandidate] = []
var textColor: UIColor = .label
var dividerColor: UIColor = .separator
var onSelect: (Int) -> Void
weak var collectionView: UICollectionView?
init(onSelect: @escaping (Int) -> Void) {
self.onSelect = onSelect
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
candidates.count
}
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: Cell.reuseID,
for: indexPath
) as! Cell
let index = indexPath.item
cell.configure(
text: candidates[index].text,
emphasized: index == 0,
textColor: textColor,
dividerColor: dividerColor,
showDivider: !isLastRow(index: index)
)
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: false)
onSelect(indexPath.item)
}
private func isLastRow(index: Int) -> Bool {
let columns = TypingLayoutMetrics.expandGridColumns
guard !candidates.isEmpty else { return true }
let row = index / columns
let lastRow = (candidates.count - 1) / columns
return row == lastRow
}
}
private final class Cell: UICollectionViewCell {
static let reuseID = "CandidateExpandCell"
private let label = UILabel()
private let divider = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
contentView.backgroundColor = .clear
isAccessibilityElement = true
accessibilityTraits = .button
label.textAlignment = .center
label.lineBreakMode = .byTruncatingTail
label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.7
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
divider.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(divider)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 2),
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2),
label.topAnchor.constraint(equalTo: contentView.topAnchor),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
divider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
divider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
divider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
divider.heightAnchor.constraint(equalToConstant: 1.0 / 3.0)
])
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(
text: String,
emphasized: Bool,
textColor: UIColor,
dividerColor: UIColor,
showDivider: Bool
) {
label.text = text
label.textColor = textColor
label.font = .systemFont(
ofSize: emphasized ? 19 : 18,
weight: emphasized ? .medium : .regular
)
divider.backgroundColor = dividerColor
divider.isHidden = !showDivider
accessibilityLabel = text
}
override var isHighlighted: Bool {
didSet {
contentView.alpha = isHighlighted ? 0.45 : 1
}
}
}
}
@@ -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()
}
}
}
}
+622
View File
@@ -0,0 +1,622 @@
// TypingRootView.swift
// OSGKeyboard · Keyboard Extension
//
// Typing surface: 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
/// Match the voice surface's shared 20 / 60 / 20 bottom-row geometry.
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
/// Collapsed candidate strip: keep this small so ScrollView doesn't fight .
static let collapsedBarCandidateLimit = 10
/// Trailing / control (visual chip + hit pad); lives in an HStack, not over text.
static let expandChevronHitWidth: CGFloat = 44
static let expandChevronVisualSize: CGFloat = 34
static let expandGridColumns = 5
static let expandCellHeight: CGFloat = 42
}
struct TypingRootView: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
var onInsert: (String) -> Void
var onDeleteBackward: () -> Void
/// After the first expand, keep the panel tree mounted and only toggle
/// opacity so subsequent / taps stay cheap.
@State private var candidatePanelMounted = false
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
let expanded = typing.isCandidatePanelExpanded
VStack(spacing: 0) {
topRegion
.frame(height: TypingLayoutMetrics.topRegionHeight)
// Keep the QWERTY tree mounted; only toggle visibility so /
// does not destroy GeometryReader key rows every time.
ZStack(alignment: .top) {
VStack(spacing: 0) {
keyGrid
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
bottomRow
.frame(height: TypingLayoutMetrics.bottomRowHeight)
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
}
.opacity(expanded ? 0 : 1)
.allowsHitTesting(!expanded)
.accessibilityHidden(expanded)
if expanded || candidatePanelMounted {
expandedCandidatePanel
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.opacity(expanded ? 1 : 0)
.allowsHitTesting(expanded)
.accessibilityHidden(!expanded)
.transition(.identity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.contentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight)
.background(Color.clear)
.environment(\.themePalette, palette)
// enterTypingMode is owned by KeyboardViewController.viewWillAppear
// when surface == .typing avoid a duplicate prepare here.
.onChange(of: typing.isCandidatePanelExpanded) { _, isExpanded in
if isExpanded { candidatePanelMounted = true }
}
.onChange(of: hasCandidateContent) { _, hasContent in
if !hasContent { candidatePanelMounted = false }
}
.onAppear {
KeyboardHapticFeedback.prepare()
}
}
// 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 {
// HStack (not overlay / safeAreaInset): never paints over candidate text.
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Spacing.xs) {
if typing.composition.candidates.isEmpty {
selectedCandidateLabel(text: typing.composition.preedit)
} else if typing.isCandidatePanelExpanded {
candidateChip(text: typing.composition.candidates[0].text) {
apply(typing.selectCandidate(at: 0))
}
} else {
ForEach(
Array(
typing.composition.candidates
.prefix(TypingLayoutMetrics.collapsedBarCandidateLimit)
.enumerated()
),
id: \.element.id
) { index, candidate in
if index == 0 {
candidateChip(text: candidate.text) {
apply(typing.selectCandidate(at: index))
}
} else {
Text(candidate.text)
.font(.system(size: 20, weight: .regular))
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, 10)
.frame(height: 40)
.contentShape(Rectangle())
.onTapGesture {
apply(typing.selectCandidate(at: index))
}
.accessibilityAddTraits(.isButton)
.accessibilityLabel(candidate.text)
}
}
}
}
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset)
.padding(.trailing, Spacing.xs)
}
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
if typing.canExpandCandidatePanel {
expandChevronButton
} else {
Color.clear.frame(width: KeyboardTopBarMetrics.nestedHorizontalInset)
}
}
}
/// Opaque chip like the translation control so never shares pixels with text.
private var expandChevronButton: some View {
Button {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
typing.toggleCandidatePanelExpanded()
}
} label: {
Image(systemName: typing.isCandidatePanelExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textSecondary)
.frame(
width: TypingLayoutMetrics.expandChevronVisualSize,
height: TypingLayoutMetrics.expandChevronVisualSize
)
.background(expandChevronFill, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
.frame(
width: TypingLayoutMetrics.expandChevronHitWidth,
height: KeyboardTopBarMetrics.height
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.trailing, KeyboardTopBarMetrics.nestedHorizontalInset)
.accessibilityLabel(typing.isCandidatePanelExpanded ? "收起候选" : "更多候选")
}
private var expandChevronFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
/// UIKit-recycled labels no SwiftUI Button per candidate.
private var expandedCandidatePanel: some View {
CandidateExpandGridView(
candidates: typing.composition.candidates,
textColor: UIColor(keyTextColor),
dividerColor: UIColor(palette.dividerStrong),
onSelect: { index in
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
apply(typing.selectCandidate(at: index))
}
}
)
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
private func candidateChip(text: String, action: @escaping () -> Void) -> some View {
selectedCandidateLabel(text: text)
.contentShape(Rectangle())
.onTapGesture(perform: action)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(text)
}
private func selectedCandidateLabel(text: String) -> some View {
VStack(spacing: 0) {
Text(text)
.font(.system(size: 19, weight: .medium))
.lineLimit(1)
if !typing.composition.preedit.isEmpty {
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)
}
}
}
@ViewBuilder
private func keyButton(_ label: String) -> some View {
if label == "" {
typingDeleteKey()
} else if label == "" {
typingShiftKey()
} else {
let isSpecial = ["123", "#+=", "ABC"].contains(label)
let role: KeyboardHapticKeyRole = isSpecial ? .modifier : .character
PressDownKeyButton {
TypingKeyFeedback.play(
role: role,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleKey(label))
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
keyLabel(label, isSpecial: isSpecial)
}
}
.accessibilityLabel(Text(label))
}
}
/// Hold = continuous uppercase while pressed; tap = one-shot / Caps Lock cycle.
private func typingShiftKey() -> some View {
let lit = typing.isShiftEnabled
return keyLabel("", isSpecial: true)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.fill(lit ? keyPressedFill : keyFill)
)
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.stroke(palette.divider, lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(lit ? 0.04 : 0.13),
radius: lit ? 0.5 : 1,
y: lit ? 0 : 1
)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !typing.shiftHeld {
TypingKeyFeedback.play(
role: .modifier,
intensity: state.keyboardHapticIntensity
)
typing.beginShiftHold()
}
}
.onEnded { _ in
typing.endShiftHold()
}
)
.accessibilityLabel(Text("shift"))
.accessibilityAddTraits(.isButton)
}
/// Shares ``RepeatingPressButton`` with the voice toolbar delete key.
private func typingDeleteKey() -> some View {
RepeatingPressButton(hapticIntensity: state.keyboardHapticIntensity) {
apply(typing.handleKey(""))
} label: { isPressed in
Image(systemName: "delete.left")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(keyTextColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.fill(isPressed ? keyPressedFill : keyFill)
)
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.stroke(palette.divider, 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)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private func keyLabel(_ label: String, isSpecial: Bool) -> some View {
switch label {
case "":
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(keyTextColor)
default:
Text(label)
.font(
.system(
size: isSpecial ? 15 : 22,
weight: isSpecial ? .semibold : .regular
)
)
.foregroundStyle(keyTextColor)
}
}
private var bottomRow: some View {
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidths(
availableWidth: proxy.size.width
)
HStack(spacing: TypingLayoutMetrics.bottomActionSpacing) {
PressDownKeyButton {
TypingKeyFeedback.play(
role: .modifier,
intensity: state.keyboardHapticIntensity
)
typing.setPage(typing.page == .letters ? .numbers : .letters)
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
Text(typing.page == .letters ? "123" : "ABC")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(keyTextColor)
}
.frame(
width: widths.side,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.accessibilityLabel(Text(typing.page == .letters ? "123" : "ABC"))
PressDownKeyButton {
TypingKeyFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleSpace())
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
Text(typing.language == .chinese ? "空格" : "space")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(keyTextColor)
}
.frame(
width: widths.center,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.accessibilityLabel(Text(typing.language == .chinese ? "空格" : "space"))
PressDownKeyButton {
TypingKeyFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleReturn())
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: returnKeyFill,
pressedFill: returnKeyPressedFill,
border: returnKeyBorder,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
returnKeyLabel
.foregroundStyle(returnKeyTextColor)
}
.frame(
width: widths.side,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.accessibilityLabel(Text(returnKeyAccessibilityLabel))
}
}
.frame(height: TypingLayoutMetrics.bottomRowHeight)
}
@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 apply(_ output: TypingOutput) {
guard !output.isEmpty else {
typing.syncAutocapitalization()
return
}
if output.deleteCount > 0 {
for _ in 0..<output.deleteCount {
onDeleteBackward()
}
}
if !output.text.isEmpty {
onInsert(output.text)
}
// Proxy context is up to date after inserts/deletes refresh Shift.
typing.syncAutocapitalization()
}
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
// Match system: page switchers + delete are wider than character keys.
if label == "" || label == "" || label == "#+=" || label == "123" {
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 returnKeyFill: Color {
switch state.returnKeyRole {
case .newline: return keyFill
case .send: return sendKeyFill
}
}
private var returnKeyPressedFill: Color {
switch state.returnKeyRole {
case .newline: return keyPressedFill
case .send: return sendKeyPressedFill
}
}
private var returnKeyBorder: Color {
switch state.returnKeyRole {
case .newline:
return palette.divider
case .send:
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
}
private var returnKeyAccessibilityLabel: String {
switch state.returnKeyRole {
case .newline: return "return"
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
}
}
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)
}
}
@@ -0,0 +1,105 @@
// KeyboardHapticFeedback.swift
// OSGKeyboard · Keyboard Extension
//
// Role-based typing haptics (no screen-position mapping). Intensity comes
// from Settings General Haptics (off / light / strong).
import UIKit
import OSGKeyboardShared
/// Key roles drive distinct Taptic styles closer to a real keyboard than
/// a single buzz for every tap.
enum KeyboardHapticKeyRole {
/// Letters, digits, punctuation.
case character
/// Shift, 123 / ABC / #+=.
case modifier
/// Space, return / send.
case action
/// Delete (including hold-to-repeat ticks).
case delete
}
@MainActor
enum KeyboardHapticFeedback {
private static let soft = UIImpactFeedbackGenerator(style: .soft)
private static let light = UIImpactFeedbackGenerator(style: .light)
private static let medium = UIImpactFeedbackGenerator(style: .medium)
private static let heavy = UIImpactFeedbackGenerator(style: .heavy)
private static let rigid = UIImpactFeedbackGenerator(style: .rigid)
/// Warm the generators so the first keypress is not soft/late.
static func prepare() {
soft.prepare()
light.prepare()
medium.prepare()
heavy.prepare()
rigid.prepare()
}
static func play(role: KeyboardHapticKeyRole, intensity: KeyboardHapticIntensity) {
guard intensity != .off else { return }
switch intensity {
case .off:
return
case .light:
playLight(role: role)
case .strong:
playStrong(role: role)
}
}
// Soft / light near stock keyboard feedback.
private static func playLight(role: KeyboardHapticKeyRole) {
switch role {
case .character:
soft.impactOccurred(intensity: 0.55)
soft.prepare()
case .modifier:
light.impactOccurred(intensity: 0.65)
light.prepare()
case .action:
light.impactOccurred(intensity: 0.8)
light.prepare()
case .delete:
medium.impactOccurred(intensity: 0.45)
medium.prepare()
}
}
// Heavier / sharper more mechanical-keyboard feel.
private static func playStrong(role: KeyboardHapticKeyRole) {
switch role {
case .character:
medium.impactOccurred(intensity: 0.75)
medium.prepare()
case .modifier:
medium.impactOccurred(intensity: 0.95)
medium.prepare()
case .action:
heavy.impactOccurred(intensity: 0.9)
heavy.prepare()
case .delete:
rigid.impactOccurred(intensity: 1.0)
rigid.prepare()
}
}
}
/// Sound + haptic for a typing-grid key press (pressed-down timing).
@MainActor
enum TypingKeyFeedback {
static func play(
role: KeyboardHapticKeyRole,
intensity: KeyboardHapticIntensity,
isDelete: Bool = false
) {
if isDelete {
KeyboardSoundFeedback.deleteClick()
} else {
KeyboardSoundFeedback.keyClick()
}
KeyboardHapticFeedback.play(role: role, intensity: intensity)
}
}
@@ -1,293 +0,0 @@
// KeyboardOnboardingOverlay.swift
// OSGKeyboard · Keyboard Extension
//
// v0.3.0 in-keyboard onboarding. Replaces the previous "jump out to
// the host app" flow with a five-step overlay that lives on top of
// the normal keyboard UI.
//
// Why in-keyboard instead of jumping to the host app?
//
// - iOS keyboard extensions **cannot programmatically switch back
// to the previous app** after a host-app jump. The user has to
// re-find their app, re-tap a text field, and re-select OSGKeyboard
// from the globe menu. That's a 5+ tap friction.
//
// - Steps 1, 2, 4 (welcome, mic permission, speech permission,
// API key) need nothing the host app owns. They can all live in
// the keyboard.
//
// - The only step that *must* leave the keyboard is step 3
// ("Enable Keyboard") iOS requires the user to flip a toggle
// in `Settings.app`, which is reachable from the extension via
// `UIApplication.openSettingsURLString`. After the user comes
// back, `viewWillAppear` reads `KeyboardSetupBridge.isReadyForOnboardingSkip`
// and the overlay auto-advances past step 3.
//
// The overlay mounts only when `state.hasCompletedOnboarding == false`.
// All inputs route through `KeyboardState` action hooks, so the
// controller can mirror them into the App Group without the view
// having to know about persistence.
import SwiftUI
import OSGKeyboardShared
struct KeyboardOnboardingOverlay: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var state: KeyboardViewController.State
var body: some View {
ZStack {
// Dim the underlying keyboard so the overlay reads as a
// distinct surface. We can't completely hide it without
// losing keyboard-system visibility, so a 60% black wash
// is the sweet spot between focus and consistency.
palette.background.opacity(0.96).ignoresSafeArea()
VStack(spacing: 0) {
header
Spacer(minLength: Spacing.sm)
Group {
switch currentStep {
case .welcome: welcomeStep
case .microphone: microphoneStep
case .speech: speechStep
case .keyboard: keyboardStep
case .api: apiStep
}
}
.transition(.opacity.combined(with: .move(edge: .trailing)))
.frame(maxWidth: .infinity, maxHeight: .infinity)
Spacer(minLength: Spacing.sm)
footer
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
}
}
// MARK: - Steps
private enum Step: Int, CaseIterable {
case welcome = 0, microphone, speech, keyboard, api
static let count = 5
}
private var currentStep: Step {
Step(rawValue: state.onboardingPage) ?? .welcome
}
private var header: some View {
VStack(spacing: Spacing.xs) {
HStack(spacing: 6) {
ForEach(0..<Step.count, id: \.self) { idx in
Capsule()
.fill(idx <= currentStep.rawValue
? palette.accent
: palette.divider)
.frame(height: 4)
}
}
.padding(.horizontal, Spacing.xs)
Text(ExtL10n.string("keyboard.onboarding.title"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
}
// MARK: - Step content
private var welcomeStep: some View {
stepBody(
iconSystemName: "waveform.badge.mic",
title: "keyboard.onboarding.welcome.title",
body: "keyboard.onboarding.welcome.body"
)
}
private var microphoneStep: some View {
stepBody(
iconSystemName: "mic.fill",
title: "keyboard.onboarding.mic.title",
body: "keyboard.onboarding.mic.body"
)
}
private var speechStep: some View {
stepBody(
iconSystemName: "ear",
title: "keyboard.onboarding.speech.title",
body: "keyboard.onboarding.speech.body"
)
}
private var keyboardStep: some View {
VStack(spacing: Spacing.md) {
Image(systemName: "keyboard")
.font(.system(size: 36, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.string("keyboard.onboarding.keyboard.title"))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.string("keyboard.onboarding.keyboard.body"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Button {
state.openSystemSettings()
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.up.right.square")
Text(ExtL10n.string("keyboard.onboarding.keyboard.openSettings"))
}
.font(TypeStyle.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs + 2)
.background(palette.accent, in: Capsule())
}
.accessibilityLabel(ExtL10n.string("keyboard.onboarding.keyboard.openSettings"))
}
.frame(maxWidth: .infinity)
}
private var apiStep: some View {
VStack(spacing: Spacing.md) {
Image(systemName: "key.fill")
.font(.system(size: 32, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.string("keyboard.onboarding.api.title"))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.string("keyboard.onboarding.api.body"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(ExtL10n.string("keyboard.onboarding.api.skipHint"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private func stepBody(
iconSystemName: String,
title: String,
body: String
) -> some View {
VStack(spacing: Spacing.md) {
Image(systemName: iconSystemName)
.font(.system(size: 36, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.string(title))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.string(body))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
}
// MARK: - Footer
private var footer: some View {
HStack(spacing: Spacing.xs) {
if currentStep != .welcome {
Button(ExtL10n.string("keyboard.onboarding.back")) {
state.onboardingPage = max(0, currentStep.rawValue - 1)
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.frame(minHeight: 36)
}
Spacer(minLength: 0)
primaryButton
}
}
@ViewBuilder
private var primaryButton: some View {
switch currentStep {
case .welcome:
Button(ExtL10n.string("keyboard.onboarding.getStarted")) {
state.onboardingPage = 1
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .microphone:
Button(ExtL10n.string("keyboard.onboarding.mic.grant")) {
state.requestMicPermission()
// Optimistically advance if permission is denied the
// status text on the next viewWillAppear will reflect it.
state.onboardingPage = 2
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .speech:
Button(ExtL10n.string("keyboard.onboarding.speech.grant")) {
state.requestSpeechPermission()
state.onboardingPage = 3
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .keyboard:
// Step 3 is auto-advanced by viewWillAppear once the user
// has enabled the keyboard in Settings.app. We don't show
// a "Continue" button here that would re-trigger the
// confusion we're solving.
Button(ExtL10n.string("keyboard.onboarding.keyboard.openSettings")) {
state.openSystemSettings()
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .api:
HStack(spacing: Spacing.xs) {
Button(ExtL10n.string("keyboard.onboarding.api.skip")) {
state.completeOnboarding()
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 36)
Button(ExtL10n.string("keyboard.onboarding.api.openHostApp")) {
state.openSettings()
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
}
}
}
}
private struct OverlayPrimaryButtonStyle: ButtonStyle {
let palette: ThemePalette
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(TypeStyle.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs + 2)
.background(palette.accent.opacity(configuration.isPressed ? 0.7 : 1.0),
in: Capsule())
.frame(minHeight: 36)
.contentShape(Capsule())
}
}
+85 -93
View File
@@ -8,7 +8,7 @@
// in `applyPresentationHeightOffset()`.
//
//
// [polish] [] header band (top)
// [OSG] EN header band (top)
// (transcript preview)
//
// mic (centred) action cluster:
@@ -22,59 +22,56 @@ import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let micSize: CGFloat = 121
static let micToButtonGap: CGFloat = 8
static let bottomActionRowHeight: CGFloat = 48
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.
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
/// 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
/// phone layout. iPhone widths are all below this, so it is a no-op there.
static let contentMaxWidth: CGFloat = 700
static let contentMaxWidth: CGFloat = KeyboardChromeLayout.contentMaxWidth
// 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
/// Centres the mic between the transcript hint and bottom action row.
static let micUpwardAdjustment: CGFloat = (actionClusterTopGap - micToButtonGap) / 2
/// 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,
onInsert: @escaping (String) -> Void = { _ in }
) {
self.state = state
self.typing = typing
self.onInsert = onInsert
}
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
@@ -89,6 +86,7 @@ public struct KeyboardRootView: View {
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
+ KeyboardLayoutMetrics.headerBandHeight
+ KeyboardLayoutMetrics.actionClusterTopGap
- KeyboardLayoutMetrics.micUpwardAdjustment
/// Horizontal inset the side pads should respect.
static let sideInset: CGFloat = KeyboardLayoutMetrics.sideActionHorizontalInset
@@ -119,24 +117,11 @@ public struct KeyboardRootView: View {
.frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
// v0.3.0: in-keyboard first-launch onboarding. Mounted as
// an overlay so the normal keyboard chrome stays
// responsive underneath (mic button still works, chip
// taps register). Only rendered until
// `state.hasCompletedOnboarding` flips to true; from
// then on the overlay is unmounted and never re-rendered.
if !state.hasCompletedOnboarding {
KeyboardOnboardingOverlay(state: state)
.environment(\.themePalette, palette)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.18), value: state.hasCompletedOnboarding)
.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 +143,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
@@ -217,23 +185,37 @@ public struct KeyboardRootView: View {
onToggle: state.tapMic
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
.opacity(dragging ? 0 : 1)
cursorDragPad(enabled: cursorPadsEnabled)
}
.frame(height: KeyboardLayoutMetrics.micSize)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
bottomDeleteButton(disabled: editingBlocked)
} else {
bottomDeleteButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidths(
availableWidth: proxy.size.width
)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
} else {
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
}
}
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
.opacity(dragging ? 0 : 1)
}
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
@@ -255,25 +237,24 @@ public struct KeyboardRootView: View {
RepeatingDeleteButton(disabled: disabled) {
state.deleteBackward()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomSpaceButton(disabled: Bool) -> some View {
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
state.insertSpace()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
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)
@@ -315,19 +296,28 @@ extension KeyboardRootView {
#if DEBUG
#Preview("Keyboard · Idle") {
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
KeyboardRootView(
state: KeyboardViewController.State.previewIdle,
typing: TypingSessionController()
)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Recording") {
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
KeyboardRootView(
state: KeyboardViewController.State.previewRecording,
typing: TypingSessionController()
)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Processing") {
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
KeyboardRootView(
state: KeyboardViewController.State.previewProcessing,
typing: TypingSessionController()
)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
@@ -433,6 +423,8 @@ private struct TranscriptLine: View {
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
ExtL10n.text("keyboard.error.appGroupCommunication")
case .unavailable(.onboardingIncomplete):
ExtL10n.text("keyboard.hint.finishSetupInApp")
case .recording, .processing:
EmptyView()
}
@@ -0,0 +1,251 @@
// 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.openSettingsA11y"))
}
}
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:
applyTypingOutput(typing.setLanguage(.chinese))
state.setSurface(.typing)
case .english:
applyTypingOutput(typing.setLanguage(.english))
state.setSurface(.typing)
}
}
private func applyTypingOutput(_ output: TypingOutput) {
if output.deleteCount > 0 {
// Language switch rarely deletes; keep insert-only path for capsule.
}
guard !output.text.isEmpty else {
typing.syncAutocapitalization()
return
}
onInsert(output.text)
typing.syncAutocapitalization()
}
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
}
}
}
+162 -60
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,65 +28,90 @@ 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)
}
}
}
// MARK: - Repeating delete
/// Tap deletes once; hold repeats with tiered acceleration after 5 s.
struct RepeatingDeleteButton: View {
@Environment(\.themePalette) private var palette
/// Shared hold-to-repeat timing for delete keys (voice + typing).
enum RepeatingDeleteTiming {
static let initialDelay: TimeInterval = 0.4
static let normalInterval: TimeInterval = 0.08
static let accelTier2: TimeInterval = 0.05
static let accelTier3: TimeInterval = 0.03
static let accelTier4: TimeInterval = 0.015
let disabled: Bool
static func interval(for elapsed: TimeInterval) -> TimeInterval {
if elapsed < 5 { return normalInterval }
if elapsed < 8 { return accelTier2 }
if elapsed < 12 { return accelTier3 }
return accelTier4
}
}
/// Tap fires once; hold repeats with tiered acceleration after 5 s.
/// Voice and typing delete keys share this press engine.
struct RepeatingPressButton<Label: View>: View {
var disabled: Bool = false
/// Plays the system delete click on each fire (matches stock keyboard).
var playsDeleteSound: Bool = true
/// Typing-grid haptic strength; `.off` skips haptics (voice toolbar default).
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@State private var isPressing = false
@State private var repeatTask: Task<Void, Never>?
@State private var repeatStartedAt: Date?
private let initialDelay: TimeInterval = 0.4
private let normalInterval: TimeInterval = 0.08
private let accelTier2: TimeInterval = 0.05
private let accelTier3: TimeInterval = 0.03
private let accelTier4: TimeInterval = 0.015
var body: some View {
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
Image(systemName: "delete.left")
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text("delete"))
.accessibilityAddTraits(.isButton)
label(isPressing)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text("delete"))
.accessibilityAddTraits(.isButton)
.onDisappear { stopRepeating() }
}
private var pressGesture: some Gesture {
@@ -92,8 +120,7 @@ struct RepeatingDeleteButton: View {
guard !disabled, !isPressing else { return }
isPressing = true
repeatStartedAt = Date()
KeyboardSoundFeedback.deleteClick()
action()
fireOnce()
startRepeating()
}
.onEnded { _ in
@@ -101,24 +128,26 @@ struct RepeatingDeleteButton: View {
}
}
private func interval(for elapsed: TimeInterval) -> TimeInterval {
if elapsed < 5 { return normalInterval }
if elapsed < 8 { return accelTier2 }
if elapsed < 12 { return accelTier3 }
return accelTier4
private func fireOnce() {
if playsDeleteSound {
KeyboardSoundFeedback.deleteClick()
}
KeyboardHapticFeedback.play(role: .delete, intensity: hapticIntensity)
action()
}
private func startRepeating() {
repeatTask?.cancel()
repeatTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(initialDelay * 1_000_000_000))
try? await Task.sleep(
nanoseconds: UInt64(RepeatingDeleteTiming.initialDelay * 1_000_000_000)
)
guard !Task.isCancelled, isPressing else { return }
let anchor = repeatStartedAt ?? Date()
while !Task.isCancelled, isPressing {
KeyboardSoundFeedback.deleteClick()
action()
fireOnce()
let elapsed = Date().timeIntervalSince(anchor)
let wait = interval(for: elapsed)
let wait = RepeatingDeleteTiming.interval(for: elapsed)
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
}
}
@@ -132,16 +161,72 @@ struct RepeatingDeleteButton: View {
}
}
/// Voice-toolbar delete chrome around ``RepeatingPressButton``.
struct RepeatingDeleteButton: View {
@Environment(\.colorScheme) private var colorScheme
let disabled: Bool
let action: () -> Void
var body: some View {
RepeatingPressButton(disabled: disabled, action: action) { isPressed in
ToolbarKeySurface(
isPressed: isPressed,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: .standard
) {
Image(systemName: "delete.left")
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
}
}
}
}
// MARK: - Press-down typing key
/// Fires on touch-down (not release) so click sound / haptic match the stock
/// keyboard and the voice toolbars RectangularToolbarButton.
struct PressDownKeyButton<Label: View>: View {
var disabled: Bool = false
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@State private var isPressing = false
var body: some View {
label(isPressing)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityAddTraits(.isButton)
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
action()
}
.onEnded { _ in
isPressing = false
}
}
}
// 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 +235,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 +261,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 +295,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)
+2 -20
View File
@@ -211,26 +211,8 @@
"locale.ja-JP" = "Japanese";
"locale.ko-KR" = "Korean";
/* v0.3.0: in-keyboard first-launch onboarding */
"keyboard.onboarding.title" = "Set up OSGKeyboard";
"keyboard.onboarding.back" = "Back";
"keyboard.onboarding.getStarted" = "Get Started";
"keyboard.onboarding.welcome.title" = "Welcome to OSGKeyboard";
"keyboard.onboarding.welcome.body" = "Voice-to-text with AI polish. Set up takes 30 seconds — most of it happens right here in the keyboard.";
"keyboard.onboarding.mic.title" = "Microphone access";
"keyboard.onboarding.mic.body" = "OSGKeyboard needs microphone access to transcribe your speech. iOS will show a system prompt.";
"keyboard.onboarding.mic.grant" = "Allow Microphone";
"keyboard.onboarding.speech.title" = "Speech recognition";
"keyboard.onboarding.speech.body" = "Apple's on-device speech engine turns your voice into text. iOS will show a system prompt.";
"keyboard.onboarding.speech.grant" = "Allow Speech Recognition";
"keyboard.onboarding.keyboard.title" = "Enable OSGKeyboard";
"keyboard.onboarding.keyboard.body" = "Open Settings → General → Keyboard → Keyboards → Add New Keyboard → OSGKeyboard. Then tap OSGKeyboard again and enable Allow Full Access. Come back here when you're done.";
"keyboard.onboarding.keyboard.openSettings" = "Open Settings";
"keyboard.onboarding.api.title" = "One last step";
"keyboard.onboarding.api.body" = "To polish your text with AI, OSGKeyboard needs an LLM API key. You can add it now in the app, or skip and add it later from Settings.";
"keyboard.onboarding.api.skipHint" = "Polishing won't work without an API key, but the keyboard will still type the raw transcript.";
"keyboard.onboarding.api.skip" = "Skip";
"keyboard.onboarding.api.openHostApp" = "Open OSGKeyboard";
/* Voice gated until host-app onboarding finishes */
"keyboard.hint.finishSetupInApp" = "Finish setup in OSGKeyboard to use voice input.";
/* v0.3.0: AppContext chip on the keyboard top bar */
"keyboard.appContext.a11y" = "Polish context";
+2 -20
View File
@@ -211,26 +211,8 @@
"locale.ja-JP" = "日本語";
"locale.ko-KR" = "한국어";
/* v0.3.0:键盘内首次启动引导 */
"keyboard.onboarding.title" = "设置 OSGKeyboard";
"keyboard.onboarding.back" = "返回";
"keyboard.onboarding.getStarted" = "开始设置";
"keyboard.onboarding.welcome.title" = "欢迎使用 OSGKeyboard";
"keyboard.onboarding.welcome.body" = "语音转文字 + AI 润色。30 秒搞定,绝大部分步骤直接在键盘里完成。";
"keyboard.onboarding.mic.title" = "麦克风权限";
"keyboard.onboarding.mic.body" = "OSGKeyboard 需要麦克风权限来转写你的语音。点击下方按钮后,iOS 会弹出系统提示。";
"keyboard.onboarding.mic.grant" = "允许麦克风";
"keyboard.onboarding.speech.title" = "语音识别权限";
"keyboard.onboarding.speech.body" = "Apple 的本地语音引擎负责把声音变成文字。点击下方按钮后,iOS 会弹出系统提示。";
"keyboard.onboarding.speech.grant" = "允许语音识别";
"keyboard.onboarding.keyboard.title" = "启用 OSGKeyboard";
"keyboard.onboarding.keyboard.body" = "打开 设置 → 通用 → 键盘 → 键盘 → 添加新键盘 → OSGKeyboard。然后再次点击 OSGKeyboard,打开\"允许完全访问\"。完成后回到这里。";
"keyboard.onboarding.keyboard.openSettings" = "打开设置";
"keyboard.onboarding.api.title" = "最后一步";
"keyboard.onboarding.api.body" = "要让 AI 润色你的文字,OSGKeyboard 需要一个 LLM API key。你可以现在在 App 里填写,也可以先跳过,之后在设置里补上。";
"keyboard.onboarding.api.skipHint" = "没有 API key 也能用——只是不会润色,只输出原始转写。";
"keyboard.onboarding.api.skip" = "跳过";
"keyboard.onboarding.api.openHostApp" = "打开 OSGKeyboard";
/* 未完成主 App 引导时,语音入口提示 */
"keyboard.hint.finishSetupInApp" = "请在 OSGKeyboard App 中完成设置后再使用语音输入。";
/* v0.3.0:键盘顶栏的输入场景芯片 */
"keyboard.appContext.a11y" = "润色场景";
@@ -0,0 +1,163 @@
// CandidatePanelExpandTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class CandidatePanelExpandTests: XCTestCase {
func testDefaultConfigPageSizeMatchesExpandPool() {
let yaml = RimeSchemaGenerator.defaultConfiguration()
XCTAssertTrue(yaml.contains("page_size: 100"))
}
func testChevronRequiresChineseAndAtLeastTwoCandidates() {
let engine = StubRimeEngine()
let typing = TypingSessionController(engine: { engine })
XCTAssertFalse(typing.canExpandCandidatePanel)
engine.composition = TypingComposition(
preedit: "ni",
candidates: [
TypingCandidate(text: ""),
TypingCandidate(text: "")
]
)
// Composition is owned by the session; drive via processCharacter.
_ = typing.handleKey("n")
XCTAssertTrue(typing.canExpandCandidatePanel)
XCTAssertEqual(typing.composition.candidates.count, 2)
_ = typing.setLanguage(.english)
XCTAssertFalse(typing.canExpandCandidatePanel)
XCTAssertFalse(typing.isCandidatePanelExpanded)
}
func testToggleExpandAndSelectCollapses() {
let engine = StubRimeEngine()
let typing = TypingSessionController(engine: { engine })
_ = typing.handleKey("n")
XCTAssertTrue(typing.canExpandCandidatePanel)
typing.toggleCandidatePanelExpanded()
XCTAssertTrue(typing.isCandidatePanelExpanded)
typing.toggleCandidatePanelExpanded()
XCTAssertFalse(typing.isCandidatePanelExpanded)
typing.toggleCandidatePanelExpanded()
XCTAssertTrue(typing.isCandidatePanelExpanded)
let output = typing.selectCandidate(at: 0)
XCTAssertEqual(output, .insert(""))
XCTAssertFalse(typing.isCandidatePanelExpanded)
}
func testPanelCollapsesWhenCandidatesDropBelowTwo() {
let engine = StubRimeEngine()
let typing = TypingSessionController(engine: { engine })
_ = typing.handleKey("n")
typing.toggleCandidatePanelExpanded()
XCTAssertTrue(typing.isCandidatePanelExpanded)
// Backspace to a single-candidate (or empty) composition.
engine.nextComposition = TypingComposition(
preedit: "n",
candidates: [TypingCandidate(text: "")]
)
_ = typing.handleKey("")
XCTAssertFalse(typing.canExpandCandidatePanel)
XCTAssertFalse(typing.isCandidatePanelExpanded)
}
func testLeaveTypingModeCollapsesPanel() {
let engine = StubRimeEngine()
let typing = TypingSessionController(engine: { engine })
_ = typing.handleKey("n")
typing.toggleCandidatePanelExpanded()
XCTAssertTrue(typing.isCandidatePanelExpanded)
typing.leaveTypingMode()
XCTAssertFalse(typing.isCandidatePanelExpanded)
XCTAssertTrue(typing.composition.candidates.isEmpty)
}
}
// Minimal engine that returns a two-candidate snapshot after any letter.
@MainActor
private final class StubRimeEngine: RimeEngineBridging {
var composition: TypingComposition = .empty
var isReady: Bool = true
var schema: TypingInputSchema = .fullPinyin
/// Optional override applied on the next processBackspace.
var nextComposition: TypingComposition?
func prepare() async throws {}
func teardown() {
composition = .empty
}
func setLanguage(_ language: TypingInputLanguage) {
if language == .english {
composition = .empty
}
}
@discardableResult
func setSchema(_ schema: TypingInputSchema) -> Bool {
self.schema = schema
return true
}
func processCharacter(_ character: Character) -> String? {
composition = TypingComposition(
preedit: String(character),
candidates: [
TypingCandidate(text: ""),
TypingCandidate(text: "")
]
)
return nil
}
func processBackspace() -> String? {
if let next = nextComposition {
composition = next
nextComposition = nil
} else {
composition = .empty
}
return nil
}
func processSpace() -> String? {
let text = composition.candidates.first?.text ?? " "
composition = .empty
return text
}
func processReturn() -> String? {
composition = .empty
return "\n"
}
func selectCandidate(at index: Int) -> String {
guard composition.candidates.indices.contains(index) else { return "" }
let text = composition.candidates[index].text
composition = .empty
return text
}
func flushPreedit() -> String {
let raw = composition.preedit
composition = .empty
return raw
}
func clearComposition() {
composition = .empty
}
}
@@ -0,0 +1,140 @@
// EnglishTypingTests.swift
// OSGKeyboard · Ext unit tests
//
// Lexicon / accent / suggestion ranking for the English typing path.
import XCTest
@testable import OSGKeyboardShared
final class EnglishTypingTests: XCTestCase {
func testLexiconLoadsAndCompletesPrefix() {
let lexicon = EnglishLexicon()
lexicon.prepare()
XCTAssertGreaterThan(lexicon.wordCount, 1_000)
let hits = lexicon.completions(prefix: "hel", limit: 5)
XCTAssertTrue(hits.contains("hello") || hits.contains("help") || hits.contains("held"))
}
func testCorrectionFindsNearbyWord() {
let lexicon = EnglishLexicon()
lexicon.prepare()
// "teh" is a classic typo for "the".
let correction = lexicon.bestCorrection(for: "teh")
XCTAssertEqual(correction, "the")
XCTAssertNil(lexicon.bestCorrection(for: "the"))
}
func testSuggestionEngineSkipsPersonalDictionaryTypos() {
let engine = EnglishSuggestionEngine()
engine.prepare()
let decision = engine.correctionDecision(
for: "teh",
personalTerms: ["teh"],
learnedBoosts: [:]
)
XCTAssertNil(decision)
}
func testSuggestionEngineCompletionsPreferPersonalTerms() {
let engine = EnglishSuggestionEngine()
engine.prepare()
let composition = engine.compositionWhileTyping(
EnglishSuggestionContext(
currentWord: "osg",
personalTerms: ["OSGKeyboard"],
learnedBoosts: [:]
)
)
XCTAssertEqual(composition.candidates.first?.text, "OSGKeyboard")
}
func testAutocapitalizationAtFieldStartAndAfterSentence() {
XCTAssertTrue(
TypingAutocapitalization.shouldCapitalize(precedingText: nil, mode: .sentences)
)
XCTAssertTrue(
TypingAutocapitalization.shouldCapitalize(precedingText: "", mode: .sentences)
)
XCTAssertTrue(
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello. ", mode: .sentences)
)
XCTAssertTrue(
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello!\n", mode: .sentences)
)
XCTAssertFalse(
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello ", mode: .sentences)
)
XCTAssertTrue(
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello ", mode: .words)
)
XCTAssertFalse(
TypingAutocapitalization.shouldCapitalize(precedingText: nil, mode: .none)
)
}
@MainActor
func testEnglishIdleShowsNoCandidatesUntilLetterTyped() {
let typing = TypingSessionController()
typing.suggestionsEnabled = true
_ = typing.setLanguage(.english)
typing.enterTypingMode()
XCTAssertTrue(typing.composition.candidates.isEmpty)
_ = typing.handleKey("h")
XCTAssertFalse(typing.composition.candidates.isEmpty)
XCTAssertEqual(typing.composition.preedit, "h")
}
func testEnglishTypingHotwordsFilterOutChineseTerms() {
var dictionary = PersonalDictionary()
_ = dictionary.upsertManual(term: "张三")
_ = dictionary.upsertManual(term: "OSGKeyboard")
_ = dictionary.upsertManual(term: "微信")
guard let wechatID = dictionary.entry(matchingTerm: "微信")?.id else {
return XCTFail("expected 微信 entry")
}
dictionary.updateAliases(for: wechatID, aliases: ["WeChat"])
let hotwords = dictionary.englishTypingHotwords()
XCTAssertTrue(hotwords.contains("OSGKeyboard"))
XCTAssertTrue(hotwords.contains("WeChat"))
XCTAssertFalse(hotwords.contains(where: { $0.contains("") || $0.contains("") }))
XCTAssertFalse(PersonalDictionary.isEnglishTypingHotword("你好"))
XCTAssertTrue(PersonalDictionary.isEnglishTypingHotword("GPT-4"))
}
@MainActor
func testEnglishTypingEmitsCompletionsIntoComposition() {
let typing = TypingSessionController()
typing.suggestionsEnabled = true
_ = typing.setLanguage(.english)
typing.enterTypingMode()
_ = typing.handleKey("h")
_ = typing.handleKey("e")
_ = typing.handleKey("l")
XCTAssertFalse(typing.composition.candidates.isEmpty)
XCTAssertEqual(typing.composition.preedit, "hel")
}
@MainActor
func testAutocorrectUndoRestoresOriginal() {
let typing = TypingSessionController()
typing.suggestionsEnabled = true
_ = typing.setLanguage(.english)
typing.enterTypingMode()
for ch in ["t", "e", "h"] {
_ = typing.handleKey(ch)
}
let spaced = typing.handleSpace()
// Either corrected to "the " or left as-is if lexicon missing in test bundle.
if spaced.deleteCount > 0 {
XCTAssertTrue(spaced.text.hasPrefix("the"))
let undone = typing.handleKey("")
XCTAssertEqual(undone.text, "teh")
XCTAssertEqual(undone.deleteCount, spaced.text.count)
}
}
}
@@ -0,0 +1,157 @@
// 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, language: .english, shiftActive: false)
XCTAssertEqual(rows.first, ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"])
let shifted = layout.rows(for: .letters, language: .english, shiftActive: true)
XCTAssertEqual(shifted.first?.first, "Q")
}
func testEnglishNumberAndSymbolPagesMatchIOSUS() {
let layout = StandardTypingLayout()
XCTAssertEqual(
layout.rows(for: .numbers, language: .english, shiftActive: false),
[
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
["#+=", ".", ",", "?", "!", "'", ""]
]
)
XCTAssertEqual(
layout.rows(for: .symbols, language: .english, shiftActive: false),
[
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
["_", "\\", "|", "~", "<", ">", "", "£", "¥", "·"],
["123", ".", ",", "?", "!", "'", ""]
]
)
}
func testChineseNumberAndSymbolPagesMatchIOSSimplified() {
let layout = StandardTypingLayout()
XCTAssertEqual(
layout.rows(for: .numbers, language: .chinese, shiftActive: false),
[
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["-", "/", "", "", "", "", "", "@", "", ""],
["#+=", "", "", "", "", "", ".", ""]
]
)
XCTAssertEqual(
layout.rows(for: .symbols, language: .chinese, shiftActive: false),
[
["", "", "", "", "#", "%", "^", "*", "+", "="],
["_", "\\", "|", "~", "", "", "", "£", "¥", "·"],
["123", "", "", "", "", "", ".", ""]
]
)
}
func testKeyRowsFollowTypingLanguageOnNumberPage() {
let typing = TypingSessionController()
typing.setPage(.numbers)
XCTAssertEqual(typing.keyRows[1], ["-", "/", "", "", "", "", "", "@", "", ""])
_ = typing.setLanguage(.english)
typing.setPage(.numbers)
XCTAssertEqual(typing.keyRows[1], ["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""])
}
func testVoiceAndTypingChromeShareStableDimensions() {
XCTAssertEqual(KeyboardChromeLayout.totalHeight, 281)
XCTAssertEqual(KeyboardChromeLayout.actionKeyHeight, 50)
XCTAssertEqual(KeyboardChromeLayout.actionKeyCornerRadius, 10)
XCTAssertEqual(KeyboardChromeLayout.actionKeySpacing, 8)
XCTAssertEqual(KeyboardChromeLayout.sideActionKeyFraction, 0.2)
XCTAssertEqual(KeyboardChromeLayout.centerActionKeyFraction, 0.6)
XCTAssertEqual(KeyboardChromeLayout.horizontalInset, 8)
XCTAssertEqual(KeyboardChromeLayout.contentMaxWidth, 700)
let widths = KeyboardChromeLayout.actionKeyWidths(availableWidth: 374)
XCTAssertEqual(widths.side, 71.6, accuracy: 0.001)
XCTAssertEqual(widths.center, 214.8, accuracy: 0.001)
}
func testSharedCapsuleCanSelectSpecificTypingLanguage() {
let typing = TypingSessionController()
XCTAssertEqual(typing.language, .chinese)
XCTAssertEqual(typing.setLanguage(.english), .none)
XCTAssertEqual(typing.language, .english)
XCTAssertEqual(typing.setLanguage(.chinese), .none)
XCTAssertEqual(typing.language, .chinese)
}
func testShiftStateProducesUppercaseKeyRows() {
let typing = TypingSessionController()
XCTAssertFalse(typing.shiftActive)
_ = typing.handleKey("")
XCTAssertTrue(typing.shiftActive)
XCTAssertTrue(typing.isShiftEnabled)
XCTAssertEqual(typing.keyRows.first?.first, "Q")
}
func testManualShiftSurvivesAutocapitalizationSync() {
let typing = TypingSessionController()
// Providers must be set before language switch (which syncs autocap).
typing.precedingTextProvider = { "hello " } // mid-sentence: auto stays off
typing.autocapitalizationModeProvider = { .sentences }
_ = typing.setLanguage(.english)
XCTAssertFalse(typing.shiftActive)
_ = typing.handleKey("")
XCTAssertTrue(typing.shiftActive)
typing.syncAutocapitalization()
XCTAssertTrue(typing.shiftActive, "manual one-shot must outrank autocap sync")
XCTAssertEqual(typing.keyRows.first?.first, "Q")
}
func testShiftHoldTypesUppercaseWithoutEnteringCapsLock() {
let typing = TypingSessionController()
_ = typing.setLanguage(.english)
typing.precedingTextProvider = { "hello " }
typing.autocapitalizationModeProvider = { .sentences }
typing.beginShiftHold()
XCTAssertTrue(typing.shiftHeld)
XCTAssertEqual(typing.keyRows.first?.first, "Q")
_ = typing.handleKey("S")
_ = typing.handleKey("m")
XCTAssertFalse(typing.capsLock)
typing.endShiftHold()
XCTAssertFalse(typing.shiftHeld)
XCTAssertFalse(typing.capsLock)
XCTAssertFalse(typing.shiftActive)
}
func testShiftHoldWithoutTypingActsAsTap() {
let typing = TypingSessionController()
typing.beginShiftHold()
typing.endShiftHold()
XCTAssertTrue(typing.shiftActive)
XCTAssertFalse(typing.shiftHeld)
XCTAssertFalse(typing.capsLock)
}
}
@@ -0,0 +1,267 @@
// 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 = Bundle(for: LibrimeIntegrationTests.self)
let dictionary = try XCTUnwrap(
bundle.url(forResource: "osg_pinyin.dict", withExtension: "yaml")
)
try fileManager.copyItem(
at: dictionary,
to: shared.appendingPathComponent("osg_pinyin.dict.yaml")
)
try RimeSchemaGenerator.defaultConfiguration().write(
to: shared.appendingPathComponent("default.yaml"),
atomically: true,
encoding: .utf8
)
for schema in TypingInputSchema.allCases {
try RimeSchemaGenerator.schema(for: schema, fuzzyPairs: []).write(
to: shared.appendingPathComponent("\(schema.rawValue).schema.yaml"),
atomically: true,
encoding: .utf8
)
}
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.35,
"\(schema.displayName) first candidates exceeded 350 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 hello = snapshot.candidates.first(where: { $0.text == "你好" }) {
XCTAssertTrue(bridge.selectCandidate(at: hello.index))
XCTAssertEqual(
bridge.snapshot(withCandidateLimit: 10).commitText,
"你好"
)
}
}
// Incomplete multi-syllable input should keep phrase completions and
// still expose first-syllable characters (PC-IME progressive style).
bridge.clearComposition()
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
for scalar in "zhongg".utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let zhongg = bridge.snapshot(withCandidateLimit: 160)
XCTAssertTrue(
zhongg.candidates.contains(where: { $0.text == "中国" }),
"zhongg phrases: \(zhongg.candidates.map(\.text).prefix(30))"
)
XCTAssertTrue(
zhongg.candidates.contains(where: { $0.text == "" }),
"zhongg should keep first-syllable 中: \(zhongg.candidates.map(\.text).prefix(40))"
)
if let china = zhongg.candidates.firstIndex(where: { $0.text == "中国" }),
let zhong = zhongg.candidates.firstIndex(where: { $0.text == "" }) {
XCTAssertLessThan(china, zhong, "phrases should precede first-syllable chars")
}
XCTAssertGreaterThanOrEqual(zhongg.candidates.count, 40)
bridge.clearComposition()
for scalar in "zhao".utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let zhao = bridge.snapshot(withCandidateLimit: 160)
XCTAssertGreaterThanOrEqual(
zhao.candidates.count,
40,
"zhao candidates: \(zhao.candidates.map(\.text).prefix(20))"
)
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()
}
func testPersonalDictionarySidecarPinsSameCodeCandidates() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("OSGRimePersonal-\(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 = Bundle(for: LibrimeIntegrationTests.self)
let dictionaryURL = try XCTUnwrap(
bundle.url(forResource: "osg_pinyin.dict", withExtension: "yaml")
)
let baseline = try String(contentsOf: dictionaryURL, encoding: .utf8)
let patched = RimePersonalDictionaryExporter.injectingImportTables(into: baseline)
try patched.write(
to: shared.appendingPathComponent("osg_pinyin.dict.yaml"),
atomically: true,
encoding: .utf8
)
// Unique personal phrase on a common code must outrank baseline .
let personalYAML = RimePersonalDictionaryExporter.yaml(
entries: [
.init(text: "尼好专名", code: "ni hao"),
.init(text: "ChatGPT", code: "chatgpt")
]
)
try personalYAML.write(
to: shared.appendingPathComponent("osg_personal.dict.yaml"),
atomically: true,
encoding: .utf8
)
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-personal"
)
try deployer.deploy(withFullCheck: true)
deployer.finalizeRuntime()
let bridge = OSGRimeBridge(
sharedDataDirectory: shared.path,
userDataDirectory: user.path,
distributionVersion: "tests-personal"
)
try bridge.start()
XCTAssertTrue(bridge.selectSchema(TypingInputSchema.fullPinyin.rawValue))
for scalar in "nihao".utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let chinese = bridge.snapshot(withCandidateLimit: 40)
XCTAssertTrue(
chinese.candidates.contains(where: { $0.text == "尼好专名" }),
"personal Chinese missing: \(chinese.candidates.map(\.text).prefix(20))"
)
if let personal = chinese.candidates.firstIndex(where: { $0.text == "尼好专名" }),
let baselineHello = chinese.candidates.firstIndex(where: { $0.text == "你好" }) {
XCTAssertLessThan(personal, baselineHello, "personal same-code should pin above baseline")
}
bridge.clearComposition()
for scalar in "chatgpt".utf8 {
XCTAssertTrue(bridge.processKeyCode(Int32(scalar), modifiers: 0))
}
let english = bridge.snapshot(withCandidateLimit: 40)
XCTAssertTrue(
english.candidates.contains(where: { $0.text == "ChatGPT" }),
"personal English missing: \(english.candidates.map(\.text).prefix(20))"
)
bridge.finalizeRuntime()
}
}
@@ -0,0 +1,110 @@
// RimePersonalDictionaryExporterTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
final class RimePersonalDictionaryExporterTests: XCTestCase {
private let annotator = RimePinyinAnnotator(
phraseCodes: [
"你好": "ni hao",
"阿坝": "a ba",
"官网": "guan wang"
],
characterCodes: [
"": "ni",
"": "hao",
"": "a",
"": "ba",
"": "guan",
"": "wang",
"": "ou",
"": "si",
"": "ji",
"": "jian"
]
)
func testAnnotatesPhraseExactMatch() {
XCTAssertEqual(annotator.code(for: "阿坝"), "a ba")
}
func testAnnotatesCharacterFallback() {
XCTAssertEqual(annotator.code(for: "欧斯吉键"), "ou si ji jian")
}
func testAnnotatesLatinProductName() {
XCTAssertEqual(annotator.code(for: "ChatGPT"), "chatgpt")
XCTAssertEqual(annotator.code(for: "GPT-4"), "gpt")
}
func testAnnotatesMixedChineseAndLatin() {
XCTAssertEqual(annotator.code(for: "ChatGPT官网"), "chatgpt guan wang")
}
func testExporterPinsChineseEnglishAndLatinAlias() {
var dictionary = PersonalDictionary.empty
dictionary.entries = [
PersonalDictionary.Entry(
term: "阿坝",
aliases: ["Aba"],
category: .properNoun,
source: .manual
),
PersonalDictionary.Entry(
term: "ChatGPT",
aliases: ["聊天GP"],
category: .productName,
source: .manual
)
]
let rows = RimePersonalDictionaryExporter.entries(from: dictionary, annotator: annotator)
XCTAssertTrue(rows.contains { $0.text == "阿坝" && $0.code == "a ba" })
XCTAssertTrue(rows.contains { $0.text == "阿坝" && $0.code == "aba" })
XCTAssertTrue(rows.contains { $0.text == "ChatGPT" && $0.code == "chatgpt" })
// Chinese ASR alias must not become a Rime code for the English term.
XCTAssertFalse(rows.contains { $0.text == "ChatGPT" && $0.code.contains(" ") })
XCTAssertTrue(rows.allSatisfy { $0.weight == RimePersonalDictionaryExporter.pinWeight })
}
func testYamlContainsImportReadyHeaderAndRows() {
let yaml = RimePersonalDictionaryExporter.yaml(
entries: [
.init(text: "阿坝", code: "a ba"),
.init(text: "ChatGPT", code: "chatgpt")
]
)
XCTAssertTrue(yaml.contains("name: osg_personal"))
XCTAssertTrue(yaml.contains("阿坝\ta ba\t\(RimePersonalDictionaryExporter.pinWeight)"))
XCTAssertTrue(yaml.contains("ChatGPT\tchatgpt\t\(RimePersonalDictionaryExporter.pinWeight)"))
}
func testInjectImportTablesIsIdempotent() {
let baseline = """
---
name: osg_pinyin
version: "1.0"
sort: by_weight
use_preset_vocabulary: false
columns:
- text
...
\ta\t1
"""
let once = RimePersonalDictionaryExporter.injectingImportTables(into: baseline)
XCTAssertTrue(once.contains("import_tables:"))
XCTAssertTrue(once.contains("- osg_personal"))
let twice = RimePersonalDictionaryExporter.injectingImportTables(into: once)
XCTAssertEqual(once, twice)
}
func testFingerprintChangesWhenEntriesChange() {
let a = RimePersonalDictionaryExporter.yaml(entries: [.init(text: "阿坝", code: "a ba")])
let b = RimePersonalDictionaryExporter.yaml(entries: [])
XCTAssertNotEqual(
RimePersonalDictionaryExporter.fingerprint(of: a),
RimePersonalDictionaryExporter.fingerprint(of: b)
)
}
}
@@ -0,0 +1,118 @@
// 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)
XCTAssertFalse(configuration.rememberLastSurface)
}
@MainActor
func testDefaultTypingPreferencePersistsAndDefaultsToOff() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertFalse(TypingInputConfiguration.prefersTypingOnOpen(defaults: defaults))
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.voice
)
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
XCTAssertTrue(TypingInputConfiguration.prefersTypingOnOpen(defaults: defaults))
XCTAssertTrue(TypingInputConfiguration(defaults: defaults).defaultToTyping)
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
)
}
@MainActor
func testRememberLastSurfaceOverridesDefaultToTyping() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
configuration.rememberLastSurface = true
TypingInputConfiguration.persistLastSurface(.voice, defaults: defaults)
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.voice
)
TypingInputConfiguration.persistLastSurface(.typing, defaults: defaults)
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
)
}
@MainActor
func testRememberLastSurfaceFallsBackWhenNothingPersisted() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
configuration.rememberLastSurface = true
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
)
}
}
@@ -6,6 +6,9 @@
import Charts
import SwiftUI
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public struct SevenDayUsageChart: View {
@Environment(\.themePalette) private var palette
@@ -4,6 +4,9 @@
// Optional voluntary tip block for Settings. Does not gate features.
import SwiftUI
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
/// iOS Settings card for the consumable support tip.
public struct SupportDeveloperSection: View {
@@ -6,6 +6,9 @@
// from platform singletons.
import SwiftUI
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public struct UsageStatsCluster: View {
@Environment(\.themePalette) private var palette
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
@@ -0,0 +1,25 @@
// AudioBufferSnapshot+AVFoundation.swift
// OSGKeyboard · HostSupport
//
// AVAudioPCMBuffer AudioBufferSnapshot copy helper. Kept out of Shared so
// the keyboard extension does not link AVFoundation for this type alone.
import AVFoundation
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
extension AudioBufferSnapshot {
/// Construct from an `AVAudioPCMBuffer` by copying out the channel data.
public init(buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData else {
self.init(samples: [], sampleRate: buffer.format.sampleRate)
return
}
let n = Int(buffer.frameLength)
var copy = [Float](repeating: 0, count: n)
memcpy(&copy, channelData[0], n * MemoryLayout<Float>.size)
self.init(samples: copy, sampleRate: buffer.format.sampleRate)
}
}
@@ -5,6 +5,9 @@
// `ChunkedUtterancePipeline` independent of iOS-only `SpeechAnalyzer`.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public enum ASRChunkResult: Sendable, Equatable {
case success(String)
@@ -19,6 +19,9 @@ import AVFoundation
import CoreMedia
import Speech
import os
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
// MARK: - Sendable conformance
@@ -5,6 +5,9 @@
// serially on a background queue, stitch partials for display and delivery.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public struct ChunkedUtteranceSuccess: Sendable, Equatable {
public let text: String
@@ -4,6 +4,9 @@
// Syncs PersonalDictionary DashScope custom vocabulary (Fun-ASR Flash).
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public enum AlibabaVocabularySync {
public enum Keys {
@@ -8,6 +8,9 @@
import Foundation
import os
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable {
let apiKey: String
@@ -373,9 +376,7 @@ private final class BailianEventStream: @unchecked Sendable {
}
private func readLoop() async {
var finalSegments: [Int64: String] = [:]
var partialSegments: [Int64: String] = [:]
var lastResultText = ""
var reducer = BailianASREventReducer()
while !Task.isCancelled {
let message: URLSessionWebSocketTask.Message
@@ -397,68 +398,19 @@ private final class BailianEventStream: @unchecked Sendable {
}
guard !text.isEmpty else { continue }
guard let json = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any],
let header = json["header"] as? [String: Any] else {
switch reducer.apply(jsonText: text) {
case .none:
continue
}
let event = header["event"] as? String ?? ""
switch event {
case "task-started":
case .started:
publishStarted()
case "result-generated":
guard let payload = json["payload"] as? [String: Any],
let output = payload["output"] as? [String: Any],
let sentenceObj = output["sentence"] as? [String: Any] else {
continue
}
if sentenceObj["heartbeat"] as? Bool == true { continue }
guard let rawText = sentenceObj["text"] as? String else { continue }
let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
lastResultText = trimmed
let sentenceID = sentenceObj["sentence_id"] as? Int64 ?? 0
let sentenceEndValue = sentenceObj["sentence_end"]
let sentenceEnd = sentenceEndValue as? Bool ?? false
let endTime = sentenceObj["end_time"] as? Int64 ?? 0
let isFinal = sentenceEndValue != nil ? sentenceEnd : endTime > 0
if isFinal {
finalSegments[sentenceID] = trimmed
partialSegments.removeValue(forKey: sentenceID)
} else {
partialSegments[sentenceID] = trimmed
}
var displayParts: [String] = []
let ids = Set(finalSegments.keys).union(partialSegments.keys).sorted()
for id in ids {
if let committed = finalSegments[id] {
displayParts.append(committed)
} else if let live = partialSegments[id] {
displayParts.append(live)
}
}
let display = BailianRealtimeASRClient.mergeSegments(displayParts)
.trimmingCharacters(in: .whitespacesAndNewlines)
if !display.isEmpty {
onPartial?(display)
}
case "task-finished":
if finalSegments.isEmpty {
publishFinal(lastResultText)
} else {
let ordered = finalSegments.keys.sorted().compactMap { finalSegments[$0] }
publishFinal(BailianRealtimeASRClient.mergeSegments(ordered))
}
case .partial(let display):
onPartial?(display)
case .finished(let final):
publishFinal(final)
return
case "task-failed":
let message = header["error_message"] as? String ?? "task failed"
case .failed(let message):
publishFailure(CloudASRError.transport(message))
return
default:
break
}
}
}
@@ -4,6 +4,9 @@
// Provider-specific cloud ASR backends with personal-dictionary bias.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public protocol CloudASRTranscribing: Sendable {
func prepare(dictionary: PersonalDictionary) async throws
@@ -21,8 +24,9 @@ public protocol CloudASRTranscribing: Sendable {
extension CloudASRTranscribing {
/// Default probe: transcribe ~1 s of near-silence. An empty transcript
/// counts as success HTTP/streaming providers only need to prove that
/// transport + auth work. Providers whose service rejects silent/short
/// audio (e.g. DashScope realtime returns `emptyAudio`) override this.
/// transport + auth work. Providers that reject silent/short audio or
/// close after an empty final (DashScope realtime, Volcengine SAUC)
/// override with a handshake-only probe.
public func probeConnection() async throws {
do {
_ = try await transcribe(
@@ -4,14 +4,17 @@
// Settings "validate connection" probe shared by iOS and macOS.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public enum CloudASRConnectionCheck {
/// Verifies the active cloud ASR client can connect + authenticate.
///
/// Each backend decides how to probe (see `CloudASRTranscribing`):
/// HTTP/batch providers transcribe a short silence clip and treat an
/// empty transcript as success; DashScope realtime only handshakes to
/// `task-started` (pushing fake audio makes it fail with `emptyAudio`).
/// empty transcript as success; Bailian / Volcengine / OpenAI Realtime
/// handshake (and auth) only silence clips make streaming backends fail.
public static func validate(store: any ConfigurationStore) async throws {
let client = CloudASRClientFactory.make(store: store)
try await client.probeConnection()
@@ -7,6 +7,9 @@
import Foundation
import os
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public final class CloudASRService: ASRService, @unchecked Sendable {
private let store: any ConfigurationStore
@@ -6,6 +6,9 @@
// fallback for providers without a true streaming protocol.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
/// Long-lived cloud ASR session for one Flow utterance.
public protocol CloudASRStreamingSession: Sendable {
@@ -0,0 +1,188 @@
// CloudASRStreamingEventParsing.swift
// OSGKeyboard · HostSupport
//
// Pure reducers for streaming ASR WebSocket event JSON hermetic fixtures
// without a live socket.
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
// MARK: - Bailian Fun-ASR Realtime
enum BailianASREventEffect: Equatable, Sendable {
case none
case started
case partial(String)
case finished(String)
case failed(String)
}
struct BailianASREventReducer: Sendable {
private(set) var finalSegments: [Int64: String] = [:]
private(set) var partialSegments: [Int64: String] = [:]
private(set) var lastResultText = ""
private(set) var started = false
mutating func apply(jsonText: String) -> BailianASREventEffect {
guard let data = jsonText.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return .none
}
return apply(json: json)
}
mutating func apply(json: [String: Any]) -> BailianASREventEffect {
guard let header = json["header"] as? [String: Any] else { return .none }
let event = header["event"] as? String ?? ""
switch event {
case "task-started":
started = true
return .started
case "result-generated":
return applyResultGenerated(json: json)
case "task-finished":
if finalSegments.isEmpty {
return .finished(lastResultText)
}
let ordered = finalSegments.keys.sorted().compactMap { finalSegments[$0] }
return .finished(BailianRealtimeASRClient.mergeSegments(ordered))
case "task-failed":
let message = header["error_message"] as? String ?? "task failed"
return .failed(message)
default:
return .none
}
}
private mutating func applyResultGenerated(json: [String: Any]) -> BailianASREventEffect {
guard let payload = json["payload"] as? [String: Any],
let output = payload["output"] as? [String: Any],
let sentenceObj = output["sentence"] as? [String: Any] else {
return .none
}
if sentenceObj["heartbeat"] as? Bool == true { return .none }
guard let rawText = sentenceObj["text"] as? String else { return .none }
let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .none }
lastResultText = trimmed
let sentenceID = sentenceObj["sentence_id"] as? Int64 ?? 0
let sentenceEndValue = sentenceObj["sentence_end"]
let sentenceEnd = sentenceEndValue as? Bool ?? false
let endTime = sentenceObj["end_time"] as? Int64 ?? 0
let isFinal = sentenceEndValue != nil ? sentenceEnd : endTime > 0
if isFinal {
finalSegments[sentenceID] = trimmed
partialSegments.removeValue(forKey: sentenceID)
} else {
partialSegments[sentenceID] = trimmed
}
var displayParts: [String] = []
let ids = Set(finalSegments.keys).union(partialSegments.keys).sorted()
for id in ids {
if let committed = finalSegments[id] {
displayParts.append(committed)
} else if let live = partialSegments[id] {
displayParts.append(live)
}
}
let display = BailianRealtimeASRClient.mergeSegments(displayParts)
.trimmingCharacters(in: .whitespacesAndNewlines)
return display.isEmpty ? .none : .partial(display)
}
}
// MARK: - OpenAI Realtime transcription
enum OpenAIRealtimeEventEffect: Equatable, Sendable {
case none
case sessionReady
case partial(String)
case failed(String)
}
struct OpenAIRealtimeTranscriptReducer: Sendable {
private(set) var sessionReady = false
private(set) var partialByItem: [String: String] = [:]
private(set) var completedByItem: [String: String] = [:]
private(set) var itemOrder: [String] = []
mutating func apply(jsonText: String) -> OpenAIRealtimeEventEffect {
guard let data = jsonText.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else {
return .none
}
return apply(type: type, json: json)
}
mutating func apply(type: String, json: [String: Any]) -> OpenAIRealtimeEventEffect {
switch type {
case "session.created", "session.updated":
sessionReady = true
return .sessionReady
case "conversation.item.input_audio_transcription.delta":
let itemID = json["item_id"] as? String ?? "default"
let delta = json["delta"] as? String ?? ""
guard !delta.isEmpty else { return .none }
if partialByItem[itemID] == nil, completedByItem[itemID] == nil {
itemOrder.append(itemID)
}
partialByItem[itemID, default: ""] += delta
let display = composedDisplay()
return display.isEmpty ? .none : .partial(display)
case "conversation.item.input_audio_transcription.completed":
let itemID = json["item_id"] as? String ?? "default"
let transcript = (json["transcript"] as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !itemOrder.contains(itemID) {
itemOrder.append(itemID)
}
if !transcript.isEmpty {
completedByItem[itemID] = transcript
}
partialByItem.removeValue(forKey: itemID)
let display = composedDisplay()
return display.isEmpty ? .none : .partial(display)
case "error":
let message: String
if let error = json["error"] as? [String: Any],
let nested = error["message"] as? String {
message = nested
} else {
message = json["message"] as? String ?? "OpenAI realtime error"
}
return .failed(message)
default:
return .none
}
}
func composedDisplay() -> String {
itemOrder.compactMap { id in
completedByItem[id] ?? partialByItem[id]
}
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
func composedFinal() -> String {
itemOrder.compactMap { completedByItem[$0] }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func languageHint(from locale: Locale) -> String? {
let id = locale.identifier.lowercased()
if id.hasPrefix("zh") { return "zh" }
if id.hasPrefix("en") { return "en" }
if id.hasPrefix("ja") { return "ja" }
if id.hasPrefix("ko") { return "ko" }
return nil
}
}
@@ -7,6 +7,9 @@
import Foundation
import os
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
struct OpenAIRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable {
let apiKey: String
@@ -134,12 +137,9 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
private let lock = OSAllocatedUnfairLock()
private var receiveTask: Task<Void, Never>?
private var failure: Error?
private var sessionReady = false
private var finished = false
private var pcmBuffer = Data()
private var partialByItem: [String: String] = [:]
private var completedByItem: [String: String] = [:]
private var itemOrder: [String] = []
private var reducer = OpenAIRealtimeTranscriptReducer()
private var awaitingCommit = false
init(
@@ -158,7 +158,7 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
receiveTask = Task { [weak self] in
await self?.receiveLoop()
}
let language = Self.languageHint(from: locale)
let language = OpenAIRealtimeTranscriptReducer.languageHint(from: locale)
var transcription: [String: Any] = [
"model": model,
"delay": "low",
@@ -187,7 +187,7 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
let deadline = Date().addingTimeInterval(8)
while Date() < deadline {
try throwIfFailed()
if lock.withLock({ sessionReady }) { return }
if lock.withLock({ reducer.sessionReady }) { return }
try await Task.sleep(nanoseconds: 20_000_000)
}
cancel()
@@ -229,7 +229,9 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
let deadline = Date().addingTimeInterval(OpenAIRealtimeASRClient.finalTimeout)
while Date() < deadline {
try throwIfFailed()
let snapshot = lock.withLock { (awaitingCommit, composedFinal(), composedDisplay()) }
let snapshot = lock.withLock {
(awaitingCommit, reducer.composedFinal(), reducer.composedDisplay())
}
if !snapshot.0 {
let text = snapshot.1.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? snapshot.2
@@ -240,10 +242,10 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
return trimmed
}
let settled = lock.withLock {
!completedByItem.isEmpty && partialByItem.isEmpty && !awaitingCommit
!reducer.completedByItem.isEmpty && reducer.partialByItem.isEmpty && !awaitingCommit
}
if settled {
let text = lock.withLock { composedFinal() }
let text = lock.withLock { reducer.composedFinal() }
.trimmingCharacters(in: .whitespacesAndNewlines)
cancel()
if text.isEmpty { throw CloudASRError.emptyTranscript }
@@ -252,8 +254,8 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
try await Task.sleep(nanoseconds: 20_000_000)
}
let fallback = lock.withLock {
let final = composedFinal()
return final.isEmpty ? composedDisplay() : final
let final = reducer.composedFinal()
return final.isEmpty ? reducer.composedDisplay() : final
}
.trimmingCharacters(in: .whitespacesAndNewlines)
cancel()
@@ -287,67 +289,27 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
@unknown default:
continue
}
guard let json = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any],
let type = json["type"] as? String else {
continue
}
guard !text.isEmpty else { continue }
switch type {
case "session.created", "session.updated":
lock.withLock { sessionReady = true }
case "conversation.item.input_audio_transcription.delta":
let itemID = json["item_id"] as? String ?? "default"
let delta = json["delta"] as? String ?? ""
guard !delta.isEmpty else { continue }
let display = lock.withLock { () -> String in
if partialByItem[itemID] == nil, completedByItem[itemID] == nil {
itemOrder.append(itemID)
}
partialByItem[itemID, default: ""] += delta
return composedDisplay()
}
if !display.isEmpty { onPartial(display) }
case "conversation.item.input_audio_transcription.completed":
let itemID = json["item_id"] as? String ?? "default"
let transcript = (json["transcript"] as? String ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let display = lock.withLock { () -> String in
if !itemOrder.contains(itemID) {
itemOrder.append(itemID)
}
if !transcript.isEmpty {
completedByItem[itemID] = transcript
}
partialByItem.removeValue(forKey: itemID)
let effect = lock.withLock { () -> OpenAIRealtimeEventEffect in
let effect = reducer.apply(jsonText: text)
if case .partial = effect, !reducer.completedByItem.isEmpty {
awaitingCommit = false
return composedDisplay()
}
if !display.isEmpty { onPartial(display) }
case "error":
let message = ((json["error"] as? [String: Any])?["message"] as? String)
?? "OpenAI realtime error"
return effect
}
switch effect {
case .none, .sessionReady:
continue
case .partial(let display):
onPartial(display)
case .failed(let message):
publishFailure(CloudASRError.transport(message))
return
default:
break
}
}
}
private func composedDisplay() -> String {
itemOrder.compactMap { id in
completedByItem[id] ?? partialByItem[id]
}
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func composedFinal() -> String {
itemOrder.compactMap { completedByItem[$0] }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func sendAppend(_ pcm: Data) async throws {
let audio = pcm.base64EncodedString()
try await sendJSON([
@@ -379,13 +341,4 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
lock.withLock { failure = error }
cancel()
}
private static func languageHint(from locale: Locale) -> String? {
let id = locale.identifier.lowercased()
if id.hasPrefix("zh") { return "zh" }
if id.hasPrefix("en") { return "en" }
if id.hasPrefix("ja") { return "ja" }
if id.hasPrefix("ko") { return "ko" }
return nil
}
}
@@ -7,6 +7,9 @@
import Foundation
import os
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable {
let apiKey: String
@@ -72,6 +75,21 @@ struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable
return trimmed
}
/// Settings connection probe: WebSocket upgrade + first-frame auth only.
///
/// Do not push silence through `transcribe` SAUC returns an empty final
/// then closes the socket, and `finish()` prefers that Socket error over
/// `emptyTranscript`, so the default silence probe always failed while
/// real dictation (with speech) still worked.
func probeConnection() async throws {
let live = try await openStreamingSession(
locale: Locale(identifier: "zh-CN"),
dictionary: .empty,
onPartial: { _ in }
)
live.cancel()
}
private var resolvedResourceID: String {
resourceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CloudASRModelCatalog.volcengineDefaultResourceID
@@ -266,15 +284,22 @@ private final class VolcengineStreamingSession: CloudASRStreamingSession, @unche
func finish() async throws -> String {
try throwIfFailed()
let (trailing, endSequence): (Data, Int32) = lock.withLock {
let trailing = pcmBuffer
// Only consume a sequence number when we actually send trailing PCM.
// Skipping an unused seq (common when length is an exact chunk multiple,
// e.g. the settings probe's 1 s / 32_000-byte clip) makes the final
// negative packet mismatch server autoAssignedSequence error 45000000.
let trailing = lock.withLock { () -> Data in
let data = pcmBuffer
pcmBuffer.removeAll(keepingCapacity: false)
let endSequence = sequence
sequence += 1
return (trailing, endSequence)
return data
}
if !trailing.isEmpty {
let endSequence = lock.withLock { () -> Int32 in
let seq = sequence
sequence += 1
return seq
}
try await send(
VolcengineFrame.build(
messageType: .audioOnlyRequest,
@@ -439,26 +464,26 @@ private struct VolcengineCredentials {
}
}
private enum VolcengineMessageType: UInt8 {
enum VolcengineMessageType: UInt8 {
case fullClientRequest = 0b0001
case audioOnlyRequest = 0b0010
case fullServerResponse = 0b1001
case errorMessage = 0b1111
}
private enum VolcengineFlags: UInt8 {
enum VolcengineFlags: UInt8 {
case none = 0b0000
case positiveSequence = 0b0001
case lastPacket = 0b0010
case negativeSequence = 0b0011
}
private enum VolcengineSerialization: UInt8 {
enum VolcengineSerialization: UInt8 {
case none = 0b0000
case json = 0b0001
}
private struct VolcengineFrame {
struct VolcengineFrame {
let messageType: VolcengineMessageType?
let flags: UInt8
let sequence: Int32?
@@ -9,6 +9,9 @@
import Foundation
import Speech
import os
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public final class CustomLanguageModelManager: @unchecked Sendable {
@@ -161,7 +164,13 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
vocabulary: vocabularyURL
)
Self.log("preparing custom LM (\(manifest.bin_bytes) byte asset)…")
Self.log(
"preparing custom LM (\(manifest.bin_bytes) byte asset)… \(OSGDiag.memoryTag())"
)
OSGDiag.log(
"clm.prepare.begin bytes=\(manifest.bin_bytes) \(OSGDiag.memoryTag())",
category: "asr"
)
try await Self.prepareLanguageModel(assetURL: assetURL, configuration: configuration)
guard FileManager.default.fileExists(atPath: languageModelURL.path),
@@ -179,6 +188,10 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
}
Self.log("custom LM ready at \(preparedDir.path)")
OSGDiag.log(
"clm.prepare.done path=\(preparedDir.lastPathComponent) \(OSGDiag.memoryTag())",
category: "asr"
)
return configuration
}
@@ -237,29 +250,44 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
// MARK: - Bundle / disk helpers
private static var resourceBundle: Bundle {
Bundle(for: CustomLanguageModelManager.self)
// CLM assets ship in the host app bundle (not the extension Shared
// framework) so the keyboard process never mmaps the training bin.
Bundle.main
}
static func bundledTrainingAssetURL() -> URL? {
if let url = resourceBundle.url(
forResource: "OSGKeyboardCLM",
withExtension: "bin",
subdirectory: Storage.subdirectory
) {
return url
}
return resourceBundle.url(forResource: "OSGKeyboardCLM", withExtension: "bin")
// Prefer flat Bundle.main (iOS HostCLM copy), then legacy subdirs.
let candidates: [URL?] = [
resourceBundle.url(forResource: "OSGKeyboardCLM", withExtension: "bin"),
resourceBundle.url(
forResource: "OSGKeyboardCLM",
withExtension: "bin",
subdirectory: "HostCLM/v1"
),
resourceBundle.url(
forResource: "OSGKeyboardCLM",
withExtension: "bin",
subdirectory: Storage.subdirectory
),
]
return candidates.compactMap { $0 }.first
}
static func bundledManifest() -> BundledManifest? {
let manifestURL =
let candidates: [URL?] = [
resourceBundle.url(forResource: "compiled-manifest", withExtension: "json"),
resourceBundle.url(
forResource: "compiled-manifest",
withExtension: "json",
subdirectory: "HostCLM/v1"
),
resourceBundle.url(
forResource: "compiled-manifest",
withExtension: "json",
subdirectory: Storage.subdirectory
)
?? resourceBundle.url(forResource: "compiled-manifest", withExtension: "json")
guard let manifestURL,
),
]
guard let manifestURL = candidates.compactMap({ $0 }).first,
let data = try? Data(contentsOf: manifestURL),
let manifest = try? JSONDecoder().decode(BundledManifest.self, from: data)
else {
@@ -0,0 +1,245 @@
// FlowAudioSessionCoordinator.swift
// OSGKeyboard · Host Support
//
// Single owner for the shared iOS AVAudioSession used by Flow capture and
// Picture in Picture keep-alive. Keeping category transitions here prevents
// two independent state machines from racing the same process-wide session.
import AVFoundation
import Foundation
import OSGKeyboardShared
public enum FlowAudioRouteRecoveryPolicy {
public static func shouldRebuild(
reasonRaw: UInt,
formatIsStable: Bool,
engineIsRunning: Bool
) -> Bool {
switch AVAudioSession.RouteChangeReason(rawValue: reasonRaw) {
case .newDeviceAvailable, .oldDeviceUnavailable:
return true
case .categoryChange, .routeConfigurationChange:
return !formatIsStable || !engineIsRunning
default:
return false
}
}
}
public struct FlowAudioSessionSnapshot: Sendable, Equatable {
public let sampleRate: Double
public let inputChannels: Int
public let inputPortType: String
public let inputPortUID: String
}
public final class FlowAudioEngineHandle: @unchecked Sendable {
public let engine: AVAudioEngine
public init(_ engine: AVAudioEngine) {
self.engine = engine
}
}
public final class FlowAudioSessionCoordinator: @unchecked Sendable {
private struct CaptureActivation: Sendable {
let snapshot: FlowAudioSessionSnapshot
let preferredInputUID: String?
}
public enum Mode: Sendable, Equatable {
case inactive
case playback
case capture
}
public static let shared = FlowAudioSessionCoordinator()
private let session = AVAudioSession.sharedInstance()
private let queue = DispatchQueue(
label: "com.osgkeyboard.flow.audio-session",
qos: .userInitiated
)
private var mode: Mode = .inactive
private var active = false
private init() {}
public func activateCapture() async throws -> FlowAudioSessionSnapshot {
let activation: CaptureActivation = try await perform {
if self.mode != .capture {
try self.session.setCategory(
.playAndRecord,
mode: .measurement,
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
)
}
if !self.active {
try self.session.setActive(true, options: .notifyOthersOnDeactivation)
self.active = true
}
// When an HFP device is already connected, iOS can initially
// report the built-in mic and switch to HFP after recording has
// begun. Select the route before building AVAudioEngine so short
// utterances do not land entirely inside that negotiation window.
let hfp = self.session.availableInputs?.first(where: {
$0.portType == .bluetoothHFP
})
if self.session.currentRoute.inputs.first?.portType != .bluetoothHFP,
let hfp {
do {
try self.session.setPreferredInput(hfp)
} catch {
OSGDiag.log(
"preferred HFP input failed: \(error.localizedDescription)",
category: "flow"
)
}
}
self.mode = .capture
return CaptureActivation(
snapshot: self.makeSnapshot(),
preferredInputUID: hfp?.uid
)
}
return await waitForStableRoute(
initial: activation.snapshot,
preferredInputUID: activation.preferredInputUID
)
}
public func activatePlayback() async -> Bool {
do {
_ = try await perform {
// Once capture has established a stable playAndRecord route,
// keep that active session between utterances. The engine and
// tap are stopped separately, so the microphone is released,
// while avoiding a category flip that eventually yields !pri
// after repeated PiP/capture cycles.
if self.active, self.mode == .capture {
return self.makeSnapshot()
}
if self.mode != .playback {
try self.session.setCategory(
.playback,
mode: .moviePlayback,
options: [.mixWithOthers]
)
}
if !self.active {
try self.session.setActive(true)
self.active = true
}
self.mode = .playback
return self.makeSnapshot()
}
return true
} catch {
OSGDiag.log(
"PiP audio session failed: \(error.localizedDescription)",
category: "flow"
)
return false
}
}
public func deactivate() {
queue.async { [self] in
guard active else { return }
do {
try session.setActive(false, options: .notifyOthersOnDeactivation)
active = false
mode = .inactive
} catch {
OSGDiag.log(
"audio session deactivate failed: \(error.localizedDescription)",
category: "flow"
)
}
}
}
public func startEngine(_ handle: FlowAudioEngineHandle) async throws {
try await perform {
handle.engine.prepare()
try handle.engine.start()
return true
}
}
public func stopEngine(_ handle: FlowAudioEngineHandle) async {
_ = try? await perform {
if handle.engine.isRunning {
handle.engine.stop()
}
handle.engine.reset()
return true
}
}
public func enqueueStopEngine(_ handle: FlowAudioEngineHandle) {
queue.async {
if handle.engine.isRunning {
handle.engine.stop()
}
handle.engine.reset()
}
}
private func waitForStableRoute(
initial: FlowAudioSessionSnapshot,
preferredInputUID: String?,
timeout: TimeInterval = 1.5
) async -> FlowAudioSessionSnapshot {
let deadline = Date().addingTimeInterval(timeout)
var previous = initial
var stableReadCount = 0
while Date() < deadline {
try? await Task.sleep(nanoseconds: 50_000_000)
let current = await snapshot()
let preferredRouteReached = preferredInputUID == nil
|| current.inputPortUID == preferredInputUID
if current == previous, current.sampleRate > 0,
current.inputChannels > 0, preferredRouteReached {
stableReadCount += 1
} else {
stableReadCount = 0
}
if stableReadCount >= 4 {
return current
}
previous = current
}
return previous
}
private func snapshot() async -> FlowAudioSessionSnapshot {
(try? await perform { self.makeSnapshot() })
?? FlowAudioSessionSnapshot(
sampleRate: 0,
inputChannels: 0,
inputPortType: "none",
inputPortUID: ""
)
}
private func makeSnapshot() -> FlowAudioSessionSnapshot {
let input = session.currentRoute.inputs.first
return FlowAudioSessionSnapshot(
sampleRate: session.sampleRate,
inputChannels: session.inputNumberOfChannels,
inputPortType: input?.portType.rawValue ?? "none",
inputPortUID: input?.uid ?? ""
)
}
private func perform<T: Sendable>(
_ operation: @escaping @Sendable () throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
continuation.resume(with: Result { try operation() })
}
}
}
}
@@ -10,6 +10,9 @@
import Foundation
import AVFoundation
import os
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
private enum FlowCaptureConstants {
static let levelBarCount = 24
@@ -448,7 +451,9 @@ public final class FlowContinuousCapture {
public static let levelBarCount = FlowCaptureConstants.levelBarCount
private let audioEngine = AVAudioEngine()
/// Recreated after every hardware format transition. Reusing the same
/// engine preserves a stale input node on some Bluetooth HFP changes.
private var audioEngine = AVAudioEngine()
private let streamRelay = FlowCaptureStreamRelay()
private let prerollStore = FlowPrerollStore()
private let levelStore = FlowLevelStore(barCount: FlowCaptureConstants.levelBarCount)
@@ -464,11 +469,13 @@ public final class FlowContinuousCapture {
private var downsampler: AdaptiveDownsampler?
private var targetFormat: AVAudioFormat?
private var hwFormat: AVAudioFormat?
private var activeRouteSnapshot: FlowAudioSessionSnapshot?
private var drainPolicy = FlowCaptureTailDrainPolicy.flowDefault
private var didInstallTap = false
private var isRunning = false
private var isRebuilding = false
private var isActivatingEngine = false
private var interrupted = false
/// When the engine last (re)activated a freshly started engine has
/// produced no frames yet and must not be misclassified as a zombie.
@@ -477,11 +484,15 @@ public final class FlowContinuousCapture {
private var routeObserver: NSObjectProtocol?
private var interruptionObserver: NSObjectProtocol?
private var mediaResetObserver: NSObjectProtocol?
private var engineConfigurationObserver: NSObjectProtocol?
private var routeRecoveryTask: Task<Void, Never>?
private let log = Logger(subsystem: "com.osgkeyboard.shared", category: "FlowCapture")
public init() {}
public var running: Bool { isRunning }
public private(set) var engineActivationCount = 0
public private(set) var routeRecoveryCount = 0
/// True between interruption `.began` and `.ended` (phone call, Siri).
/// While set, `setActive(true)` is guaranteed to fail owners should
@@ -526,7 +537,7 @@ public final class FlowContinuousCapture {
/// torn down and rebuilt in place. It must never be a silent no-op
/// a `guard !isRunning` early-return here turned every cold-start retry
/// into a guaranteed audio-proof timeout.
public func start() throws {
public func start() async throws {
if isRunning {
let startedMomentsAgo = Date().timeIntervalSince(lastActivationAt) < 2
if engineIsLive && (engineHasRecentAudio(maxAge: 2) || startedMomentsAgo) {
@@ -550,14 +561,15 @@ public final class FlowContinuousCapture {
}
audioProofStore.reset()
FlowTrace.capture("start.begin", "coldEngine=1")
installSessionObservers()
do {
try activateEngine()
try await activateEngine()
} catch {
removeSessionObservers()
FlowTrace.warn("capture.start.failed", "error=\(error.localizedDescription)")
throw error
}
isRunning = true
installSessionObservers()
notifyEngineLiveChanged()
FlowTrace.capture("start.done", "engineLive=\(engineIsLive ? 1 : 0)")
}
@@ -566,15 +578,12 @@ public final class FlowContinuousCapture {
/// Reused for route-change / interruption recovery, so it always rebuilds
/// the tap against the live hardware format (which changes when the user
/// plugs in AirPods or a wired headset mid-session).
private func activateEngine() throws {
let session = AVAudioSession.sharedInstance()
private func activateEngine() async throws {
isActivatingEngine = true
defer { isActivatingEngine = false }
let sessionSnapshot: FlowAudioSessionSnapshot
do {
try session.setCategory(
.playAndRecord,
mode: .measurement,
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
sessionSnapshot = try await FlowAudioSessionCoordinator.shared.activateCapture()
} catch {
FlowTrace.warn(
"capture.audioSession.activateFailed",
@@ -583,13 +592,35 @@ public final class FlowContinuousCapture {
throw StartError.audioSessionFailed(error.localizedDescription)
}
let inputNode = audioEngine.inputNode
let hardwareFormat = inputNode.outputFormat(forBus: 0)
// AVAudioSession activation may replace the RemoteIO format. Build a
// fresh engine only after activation so its input node cannot retain
// the prior built-in-mic (48 kHz) format when HFP is now 24 kHz.
// Retire the previous graph before publishing the fresh engine.
if didInstallTap {
audioEngine.inputNode.removeTap(onBus: 0)
didInstallTap = false
}
await FlowAudioSessionCoordinator.shared.stopEngine(
FlowAudioEngineHandle(audioEngine)
)
var candidateEngine = AVAudioEngine()
var inputNode = candidateEngine.inputNode
var hardwareFormat = inputNode.inputFormat(forBus: 0)
var outputFormat = inputNode.outputFormat(forBus: 0)
for _ in 0..<3 where abs(hardwareFormat.sampleRate - sessionSnapshot.sampleRate) >= 1 {
try? await Task.sleep(nanoseconds: 50_000_000)
candidateEngine = AVAudioEngine()
inputNode = candidateEngine.inputNode
hardwareFormat = inputNode.inputFormat(forBus: 0)
outputFormat = inputNode.outputFormat(forBus: 0)
}
FlowTrace.capture(
"audioSession.active",
"hwRate=\(Int(hardwareFormat.sampleRate)) hwChannels=\(hardwareFormat.channelCount) "
+ "sessionRate=\(Int(session.sampleRate)) "
+ "route=\(session.currentRoute.inputs.first?.portType.rawValue ?? "none")"
+ "outputRate=\(Int(outputFormat.sampleRate)) "
+ "sessionRate=\(Int(sessionSnapshot.sampleRate)) "
+ "route=\(sessionSnapshot.inputPortType)"
)
guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else {
FlowTrace.warn(
@@ -601,6 +632,13 @@ public final class FlowContinuousCapture {
channels: Int(hardwareFormat.channelCount)
)
}
guard sessionSnapshot.sampleRate <= 0
|| abs(hardwareFormat.sampleRate - sessionSnapshot.sampleRate) < 1 else {
throw StartError.invalidHardwareFormat(
sampleRate: hardwareFormat.sampleRate,
channels: Int(hardwareFormat.channelCount)
)
}
guard let resolvedTargetFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
@@ -618,12 +656,6 @@ public final class FlowContinuousCapture {
targetFormat = resolvedTargetFormat
hwFormat = hardwareFormat
// Rebuild the tap so its bound hardware format matches the new route.
if didInstallTap {
inputNode.removeTap(onBus: 0)
didInstallTap = false
}
let gateLock = gate
let relay = streamRelay
let preroll = prerollStore
@@ -657,19 +689,28 @@ public final class FlowContinuousCapture {
+ "bufferSize=4096 format=live"
)
audioEngine.prepare()
do {
try audioEngine.start()
try await FlowAudioSessionCoordinator.shared.startEngine(
FlowAudioEngineHandle(candidateEngine)
)
} catch {
inputNode.removeTap(onBus: 0)
didInstallTap = false
await FlowAudioSessionCoordinator.shared.stopEngine(
FlowAudioEngineHandle(candidateEngine)
)
FlowTrace.warn("capture.engine.startFailed", "error=\(error.localizedDescription)")
throw StartError.engineStartFailed(error.localizedDescription)
}
audioEngine = candidateEngine
activeRouteSnapshot = sessionSnapshot
engineActivationCount += 1
lastActivationAt = Date()
FlowTrace.capture("engine.started", "running=\(audioEngine.isRunning ? 1 : 0)")
}
/// Tear down the engine and release the audio session.
public func stop() {
public func stop(releaseSession: Bool = true) {
// Logged before teardown: in PiP keep-alive every utterance ends with a
// stop(), which also discards the converter so this line marks the
// point after which the next press must rebuild the whole audio path.
@@ -679,6 +720,8 @@ public final class FlowContinuousCapture {
+ frameStats.snapshot().summary
)
removeSessionObservers()
routeRecoveryTask?.cancel()
routeRecoveryTask = nil
gate.withLock { $0 = .idle }
drainTracker.reset()
tailSampleCounter.withLock { $0 = 0 }
@@ -688,19 +731,19 @@ public final class FlowContinuousCapture {
audioEngine.inputNode.removeTap(onBus: 0)
didInstallTap = false
}
if audioEngine.isRunning {
audioEngine.stop()
}
FlowAudioSessionCoordinator.shared.enqueueStopEngine(
FlowAudioEngineHandle(audioEngine)
)
isRunning = false
interrupted = false
audioProofStore.reset()
downsampler = nil
targetFormat = nil
hwFormat = nil
try? AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
activeRouteSnapshot = nil
if releaseSession {
FlowAudioSessionCoordinator.shared.deactivate()
}
notifyEngineLiveChanged()
}
@@ -714,22 +757,24 @@ public final class FlowContinuousCapture {
/// suspended during the call), so this is the only reliable way to clear
/// the `interrupted` latch in that case.
@discardableResult
public func reassertIfRunning() -> Bool {
public func reassertIfRunning() async -> Bool {
guard isRunning else { return false }
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(
.playAndRecord,
mode: .measurement,
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
)
try session.setActive(true, options: .notifyOthersOnDeactivation)
interrupted = false
if !audioEngine.isRunning {
try audioEngine.start()
if routeFormatIsStable(), audioEngine.isRunning {
do {
_ = try await FlowAudioSessionCoordinator.shared.activateCapture()
interrupted = false
notifyEngineLiveChanged()
FlowTrace.capture("reassert.ok", "engineLive=\(engineIsLive ? 1 : 0)")
return engineIsLive
} catch {
FlowTrace.warn("capture.reassert.failed", "error=\(error.localizedDescription)")
}
}
do {
try await activateEngine()
interrupted = false
notifyEngineLiveChanged()
FlowTrace.capture("reassert.ok", "engineLive=\(engineIsLive ? 1 : 0)")
FlowTrace.capture("reassert.rebuilt", "engineLive=\(engineIsLive ? 1 : 0)")
return engineIsLive
} catch {
FlowTrace.warn("capture.reassert.failed", "error=\(error.localizedDescription)")
@@ -799,6 +844,32 @@ public final class FlowContinuousCapture {
MainActor.assumeIsolated { self?.handleMediaServicesReset() }
}
}
if engineConfigurationObserver == nil {
engineConfigurationObserver = center.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
guard !self.isActivatingEngine else {
FlowTrace.capture(
"engineConfigurationChange.ignored",
"reason=activationInProgress"
)
return
}
if !self.routeFormatIsStable() || !self.audioEngine.isRunning {
self.scheduleRouteRecovery(reason: "engineConfigurationChange")
} else {
FlowTrace.capture(
"engineConfigurationChange.ignored",
"reason=stable"
)
}
}
}
}
}
private func removeSessionObservers() {
@@ -806,33 +877,77 @@ public final class FlowContinuousCapture {
if let routeObserver { center.removeObserver(routeObserver) }
if let interruptionObserver { center.removeObserver(interruptionObserver) }
if let mediaResetObserver { center.removeObserver(mediaResetObserver) }
if let engineConfigurationObserver { center.removeObserver(engineConfigurationObserver) }
routeObserver = nil
interruptionObserver = nil
mediaResetObserver = nil
engineConfigurationObserver = nil
}
private func handleMediaServicesReset() {
guard isRunning else { return }
log.info("Media services were reset — rebuilding engine and converter")
FlowTrace.warn("capture.mediaServicesReset", frameStats.snapshot().summary)
rebuildEngine()
Task { @MainActor [weak self] in
await self?.rebuildEngine()
}
}
private func handleRouteChange(reasonRaw: UInt?) {
guard isRunning else { return }
guard !isActivatingEngine else {
FlowTrace.capture(
"routeChange.ignored",
"reason=\(reasonRaw ?? 0) activationInProgress=1"
)
return
}
guard let reasonRaw,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return }
switch reason {
case .oldDeviceUnavailable, .newDeviceAvailable:
let formatIsStable = routeFormatIsStable()
if FlowAudioRouteRecoveryPolicy.shouldRebuild(
reasonRaw: reasonRaw,
formatIsStable: formatIsStable,
engineIsRunning: audioEngine.isRunning
) {
log.info("Audio route changed (\(reasonRaw, privacy: .public)) — rebuilding engine")
FlowTrace.capture(
"routeChange.rebuild",
"reason=\(reasonRaw) gate=\(gate.withLock { $0 }.label) "
+ frameStats.snapshot().summary
)
rebuildEngine()
default:
FlowTrace.capture("routeChange.ignored", "reason=\(reasonRaw)")
scheduleRouteRecovery(reason: "route=\(reasonRaw)")
} else {
FlowTrace.capture(
"routeChange.ignored",
"reason=\(reason.rawValue) stable=\(formatIsStable ? 1 : 0)"
)
}
}
private func routeFormatIsStable() -> Bool {
let session = AVAudioSession.sharedInstance()
let sessionRate = session.sampleRate
let inputRate = audioEngine.inputNode.inputFormat(forBus: 0).sampleRate
guard sessionRate > 0, inputRate > 0 else { return false }
guard abs(sessionRate - inputRate) < 1 else { return false }
guard let activeRouteSnapshot else { return true }
let currentInput = session.currentRoute.inputs.first
return activeRouteSnapshot.inputPortUID == (currentInput?.uid ?? "")
&& activeRouteSnapshot.inputPortType == (currentInput?.portType.rawValue ?? "none")
}
private func scheduleRouteRecovery(reason: String) {
guard isRunning else { return }
routeRecoveryTask?.cancel()
routeRecoveryTask = Task { @MainActor [weak self] in
// Route notifications arrive in bursts while HFP negotiates its
// final sample rate. Debounce before creating the new graph.
try? await Task.sleep(nanoseconds: 150_000_000)
guard let self, !Task.isCancelled, self.isRunning else { return }
self.routeRecoveryCount += 1
FlowTrace.capture("routeRecovery.begin", "reason=\(reason)")
await self.rebuildEngine()
}
}
@@ -861,7 +976,9 @@ public final class FlowContinuousCapture {
FlowTrace.capture("interruption.ended", "shouldResume=\(shouldResume ? 1 : 0)")
if shouldResume {
log.info("Audio interruption ended — resuming capture")
rebuildEngine()
Task { @MainActor [weak self] in
await self?.rebuildEngine()
}
}
@unknown default:
break
@@ -870,7 +987,7 @@ public final class FlowContinuousCapture {
/// Stop and rebuild the engine against the current route, keeping
/// `isRunning` intact so the session survives the swap transparently.
private func rebuildEngine() {
private func rebuildEngine() async {
guard isRunning, !isRebuilding else {
FlowTrace.capture(
"rebuild.skipped",
@@ -880,14 +997,20 @@ public final class FlowContinuousCapture {
}
isRebuilding = true
defer { isRebuilding = false }
if audioEngine.isRunning {
audioEngine.stop()
}
do {
try activateEngine()
try await activateEngine()
notifyEngineLiveChanged()
FlowTrace.capture("rebuild.done", "engineLive=\(engineIsLive ? 1 : 0)")
} catch {
isRunning = false
activeRouteSnapshot = nil
if didInstallTap {
audioEngine.inputNode.removeTap(onBus: 0)
didInstallTap = false
}
FlowAudioSessionCoordinator.shared.enqueueStopEngine(
FlowAudioEngineHandle(audioEngine)
)
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
FlowTrace.warn("capture.rebuild.failed", "error=\(error.localizedDescription)")
notifyEngineLiveChanged()
@@ -40,6 +40,9 @@ import Foundation
import AVFoundation
import Speech
import os
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
private enum LiveCaptureGatePhase: Equatable {
case idle
@@ -96,7 +99,7 @@ public final class LiveDictationController: ObservableObject {
@Published public var lastFinal: String = ""
private let asr: ASRService
private let audioEngine = AVAudioEngine()
private var audioEngine = AVAudioEngine()
/// `internal` (not `private`) so the regression test in
/// `OSGKeyboardTests/PreviewASRControllerStateTests.swift` can
/// install a known consumer task and assert `stop()` doesn't
@@ -208,6 +211,9 @@ public final class LiveDictationController: ObservableObject {
return
}
}
// The route may have changed while the preview was closed. A fresh
// engine created after session activation avoids a stale input node.
audioEngine = AVAudioEngine()
// 4. Spin up the engine + ASR.
phase = .recording
@@ -278,7 +284,7 @@ public final class LiveDictationController: ObservableObject {
private func startEngineAndASR(locale: Locale) {
let inputNode = audioEngine.inputNode
let hwFormat = inputNode.outputFormat(forBus: 0)
let hwFormat = inputNode.inputFormat(forBus: 0)
// Pre-flight check: a placeholder / unconfigured input bus
// reports `sampleRate == 0` (or `channelCount == 0`).
@@ -7,6 +7,9 @@
import Foundation
import Combine
import StoreKit
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public enum TipPurchaseState: Equatable {
case idle
@@ -244,7 +244,6 @@ enum MacDictationPipeline {
!supplement.isEmpty {
polishContext = PolishContext(
appContext: store.detectedAppContext?.context ?? .unknown,
intensity: store.polishIntensity,
dictionarySupplement: supplement
)
} else {
+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: {
+9
View File
@@ -345,6 +345,15 @@ struct MacSettingsView: View {
.buttonStyle(.plain)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
// Read-only version row at the bottom of About.
MacInlineRow(title: MacL10n.string("mac.settings.version", language: lang)) {
Text(AppVersionDisplay.detailedLabel)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.trailing)
}
.accessibilityElement(children: .combine)
}
}
}
+8 -10
View File
@@ -2,8 +2,7 @@
// OSGKeyboard · Shared
//
// Coarse classification of "where is the user typing right now?".
// We use it to pick a tone / style guideline for the LLM polish
// step (e.g. code stays technical, chat stays casual).
// We use it to provide concise environment hints to polish and translation.
//
// The detection is best-effort and runs entirely in the keyboard
// extension iOS sandboxing blocks us from reading the foreground
@@ -36,21 +35,20 @@ public enum AppContext: String, Codable, Sendable, CaseIterable {
}
}
/// Tone / style constraint appended to the LLM prompt. Kept
/// intentionally short the LLM does better with 1-2 sharp
/// instructions than a wall of rules.
public var polishGuideline: String {
/// Translation uses an English context hint; polish has an equivalent
/// localized hint in PolishPromptComposer.
public var translationGuideline: String {
switch self {
case .code:
return "Code context: preserve English identifiers, variable names, file paths, and indentation-relevant whitespace exactly. Do not natural-language them. Keep code snippets unformatted; do not wrap in code fences."
case .email:
return "Email context: you may add a polite greeting or sign-off if the user clearly forgot one. Reasonable paragraph breaks. Keep tone professional but not stiff."
return "Email context: preserve existing paragraph breaks and lists; if the transcript is a flat blob with multiple points, reconstruct clear paragraphs. Keep the tone professional but not stiff. Preserve greetings and sign-offs only when present; never invent them."
case .chat:
return "Chat context: keep it short, conversational, and natural. Drop formalities. Preserve the speaker's casual voice. Do not add emojis."
return "Chat context: keep it short, conversational, and natural. Drop formalities. Preserve the speaker's casual voice. Still honor the structure contract for multi-point messages; do not add decorative paragraphs to a single short line. Do not add emojis."
case .document:
return "Document context: add structure — split into paragraphs, use lists when the user enumerates. Keep tone written-formal. Do not invent headings the user did not say."
return "Document context: preserve existing paragraphs and lists; if the transcript lacks breaks, split into paragraphs and use lists when the user enumerates. Keep tone written-formal. Do not invent headings the user did not say."
case .unknown:
return "Unknown context: pick a neutral, friendly tone. Err on the side of minimal changes."
return "Unknown context: pick a neutral, friendly tone. Prefer minimal wording changes, but still preserve or reconstruct paragraphs and lists per the structure contract."
}
}
}
@@ -35,6 +35,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let translationTargetLocaleId = "config.translationTargetLocaleId"
public static let handednessPreference = "config.handednessPreference"
public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
public static let keyboardHapticIntensity = "config.keyboardHapticIntensity"
public static let polishIntensity = "config.polishIntensity"
public static let llmThinkingEnabled = "config.llmThinkingEnabled"
public static let detectedAppContext = "config.detectedAppContext"
@@ -86,6 +87,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var translationTargetLocaleId: String
public var handednessPreference: HandednessPreference
public var cursorDragNavigationEnabled: Bool
/// Typing-grid haptic strength (off / light / strong).
public var keyboardHapticIntensity: KeyboardHapticIntensity
/// Safety envelope for built-in fun polish styles (light by default).
public var polishIntensity: PolishIntensity
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
public var llmThinkingEnabled: Bool
@@ -253,7 +257,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
}(),
polishIntensity: resolvePolishIntensity(from: defaults),
keyboardHapticIntensity: KeyboardHapticIntensity.fromStored(
defaults.string(forKey: Keys.keyboardHapticIntensity)
),
polishIntensity: PolishIntensity.resolve(
storedRawValue: defaults.string(forKey: Keys.polishIntensity)
),
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
personalDictionary: decodePersonalDictionary(from: defaults),
polishStyleCatalog: decodePolishStyleCatalog(from: defaults),
@@ -386,6 +395,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(translationTargetLocaleId, forKey: Keys.translationTargetLocaleId)
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
defaults.set(keyboardHapticIntensity.rawValue, forKey: Keys.keyboardHapticIntensity)
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
defaults.set(activePolishStyleId, forKey: Keys.activePolishStyleId)
@@ -401,17 +411,6 @@ public struct AppGroupConfiguration: Sendable, Equatable {
// MARK: - Private helpers
private static func resolvePolishIntensity(from defaults: UserDefaults) -> PolishIntensity {
guard let raw = defaults.string(forKey: Keys.polishIntensity) else {
return .default
}
let resolved = PolishIntensity.resolve(storedRawValue: raw)
if raw == PolishIntensity.legacyOffRawValue {
defaults.set(resolved.rawValue, forKey: Keys.polishIntensity)
}
return resolved
}
private static func decodePersonalDictionary(from defaults: UserDefaults) -> PersonalDictionary {
guard let data = defaults.data(forKey: Keys.personalDictionary) else {
return .empty
@@ -2,12 +2,10 @@
// OSGKeyboard · Shared
//
// Sendable wrapper around a Float32 audio buffer's raw samples.
// The snapshot is the only thing that crosses actor / concurrency
// boundaries; the recognizer re-creates an `AVAudioPCMBuffer` on its
// own side and consumes it locally (never yielding it back out).
// Lives in Shared (no AVFoundation) so utterance chunking stays extension-safe.
// The AVAudioPCMBuffer initializer lives in OSGKeyboardHostSupport.
import Foundation
import AVFoundation
public struct AudioBufferSnapshot: Sendable {
public let samples: [Float]
@@ -17,18 +15,4 @@ public struct AudioBufferSnapshot: Sendable {
self.samples = samples
self.sampleRate = sampleRate
}
/// Construct from an `AVAudioPCMBuffer` by copying out the channel data.
public init(buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData else {
self.samples = []
self.sampleRate = buffer.format.sampleRate
return
}
let n = Int(buffer.frameLength)
var copy = [Float](repeating: 0, count: n)
memcpy(&copy, channelData[0], n * MemoryLayout<Float>.size)
self.samples = copy
self.sampleRate = buffer.format.sampleRate
}
}
@@ -0,0 +1,129 @@
// BuiltinPolishStyleLoader.swift
// OSGKeyboard · Shared
//
// Loads built-in polish style packs from bundled JSON
// (`Resources/PolishStyles/manifest.json` + one file per style).
// Older fun-style JSON files may still contain the retired shared-foundation
// placeholder. It is stripped while loading because the composer now owns the
// single minimal formatting layer.
import Foundation
enum BuiltinPolishStyleLoader {
static let foundationPlaceholder = "{{FUN_SINGLE_PASS_FOUNDATION}}"
private static let catalogDirectory = "PolishStyles"
private static let manifestName = "manifest"
private struct Manifest: Decodable {
let version: Int
let styles: [String]
}
private struct FilePayload: Decodable {
let id: String
let name: String
let prompt: String
}
/// Ordered built-in packs. Empty only if the bundle is misconfigured.
static func load(
bundles: [Bundle] = candidateBundles()
) -> [PolishStylePack] {
guard let manifestURL = locate(resource: manifestName, extension: "json", in: bundles) else {
OSGLog.config.error("builtin polish styles: manifest.json missing from bundle")
return []
}
return load(manifestURL: manifestURL, styleURL: { id in
locate(resource: id, extension: "json", in: bundles)
})
}
/// Loads from an on-disk catalog directory (manifest + `{id}.json`). Used by tests.
static func load(fromDirectory directory: URL) -> [PolishStylePack] {
let manifestURL = directory.appendingPathComponent("\(manifestName).json")
return load(manifestURL: manifestURL, styleURL: { id in
let url = directory.appendingPathComponent("\(id).json")
return FileManager.default.fileExists(atPath: url.path) ? url : nil
})
}
private static func load(
manifestURL: URL,
styleURL: (String) -> URL?
) -> [PolishStylePack] {
do {
let manifest = try JSONDecoder().decode(Manifest.self, from: Data(contentsOf: manifestURL))
var packs: [PolishStylePack] = []
packs.reserveCapacity(manifest.styles.count)
for id in manifest.styles {
guard let url = styleURL(id) else {
OSGLog.config.error("builtin polish styles: missing \(id, privacy: .public).json")
continue
}
let payload = try JSONDecoder().decode(FilePayload.self, from: Data(contentsOf: url))
guard payload.id == id else {
OSGLog.config.error(
"builtin polish styles: id mismatch file=\(id, privacy: .public) payload=\(payload.id, privacy: .public)"
)
continue
}
let prompt = payload.prompt
.replacingOccurrences(of: foundationPlaceholder, with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !payload.name.isEmpty, !prompt.isEmpty else { continue }
packs.append(
PolishStylePack(
id: payload.id,
name: payload.name,
prompt: prompt,
kind: .builtin,
createdAt: .distantPast,
updatedAt: .distantPast
)
)
}
return packs
} catch {
OSGLog.config.error(
"builtin polish styles: load failed \(error.localizedDescription, privacy: .public)"
)
return []
}
}
static func candidateBundles() -> [Bundle] {
// Shared framework bundle first; Mac embeds Shared sources into the app, so
// fall back to main / class bundle the same way other Shared resources do.
var bundles: [Bundle] = [
Bundle(for: BundleToken.self),
Bundle.main,
]
#if !os(macOS)
if let shared = Bundle(identifier: "com.osgkeyboard.ios.shared") {
bundles.insert(shared, at: 0)
}
#endif
var seen = Set<ObjectIdentifier>()
return bundles.filter { seen.insert(ObjectIdentifier($0)).inserted }
}
private static func locate(resource: String, extension ext: String, in bundles: [Bundle]) -> URL? {
for bundle in bundles {
if let url = bundle.url(
forResource: resource,
withExtension: ext,
subdirectory: catalogDirectory
) {
return url
}
if let url = bundle.url(forResource: resource, withExtension: ext) {
return url
}
}
return nil
}
}
/// Anchor type so `Bundle(for:)` resolves to the Shared (or host) binary that
/// owns the compiled-in PolishStyles resources.
private final class BundleToken {}
@@ -29,10 +29,15 @@ public enum FlowHandoffPolicy {
/// Opening the host must be driven by an explicit mic press (or a Live
/// Activity tap when that keep-alive mode is selected). PiP sessions
/// never auto-jump once `hostReady` is published.
///
/// Ready-wait polls may observe a dead host, but must still gate
/// `startflow` on mic intent (`recordWhenHostReady`) otherwise an idle
/// keyboard open relaunches ASR and jetsams the extension.
public static let allowsProactiveHostAutoLaunch = false
/// Samples of "host truly dead" required before a cold-start jump is allowed
/// from a non-press path. Mic press uses `shouldOpenHostColdStart` directly.
/// from a mic-driven ready-wait recovery. Idle opens must never cold-start.
/// Mic press uses `shouldOpenHostColdStart` directly.
public static let coldStartDeadSampleThreshold = 2
/// True when the session contract still implies a living (or recoverable)
@@ -82,8 +87,9 @@ public enum FlowHandoffPolicy {
return .ignore
case .unavailable(.missingAPIKey),
.unavailable(.noFullAccess),
.unavailable(.appGroupUnavailable):
// Caller surfaces the specific error UI.
.unavailable(.appGroupUnavailable),
.unavailable(.onboardingIncomplete):
// Caller surfaces the specific error UI / host jump.
return .ignore
case .unavailable(.preparingSession):
// Session is warming never cold-start; wait then record.
@@ -0,0 +1,28 @@
// 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
/// Shared geometry for every three-key bottom row.
public static let actionKeySpacing: CGFloat = 8
public static let sideActionKeyFraction: CGFloat = 0.2
public static let centerActionKeyFraction: CGFloat = 0.6
public static let horizontalInset: CGFloat = 8
/// Keeps voice and typing controls equally reachable on iPad.
public static let contentMaxWidth: CGFloat = 700
/// Splits the width left after spacing into a 20 / 60 / 20 row.
public static func actionKeyWidths(availableWidth: CGFloat) -> (side: CGFloat, center: CGFloat) {
let keyWidth = max(0, availableWidth - actionKeySpacing * 2)
return (
side: keyWidth * sideActionKeyFraction,
center: keyWidth * centerActionKeyFraction
)
}
}

Some files were not shown because too many files have changed in this diff Show More