merge: macOS architecture, cloud providers, and 6-step onboarding into main

This commit is contained in:
Rocky
2026-07-11 19:10:50 +08:00
175 changed files with 11748 additions and 2782 deletions
+5
View File
@@ -73,3 +73,8 @@ __pycache__/
# Standalone experiment: 灵动岛录音机制验证 demo (throwaway)
AudioIntentProbe/
# Local crash-log pulls and one-off diff/working notes (not for the repo)
.crash-pull/
all-changes-vs-main.patch
CHANGES.md
+1 -1
View File
@@ -116,7 +116,7 @@
- **6.7"** (1290×2796) — 必需
- **6.1"** (1179×2556) — 必需(iPhone 17 Pro
- 5.5" 已被苹果官方文档降级为"可选"iPhone 8 Plus 等已停产机型)
- iPad 截图 — 项目声明 iPhone only无需提供
- iPad 截图 — **现已必需**:项目自 TARGETED_DEVICE_FAMILY "1,2" 起支持 iPadApp Store Connect 要求提供 13″ iPad Pro 截图套组(本条为后续更新覆盖原「iPhone only 无需提供」的结论)
- 每套至少 3 张、最多 10 张
**本审计交付**:
+19
View File
@@ -7,7 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.3] - 2026-07-11
### Added
- **Shared 7-day usage chart on iOS Home**: iPhone and iPad Home now show the same trailing-7-day dictation chart as macOS, via a shared `UsageStatsCluster`. / **iOS 首页共享近 7 天统计图**iPhone / iPad 首页与 macOS 共用 `UsageStatsCluster`,展示近 7 天听写字数柱状图。
- **macOS dictation overlay**: a bottom-centered floating pill appears for any recording path (global hotkey, menu bar, or main window) — shows listening / transcribing state, a stronger live waveform, a one-line live transcript preview (partials when chunked ASR runs), front-app name, and a stop control, then briefly confirms success before fading out without stealing focus. / **macOS 听写浮层**:任意录音路径(全局热键、菜单栏或主窗口)都会在屏幕底部居中出现胶囊浮层——显示聆听 / 识别状态、更强的实时波形、单行转写预览(分块识别时显示 partial)、前台应用名与停止按钮,成功后短暂确认再淡出,且不抢前台焦点。
- **macOS Option-key picker**: Settings → Input lets you choose Left / Right / Either Option as the hold-to-talk key, so the shortcut can avoid conflicts with other apps. / **macOS Option 键选择**:设置 → 输入与快捷键可选择左 / 右 / 任一 Option 作为按住听写键,避免与其他应用冲突。
### Changed
- **Shared home stats UI**: 7-day chart, stat tiles, and surface chrome live in `OSGKeyboardShared` (`UsageStatsCluster` / `UsageStatCard` / `SevenDayUsageChart`); Mac Dashboard and iOS Home (phone stacked / iPad split) both consume them. Localization keys moved from `mac.stat.*` to `stat.*`. / **共享首页统计 UI**:近 7 天图表、统计卡与表面壳迁入 `OSGKeyboardShared``UsageStatsCluster` / `UsageStatCard` / `SevenDayUsageChart`);Mac Dashboard 与 iOS 首页(手机上下叠 / iPad 左右分栏)共用。文案 key 由 `mac.stat.*` 改为 `stat.*`
- **Removed temporary Flow DEBUG panels**: the on-screen App Group / session debug text boxes on Home and the keyboard extension are gone now that the orange-mic investigation is closed. / **移除临时 Flow DEBUG 面板**:橙色麦克风排查结束后,首页与键盘扩展上的 App Group / 会话调试文本框已去掉。
- **macOS local ASR catalog**: removed offline Paraformer; SenseVoice / Qwen3 0.6B / Qwen3 1.7B now show Fastest / Most balanced / Best quality badges (users still on Paraformer migrate to Qwen3 0.6B). / **macOS 本地 ASR 目录**:移除 offline ParaformerSenseVoice / Qwen3 0.6B / Qwen3 1.7B 分别标注速度最快 / 最平衡 / 质量最好(仍选 Paraformer 的用户迁移到 Qwen3 0.6B)。
- **macOS visual system**: full-app redesign around the brand line “Speak it. Its typed.” / 「开口即文字。」— grouped sidebar, restrained accent selection, asymmetric Home stats (chars as hero), unified page headers, quieter status footer, and clearer dark-mode card elevation. / **macOS 视觉体系**:围绕品牌句「开口即文字。」/ “Speak it. Its typed.” 做全 App 设计升级——侧栏分组、克制的选中态、首页不对称统计(字数主卡)、统一页头、降权状态栏,以及更清晰的暗色卡片层次。
- **macOS Home stat cards**: the hero word-count card is now a full-width horizontal bar sized to its content (icon + label + big number) instead of a tall card stretched to match its neighbors, removing the large dead space below the number; the "Recent" list was removed from Home since it duplicated the History page. / **macOS 首页统计卡**:字数主卡改为按内容自适应高度的满宽横条(图标+标题+大数字),不再被拉伸到与相邻卡片等高、留出大片空白;首页底部与历史页重复的「最近」列表已移除。
- **macOS page margins**: Home / History / Dictionary / Settings share `pageHorizontalInset` on titles and scroll *content*; ScrollViews / Forms stay full-bleed so the scrollbar sits on the window edge, while cards stay aligned with the page title. Settings keeps native grouped Form for control layout. / **macOS 页边距**:首页 / 历史 / 词库 / 设置在标题与滚动*内容*上共用 `pageHorizontalInset`ScrollView / Form 通栏使滚动条贴窗口右缘,卡片仍与页标题对齐。设置保留原生分组 Form 以保证控件排版。
### Fixed
- **Spurious "Voice is ready" overlay**: a healthy in-app Flow session no longer flashes the cold-start overlay after dictation. Keyboard mic presses wait when the session is still alive (including `preparingSession` / single-frame `hostNotReady` races); only a truly dead host opens `startflow`. The host silences overlay when already ready or busy. Proactive auto-launch is disabled. / **误弹「语音已就绪」**:App 内健康 Flow 会话在听写后不再闪冷启动浮层。键盘在会话仍存活时(含 `preparingSession` / 单帧 `hostNotReady` 竞态)只等待;仅宿主真正死亡才打开 `startflow`。主 App 在已就绪或忙碌时静默忽略浮层。已关闭被动自动拉起。
- **Cold-start overlay recursion crash**: dismissing the ready overlay while an utterance is already recording no longer recurses `refreshHostReady``reconcile``dismiss` on the main thread until stack overflow (`EXC_BAD_ACCESS`). Handoff flags are cleared before any refresh. / **冷启动浮层递归崩溃**:就绪浮层仍在时若已开始录音,不再在主线程上递归 `refreshHostReady``reconcile``dismiss` 直至栈溢出(`EXC_BAD_ACCESS`);交接标志会在任何 refresh 之前先清除。
- **macOS Qwen3 “language” garbage transcript**: Sherpa Qwen3 results that still include the model scaffold (`language Chinese<asr_text>…`) are now stripped to the spoken text; incomplete outputs that stop at the bare word `language` are treated as empty instead of being inserted. / **macOS Qwen3「language」乱码转写**Sherpa Qwen3 结果若仍带模型脚手架(`language Chinese<asr_text>…`)会剥到真实口语文案;不完整输出停在单词 `language` 时按空结果处理,不再插入。
- **macOS local ASR silence garbage output**: dictating with no speech (silence) on a Sherpa-backed local model (SenseVoice/Qwen3/Paraformer) no longer inserts the raw JSON result line (`{"lang": "", "emotion": "", ...}`) as the transcript — it now correctly reports "no speech recognized". / **macOS 本地识别静音乱码**:使用 Sherpa 本地模型(SenseVoice/Qwen3/Paraformer)听写时若未检测到语音,不再把原始 JSON 结果行(`{"lang": "", "emotion": "", ...}`)当作转写文本插入,现在会正确提示「没有识别到语音」。
- **Force-quit mic release**: on termination the host app now synchronously stops `AVAudioEngine`, deactivates `AVAudioSession`, and ends Live Activities (Dynamic Island + Lock Screen) in `applicationWillTerminate`, reducing “microphone in use” errors after reopening. / **强杀麦克风释放**:进程终止时在 `applicationWillTerminate` 内同步停止 `AVAudioEngine`、释放 `AVAudioSession` 并结束 Live Activity(灵动岛 + 锁屏),降低强杀后重开提示麦克风被占用的概率。
## [0.5.2] - 2026-07-09
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "anthropic.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "ark.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Volcengine</title><path d="M7.29 5.36L3.148 21.737a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.359a.214.214 0 00-.41 0z" fill-opacity=".5"></path><path clip-rule="evenodd" d="M4.553 16.18l-1.406 5.558a.214.214 0 00.203.261h2.42-4.551a.214.214 0 01-.214-.26l2.275-8.961a.214.214 0 01.409 0l.864 3.402z"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.214.214 0 00.215-.261L14.44.151z" fill-opacity=".5"></path><path clip-rule="evenodd" d="M16.694 22h3.207a.215.215 0 00.214-.262l-1.839-6.993 1.164-4.592a.214.214 0 01.411 0l2.951 11.586a.214.214 0 01-.214.261h-5.894z"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.216.216 0 00.214-.166.216.216 0 000-.098L10.687 7.742a.214.214 0 00-.409 0z"></path></svg>

After

Width:  |  Height:  |  Size: 944 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "codingplanx.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 6.5 3 12l5.5 5.5 1.4-1.4L5.8 12l4.1-4.1-1.4-1.4Zm7 0-1.4 1.4L18.2 12l-4.1 4.1 1.4 1.4L21 12l-5.5-5.5Z"/></svg>

After

Width:  |  Height:  |  Size: 205 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "cometapi.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>CometAPI</title><path d="M7.754 3.248C9.483.97 12.144-.223 14.99.035c4.67.422 8.023 4.694 7.27 9.384-.266 1.667-1 3.125-2.203 4.374-.468.487-1.025.9-1.662 1.422-2.554 2.09-6.026 4.854-10.413 8.294-.224.176-.669.495-.94.49a.19.19 0 01-.137-.06.192.192 0 01-.05-.14c.01-.207.077-.473.202-.8.04-.108.44-.956 1.197-2.545a1.99 1.99 0 00.179-.577.143.143 0 00-.007-.068.142.142 0 00-.098-.09.144.144 0 00-.07 0 1.479 1.479 0 00-.505.237c-.414.288-.86.648-1.337 1.078-.506.453-1.137 1.025-1.895 1.716a8.873 8.873 0 01-1.252.977.155.155 0 01-.064.021.152.152 0 01-.123-.04.154.154 0 01-.037-.055c-.027-.067-.024-.165.01-.292.113-.423.283-.902.511-1.437.17-.396.52-1.206 1.051-2.428.17-.39.697-1.592.61-1.897a.167.167 0 00-.102-.111.166.166 0 00-.15.018c-.284.194-.593.485-.93.87-.782.895-1.569 1.78-2.358 2.657-.248.274-.477.388-.687.343v-.238c.058-.215.104-.438.178-.642C4.075 12.378 5.938 7.2 6.764 4.964c.198-.537.529-1.11.99-1.716zm6.49-1.771a6.641 6.641 0 100 13.283 6.641 6.641 0 000-13.283z"></path><path d="M14.244 3.104a5.017 5.017 0 11-.002 10.033 5.017 5.017 0 01.002-10.033zm2.049 1.695a3.087 3.087 0 00-4.363 1.187 1.583 1.583 0 00-.165.442c-.015.067-.027.13-.033.194a1.308 1.308 0 00.078.56c.025.07.056.137.091.203.287.529.884.944 1.43 1.288.135.086.269.167.393.245l.392.246c.343.212.72.43 1.102.568.305.112.613.173.908.142a1.34 1.34 0 00.535-.178c.103-.061.202-.14.298-.237.064-.065.127-.137.186-.22a3.133 3.133 0 00.445-.868 3.08 3.08 0 00.056-1.71A3.063 3.063 0 0016.293 4.8z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "gemini.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"></path></svg>

After

Width:  |  Height:  |  Size: 651 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "groq.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Groq</title><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>

After

Width:  |  Height:  |  Size: 568 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "minimax.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mistral.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path clip-rule="evenodd" d="M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z"></path></svg>

After

Width:  |  Height:  |  Size: 418 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "openrouter.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z"/></svg>

After

Width:  |  Height:  |  Size: 704 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "siliconflow.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z"></path></svg>

After

Width:  |  Height:  |  Size: 525 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "xai.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"></path></svg>

After

Width:  |  Height:  |  Size: 372 B

+7
View File
@@ -117,5 +117,12 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+15
View File
@@ -8,6 +8,21 @@
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<!-- Voice recordings: processed on-device by default; uploaded to
the user's configured ASR provider only when the cloud engine
is explicitly enabled (opt-in with acknowledgment). -->
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeAudioData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
+5 -2
View File
@@ -97,8 +97,11 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
for item in items {
// `sourceApplication` is only non-nil when the caller belongs to
// the same Apple Developer Team (our own keyboard extension)
// exactly what the host-return whitelist relies on.
if let source = item.source {
// exactly what the host-return whitelist relies on. Record it
// ONLY for the `startflow` handoff: overwriting it for every
// deep link (e.g. `osgkeyboard://settings`) could point a later
// cold-start "return to host" at the wrong app.
if let source = item.source, item.url.host == "startflow" {
FlowSessionBridge.setPendingHostBundleId(source)
}
AppOpenURLRouter.shared.route(item.url)
@@ -15,11 +15,17 @@ enum FlowLiveActivityController {
nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle
/// If the host app is force-quit its `endSession()` never runs, orphaning
/// the Live Activity. A short `staleDate` lets the system grey it out and
/// reclaim it on its own within ~45s of the process dying. While the host is
/// alive the heartbeat calls `keepAlive()` well inside this window, so a
/// genuinely active session never looks stale.
private static let staleWindow: TimeInterval = 45
/// the Live Activity. `staleDate` semantics (verified against ActivityKit
/// behaviour, not folklore): the *Dynamic Island* presentation is reliably
/// removed shortly after the stale date passes, but the *lock-screen*
/// banner may linger greyed-out depending on the iOS version it is NOT
/// guaranteed to be dismissed. Treating staleDate as "auto-cleanup" is
/// therefore wrong on its own; the full zombie defence is this short
/// window + launch-time reconciliation (`clearOrphanedActivities`) + the
/// widget rendering an explicit "disconnected" state via
/// `context.isStale`. While the host is alive the heartbeat calls
/// `keepAlive()` every ~10 s, well inside this window.
private static let staleWindow: TimeInterval = 30
private static func freshContent(
phase: FlowActivityAttributes.ContentState.Phase
@@ -118,6 +124,8 @@ enum FlowLiveActivityController {
}
/// `applicationWillTerminate` `end` 退
/// ActivityKit `end` XPC watchdog
/// `wait()` ~5
nonisolated static func endAllSynchronouslyOnTerminate() {
let semaphore = DispatchSemaphore(value: 0)
Task.detached(priority: .userInitiated) {
@@ -129,7 +137,7 @@ enum FlowLiveActivityController {
FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
semaphore.signal()
}
semaphore.wait()
_ = semaphore.wait(timeout: .now() + 2)
currentPhase = .idle
currentActivity = nil
}
+400 -88
View File
@@ -54,9 +54,10 @@ final class FlowSessionManager: ObservableObject {
private var currentUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
private var lastHandledCommandSeq: Int64 = 0
private var isUtteranceRecording = false
/// Published so Home / debug UI can show "recording" instead of a false "ready".
@Published private(set) var isUtteranceRecording = false
/// True from `stopped` until the result/error is written back to App Group.
private var isUtteranceProcessing = false
@Published private(set) var isUtteranceProcessing = false
private var finalizeTask: Task<Void, Never>?
private var asrTask: Task<Void, Never>?
private var utteranceSafetyTask: Task<Void, Never>?
@@ -77,17 +78,50 @@ final class FlowSessionManager: ObservableObject {
private var coldStartRecoveryTask: Task<Void, Never>?
/// Initial proof window cold mic sessions often need >2.5s after app switch.
private static let coldStartAudioProofTimeout: TimeInterval = 6
/// Extra window after the first timeout while the overlay shows a failure hint.
private static let coldStartRecoveryProofTimeout: TimeInterval = 12
/// Guards the once-per-process launch reconciliation (scene reconnects
/// recreate the `@StateObject`-owned manager within the same process).
private static var didRunLaunchReconciliation = false
init() {
// Sessions are (re)started explicitly on app foreground via
// `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here after a force-quit that would resurrect capture
// (and keep a stale Live Activity alive) without the user re-opening.
//
// Launch reconciliation: a brand-new process can never own an
// in-flight session, so whatever the previous generation persisted
// (force-quit skips `applicationWillTerminate` entirely when the app
// was suspended) is void. Rotating the generation token also lets the
// keyboard invalidate stale ready snapshots instantly instead of
// waiting out the 60 s heartbeat-zombie window.
//
// Once per PROCESS, not per manager: iOS can disconnect and later
// reconnect the sole scene without killing the process, which
// recreates the `@StateObject` (and thus this init). Re-rotating then
// would wipe live state that belongs to this very process.
if AppGroup.isAvailable, !Self.didRunLaunchReconciliation {
Self.didRunLaunchReconciliation = true
let previous = FlowSessionBridge.rotateHostGeneration()
if previous != nil || FlowSessionBridge.isSessionActive() {
FlowSessionBridge.clearFlowStateOnHostLaunch()
FlowLiveActivityController.clearOrphanedActivities()
FlowSessionDarwin.postSessionChanged()
debug("launch reconciliation: voided previous-generation Flow state")
}
}
capture.onEngineLiveChanged = { [weak self] _ in
self?.refreshHostReady()
}
// A system interruption (call / Siri) stops audio frames mid-utterance;
// fail fast so the user is not silently recording into a gap.
capture.onInterruptionBegan = { [weak self] in
guard let self, self.isUtteranceRecording else { return }
self.failUtterance(
message: AppL10n.string("flow.error.recognitionInterrupted"),
kind: .recognitionInterrupted
)
}
FlowTerminationCoordinator.register(self)
}
@@ -104,21 +138,43 @@ final class FlowSessionManager: ObservableObject {
return
}
reconcilePersistedFlowStateBeforeStart()
// Healthy / busy sessions must not flash the cold-start overlay when a
// spurious `startflow` arrives (e.g. keyboard finalize race).
if coldStart, isActive {
extendSession(duration: duration)
refreshHostReady()
let decision = FlowHandoffPolicy.coldStartOverlayDecision(
sessionIsActive: true,
hostIsReady: FlowSessionBridge.isHostReady(),
isUtteranceBusy: isUtteranceRecording || isUtteranceProcessing
)
switch decision {
case .silence:
traceState(
"startSession.coldStart.silenced",
extra: FlowSessionBridge.isHostReady() ? "reason=alreadyReady" : "reason=busy"
)
return
case .present:
isColdStartHandoff = true
showColdStartPreparing()
Task { @MainActor [weak self] in
await self?.prepareExistingSessionForColdStartReturn()
}
return
}
}
if coldStart {
isColdStartHandoff = true
showColdStartPreparing()
}
reconcilePersistedFlowStateBeforeStart()
if isActive {
extendSession(duration: duration)
refreshHostReady()
if coldStart {
Task { @MainActor [weak self] in
await self?.prepareExistingSessionForColdStartReturn()
}
}
return
}
@@ -126,6 +182,10 @@ final class FlowSessionManager: ObservableObject {
traceState("startSession.ignored", extra: "reason=alreadyStarting")
return
}
// Claim the flag synchronously: on a cold start the URL router and
// `activateOnForeground()` both fire in the same runloop turn, and
// setting it inside the async body let two start bodies interleave.
isStarting = true
startTask?.cancel()
startTask = Task { @MainActor [weak self] in
@@ -190,11 +250,15 @@ final class FlowSessionManager: ObservableObject {
func dismissColdStartOverlay() {
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil
// Clear handoff flags BEFORE any refreshHostReady call. Otherwise
// refresh reconcileColdStartOverlayIfRecovered dismiss refresh
// recurses until the main-thread stack overflows (EXC_BAD_ACCESS,
// "Thread stack size exceeded due to excessive recursion").
coldStartContext = nil
isColdStartHandoff = false
if isActive {
refreshHostReady()
}
coldStartContext = nil
isColdStartHandoff = false
}
func returnToPendingHostFromColdStart() {
@@ -204,6 +268,21 @@ final class FlowSessionManager: ObservableObject {
func retryColdStartReadiness() {
guard AppGroup.isAvailable else { return }
// A failed cold start leaves capture in a running-but-dead state on
// purpose (the recovery loop keeps probing it). A user-initiated
// retry must instead begin from a clean pipeline: tear down capture
// and the cached ASR instance so `startSession` rebuilds both
// otherwise the retry reuses the zombie engine and is guaranteed to
// hit the same audio-proof timeout.
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil
if capture.running {
capture.stop()
}
sessionASR?.cancel()
sessionASR = nil
sessionASREngineMode = nil
sessionASRWarmedLocaleID = nil
startSession(coldStart: true)
}
@@ -241,7 +320,10 @@ final class FlowSessionManager: ObservableObject {
if isUtteranceRecording || isUtteranceProcessing {
capture.cancelUtterance()
asr.cancel()
// `asr` is a computed property that ALLOCATES a fresh ASRService
// when `sessionASR` is nil never do that inside the ~5 s
// termination window; only cancel an instance that exists.
sessionASR?.cancel()
}
capture.cancelUtterance()
@@ -407,16 +489,42 @@ final class FlowSessionManager: ObservableObject {
private func reactivateCaptureIfNeeded() async {
guard isActive else { return }
// A system interruption (call / Siri) may be in progress. Probe it:
// `setActive(true)` inside `reassertIfRunning` fails while the
// interruption is live and succeeds once it ends which also covers
// the documented case where iOS never delivers `.ended` (the latch
// must not depend on that notification, or the session is dead until
// its TTL). While the probe fails we deliberately do NOT stop or
// rebuild: tearing the engine down would remove the observers the
// `.ended` rebuild relies on and churn the shared session mid-call.
if capture.isInterrupted {
guard capture.reassertIfRunning(), !capture.isInterrupted else { return }
}
if capture.running {
let reasserted = capture.reassertIfRunning()
if reasserted, capture.engineHasRecentAudio() {
if reasserted, await capture.awaitAudioFlowing(timeout: 2) {
sessionWarning = nil
} else if !reasserted {
sessionWarning = AppL10n.string("flow.error.audioUnavailable")
refreshHostReady()
return
}
refreshHostReady()
return
// The await above is a suspension point: the session may have
// ended (expiry, user, teardown) while we waited. Never restart
// the microphone for a session that no longer exists.
guard isActive, !Task.isCancelled else { return }
// Never tear capture down underneath a live utterance either a
// stalled route transition mid-recording must surface through the
// utterance pipeline (safety timer / empty-transcript error), not
// as a silent stop that truncates the take with no error at all.
guard !isUtteranceRecording, !isUtteranceProcessing else {
refreshHostReady()
return
}
// Reassert failed, or the engine reports running yet produces no
// frames (zombie after suspend / mediaserverd reset) fall
// through to a full rebuild instead of leaving it half-dead.
capture.stop()
debug("capture zombie after foreground — rebuilding")
}
do {
@@ -442,7 +550,8 @@ final class FlowSessionManager: ObservableObject {
reason: .noSession,
engineMode: store.engineMode,
localeId: store.localeId,
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt()
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
hostGeneration: FlowSessionBridge.currentHostGeneration()
)
)
return
@@ -486,7 +595,8 @@ final class FlowSessionManager: ObservableObject {
engineMode: store.engineMode,
localeId: store.localeId,
busyUtteranceId: isUtteranceRecording || isUtteranceProcessing ? currentUtteranceId : nil,
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt()
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
hostGeneration: FlowSessionBridge.currentHostGeneration()
)
)
let signature = [
@@ -509,9 +619,19 @@ final class FlowSessionManager: ObservableObject {
/// shows a stale preparing/failed snapshot, heal automatically.
private func reconcileColdStartOverlayIfRecovered() {
guard isColdStartHandoff, isActive else { return }
guard FlowSessionBridge.isHostReady() else { return }
guard let context = coldStartContext else { return }
// Mid-utterance is not "ready" dismiss the ready overlay so Home
// does not keep advertising "" while utt.rec=1.
if isUtteranceRecording || isUtteranceProcessing {
if case .ready = context.state {
dismissColdStartOverlay()
}
return
}
guard FlowSessionBridge.isHostReady() else { return }
switch context.state {
case .preparing:
presentColdStartReadyOverlay()
@@ -556,10 +676,11 @@ final class FlowSessionManager: ObservableObject {
// MARK: - Session start
private func startSessionAsync(duration: TimeInterval?) async {
traceState("startSessionAsync.begin")
isStarting = true
sessionWarning = nil
// `isStarting` was claimed synchronously in `startSession()`.
defer { isStarting = false }
guard !Task.isCancelled else { return }
traceState("startSessionAsync.begin")
sessionWarning = nil
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
@@ -586,7 +707,9 @@ final class FlowSessionManager: ObservableObject {
return
}
guard await waitForAudioProof() else {
let audioProved = await waitForAudioProof()
guard !Task.isCancelled else { return }
guard audioProved else {
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message
traceState("startSessionAsync.failed", extra: "reason=audioProofTimeout")
@@ -658,6 +781,16 @@ final class FlowSessionManager: ObservableObject {
refreshHostReady()
guard FlowSessionBridge.isHostReady() else {
// Busy broken: a startflow arriving mid-utterance (e.g. tapping
// the Live Activity while dictating) finds a healthy session that
// is simply recording/processing. Showing the audio-failure
// overlay here would be a lie and its recovery loop could even
// stop capture and kill the live utterance.
if isUtteranceRecording || isUtteranceProcessing {
dismissColdStartOverlay()
debug("cold-start handoff ignored: session busy with an utterance")
return
}
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message
showColdStartAudioFailure(message: message)
@@ -687,18 +820,54 @@ final class FlowSessionManager: ObservableObject {
}
}
/// Keeps proving mic readiness after the first timeout instead of tearing
/// capture down many handoffs become ready a few seconds later.
/// Actively rebuilds the audio pipeline after a failed cold start instead
/// of passively waiting for frames that a dead engine will never produce.
/// Escalates per attempt: reassert the session full engine rebuild
/// bounce the audio session and rebuild. Force-quit relaunches routinely
/// inherit stale mediaserverd state that only a rebuild clears.
private func scheduleColdStartRecovery(duration: TimeInterval?) {
coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = Task { @MainActor [weak self] in
guard let self else { return }
let recovered = await self.capture.awaitAudioFlowing(
timeout: Self.coldStartRecoveryProofTimeout
)
var recovered = false
for attempt in 1...3 {
guard !Task.isCancelled, self.isColdStartHandoff else { return }
switch attempt {
case 1:
_ = self.capture.reassertIfRunning()
case 2:
self.capture.stop()
try? self.capture.start()
default:
self.capture.stop()
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
try? self.capture.start()
}
recovered = await self.capture.awaitAudioFlowing(
timeout: TimeInterval(attempt + 1)
)
self.traceState(
"coldStartRecovery.attempt",
extra: "attempt=\(attempt) recovered=\(recovered)"
)
if recovered { break }
}
guard !Task.isCancelled else { return }
guard self.isColdStartHandoff else { return }
guard recovered else { return }
guard recovered else {
// Out of attempts leave the failure overlay up; its retry
// button now performs a full teardown so the user always has
// a working escape hatch (no more force-quit loops). Only
// tear capture down when no session owns it: for an active
// session the 1 Hz heartbeat keeps self-healing, and a stop
// here would just fight it.
if !self.isActive {
self.capture.stop()
}
self.traceState("coldStartRecovery.exhausted")
return
}
self.sessionWarning = nil
self.traceState("coldStartRecovery.recovered")
@@ -1027,12 +1196,22 @@ final class FlowSessionManager: ObservableObject {
// Do NOT cancel `asrTask` or `asr` drain trailing PCM, then finalize.
// Capture ids now: a cancelled finalize must still clear *this*
// utterance's processing gate even if currentUtteranceId was cleared
// by a racing fail/abort path.
let drainingSessionId = activeSessionId
let drainingUtteranceId = currentUtteranceId
let drainingCommandSeq = currentCommandSeq
finalizeTask?.cancel()
finalizeTask = Task { @MainActor [weak self] in
guard let self else { return }
let drainReport = await self.capture.endUtteranceAndDrain()
FlowDiagnostics.logDrain(drainReport)
await self.finalizeUtterance()
await self.finalizeUtterance(
sessionId: drainingSessionId,
utteranceId: drainingUtteranceId,
commandSeq: drainingCommandSeq
)
}
debug("utterance stopped, draining tail")
}
@@ -1109,20 +1288,22 @@ final class FlowSessionManager: ObservableObject {
debug("utterance processing failed: \(message)")
}
private func finalizeUtterance() async {
let finalizeSessionId = activeSessionId
let finalizeUtteranceId = currentUtteranceId
private func finalizeUtterance(
sessionId finalizeSessionId: UUID?,
utteranceId finalizeUtteranceId: UUID?,
commandSeq finalizeCommandSeq: Int64
) async {
let pipelineStarted = Date()
// ALWAYS clear the processing gate for this utterance. The previous
// guard required currentUtteranceId to still match; a racing
// fail/abort/cancel path could nil the id (or leave processing stuck)
// and then skip refreshHostReady keyboard stayed white forever
// while host logs still said "utterance finalized".
defer {
if activeSessionId == finalizeSessionId,
currentUtteranceId == finalizeUtteranceId {
isUtteranceProcessing = false
FlowLiveActivityController.update(phase: .idle)
touchSessionActivity()
currentUtteranceId = nil
currentCommandSeq = 0
refreshHostReady()
}
completeFinalizeCleanup(
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId
)
}
let asrWait = asrWaitTimeout()
@@ -1134,6 +1315,10 @@ final class FlowSessionManager: ObservableObject {
while Date() < asrDeadline {
if !lastFinal.isEmpty { break }
if asrTask?.isCancelled == true { break }
// Honour cooperative cancel so a replaced finalize exits promptly,
// but still run defer cleanup (unlike an early `return` mid-polish
// that used to leave processing=true when ids no longer matched).
if Task.isCancelled { break }
try? await Task.sleep(nanoseconds: 100_000_000)
}
@@ -1151,14 +1336,21 @@ final class FlowSessionManager: ObservableObject {
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !text.isEmpty else {
let key = (asrTask?.isCancelled == true)
let key = (asrTask?.isCancelled == true || Task.isCancelled)
? "flow.error.recognitionInterrupted"
: "flow.error.noSpeech"
let kind: FlowSessionKeys.TranscriptionErrorKind =
(asrTask?.isCancelled == true) ? .recognitionInterrupted : .noSpeech
(asrTask?.isCancelled == true || Task.isCancelled)
? .recognitionInterrupted : .noSpeech
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
utteranceRecordingStartedAt = nil
storeCurrentError(AppL10n.string(key), kind: kind)
storeFinalizedError(
AppL10n.string(key),
kind: kind,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
return
}
@@ -1178,23 +1370,34 @@ final class FlowSessionManager: ObservableObject {
"translationTarget=\(pipelineStore.translationTargetLocaleId)"
)
do {
let polished = try await polisher.polish(
text,
// If the finalize task was cancelled (cold-start churn / abort),
// skip the LLM round-trip and deliver the raw transcript so the
// keyboard is not left waiting on a result that never arrives.
if Task.isCancelled {
throw CancellationError()
}
let polished = try await Self.polishWithHostTimeout(
polisher: polisher,
text: text,
mode: polishMode,
providerIdOverride: pipelineStore.polishProviderIdOverride
)
delivered = polished
storeCurrentFinal(polished, warning: chunkNote)
storeFinalizedResult(
polished,
warning: chunkNote,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
FlowDiagnostics.log(
"polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " +
"total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s"
)
} catch {
// v0.2.0: local + cloud-polish-on + no API key surfaces
// `.missingAPIKey`. We translate it into a polishWarning
// so the keyboard can show the "fill in your key" hint
// inline rather than a generic failure message. The raw
// transcript is still delivered no data loss.
// CancellationError is common when the user jumps back via
// startflow mid-polish; still deliver raw text. Other errors
// keep the existing polish-warning fallback.
let fallback = Self.makeFallbackDelivery(
rawText: text,
error: error,
@@ -1206,7 +1409,13 @@ final class FlowSessionManager: ObservableObject {
"\(error.localizedDescription)"
)
delivered = fallback.text
storeCurrentFinal(fallback.text, warning: fallback.polishWarning)
storeFinalizedResult(
fallback.text,
warning: fallback.polishWarning,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
}
SpeechHistoryStore.shared.recordUtterance(
@@ -1223,6 +1432,92 @@ final class FlowSessionManager: ObservableObject {
debug("utterance finalized length=\(text.count)")
}
/// Drop the processing gate and republish hostReady after finalize.
/// Must not depend on a perfect id match a racing fail/abort/cancel
/// used to skip this block and leave the keyboard stuck on white
/// even after "utterance finalized" was logged.
private func completeFinalizeCleanup(sessionId: UUID?, utteranceId: UUID?) {
// A newer utterance may have started; never clobber its gate.
if let current = currentUtteranceId,
let finished = utteranceId,
current != finished {
debug(
"finalize cleanup skipped — newer utterance live " +
"finished=\(finished.uuidString.prefix(8)) current=\(current.uuidString.prefix(8))"
)
return
}
let wasProcessing = isUtteranceProcessing
isUtteranceProcessing = false
FlowLiveActivityController.update(phase: .idle)
if isActive {
touchSessionActivity()
}
if currentUtteranceId == utteranceId || currentUtteranceId == nil {
currentUtteranceId = nil
currentCommandSeq = 0
}
refreshHostReady()
debug(
"finalize cleanup done wasProcessing=\(wasProcessing ? 1 : 0) " +
"utterance=\(utteranceId?.uuidString.prefix(8) ?? "nil") " +
"session=\(sessionId?.uuidString.prefix(8) ?? "nil")"
)
}
private func storeFinalizedResult(
_ text: String,
warning: String?,
sessionId: UUID?,
utteranceId: UUID?,
commandSeq: Int64
) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
storeFinalizedError(
AppL10n.string("flow.error.noSpeech"),
kind: .noSpeech,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
return
}
guard let sessionId, let utteranceId else { return }
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
status: .final,
text: trimmed,
warning: warning
)
)
}
private func storeFinalizedError(
_ message: String,
kind: FlowSessionKeys.TranscriptionErrorKind,
sessionId: UUID?,
utteranceId: UUID?,
commandSeq: Int64,
status: FlowResult.Status = .error
) {
guard let sessionId, let utteranceId else { return }
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
status: status,
text: message,
errorKind: kind
)
)
}
private static func polishModeLogLabel(_ mode: PolishingService.PolishMode) -> String {
switch mode {
case .polish:
@@ -1252,35 +1547,12 @@ final class FlowSessionManager: ObservableObject {
engineMode: String,
chunkWarning: String?
) -> TranscriptionDelivery {
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
let warning = warningFromPolishError(error, engineMode: engineMode)
?? polishDegradedWarning()
?? chunkWarning
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
}
private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? {
if let polishError = error as? PolishingService.PolishError {
switch polishError {
case .missingAPIKey:
if engineMode == "local" {
return SharedL10n.string("flow.warning.localPolishUnavailable")
}
return SharedL10n.string("flow.warning.cloudPolishMissingKey")
case .timeout:
return polishDegradedWarning()
case .noTranscript:
return nil
}
}
if error is LLMError {
return polishDegradedWarning()
}
return nil
}
private static func polishDegradedWarning() -> String? {
SharedL10n.string("flow.warning.polishDegraded")
TranscriptionPolishFallback.makeDelivery(
rawText: rawText,
error: error,
engineMode: engineMode,
chunkWarning: chunkWarning
)
}
private func asrWaitTimeout() -> TimeInterval {
@@ -1293,6 +1565,22 @@ final class FlowSessionManager: ObservableObject {
return FlowSessionKeys.cloudASRWaitTimeout
}
/// Host-level polish cap does not wait for a cancelled LLM task to unwind.
private static func polishWithHostTimeout(
polisher: PolishingService,
text: String,
mode: PolishingService.PolishMode,
providerIdOverride: String?
) async throws -> String {
try await HardTimeout.run(seconds: FlowSessionKeys.maxPolishTimeout) {
try await polisher.polish(
text,
mode: mode,
providerIdOverride: providerIdOverride
)
}
}
// MARK: - Level publishing (main thread only)
private func startLevelPublishing() {
@@ -1318,8 +1606,8 @@ final class FlowSessionManager: ObservableObject {
// Refresh the Live Activity `staleDate` every N heartbeat ticks
// (1 Hz) well inside `FlowLiveActivityController.staleWindow` so a
// live session never looks stale, while a force-quit stops these
// refreshes and lets the system reclaim the orphaned island.
let liveActivityKeepAliveEveryTicks = 15
// refreshes and lets the island go stale within ~30 s.
let liveActivityKeepAliveEveryTicks = 10
var tick = 0
while !Task.isCancelled {
guard let self else { break }
@@ -1351,6 +1639,30 @@ final class FlowSessionManager: ObservableObject {
FlowDiagnostics.log(message)
}
// MARK: - Temporary Flow debug panel (remove after orange-mic investigation)
/// Snapshot for the on-screen debug panel. Safe to call from the main actor.
func makeDebugRows() -> [FlowDebugRow] {
let snapshot = FlowSessionBridge.readySnapshot()
let hostRows = FlowDebugAppGroupSnapshot.rows()
let memRows: [FlowDebugRow] = [
FlowDebugRow("isActive", isActive ? "1" : "0"),
FlowDebugRow("isStarting", isStarting ? "1" : "0"),
FlowDebugRow("coldStart", isColdStartHandoff ? "1" : "0"),
FlowDebugRow("engineLive", capture.engineIsLive ? "1" : "0"),
FlowDebugRow("audioFresh", capture.engineHasRecentAudio(maxAge: 2) ? "1" : "0"),
FlowDebugRow("mem.reason", snapshot?.reason.rawValue ?? "nil"),
FlowDebugRow("utt.rec", isUtteranceRecording ? "1" : "0"),
FlowDebugRow("utt.proc", isUtteranceProcessing ? "1" : "0"),
FlowDebugRow("sessionId", activeSessionId.map { String($0.uuidString.prefix(8)) } ?? "nil"),
FlowDebugRow("warning", sessionWarning == nil ? "0" : "1"),
FlowDebugRow("overlay", coldStartContext.map { String(describing: $0.state) } ?? "nil"),
FlowDebugRow("bridgeReady", FlowSessionBridge.isHostReady() ? "1" : "0")
]
// Prefer App Group snap.reason near the top of the shared block.
return memRows + hostRows
}
private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
guard signature != lastIgnoredCommandSignature else { return }
@@ -4,12 +4,10 @@
// Single source of truth for third-party open-source components shipped
// with OSGKeyboard. Consumed by Settings About Third-Party Licenses.
//
// Keep this list aligned with `project.yml` package dependencies.
// Keep this list aligned with bundled resources and runtime downloads.
//
// v0.2.0: dropped the `Qwen3Speech` SPM fork (Qwen3 CoreML ASR is gone)
// and the `aufklarer/Qwen3-ASR-CoreML` runtime artefact. The local
// engine now ships with iOS 26 `SpeechAnalyzer` + `DictationTranscriber`
// and has no on-device ML dependencies of our own.
// iOS targets remain zero-SPM. macOS local ASR downloads the sherpa-onnx
// runtime binary on demand; model weights are cached under Application Support.
import Foundation
@@ -26,19 +24,24 @@ enum OpenSourceLicenseCatalog {
let licenseText: String
}
/// Bundled libraries referenced by the app. v0.2.0 no longer pulls in
/// `soniqo/speech-swift` (we use iOS 26 `SpeechAnalyzer` instead) and
/// no longer downloads `Qwen3-ASR-CoreML` weights both entries are
/// intentionally absent.
/// Bundled libraries and runtime components referenced by the app.
static let entries: [Entry] = [
.init(
id: "material-icons",
name: "Google Material Icons",
licenseName: "Apache-2.0",
purpose: "MaterialIcons-Regular.ttf bundled for Settings and navigation iconography.",
purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.",
url: URL(string: "https://github.com/google/material-design-icons"),
licenseText: apache2Text
),
.init(
id: "sherpa-onnx",
name: "sherpa-onnx",
licenseName: "Apache-2.0",
purpose: "macOS local ASR runtime (`sherpa-onnx-offline`) downloaded at install time and cached on device.",
url: URL(string: "https://github.com/k2-fsa/sherpa-onnx"),
licenseText: apache2Text
),
]
// MARK: - License bodies
@@ -60,30 +63,4 @@ enum OpenSourceLicenseCatalog {
implied. See the License for the specific language governing
permissions and limitations under the License.
"""
static let mitText = """
MIT License
Copyright (c) 2023 Apple Inc.
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.
"""
}
}
+60 -189
View File
@@ -11,215 +11,86 @@ struct APISettingsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var showKey: Bool = false
@State private var testStatus: TestStatus = .idle
private enum TestStatus: Equatable {
case idle
case running
case success
case failure(String)
}
/// `false`
var showsSurface: Bool = true
var body: some View {
VStack(spacing: 0) {
field(
SettingsCredentialRow(
title: AppL10n.string("api.key"),
placeholder: "sk-…",
text: $config.apiKey,
isSecret: true,
isMonospaced: true
)
rowDivider
SettingsCredentialRow(
title: AppL10n.string("api.baseUrl"),
placeholder: "https://api.openai.com/v1",
placeholder: LLMProvider.provider(id: config.providerId).defaultBaseURL,
text: $config.baseURL,
autocap: false
isMonospaced: true
)
Divider().background(palette.divider)
keyField
Divider().background(palette.divider)
field(
rowDivider
SettingsModelPickerRow(
title: AppL10n.string("api.model"),
placeholder: "gpt-4o-mini",
text: $config.model,
autocap: false
placeholder: LLMProvider.provider(id: config.providerId).defaultModel,
model: $config.model,
fetchModels: fetchModels
)
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
Divider().background(palette.divider)
// Use a Button + UIApplication.open instead of SwiftUI
// `Link`. SwiftUI `Link` can have hit-test quirks on some
// makes its tappable area eat gestures from the adjacent
// TextField, which manifests as "typing jumps to a website".
Button {
UIApplication.shared.open(url)
} label: {
HStack {
Text("api.getKey")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Divider().background(palette.divider)
testConnectionRow
.id(config.providerId)
rowDivider
thinkingRow
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
// MARK: - Key
private var keyField: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("api.key")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
Spacer()
Button(action: { showKey.toggle() }) {
Image(systemName: showKey ? "eye.slash.fill" : "eye.fill")
.foregroundStyle(palette.textSecondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(showKey
? AppL10n.string("api.key.hide")
: AppL10n.string("api.key.show")))
}
Group {
if showKey {
TextField("sk-…", text: $config.apiKey)
} else {
SecureField("sk-…", text: $config.apiKey)
}
}
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
private var rowDivider: some View {
Divider().background(palette.divider)
}
// MARK: - Generic field
@ViewBuilder
private func field(
title: String,
placeholder: String,
text: Binding<String>,
autocap: Bool
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
TextField(placeholder, text: text)
.keyboardType(.asciiCapable)
.autocorrectionDisabled(true)
.textInputAutocapitalization(autocap ? .sentences : .never)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.submitLabel(.done)
.onSubmit { /* no-op: prevent the keyboard from "submitting"
and dismissing the sheet unexpectedly */ }
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
}
// MARK: - Test connection
private var testConnectionRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("api.connection")
/// Two-line thinking toggle opt-in for slower, higher-quality polish.
private var thinkingRow: some View {
HStack(alignment: .center, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(AppL10n.string("settings.provider.thinking"))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Button(action: runTest) {
Group {
if testStatus == .running {
ProgressView().controlSize(.mini)
} else {
Text(testButtonLabel)
.font(TypeStyle.body)
.foregroundStyle(testTint)
}
}
}
.buttonStyle(.plain)
.disabled(testStatus == .running)
}
if let detail = testDetail {
Text(detail)
.fixedSize(horizontal: false, vertical: true)
Text(AppL10n.string("settings.provider.thinkingSubtitle"))
.font(TypeStyle.caption2)
.foregroundStyle(testTint)
.lineLimit(3)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
Toggle("", isOn: $config.llmThinkingEnabled)
.labelsHidden()
.tint(palette.accent)
.accessibilityLabel(AppL10n.string("settings.provider.thinking"))
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
.settingsListRow()
}
private var testButtonLabel: String {
switch testStatus {
case .idle: return AppL10n.string("api.test.idle")
case .running: return AppL10n.string("api.test.running")
case .success: return AppL10n.string("api.test.success")
case .failure: return AppL10n.string("api.test.failure")
}
}
private var testTint: Color {
switch testStatus {
case .idle, .running: return palette.accent
case .success: return palette.accent
case .failure: return palette.danger
}
}
private var testDetail: String? {
switch testStatus {
case .idle, .running, .success: return nil
case .failure(let message): return message
}
}
private func runTest() {
testStatus = .running
let store = AppGroupStore()
let client = OpenAICompatibleClient(
baseURL: store.baseURL,
apiKey: store.apiKey,
model: store.model
private func validateConnection() async throws {
// Use on-screen config not a fresh AppGroupStore so a just-typed
// key is visible even if Keychain write is still settling.
let client = LLMClientFactory.make(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
thinkingEnabled: config.llmThinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
private func fetchModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
currentModel: config.model
)
Task {
do {
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
testStatus = .success
} catch LLMError.noAPIKey {
testStatus = .failure(AppL10n.string("api.test.missing"))
} catch let error as LLMError {
switch error {
case .http(let status):
testStatus = .failure(AppL10n.format("api.test.http", status))
case .rateLimited:
testStatus = .failure(AppL10n.string("api.test.rateLimited"))
case .transport(let msg):
testStatus = .failure(AppL10n.format("api.test.transportWith", msg))
default:
testStatus = .failure(error.errorDescription ?? "\(error)")
}
} catch {
testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)")
}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
// ASRSettingsCard.swift
// OSGKeyboard · Main App
//
// Cloud ASR credentials independent from the polish LLM card.
import SwiftUI
import OSGKeyboardShared
struct ASRSettingsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
/// `false`
var showsSurface: Bool = true
var body: some View {
VStack(spacing: 0) {
if config.asrProviderId == "volcengine" {
volcengineRows
} else {
genericRows
}
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
}
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
@ViewBuilder
private var genericRows: some View {
if CloudASRModelCatalog.showsASREndpointField(for: config.asrProviderId) {
SettingsCredentialRow(
title: AppL10n.string("api.baseUrl"),
placeholder: LLMProvider.provider(id: config.asrProviderId).defaultBaseURL,
text: $config.asrBaseURL,
isMonospaced: true
)
rowDivider
}
SettingsCredentialRow(
title: AppL10n.string("api.key"),
placeholder: "sk-…",
text: $config.asrApiKey,
isSecret: true,
isMonospaced: true
)
rowDivider
SettingsModelPickerRow(
title: AppL10n.string("settings.asr.model"),
placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId),
model: $config.asrModel,
fetchModels: fetchModels
)
.id(config.asrProviderId)
}
private var volcengineRows: some View {
Group {
SettingsCredentialRow(
title: AppL10n.string("settings.asr.volcengine.appId"),
placeholder: "APP ID",
text: Binding(
get: { volcengineFields.appID },
set: { updateVolcengine(appID: $0) }
),
isSecret: true,
isMonospaced: true
)
rowDivider
SettingsCredentialRow(
title: AppL10n.string("settings.asr.volcengine.accessToken"),
placeholder: "Access Token",
text: Binding(
get: { volcengineFields.accessToken },
set: { updateVolcengine(accessToken: $0) }
),
isSecret: true,
isMonospaced: true
)
rowDivider
SettingsCredentialRow(
title: AppL10n.string("settings.asr.volcengine.resourceId"),
placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
text: Binding(
get: { volcengineFields.resourceID },
set: { updateVolcengine(resourceID: $0) }
),
isMonospaced: true,
defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
)
rowDivider
SettingsProviderRow(title: AppL10n.string("settings.provider.note")) {
Text("settings.asr.volcengine.note")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private var rowDivider: some View {
Divider().background(palette.divider)
}
private var volcengineFields: VolcengineASRFields {
VolcengineASRFields.parse(
apiKey: config.asrApiKey,
resourceFallback: config.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: "volcengine")
: config.asrModel
)
}
private func updateVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
var fields = volcengineFields
if let appID { fields.appID = appID }
if let accessToken { fields.accessToken = accessToken }
if let resourceID {
fields.resourceID = resourceID
config.asrModel = resourceID
}
config.asrApiKey = fields.encodedAPIKey
}
private func validateConnection() async throws {
let persisted = AppGroupStore()
let live = LiveConfigurationStore(config: config, fallback: persisted)
try await CloudASRConnectionCheck.validate(store: live)
}
private func fetchModels() async throws -> [String] {
try await ProviderModelService.listASRModels(
providerId: config.asrProviderId,
baseURL: config.asrBaseURL,
apiKey: config.asrApiKey,
currentModel: config.asrModel
)
}
}
@@ -1,138 +0,0 @@
// HomeStatsCard.swift
// OSGKeyboard · Main App
//
// Home screen summary: dictation time, dictation characters,
// translation characters, and personal-dictionary entry count.
import SwiftUI
import OSGKeyboardShared
struct HomeStatsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@State private var dictionaryCount = 0
private enum Layout {
static let fixedHeight: CGFloat = 166
static let valueFontSize: CGFloat = 24
static let iconSize: CGFloat = 18
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
statCell(
systemImage: "waveform",
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: config.uiLanguage
),
label: "home.stats.dictationDuration"
)
divider
statCell(
systemImage: "text.alignleft",
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount,
language: config.uiLanguage
),
label: "home.stats.dictationCharacters"
)
}
horizontalDivider
HStack(spacing: 0) {
statCell(
systemImage: "character.bubble",
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: config.uiLanguage
),
label: "home.stats.translationCharacters"
)
divider
statCell(
systemImage: "square.stack.3d.down.right.fill",
value: UsageStatisticsStore.formatCount(
dictionaryCount,
language: config.uiLanguage
),
label: "home.stats.dictionaryEntries"
)
}
}
.frame(height: Layout.fixedHeight)
.background(cardBackground)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.onAppear(perform: refreshDictionaryCount)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(value)
.font(.system(size: Layout.valueFontSize, weight: .semibold, design: .rounded))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
Text(label)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Spacer(minLength: Spacing.xs)
Image(systemName: systemImage)
.font(.system(size: Layout.iconSize, weight: .semibold))
.foregroundStyle(palette.accent)
.padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
}
private var divider: some View {
Rectangle()
.fill(palette.divider)
.frame(width: 0.5)
}
private var horizontalDivider: some View {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
}
private func refreshDictionaryCount() {
dictionaryCount = AppGroupStore().personalDictionary.entries.count
}
private var cardBackground: Color {
colorScheme == .dark ? palette.surface : .white
}
}
#if DEBUG
#Preview {
ThemedRoot {
HomeStatsCard()
.padding()
}
}
#endif
@@ -0,0 +1,61 @@
// HomeUsageStatsSection.swift
// OSGKeyboard · Main App
//
// Observes usage + dictionary counts and feeds the shared
// `UsageStatsCluster` (phone stacked / iPad split).
import SwiftUI
import OSGKeyboardShared
struct HomeUsageStatsSection: View {
let layout: UsageStatsCluster.Layout
var compact: Bool = false
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@State private var dictionaryCount = 0
var body: some View {
UsageStatsCluster(
layout: layout,
language: config.uiLanguage,
points: stats.last7Days,
dictationCharacterCount: stats.dictationCharacterCount,
dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount,
dictionaryTermCount: dictionaryCount,
compact: compact
)
.onAppear(perform: refreshDictionaryCount)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryCount()
}
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk()
}
}
private func refreshDictionaryCount() {
dictionaryCount = AppGroupStore().personalDictionary.entries.count
}
}
#if DEBUG
#Preview("Phone stacked") {
ThemedRoot {
HomeUsageStatsSection(layout: .stacked)
.padding()
}
}
#Preview("Wide split") {
ThemedRoot {
HomeUsageStatsSection(layout: .split)
.padding()
}
}
#endif
@@ -23,7 +23,7 @@ enum AppTab: Int, CaseIterable {
}
}
/// Matches `HomeStatsCard` dictionary stat cell (filled variant).
/// Filled SF Symbol override for the dictionary tab.
var sfSymbol: String? {
switch self {
case .dictionary: return "square.stack.3d.down.right.fill"
@@ -39,6 +39,18 @@ enum AppTab: Int, CaseIterable {
case .settings: return "tab.settings"
}
}
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
var sidebarTitle: LocalizedStringKey { accessibilityKey }
var sidebarSystemImage: String {
switch self {
case .keyboard: return "house"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .settings: return "gearshape"
}
}
}
struct MinimalTabBar: View {
@@ -32,62 +32,3 @@ struct PrivacyInfoCard: View {
)
}
}
/// Footnote when Cloud polish is active text goes to the user's configured API.
struct CloudPolishDisclosureBanner: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(alignment: .top, spacing: Spacing.sm) {
Image(systemName: "info.circle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.accent)
Text("settings.privacy.cloud.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.accentMuted.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
}
}
/// First-time confirmation before enabling Cloud polish (user-configured third-party API).
struct CloudSharingAcknowledgmentModifier: ViewModifier {
@ObservedObject var config: ProviderConfig
@Binding var isPresented: Bool
let onConfirm: () -> Void
let onCancel: () -> Void
func body(content: Content) -> some View {
content
.alert("settings.privacy.cloud.alert.title", isPresented: $isPresented) {
Button("common.continue") {
config.hasAcknowledgedCloudSharing = true
onConfirm()
}
Button("common.cancel", role: .cancel) {
onCancel()
}
} message: {
Text("settings.privacy.cloud.alert.message")
}
}
}
extension View {
func cloudSharingAcknowledgment(
config: ProviderConfig,
isPresented: Binding<Bool>,
onConfirm: @escaping () -> Void,
onCancel: @escaping () -> Void
) -> some View {
modifier(CloudSharingAcknowledgmentModifier(
config: config,
isPresented: isPresented,
onConfirm: onConfirm,
onCancel: onCancel
))
}
}
@@ -0,0 +1,43 @@
// WideLayoutComponents.swift
// OSGKeyboard · Main App
//
// Reusable layout pieces for iPad / regular-width surfaces. Styled with the
// shared design tokens so the wide Home dashboard can mirror the macOS shell
// without pulling in AppKit-only types from OSGKeyboardMac.
//
// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`.
import SwiftUI
import OSGKeyboardShared
// MARK: - Layout metrics
/// Fixed metrics that keep wide surfaces on the same grid as the macOS app.
enum WideLayoutMetrics {
static let sidebarWidth: CGFloat = 240
static let sidebarInset: CGFloat = Spacing.md
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
static let pageHorizontalInset: CGFloat = 40
static let dictationCanvasMinHeight: CGFloat = 120
}
// MARK: - Card container
/// Elevated surface used for the dictation canvas on wide Home.
struct WideCard<Content: View>: View {
@Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md
var cornerRadius: CGFloat = Radius.medium
@ViewBuilder var content: () -> Content
var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
content()
.padding(padding)
.background(palette.surface, in: shape)
.overlay(
shape.stroke(palette.divider, lineWidth: 0.5)
)
}
}
+1 -28
View File
@@ -11,8 +11,6 @@ struct EnginePickerSection: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@State private var showCloudAcknowledgment = false
@State private var pendingEngineSelection: String?
var body: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
@@ -37,17 +35,7 @@ struct EnginePickerSection: View {
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
if config.engineMode == "cloud" {
CloudPolishDisclosureBanner()
}
}
.cloudSharingAcknowledgment(
config: config,
isPresented: $showCloudAcknowledgment,
onConfirm: { applyPendingEngineSelection() },
onCancel: { pendingEngineSelection = nil }
)
}
private var localSubtitle: String {
@@ -64,11 +52,6 @@ struct EnginePickerSection: View {
let isSelected = config.engineMode == id
return Button {
guard config.engineMode != id else { return }
if id == "cloud", !config.hasAcknowledgedCloudSharing {
pendingEngineSelection = id
showCloudAcknowledgment = true
return
}
selectEngine(id)
} label: {
HStack(spacing: Spacing.sm) {
@@ -92,8 +75,7 @@ struct EnginePickerSection: View {
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -104,19 +86,10 @@ struct EnginePickerSection: View {
config.engineMode = id
if id == "cloud" {
config.modeId = "polish"
if config.providerId == "deepseek" {
config.apply(preset: LLMProvider.provider(id: "openai"))
}
}
}
}
private func applyPendingEngineSelection() {
guard let id = pendingEngineSelection else { return }
pendingEngineSelection = nil
selectEngine(id)
}
@ViewBuilder
private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
ZStack {
+175 -58
View File
@@ -15,6 +15,7 @@ import UIKit
struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager
@@ -24,6 +25,10 @@ struct HomeView: View {
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
private var usesWideLayout: Bool {
horizontalSizeClass == .regular
}
private var sessionIsLive: Bool {
flowManager.isActive || flowManager.isStarting
}
@@ -52,50 +57,11 @@ struct HomeView: View {
}
var body: some View {
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
VStack(spacing: 0) {
logoHeader
.padding(.top, Spacing.xxxl)
.padding(.bottom, Spacing.xxl)
if showsFlowSessionExtras {
flowSessionExtras
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.lg)
}
HomeStatsCard()
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
previewField
.padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
HStack(spacing: Spacing.sm) {
engineStatusLine
flowStatusFooter
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xl)
.padding(.bottom, Spacing.sm)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.background(palette.background)
.contentShape(Rectangle())
.onTapGesture {
if previewFocused {
previewFocused = false
}
Group {
if usesWideLayout {
wideBody
} else {
phoneBody
}
}
.onAppear {
@@ -114,6 +80,126 @@ struct HomeView: View {
}
}
// MARK: - Phone layout
private var phoneBody: some View {
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
// iPhone SE
// tab
let isCompact = geo.size.height < 700
// logo
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg
let statusTopPadding = isCompact ? Spacing.sm : Spacing.xl
// tab
let previewMinHeight: CGFloat = isCompact ? 72 : 160
ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
VStack(spacing: 0) {
logoHeader(compact: isCompact)
.padding(.top, logoTopPadding)
.padding(.bottom, logoBottomPadding)
if showsFlowSessionExtras {
flowSessionExtras
.padding(.horizontal, Spacing.lg)
.padding(.bottom, extrasBottomPadding)
}
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
//
previewField(minHeight: previewMinHeight)
.padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.layoutPriority(-1)
HStack(spacing: Spacing.sm) {
engineStatusLine
flowStatusFooter
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, Spacing.lg)
.padding(.top, statusTopPadding)
.padding(.bottom, Spacing.sm)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.background(palette.background)
.contentShape(Rectangle())
.onTapGesture {
if previewFocused {
previewFocused = false
}
}
}
}
// MARK: - Wide layout (iPad / regular width)
private var wideBody: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: Spacing.lg) {
wideHeroHeader
HomeUsageStatsSection(layout: .split)
if showsFlowSessionExtras {
flowSessionExtras
}
widePreviewStage
}
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.md)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.background(palette.background)
.contentShape(Rectangle())
.onTapGesture {
if previewFocused {
previewFocused = false
}
}
}
private var wideHeroHeader: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text("home.wide.tagline")
.font(.system(size: 30, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.lineLimit(2)
.minimumScaleFactor(0.85)
Text("home.wide.tagline.subtitle")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var widePreviewStage: some View {
WideCard(padding: Spacing.md, cornerRadius: Radius.large) {
previewFieldContent
.frame(
maxWidth: .infinity,
minHeight: WideLayoutMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
@@ -161,12 +247,15 @@ struct HomeView: View {
// MARK: - Header
private var logoHeader: some View {
VStack(spacing: Spacing.xxl) {
// logo 144:41
private func logoHeader(compact: Bool) -> some View {
let logoWidth: CGFloat = compact ? 104 : 124
let logoHeight = logoWidth * (41.0 / 144.0)
return VStack(spacing: Spacing.xxl) {
Image("osglogo")
.resizable()
.scaledToFit()
.frame(width: 144, height: 41)
.frame(width: logoWidth, height: logoHeight)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity)
@@ -187,7 +276,18 @@ struct HomeView: View {
.foregroundStyle(palette.warning)
.lineLimit(1)
.minimumScaleFactor(0.85)
} else if flowManager.isUtteranceRecording {
Text("home.flow.recording")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else if flowManager.isUtteranceProcessing {
Text("home.flow.processing")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else if flowManager.isActive,
FlowSessionBridge.isHostReady(),
let expires = flowManager.sessionExpiresAt {
Text("home.flow.label")
.font(TypeStyle.caption2)
@@ -322,10 +422,14 @@ struct HomeView: View {
private var flowStatusColor: Color {
if needsCloudSetup { return palette.warning }
if flowManager.isActive { return palette.accent }
if flowManager.isUtteranceRecording { return palette.accent }
if flowManager.isUtteranceProcessing { return palette.accent }
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
if flowManager.isStarting { return palette.accent }
if needsPermissionSetup { return palette.warning }
if flowManager.sessionWarning != nil { return palette.warning }
// Active but not host-ready (e.g. mid-utterance / audio proof) amber.
if flowManager.isActive { return palette.warning }
return palette.textTertiary
}
@@ -337,33 +441,46 @@ struct HomeView: View {
if flowManager.isStarting {
return AppL10n.string("home.flow.starting")
}
if flowManager.isActive {
if flowManager.isUtteranceRecording {
return AppL10n.string("home.flow.recording")
}
if flowManager.isUtteranceProcessing {
return AppL10n.string("home.flow.processing")
}
if flowManager.isActive, FlowSessionBridge.isHostReady() {
return AppL10n.string("home.flow.label")
}
if flowManager.isActive {
// Session flag is up but the ready contract is not do not lie.
return AppL10n.string("home.flow.notReady")
}
return AppL10n.string("home.flow.inactive")
}
// MARK: - Preview field
private var previewField: some View {
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.tint(palette.accent)
.focused($previewFocused)
.lineLimit(1...100)
.frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading)
private func previewField(minHeight: CGFloat) -> some View {
previewFieldContent
.frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1)
)
// TextField only hit-tests the text line(s); expand taps to the full card.
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.onTapGesture { previewFocused = true }
}
private var previewFieldContent: some View {
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.tint(palette.accent)
.focused($previewFocused)
.lineLimit(1...100)
}
private var engineStatusLine: some View {
Text(
EngineServiceLabel.summary(
@@ -25,8 +25,10 @@ struct LocalModelsGroup: View {
speechRow
Divider().background(palette.divider)
polishRow
#if DEBUG
Divider().background(palette.divider)
customLanguageModelDiagnosticRow
#endif
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
@@ -45,8 +47,7 @@ struct LocalModelsGroup: View {
Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.speechEngine")
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
// MARK: Polish row
@@ -61,12 +62,12 @@ struct LocalModelsGroup: View {
Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.polishEngine")
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
// MARK: Custom language model diagnostic row
// MARK: Custom language model diagnostic row (DEBUG builds only)
#if DEBUG
private var customLanguageModelDiagnosticRow: some View {
Toggle(isOn: $config.localASRCustomLanguageModelEnabled) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
@@ -79,10 +80,9 @@ struct LocalModelsGroup: View {
}
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
#endif
// MARK: Helpers
+9 -5
View File
@@ -39,14 +39,18 @@ struct MainAppRoot: View {
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
.onAppear {
flowManager.setAppForeground(scenePhase == .active)
flowManager.activateOnForeground()
AppCloudSync.shared.startObservingExternalChanges()
// Registering here also flushes any URL buffered during a cold
// launch (the keyboard app `startflow` handoff arrives via the
// scene delegate before this view is on screen).
// Register the URL handler BEFORE the foreground auto-start.
// Registering flushes any URL buffered during a cold launch (the
// keyboard app `startflow` handoff arrives via the scene
// delegate before this view is on screen), so a cold start takes
// the cold-start path first and `activateOnForeground()`'s plain
// start then no-ops on the isStarting guard instead of two
// start bodies racing each other on the main actor.
AppOpenURLRouter.shared.register { url in
handleIncomingURL(url)
}
flowManager.activateOnForeground()
AppCloudSync.shared.startObservingExternalChanges()
Task {
await AppCloudSync.shared.pullAllIfEnabled()
}
+276
View File
@@ -0,0 +1,276 @@
// MainSplitView.swift
// OSGKeyboard · Main App
//
// iPad / regular-width shell: sidebar navigation + detail workspace.
// Mirrors the macOS `NavigationSplitView` structure while keeping iOS tabs
// and Flow session behaviour unchanged underneath.
import SwiftUI
import OSGKeyboardShared
import UIKit
struct MainSplitView: View {
@Binding var selection: AppTab
@Environment(\.themePalette) private var palette
@State private var columnVisibility: NavigationSplitViewVisibility = .all
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
.navigationSplitViewColumnWidth(WideLayoutMetrics.sidebarWidth)
} detail: {
detail
}
.navigationSplitViewStyle(.balanced)
.background(palette.background)
// No floating dock in split mode child scroll views should not
// reserve bottom clearance for the phone tab bar.
.environment(\.isTabBarVisible, false)
}
// MARK: - Sidebar
private var sidebar: some View {
VStack(spacing: 0) {
brandHeader
VStack(spacing: 4) {
ForEach(AppTab.allCases, id: \.rawValue) { tab in
WideSidebarRow(
tab: tab,
isSelected: selection == tab
) {
withAnimation(Motion.soft) { selection = tab }
}
}
}
.padding(.horizontal, WideLayoutMetrics.sidebarInset)
Spacer()
devicesFooter
}
.background(palette.background)
}
private var brandHeader: some View {
HStack {
Image("osglogo")
.resizable()
.scaledToFit()
.frame(height: 28)
.accessibilityLabel("OSGKeyboard")
Spacer()
}
.padding(.leading, WideLayoutMetrics.sidebarContentInset)
.padding(.trailing, WideLayoutMetrics.sidebarInset)
.padding(.top, Spacing.lg)
.padding(.bottom, Spacing.md)
}
private var devicesFooter: some View {
Label("home.wide.devices", systemImage: "ipad.and.iphone")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, WideLayoutMetrics.sidebarInset + Spacing.sm)
.padding(.vertical, Spacing.sm)
}
// MARK: - Detail
private var detail: some View {
VStack(spacing: 0) {
MainTabContent(tab: selection)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(selection)
.transition(.opacity)
WideStatusFooter()
}
.background(palette.background)
}
}
// MARK: - Sidebar row
private struct WideSidebarRow: View {
let tab: AppTab
let isSelected: Bool
let action: () -> Void
@Environment(\.themePalette) private var palette
var body: some View {
Button(action: action) {
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage)
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.background(
rowBackground,
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.animation(Motion.quick, value: isSelected)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private var rowBackground: Color {
isSelected ? palette.accentMuted : .clear
}
}
// MARK: - Status footer
/// Quiet bottom strip: engine mode + translation target + Flow readiness.
private struct WideStatusFooter: View {
@Environment(\.themePalette) private var palette
@ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
private var needsCloudSetup: Bool {
!config.isLocalEngine && !config.isConfigured
}
private var needsPermissionSetup: Bool {
micStatus != .granted || speechStatus != .granted
}
private var canManuallyStartSession: Bool {
!flowManager.isActive && !flowManager.isStarting && !needsPermissionSetup
}
var body: some View {
HStack(spacing: Spacing.sm) {
Spacer()
Label(
config.engineMode == "cloud" ? "home.wide.mode.cloud" : "home.wide.mode.local",
systemImage: config.engineMode == "cloud" ? "cloud" : "cpu"
)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(
translationLabel,
systemImage: "translate"
)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
flowStatusControl
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.labelStyle(.titleAndIcon)
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
.padding(.vertical, Spacing.sm)
.animation(Motion.quick, value: config.engineMode)
.animation(Motion.quick, value: config.translationTargetLocaleId)
.animation(Motion.soft, value: flowManager.isActive)
.onAppear { refreshPermissionStatuses() }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshPermissionStatuses()
}
}
@ViewBuilder
private var flowStatusControl: some View {
HStack(spacing: Spacing.xs) {
Circle()
.fill(flowStatusColor)
.frame(width: 6, height: 6)
if needsCloudSetup {
Text("home.flow.notReady")
.foregroundStyle(palette.warning)
} else if flowManager.isUtteranceRecording {
Text("home.flow.recording")
} else if flowManager.isUtteranceProcessing {
Text("home.flow.processing")
} else if flowManager.isActive,
FlowSessionBridge.isHostReady(),
let expires = flowManager.sessionExpiresAt {
Text("home.flow.label")
Text(":")
Text(expires, style: .timer)
.monospacedDigit()
} else {
Text(flowStatusLabel)
}
if flowManager.isActive {
Button {
flowManager.endSession()
} label: {
Text("home.flow.endShort")
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
} else if canManuallyStartSession && !needsCloudSetup {
Button {
flowManager.activateOnForeground()
} label: {
Text("home.flow.startShort")
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
}
}
}
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
}
private var translationLabel: String {
let resolved = TranslationLanguageCatalog.resolve(config.translationTargetLocaleId)
if TranslationLanguageCatalog.isOff(resolved.id) {
return AppL10n.string("keyboard.translation.offMenu", language: config.uiLanguage)
}
return resolved.nativeName
}
private var flowStatusColor: Color {
if !config.isLocalEngine && !config.isConfigured { return palette.warning }
if flowManager.isUtteranceRecording || flowManager.isUtteranceProcessing {
return palette.accent
}
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
if flowManager.isStarting { return palette.accent }
if flowManager.isActive { return palette.warning }
return palette.textTertiary
}
private var flowStatusLabel: LocalizedStringKey {
if !config.isLocalEngine && !config.isConfigured {
return "home.flow.notReady"
}
if flowManager.isStarting {
return "home.flow.starting"
}
if flowManager.isUtteranceRecording {
return "home.flow.recording"
}
if flowManager.isUtteranceProcessing {
return "home.flow.processing"
}
if flowManager.isActive, FlowSessionBridge.isHostReady() {
return "home.flow.label"
}
if flowManager.isActive {
return "home.flow.notReady"
}
return "home.flow.inactive"
}
}
+25
View File
@@ -0,0 +1,25 @@
// MainTabContent.swift
// OSGKeyboard · Main App
//
// Shared tab destination switcher used by both the phone dock and the iPad
// split-view detail column.
import SwiftUI
import OSGKeyboardShared
struct MainTabContent: View {
let tab: AppTab
var body: some View {
switch tab {
case .keyboard:
HomeView()
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
case .settings:
SettingsView(presentation: .tab)
}
}
}
+30 -25
View File
@@ -6,47 +6,52 @@ import OSGKeyboardShared
struct MainTabView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var flowManager: FlowSessionManager
@State private var tab: AppTab = .keyboard
@State private var isTabBarHidden = false
private var usesSplitLayout: Bool {
horizontalSizeClass == .regular
}
var body: some View {
Group {
if usesSplitLayout {
MainSplitView(selection: $tab)
} else {
phoneTabLayout
}
}
.background(palette.background)
.ignoresSafeArea(.keyboard, edges: .bottom)
}
// MARK: - Phone layout
private var phoneTabLayout: some View {
ZStack(alignment: .bottom) {
palette.background.ignoresSafeArea()
Group {
switch tab {
case .keyboard:
HomeView()
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
case .settings:
SettingsView(presentation: .tab)
MainTabContent(tab: tab)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(\.isTabBarVisible, !isTabBarHidden)
.safeAreaInset(edge: .bottom, spacing: 0) {
if !isTabBarHidden {
Color.clear.frame(height: 88)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(\.isTabBarVisible, !isTabBarHidden)
.safeAreaInset(edge: .bottom, spacing: 0) {
if !isTabBarHidden {
Color.clear.frame(height: 88)
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
withAnimation(Motion.quick) {
isTabBarHidden = hidden
}
}
}
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
withAnimation(Motion.quick) {
isTabBarHidden = hidden
}
}
if !isTabBarHidden {
MinimalTabBar(selection: $tab)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
// Keep home card/input/tab layout fixed when system keyboard appears.
// Let the keyboard overlay the content instead of pushing it.
.ignoresSafeArea(.keyboard, edges: .bottom)
}
}
+67 -34
View File
@@ -1,12 +1,13 @@
// OnboardingView.swift
// OSGKeyboard · Main App
//
// Five-step onboarding:
// Six-step onboarding:
// 1) Welcome
// 2) Microphone permission
// 3) Speech recognition permission
// 4) Enable keyboard + Allow Full Access
// 5) Engine / API setup
// 5) Engine / ASR setup
// 6) Polish LLM setup
import SwiftUI
import OSGKeyboardShared
@@ -18,8 +19,9 @@ private enum OnboardingPage: Int, CaseIterable {
case speech
case keyboard
case api
case polish
static let count = 5
static let count = 6
}
struct OnboardingView: View {
@@ -61,6 +63,8 @@ struct OnboardingView: View {
EnableKeyboardPage()
case .api:
APISetupPage(config: config)
case .polish:
PolishSetupPage(config: config)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -234,14 +238,14 @@ struct OnboardingView: View {
}
}
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue }
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.polish.rawValue }
private var canAdvance: Bool {
switch currentPage {
case .welcome, .keyboard:
return !isLastPage || onboardingCompleteReady
case .api:
case .welcome, .keyboard, .api:
return !isLastPage || onboardingCompleteReady
case .polish:
return onboardingCompleteReady
case .microphone:
return micStatus != .undetermined
case .speech:
@@ -250,9 +254,8 @@ struct OnboardingView: View {
}
private var onboardingCompleteReady: Bool {
// v0.2.0: the local engine uses iOS `SpeechAnalyzer`, which is
// always available no model download gate. The cloud engine
// still requires base URL + API key + model.
// Local engine: built-in polish satisfies `isPolishConfigured`.
// Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready.
if config.isLocalEngine { return true }
return config.isConfigured
}
@@ -364,7 +367,6 @@ private struct OnboardingTitleBlock: View {
// MARK: - Welcome
private struct WelcomePage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var logoAppeared = false
var body: some View {
@@ -386,26 +388,13 @@ private struct WelcomePage: View {
}
OnboardingTitleBlock(
title: "onboarding.welcome.tagline",
subtitle: "onboarding.welcome.subtitle",
secondarySubtitle: "onboarding.welcome.subtitle2"
title: "onboarding.welcome.tagline"
)
.opacity(logoAppeared ? 1 : 0)
.offset(y: logoAppeared ? 0 : 10)
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
.animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared)
if let url = LegalLinks.privacyPolicyURL {
Link(destination: url) {
Text("legal.privacyPolicy")
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
}
.padding(.top, Spacing.xxxl)
.opacity(logoAppeared ? 1 : 0)
.animation(.easeOut(duration: 0.45).delay(0.28), value: logoAppeared)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -708,7 +697,7 @@ private struct EnableKeyboardPage: View {
}
}
// MARK: - API setup
// MARK: - Engine / ASR setup
private struct APISetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@@ -728,16 +717,60 @@ private struct APISetupPage: View {
.padding(.horizontal, Spacing.lg)
if config.engineMode == "cloud" {
ProviderPickerSection(config: config)
.padding(.horizontal, Spacing.lg)
APISettingsCard(config: config)
.padding(.horizontal, Spacing.lg)
VStack(spacing: 0) {
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
.padding(.horizontal, Spacing.lg)
} else {
// Local engine: built-in ASR + built-in polish (no API card).
LocalModelsGroup(config: config)
.padding(.horizontal, Spacing.lg)
Text("onboarding.api.localModels.hint")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
}
}
.padding(.bottom, Spacing.xxxl)
}
}
}
// MARK: - Polish LLM setup
private struct PolishSetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
VStack(spacing: Spacing.lg) {
OnboardingHeroIcon(systemName: "wand.and.stars", circleSize: 72, iconSize: 30)
.padding(.top, Spacing.xl)
OnboardingTitleBlock(
title: "onboarding.polish.title",
subtitle: "onboarding.polish.subtitle"
)
.padding(.horizontal, Spacing.lg)
if config.isLocalEngine {
Text("onboarding.polish.localHint")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
}
VStack(spacing: 0) {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
.padding(.horizontal, Spacing.lg)
}
.padding(.bottom, Spacing.xxxl)
}
@@ -64,8 +64,7 @@ struct OpenSourceLicensesView: View {
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, 10)
.settingsListRow()
.contentShape(Rectangle())
}
}
@@ -30,8 +30,7 @@ struct PersonalDictionaryEntrySheet: View {
.focused($termFocused)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
@@ -1,99 +0,0 @@
// PersonalDictionaryICloudSyncRow.swift
// OSGKeyboard · Main App
//
// Settings-row toggle for mirroring the personal dictionary through
// iCloud Key-Value Store. Lives in Settings .
import SwiftUI
import OSGKeyboardShared
@MainActor
struct PersonalDictionaryICloudSyncRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var isEnabled: Bool = AppGroupStore().personalDictionaryICloudSyncEnabled
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
private let store = AppGroupStore()
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Toggle(isOn: toggleBinding) {
Text("settings.personalDictionary.iCloudSync.settingsTitle")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.disabled(isApplyingToggle)
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption2)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Spacing.xxs)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.onAppear { reloadFromStore() }
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
if newValue {
enableSync()
} else {
disableSync()
}
}
)
}
private func reloadFromStore() {
isEnabled = store.personalDictionaryICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await PersonalDictionaryCloudSync.shared.enableSync()
reloadFromStore()
} catch let error as PersonalDictionaryCloudSyncError {
isEnabled = false
syncErrorMessage = localizedSyncError(error)
} catch {
isEnabled = false
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
}
}
private func disableSync() {
PersonalDictionaryCloudSync.shared.disableSync()
isEnabled = false
syncErrorMessage = nil
}
private func localizedSyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
switch error {
case .payloadTooLarge:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
}
}
}
@@ -170,8 +170,7 @@ struct PersonalDictionaryView: View {
.font(.caption.weight(.semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
+56 -35
View File
@@ -8,79 +8,100 @@ struct ProviderPickerSection: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var role: CloudProviderRole = .polish
/// `false`
var showsSurface: Bool = true
private var selectedProviderId: String {
role == .asr ? config.asrProviderId : config.providerId
}
private var visiblePresets: [LLMProvider] {
role == .asr
? LLMProvider.asrSelectablePresets
: LLMProvider.userSelectablePresets
}
private var selectedProvider: LLMProvider {
visiblePresets.first(where: { $0.id == selectedProviderId })
?? LLMProvider.provider(id: selectedProviderId)
}
var body: some View {
// v0.2.1 follow-up: filter out presets marked as
// `isUserSelectable == false` (DeepSeek is local-engine only).
let visiblePresets = LLMProvider.userSelectablePresets
VStack(spacing: 0) {
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in
Button {
select(provider)
} label: {
row(provider, selected: provider.id == config.providerId)
}
.buttonStyle(.plain)
if index < visiblePresets.count - 1 {
Divider().background(palette.divider)
// Menu label
// label
SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) {
Menu {
ForEach(visiblePresets, id: \.id) { provider in
Button {
select(provider)
} label: {
let name = ProviderDisplayName.name(for: provider.id)
if provider.id == selectedProviderId {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
providerChip
}
.buttonStyle(.plain)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
private func select(_ provider: LLMProvider) {
withAnimation(Motion.quick) {
config.apply(preset: provider)
switch role {
case .polish:
config.apply(preset: provider)
case .asr:
config.applyAsr(preset: provider)
}
}
}
@ViewBuilder
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
/// logo + +
private var providerChip: some View {
HStack(spacing: Spacing.sm) {
providerMark(provider, selected: selected)
providerMark(selectedProvider)
Text(ProviderDisplayName.name(for: provider.id))
Text(ProviderDisplayName.name(for: selectedProvider.id))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
if provider.supportsPersonalDictionaryCloudASR {
if selectedProvider.supportsPersonalDictionaryCloudASR {
personalDictionaryBadge
}
Spacer(minLength: Spacing.xs)
if selected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(palette.accent)
}
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
@ViewBuilder
private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View {
private func providerMark(_ provider: LLMProvider) -> some View {
ZStack {
Circle()
.fill(selected ? palette.accentMuted : palette.surfaceElevated)
.fill(palette.accentMuted)
.frame(width: 32, height: 32)
if let asset = ProviderLogo.assetName(for: provider.id) {
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundStyle(selected ? palette.accent : palette.textPrimary)
.foregroundStyle(palette.accent)
} else {
Text(String(provider.name.prefix(1)))
.font(TypeStyle.caption)
.foregroundStyle(selected ? palette.accent : palette.textSecondary)
.foregroundStyle(palette.accent)
}
}
}
@@ -0,0 +1,26 @@
// SettingsCardChrome.swift
// OSGKeyboard · Main App
//
// Shared rounded surface chrome for settings list cards.
import SwiftUI
import OSGKeyboardShared
struct SettingsSurfaceCardModifier: ViewModifier {
@Environment(\.themePalette) private var palette: ThemePalette
let enabled: Bool
func body(content: Content) -> some View {
if enabled {
content
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} else {
content
}
}
}
+57 -7
View File
@@ -14,6 +14,9 @@ struct SettingsICloudSyncRow: View {
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
@State private var isSyncingNow = false
/// Transient success flag: shows a brief "" confirmation so a fast
/// sync gives visible feedback instead of a spinner that flashes once.
@State private var showSyncedConfirmation = false
private let store = AppGroupStore()
@@ -38,13 +41,21 @@ struct SettingsICloudSyncRow: View {
syncNow()
} label: {
HStack(spacing: Spacing.xs) {
Text(syncButtonTitleKey)
.font(TypeStyle.caption)
Spacer(minLength: 0)
if isSyncingNow {
ProgressView()
.controlSize(.small)
.controlSize(.mini)
} else if showSyncedConfirmation {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .semibold))
}
Text("settings.appSettings.iCloudSync.syncNow")
.font(TypeStyle.caption)
}
// Fill the row so the whole strip is tappable, not just
// the caption glyphs (previously easy to miss).
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(palette.accent)
@@ -60,15 +71,18 @@ struct SettingsICloudSyncRow: View {
.padding(.top, Spacing.xxs)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.settingsListRow(alignment: .leading)
.onAppear { reloadFromStore() }
.onReceive(
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
) { _ in
reloadFromStore()
}
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
}
private var toggleBinding: Binding<Bool> {
@@ -85,6 +99,16 @@ struct SettingsICloudSyncRow: View {
)
}
private var syncButtonTitleKey: LocalizedStringKey {
if isSyncingNow {
return "settings.appSettings.iCloudSync.syncing"
}
if showSyncedConfirmation {
return "settings.appSettings.iCloudSync.synced"
}
return "settings.appSettings.iCloudSync.syncNow"
}
private func reloadFromStore() {
isEnabled = store.settingsICloudSyncEnabled
}
@@ -95,6 +119,15 @@ struct SettingsICloudSyncRow: View {
Task {
do {
try await CloudSyncContext.shared.settingsSyncService.enableSync()
do {
try await CloudSyncContext.shared.dictionarySyncService.enableSync()
} catch let error as PersonalDictionaryCloudSyncError {
CloudSyncContext.shared.settingsSyncService.disableSync()
isEnabled = false
syncErrorMessage = localizedDictionarySyncError(error)
isApplyingToggle = false
return
}
reloadFromStore()
} catch let error as SettingsCloudSyncError {
isEnabled = false
@@ -114,15 +147,23 @@ struct SettingsICloudSyncRow: View {
}
private func syncNow() {
guard !isSyncingNow else { return }
isSyncingNow = true
showSyncedConfirmation = false
syncErrorMessage = nil
Task {
do {
try await CloudSyncContext.shared.syncNow()
isSyncingNow = false
withAnimation { showSyncedConfirmation = true }
// Auto-dismiss the confirmation so the label returns to
// its default "" state.
try? await Task.sleep(nanoseconds: 1_800_000_000)
withAnimation { showSyncedConfirmation = false }
} catch {
isSyncingNow = false
syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic")
}
isSyncingNow = false
}
}
@@ -132,4 +173,13 @@ struct SettingsICloudSyncRow: View {
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
}
}
private func localizedDictionarySyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
switch error {
case .payloadTooLarge:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
}
}
}
@@ -0,0 +1,316 @@
// SettingsProviderControls.swift
// OSGKeyboard · Main App
//
// OpenLess-style setting rows for provider credentials and tools. Compact iOS
// stacks label above control; regular-width iPad keeps label/control in one row.
import SwiftUI
import OSGKeyboardShared
struct SettingsProviderRow<Content: View>: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let title: String
@ViewBuilder var content: () -> Content
var body: some View {
if horizontalSizeClass == .compact {
VStack(alignment: .leading, spacing: 8) {
label
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
.settingsListRow()
} else {
HStack(alignment: .center, spacing: Spacing.lg) {
label
.frame(width: 150, alignment: .leading)
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
.settingsListRow()
}
}
private var label: some View {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
}
struct SettingsCredentialRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
let placeholder: String
@Binding var text: String
var isSecret: Bool = false
var isMonospaced: Bool = false
var defaultValue: String?
var trailing: AnyView?
@State private var revealed = false
var body: some View {
SettingsProviderRow(title: title) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: Spacing.xs) {
input
if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
iconButton(systemName: "checkmark", label: "settings.provider.fillDefault") {
text = defaultValue
}
}
if let trailing {
trailing
}
if isSecret {
iconButton(systemName: revealed ? "eye.slash" : "eye", label: revealed ? "api.key.hide" : "api.key.show") {
revealed.toggle()
}
}
}
}
}
}
@ViewBuilder
private var input: some View {
Group {
if isSecret && !revealed {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
}
}
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(isMonospaced ? TypeStyle.mono : TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 38)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 38, height: 38)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
}
// MARK: - Editable model field + fetch icon
/// Model id: type freely, or fetch then pick from the trailing dropdown.
struct SettingsModelPickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: String
let placeholder: String
@Binding var model: String
let fetchModels: () async throws -> [String]
@State private var models: [String] = []
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
private let controlHeight: CGFloat = 38
var body: some View {
SettingsProviderRow(title: title) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: Spacing.xs) {
comboField
refreshButton
}
if let message {
Text(message)
.font(TypeStyle.caption2)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
}
}
}
}
/// Editable model id + trailing menu chevron in one well (same chrome as
/// Mac `MacPickerFieldBox`). Chevron is overlaid so it stays inside the border.
private var comboField: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let chevronWidth: CGFloat = 28
return ZStack(alignment: .trailing) {
TextField(placeholder, text: $model)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.mono)
.foregroundStyle(palette.textPrimary)
.padding(.leading, Spacing.sm)
.padding(.trailing, chevronWidth + Spacing.sm)
.frame(maxWidth: .infinity, minHeight: controlHeight, alignment: .leading)
.background(palette.surfaceElevated, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
Menu {
if models.isEmpty {
Button(AppL10n.string("settings.provider.modelsEmptyHint")) {}
.disabled(true)
} else {
ForEach(models, id: \.self) { modelId in
Button {
model = modelId
message = AppL10n.format("settings.provider.modelSelected", modelId)
failed = false
} label: {
if modelId == model {
Label(modelId, systemImage: "checkmark")
} else {
Text(modelId)
}
}
}
}
} label: {
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(width: chevronWidth, height: controlHeight)
.contentShape(Rectangle())
}
.padding(.trailing, Spacing.sm)
.accessibilityLabel(AppL10n.string("settings.provider.selectModel"))
}
}
private var refreshButton: some View {
Button {
Task { await runFetchModels() }
} label: {
Group {
if isRunning {
ProgressView()
.controlSize(.mini)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
}
}
.frame(width: controlHeight, height: controlHeight)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.disabled(isRunning)
.accessibilityLabel(AppL10n.string("settings.provider.fetchModels"))
}
@MainActor
private func runFetchModels() async {
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: AppL10n.string("settings.provider.loadingModels"),
loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty"),
currentModel: model,
fetchModels: fetchModels
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
}
}
}
// MARK: - Connection validate only
struct SettingsProviderToolsRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let validate: () async throws -> Void
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
var body: some View {
HStack(alignment: .center, spacing: Spacing.sm) {
Text(AppL10n.string("settings.provider.tools"))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
if isRunning {
ProgressView()
.controlSize(.mini)
} else if let message {
Text(message)
.font(TypeStyle.caption2)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
.truncationMode(.tail)
}
Spacer(minLength: 0)
Button {
Task { await runValidate() }
} label: {
Text(AppL10n.string("settings.provider.validate"))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.md)
.frame(minHeight: 34)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.disabled(isRunning)
}
.settingsListRow()
}
@MainActor
private func runValidate() async {
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runValidate(
runningMessage: AppL10n.string("api.test.running"),
successMessage: AppL10n.string("api.test.success"),
validate: validate
)
message = outcome.message
failed = outcome.failed
}
}
+33 -36
View File
@@ -43,20 +43,13 @@ struct SettingsView: View {
dictionaryAndPolishSection
flowSessionSection
engineSection
// v0.2.1: hide provider/api card when the
// local engine is active regardless of the
// cloud-polish toggle. Local mode is
// contractually ASR-only, so provider/model/
// base URL/API key controls have no use
// and exposing them invites the user to fill
// out a DeepSeek key they can't use.
if config.engineMode == "cloud" {
providerSection
apiSection
asrSettingsSection
}
if config.engineMode == "local" {
localEngineSettingsSection
}
polishSettingsSection
if presentation == .tab {
footerLinks
}
@@ -120,9 +113,7 @@ struct SettingsView: View {
}
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
Divider().background(palette.divider)
@@ -227,10 +218,6 @@ struct SettingsView: View {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
Divider().background(palette.divider)
PersonalDictionaryICloudSyncRow()
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
@@ -253,22 +240,38 @@ struct SettingsView: View {
}
}
private var providerSection: some View {
private var polishSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.provider.title")
ProviderPickerSection(config: config)
sectionHeader("settings.polishProvider.title")
cloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
}
}
// MARK: - API
private var apiSection: some View {
private var asrSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.api.title")
APISettingsCard(config: config)
sectionHeader("settings.asrProvider.title")
cloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
}
}
@ViewBuilder
private func cloudProviderSettingsCard<Content: View>(
@ViewBuilder content: () -> Content
) -> some View {
VStack(spacing: 0) {
content()
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
}
// MARK: - Language helpers
/// Falls back to a static list while dynamic locales are loading.
@@ -337,8 +340,7 @@ struct SettingsView: View {
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
// MARK: - Footer links (tab settings only)
@@ -360,8 +362,7 @@ struct SettingsView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -417,8 +418,7 @@ struct SettingsView: View {
MaterialIcon(name: .openInNew, size: 18)
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -438,8 +438,7 @@ struct SettingsView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
.contentShape(Rectangle())
}
@@ -581,8 +580,7 @@ private struct PickerRow: View {
}
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
private var currentLabel: String {
@@ -639,8 +637,7 @@ private struct LocalePickerRow: View {
}
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
private func label(for localeId: String) -> String {
+1 -4
View File
@@ -69,13 +69,10 @@ struct TranslationPickerRow: View {
}
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.settingsListRow()
}
}
// MARK: - Selection plumbing
/// Currently selected id the picker always reads
/// `translationTargetLocaleId` directly (the previous
/// `translationEnabled` boolean is now derived from it).
+1 -1
View File
@@ -1,4 +1,4 @@
/* Permission prompts — English */
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard transcribes your voice with on-device speech recognition by default. If you explicitly switch to a cloud engine in Settings, recordings are sent to the ASR provider you configure.";
+54 -19
View File
@@ -4,9 +4,7 @@
/* Onboarding */
"onboarding.progress" = "Step %1$d of %2$d";
"onboarding.welcome.tagline" = "Speak instead of type";
"onboarding.welcome.subtitle" = "Open-source voice keyboard";
"onboarding.welcome.subtitle2" = "We never read or upload anything you type.";
"onboarding.welcome.tagline" = "Speak it. Its typed.";
"onboarding.permission.preface" = "iOS will ask for two permissions — please allow both.";
"onboarding.permission.mic.title" = "Microphone";
"onboarding.permission.mic.body" = "Records your voice.";
@@ -29,7 +27,10 @@
"onboarding.enable.step3.suffix" = "and select OSGKeyboard";
"onboarding.enable.openSettings" = "Open Settings";
"onboarding.api.title" = "Choose Engine";
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition and built-in polish — no API key needed.";
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition — no API key needed.";
"onboarding.polish.title" = "Text polish (LLM)";
"onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider.";
"onboarding.polish.localHint" = "Built-in polish is included. Add your own API key here to override.";
"settings.onboarding.replay" = "Restart permission setup";
/* Common navigation */
@@ -44,6 +45,7 @@
"common.delete" = "Delete";
"common.space" = "Space";
"common.newline" = "Return";
"common.send" = "Send";
/* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "On-device transcription";
@@ -92,17 +94,45 @@
"settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Recognition method";
"settings.engine.subtitle" = "Local: on-device ASR only. Cloud: ASR + polish via your API.";
"settings.engine.local.title" = "On-device recognition";
"settings.engine.title" = "Speech transcription method";
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
"settings.engine.local.title" = "On-device transcription";
"settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
"settings.engine.cloud.title" = "Cloud recognition & polish";
"settings.engine.cloud.subtitle" = "Cloud ASR (with your dictionary) + API polish. Audio is sent to your provider.";
"settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
"settings.engine.cloud.title" = "Cloud transcription";
"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
"settings.engine.cloud.badge" = "Cloud engine";
"settings.provider.title" = "Provider";
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"settings.polishProvider.title" = "Text polish (LLM)";
"settings.polishProvider.subtitle" = "Cleans up the transcript after recognition. Independent from the ASR provider.";
"settings.polishApi.title" = "Polish API";
"settings.asrProvider.title" = "Speech recognition (ASR)";
"settings.asrProvider.subtitle" = "Transcribes your audio in cloud mode. Can differ from the polish provider.";
"settings.asrApi.title" = "ASR API";
"settings.asr.model" = "ASR model";
"settings.asr.testConnection" = "Test ASR";
"settings.provider.supplier" = "Provider";
"settings.provider.tools" = "Connection check";
"settings.provider.validate" = "Validate";
"settings.provider.fetchModels" = "Fetch models";
"settings.provider.loadingModels" = "Loading models…";
"settings.provider.modelsLoaded" = "%lld models loaded";
"settings.provider.selectModel" = "Select model";
"settings.provider.modelSelected" = "Selected %@";
"settings.provider.modelsEmptyHint" = "Tap refresh to load models";
"settings.provider.fillDefault" = "Fill default";
"settings.provider.thinking" = "Thinking";
"settings.provider.thinkingSubtitle" = "Slower, higher quality — recommended off";
"settings.provider.thinkingOn" = "On";
"settings.provider.thinkingOff" = "Off";
"settings.provider.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning.";
"settings.provider.note" = "Note";
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
"settings.asr.volcengine.resourceId" = "Resource ID";
"settings.asr.volcengine.note" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "Qwen (DashScope)";
@@ -161,8 +191,8 @@
"settings.privacy.fullAccess.title" = "About Full Access";
"settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard.";
"settings.privacy.cloud.body" = "In Cloud polish mode, transcribed text is sent to the API endpoint you configure (e.g. OpenAI or your own server). OSGKeyboard does not operate servers and does not store transcripts in the cloud.";
"settings.privacy.cloud.alert.title" = "Third-party API";
"settings.privacy.cloud.alert.message" = "Cloud polish sends transcribed text to the third-party API you configure. OSGKeyboard never stores data on our servers. Continue?";
"settings.privacy.cloud.alert.title" = "Audio leaves your device";
"settings.privacy.cloud.alert.message" = "The cloud engine uploads your voice recordings to the third-party ASR provider you configure, and sends the transcript to its API for polish. That provider's privacy policy applies. OSGKeyboard never stores data on our servers. Continue?";
"settings.link.support" = "Help & Feedback";
"settings.link.github" = "GitHub";
"settings.support.footer" = "OSGKeyboard is open source. Report bugs and share ideas on GitHub.";
@@ -287,6 +317,8 @@
"home.flow.label" = "Ready";
"home.flow.inactive" = "Voice session inactive";
"home.flow.notReady" = "Not ready";
"home.flow.recording" = "Recording…";
"home.flow.processing" = "Processing…";
"home.flow.hint" = "Switch to any app and tap the keyboard mic to dictate.";
"home.setup.permission.mic" = "Microphone access is off — voice input won't work.";
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
@@ -303,10 +335,11 @@
"home.flow.startShort" = "Start";
"home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…";
"home.stats.dictationDuration" = "Dictation time";
"home.stats.dictationCharacters" = "Dictation chars";
"home.stats.translationCharacters" = "Translation chars";
"home.stats.dictionaryEntries" = "Dictionary";
"home.wide.tagline" = "Voice dictation, anywhere.";
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
"home.wide.mode.cloud" = "Cloud";
"home.wide.mode.local" = "On-device";
"home.wide.devices" = "iPhone & iPad";
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+";
"home.engine.warming" = "Loading ASR model into memory…";
"home.engine.downloading" = "Downloading ASR model…";
@@ -368,10 +401,12 @@
"settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again.";
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
"settings.iCloudSync.title" = "iCloud Sync";
"settings.appSettings.iCloudSync.title" = "iCloud Sync";
"settings.appSettings.iCloudSync.subtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud. API keys use your private iCloud Keychain.";
"settings.iCloudSync.title" = "Cross-Device iCloud Sync";
"settings.appSettings.iCloudSync.title" = "Cross-Device iCloud Sync";
"settings.appSettings.iCloudSync.subtitle" = "Sync settings, history, and API keys across devices via iCloud.";
"settings.appSettings.iCloudSync.syncNow" = "Sync Now";
"settings.appSettings.iCloudSync.syncing" = "Syncing…";
"settings.appSettings.iCloudSync.synced" = "Synced";
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
/* v0.3.0: Polish intensity */
+1 -1
View File
@@ -1,4 +1,4 @@
/* 权限说明 — 简体中文 */
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,并在语音会话运行期间保持后台音频会话。";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 使用设备端语音识别将你的语音转为文字。音频仅在设备上处理,不会上传用于转写。";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
+55 -20
View File
@@ -4,9 +4,7 @@
/* Onboarding */
"onboarding.progress" = "第 %1$d / %2$d 步";
"onboarding.welcome.tagline" = "能说就不打字";
"onboarding.welcome.subtitle" = "开源语音输入法";
"onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。";
"onboarding.welcome.tagline" = "开口即文字。";
"onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。";
"onboarding.permission.mic.title" = "麦克风";
"onboarding.permission.mic.body" = "用于录制语音。";
@@ -29,7 +27,10 @@
"onboarding.enable.step3.suffix" = ",选中 OSGKeyboard";
"onboarding.enable.openSettings" = "去设置";
"onboarding.api.title" = "选择语音转文字 AI 引擎";
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别与内置润色,无需填写 API Key。";
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别,无需填写 API Key。";
"onboarding.polish.title" = "文本润色(LLM";
"onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
"onboarding.polish.localHint" = "已包含内置润色。在此填入 API Key 可替换为自定义模型。";
"settings.onboarding.replay" = "重新开始权限引导";
/* Common navigation */
@@ -43,7 +44,8 @@
"common.clear" = "清空";
"common.delete" = "删除";
"common.space" = "空格";
"common.newline" = "换行";
"common.newline" = "回车";
"common.send" = "发送";
/* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "支持本地转写";
@@ -92,17 +94,45 @@
"settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "识别方式";
"settings.engine.subtitle" = "本地:端侧识别,仅转录;云端:通过 API 润色。";
"settings.engine.local.title" = "本地识别";
"settings.engine.title" = "语音转写方式";
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
"settings.engine.local.title" = "本地转写";
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
"settings.engine.cloud.title" = "云端识别与润色";
"settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务";
"settings.engine.local.legacy" = "通过系统内置服务语音转写";
"settings.engine.cloud.title" = "云端转写";
"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
"settings.engine.cloud.badge" = "云端引擎";
"settings.provider.title" = "云端引擎";
"settings.provider.personalDictionaryBadge" = "个性词库";
"settings.provider.subtitle" = "选择 LLM 提供商。";
"settings.polishProvider.title" = "文本润色(LLM";
"settings.polishProvider.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
"settings.polishApi.title" = "润色接口";
"settings.asrProvider.title" = "语音转写(ASR";
"settings.asrProvider.subtitle" = "云端模式下负责听写转文字,可与润色模型分开配置。";
"settings.asrApi.title" = "转写接口";
"settings.asr.model" = "ASR 模型";
"settings.asr.testConnection" = "测试转写";
"settings.provider.supplier" = "供应商";
"settings.provider.tools" = "连接检查";
"settings.provider.validate" = "验证";
"settings.provider.fetchModels" = "拉取模型";
"settings.provider.loadingModels" = "正在拉取模型…";
"settings.provider.modelsLoaded" = "已拉取 %lld 个模型";
"settings.provider.selectModel" = "选择模型";
"settings.provider.modelSelected" = "已选择 %@";
"settings.provider.modelsEmptyHint" = "先点右侧刷新拉取模型";
"settings.provider.fillDefault" = "填入默认值";
"settings.provider.thinking" = "思考";
"settings.provider.thinkingSubtitle" = "速度更慢、质量更高,建议关闭";
"settings.provider.thinkingOn" = "开启";
"settings.provider.thinkingOff" = "关闭";
"settings.provider.thinkingHint" = "默认关闭。仅在需要更慢、更深的推理时开启。";
"settings.provider.note" = "提示";
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
"settings.asr.volcengine.resourceId" = "Resource ID";
"settings.asr.volcengine.note" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "通义千问";
@@ -161,8 +191,8 @@
"settings.privacy.fullAccess.title" = "关于完全访问";
"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。";
"settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。";
"settings.privacy.cloud.alert.title" = "第三方 API";
"settings.privacy.cloud.alert.message" = "云端润色会把转写文字发到你配置的第三方 API。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
"settings.privacy.cloud.alert.title" = "音频将离开你的设备";
"settings.privacy.cloud.alert.message" = "云端引擎会把你的语音录音上传到你配置的第三方识别服务,并把转写文字发送到其 API 进行润色,适用该服务商的隐私政策。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
"settings.link.support" = "帮助与反馈";
"settings.link.github" = "GitHub";
"settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。";
@@ -286,6 +316,8 @@
"home.flow.label" = "就绪";
"home.flow.inactive" = "语音会话未启动";
"home.flow.notReady" = "未就绪";
"home.flow.recording" = "录音中…";
"home.flow.processing" = "处理中…";
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
@@ -302,10 +334,11 @@
"home.flow.startShort" = "开启";
"home.preview.label" = "输入测试";
"home.preview.placeholder" = "点这里试试键盘";
"home.stats.dictationDuration" = "听写时长";
"home.stats.dictationCharacters" = "听写字数";
"home.stats.translationCharacters" = "翻译字数";
"home.stats.dictionaryEntries" = "个性词库";
"home.wide.tagline" = "随处语音听写";
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
"home.wide.mode.cloud" = "云端";
"home.wide.mode.local" = "本机";
"home.wide.devices" = "iPhone 与 iPad";
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本";
"home.engine.warming" = "正在加载语音识别模型…";
"home.engine.downloading" = "正在下载语音识别模型…";
@@ -367,10 +400,12 @@
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
"settings.iCloudSync.title" = "iCloud 同步";
"settings.appSettings.iCloudSync.title" = "iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。";
"settings.iCloudSync.title" = "跨设备iCloud 同步";
"settings.appSettings.iCloudSync.title" = "跨设备iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 设备同步设置、历史记录、API Key";
"settings.appSettings.iCloudSync.syncNow" = "立即同步";
"settings.appSettings.iCloudSync.syncing" = "同步中…";
"settings.appSettings.iCloudSync.synced" = "已同步";
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
/* v0.3.0: 润色强度 */
+28 -1
View File
@@ -57,7 +57,11 @@ public final class KeyboardViewController: UIInputViewController {
// Voice-first keyboard hide the misleading "English" subtitle in Settings.
primaryLanguage = "mis"
OSGLog.keyboardExt.info("viewDidLoad — extension booted")
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
// 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()
installKeyboardHeight()
configureDictationBehavior()
@@ -84,6 +88,7 @@ public final class KeyboardViewController: UIInputViewController {
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
state.debugHasFullAccess = hasFullAccess
flowCoordinator.refreshSessionState()
flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup()
@@ -100,6 +105,12 @@ public final class KeyboardViewController: UIInputViewController {
super.viewDidAppear(animated)
disableSystemGestureDelays()
keyboardHeightConstraint?.constant = targetKeyboardHeight
refreshReturnKeyRole()
}
public override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput)
refreshReturnKeyRole()
}
public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
@@ -126,6 +137,7 @@ public final class KeyboardViewController: UIInputViewController {
textInserter = KeyboardTextInserter(
state: state,
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() }
)
@@ -189,6 +201,21 @@ public final class KeyboardViewController: UIInputViewController {
}
}
private func refreshReturnKeyRole() {
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
}
private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole {
switch returnKeyType {
case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall:
return .send
case .default, .next, .done:
return .newline
@unknown default:
return .newline
}
}
// MARK: - System keyboard chrome
private func configureDictationBehavior() {
+51 -10
View File
@@ -4,15 +4,17 @@
// Opens the host app from the keyboard extension.
//
// Reality check (verified against iOS 1826 behaviour):
// `extensionContext.open` is documented for Today widgets only; for a
// keyboard extension it resolves `false`, so we do not use it.
// The deprecated `openURL:` selector hack was disabled in iOS 18
// ("BUG IN CLIENT OF UIKIT migrate to open(_:options:completionHandler:)").
// The still-working path is: walk the responder chain to `UIApplication`
// and call the non-deprecated `open(_:options:completionHandler:)`. This
// requires Full Access and grows less reliable on newer iOS, so we report
// the *real* success from the completion handler instead of assuming it
// worked callers degrade to on-keyboard guidance when it returns false.
// Primary path: walk the responder chain to `UIApplication` and call the
// non-deprecated `open(_:options:completionHandler:)`. This requires Full
// Access and grows less reliable on newer iOS, so we report the *real*
// success from the completion handler instead of assuming it worked.
// Fallback: `extensionContext.open`. Historically documented for Today
// widgets only (and it used to resolve `false` for keyboards), but it is
// the Apple-documented API for extensions to open URLs and ships in
// production keyboards on current iOS worth trying before giving up.
// When both paths fail, callers degrade to on-keyboard guidance.
import UIKit
@@ -27,13 +29,52 @@ enum HostAppLauncher {
while let current = responder {
if let application = current as? UIApplication {
application.open(url, options: [:]) { success in
Task { @MainActor in completion(success) }
Task { @MainActor in
if success {
completion(true)
} else {
openViaExtensionContext(url: url, from: controller, completion: completion)
}
}
}
return
}
responder = current.next
}
// No `UIApplication` in the responder chain cannot open the host app.
completion(false)
// No `UIApplication` in the responder chain try the extension context.
openViaExtensionContext(url: url, from: controller, completion: completion)
}
@MainActor
private static func openViaExtensionContext(
url: URL,
from controller: KeyboardViewController,
completion: @escaping @MainActor (Bool) -> Void
) {
guard let context = controller.extensionContext else {
completion(false)
return
}
// `NSExtensionContext.open` from keyboards has historically been
// flaky about ever invoking its completion on some iOS versions.
// Callers rely on a real answer to fail fast (instead of spinning
// the 30 s start watchdog), so race the callback against a timeout
// and report the first result only.
var didComplete = false
let finish: @MainActor (Bool) -> Void = { success in
guard !didComplete else { return }
didComplete = true
completion(success)
}
Task { @MainActor in
// 1.5 s: long enough for a real open to call back, short enough
// that a dead completion degrades to on-keyboard guidance before
// the user gives up staring at nothing.
try? await Task.sleep(nanoseconds: 1_500_000_000)
finish(false)
}
context.open(url) { success in
Task { @MainActor in finish(success) }
}
}
}
@@ -44,8 +44,25 @@ final class KeyboardFlowCoordinator {
private var isAwaitingFlowResult = false
private var activeSessionId: UUID?
private var currentUtteranceId: UUID?
/// Utterance whose final result we already inserted (or failed). Prevents
/// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a
/// stale App Group snapshot still says `reason=processing`.
private var lastConsumedUtteranceId: UUID?
/// Utterance we just asked the host to stop. Until the host publishes
/// processing/final state, stale App Group snapshots can still say
/// `reason=recording`; do not re-adopt that utterance as locally active.
private var lastStoppedUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
private var lastAvailabilityTraceSignature = ""
/// When true, `completeFlowStartHandoff` starts recording after the host
/// publishes ready set only for an explicit mic press.
private var recordAfterHandoff = false
/// When true, `startHostReadyWaitIfNeeded` starts recording once ready
/// (mic pressed while session was still warming / mid ready-flap).
private var recordWhenHostReady = false
/// Ignores single-frame "host dead" samples before allowing a cold-start jump
/// from non-press recovery paths.
private var coldStartDebouncer = FlowColdStartDebouncer()
init(
state: KeyboardState,
@@ -116,6 +133,8 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability()
startHostReadyWaitIfNeeded()
// Proactive host auto-launch is disabled (FlowHandoffPolicy): a single
// stale ready snapshot after finalize must never open startflow.
// Only surface "session ended" when the session contract *genuinely*
// dropped (expired / cleared). A transient host-ready flap engine
@@ -139,6 +158,12 @@ final class KeyboardFlowCoordinator {
FlowSessionBridge.reloadFromDisk()
let readySnapshot = FlowSessionBridge.readySnapshot()
activeSessionId = readySnapshot?.sessionId ?? activeSessionId
// If the host is mid-utterance but this extension process lost local
// ownership (jetsam / recreate after app switch), re-adopt it so we
// show red/white instead of a fake orange "starting" state.
adoptHostBusyStateIfNeeded(snapshot: readySnapshot)
let hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
let now = Date().timeIntervalSince1970
if hostReady { lastHostReadyAt = now }
@@ -148,10 +173,20 @@ final class KeyboardFlowCoordinator {
// across cross-process read jitter and anchors this smoothing.
let withinReadyGrace = lastHostReadyAt > 0
&& (now - lastHostReadyAt) <= Self.hostReadyGrace
// 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 hostWarming = !hostReady
&& !hostBusy
&& FlowSessionBridge.isSessionActive()
&& (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace)
state.flowSessionActive = FlowSessionBridge.isSessionActive()
state.debugPendingFlowStart = isPendingFlowStart
state.debugFlowRecording = isFlowRecording
state.debugAwaitingFlowResult = isAwaitingFlowResult
state.debugHasFullAccess = hasFullAccess()
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
phase: state.phase,
micDisabled: state.micDisabled,
@@ -176,16 +211,110 @@ 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 {
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 }
// 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
flowStartDeadline = 0
stopHostReadyWait()
isFlowRecording = true
state.phase = .recording
if state.lastTranscript.isEmpty {
state.lastTranscript = ""
}
if let view = wakeLockView() {
ExtensionScreenWakeLock.acquire(from: view)
}
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 }
activeSessionId = sessionId
currentUtteranceId = busyId
isPendingFlowStart = false
flowStartDeadline = 0
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
state.phase = .processing
if state.lastTranscript.isEmpty {
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
}
startFlowResultWatchdog()
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
default:
break
}
}
/// After insert, a stale `reason=processing` snapshot can bounce the mic
/// back to white loading. When the host is no longer busy, force idle.
private func clearStickyProcessingIfNeeded(hostReady: Bool) {
guard !isAwaitingFlowResult, !isFlowRecording else { return }
guard case .processing = state.phase else { return }
state.phase = .idle
state.lastTranscript = ""
stopFlowWatchdog()
currentUtteranceId = nil
lastStoppedUtteranceId = nil
traceState(
"stickyProcessing.cleared",
extra: hostReady ? "hostReady=1" : "hostReady=0"
)
}
/// Session is live but the ready contract has not landed yet poll
/// quickly instead of sticking on "session inactive" orange.
private func startHostReadyWaitIfNeeded() {
guard !isPendingFlowStart else { return }
guard FlowSessionBridge.isSessionActive() else {
stopHostReadyWait()
if recordWhenHostReady {
// Session gone while waiting escalate to a real cold start.
let shouldRecord = recordWhenHostReady
recordWhenHostReady = false
beginFlowStart(recordAfterHandoff: shouldRecord)
}
return
}
guard !FlowSessionBridge.isHostReady() else {
// Host busy waiting for ready. Do not spin the ready-wait poll.
if let reason = FlowSessionBridge.readySnapshot()?.reason,
reason == .recording || reason == .processing {
stopHostReadyWait()
recordWhenHostReady = false
return
}
if FlowSessionBridge.isHostReady() {
stopHostReadyWait()
finishHostReadyWaitIfNeeded()
return
}
@@ -197,13 +326,49 @@ final class KeyboardFlowCoordinator {
FlowSessionBridge.reloadFromDisk()
self.recomputeMicVoiceAvailability()
if self.state.micVoiceAvailability.isReady {
self.finishHostReadyWaitIfNeeded()
return
}
if self.state.micVoiceAvailability == .recording
|| self.state.micVoiceAvailability == .processing {
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
)
if self.coldStartDebouncer.observe(hostTrulyDead: dead) {
let shouldRecord = self.recordWhenHostReady
self.recordWhenHostReady = false
self.coldStartDebouncer.reset()
self.beginFlowStart(recordAfterHandoff: shouldRecord)
return
}
try? await Task.sleep(nanoseconds: 150_000_000)
}
// Timed out still not ready if the user asked to record, cold-start.
guard let self else { return }
if self.recordWhenHostReady {
let shouldRecord = self.recordWhenHostReady
self.recordWhenHostReady = false
self.beginFlowStart(recordAfterHandoff: shouldRecord)
}
}
}
private func finishHostReadyWaitIfNeeded() {
coldStartDebouncer.reset()
guard recordWhenHostReady else { return }
recordWhenHostReady = false
guard state.micVoiceAvailability.isReady else { return }
startFlowRecording()
traceState("hostReadyWait.recordStarted")
}
private func stopHostReadyWait() {
hostReadyWaitTask?.cancel()
hostReadyWaitTask = nil
@@ -232,9 +397,6 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability()
switch state.micVoiceAvailability {
case .ready:
detectAndStoreAppContext()
startFlowRecording()
case .unavailable(.missingAPIKey):
return
case .unavailable(.noFullAccess):
@@ -242,18 +404,43 @@ final class KeyboardFlowCoordinator {
state.phase = .error(.fullAccessRequired, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
return
case .unavailable(.appGroupUnavailable):
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
case .unavailable(.preparingSession):
return
default:
break
}
let withinReadyGrace = lastHostReadyAt > 0
&& (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
let action = FlowHandoffPolicy.micPressAction(
availability: state.micVoiceAvailability,
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: withinReadyGrace
)
switch action {
case .startRecording:
detectAndStoreAppContext()
beginFlowStart()
case .unavailable(.hostNotReady):
startFlowRecording()
case .waitForHostReady(let recordWhenReady):
detectAndStoreAppContext()
beginFlowStart()
case .recording, .processing:
recordWhenHostReady = recordWhenReady
coldStartDebouncer.reset()
startHostReadyWaitIfNeeded()
traceState(
"pressBegan.waitForHostReady",
extra: recordWhenReady ? "recordWhenReady=1" : "recordWhenReady=0"
)
case .openHostColdStart:
detectAndStoreAppContext()
beginFlowStart(recordAfterHandoff: true)
case .ignore:
return
}
}
@@ -268,19 +455,23 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
lastStoppedUtteranceId = currentUtteranceId
writeCommand(.stopRecording)
debug("pressEnded wrote stop command")
state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
recomputeMicVoiceAvailability()
startFlowResultWatchdog()
recomputeMicVoiceAvailability()
}
func beginFlowStart() {
func beginFlowStart(recordAfterHandoff: Bool = false) {
guard !isPendingFlowStart else {
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
return
}
self.recordAfterHandoff = recordAfterHandoff
recordWhenHostReady = false
coldStartDebouncer.reset()
isPendingFlowStart = true
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
@@ -288,7 +479,10 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability()
openHostApp("startflow")
startFlowStartWatchdog()
traceState("beginFlowStart.started")
traceState(
"beginFlowStart.started",
extra: recordAfterHandoff ? "recordAfterHandoff=1" : "recordAfterHandoff=0"
)
}
func handleHostAppOpenResult(path: String, success: Bool) {
@@ -300,6 +494,7 @@ final class KeyboardFlowCoordinator {
// guide the user to open OSGKeyboard manually.
if path == "startflow", isPendingFlowStart {
isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0
stopFlowWatchdog()
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
@@ -319,8 +514,10 @@ final class KeyboardFlowCoordinator {
ExtensionScreenWakeLock.release()
}
currentUtteranceId = nil
lastStoppedUtteranceId = nil
isFlowRecording = false
isPendingFlowStart = false
recordAfterHandoff = false
stopUtteranceCountdown()
stopFlowWatchdog()
state.level = 0
@@ -365,6 +562,8 @@ final class KeyboardFlowCoordinator {
)
)
FlowSessionBridge.clearResult()
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
@@ -375,6 +574,8 @@ final class KeyboardFlowCoordinator {
isAwaitingFlowResult = false
stopFlowWatchdog()
FlowSessionBridge.clearResult()
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
@@ -420,6 +621,7 @@ final class KeyboardFlowCoordinator {
ExtensionScreenWakeLock.release()
writeCommand(.abort)
currentUtteranceId = nil
lastStoppedUtteranceId = nil
stopFlowWatchdog()
state.level = 0
state.phase = .idle
@@ -439,10 +641,12 @@ final class KeyboardFlowCoordinator {
isAwaitingFlowResult = false
isFlowRecording = false
isPendingFlowStart = false
recordAfterHandoff = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
writeCommand(.abort)
currentUtteranceId = nil
lastStoppedUtteranceId = nil
stopFlowWatchdog()
state.level = 0
let message = ExtL10n.string("keyboard.flow.hostDisconnected")
@@ -477,9 +681,29 @@ final class KeyboardFlowCoordinator {
private func startFlowRecording() {
recomputeMicVoiceAvailability()
guard state.micVoiceAvailability.isReady else {
traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))")
beginFlowStart()
let withinReadyGrace = lastHostReadyAt > 0
&& (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
if !state.micVoiceAvailability.isReady {
let action = FlowHandoffPolicy.micPressAction(
availability: state.micVoiceAvailability,
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: withinReadyGrace
)
traceState(
"startFlowRecording.blocked",
extra: "availability=\(String(describing: state.micVoiceAvailability)) action=\(action)"
)
switch action {
case .waitForHostReady(let recordWhenReady):
recordWhenHostReady = recordWhenReady
startHostReadyWaitIfNeeded()
case .openHostColdStart:
beginFlowStart(recordAfterHandoff: true)
case .startRecording, .ignore:
break
}
return
}
isPendingFlowStart = false
@@ -488,11 +712,23 @@ final class KeyboardFlowCoordinator {
guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else {
traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot")
beginFlowStart()
// Snapshot lag with a live session wait; only cold-start if host is dead.
if FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: withinReadyGrace
) {
beginFlowStart(recordAfterHandoff: true)
} else {
recordWhenHostReady = true
startHostReadyWaitIfNeeded()
}
return
}
activeSessionId = sessionId
currentUtteranceId = UUID()
lastStoppedUtteranceId = nil
writeCommand(.startRecording)
isFlowRecording = true
state.lastTranscript = ""
@@ -532,8 +768,12 @@ final class KeyboardFlowCoordinator {
private func cancelPendingFlowStart() {
isPendingFlowStart = false
recordAfterHandoff = false
recordWhenHostReady = false
flowStartDeadline = 0
coldStartDebouncer.reset()
stopFlowWatchdog()
stopHostReadyWait()
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
@@ -552,6 +792,7 @@ final class KeyboardFlowCoordinator {
let now = Date().timeIntervalSince1970
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false
self.recordAfterHandoff = false
self.flowStartDeadline = 0
self.traceState("startWatchdog.timeout")
self.showManualOpenHint(path: "startflow")
@@ -563,13 +804,20 @@ final class KeyboardFlowCoordinator {
}
private func completeFlowStartHandoff() {
let shouldRecord = recordAfterHandoff
isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0
stopFlowWatchdog()
state.lastTranscript = ""
refreshSessionState()
startFlowRecording()
traceState("completeFlowStartHandoff.done")
if shouldRecord {
startFlowRecording()
traceState("completeFlowStartHandoff.done", extra: "record=1")
} else {
recomputeMicVoiceAvailability()
traceState("completeFlowStartHandoff.done", extra: "record=0 warmOnly")
}
}
private func startFlowLevelWatchdog() {
@@ -626,6 +874,8 @@ final class KeyboardFlowCoordinator {
)
)
FlowSessionBridge.clearResult()
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil
self.debug("resultWatchdog consumed delivery len=\(text.count)")
self.textInserter.handleFlowTranscript(
@@ -637,6 +887,8 @@ final class KeyboardFlowCoordinator {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
FlowSessionBridge.clearResult()
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil
let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
@@ -674,6 +926,7 @@ final class KeyboardFlowCoordinator {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.currentUtteranceId = nil
self.lastStoppedUtteranceId = nil
self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host")
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.flowResultTimeout, message: msg)
@@ -10,15 +10,18 @@ import OSGKeyboardShared
final class KeyboardTextInserter {
private let state: KeyboardState
private let insertText: (String) -> Void
private let contextBeforeInput: () -> String?
private let scheduleAutoClearError: () -> Void
init(
state: KeyboardState,
insertText: @escaping (String) -> Void,
contextBeforeInput: @escaping () -> String?,
scheduleAutoClearError: @escaping () -> Void
) {
self.state = state
self.insertText = insertText
self.contextBeforeInput = contextBeforeInput
self.scheduleAutoClearError = scheduleAutoClearError
}
@@ -30,7 +33,13 @@ final class KeyboardTextInserter {
return
}
// Host app already polished when configured; keyboard only inserts.
insertText(trimmed)
// Word-boundary hygiene: dictating "world" with the cursor right
// after "Hello" must yield "Hello world", not "Helloworld".
let separator = DictationTextComposer.insertionSeparator(
previousContext: contextBeforeInput(),
insertion: trimmed
)
insertText(separator + trimmed)
state.lastTranscript = ""
state.level = 0
if let warning = delivery.polishWarning {
+19 -11
View File
@@ -12,7 +12,7 @@
// (transcript preview)
//
// mic (centred) action cluster:
// [delete] [ space ] [return] mic + bottom row
// [delete] [ return ] [space] mic + bottom row
//
//
@@ -33,6 +33,11 @@ private enum KeyboardLayoutMetrics {
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
/// 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
// MARK: - Content-driven keyboard height (single source of truth)
static let outerPaddingTop: CGFloat = 2
@@ -109,6 +114,8 @@ public struct KeyboardRootView: View {
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome
.background(Color.clear)
.frame(maxWidth: KeyboardLayoutMetrics.contentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
@@ -184,7 +191,7 @@ public struct KeyboardRootView: View {
// MARK: - Action cluster
/// Mic centred above a bottom row: delete · space · return (or swapped).
/// Mic centred above a bottom row: delete · smart return · space (or swapped).
/// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
/// pan recognizers, avoiding SwiftUI gesture delivery issues in
/// keyboard extensions.
@@ -218,13 +225,13 @@ public struct KeyboardRootView: View {
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
bottomDeleteButton(disabled: editingBlocked)
} else {
bottomDeleteButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
}
}
.opacity(dragging ? 0 : 1)
@@ -258,19 +265,20 @@ public struct KeyboardRootView: View {
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
state.insertSpace()
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomReturnButton(disabled: Bool) -> some View {
RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) {
state.insertNewline()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
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) {
state.insertNewline()
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
/// Option C: block typing keys during the full voice-input pipeline.
private var voiceInputBlocksEditing: Bool {
switch state.phase {
@@ -10,6 +10,7 @@ import OSGKeyboardShared
private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14
static let titleSize: CGFloat = 16
static let cornerRadius: CGFloat = 12
static let spaceBarCapsuleWidth: CGFloat = 31
static let pressScale: CGFloat = 0.94
@@ -138,6 +139,7 @@ struct RectangularToolbarButton: View {
let systemName: String?
let spaceStyle: Bool
let title: String?
let label: String
let disabled: Bool
let action: () -> Void
@@ -145,14 +147,25 @@ struct RectangularToolbarButton: View {
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = systemName
self.spaceStyle = false
self.title = nil
self.label = label
self.disabled = disabled
self.action = action
}
init(title: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = false
self.label = label
self.disabled = disabled
self.action = action
self.title = title
}
init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.title = nil
self.label = label
self.disabled = disabled
self.action = action
@@ -170,6 +183,10 @@ struct RectangularToolbarButton: View {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
}
.contentShape(Rectangle())
+7 -6
View File
@@ -25,6 +25,7 @@
"common.delete" = "Delete";
"common.space" = "Space";
"common.newline" = "Return";
"common.send" = "Send";
/* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "On-device transcription";
@@ -69,13 +70,13 @@
"settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, and base URL will be cleared.";
"settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Recognition method";
"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed.";
"settings.engine.local.title" = "On-device recognition";
"settings.engine.title" = "Speech transcription method";
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
"settings.engine.local.title" = "On-device transcription";
"settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
"settings.engine.cloud.title" = "Cloud recognition & polish";
"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required.";
"settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
"settings.engine.cloud.title" = "Cloud transcription";
"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
"settings.provider.title" = "Provider";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"settings.api.title" = "API";
@@ -24,7 +24,8 @@
"common.cancel" = "取消";
"common.delete" = "删除";
"common.space" = "空格";
"common.newline" = "换行";
"common.newline" = "回车";
"common.send" = "发送";
/* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "支持本地转写";
@@ -69,13 +70,13 @@
"settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "API key、model 和 base URL 都会被清空。";
"settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "识别方式";
"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。";
"settings.engine.local.title" = "本地识别";
"settings.engine.title" = "语音转写方式";
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
"settings.engine.local.title" = "本地转写";
"settings.engine.local.ios26" = "始终端侧,无需联网。";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
"settings.engine.cloud.title" = "云端识别与润色";
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
"settings.engine.local.legacy" = "通过系统内置服务语音转写";
"settings.engine.cloud.title" = "云端转写";
"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
"settings.provider.title" = "云端引擎";
"settings.provider.subtitle" = "选择 LLM 提供商。";
"settings.api.title" = "接口";
@@ -9,37 +9,50 @@ import SwiftUI
import WidgetKit
struct FlowLiveActivityWidget: Widget {
/// Deep link that restarts the Flow session. When the host process is
/// dead the activity goes stale; tapping it must take the *cold-start*
/// path (same as the keyboard's mic button), not just open the app.
private static let reconnectURL = URL(string: "osgkeyboard://startflow")
var body: some WidgetConfiguration {
ActivityConfiguration(for: FlowActivityAttributes.self) { context in
FlowLiveActivityLockScreenView(phase: context.state.phase)
.activityBackgroundTint(Color.black.opacity(0.82))
.activitySystemActionForegroundColor(.white)
FlowLiveActivityLockScreenView(
phase: context.state.phase,
isStale: context.isStale
)
.activityBackgroundTint(Color.black.opacity(0.82))
.activitySystemActionForegroundColor(.white)
// Deep-link to a session restart only when the host is dead
// tapping a HEALTHY activity should just open the app, not
// force a cold-start handoff into a running session.
.widgetURL(context.isStale ? Self.reconnectURL : nil)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
FlowLiveActivityBrandMark(height: 16)
}
DynamicIslandExpandedRegion(.trailing) {
FlowLiveActivityPhaseLabel(phase: context.state.phase)
FlowLiveActivityPhaseLabel(phase: context.state.phase, isStale: context.isStale)
}
DynamicIslandExpandedRegion(.center) {
Text("OSGKeyboard")
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
FlowLiveActivityPhaseCaption(phase: context.state.phase)
FlowLiveActivityPhaseCaption(phase: context.state.phase, isStale: context.isStale)
.font(.caption)
.foregroundStyle(.secondary)
}
} compactLeading: {
FlowLiveActivityBrandMark(height: 12)
} compactTrailing: {
FlowLiveActivityTrailingGlyph(phase: context.state.phase)
FlowLiveActivityTrailingGlyph(phase: context.state.phase, isStale: context.isStale)
} minimal: {
// The minimal slot is a tiny circle; a short wordmark keeps
// the natural ratio without overflowing its bounds.
FlowLiveActivityBrandMark(height: 6)
}
.widgetURL(context.isStale ? Self.reconnectURL : nil)
.keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0))
}
}
@@ -49,20 +62,24 @@ struct FlowLiveActivityWidget: Widget {
private struct FlowLiveActivityLockScreenView: View {
let phase: FlowActivityAttributes.ContentState.Phase
let isStale: Bool
var body: some View {
HStack(spacing: 12) {
FlowLiveActivityBrandMark(height: 13)
FlowLiveActivityBrandMark(height: 10.4)
VStack(alignment: .leading, spacing: 4) {
Text("OSGKeyboard")
.font(.headline)
FlowLiveActivityPhaseCaption(phase: phase)
FlowLiveActivityPhaseCaption(phase: phase, isStale: isStale)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
FlowLiveActivityTrailingGlyph(phase: phase)
FlowLiveActivityTrailingGlyph(phase: phase, isStale: isStale)
}
// A stale activity means the host process is gone never advertise
// "Voice session active" for a dead session; grey the card instead.
.opacity(isStale ? 0.55 : 1)
// iOS Live Activity lock-screen content needs margins so the leading
// logo and trailing glyph don't touch the card edges.
.padding(.horizontal, 16)
@@ -91,8 +108,19 @@ private struct FlowLiveActivityBrandMark: View {
private struct FlowLiveActivityTrailingGlyph: View {
let phase: FlowActivityAttributes.ContentState.Phase
var isStale: Bool = false
var body: some View {
if isStale {
Image(systemName: "bolt.slash.circle")
.foregroundStyle(.secondary)
} else {
phaseGlyph
}
}
@ViewBuilder
private var phaseGlyph: some View {
switch phase {
case .recording:
Image(systemName: "waveform")
@@ -114,8 +142,19 @@ private struct FlowLiveActivityTrailingGlyph: View {
private struct FlowLiveActivityPhaseLabel: View {
let phase: FlowActivityAttributes.ContentState.Phase
var isStale: Bool = false
var body: some View {
if isStale {
Image(systemName: "bolt.slash.circle")
.foregroundStyle(.secondary)
} else {
phaseLabel
}
}
@ViewBuilder
private var phaseLabel: some View {
switch phase {
case .recording:
Text("REC")
@@ -133,8 +172,20 @@ private struct FlowLiveActivityPhaseLabel: View {
private struct FlowLiveActivityPhaseCaption: View {
let phase: FlowActivityAttributes.ContentState.Phase
var isStale: Bool = false
var body: some View {
if isStale {
// Host process is gone be honest about it and turn the card
// into a recovery entry point (tap deep-links to startflow).
Text("Session disconnected · tap to reconnect")
} else {
phaseCaption
}
}
@ViewBuilder
private var phaseCaption: some View {
switch phase {
case .idle:
Text("Voice session active")
@@ -0,0 +1,7 @@
/* Live Activity captions. Keys are the English literals used in
FlowLiveActivityWidget — SwiftUI Text(_:) resolves string literals as
LocalizedStringKey against this table automatically. */
"Voice session active" = "Voice session active";
"Listening…" = "Listening…";
"Transcribing…" = "Transcribing…";
"Session disconnected · tap to reconnect" = "Session disconnected · tap to reconnect";
@@ -0,0 +1,6 @@
/* Live Activity 文案。键为 FlowLiveActivityWidget 中的英文字面量 —
SwiftUI Text(_:) 会把字符串字面量按 LocalizedStringKey 在本表解析。 */
"Voice session active" = "语音会话进行中";
"Listening…" = "正在聆听…";
"Transcribing…" = "正在转写…";
"Session disconnected · tap to reconnect" = "会话已断开 · 点按重连";
+109 -81
View File
@@ -1,7 +1,8 @@
// DashboardView.swift
// OSGKeyboard · Mac
//
// Primary workspace: session stats, dictation canvas, floating record bar.
// Primary workspace: brand voice, asymmetric stats, dictation stage, and
// the record bar. History lives on its own page no duplicate list here.
import SwiftUI
@@ -20,33 +21,33 @@ struct DashboardView: View {
self._stats = ObservedObject(wrappedValue: viewModel.usageStatistics)
}
// Four equal-width columns same metrics as the iOS home stats card.
private let columns = Array(
repeating: GridItem(.flexible(minimum: 120), spacing: Spacing.md),
count: 4
)
var body: some View {
VStack(spacing: 0) {
// Scrollable like the other pages so the shared status footer always
// stays pinned to the window's bottom edge. `minHeight: viewport` keeps
// the balanced Spacer layout when the window is tall (no scrollbar) and
// lets the content scroll only when the window is too short to fit it.
GeometryReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) {
if let appName = viewModel.foregroundAppName {
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.transition(.opacity)
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: Spacing.lg) {
heroHeader
statCluster
dictationStage
}
statGrid
dictationCanvas
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
// Leftover window height splits evenly above / below the mic
// bar so spacing stays balanced at any window size.
Spacer(minLength: Spacing.xs)
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
Spacer(minLength: Spacing.xs)
}
.animation(Motion.soft, value: viewModel.foregroundAppName)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.lg)
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
}
BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.sm)
}
.onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
@@ -54,47 +55,54 @@ struct DashboardView: View {
}
}
private var statGrid: some View {
LazyVGrid(columns: columns, spacing: Spacing.md) {
StatCard(
title: MacL10n.string("mac.stat.dictationTime", language: lang),
value: UsageStatisticsStore.formatDuration(
stats.dictationDurationSeconds,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang),
systemImage: "waveform",
accent: true
)
StatCard(
title: MacL10n.string("mac.stat.words", language: lang),
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.transcribed", language: lang),
systemImage: "text.alignleft"
)
StatCard(
title: MacL10n.string("mac.stat.translation", language: lang),
value: UsageStatisticsStore.formatCount(
stats.translationCharacterCount,
language: lang
),
caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang),
systemImage: "character.bubble"
)
StatCard(
title: MacL10n.string("mac.stat.dictionary", language: lang),
value: "\(viewModel.dictionaryTermCount)",
caption: MacL10n.string("mac.stat.customTerms", language: lang),
systemImage: "character.book.closed"
)
// MARK: - Hero
private var heroHeader: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(MacL10n.string("mac.brand.tagline", language: lang))
.font(TypeStyle.pageTitle)
.foregroundStyle(palette.textPrimary)
.lineLimit(2)
.minimumScaleFactor(0.85)
HStack(spacing: Spacing.sm) {
Text(MacL10n.string("mac.brand.tagline.subtitle", language: lang))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
if let appName = viewModel.foregroundAppName {
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.45))
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.transition(.opacity)
.lineLimit(1)
}
}
.animation(Motion.soft, value: viewModel.foregroundAppName)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var dictationCanvas: some View {
MacCard(padding: Spacing.lg) {
// MARK: - Stats (7-day chart + cumulative grid shared cluster)
private var statCluster: some View {
UsageStatsCluster(
layout: .split,
language: lang,
points: stats.last7Days,
dictationCharacterCount: stats.dictationCharacterCount,
dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount,
dictionaryTermCount: viewModel.dictionaryTermCount
)
}
// MARK: - Dictation stage
private var dictationStage: some View {
MacCard(padding: Spacing.md, cornerRadius: Radius.large) {
ZStack(alignment: .topLeading) {
if viewModel.transcript.isEmpty {
Text(
@@ -102,24 +110,37 @@ struct DashboardView: View {
? MacL10n.string("mac.status.listening", language: lang)
: MacL10n.string("mac.status.ready", language: lang)
)
.font(.system(size: 26, weight: .light))
.font(.system(size: 22, weight: .light))
.foregroundStyle(palette.textTertiary)
.contentTransition(.opacity)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
.frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
.transition(.opacity)
} else {
Text(viewModel.transcript)
.font(.system(size: 22, weight: .regular))
.font(.system(size: 20, weight: .regular))
.foregroundStyle(palette.textPrimary)
.lineSpacing(4)
.textSelection(.enabled)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
.frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
.transition(.opacity)
}
}
.frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: 160)
}
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
.animation(Motion.quick, value: viewModel.isRecording)
}
}
// MARK: - Floating record bar
@@ -142,12 +163,10 @@ struct BottomDictationBar: View {
recordControl
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1)
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.padding(.vertical, Spacing.xs)
// No surface fill the mic bar sits on the page background so Home
// stays flat and the canvas above can stay shorter without a second
// floating card competing for height.
}
private var readinessChip: some View {
@@ -178,26 +197,36 @@ struct BottomDictationBar: View {
}
}
} label: {
HStack(spacing: 6) {
HStack(spacing: Spacing.sm) {
Image(systemName: "translate")
.foregroundStyle(palette.textSecondary)
Text(currentTranslationLabel)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.font(MacSettingsType.control)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.macGlassSurface(in: Capsule(), fillOpacity: 0.66)
.frame(minHeight: MacMetrics.settingsControlHeight)
.background(
palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
//
//
// Option
// Mic stays geometrically centred: waveform lives inside the button,
// press stop floats above neither participates in layout.
private var recordControl: some View {
recordButton
.overlay(alignment: .top) {
@@ -225,7 +254,6 @@ struct BottomDictationBar: View {
)
Group {
if viewModel.isRecording {
// iOS
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
} else {
Image(systemName: "mic.fill")
+71 -2
View File
@@ -11,11 +11,16 @@
final class MacAudioRecorder: @unchecked Sendable {
enum RecorderError: Error, LocalizedError {
case converterUnavailable
case microphoneAccessDenied
var errorDescription: String? {
switch self {
case .converterUnavailable:
return "无法初始化音频转换器 / Failed to initialize audio converter"
case .microphoneAccessDenied:
return "麦克风权限被拒绝——请在「系统设置 → 隐私与安全性 → 麦克风」中启用"
+ " / Microphone access denied — enable it in System Settings"
+ " → Privacy & Security → Microphone"
}
}
}
@@ -30,7 +35,20 @@ final class MacAudioRecorder: @unchecked Sendable {
private var converter: AVAudioConverter?
private let lock = NSLock()
private var samples: [Float] = []
private var snapshotContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
private var isRunning = false
/// Hard cap on accumulated audio: 10 minutes @16 kHz 38 MB of Float32.
/// Recording is push-to-talk, but a stuck hotkey (or a latched Option
/// key) would otherwise grow this buffer without bound; past the cap we
/// keep the newest audio (drop from the front) so the take still ends
/// with what the user last said.
private static let maxSampleCount = 10 * 60 * 16_000
/// Trim hysteresis: dropping from the front is an O(n) memmove of the
/// whole ~38 MB buffer, done under the same lock the UI's level poll
/// takes doing it on EVERY tap callback once capped would stall the
/// render thread ~12×/s. Let the buffer overshoot by 30 s and trim the
/// whole excess in one move instead.
private static let trimHysteresisSamples = 30 * 16_000
private var smoothedLevel: Float = 0
/// One-shot flag for the converter pull block. Taps are serialized per
/// bus, so a plain instance property (not a captured local) is safe here.
@@ -42,8 +60,51 @@ final class MacAudioRecorder: @unchecked Sendable {
lock.withLock { smoothedLevel }
}
func start() throws {
lock.withLock { samples.removeAll(keepingCapacity: true) }
/// Resolves microphone authorization before capture. Prompts on first
/// use; throws `microphoneAccessDenied` once the user has declined so
/// failures surface as a permission problem, not an empty transcription.
private static func ensureMicrophoneAccess() async throws {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
return
case .notDetermined:
guard await AVCaptureDevice.requestAccess(for: .audio) else {
throw RecorderError.microphoneAccessDenied
}
case .denied, .restricted:
throw RecorderError.microphoneAccessDenied
@unknown default:
throw RecorderError.microphoneAccessDenied
}
}
func start() async throws {
try await Self.ensureMicrophoneAccess()
try startEngine()
}
/// Live 16 kHz mono snapshots for streaming ASR while the mic is open.
/// The stream is finished automatically in `stop()`.
func makeSnapshotStream() -> AsyncStream<AudioBufferSnapshot> {
AsyncStream { continuation in
lock.withLock {
snapshotContinuation?.finish()
snapshotContinuation = continuation
}
continuation.onTermination = { [weak self] _ in
self?.lock.withLock {
self?.snapshotContinuation = nil
}
}
}
}
private func startEngine() throws {
lock.withLock {
samples.removeAll(keepingCapacity: true)
snapshotContinuation?.finish()
snapshotContinuation = nil
}
let input = engine.inputNode
let inputFormat = input.outputFormat(forBus: 0)
@@ -67,6 +128,8 @@ final class MacAudioRecorder: @unchecked Sendable {
engine.stop()
isRunning = false
return lock.withLock {
snapshotContinuation?.finish()
snapshotContinuation = nil
let out = samples
samples.removeAll(keepingCapacity: false)
return out
@@ -105,8 +168,14 @@ final class MacAudioRecorder: @unchecked Sendable {
lock.withLock {
samples.append(contentsOf: chunk)
if samples.count > Self.maxSampleCount + Self.trimHysteresisSamples {
samples.removeFirst(samples.count - Self.maxSampleCount)
}
let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
smoothedLevel += (normalized - smoothedLevel) * factor
snapshotContinuation?.yield(
AudioBufferSnapshot(samples: chunk, sampleRate: 16_000)
)
}
}
}
@@ -0,0 +1,55 @@
// MacCloudASRChunkAdapter.swift
// OSGKeyboard · Mac
//
// Adapts configured cloud ASR clients to the shared chunked utterance pipeline.
import Foundation
import os
final class MacCloudASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
private let store: AppGroupStore
private let client: CloudASRTranscribing
private let cancelled = OSAllocatedUnfairLock(initialState: false)
init(store: AppGroupStore) throws {
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
guard strategy != .localFallback else {
throw MacDictationError.providerHasNoCloudASR
}
self.store = store
self.client = CloudASRClientFactory.make(store: store)
}
func prepare() async throws {
try await client.prepare(dictionary: store.personalDictionary)
}
func resetForNewUtterance() {
cancelled.withLock { $0 = false }
}
func cancel() {
cancelled.withLock { $0 = true }
}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
let isCancelled = cancelled.withLock { $0 }
if isCancelled || Task.isCancelled { return .cancelled }
guard !samples.isEmpty else { return .success("") }
do {
let text = try await client.transcribe(
samples: samples,
sampleRate: 16_000,
locale: locale,
dictionary: store.personalDictionary
)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return .success(trimmed)
} catch is CancellationError {
return .cancelled
} catch {
return .failure(error.localizedDescription)
}
}
}
+544 -68
View File
@@ -14,8 +14,33 @@ import SwiftUI
/// Fixed metrics that keep every desktop surface on the same grid.
enum MacMetrics {
/// Uniform max width for trailing text controls (API key / model field).
static let controlWidth: CGFloat = 240
/// Shared height for credential inputs and icon buttons matches the iOS
/// settings controls (38).
static let settingsControlHeight: CGFloat = 38
/// Fixed label column for provider rows: wider than iOS (150) so the label
/// text takes a larger share of the row and the trailing control narrows.
static let settingLabelWidth: CGFloat = 200
/// Uniform minimum height for every settings row, so rows read as an even
/// list regardless of whether they hold a 38pt control or a single-line
/// label. Content is centered within it; taller rows (status text, progress)
/// grow past it.
static let settingsRowMinHeight: CGFloat = 40
/// The single vertical rhythm for settings cards: the gap *between* rows AND
/// the padding between a card's top/bottom edge and its first/last row are
/// both this value, so the card breathes evenly. Applied as `VStack(spacing:)`
/// between rows and `.padding(.vertical:)` on the card body.
static let settingsRowGap: CGFloat = Spacing.md
/// Provider menu trigger width (legacy).
static let selectWidth: CGFloat = 200
/// Text-field max width for provider credential rows (narrower than the
/// old 360pt so long keys truncate instead of wrapping when the window shrinks).
static let fieldWidth: CGFloat = 280
/// Legacy alias used by older call sites; prefer `fieldWidth`.
static let controlWidth: CGFloat = fieldWidth
/// Horizontal inset inside settings cards matches History / Dictionary rows.
static let settingsCardInset: CGFloat = Spacing.md
/// Below this row width, provider rows stack label above control.
static let settingsCompactBreakpoint: CGFloat = 520
/// Sidebar width and the horizontal inset shared by brand, nav and footer.
static let sidebarWidth: CGFloat = 240
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
@@ -25,11 +50,76 @@ enum MacMetrics {
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
/// Reading width for single-column content.
static let contentMaxWidth: CGFloat = 720
/// Horizontal inset for page titles and scroll *content* (cards).
/// ScrollViews / Forms stay full-bleed so the scrollbar sits on the
/// window edge; only the content inside is inset.
/// Doubled from `Spacing.lg` so title + cards breathe from the edges.
static let pageHorizontalInset: CGFloat = Spacing.lg * 2
/// Built-in horizontal inset macOS grouped `Form` adds around its section
/// cards, on top of any padding we apply. Subtracted from
/// `pageHorizontalInset` on the Settings Form so its card outer edge lands
/// on `pageHorizontalInset` matching the History page and the page title.
static let groupedFormSectionInset: CGFloat = Spacing.lg
/// Default (= minimum) main-window size. Opening the app uses this size;
/// the window cannot shrink below it.
static let windowMinWidth: CGFloat = 860
static let windowMinHeight: CGFloat = 600
/// Compact dictation-canvas height so Home fits the min window without scrolling.
static let dictationCanvasMinHeight: CGFloat = 120
/// Top inset that clears the window traffic-light buttons now that the
/// title bar is hidden.
static let trafficLightInset: CGFloat = 28
}
// MARK: - Settings typography (mirrors the iOS settings rows)
/// Type scale that maps 1:1 to the iOS settings controls so the desktop and
/// phone read identically: row label / picker value at body (15), hints at
/// caption2 (11), section header at caption2 (uppercased at the call site).
enum MacSettingsType {
static let sectionTitle = TypeStyle.caption2
static let rowLabel = TypeStyle.body
static let control = TypeStyle.body
static let controlEmph = TypeStyle.bodyEmph
static let hint = TypeStyle.caption2
static let button = TypeStyle.body
}
// MARK: - iOS-style toggle
/// Pill switch mirroring the iOS settings toggle: accent track when on, neutral
/// when off, 16pt white knob, spring slide. Colours resolve through the active
/// OSG palette so it follows the app theme.
struct MacToggleStyle: ToggleStyle {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
ZStack(alignment: configuration.isOn ? .trailing : .leading) {
Capsule()
.fill(configuration.isOn ? palette.accent : offTrackColor)
Circle()
.fill(Color.white)
.frame(width: 16, height: 16)
.shadow(color: .black.opacity(0.25), radius: 1, y: 1)
.padding(2)
}
.frame(width: 36, height: 20)
.animation(Motion.quick, value: configuration.isOn)
}
.buttonStyle(.plain)
}
private var offTrackColor: Color {
colorScheme == .dark
? Color.white.opacity(0.18)
: Color.black.opacity(0.15)
}
}
// MARK: - Liquid Glass
private struct MacGlassSurface<S: Shape>: ViewModifier {
@@ -59,26 +149,346 @@ extension View {
// MARK: - Text field styling
/// Text-field chrome aligned with native macOS Form controls: compact
/// height, text-background fill, hairline border.
/// Theme-aware credential field chrome comfortable tap target,
/// monospaced-friendly body size, and a fill/border that stays clearly
/// distinct from the section card in both appearances.
private struct MacFieldStyleModifier: ViewModifier {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
var monospaced: Bool = false
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: 6, style: .continuous)
// Mirrors the iOS credential field: recessed surface-elevated fill,
// medium radius, hairline border, comfortable 38pt tap target.
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
content
.textFieldStyle(.plain)
.font(TypeStyle.footnote)
.padding(.horizontal, 8)
.frame(height: 22)
.background(Color(nsColor: .textBackgroundColor), in: shape)
.font(monospaced ? TypeStyle.mono : TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: MacMetrics.settingsControlHeight)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
}
/// Light mode: the section card reads near-white, so a recessed `surfaceElevated`
/// grey barely differs. Use the system text-input background (true white) so the
/// field reads as an editable well. Dark mode keeps the elevated grey, which sits
/// *lighter* than the card and already stands out.
private var fieldFill: Color {
#if os(macOS)
if colorScheme != .dark {
return Color(nsColor: .textBackgroundColor)
}
#endif
return palette.surfaceElevated
}
}
extension View {
/// Theme-aware text-field styling for the settings inputs.
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
func macFieldStyle(monospaced: Bool = false) -> some View {
modifier(MacFieldStyleModifier(monospaced: monospaced))
}
}
// MARK: - Provider settings chrome
/// Section header + `MacCard` shell same outer alignment as History /
/// Dictionary (title and card share `pageHorizontalInset` from the parent).
struct MacSettingsSection<Content: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var footer: String? = nil
@ViewBuilder var content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(title)
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) {
VStack(alignment: .leading, spacing: 0) {
content()
}
// Card top/bottom breathe on the same rhythm as the gaps between
// rows (rows own their own inter-row spacing via their container).
.padding(.vertical, MacMetrics.settingsRowGap)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let footer, !footer.isEmpty {
Text(footer)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
/// iOS-style inline row: title (+ optional subtitle) on the left, control
/// right-aligned. Used by simple picker / toggle rows.
struct MacInlineRow<Control: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String? = nil
@ViewBuilder var control: () -> Control
var body: some View {
HStack(alignment: .center, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: Spacing.sm)
control()
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
}
}
/// Backwards-compatible alias: title + optional subtitle + trailing control.
struct MacFormSubtitleRow<Control: View>: View {
let title: String
var subtitle: String? = nil
@ViewBuilder var control: () -> Control
var body: some View {
MacInlineRow(title: title, subtitle: subtitle) {
control()
}
}
}
/// Tappable navigation / action row inside a settings card.
struct MacFormLinkRow: View {
@Environment(\.themePalette) private var palette
let title: String
var showsChevron: Bool = true
var body: some View {
HStack(spacing: Spacing.sm) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: 0)
if showsChevron {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
}
.padding(.horizontal, MacMetrics.settingsCardInset)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.contentShape(Rectangle())
}
}
/// Single merged card body for LLM / ASR provider configuration sections.
struct MacSettingsProviderCard<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
VStack(spacing: MacMetrics.settingsRowGap) {
content()
}
}
}
// MARK: - Responsive provider setting row
/// Label + control row for provider cards. Uses a two-column layout at
/// comfortable widths and stacks vertically when horizontal space is tight.
struct MacProviderSettingRow<Content: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String? = nil
/// Retained for source compatibility; the control now fills its column.
var controlMaxWidth: CGFloat? = nil
/// Cross-axis alignment between the label column and the control. Provider
/// rows keep `.top` (model row grows a status line below its field); single
/// control rows can pass `.center` to vertically center label and control.
var verticalAlignment: VerticalAlignment = .top
@ViewBuilder var content: () -> Content
var body: some View {
HStack(alignment: verticalAlignment, spacing: Spacing.md) {
label
.frame(width: MacMetrics.settingLabelWidth, alignment: .leading)
content()
.frame(maxWidth: .infinity, alignment: .leading)
}
// Uniform row height (content centered); inter-row spacing is owned by the
// enclosing card, so the row adds no vertical padding of its own.
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
private var label: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(MacSettingsType.rowLabel)
.foregroundStyle(palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
// Align the title with the first 38pt control row, not with any
// status/help text that appears below the control.
.frame(minHeight: MacMetrics.settingsControlHeight, alignment: .leading)
}
}
/// Square icon button matching credential field height.
struct MacSettingsIconButton: View {
@Environment(\.themePalette) private var palette
let systemName: String
var help: String? = nil
var disabled: Bool = false
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary)
.frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.disabled(disabled)
.help(help ?? "")
}
}
/// Compact tool button for validate / fetch-models actions.
struct MacSettingsToolButton: View {
@Environment(\.themePalette) private var palette
let title: String
/// Background fill. Defaults to the neutral elevated surface.
var fill: Color? = nil
/// Text color. Defaults to primary text (tertiary when disabled).
var foreground: Color? = nil
/// Hairline divider border drawn for the neutral variant, hidden for
/// colored fills (delete / download) so the fill reads as the button.
var showsBorder: Bool = true
var disabled: Bool = false
let action: () -> Void
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
Button(action: action) {
Text(title)
.font(MacSettingsType.button)
.foregroundStyle(resolvedForeground)
.padding(.horizontal, Spacing.md)
.frame(minHeight: 34)
.background(fill ?? palette.surfaceElevated, in: shape)
.overlay {
if showsBorder {
shape.stroke(palette.divider, lineWidth: 0.5)
}
}
}
.buttonStyle(.plain)
.disabled(disabled)
}
private var resolvedForeground: Color {
if disabled { return palette.textTertiary }
return foreground ?? palette.textPrimary
}
}
// MARK: - Legacy setting row
/// Fixed label column + left-aligned control. Prefer `MacProviderSettingRow`
/// for provider configuration cards.
struct MacSettingRow<Content: View>: View {
let title: String
var controlMaxWidth: CGFloat? = nil
@ViewBuilder var content: () -> Content
var body: some View {
MacProviderSettingRow(title: title, controlMaxWidth: controlMaxWidth) {
content()
}
}
}
// MARK: - Page header
/// Page title for History / Dictionary / Settings. Applies the shared
/// `pageHorizontalInset` so its left edge matches inset card content below.
/// Type size matches Home's brand line (`TypeStyle.pageTitle`).
struct MacPageHeader<Trailing: View>: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String?
@ViewBuilder var trailing: () -> Trailing
init(
title: String,
subtitle: String? = nil,
@ViewBuilder trailing: @escaping () -> Trailing
) {
self.title = title
self.subtitle = subtitle
self.trailing = trailing
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(title)
.font(TypeStyle.pageTitle)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.85)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
}
}
Spacer(minLength: 0)
trailing()
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.sm)
}
}
extension MacPageHeader where Trailing == EmptyView {
init(title: String, subtitle: String? = nil) {
self.init(title: title, subtitle: subtitle) { EmptyView() }
}
}
// MARK: - Card container
@@ -87,10 +497,11 @@ extension View {
struct MacCard<Content: View>: View {
@Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md
var cornerRadius: CGFloat = Radius.medium
@ViewBuilder var content: () -> Content
var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
content()
.padding(padding)
@@ -102,47 +513,6 @@ struct MacCard<Content: View>: View {
}
}
// MARK: - Stat tile
struct StatCard: View {
@Environment(\.themePalette) private var palette
let title: String
let value: String
let caption: String
var systemImage: String?
var accent: Bool = false
var body: some View {
MacCard {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Text(title.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Spacer()
if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
}
}
Text(value)
.font(TypeStyle.title2)
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: value)
Text(caption)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - Live waveform
/// Compact bar visualiser reacting to the input level while recording.
@@ -152,30 +522,39 @@ struct MiniWaveform: View {
var barCount: Int = 5
/// Pass nil to inherit the palette accent automatically.
var tint: Color?
/// Peak bar height; overlay HUD uses a taller meter than the mic button.
var maxBarHeight: CGFloat = 22
var barWidth: CGFloat = 3
var barSpacing: CGFloat = 3
@State private var phase: CGFloat = 0
var body: some View {
HStack(spacing: 3) {
HStack(spacing: barSpacing) {
ForEach(0..<barCount, id: \.self) { index in
Capsule()
.fill(tint ?? palette.accent)
.frame(width: 3, height: barHeight(index))
.frame(width: barWidth, height: barHeight(index))
}
}
.frame(height: 22)
.frame(height: maxBarHeight)
.animation(Motion.instant, value: level)
.onAppear {
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
withAnimation(.linear(duration: 0.75).repeatForever(autoreverses: true)) {
phase = 1
}
}
}
private func barHeight(_ index: Int) -> CGFloat {
let base = CGFloat(level) * 22
let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4
return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble))
// Stronger level coupling + staggered phase so the meter reads as
// "alive" even at modest mic levels.
let boosted = min(1, CGFloat(level) * 1.35 + 0.08)
let base = boosted * maxBarHeight
let wobble = sin((phase * .pi * 2) + CGFloat(index) * 0.85) * (maxBarHeight * 0.22)
+ (maxBarHeight * 0.12)
let parity = 0.55 + CGFloat(index % 3) * 0.2
return max(maxBarHeight * 0.18, min(maxBarHeight, base * parity + wobble))
}
}
@@ -195,8 +574,8 @@ enum MacTranslationDisplay {
// MARK: - Status footer
/// Bottom status strip: engine mode (cloud/local), translation target, and
/// the connection state icons and wording mirror the dashboard record bar.
/// Bottom status strip: engine mode, translation target, connection
/// kept visually quiet so it never competes with the record bar.
struct MacStatusFooter: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
@@ -204,32 +583,129 @@ struct MacStatusFooter: View {
private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View {
HStack(spacing: Spacing.md) {
HStack(spacing: Spacing.sm) {
Spacer()
// Current models, shown just before the engine mode. Label text is
// the model name only; the full "provider · model" lives in the
// hover tooltip to keep the strip quiet.
Label(asrModel.name, systemImage: "waveform")
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150, alignment: .trailing)
.help(asrModel.tooltip)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(llmModel.name, systemImage: "sparkles")
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 150, alignment: .trailing)
.help(llmModel.tooltip)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(
viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang)
: MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
systemImage: "translate"
)
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
.foregroundStyle(palette.accent)
.foregroundStyle(palette.accent.opacity(0.85))
}
.font(TypeStyle.caption)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.labelStyle(.titleAndIcon)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.vertical, Spacing.sm)
.animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
.animation(Motion.quick, value: asrModel.name)
.animation(Motion.quick, value: llmModel.name)
}
// MARK: - Current model resolution
/// ASR: cloud shows the configured provider's model (or its default);
/// local shows the selected on-device model's display name.
private var asrModel: (name: String, tooltip: String) {
let config = viewModel.config
if viewModel.isCloudMode {
let providerName = ProviderDisplayName.name(for: config.asrProviderId, language: lang)
let model = config.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: config.asrProviderId)
: config.asrModel
return (model, "\(providerName) · \(model)")
}
let providerName = MacL10n.string("mac.mode.local", language: lang)
let model = MacLocalASRModelName.displayName(for: MacLocalASRPreferences.selectedModelId, language: lang)
return (model, "\(providerName) · \(model)")
}
/// LLM: cloud uses the configured provider; local routes through
/// `localModeProviderId` (built-in DeepSeek unless the user supplied a key).
private var llmModel: (name: String, tooltip: String) {
let config = viewModel.config
let providerId = viewModel.isCloudMode ? config.providerId : config.localModeProviderId
let providerName = ProviderDisplayName.name(for: providerId, language: lang)
let model: String
if providerId == config.providerId, !config.model.isEmpty {
model = config.model
} else {
model = LLMProvider.provider(id: providerId).defaultModel
}
return (model, "\(providerName) · \(model)")
}
}
/// Cached lookup from a local ASR model id to its catalog display name. The
/// bundled catalog is small and immutable, so decoding it once is enough.
enum MacLocalASRModelName {
private static let catalog: LocalASRCatalogDocument? = try? LocalASRModelCatalog.loadBundled()
static func displayName(for modelId: String, language: AppUILanguage) -> String {
if !modelId.isEmpty,
let catalog,
let model = LocalASRModelCatalog.model(modelId, in: catalog) {
return model.displayName
}
return modelId.isEmpty ? MacL10n.string("mac.mode.local", language: language) : modelId
}
}
// MARK: - Form alignment
private struct MacFormPageAlignModifier: ViewModifier {
func body(content: Content) -> some View {
// Form stays full-bleed (scrollbar on the window edge). Section
// cards are inset to match `MacPageHeader`.
content
.contentMargins(.horizontal, MacMetrics.pageHorizontalInset, for: .scrollContent)
}
}
extension View {
/// Insets grouped-`Form` section cards to `pageHorizontalInset`.
func macFormPageAligned() -> some View {
modifier(MacFormPageAlignModifier())
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ struct MacContentView: View {
VStack(alignment: .leading, spacing: Spacing.sm) {
header
recordButton
Text(MacL10n.string("mac.hint.holdOption", language: lang))
Text(MacL10n.string(viewModel.hotkeyTrigger.hintKey, language: lang))
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
@@ -0,0 +1,222 @@
// MacDictationOverlayController.swift
// OSGKeyboard · Mac
//
// Owns a borderless, non-activating floating NSPanel that hosts the
// dictation HUD. Shown for any recording path (hotkey, menu bar, main
// window) and dismissed after a short success beat when processing ends.
import AppKit
import Combine
import SwiftUI
@MainActor
final class MacDictationOverlayController {
static let shared = MacDictationOverlayController()
private var panel: NSPanel?
private var hosting: NSHostingView<AnyView>?
private var cancellables = Set<AnyCancellable>()
private var hideWorkItem: DispatchWorkItem?
/// Keeps the pill visible briefly after a successful delivery.
private var showingCompletion = false
private var wasBusy = false
private let bottomMargin: CGFloat = 36
private let fallbackSize = NSSize(width: 400, height: 52)
private init() {}
func start(observing viewModel: MacDictationViewModel) {
guard cancellables.isEmpty else { return }
Publishers.CombineLatest3(
viewModel.$isRecording,
viewModel.$isPreparingToRecord,
viewModel.$isProcessing
)
.receive(on: RunLoop.main)
.sink { [weak self] recording, preparing, processing in
self?.handleBusyChange(
recording: recording,
preparing: preparing,
processing: processing,
viewModel: viewModel
)
}
.store(in: &cancellables)
// Keep waveform / app name / copy fresh while visible.
viewModel.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self, self.panel?.isVisible == true else { return }
self.refreshContent(viewModel: viewModel)
self.resizeToFit()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.reposition() }
.store(in: &cancellables)
}
private func handleBusyChange(
recording: Bool,
preparing: Bool,
processing: Bool,
viewModel: MacDictationViewModel
) {
let busy = recording || preparing || processing
if busy {
hideWorkItem?.cancel()
hideWorkItem = nil
showingCompletion = false
wasBusy = true
present(viewModel: viewModel)
return
}
// Transition: busy idle. Flash a short "done" state, then hide.
if wasBusy {
wasBusy = false
showingCompletion = true
present(viewModel: viewModel)
scheduleHide()
return
}
if !showingCompletion {
hideImmediately()
}
}
private func present(viewModel: MacDictationViewModel) {
ensurePanel(viewModel: viewModel)
refreshContent(viewModel: viewModel)
resizeToFit()
reposition()
guard let panel else { return }
if panel.isVisible {
// Already up still bump to front in case another space stole it.
panel.orderFrontRegardless()
return
}
panel.alphaValue = 0
panel.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.22
panel.animator().alphaValue = 1
}
}
private func ensurePanel(viewModel: MacDictationViewModel) {
if panel != nil { return }
let host = NSHostingView(rootView: makeRoot(viewModel: viewModel))
host.frame = NSRect(origin: .zero, size: fallbackSize)
hosting = host
let panel = NSPanel(
contentRect: NSRect(origin: .zero, size: fallbackSize),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.contentView = host
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
// Above normal floating windows so the HUD stays visible over browsers /
// full-screen apps, without going as high as the screen saver.
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.floatingWindow)) + 1)
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
panel.isMovableByWindowBackground = false
panel.hidesOnDeactivate = false
panel.ignoresMouseEvents = false
panel.becomesKeyOnlyIfNeeded = true
self.panel = panel
}
private func refreshContent(viewModel: MacDictationViewModel) {
hosting?.rootView = makeRoot(viewModel: viewModel)
}
private func makeRoot(viewModel: MacDictationViewModel) -> AnyView {
AnyView(
MacDictationOverlayView(viewModel: viewModel)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
)
}
private func resizeToFit() {
guard let panel, let hosting else { return }
hosting.layoutSubtreeIfNeeded()
let fitting = hosting.fittingSize
let width = fitting.width.isFinite && fitting.width > 1
? min(max(fitting.width, 300), 520)
: fallbackSize.width
let height = fitting.height.isFinite && fitting.height > 1
? max(fitting.height, fallbackSize.height)
: fallbackSize.height
var frame = panel.frame
let midX = frame.midX
frame.size = NSSize(width: width, height: height)
if midX.isFinite {
frame.origin.x = midX - width / 2
}
panel.setFrame(frame, display: true)
hosting.frame = NSRect(origin: .zero, size: frame.size)
}
private func reposition() {
guard let panel else { return }
let screen = NSScreen.main ?? NSScreen.screens.first
guard let visible = screen?.visibleFrame else { return }
let size = panel.frame.size
let origin = NSPoint(
x: visible.midX - size.width / 2,
y: visible.minY + bottomMargin
)
panel.setFrameOrigin(origin)
}
private func scheduleHide() {
hideWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.showingCompletion = false
self?.hideAnimated()
}
hideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.15, execute: work)
}
private func hideImmediately() {
hideWorkItem?.cancel()
hideWorkItem = nil
showingCompletion = false
panel?.orderOut(nil)
panel?.alphaValue = 1
}
private func hideAnimated() {
guard let panel, panel.isVisible else {
hideImmediately()
return
}
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.2
panel.animator().alphaValue = 0
}, completionHandler: { [weak self] in
Task { @MainActor in
self?.panel?.orderOut(nil)
self?.panel?.alphaValue = 1
}
})
}
}
@@ -0,0 +1,170 @@
// MacDictationOverlayView.swift
// OSGKeyboard · Mac
//
// Compact bottom-of-screen HUD shown while dictating. Lives inside a
// non-activating NSPanel so it never steals focus from the front app.
// One-line layout: status / live transcript preview + waveform + stop.
import SwiftUI
struct MacDictationOverlayView: View {
@ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var isBusy: Bool {
viewModel.isRecording || viewModel.isPreparingToRecord || viewModel.isProcessing
}
/// Trimmed live / final transcript for the single-line preview.
private var previewText: String {
viewModel.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var hasPreview: Bool { !previewText.isEmpty }
private var showsLiveBadge: Bool {
viewModel.isRecording && viewModel.isStreamingPartial
}
var body: some View {
HStack(spacing: Spacing.sm) {
statusDot
primaryLine
Spacer(minLength: Spacing.xs)
trailingControl
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, 11)
.frame(minWidth: 300, idealWidth: 400, maxWidth: 520)
.fixedSize(horizontal: true, vertical: true)
.background(palette.surface, in: Capsule(style: .continuous))
.overlay(
Capsule(style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
.padding(2)
.animation(Motion.soft, value: hasPreview)
.animation(Motion.quick, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isStreamingPartial)
}
// MARK: - Primary line (status or one-line transcript)
@ViewBuilder
private var primaryLine: some View {
if hasPreview {
HStack(spacing: 6) {
if showsLiveBadge {
liveBadge
}
Text(previewText)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .leading)
.contentTransition(.opacity)
.animation(Motion.quick, value: previewText)
.accessibilityLabel(previewText)
}
} else {
HStack(spacing: 6) {
Text(statusText)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
.contentTransition(.opacity)
if let appName = viewModel.foregroundAppName, isBusy {
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.45))
Text(appName)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
}
.animation(Motion.quick, value: statusText)
}
}
private var liveBadge: some View {
Text(MacL10n.string("mac.overlay.live", language: lang))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.recordRed)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(palette.recordRed.opacity(0.12), in: Capsule(style: .continuous))
.accessibilityHidden(true)
}
private var statusDot: some View {
Circle()
.fill(dotColor)
.frame(width: 8, height: 8)
.animation(Motion.quick, value: viewModel.isRecording)
.animation(Motion.quick, value: viewModel.isProcessing)
.animation(Motion.quick, value: viewModel.isPreparingToRecord)
.animation(Motion.quick, value: viewModel.isStreamingPartial)
}
private var dotColor: Color {
if viewModel.isRecording {
return viewModel.isStreamingPartial ? palette.accent : palette.recordRed
}
if viewModel.isPreparingToRecord || viewModel.isProcessing { return palette.warning }
return palette.accent
}
private var statusText: String {
if viewModel.isRecording {
return MacL10n.string("mac.overlay.listening", language: lang)
}
if viewModel.isPreparingToRecord {
return MacL10n.string("mac.overlay.preparing", language: lang)
}
if viewModel.isProcessing {
if viewModel.isStreamingPartial {
return MacL10n.string("mac.overlay.polishing", language: lang)
}
return MacL10n.string("mac.overlay.transcribing", language: lang)
}
return MacL10n.string("mac.overlay.done", language: lang)
}
@ViewBuilder
private var trailingControl: some View {
if viewModel.isRecording {
MiniWaveform(
level: viewModel.audioLevel,
barCount: 7,
tint: (viewModel.isStreamingPartial ? palette.accent : palette.recordRed)
.opacity(0.9),
maxBarHeight: 28,
barWidth: 3.5,
barSpacing: 2.5
)
stopButton
} else if viewModel.isPreparingToRecord || viewModel.isProcessing {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
}
}
private var stopButton: some View {
Button(action: viewModel.toggleRecording) {
Image(systemName: "stop.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textOnAccent)
.frame(width: 28, height: 28)
.background(palette.recordRed, in: Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(MacL10n.string("mac.record.stop", language: lang))
}
}
+246 -34
View File
@@ -1,9 +1,7 @@
// MacDictationPipeline.swift
// OSGKeyboard · Mac
//
// Dictation pipeline: samples ASR (cloud or local) polish.
// Cloud path reuses `CloudASRClientFactory`; local path uses Qwen3-ASR (MLX)
// with Apple Speech fallback when weights are missing.
// Dictation pipeline: samples ASR (cloud or local, chunked when long) polish.
import Foundation
@@ -24,39 +22,61 @@ enum MacDictationError: Error, LocalizedError {
}
}
/// Outcome of ASR that ran while the microphone was still open.
struct MacLiveASRCaptureResult: Sendable {
let raw: String
let chunkWarning: String?
let localBias: LocalASRBiasPayload?
/// When true, callers should fall back to batch ASR on the recorded samples.
let shouldFallbackToBatch: Bool
}
enum MacDictationPipeline {
/// Runs ASR then best-effort polish. Polish failures fall back to raw text.
static func run(samples: [Float], store: AppGroupStore) async throws -> String {
/// First-chunk threshold: longer local utterances use pipelined chunk ASR.
private static let chunkedLocalThresholdSamples = Int(
FlowUtteranceChunkConfig.flowDefault.maxChunkDurationSeconds(forChunkIndex: 0) * 16_000
)
/// Whether the active engine can surface `onPartial` text while recording.
static func supportsLivePartials(store: AppGroupStore) -> Bool {
if store.engineMode == "local" { return true }
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
return strategy != .localFallback
}
/// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning.
static func run(
samples: [Float],
store: AppGroupStore,
onPartial: (@Sendable (String) -> Void)? = nil
) async throws -> MacDictationResult {
guard !samples.isEmpty else { throw MacDictationError.noAudio }
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
let locale = resolvedLocale(store: store)
var chunkWarning: String?
let raw: String
var localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
MacAppContextService.captureAndPersist(to: store)
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
localBias = resolveLocalBias(store: store, locale: locale)
if samples.count > chunkedLocalThresholdSamples {
let chunked = try await transcribeLocalChunked(
samples: samples,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
capabilities: capabilities
bias: localBias,
onPartial: onPartial
)
)
localBias = bias
LocalASRBiasDiagnosticsStore.save(
payload: bias,
modelId: MacLocalASRService.selectedModelDefinition()?.id,
backendLabel: MacLocalASRService.currentBackendLabel()
)
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: bias
)
raw = chunked.text
chunkWarning = chunked.chunkWarning
} else {
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: localBias
)
}
} else {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
let client = CloudASRClientFactory.make(store: store)
@@ -69,6 +89,85 @@ enum MacDictationPipeline {
)
}
return try await polishCapturedASR(
raw: raw,
store: store,
localBias: localBias,
chunkWarning: chunkWarning
)
}
/// Consumes a live mic snapshot stream until finished; yields stitched partials.
static func captureLive(
stream: AsyncStream<AudioBufferSnapshot>,
store: AppGroupStore,
onPartial: @escaping @Sendable (String) -> Void
) async -> MacLiveASRCaptureResult {
let locale = resolvedLocale(store: store)
let localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
localBias = resolveLocalBias(store: store, locale: locale)
} else {
localBias = nil
}
do {
let adapter = try makeChunkASRAdapter(
store: store,
locale: locale,
bias: localBias
)
if let cloudAdapter = adapter as? MacCloudASRChunkAdapter {
try? await cloudAdapter.prepare()
}
let pipeline = ChunkedUtterancePipeline(
asr: adapter,
locale: locale,
config: .flowDefault
)
let outcome = await pipeline.transcribe(stream: stream, onPartial: onPartial)
switch outcome {
case .success(let success):
return MacLiveASRCaptureResult(
raw: success.text,
chunkWarning: success.chunkWarnings.first,
localBias: localBias,
shouldFallbackToBatch: false
)
case .failure:
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: localBias,
shouldFallbackToBatch: true
)
case .cancelled:
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: localBias,
shouldFallbackToBatch: true
)
}
} catch {
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: localBias,
shouldFallbackToBatch: true
)
}
}
/// Polish-only step after live or batch ASR has produced raw text.
static func polishCapturedASR(
raw: String,
store: AppGroupStore,
localBias: LocalASRBiasPayload?,
chunkWarning: String?
) async throws -> MacDictationResult {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
@@ -91,14 +190,127 @@ enum MacDictationPipeline {
polishContext = nil
}
if let polished = try? await PolishingService(store: store).polish(
postASR,
mode: store.polishModeForPipeline,
context: polishContext
),
!polished.isEmpty {
return polished
do {
let polished = try await PolishingService(store: store).polish(
postASR,
mode: store.polishModeForPipeline,
context: polishContext
)
guard !polished.isEmpty else {
throw PolishingService.PolishError.noTranscript
}
return MacDictationResult(
text: polished,
polishWarning: nil,
chunkWarning: chunkWarning
)
} catch {
let delivery = TranscriptionPolishFallback.makeDelivery(
rawText: postASR,
error: error,
engineMode: store.engineMode,
chunkWarning: chunkWarning
)
return MacDictationResult(
text: delivery.text,
polishWarning: delivery.polishWarning,
chunkWarning: nil
)
}
}
// MARK: - Chunked local ASR
private struct ChunkedLocalResult {
let text: String
let chunkWarning: String?
}
private static func resolvedLocale(store: AppGroupStore) -> Locale {
Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
}
private static func resolveLocalBias(
store: AppGroupStore,
locale: Locale
) -> LocalASRBiasPayload? {
MacAppContextService.captureAndPersist(to: store)
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
capabilities: capabilities
)
)
LocalASRBiasDiagnosticsStore.save(
payload: bias,
modelId: MacLocalASRService.selectedModelDefinition()?.id,
backendLabel: MacLocalASRService.currentBackendLabel()
)
return bias
}
private static func makeChunkASRAdapter(
store: AppGroupStore,
locale: Locale,
bias: LocalASRBiasPayload?
) throws -> any ASRChunkTranscribing {
if store.engineMode == "local" {
return MacLocalASRChunkAdapter(locale: locale, bias: bias)
}
return try MacCloudASRChunkAdapter(store: store)
}
private static func transcribeLocalChunked(
samples: [Float],
locale: Locale,
bias: LocalASRBiasPayload?,
onPartial: (@Sendable (String) -> Void)?
) async throws -> ChunkedLocalResult {
let adapter = MacLocalASRChunkAdapter(locale: locale, bias: bias)
let pipeline = ChunkedUtterancePipeline(
asr: adapter,
locale: locale,
config: .flowDefault
)
let outcome = await pipeline.transcribe(
stream: audioStream(from: samples),
onPartial: { partial in
onPartial?(partial)
}
)
switch outcome {
case .success(let success):
let warning = success.chunkWarnings.first
return ChunkedLocalResult(text: success.text, chunkWarning: warning)
case .failure(let message):
throw MacLocalASRError.qwen3InferenceFailed(message)
case .cancelled:
throw MacLocalASRError.qwen3InferenceFailed("Cancelled")
}
}
/// Feeds recorded PCM into the chunker as if it arrived incrementally.
private static func audioStream(
from samples: [Float],
sliceSamples: Int = 8_000
) -> AsyncStream<AudioBufferSnapshot> {
AsyncStream { continuation in
var offset = 0
while offset < samples.count {
let end = min(offset + sliceSamples, samples.count)
continuation.yield(
AudioBufferSnapshot(
samples: Array(samples[offset..<end]),
sampleRate: 16_000
)
)
offset = end
}
continuation.finish()
}
return postASR
}
}
+12
View File
@@ -0,0 +1,12 @@
// MacDictationResult.swift
// OSGKeyboard · Mac
import Foundation
struct MacDictationResult: Sendable, Equatable {
let text: String
/// Shown when DeepSeek / cloud polish failed but raw ASR was delivered.
let polishWarning: String?
/// Non-fatal per-chunk ASR issues from long utterance chunking.
let chunkWarning: String?
}
+219 -26
View File
@@ -28,7 +28,7 @@ enum MacSection: String, CaseIterable, Identifiable {
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .dashboard: return "house"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .settings: return "gearshape"
@@ -45,7 +45,13 @@ final class MacDictationViewModel: ObservableObject {
@Published var selectedSection: MacSection = .dashboard
@Published var isRecording = false
/// True while microphone permission / engine start is in flight.
/// Drives the overlay so the HUD appears on Option-down immediately,
/// instead of waiting for the async `beginRecording` to finish.
@Published private(set) var isPreparingToRecord = false
@Published var isProcessing = false
/// True once live ASR has surfaced at least one partial during this take.
@Published private(set) var isStreamingPartial = false
@Published var transcript = ""
@Published var statusMessage = ""
@Published var audioLevel: Float = 0
@@ -55,6 +61,7 @@ final class MacDictationViewModel: ObservableObject {
@Published var autoPasteEnabled: Bool
@Published var hotkeyEnabled: Bool
@Published var hotkeyTrigger: MacHotkeyTrigger
@Published var config: ProviderConfig
@@ -64,6 +71,12 @@ final class MacDictationViewModel: ObservableObject {
private var levelTimer: Timer?
private var sessionTimer: Timer?
private var cancellables = Set<AnyCancellable>()
/// In-flight `beginRecording` started by the hotkey cancelled if the
/// key is released before the engine is ready (avoids a stuck session).
private var hotkeyBeginTask: Task<Void, Never>?
/// Live chunked ASR while recording (cloud / supported local paths).
/// Finished in `finishRecording` so partials can become the final draft.
private var liveCaptureTask: Task<MacLiveASRCaptureResult, Never>?
let usageStatistics: UsageStatisticsStore
let speechHistory = SpeechHistoryStore.shared
@@ -71,6 +84,7 @@ final class MacDictationViewModel: ObservableObject {
private enum StoredKeys {
static let autoPaste = "mac.autoPasteEnabled"
static let hotkey = "mac.hotkeyEnabled"
static let hotkeyTrigger = MacHotkeyTrigger.storageKey
}
init(defaults: UserDefaults = .standard) {
@@ -78,7 +92,12 @@ final class MacDictationViewModel: ObservableObject {
self.config = ProviderConfig(defaults: defaults)
self.usageStatistics = UsageStatisticsStore(defaults: defaults)
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true
self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true
// Global hotkey is always on the settings toggle was removed, so any
// previously-stored "off" value is intentionally ignored.
self.hotkeyEnabled = true
self.hotkeyTrigger = MacHotkeyTrigger(
rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? ""
) ?? .rightOption
MacICloudSyncBootstrap.configure(defaults: defaults)
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
@@ -114,10 +133,16 @@ final class MacDictationViewModel: ObservableObject {
// MARK: - Derived
var polishSelectableProviders: [LLMProvider] {
LLMProvider.userSelectablePresets
}
var asrSelectableProviders: [LLMProvider] {
LLMProvider.asrSelectablePresets
}
var selectableProviders: [LLMProvider] {
LLMProvider.presets.filter {
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
}
asrSelectableProviders
}
var dictionaryTermCount: Int {
@@ -185,6 +210,12 @@ final class MacDictationViewModel: ObservableObject {
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
}
func setHotkeyTrigger(_ trigger: MacHotkeyTrigger) {
hotkeyTrigger = trigger
defaults.set(trigger.rawValue, forKey: StoredKeys.hotkeyTrigger)
hotkeyService.trigger = trigger
}
func setEngineMode(_ mode: String) {
config.engineMode = mode
}
@@ -192,23 +223,45 @@ final class MacDictationViewModel: ObservableObject {
// MARK: - Recording
func toggleRecording() {
if isRecording { finishRecording() } else { beginRecording() }
if isRecording || isPreparingToRecord {
cancelOrFinishRecording()
} else {
Task { await beginRecording() }
}
}
func beginRecording() {
guard !isProcessing else { return }
func beginRecording() async {
guard !isProcessing, !isRecording, !isPreparingToRecord else { return }
isPreparingToRecord = true
let store = AppGroupStore(defaults: defaults)
MacAppContextService.captureAndPersist(to: store)
refreshForegroundAppName()
do {
try recorder.start()
try await recorder.start()
// Hotkey may have been released while we awaited mic permission /
// engine start abandon cleanly instead of latching a stuck session.
isPreparingToRecord = false
if Task.isCancelled {
_ = recorder.stop()
return
}
isRecording = true
transcript = ""
isStreamingPartial = false
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
startTimers()
startLiveCaptureIfSupported(store: store)
// Tiny race: Option released between the cancel check and
// `isRecording = true`. Treat it as end-of-hold and finish.
if Task.isCancelled {
finishRecording()
}
} catch {
statusMessage = error.localizedDescription
isPreparingToRecord = false
if !Task.isCancelled {
statusMessage = error.localizedDescription
}
}
}
@@ -216,49 +269,185 @@ final class MacDictationViewModel: ObservableObject {
guard isRecording else { return }
isRecording = false
isProcessing = true
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
let hadLivePartial = isStreamingPartial
statusMessage = MacL10n.string(
hadLivePartial ? "mac.status.polishing" : "mac.status.transcribing",
language: config.uiLanguage
)
stopTimers()
audioLevel = 0
let samples = recorder.stop()
let store = AppGroupStore(defaults: defaults)
let liveTask = liveCaptureTask
liveCaptureTask = nil
Task { [weak self] in
guard let self else { return }
do {
let text = try await MacDictationPipeline.run(samples: samples, store: store)
self.transcript = text
let pasted = try self.deliver(text)
self.recordUsage(for: text)
self.speechHistory.append(text: text)
self.statusMessage = self.statusAfterDelivery(pasted: pasted)
let result: MacDictationResult
if let liveTask {
let capture = await Self.awaitLiveCapture(liveTask)
let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty {
if self.transcript.isEmpty {
self.transcript = trimmedLive
}
result = try await MacDictationPipeline.polishCapturedASR(
raw: capture.raw,
store: store,
localBias: capture.localBias,
chunkWarning: capture.chunkWarning
)
} else {
result = try await MacDictationPipeline.run(
samples: samples,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
self?.transcript = partial
}
}
)
}
} else {
result = try await MacDictationPipeline.run(
samples: samples,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
self?.transcript = partial
}
}
)
}
self.transcript = result.text
let pasted = try await self.deliver(result.text)
self.recordUsage(for: result.text)
self.speechHistory.append(text: result.text)
self.statusMessage = self.statusAfterDelivery(
pasted: pasted,
polishWarning: result.polishWarning,
chunkWarning: result.chunkWarning
)
} catch {
self.statusMessage = error.localizedDescription
}
self.isStreamingPartial = false
self.isProcessing = false
}
}
private func deliver(_ text: String) throws -> Bool {
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
private func startLiveCaptureIfSupported(store: AppGroupStore) {
guard MacDictationPipeline.supportsLivePartials(store: store) else { return }
let stream = recorder.makeSnapshotStream()
liveCaptureTask = Task { [weak self] in
await MacDictationPipeline.captureLive(
stream: stream,
store: store,
onPartial: { [weak self] partial in
Task { @MainActor in
guard let self else { return }
guard self.isRecording || self.isProcessing else { return }
let trimmed = partial.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.transcript = partial
self.isStreamingPartial = true
}
}
)
}
}
private func statusAfterDelivery(pasted: Bool) -> String {
private func cancelLiveCapture() {
liveCaptureTask?.cancel()
liveCaptureTask = nil
isStreamingPartial = false
}
/// Hard timeout so a hung realtime ASR drain cannot leave `isProcessing` stuck.
private static func awaitLiveCapture(
_ task: Task<MacLiveASRCaptureResult, Never>
) async -> MacLiveASRCaptureResult {
await HardTimeout.value(
seconds: FlowSessionKeys.cloudASRWaitTimeout,
operation: { await task.value },
onTimeout: {
task.cancel()
return MacLiveASRCaptureResult(
raw: "",
chunkWarning: nil,
localBias: nil,
shouldFallbackToBatch: true
)
}
)
}
/// Stops an in-flight prepare, or finishes an active recording.
private func cancelOrFinishRecording() {
if isRecording {
finishRecording()
return
}
if isPreparingToRecord {
hotkeyBeginTask?.cancel()
hotkeyBeginTask = nil
// If the button-triggered prepare wasn't tracked by hotkeyBeginTask,
// still clear the preparing flag and stop any engine that raced in.
isPreparingToRecord = false
cancelLiveCapture()
_ = recorder.stop()
}
}
private func deliver(_ text: String) async throws -> Bool {
try await MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
}
private func statusAfterDelivery(
pasted: Bool,
polishWarning: String? = nil,
chunkWarning: String? = nil
) -> String {
let lang = config.uiLanguage
let base: String
if autoPasteEnabled, pasted {
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
base = MacL10n.string("mac.status.copiedAndPasted", language: lang)
} else if autoPasteEnabled, !pasted {
base = MacL10n.string("mac.status.copied", language: lang)
} else {
base = MacL10n.string("mac.status.copied", language: lang)
}
if autoPasteEnabled, !pasted {
return MacL10n.string("mac.status.copied", language: lang)
if let polishWarning, !polishWarning.isEmpty {
return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, polishWarning)
}
return MacL10n.string("mac.status.copied", language: lang)
if let chunkWarning, !chunkWarning.isEmpty {
return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, chunkWarning)
}
return base
}
private func wireHotkeyService() {
hotkeyService.trigger = hotkeyTrigger
hotkeyService.onPressBegan = { [weak self] in
self?.beginRecording()
guard let self else { return }
self.hotkeyBeginTask?.cancel()
self.hotkeyBeginTask = Task { [weak self] in
await self?.beginRecording()
}
}
hotkeyService.onPressEnded = { [weak self] in
self?.finishRecording()
guard let self else { return }
// Cancel a still-preparing start so a quick Option tap never
// latches recording. If recording already began, finish it.
if self.isRecording {
self.hotkeyBeginTask = nil
self.finishRecording()
} else {
self.hotkeyBeginTask?.cancel()
self.hotkeyBeginTask = nil
}
}
if hotkeyEnabled { hotkeyService.start() }
}
@@ -304,6 +493,10 @@ final class MacDictationViewModel: ObservableObject {
config.apply(preset: provider)
}
func selectAsrProvider(_ provider: LLMProvider) {
config.applyAsr(preset: provider)
}
func refreshForegroundAppName() {
foregroundAppName = MacAppContextService.frontmostApplicationName()
}
+84 -62
View File
@@ -1,9 +1,8 @@
// MacDictionaryView.swift
// OSGKeyboard · Mac
//
// Personal dictionary synced via iCloud KVS with the iOS app. Read-only on
// the desktop (words are authored on iPhone / iPad): grouped cards (native
// `Form`) with search, matching the Settings and History card style.
// Personal dictionary synced via iCloud KVS. ScrollView is full-bleed
// (scrollbar on the window edge); title + cards share `pageHorizontalInset`.
import SwiftUI
@@ -45,16 +44,27 @@ struct MacDictionaryView: View {
}
var body: some View {
Group {
if entries.isEmpty {
emptyState
.transition(.opacity)
} else {
form
.transition(.opacity)
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.dictionary", language: lang),
subtitle: MacL10n.string("mac.page.dictionary.subtitle", language: lang)
) {
if !entries.isEmpty {
searchField
}
}
Group {
if entries.isEmpty {
emptyState
.transition(.opacity)
} else {
list
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
.animation(Motion.soft, value: entries.isEmpty)
.task {
@@ -64,33 +74,6 @@ struct MacDictionaryView: View {
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
viewModel.refreshDictionaryFromCloud()
}
}
// MARK: - Grouped cards
private var form: some View {
Form {
if sections.isEmpty {
Section {
Text(MacL10n.string("mac.dict.noMatch", language: lang))
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
}
} else {
ForEach(sections, id: \.category) { section in
Section(MacL10n.string(section.category.labelKey, language: lang)) {
ForEach(section.items) { entry in
row(entry)
}
}
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.animation(Motion.soft, value: query)
.safeAreaInset(edge: .top, spacing: 0) { centeredSearchField }
.confirmationDialog(
MacL10n.string("mac.dict.deleteTitle", language: lang),
isPresented: deletionDialogBinding,
@@ -110,6 +93,51 @@ struct MacDictionaryView: View {
}
}
// MARK: - List
private var list: some View {
// Full-bleed ScrollView scrollbar on the detail pane's right edge.
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
if sections.isEmpty {
MacCard {
Text(MacL10n.string("mac.dict.noMatch", language: lang))
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
}
} else {
ForEach(sections, id: \.category) { section in
categorySection(section)
}
}
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.md)
.animation(Motion.soft, value: query)
}
}
private func categorySection(
_ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])
) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(MacL10n.string(section.category.labelKey, language: lang))
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) {
VStack(spacing: 0) {
ForEach(section.items, id: \.id) { entry in
row(entry)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
}
}
}
}
private var deletionDialogBinding: Binding<Bool> {
Binding(
get: { entryPendingDeletion != nil },
@@ -117,25 +145,19 @@ struct MacDictionaryView: View {
)
}
private var centeredSearchField: some View {
HStack {
Spacer()
HStack(spacing: Spacing.xs) {
Image(systemName: "magnifyingglass")
.foregroundStyle(palette.textTertiary)
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
.textFieldStyle(.plain)
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.frame(width: 240)
.macGlassSurface(in: Capsule(), fillOpacity: 0.72)
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
Spacer()
private var searchField: some View {
HStack(spacing: Spacing.xs) {
Image(systemName: "magnifyingglass")
.foregroundStyle(palette.textTertiary)
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
.textFieldStyle(.plain)
.font(TypeStyle.footnote)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 6)
.frame(width: 220)
.background(palette.surface, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
private func row(_ entry: PersonalDictionary.Entry) -> some View {
@@ -165,19 +187,19 @@ struct MacDictionaryView: View {
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "character.book.closed")
.font(.system(size: 34))
.foregroundStyle(palette.textTertiary.opacity(0.6))
.font(.system(size: 34, weight: .light))
.foregroundStyle(palette.textTertiary.opacity(0.55))
.symbolRenderingMode(.hierarchical)
Text(MacL10n.string("mac.dict.empty", language: lang))
.font(TypeStyle.body)
.font(TypeStyle.headline)
.foregroundStyle(palette.textSecondary)
Text(MacL10n.string("mac.dict.emptyBody", language: lang))
.font(TypeStyle.caption)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
.frame(maxWidth: 320)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, Spacing.xl)
}
private func delete(_ entry: PersonalDictionary.Entry) {
+70 -44
View File
@@ -1,9 +1,8 @@
// MacHistoryView.swift
// OSGKeyboard · Mac
//
// Single-column, day-grouped transcript log rendered as grouped cards (the
// same native `Form` container as Settings). Every entry shows its full text
// inline no master/detail split, so content never pushes the sidebar out.
// Day-grouped transcript log. ScrollView is full-bleed (scrollbar on the
// window edge); title + cards share `pageHorizontalInset` on their content.
import SwiftUI
@@ -31,36 +30,39 @@ struct MacHistoryView: View {
}()
var body: some View {
Group {
if historyStore.entries.isEmpty {
emptyState
.transition(.opacity)
} else {
form
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.background)
.animation(Motion.soft, value: historyStore.entries.isEmpty)
}
// MARK: - Grouped cards
private var form: some View {
Form {
ForEach(historyStore.groupedByDay, id: \.day) { group in
Section(Self.dayFormatter.string(from: group.day)) {
ForEach(group.items) { entry in
row(entry)
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.history", language: lang),
subtitle: MacL10n.string("mac.page.history.subtitle", language: lang)
) {
if !historyStore.entries.isEmpty {
Button {
showClearConfirmation = true
} label: {
Label(
MacL10n.string("mac.history.clearConfirm", language: lang),
systemImage: "trash"
)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.buttonStyle(.plain)
}
}
Group {
if historyStore.entries.isEmpty {
emptyState
.transition(.opacity)
} else {
list
.transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.safeAreaInset(edge: .top, spacing: 0) { toolbar }
.animation(Motion.soft, value: historyStore.entries.isEmpty)
.confirmationDialog(
MacL10n.string("mac.history.clearTitle", language: lang),
isPresented: $showClearConfirmation,
@@ -75,21 +77,39 @@ struct MacHistoryView: View {
}
}
private var toolbar: some View {
HStack {
Spacer()
Button {
showClearConfirmation = true
} label: {
Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash")
.font(TypeStyle.caption)
// MARK: - List
private var list: some View {
// Full-bleed ScrollView scrollbar on the detail pane's right edge.
// Horizontal inset lives on the content so cards align with the title.
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
ForEach(historyStore.groupedByDay, id: \.day) { group in
daySection(group)
}
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.md)
}
}
private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: group.day))
.font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) {
VStack(spacing: 0) {
ForEach(group.items, id: \.id) { entry in
row(entry)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
}
}
.buttonStyle(.borderless)
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
}
private func row(_ entry: SpeechHistoryEntry) -> some View {
@@ -107,11 +127,17 @@ struct MacHistoryView: View {
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Image(systemName: "text.bubble")
.font(.system(size: 34))
.foregroundStyle(palette.textTertiary.opacity(0.6))
.font(.system(size: 34, weight: .light))
.foregroundStyle(palette.textTertiary.opacity(0.55))
.symbolRenderingMode(.hierarchical)
Text(MacL10n.string("mac.history.empty", language: lang))
.font(TypeStyle.body)
.font(TypeStyle.headline)
.foregroundStyle(palette.textSecondary)
Text(MacL10n.string("mac.history.emptyBody", language: lang))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 320)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
+112 -19
View File
@@ -1,33 +1,93 @@
// MacHotkeyService.swift
// OSGKeyboard · Mac
//
// Global hold-to-talk: while Option () is held, dictation runs. Mirrors
// Typeless / SayIt push-to-talk from any foreground app.
// Global hold-to-talk: while the configured Option () key is held, dictation
// runs. Mirrors Typeless / SayIt push-to-talk from any foreground app.
import AppKit
import Foundation
/// Which physical Option () key triggers global hold-to-talk.
///
/// Right Option is the default: the left key is a routine typing modifier
/// (special characters, app shortcuts), so firing on any Option press
/// constantly misfires during normal typing.
enum MacHotkeyTrigger: String, CaseIterable, Identifiable {
case rightOption
case leftOption
case eitherOption
var id: String { rawValue }
var labelKey: String {
switch self {
case .rightOption: return "mac.hotkeyTrigger.rightOption"
case .leftOption: return "mac.hotkeyTrigger.leftOption"
case .eitherOption: return "mac.hotkeyTrigger.eitherOption"
}
}
/// Main-window hint under the record button must follow the picker,
/// or the UI tells left-Option users to hold the right key.
var hintKey: String {
switch self {
case .rightOption: return "mac.hint.hold.rightOption"
case .leftOption: return "mac.hint.hold.leftOption"
case .eitherOption: return "mac.hint.hold.eitherOption"
}
}
/// `@AppStorage`-compatible key; persisted via the view model's defaults.
static let storageKey = "mac.hotkeyTrigger"
/// Device-dependent modifier bits (IOKit `NX_DEVICELALTKEYMASK` /
/// `NX_DEVICERALTKEYMASK`) that `.flagsChanged` events carry alongside the
/// device-independent `.option` flag, telling left and right apart.
private static let leftOptionMask: UInt = 0x20
private static let rightOptionMask: UInt = 0x40
/// Whether this trigger's key is currently down in a `.flagsChanged` event.
func isPressed(in event: NSEvent) -> Bool {
guard event.modifierFlags.contains(.option) else { return false }
let raw = event.modifierFlags.rawValue
switch self {
case .rightOption: return raw & Self.rightOptionMask != 0
case .leftOption: return raw & Self.leftOptionMask != 0
case .eitherOption: return true
}
}
}
@MainActor
final class MacHotkeyService {
/// How long the trigger key must stay held before recording begins.
/// Filters out quick -taps and +key combos (special characters, app
/// shortcuts) that would otherwise start and immediately abort dictation.
private static let holdDebounce: Duration = .milliseconds(150)
var onPressBegan: (() -> Void)?
var onPressEnded: (() -> Void)?
var trigger: MacHotkeyTrigger = .rightOption
private var globalFlagsMonitor: Any?
private var localFlagsMonitor: Any?
private var optionHeld = false
/// The trigger key is physically down (debounce may still be pending).
private var triggerKeyDown = false
/// `onPressBegan` has fired and `onPressEnded` is owed.
private var pressActive = false
private var pendingBegin: Task<Void, Never>?
private var isEnabled = true
func setEnabled(_ enabled: Bool) {
isEnabled = enabled
if !enabled, optionHeld {
optionHeld = false
onPressEnded?()
}
if !enabled { cancelPress() }
}
func start() {
guard globalFlagsMonitor == nil else { return }
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return }
// Global monitors require Accessibility; without it the call returns
// nil and Option-hold never fires outside our own windows.
let trusted = MacTextInsertionService.requestAccessibilityIfNeeded()
globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in self?.handleFlagsChanged(event) }
@@ -36,6 +96,12 @@ final class MacHotkeyService {
Task { @MainActor in self?.handleFlagsChanged(event) }
return event
}
#if DEBUG
if !trusted || globalFlagsMonitor == nil {
NSLog("[OSGKeyboard] Hotkey global monitor unavailable — grant Accessibility in System Settings")
}
#endif
}
func stop() {
@@ -47,20 +113,47 @@ final class MacHotkeyService {
NSEvent.removeMonitor(localFlagsMonitor)
self.localFlagsMonitor = nil
}
if optionHeld {
optionHeld = false
onPressEnded?()
}
cancelPress()
}
private func handleFlagsChanged(_ event: NSEvent) {
guard isEnabled else { return }
let optionDown = event.modifierFlags.contains(.option)
if optionDown, !optionHeld {
optionHeld = true
onPressBegan?()
} else if !optionDown, optionHeld {
optionHeld = false
let triggerDown = trigger.isPressed(in: event)
if triggerDown, !triggerKeyDown {
triggerKeyDown = true
scheduleBegin()
} else if !triggerDown, triggerKeyDown {
triggerKeyDown = false
pendingBegin?.cancel()
pendingBegin = nil
if pressActive {
pressActive = false
onPressEnded?()
}
}
}
/// Debounce: begin only after the key has stayed held for `holdDebounce`.
/// Releasing the key first cancels the pending start, so a quick
/// Option+key combo never triggers recording.
private func scheduleBegin() {
pendingBegin?.cancel()
pendingBegin = Task { [weak self] in
try? await Task.sleep(for: Self.holdDebounce)
guard let self, !Task.isCancelled else { return }
self.pendingBegin = nil
guard self.isEnabled, self.triggerKeyDown, !self.pressActive else { return }
self.pressActive = true
self.onPressBegan?()
}
}
private func cancelPress() {
pendingBegin?.cancel()
pendingBegin = nil
triggerKeyDown = false
if pressActive {
pressActive = false
onPressEnded?()
}
}
+43 -95
View File
@@ -1,10 +1,8 @@
// MacICloudSyncRows.swift
// OSGKeyboard · Mac
//
// iCloud sync toggles for settings and personal dictionary same KVS
// keys and merge rules as the iOS settings page. Rendered as native Form
// rows (Toggle + optional action / error) so they sit inside a grouped
// `Form` section and match System Settings exactly.
// iCloud sync toggle for settings, history, API keys, and personal
// dictionary same KVS keys and merge rules as the iOS settings page.
import SwiftUI
@@ -19,38 +17,51 @@ struct MacSettingsICloudSyncRow: View {
@State private var isSyncingNow = false
var body: some View {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(MacL10n.string("mac.sync.settingsTitle", language: language))
MacProviderSettingRow(
title: MacL10n.string("mac.sync.settingsTitle", language: language),
verticalAlignment: .center
) {
HStack(alignment: .center, spacing: Spacing.sm) {
Toggle("", isOn: toggleBinding)
.labelsHidden()
.toggleStyle(MacToggleStyle())
.disabled(isApplyingToggle)
Text(MacL10n.string("mac.sync.settingsSubtitle", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
reloadFromStore()
}
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
reloadFromStore()
}
if isEnabled {
Button {
syncNow()
} label: {
HStack(spacing: Spacing.xs) {
if isSyncingNow { ProgressView().controlSize(.small) }
Text(MacL10n.string("mac.sync.syncNow", language: language))
HStack(spacing: Spacing.xs) {
MacSettingsToolButton(
title: MacL10n.string("mac.sync.syncNow", language: language),
disabled: isSyncingNow || isApplyingToggle
) {
syncNow()
}
if isSyncingNow { ProgressView().controlSize(.small) }
Spacer(minLength: 0)
}
.disabled(isSyncingNow || isApplyingToggle)
.padding(.horizontal, MacMetrics.settingsCardInset)
.padding(.bottom, Spacing.sm)
}
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption)
.font(MacSettingsType.hint)
.foregroundStyle(palette.danger)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
.padding(.bottom, Spacing.sm)
}
}
@@ -74,6 +85,19 @@ struct MacSettingsICloudSyncRow: View {
Task {
do {
try await MacICloudSyncBootstrap.settingsSync.enableSync()
do {
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
} catch let error as PersonalDictionaryCloudSyncError {
MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
return
}
reloadFromStore()
} catch {
isEnabled = false
@@ -102,79 +126,3 @@ struct MacSettingsICloudSyncRow: View {
}
}
}
struct MacDictionaryICloudSyncRow: View {
let defaults: UserDefaults
let language: AppUILanguage
@Environment(\.themePalette) private var palette
@State private var isEnabled = false
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
var body: some View {
Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(MacL10n.string("mac.sync.dictTitle", language: language))
Text(MacL10n.string("mac.sync.dictSubtitle", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
reloadFromStore()
}
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
newValue ? enableSync() : disableSync()
}
)
}
private func reloadFromStore() {
isEnabled = AppGroupStore(defaults: defaults).personalDictionaryICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
reloadFromStore()
} catch let error as PersonalDictionaryCloudSyncError {
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
} catch {
isEnabled = false
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
}
}
private func disableSync() {
MacICloudSyncBootstrap.dictionarySync.disableSync()
isEnabled = false
syncErrorMessage = nil
}
}
@@ -0,0 +1,43 @@
// MacLocalASRChunkAdapter.swift
// OSGKeyboard · Mac
//
// Adapts macOS local ASR to the shared chunked utterance pipeline.
import Foundation
import os
final class MacLocalASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
private let locale: Locale
private let bias: LocalASRBiasPayload?
private let cancelled = OSAllocatedUnfairLock(initialState: false)
init(locale: Locale, bias: LocalASRBiasPayload?) {
self.locale = locale
self.bias = bias
}
func resetForNewUtterance() {
cancelled.withLock { $0 = false }
}
func cancel() {
cancelled.withLock { $0 = true }
}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
let isCancelled = cancelled.withLock { $0 }
if isCancelled || Task.isCancelled { return .cancelled }
guard !samples.isEmpty else { return .success("") }
do {
let text = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
bias: bias
)
return .success(text)
} catch {
return .failure(error.localizedDescription)
}
}
}
+128 -79
View File
@@ -198,6 +198,9 @@ struct MacLocalASRModelSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel
@StateObject private var modelVM = MacLocalASRModelSettingsViewModel()
@Environment(\.themePalette) private var palette
/// Only one model installs at a time, so a single hover flag drives the
/// active download ring's pause/resume affordance.
@State private var isHoveringProgress = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
@@ -217,42 +220,54 @@ struct MacLocalASRModelSettingsView: View {
}
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
Section {
if let runtime = modelVM.currentRuntime(in: catalog) {
LabeledContent(runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
if let runtime = modelVM.currentRuntime(in: catalog) {
MacFormSubtitleRow(title: runtime.displayName) {
Text(
modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang)
)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
}
}
}
ForEach(catalog.models) { model in
modelRow(model)
}
ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in
modelRow(model)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
if modelVM.isInstalling,
modelVM.installProgress.phase == .extracting
|| modelVM.installProgress.phase == .validating
|| modelVM.installProgress.phase == .finalizing {
ProgressView(value: modelVM.installProgress.fraction) {
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
if modelVM.isInstalling,
modelVM.installProgress.phase == .extracting
|| modelVM.installProgress.phase == .validating
|| modelVM.installProgress.phase == .finalizing {
ProgressView(value: modelVM.installProgress.fraction) {
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
.font(TypeStyle.caption)
}
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
}
if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
HStack(spacing: 0) {
MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot()
}
Spacer(minLength: 0)
}
.padding(.horizontal, MacMetrics.settingsCardInset)
}
Button(MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot()
}
} header: {
Text(MacL10n.string("mac.localASR.models", language: lang))
}
}
@@ -276,14 +291,17 @@ struct MacLocalASRModelSettingsView: View {
HStack(spacing: Spacing.xs) {
Text(model.displayName)
.foregroundStyle(palette.textPrimary)
if let badgeKey = model.badgeKey {
modelBadge(
MacL10n.string(badgeKey, language: lang),
emphasized: true
)
}
if model.supportsHotwords {
Text(MacL10n.string("mac.localASR.personalDictionaryTag", language: lang))
.font(TypeStyle.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(palette.accent.opacity(0.15))
.foregroundStyle(palette.accent)
.clipShape(Capsule())
modelBadge(
MacL10n.string("mac.localASR.personalDictionaryTag", language: lang),
emphasized: false
)
}
}
Text(modelSubtitle(model, installed: installed))
@@ -302,7 +320,21 @@ struct MacLocalASRModelSettingsView: View {
.animation(Motion.soft, value: installed)
}
}
.padding(.vertical, 2)
.padding(.vertical, 0)
}
private func modelBadge(_ title: String, emphasized: Bool) -> some View {
Text(title)
.font(TypeStyle.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
emphasized
? palette.accent.opacity(0.18)
: palette.textTertiary.opacity(0.12)
)
.foregroundStyle(emphasized ? palette.accent : palette.textSecondary)
.clipShape(Capsule())
}
@ViewBuilder
@@ -312,49 +344,39 @@ struct MacLocalASRModelSettingsView: View {
installing: Bool
) -> some View {
if installing {
HStack(spacing: Spacing.sm) {
circularInstallProgress(for: model)
if modelVM.installProgress.phase == .downloading
|| modelVM.installProgress.phase == .paused {
Button {
if modelVM.isDownloadPaused {
modelVM.resumeDownload()
} else {
modelVM.pauseDownload()
}
} label: {
Image(systemName: modelVM.isDownloadPaused ? "play.fill" : "pause.fill")
.font(.system(size: 12, weight: .semibold))
.frame(width: 28, height: 28)
}
.buttonStyle(.bordered)
.controlSize(.small)
.help(
modelVM.isDownloadPaused
? MacL10n.string("mac.localASR.resume", language: lang)
: MacL10n.string("mac.localASR.pause", language: lang)
)
}
}
installProgressControl(for: model)
.animation(Motion.quick, value: isHoveringProgress)
.animation(Motion.quick, value: modelVM.isDownloadPaused)
} else if installed {
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
MacSettingsToolButton(
title: MacL10n.string("mac.localASR.delete", language: lang),
fill: palette.danger.opacity(0.15),
foreground: palette.danger,
showsBorder: false
) {
modelVM.deleteModel(model)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(palette.danger)
} else {
Button(MacL10n.string("mac.localASR.download", language: lang)) {
MacSettingsToolButton(
title: MacL10n.string("mac.localASR.download", language: lang),
fill: palette.accent,
foreground: .white,
showsBorder: false
) {
modelVM.installModel(model)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View {
/// Single download ring. Shows the percentage while downloading; on hover it
/// reveals a clickable pause icon, and once paused it keeps a resume (play)
/// icon so the state stays clear without a second button.
private func installProgressControl(for model: LocalASRModelDefinition) -> some View {
let phase = modelVM.installProgress.phase
let pausable = phase == .downloading || phase == .paused
let paused = modelVM.isDownloadPaused
let fraction: Double = {
if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused,
if pausable,
let received = modelVM.installProgress.bytesReceived,
let total = modelVM.installProgress.bytesTotal,
total > 0 {
@@ -362,6 +384,7 @@ struct MacLocalASRModelSettingsView: View {
}
return modelVM.installProgress.fraction
}()
return ZStack {
Circle()
.stroke(palette.textTertiary.opacity(0.25), lineWidth: 3)
@@ -370,20 +393,46 @@ struct MacLocalASRModelSettingsView: View {
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(Motion.instant, value: fraction)
if modelVM.installProgress.phase == .paused {
Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textSecondary)
} else {
Text("\(Int(fraction * 100))%")
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(palette.textSecondary)
}
progressCenter(pausable: pausable, paused: paused, fraction: fraction)
}
.frame(width: 36, height: 36)
.contentShape(Circle())
.onHover { hovering in
guard pausable else { return }
isHoveringProgress = hovering
}
.onTapGesture {
guard pausable else { return }
if paused { modelVM.resumeDownload() } else { modelVM.pauseDownload() }
}
.help(
pausable
? (paused
? MacL10n.string("mac.localASR.resume", language: lang)
: MacL10n.string("mac.localASR.pause", language: lang))
: modelVM.progressLabel(for: modelVM.installProgress, language: lang)
)
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
}
@ViewBuilder
private func progressCenter(pausable: Bool, paused: Bool, fraction: Double) -> some View {
if pausable, paused {
Image(systemName: "play.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.accent)
} else if pausable, isHoveringProgress {
Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textSecondary)
} else {
Text("\(Int(fraction * 100))%")
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(palette.textSecondary)
}
}
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
let size = modelVM.formattedSize(model.sizeBytes)
if let usage = modelVM.installedDiskUsage(model) {
+3 -3
View File
@@ -58,7 +58,7 @@ enum MacLocalASRPreferences {
/// Maps removed catalog entries to the current default Sherpa model.
static func migratedModelId(_ id: String) -> String {
switch id {
case "qwen3-mlx-1.7b":
case "qwen3-mlx-1.7b", "sherpa-paraformer-zh-int8":
return "sherpa-qwen3-0.6b-int8"
default:
return id
@@ -120,7 +120,7 @@ enum MacLocalASRService {
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
}
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
}
private static func transcribeWithModel(
@@ -141,7 +141,7 @@ enum MacLocalASRService {
bias: bias
)
case .appleSpeech:
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
}
}
}
+9 -6
View File
@@ -198,7 +198,7 @@ struct MacOnboardingView: View {
.frame(maxWidth: .infinity)
}
}
.frame(minWidth: 860, minHeight: 600)
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
.onAppear {
applyDefaults()
model.reload()
@@ -422,12 +422,15 @@ struct MacOnboardingView: View {
private var cloudAPIFields: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) {
ForEach(viewModel.selectableProviders) { provider in
Text(provider.name).tag(provider.id)
MacInlinePicker(
selection: providerBinding,
options: viewModel.selectableProviders.map {
MacInlinePickerOption(
value: $0.id,
label: ProviderDisplayName.name(for: $0.id, language: lang)
)
}
}
.labelsHidden()
)
.frame(maxWidth: .infinity, alignment: .leading)
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
-95
View File
@@ -1,95 +0,0 @@
// MacQwen3ASREngine.swift
// OSGKeyboard · Mac
//
// Singleton actor that loads, warms up, and runs Qwen3-ASR via mlx-swift-asr.
// Model load + Metal JIT warmup take several seconds call `prepareIfNeeded`
// at launch so the first dictation is fast.
import Foundation
import MLXASR
/// Lifecycle of the on-disk MLX model inside the app process.
enum MacQwen3EnginePhase: Sendable, Equatable {
case idle
case loading
case ready
case failed(String)
}
actor MacQwen3ASREngine {
static let shared = MacQwen3ASREngine()
private var stt: Qwen3ASRSTT?
private var loadedModelPath: String?
private(set) var phase: MacQwen3EnginePhase = .idle
private init() {}
/// Load and warm up the model when the path changes or nothing is loaded yet.
func prepareIfNeeded(modelPath: String) async throws {
if loadedModelPath == modelPath, stt != nil, phase == .ready { return }
phase = .loading
stt = nil
loadedModelPath = nil
let directory = URL(fileURLWithPath: modelPath, isDirectory: true)
do {
let instance = try await Qwen3ASRSTT.loadWithWarmup(from: directory)
stt = instance
loadedModelPath = modelPath
phase = .ready
} catch {
let detail = error.localizedDescription
phase = .failed(detail)
throw MacLocalASRError.qwen3LoadFailed(detail)
}
}
/// Transcribe mono 16 kHz float PCM. Ensures the model is loaded first.
func transcribe(
samples: [Float],
language: String?,
modelPath: String,
context: String? = nil
) async throws -> String {
try await prepareIfNeeded(modelPath: modelPath)
guard let stt else {
throw MacLocalASRError.qwen3LoadFailed("Engine not initialized")
}
let result = try await stt.transcribe(audio: samples, language: language, context: context)
let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw MacLocalASRError.emptyTranscript
}
return text
}
/// Drop cached weights (e.g. after the user changes the model folder).
func unload() {
stt = nil
loadedModelPath = nil
phase = .idle
}
}
enum MacQwen3LanguageHint {
/// Map persisted BCP-47 locale ids to Qwen3 prompt language names.
/// Returns `nil` for auto-detect.
static func from(locale: Locale) -> String? {
let raw = locale.identifier.lowercased()
if raw.isEmpty || raw == "auto" { return nil }
if raw.hasPrefix("zh") { return "Chinese" }
if raw.hasPrefix("en") { return "English" }
if raw.hasPrefix("ja") { return "Japanese" }
if raw.hasPrefix("ko") { return "Korean" }
if raw.hasPrefix("fr") { return "French" }
if raw.hasPrefix("de") { return "German" }
if raw.hasPrefix("es") { return "Spanish" }
if raw.hasPrefix("pt") { return "Portuguese" }
if raw.hasPrefix("ru") { return "Russian" }
if raw.hasPrefix("ar") { return "Arabic" }
return nil
}
}
-58
View File
@@ -1,58 +0,0 @@
// MacQwen3LocalASR.swift
// OSGKeyboard · Mac
//
// Qwen3-ASR-1.7B (MLX) via mlx-swift-asr. Expects a converted model directory
// containing config.json, model.safetensors, and tokenizer files.
import Foundation
enum MacQwen3LocalASR {
/// Transcribe with Qwen3-ASR MLX weights at `modelPath`.
static func transcribe(
samples: [Float],
sampleRate: Int,
locale: Locale,
modelPath: String,
bias: LocalASRBiasPayload? = nil
) async throws -> String {
guard modelDirectoryIsInstalled(at: modelPath) else {
throw MacLocalASRError.qwen3ModelMissing
}
guard sampleRate == 16_000 else {
throw MacLocalASRError.qwen3InferenceFailed(
"Qwen3-ASR expects 16 kHz audio (got \(sampleRate) Hz)"
)
}
let language = MacQwen3LanguageHint.from(locale: locale)
let context = bias?.promptBias?.trimmingCharacters(in: .whitespacesAndNewlines)
let promptContext = (context?.isEmpty == false) ? context : nil
do {
return try await MacQwen3ASREngine.shared.transcribe(
samples: samples,
language: language,
modelPath: modelPath,
context: promptContext
)
} catch let error as MacLocalASRError {
throw error
} catch {
throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription)
}
}
private static func modelDirectoryIsInstalled(at path: String) -> Bool {
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else {
return false
}
let fm = FileManager.default
let config = (path as NSString).appendingPathComponent("config.json")
let weights = (path as NSString).appendingPathComponent("model.safetensors")
guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else {
return false
}
let names = (try? fm.contentsOfDirectory(atPath: path)) ?? []
return names.contains("vocab.json") && names.contains("merges.txt")
}
}
+15 -24
View File
@@ -18,15 +18,6 @@ struct MacRootView: View {
private var uiLanguage: AppUILanguage { viewModel.config.uiLanguage }
/// `List` selection is optional; keep the view model's non-optional section
/// in sync without letting a nil selection blank the detail pane.
private var selection: Binding<MacSection?> {
Binding(
get: { viewModel.selectedSection },
set: { if let new = $0 { viewModel.selectedSection = new } }
)
}
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
@@ -35,7 +26,7 @@ struct MacRootView: View {
detail
}
.navigationSplitViewStyle(.balanced)
.frame(minWidth: 860, minHeight: 600)
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
.onAppear {
// Let the AppKit status-bar popover reopen this window on demand.
MacWindowBridge.shared.open = { openWindow(id: "main") }
@@ -47,7 +38,7 @@ struct MacRootView: View {
private var sidebar: some View {
VStack(spacing: 0) {
brandHeader
VStack(spacing: 4) {
VStack(spacing: 6) {
ForEach(MacSection.allCases) { section in
MacSidebarRow(
section: section,
@@ -72,15 +63,15 @@ struct MacRootView: View {
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: 30)
.frame(height: 28)
.foregroundStyle(palette.accent)
.accessibilityLabel("OSGKeyboard")
Spacer()
}
.padding(.leading, MacMetrics.sidebarContentInset)
.padding(.trailing, MacMetrics.sidebarInset)
.padding(.top, Spacing.lg)
.padding(.bottom, Spacing.lg)
.padding(.top, 30)
.padding(.bottom, 30)
}
private var devicesFooter: some View {
@@ -88,10 +79,10 @@ struct MacRootView: View {
MacL10n.string("mac.devices", language: uiLanguage),
systemImage: "laptopcomputer.and.iphone"
)
.font(TypeStyle.caption)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.sidebarInset)
.padding(.horizontal, MacMetrics.sidebarInset + Spacing.sm)
.padding(.vertical, Spacing.sm)
}
@@ -118,8 +109,8 @@ struct MacRootView: View {
// MARK: - Sidebar row
/// A navigation row with an animated hover highlight and selection state,
/// matching the macOS System Settings feel.
/// Navigation row with a restrained selected state: muted accent fill +
/// accent label (not a solid green pill), matching System Settings polish.
private struct MacSidebarRow: View {
let section: MacSection
let isSelected: Bool
@@ -132,14 +123,14 @@ private struct MacSidebarRow: View {
var body: some View {
Button(action: action) {
Label(section.title(language: language), systemImage: section.systemImage)
.font(.system(size: 13))
.foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary)
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 7)
.padding(.vertical, 9)
.background(
rowBackground,
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
)
.contentShape(Rectangle())
}
@@ -150,7 +141,7 @@ private struct MacSidebarRow: View {
}
private var rowBackground: Color {
if isSelected { return palette.accent }
return isHovering ? palette.textPrimary.opacity(0.06) : .clear
if isSelected { return palette.accentMuted }
return isHovering ? palette.textPrimary.opacity(0.05) : .clear
}
}
+543
View File
@@ -0,0 +1,543 @@
// MacSettingsComponents.swift
// OSGKeyboard · Mac
//
// Provider configuration rows for the Settings Form merged card layout,
// responsive label/control rows, and theme-aware credential chrome.
import SwiftUI
#if os(macOS)
import AppKit
#endif
// MARK: - Width-locked inline picker
/// Option for `MacInlinePicker`: a hashable value plus its display label.
struct MacInlinePickerOption<Value: Hashable>: Identifiable {
let value: Value
let label: String
var id: Value { value }
}
/// Boxed dropdown trigger that mirrors `.macFieldStyle()` chrome so every picker
/// reads as an editable field: the selected value on the left, a trailing
/// up/down chevron inside the *same* well. Holds a `minWidth` field footprint and
/// expands up to its column when the caller offers width (provider rows).
private struct MacPickerFieldBox: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let text: String
var body: some View {
// Same shape/fill/border rules as `MacFieldStyleModifier` so the dropdown
// sits flush with the credential text fields around it.
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
HStack(spacing: Spacing.sm) {
Text(text)
.font(MacSettingsType.control)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.sm)
// `minWidth` gives short values (e.g. "") a proper field footprint;
// `maxWidth: .infinity` lets fixed-label rows (provider picker) fill their
// column, while `.fixedSize()` callers (inline rows) hug to the minimum.
.frame(minWidth: 132, maxWidth: .infinity, minHeight: MacMetrics.settingsControlHeight, alignment: .leading)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
.contentShape(shape)
}
/// Light mode uses the true text-input white so the box reads as an editable
/// well; dark mode keeps the elevated grey (mirrors `MacFieldStyleModifier`).
private var fieldFill: Color {
#if os(macOS)
if colorScheme != .dark {
return Color(nsColor: .textBackgroundColor)
}
#endif
return palette.surfaceElevated
}
}
/// Dropdown that renders as an editable-looking field (see `MacPickerFieldBox`).
/// A `.menu` `Picker` renders as a native pop-up that ignores `.frame` and drops
/// custom labels; `Menu` instead renders its `label:` as the real control. Kept at
/// `.fixedSize()` so it hugs its content and is right-aligned by the enclosing
/// row's `Spacer`, matching the toggles/status text that share `MacInlineRow`.
struct MacInlinePicker<Value: Hashable>: View {
@Binding var selection: Value
let options: [MacInlinePickerOption<Value>]
/// When true the boxed field fills its column and is left-aligned matches
/// the two-column provider rows (see `MacProviderPickerRow`). When false it
/// hugs its content so the enclosing row's `Spacer` can right-align it.
var fillsWidth: Bool = false
var body: some View {
if fillsWidth {
menuButton
.frame(maxWidth: .infinity, alignment: .leading)
} else {
menuButton
.fixedSize()
}
}
private var menuButton: some View {
Menu {
ForEach(options) { option in
Button {
selection = option.value
} label: {
if option.value == selection {
Label(option.label, systemImage: "checkmark")
} else {
Text(option.label)
}
}
}
} label: {
MacPickerFieldBox(text: selectedLabel)
}
// `.button` menu style + `.plain` button style renders the custom
// `MacPickerFieldBox` label verbatim; `.borderlessButton` would instead
// draw macOS's own borderless pop-up chrome (accent chevrons, no box).
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
}
private var selectedLabel: String {
options.first { $0.value == selection }?.label ?? ""
}
}
// MARK: - Provider picker
struct MacProviderPickerRow: View {
let title: String
let providers: [LLMProvider]
@Binding var selection: String
var body: some View {
MacProviderSettingRow(title: title) {
Menu {
ForEach(providers) { provider in
Button {
selection = provider.id
} label: {
let name = ProviderDisplayName.name(for: provider.id)
if provider.id == selection {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
MacPickerFieldBox(text: selectedName)
}
// See `MacInlinePicker`: `.button` + `.plain` keeps our boxed label
// instead of macOS's borderless pop-up chrome.
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var selectedName: String {
let id = providers.first { $0.id == selection }?.id ?? selection
return ProviderDisplayName.name(for: id)
}
}
// MARK: - Credential field
struct MacCredentialField: View {
let title: String
let placeholder: String
@Binding var text: String
var isSecret: Bool = false
var isMonospaced: Bool = true
var defaultValue: String?
var trailing: AnyView?
@State private var revealed = false
var body: some View {
MacProviderSettingRow(title: title) {
HStack(spacing: Spacing.xs) {
input
.frame(maxWidth: .infinity, alignment: .leading)
if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
MacSettingsIconButton(systemName: "checkmark", help: "Fill default") {
text = defaultValue
}
}
if let trailing {
trailing
}
if isSecret {
MacSettingsIconButton(
systemName: revealed ? "eye.slash" : "eye",
help: revealed ? "Hide" : "Show"
) {
revealed.toggle()
}
}
MacSettingsIconButton(systemName: "doc.on.doc", help: "Copy", disabled: text.isEmpty) {
#if os(macOS)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
#endif
}
}
}
}
@ViewBuilder
private var input: some View {
Group {
if isSecret && !revealed {
SecureField(text: $text, prompt: Text(verbatim: placeholder)) {
Text(title)
}
} else {
TextField(text: $text, prompt: Text(verbatim: placeholder)) {
Text(title)
}
}
}
.labelsHidden()
.autocorrectionDisabled(true)
.lineLimit(1)
.truncationMode(.middle)
.macFieldStyle(monospaced: isMonospaced)
}
}
// MARK: - Thinking toggle
struct MacProviderThinkingRow: View {
@Environment(\.themePalette) private var palette
let title: String
var subtitle: String? = nil
@Binding var isOn: Bool
var body: some View {
MacProviderSettingRow(title: title, verticalAlignment: .center) {
HStack(alignment: .center, spacing: Spacing.sm) {
Toggle("", isOn: $isOn)
.labelsHidden()
.toggleStyle(MacToggleStyle())
.accessibilityLabel(title)
if let subtitle, !subtitle.isEmpty {
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
}
// MARK: - Model picker (editable field + fetch icon)
private struct MacModelComboFieldWidthKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct MacProviderModelRow: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let title: String
let placeholder: String
@Binding var model: String
let apiKey: String
let fetchModels: () async throws -> [String]
let language: AppUILanguage
@State private var models: [String] = []
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
@State private var isDropdownOpen = false
@State private var fieldWidth: CGFloat = 0
private let chevronWidth: CGFloat = 28
private let dropdownMaxHeight: CGFloat = 240
private var trimmedAPIKey: String {
apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Match `MacFieldStyleModifier` fill so the combo well sits flush with
/// neighboring credential fields.
private var fieldFill: Color {
#if os(macOS)
if colorScheme != .dark {
return Color(nsColor: .textBackgroundColor)
}
#endif
return palette.surfaceElevated
}
var body: some View {
MacProviderSettingRow(title: title) {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: Spacing.xs) {
comboField
MacSettingsIconButton(
systemName: "arrow.triangle.2.circlepath",
help: MacL10n.string("mac.settings.fetchModels", language: language),
disabled: isRunning || trimmedAPIKey.isEmpty
) {
isDropdownOpen = false
Task { await runFetchModels() }
}
if isRunning {
ProgressView()
.controlSize(.small)
}
}
statusMessage
}
}
}
/// Editable model id + trailing chevron. Model list is a true popover so the
/// settings row height stays fixed (not an in-flow panel that grows the card).
private var comboField: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
return ZStack(alignment: .trailing) {
TextField(text: $model, prompt: Text(verbatim: placeholder)) {
Text(title)
}
.labelsHidden()
.textFieldStyle(.plain)
.autocorrectionDisabled(true)
.font(TypeStyle.mono)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
.padding(.leading, Spacing.sm)
.padding(.trailing, chevronWidth + Spacing.sm)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsControlHeight, alignment: .leading)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
Button {
isDropdownOpen.toggle()
} label: {
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
.frame(width: chevronWidth, height: MacMetrics.settingsControlHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.trailing, Spacing.sm)
.help(MacL10n.string("mac.settings.selectModel", language: language))
.accessibilityLabel(MacL10n.string("mac.settings.selectModel", language: language))
}
.background(
GeometryReader { geo in
Color.clear.preference(key: MacModelComboFieldWidthKey.self, value: geo.size.width)
}
)
.onPreferenceChange(MacModelComboFieldWidthKey.self) { fieldWidth = $0 }
.popover(isPresented: $isDropdownOpen, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) {
dropdownPanel
.frame(width: max(fieldWidth, 180))
.padding(Spacing.xs)
}
}
private var dropdownPanel: some View {
Group {
if models.isEmpty {
Text(MacL10n.string("mac.settings.modelsEmptyHint", language: language))
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, Spacing.sm)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.xxs) {
ForEach(models, id: \.self) { modelId in
dropdownRow(modelId)
}
}
}
.frame(maxHeight: dropdownMaxHeight)
}
}
}
private func dropdownRow(_ modelId: String) -> some View {
let isSelected = modelId == model
return Button {
model = modelId
message = MacL10n.format(
"mac.settings.modelSelected",
language: language,
modelId
)
failed = false
isDropdownOpen = false
} label: {
HStack(spacing: Spacing.sm) {
Text(modelId)
.font(TypeStyle.mono)
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.sm)
.padding(.vertical, Spacing.sm)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
.fill(isSelected ? palette.accentMuted : Color.clear)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private var statusMessage: some View {
if let message {
Text(message)
.font(MacSettingsType.hint)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
}
}
@MainActor
private func runFetchModels() async {
guard !trimmedAPIKey.isEmpty else {
failed = true
message = SharedL10n.string("providerTools.error.missingAPIKey", language: language)
return
}
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: MacL10n.string("mac.settings.loadingModels", language: language),
loadedMessage: { MacL10n.format("mac.settings.modelsLoaded", language: language, $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty", language: language),
currentModel: model,
fetchModels: fetchModels
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
}
}
}
// MARK: - Connection validate
struct MacProviderToolsRow: View {
@Environment(\.themePalette) private var palette
let title: String
let validate: () async throws -> Void
let language: AppUILanguage
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
var body: some View {
MacProviderSettingRow(title: title) {
HStack(alignment: .center, spacing: Spacing.sm) {
MacSettingsToolButton(
title: MacL10n.string("mac.settings.validate", language: language),
disabled: isRunning
) {
Task { await runValidate() }
}
if isRunning {
ProgressView()
.controlSize(.small)
} else if let message {
Text(message)
.font(MacSettingsType.hint)
.foregroundStyle(failed ? palette.danger : palette.accent)
.lineLimit(3)
.truncationMode(.tail)
}
}
}
}
@MainActor
private func runValidate() async {
isRunning = true
failed = false
defer { isRunning = false }
let outcome = await ProviderToolRunner.runValidate(
runningMessage: MacL10n.string("mac.settings.validating", language: language),
successMessage: MacL10n.string("mac.settings.validateSuccess", language: language),
validate: validate
)
message = outcome.message
failed = outcome.failed
}
}
// MARK: - Caption note row
struct MacProviderNoteRow: View {
@Environment(\.themePalette) private var palette
let text: String
var body: some View {
Text(text)
.font(MacSettingsType.hint)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
}
+313 -194
View File
@@ -1,9 +1,8 @@
// MacSettingsView.swift
// OSGKeyboard · Mac
//
// Settings built on the native grouped `Form` the same container macOS
// System Settings uses. This gives system-accurate cards, dividers, insets
// and right-aligned controls for free, on both light and dark.
// Settings uses native grouped `Form`. LLM / ASR provider blocks use a merged
// `MacSettingsProviderCard` with responsive rows and theme-aware controls.
import SwiftUI
#if os(macOS)
@@ -19,7 +18,6 @@ struct MacSettingsView: View {
@AppStorage(MacOnboardingState.storageKey)
private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
@@ -33,23 +31,35 @@ struct MacSettingsView: View {
var body: some View {
NavigationStack {
Form {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
providerSection
.transition(.opacity)
VStack(spacing: 0) {
MacPageHeader(
title: MacL10n.string("mac.section.settings", language: lang),
subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang)
)
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
generalSection
recognitionSection
if viewModel.config.engineMode == "cloud" {
asrProviderSection
.transition(.opacity)
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
polishProviderSection
inputSection
legalSection
}
.padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.md)
}
if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity)
}
inputSection
legalSection
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.formStyle(.grouped)
.tint(palette.accent)
.scrollContentBackground(.hidden)
.background(palette.background)
}
.onAppear { refreshAccessibilityState() }
@@ -58,136 +68,232 @@ struct MacSettingsView: View {
// MARK: - General
private var generalSection: some View {
Section(MacL10n.string("mac.settings.general", language: lang)) {
Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) {
ForEach(MacAppearancePreference.allCases) { pref in
Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue)
MacSettingsSection(title: MacL10n.string("mac.settings.general", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
MacProviderSettingRow(title: MacL10n.string("mac.settings.appearance", language: lang)) {
MacInlinePicker(
selection: $appearanceRaw,
options: MacAppearancePreference.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
}
}
Picker(MacL10n.string("mac.settings.interfaceLanguage", language: lang), selection: interfaceLanguageBinding) {
ForEach(AppUILanguage.allCases, id: \.self) { language in
Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue)
MacProviderSettingRow(title: MacL10n.string("mac.settings.interfaceLanguage", language: lang)) {
MacInlinePicker(
selection: interfaceLanguageBinding,
options: AppUILanguage.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
}
}
Picker(MacL10n.string("mac.settings.recognitionLanguage", language: lang), selection: recognitionLanguageBinding) {
ForEach(recognitionLocales, id: \.id) { locale in
Text(localeLabel(locale)).tag(locale.id)
MacProviderSettingRow(title: MacL10n.string("mac.settings.recognitionLanguage", language: lang)) {
MacInlinePicker(
selection: recognitionLanguageBinding,
options: recognitionLocales.map {
MacInlinePickerOption(value: $0.id, label: localeLabel($0))
},
fillsWidth: true
)
}
}
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
}
}
}
// MARK: - Cloud provider
// MARK: - Polish LLM
private var providerSection: some View {
Section(MacL10n.string("mac.settings.cloudProvider", language: lang)) {
LabeledContent(MacL10n.string("mac.settings.service", language: lang)) {
Button {
showProviderPicker = true
} label: {
HStack(spacing: 6) {
providerLogo(currentProvider.id)
Text(currentProvider.name)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showProviderPicker, arrowEdge: .bottom) {
providerPickerList
}
}
LabeledContent {
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
}
LabeledContent {
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
Text(MacL10n.string("mac.settings.model", language: lang))
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.model", language: lang))
private var polishProviderSection: some View {
MacSettingsSection(title: MacL10n.string("mac.settings.polishProvider", language: lang)) {
MacSettingsProviderCard {
MacProviderPickerRow(
title: MacL10n.string("mac.settings.service", language: lang),
providers: viewModel.polishSelectableProviders,
selection: polishProviderBinding
)
MacCredentialField(
title: MacL10n.string("mac.settings.apiKey", language: lang),
placeholder: "sk-…",
text: $viewModel.config.apiKey,
isSecret: true
)
MacCredentialField(
title: MacL10n.string("mac.settings.baseURL", language: lang),
placeholder: currentPolishProvider.defaultBaseURL,
text: $viewModel.config.baseURL
)
MacProviderModelRow(
title: MacL10n.string("mac.settings.model", language: lang),
placeholder: currentPolishProvider.defaultModel,
model: $viewModel.config.model,
apiKey: viewModel.config.apiKey,
fetchModels: fetchMacLLMModels,
language: lang
)
.id(viewModel.config.providerId)
MacProviderThinkingRow(
title: MacL10n.string("mac.settings.thinking", language: lang),
subtitle: MacL10n.string("mac.settings.thinkingSubtitle", language: lang),
isOn: $viewModel.config.llmThinkingEnabled
)
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
validate: validateMacLLM,
language: lang
)
}
}
}
// MARK: - Cloud ASR
private var asrProviderSection: some View {
MacSettingsSection(title: MacL10n.string("mac.settings.asrProvider", language: lang)) {
MacSettingsProviderCard {
MacProviderPickerRow(
title: MacL10n.string("mac.settings.asrService", language: lang),
providers: viewModel.asrSelectableProviders,
selection: asrProviderBinding
)
if viewModel.config.asrProviderId == "volcengine" {
volcengineAsrRows
} else {
genericAsrRows
}
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
validate: validateMacASR,
language: lang
)
}
}
}
@ViewBuilder
private var volcengineAsrRows: some View {
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
placeholder: "APP ID",
text: Binding(
get: { macVolcengineFields.appID },
set: { updateMacVolcengine(appID: $0) }
),
isSecret: true
)
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
placeholder: "Access Token",
text: Binding(
get: { macVolcengineFields.accessToken },
set: { updateMacVolcengine(accessToken: $0) }
),
isSecret: true
)
MacCredentialField(
title: MacL10n.string("mac.settings.volcengineResourceId", language: lang),
placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
text: Binding(
get: { macVolcengineFields.resourceID },
set: { updateMacVolcengine(resourceID: $0) }
),
defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
)
MacProviderNoteRow(text: MacL10n.string("mac.settings.volcengineNote", language: lang))
}
@ViewBuilder
private var genericAsrRows: some View {
if CloudASRModelCatalog.showsASREndpointField(for: viewModel.config.asrProviderId) {
MacCredentialField(
title: MacL10n.string("mac.settings.baseURL", language: lang),
placeholder: currentAsrProvider.defaultBaseURL,
text: $viewModel.config.asrBaseURL
)
}
MacCredentialField(
title: MacL10n.string("mac.settings.asrApiKey", language: lang),
placeholder: "sk-…",
text: $viewModel.config.asrApiKey,
isSecret: true
)
MacProviderModelRow(
title: MacL10n.string("mac.settings.asrModel", language: lang),
placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId),
model: $viewModel.config.asrModel,
apiKey: viewModel.config.asrApiKey,
fetchModels: fetchMacASRModels,
language: lang
)
.id(viewModel.config.asrProviderId)
}
// MARK: - Recognition method
private var recognitionSection: some View {
Section(MacL10n.string("mac.settings.recognition", language: lang)) {
methodRow(
MacSettingsSection(title: MacL10n.string("mac.settings.recognition", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
methodRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
methodRow(
methodRow(
title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
systemImage: "cpu",
selected: viewModel.config.engineMode == "local"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
}
}
}
// MARK: - Hotkey / paste
private var inputSection: some View {
Section(MacL10n.string("mac.settings.input", language: lang)) {
Toggle(isOn: hotkeyBinding) {
rowLabel(
MacL10n.string("mac.settings.hotkey", language: lang),
subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang)
)
}
Toggle(isOn: autoPasteBinding) {
rowLabel(
MacL10n.string("mac.settings.autoPaste", language: lang),
subtitle: MacL10n.string("mac.settings.autoPasteDesc", language: lang)
)
}
LabeledContent {
HStack(spacing: Spacing.sm) {
Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
MacSettingsSection(title: MacL10n.string("mac.settings.input", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
MacProviderSettingRow(title: MacL10n.string("mac.settings.hotkeyTrigger", language: lang)) {
MacInlinePicker(
selection: hotkeyTriggerBinding,
options: MacHotkeyTrigger.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
)
.font(TypeStyle.caption)
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted)
}
MacProviderSettingRow(
title: MacL10n.string("mac.settings.autoPaste", language: lang),
verticalAlignment: .center
) {
Toggle("", isOn: autoPasteBinding)
.labelsHidden()
.toggleStyle(MacToggleStyle())
}
MacProviderSettingRow(
title: MacL10n.string("mac.settings.accessibility", language: lang),
verticalAlignment: .center
) {
HStack(spacing: Spacing.sm) {
MacSettingsToolButton(title: MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
}
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
)
.font(TypeStyle.caption)
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted)
}
}
} label: {
rowLabel(
MacL10n.string("mac.settings.accessibility", language: lang),
subtitle: MacL10n.string("mac.settings.accessibilityDesc", language: lang)
)
}
}
}
@@ -195,37 +301,32 @@ struct MacSettingsView: View {
// MARK: - Legal
private var legalSection: some View {
Section(MacL10n.string("mac.settings.about", language: lang)) {
NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang)
} label: {
Text(MacL10n.string("mac.settings.privacyPolicy", language: lang))
}
NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang)
} label: {
Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
}
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false
MacSettingsSection(title: MacL10n.string("mac.settings.about", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang)
} label: {
MacFormLinkRow(title: MacL10n.string("mac.settings.privacyPolicy", language: lang))
}
.buttonStyle(.plain)
NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang)
} label: {
MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
}
.buttonStyle(.plain)
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
}
}
// MARK: - Row helpers
private func rowLabel(_ title: String, subtitle: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(subtitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func methodRow(
title: String,
subtitle: String,
@@ -252,72 +353,79 @@ struct MacSettingsView: View {
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace))
}
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
.animation(Motion.quick, value: selected)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var currentProvider: LLMProvider {
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.selectableProviders.first
private var currentPolishProvider: LLMProvider {
viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.polishSelectableProviders.first
?? LLMProvider.presets[0]
}
/// Custom dropdown list shown in a popover. SwiftUI's `Menu` label / items
/// silently drop bundled (non-SF-Symbol) images on macOS, so we render the
/// brand marks in a plain view stack instead.
private var providerPickerList: some View {
VStack(spacing: 0) {
ForEach(viewModel.selectableProviders) { provider in
Button {
viewModel.selectProvider(provider)
showProviderPicker = false
} label: {
HStack(spacing: Spacing.sm) {
providerLogo(provider.id)
Text(provider.name)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.md)
if provider.id == currentProvider.id {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.frame(height: 34)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, Spacing.xs)
.frame(width: 260)
private var currentAsrProvider: LLMProvider {
viewModel.asrSelectableProviders.first { $0.id == viewModel.config.asrProviderId }
?? viewModel.asrSelectableProviders.first
?? LLMProvider.presets[0]
}
/// Brand mark tinted to the current label colour (black on light / white
/// on dark). Template rendering + an explicit frame make the vector assets
/// resolve at a text-matched size inside the pop-up menu without a size
/// hint they collapse to zero and disappear.
@ViewBuilder
private func providerLogo(_ providerId: String) -> some View {
if let asset = ProviderLogo.assetName(for: providerId) {
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(palette.textPrimary)
}
private var macVolcengineFields: VolcengineASRFields {
VolcengineASRFields.parse(
apiKey: viewModel.config.asrApiKey,
resourceFallback: viewModel.config.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: "volcengine")
: viewModel.config.asrModel
)
}
@ViewBuilder
private func providerLabel(_ provider: LLMProvider) -> some View {
Label {
Text(provider.name)
} icon: {
providerLogo(provider.id)
private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
var fields = macVolcengineFields
if let appID { fields.appID = appID }
if let accessToken { fields.accessToken = accessToken }
if let resourceID {
fields.resourceID = resourceID
viewModel.config.asrModel = resourceID
}
viewModel.config.asrApiKey = fields.encodedAPIKey
}
private func validateMacLLM() async throws {
let client = LLMClientFactory.make(
providerId: viewModel.config.providerId,
baseURL: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
model: viewModel.config.model,
thinkingEnabled: viewModel.config.llmThinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
private func fetchMacLLMModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: viewModel.config.providerId,
baseURL: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
currentModel: viewModel.config.model
)
}
private func validateMacASR() async throws {
let persisted = AppGroupStore(defaults: viewModel.defaults)
let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted)
try await CloudASRConnectionCheck.validate(store: store)
}
private func fetchMacASRModels() async throws -> [String] {
try await ProviderModelService.listASRModels(
providerId: viewModel.config.asrProviderId,
baseURL: viewModel.config.asrBaseURL,
apiKey: viewModel.config.asrApiKey,
currentModel: viewModel.config.asrModel
)
}
// MARK: - Bindings
@@ -329,17 +437,28 @@ struct MacSettingsView: View {
)
}
private var providerBinding: Binding<String> {
private var polishProviderBinding: Binding<String> {
Binding(
get: { viewModel.config.providerId },
set: { newId in
if let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) {
if let provider = viewModel.polishSelectableProviders.first(where: { $0.id == newId }) {
viewModel.selectProvider(provider)
}
}
)
}
private var asrProviderBinding: Binding<String> {
Binding(
get: { viewModel.config.asrProviderId },
set: { newId in
if let provider = viewModel.asrSelectableProviders.first(where: { $0.id == newId }) {
viewModel.selectAsrProvider(provider)
}
}
)
}
private var recognitionLanguageBinding: Binding<String> {
Binding(
get: {
@@ -352,10 +471,10 @@ struct MacSettingsView: View {
)
}
private var hotkeyBinding: Binding<Bool> {
private var hotkeyTriggerBinding: Binding<String> {
Binding(
get: { viewModel.hotkeyEnabled },
set: { viewModel.setHotkeyEnabled($0) }
get: { viewModel.hotkeyTrigger.rawValue },
set: { viewModel.setHotkeyTrigger(MacHotkeyTrigger(rawValue: $0) ?? .rightOption) }
)
}
+79 -6
View File
@@ -165,16 +165,89 @@ enum MacSherpaONNXRunner {
.filter { !$0.isEmpty }
for line in lines.reversed() {
if line.hasPrefix("{"), let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = object["text"] as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
if line.hasPrefix("{") {
// Sherpa's JSON result line (`{"text": ..., "lang": ..., ...}`).
// Trust only its `text` field including when it's empty
// (silence/no-speech) and never fall through to the raw
// JSON below, or the JSON blob itself gets inserted as text.
if let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = object["text"] as? String {
return sanitizeTranscript(text)
}
continue
}
if isMetadataNoiseLine(line) { continue }
if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 {
return line
let cleaned = sanitizeTranscript(line)
if !cleaned.isEmpty { return cleaned }
}
}
return ""
}
/// Qwen3-ASR (via sherpa-onnx) often prefixes the transcript with a
/// scaffold such as `language Chinese<asr_text>`. Older runtimes leave
/// that intact in `result.text`; incomplete generations can even stop at
/// the bare word `language`. Strip the scaffold so only spoken text remains.
private static func sanitizeTranscript(_ raw: String) -> String {
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty { return "" }
// Prefer the payload after the last `<asr_text>` marker.
if let marker = text.range(of: "<asr_text>", options: .backwards) {
text = String(text[marker.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
} else if let match = text.range(
of: #"^language\s+\S+\s*"#,
options: [.regularExpression, .caseInsensitive]
) {
// Fallback when the marker token was lost but the language prefix remains.
text = String(text[match.upperBound...])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
// Drop leftover control tokens / bare scaffold words.
if isMetadataNoiseLine(text) { return "" }
return text
}
/// Lines that are sherpa/Qwen metadata rather than spoken content.
private static func isMetadataNoiseLine(_ line: String) -> Bool {
let lowered = line.lowercased()
switch lowered {
case "language", "emotion", "event", "text",
"<asr_text>", "</asr_text>", "<|im_end|>":
return true
default:
// Exact scaffold with no spoken payload, e.g. "language Chinese".
if lowered.range(
of: #"^language(\s+\S+)?$"#,
options: .regularExpression
) != nil {
return true
}
return false
}
}
}
enum MacQwen3LanguageHint {
/// Map persisted BCP-47 locale ids to Qwen3 prompt language names.
/// Returns `nil` for auto-detect.
static func from(locale: Locale) -> String? {
let raw = locale.identifier.lowercased()
if raw.isEmpty || raw == "auto" { return nil }
if raw.hasPrefix("zh") { return "Chinese" }
if raw.hasPrefix("en") { return "English" }
if raw.hasPrefix("ja") { return "Japanese" }
if raw.hasPrefix("ko") { return "Korean" }
if raw.hasPrefix("fr") { return "French" }
if raw.hasPrefix("de") { return "German" }
if raw.hasPrefix("es") { return "Spanish" }
if raw.hasPrefix("pt") { return "Portuguese" }
if raw.hasPrefix("ru") { return "Russian" }
if raw.hasPrefix("ar") { return "Arabic" }
return nil
}
}
+99 -5
View File
@@ -9,10 +9,65 @@ import Foundation
import Speech
enum MacSpeechLocalASR {
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
/// Shared resume-once state for one recognition run. The recognizer
/// callback (delivered on an arbitrary Speech queue) and the timeout task
/// race to finish, and a `CheckedContinuation` must resume exactly once,
/// so both go through this lock-guarded gate. It also retains the
/// `SFSpeechRecognitionTask` so the losing/failing path can cancel it.
private final class RecognitionSession: @unchecked Sendable {
private let lock = NSLock()
private var isResumed = false
private var task: SFSpeechRecognitionTask?
private var timeoutTask: Task<Void, Never>?
func retain(_ task: SFSpeechRecognitionTask) {
lock.lock()
self.task = task
let alreadyResumed = isResumed
lock.unlock()
// Timeout won the race before the task handle was stored.
if alreadyResumed { task.cancel() }
}
func retainTimeout(_ task: Task<Void, Never>) {
lock.lock()
timeoutTask = task
let alreadyResumed = isResumed
lock.unlock()
// Recognition finished before the handle landed stop the timer.
if alreadyResumed { task.cancel() }
}
/// Returns `true` exactly once across all callers; the winner may
/// resume the continuation. Pass `cancellingTask: true` on failure
/// paths so the in-flight recognition stops doing work. The winner
/// also cancels the timeout task so it doesn't keep the session (and
/// continuation captures) alive for the rest of its sleep.
func claimResume(cancellingTask: Bool) -> Bool {
lock.lock()
guard !isResumed else {
lock.unlock()
return false
}
isResumed = true
let task = self.task
let timeout = timeoutTask
lock.unlock()
if cancellingTask { task?.cancel() }
timeout?.cancel()
return true
}
}
static func transcribe(samples: [Float], locale: Locale, bias: LocalASRBiasPayload? = nil) async throws -> String {
let auth = await requestAuthorization()
guard auth == .authorized else { throw MacLocalASRError.speechDenied }
if Self.isChineseLocale(locale) {
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
_ = try? await CustomLanguageModelManager.shared.prepareIfNeeded()
}
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
defer { try? FileManager.default.removeItem(at: wavURL) }
@@ -20,18 +75,43 @@ enum MacSpeechLocalASR {
guard let recognizer, recognizer.isAvailable else {
throw MacLocalASRError.speechFailed("Speech recognizer unavailable")
}
// The request below sets `requiresOnDeviceRecognition = true`, which
// fails (or worse, never produces a final result) when the on-device
// model for the locale is missing fail fast with a clear error.
guard recognizer.supportsOnDeviceRecognition else {
throw MacLocalASRError.speechFailed(
"On-device speech recognition is not available for \(recognizer.locale.identifier). Download the language in System Settings → Keyboard → Dictation."
)
}
return try await withCheckedThrowingContinuation { continuation in
// Overall deadline: recognition of a file is normally much faster than
// realtime, so 2× audio length with a 30 s floor is generous. Without
// it, empty audio / cancellation / a missing model can leave the
// callback silent forever and the continuation never resumes.
let audioSeconds = Double(samples.count) / 16_000
let timeoutSeconds = max(30.0, audioSeconds * 2)
let session = RecognitionSession()
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
let request = SFSpeechURLRecognitionRequest(url: wavURL)
request.shouldReportPartialResults = false
request.requiresOnDeviceRecognition = true
CustomLanguageModelManager.applyCustomLanguageModel(
to: request,
locale: locale,
bias: bias
)
recognizer.recognitionTask(with: request) { result, error in
let task = recognizer.recognitionTask(with: request) { result, error in
if let error {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
if session.claimResume(cancellingTask: true) {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
}
return
}
// Non-final callbacks carry no usable transcript yet; if a
// final result never arrives, the timeout below resumes us.
guard let result, result.isFinal else { return }
guard session.claimResume(cancellingTask: false) else { return }
let text = result.bestTranscription.formattedString
.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
@@ -40,6 +120,16 @@ enum MacSpeechLocalASR {
continuation.resume(returning: text)
}
}
session.retain(task)
let timeout = Task {
try? await Task.sleep(for: .seconds(timeoutSeconds))
guard !Task.isCancelled else { return }
if session.claimResume(cancellingTask: true) {
continuation.resume(throwing: MacLocalASRError.speechFailed("Speech recognition timed out"))
}
}
session.retainTimeout(timeout)
}
}
@@ -58,4 +148,8 @@ enum MacSpeechLocalASR {
try wav.write(to: url)
return url
}
private static func isChineseLocale(_ locale: Locale) -> Bool {
locale.identifier(.bcp47).lowercased().hasPrefix("zh")
}
}
+141 -13
View File
@@ -1,8 +1,10 @@
// MacTextInsertionService.swift
// OSGKeyboard · Mac
//
// Inserts transcribed text into the frontmost app: clipboard first, then
// Inserts transcribed text into the target app: clipboard first, then
// a synthetic V (SayIt / Typeless-style). Requires Accessibility trust.
// Re-activates the app the user was dictating into (the popover steals
// focus) and restores the original clipboard once the paste has landed.
import AppKit
@preconcurrency import ApplicationServices
@@ -30,32 +32,158 @@ enum MacTextInsertionService {
return AXIsProcessTrustedWithOptions(options)
}
/// Copy to pasteboard and optionally simulate V in the front app.
// MARK: - Paste-target tracking
/// Start observing app activations early (app launch) so a paste target
/// can still be recovered while OSGKeyboard itself is frontmost e.g.
/// when a recording is started from the menu-bar popover.
@MainActor
static func beginTrackingFrontmostApp() {
_ = FrontmostAppTracker.shared
}
/// The app a synthesized V should land in: the current frontmost app,
/// or when OSGKeyboard is frontmost because the popover has key
/// focus the app that was active immediately before it.
@MainActor
static func captureTargetApplication() -> NSRunningApplication? {
let selfPid = NSRunningApplication.current.processIdentifier
if let front = NSWorkspace.shared.frontmostApplication,
front.processIdentifier != selfPid {
return front
}
return FrontmostAppTracker.shared.lastExternalApp
}
// MARK: - Insertion
/// Copy to pasteboard and optionally simulate V in the target app.
/// Returns `true` only if the V event was actually synthesized. After a
/// successful paste the user's original clipboard is put back, so
/// dictation never permanently clobbers it.
@MainActor
static func insert(
_ text: String,
autoPaste: Bool
) throws -> Bool {
autoPaste: Bool,
targetApp: NSRunningApplication? = nil
) async throws -> Bool {
guard !text.isEmpty else { return false }
let pasteboard = NSPasteboard.general
let snapshot = snapshotItems(of: pasteboard)
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
guard autoPaste else { return false }
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
Thread.sleep(forTimeInterval: 0.08)
postCommandV()
// Make sure V lands in the app the user was dictating into, not in
// OSGKeyboard's own popover / window.
if let targetApp { await activate(targetApp) }
try? await Task.sleep(nanoseconds: 80_000_000)
guard postCommandV() else { return false }
// Give the target app time to read the transcript off the
// pasteboard, then restore whatever the user had on it.
try? await Task.sleep(nanoseconds: 300_000_000)
restoreItems(snapshot, to: pasteboard)
return true
}
private static func postCommandV() {
/// Brings `app` forward and waits (up to ~1 s) until it is frontmost so
/// the synthesized keystroke isn't swallowed mid-switch.
@MainActor
private static func activate(_ app: NSRunningApplication) async {
func isFront() -> Bool {
NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier
}
guard !isFront() else { return }
app.activate()
var attempts = 0
while !isFront(), attempts < 20 {
try? await Task.sleep(nanoseconds: 50_000_000)
attempts += 1
}
}
// MARK: - Pasteboard preservation
/// Every representation of every pasteboard item, so restore round-trips
/// rich content (images, files, multiple flavours) losslessly.
private static func snapshotItems(
of pasteboard: NSPasteboard
) -> [[NSPasteboard.PasteboardType: Data]] {
(pasteboard.pasteboardItems ?? []).map { item in
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { flavours, type in
flavours[type] = item.data(forType: type)
}
}
}
private static func restoreItems(
_ items: [[NSPasteboard.PasteboardType: Data]],
to pasteboard: NSPasteboard
) {
guard !items.isEmpty else { return }
pasteboard.clearContents()
pasteboard.writeObjects(items.map { flavours in
let item = NSPasteboardItem()
for (type, data) in flavours { item.setData(data, forType: type) }
return item
})
}
/// Returns `false` when the CGEvents could not be created in that case
/// nothing was pasted and callers must not report success.
private static func postCommandV() -> Bool {
let source = CGEventSource(stateID: .combinedSessionState)
let keyCode = CGKeyCode(kVK_ANSI_V)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
keyDown?.flags = CGEventFlags.maskCommand
keyUp?.flags = CGEventFlags.maskCommand
keyDown?.post(tap: CGEventTapLocation.cghidEventTap)
keyUp?.post(tap: CGEventTapLocation.cghidEventTap)
guard
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
else { return false }
keyDown.flags = CGEventFlags.maskCommand
keyUp.flags = CGEventFlags.maskCommand
keyDown.post(tap: CGEventTapLocation.cghidEventTap)
keyUp.post(tap: CGEventTapLocation.cghidEventTap)
return true
}
}
// MARK: - Frontmost-app tracker
/// Remembers the most recent non-OSGKeyboard frontmost app. Needed because
/// the menu-bar popover activates OSGKeyboard, hiding the real paste target
/// from `NSWorkspace.frontmostApplication`.
@MainActor
private final class FrontmostAppTracker: NSObject {
static let shared = FrontmostAppTracker()
private(set) var lastExternalApp: NSRunningApplication?
private override init() {
super.init()
// Seed with whatever is frontmost now (usually not us at launch).
if let front = NSWorkspace.shared.frontmostApplication,
front.processIdentifier != NSRunningApplication.current.processIdentifier {
lastExternalApp = front
}
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(appDidActivate(_:)),
name: NSWorkspace.didActivateApplicationNotification,
object: nil
)
}
deinit {
NSWorkspace.shared.notificationCenter.removeObserver(self)
}
@objc private func appDidActivate(_ notification: Notification) {
guard
let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.processIdentifier != NSRunningApplication.current.processIdentifier
else { return }
lastExternalApp = app
}
}
+10 -5
View File
@@ -6,7 +6,8 @@
// semantic colours, resolved to a concrete value for the *active* appearance.
// The brand green is kept only as the accent. Light mode uses a warm,
// iOS-matched surface set (the default `windowBackgroundColor` reads cold
// grey on macOS); Dark mode keeps the native AppKit semantic colours.
// grey on macOS); Dark mode uses stepped elevated greys so cards stay
// readable against the page background.
import AppKit
import SwiftUI
@@ -33,8 +34,8 @@ enum MacSystemPalette {
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
accent: Palette.accent,
accentMuted: Palette.accent.opacity(0.16),
accentGlow: Palette.accent.opacity(0.35),
accentMuted: Palette.accent.opacity(dark ? 0.22 : 0.14),
accentGlow: Palette.accent.opacity(dark ? 0.40 : 0.32),
danger: resolved(.systemRed, dark: dark),
success: Palette.accent,
@@ -45,8 +46,12 @@ enum MacSystemPalette {
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
textOnAccent: Color.white,
divider: resolved(.separatorColor, dark: dark),
dividerStrong: resolved(.separatorColor, dark: dark),
divider: dark
? Color.white.opacity(0.08)
: Color.black.opacity(0.06),
dividerStrong: dark
? Color.white.opacity(0.12)
: Color.black.opacity(0.10),
recordRed: resolved(.systemRed, dark: dark)
)
+6 -1
View File
@@ -57,7 +57,9 @@ struct OSGKeyboardMacApp: App {
// very top, matching macOS System Settings.
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentMinSize)
.defaultSize(width: 1_024, height: 720)
// Open at the minimum size same as `MacMetrics.windowMin*`, so the
// first launch already matches the smallest allowed window.
.defaultSize(width: MacMetrics.windowMinWidth, height: MacMetrics.windowMinHeight)
}
}
@@ -94,8 +96,11 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
MacAppearancePreference.applyToApp(.current)
MacTextInsertionService.beginTrackingFrontmostApp()
configurePopover()
configureStatusItem()
MacDictationOverlayController.shared.start(observing: MacDictationViewModel.shared)
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
// The menu bar always follows the *system* appearance, so the status
// item must ignore the app's forced light/dark override. Re-pin the
@@ -13,12 +13,21 @@ import Foundation
/// Keep this protocol narrow: only what the shared pipeline needs today.
/// Platform-specific settings UI and iCloud sync stay on concrete stores.
public protocol ConfigurationStore: Sendable {
/// Polish / LLM provider id.
var providerId: String { get }
var baseURL: String { get }
var apiKey: String { get }
var model: String { get }
/// Cloud ASR provider id independent from polish when `engineMode == "cloud"`.
var asrProviderId: String { get }
var asrBaseURL: String { get }
var asrApiKey: String { get }
var asrModel: String { get }
var engineMode: String { get }
var polishIntensity: PolishIntensity { get }
var llmThinkingEnabled: Bool { get }
var personalDictionary: PersonalDictionary { get }
/// Foreground-app context for polish prompts (keyboard extension publishes this).

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