@@ -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
|
||||
|
||||
@@ -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
@@ -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 toolbar’s 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 extension’s own view tree, not the host window. / **手势延迟扫描范围**:仅遍历扩展自身视图树,不再扫宿主 window。
|
||||
- **English autocapitalization**: Shift arms automatically at field start and after sentence terminators, respecting the host field’s 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 Bits–style, 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; 5–10 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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
```
|
||||
Binary file not shown.
@@ -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
@@ -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.
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" = "Couldn’t 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 */
|
||||
|
||||
@@ -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 |
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 toolbar’s 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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
@@ -6,6 +6,9 @@
|
||||
|
||||
import Charts
|
||||
import SwiftUI
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
public struct SevenDayUsageChart: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
+3
@@ -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 {
|
||||
+3
@@ -6,6 +6,9 @@
|
||||
// from platform singletons.
|
||||
|
||||
import SwiftUI
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
public struct UsageStatsCluster: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@@ -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(©, channelData[0], n * MemoryLayout<Float>.size)
|
||||
self.init(samples: copy, sampleRate: buffer.format.sampleRate)
|
||||
}
|
||||
}
|
||||
+3
@@ -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)
|
||||
+3
@@ -19,6 +19,9 @@ import AVFoundation
|
||||
import CoreMedia
|
||||
import Speech
|
||||
import os
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
// MARK: - Sendable conformance
|
||||
|
||||
+3
@@ -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
|
||||
+3
@@ -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 {
|
||||
+12
-60
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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(
|
||||
+5
-2
@@ -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()
|
||||
+3
@@ -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
|
||||
+3
@@ -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
|
||||
}
|
||||
}
|
||||
+25
-72
@@ -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
|
||||
}
|
||||
}
|
||||
+34
-9
@@ -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?
|
||||
+42
-14
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
-57
@@ -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()
|
||||
+8
-2
@@ -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`).
|
||||
+3
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(©, 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
Reference in New Issue
Block a user