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) # Standalone experiment: 灵动岛录音机制验证 demo (throwaway)
AudioIntentProbe/ 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.7"** (1290×2796) — 必需
- **6.1"** (1179×2556) — 必需(iPhone 17 Pro - **6.1"** (1179×2556) — 必需(iPhone 17 Pro
- 5.5" 已被苹果官方文档降级为"可选"iPhone 8 Plus 等已停产机型) - 5.5" 已被苹果官方文档降级为"可选"iPhone 8 Plus 等已停产机型)
- iPad 截图 — 项目声明 iPhone only无需提供 - iPad 截图 — **现已必需**:项目自 TARGETED_DEVICE_FAMILY "1,2" 起支持 iPadApp Store Connect 要求提供 13″ iPad Pro 截图套组(本条为后续更新覆盖原「iPhone only 无需提供」的结论)
- 每套至少 3 张、最多 10 张 - 每套至少 3 张、最多 10 张
**本审计交付**: **本审计交付**:
+19
View File
@@ -7,7 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ### 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(灵动岛 + 锁屏),降低强杀后重开提示麦克风被占用的概率。 - **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.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> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict> </dict>
</plist> </plist>
+15
View File
@@ -8,6 +8,21 @@
<array/> <array/>
<key>NSPrivacyCollectedDataTypes</key> <key>NSPrivacyCollectedDataTypes</key>
<array> <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> <dict>
<key>NSPrivacyCollectedDataType</key> <key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUserContent</string> <string>NSPrivacyCollectedDataTypeOtherUserContent</string>
+5 -2
View File
@@ -97,8 +97,11 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
for item in items { for item in items {
// `sourceApplication` is only non-nil when the caller belongs to // `sourceApplication` is only non-nil when the caller belongs to
// the same Apple Developer Team (our own keyboard extension) // the same Apple Developer Team (our own keyboard extension)
// exactly what the host-return whitelist relies on. // exactly what the host-return whitelist relies on. Record it
if let source = item.source { // 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) FlowSessionBridge.setPendingHostBundleId(source)
} }
AppOpenURLRouter.shared.route(item.url) AppOpenURLRouter.shared.route(item.url)
@@ -15,11 +15,17 @@ enum FlowLiveActivityController {
nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle
/// If the host app is force-quit its `endSession()` never runs, orphaning /// 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 /// the Live Activity. `staleDate` semantics (verified against ActivityKit
/// reclaim it on its own within ~45s of the process dying. While the host is /// behaviour, not folklore): the *Dynamic Island* presentation is reliably
/// alive the heartbeat calls `keepAlive()` well inside this window, so a /// removed shortly after the stale date passes, but the *lock-screen*
/// genuinely active session never looks stale. /// banner may linger greyed-out depending on the iOS version it is NOT
private static let staleWindow: TimeInterval = 45 /// 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( private static func freshContent(
phase: FlowActivityAttributes.ContentState.Phase phase: FlowActivityAttributes.ContentState.Phase
@@ -118,6 +124,8 @@ enum FlowLiveActivityController {
} }
/// `applicationWillTerminate` `end` 退 /// `applicationWillTerminate` `end` 退
/// ActivityKit `end` XPC watchdog
/// `wait()` ~5
nonisolated static func endAllSynchronouslyOnTerminate() { nonisolated static func endAllSynchronouslyOnTerminate() {
let semaphore = DispatchSemaphore(value: 0) let semaphore = DispatchSemaphore(value: 0)
Task.detached(priority: .userInitiated) { Task.detached(priority: .userInitiated) {
@@ -129,7 +137,7 @@ enum FlowLiveActivityController {
FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))") FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
semaphore.signal() semaphore.signal()
} }
semaphore.wait() _ = semaphore.wait(timeout: .now() + 2)
currentPhase = .idle currentPhase = .idle
currentActivity = nil currentActivity = nil
} }
+398 -86
View File
@@ -54,9 +54,10 @@ final class FlowSessionManager: ObservableObject {
private var currentUtteranceId: UUID? private var currentUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0 private var currentCommandSeq: Int64 = 0
private var lastHandledCommandSeq: 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. /// 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 finalizeTask: Task<Void, Never>?
private var asrTask: Task<Void, Never>? private var asrTask: Task<Void, Never>?
private var utteranceSafetyTask: Task<Void, Never>? private var utteranceSafetyTask: Task<Void, Never>?
@@ -77,17 +78,50 @@ final class FlowSessionManager: ObservableObject {
private var coldStartRecoveryTask: Task<Void, Never>? private var coldStartRecoveryTask: Task<Void, Never>?
/// Initial proof window cold mic sessions often need >2.5s after app switch. /// Initial proof window cold mic sessions often need >2.5s after app switch.
private static let coldStartAudioProofTimeout: TimeInterval = 6 private static let coldStartAudioProofTimeout: TimeInterval = 6
/// Extra window after the first timeout while the overlay shows a failure hint. /// Guards the once-per-process launch reconciliation (scene reconnects
private static let coldStartRecoveryProofTimeout: TimeInterval = 12 /// recreate the `@StateObject`-owned manager within the same process).
private static var didRunLaunchReconciliation = false
init() { init() {
// Sessions are (re)started explicitly on app foreground via // Sessions are (re)started explicitly on app foreground via
// `activateOnForeground()`. We deliberately do NOT silently reattach a // `activateOnForeground()`. We deliberately do NOT silently reattach a
// stored session here after a force-quit that would resurrect capture // stored session here after a force-quit that would resurrect capture
// (and keep a stale Live Activity alive) without the user re-opening. // (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 capture.onEngineLiveChanged = { [weak self] _ in
self?.refreshHostReady() 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) FlowTerminationCoordinator.register(self)
} }
@@ -104,21 +138,43 @@ final class FlowSessionManager: ObservableObject {
return 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 { if coldStart {
isColdStartHandoff = true isColdStartHandoff = true
showColdStartPreparing() showColdStartPreparing()
} }
reconcilePersistedFlowStateBeforeStart()
if isActive { if isActive {
extendSession(duration: duration) extendSession(duration: duration)
refreshHostReady() refreshHostReady()
if coldStart {
Task { @MainActor [weak self] in
await self?.prepareExistingSessionForColdStartReturn()
}
}
return return
} }
@@ -126,6 +182,10 @@ final class FlowSessionManager: ObservableObject {
traceState("startSession.ignored", extra: "reason=alreadyStarting") traceState("startSession.ignored", extra: "reason=alreadyStarting")
return 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?.cancel()
startTask = Task { @MainActor [weak self] in startTask = Task { @MainActor [weak self] in
@@ -190,11 +250,15 @@ final class FlowSessionManager: ObservableObject {
func dismissColdStartOverlay() { func dismissColdStartOverlay() {
coldStartRecoveryTask?.cancel() coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = nil 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 { if isActive {
refreshHostReady() refreshHostReady()
} }
coldStartContext = nil
isColdStartHandoff = false
} }
func returnToPendingHostFromColdStart() { func returnToPendingHostFromColdStart() {
@@ -204,6 +268,21 @@ final class FlowSessionManager: ObservableObject {
func retryColdStartReadiness() { func retryColdStartReadiness() {
guard AppGroup.isAvailable else { return } 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) startSession(coldStart: true)
} }
@@ -241,7 +320,10 @@ final class FlowSessionManager: ObservableObject {
if isUtteranceRecording || isUtteranceProcessing { if isUtteranceRecording || isUtteranceProcessing {
capture.cancelUtterance() 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() capture.cancelUtterance()
@@ -407,17 +489,43 @@ final class FlowSessionManager: ObservableObject {
private func reactivateCaptureIfNeeded() async { private func reactivateCaptureIfNeeded() async {
guard isActive else { return } 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 { if capture.running {
let reasserted = capture.reassertIfRunning() let reasserted = capture.reassertIfRunning()
if reasserted, capture.engineHasRecentAudio() { if reasserted, await capture.awaitAudioFlowing(timeout: 2) {
sessionWarning = nil sessionWarning = nil
} else if !reasserted {
sessionWarning = AppL10n.string("flow.error.audioUnavailable")
}
refreshHostReady() refreshHostReady()
return 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 { do {
try capture.start() try capture.start()
@@ -442,7 +550,8 @@ final class FlowSessionManager: ObservableObject {
reason: .noSession, reason: .noSession,
engineMode: store.engineMode, engineMode: store.engineMode,
localeId: store.localeId, localeId: store.localeId,
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt() sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
hostGeneration: FlowSessionBridge.currentHostGeneration()
) )
) )
return return
@@ -486,7 +595,8 @@ final class FlowSessionManager: ObservableObject {
engineMode: store.engineMode, engineMode: store.engineMode,
localeId: store.localeId, localeId: store.localeId,
busyUtteranceId: isUtteranceRecording || isUtteranceProcessing ? currentUtteranceId : nil, busyUtteranceId: isUtteranceRecording || isUtteranceProcessing ? currentUtteranceId : nil,
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt() sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
hostGeneration: FlowSessionBridge.currentHostGeneration()
) )
) )
let signature = [ let signature = [
@@ -509,9 +619,19 @@ final class FlowSessionManager: ObservableObject {
/// shows a stale preparing/failed snapshot, heal automatically. /// shows a stale preparing/failed snapshot, heal automatically.
private func reconcileColdStartOverlayIfRecovered() { private func reconcileColdStartOverlayIfRecovered() {
guard isColdStartHandoff, isActive else { return } guard isColdStartHandoff, isActive else { return }
guard FlowSessionBridge.isHostReady() else { return }
guard let context = coldStartContext 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 { switch context.state {
case .preparing: case .preparing:
presentColdStartReadyOverlay() presentColdStartReadyOverlay()
@@ -556,10 +676,11 @@ final class FlowSessionManager: ObservableObject {
// MARK: - Session start // MARK: - Session start
private func startSessionAsync(duration: TimeInterval?) async { private func startSessionAsync(duration: TimeInterval?) async {
traceState("startSessionAsync.begin") // `isStarting` was claimed synchronously in `startSession()`.
isStarting = true
sessionWarning = nil
defer { isStarting = false } defer { isStarting = false }
guard !Task.isCancelled else { return }
traceState("startSessionAsync.begin")
sessionWarning = nil
guard AppPermissions.flowRequirementsMet else { guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage() sessionWarning = permissionWarningMessage()
@@ -586,7 +707,9 @@ final class FlowSessionManager: ObservableObject {
return 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") let message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message sessionWarning = message
traceState("startSessionAsync.failed", extra: "reason=audioProofTimeout") traceState("startSessionAsync.failed", extra: "reason=audioProofTimeout")
@@ -658,6 +781,16 @@ final class FlowSessionManager: ObservableObject {
refreshHostReady() refreshHostReady()
guard FlowSessionBridge.isHostReady() else { 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") let message = AppL10n.string("flow.coldStart.error.audioTimeout")
sessionWarning = message sessionWarning = message
showColdStartAudioFailure(message: message) showColdStartAudioFailure(message: message)
@@ -687,18 +820,54 @@ final class FlowSessionManager: ObservableObject {
} }
} }
/// Keeps proving mic readiness after the first timeout instead of tearing /// Actively rebuilds the audio pipeline after a failed cold start instead
/// capture down many handoffs become ready a few seconds later. /// 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?) { private func scheduleColdStartRecovery(duration: TimeInterval?) {
coldStartRecoveryTask?.cancel() coldStartRecoveryTask?.cancel()
coldStartRecoveryTask = Task { @MainActor [weak self] in coldStartRecoveryTask = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
let recovered = await self.capture.awaitAudioFlowing( var recovered = false
timeout: Self.coldStartRecoveryProofTimeout 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 !Task.isCancelled else { return }
guard self.isColdStartHandoff 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.sessionWarning = nil
self.traceState("coldStartRecovery.recovered") self.traceState("coldStartRecovery.recovered")
@@ -1027,12 +1196,22 @@ final class FlowSessionManager: ObservableObject {
// Do NOT cancel `asrTask` or `asr` drain trailing PCM, then finalize. // 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?.cancel()
finalizeTask = Task { @MainActor [weak self] in finalizeTask = Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
let drainReport = await self.capture.endUtteranceAndDrain() let drainReport = await self.capture.endUtteranceAndDrain()
FlowDiagnostics.logDrain(drainReport) FlowDiagnostics.logDrain(drainReport)
await self.finalizeUtterance() await self.finalizeUtterance(
sessionId: drainingSessionId,
utteranceId: drainingUtteranceId,
commandSeq: drainingCommandSeq
)
} }
debug("utterance stopped, draining tail") debug("utterance stopped, draining tail")
} }
@@ -1109,20 +1288,22 @@ final class FlowSessionManager: ObservableObject {
debug("utterance processing failed: \(message)") debug("utterance processing failed: \(message)")
} }
private func finalizeUtterance() async { private func finalizeUtterance(
let finalizeSessionId = activeSessionId sessionId finalizeSessionId: UUID?,
let finalizeUtteranceId = currentUtteranceId utteranceId finalizeUtteranceId: UUID?,
commandSeq finalizeCommandSeq: Int64
) async {
let pipelineStarted = Date() 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 { defer {
if activeSessionId == finalizeSessionId, completeFinalizeCleanup(
currentUtteranceId == finalizeUtteranceId { sessionId: finalizeSessionId,
isUtteranceProcessing = false utteranceId: finalizeUtteranceId
FlowLiveActivityController.update(phase: .idle) )
touchSessionActivity()
currentUtteranceId = nil
currentCommandSeq = 0
refreshHostReady()
}
} }
let asrWait = asrWaitTimeout() let asrWait = asrWaitTimeout()
@@ -1134,6 +1315,10 @@ final class FlowSessionManager: ObservableObject {
while Date() < asrDeadline { while Date() < asrDeadline {
if !lastFinal.isEmpty { break } if !lastFinal.isEmpty { break }
if asrTask?.isCancelled == true { 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) try? await Task.sleep(nanoseconds: 100_000_000)
} }
@@ -1151,14 +1336,21 @@ final class FlowSessionManager: ObservableObject {
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines) text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
} }
guard !text.isEmpty else { guard !text.isEmpty else {
let key = (asrTask?.isCancelled == true) let key = (asrTask?.isCancelled == true || Task.isCancelled)
? "flow.error.recognitionInterrupted" ? "flow.error.recognitionInterrupted"
: "flow.error.noSpeech" : "flow.error.noSpeech"
let kind: FlowSessionKeys.TranscriptionErrorKind = 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") FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
utteranceRecordingStartedAt = nil utteranceRecordingStartedAt = nil
storeCurrentError(AppL10n.string(key), kind: kind) storeFinalizedError(
AppL10n.string(key),
kind: kind,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
return return
} }
@@ -1178,23 +1370,34 @@ final class FlowSessionManager: ObservableObject {
"translationTarget=\(pipelineStore.translationTargetLocaleId)" "translationTarget=\(pipelineStore.translationTargetLocaleId)"
) )
do { do {
let polished = try await polisher.polish( // If the finalize task was cancelled (cold-start churn / abort),
text, // 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, mode: polishMode,
providerIdOverride: pipelineStore.polishProviderIdOverride providerIdOverride: pipelineStore.polishProviderIdOverride
) )
delivered = polished delivered = polished
storeCurrentFinal(polished, warning: chunkNote) storeFinalizedResult(
polished,
warning: chunkNote,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
FlowDiagnostics.log( FlowDiagnostics.log(
"polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " + "polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " +
"total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s" "total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s"
) )
} catch { } catch {
// v0.2.0: local + cloud-polish-on + no API key surfaces // CancellationError is common when the user jumps back via
// `.missingAPIKey`. We translate it into a polishWarning // startflow mid-polish; still deliver raw text. Other errors
// so the keyboard can show the "fill in your key" hint // keep the existing polish-warning fallback.
// inline rather than a generic failure message. The raw
// transcript is still delivered no data loss.
let fallback = Self.makeFallbackDelivery( let fallback = Self.makeFallbackDelivery(
rawText: text, rawText: text,
error: error, error: error,
@@ -1206,7 +1409,13 @@ final class FlowSessionManager: ObservableObject {
"\(error.localizedDescription)" "\(error.localizedDescription)"
) )
delivered = fallback.text delivered = fallback.text
storeCurrentFinal(fallback.text, warning: fallback.polishWarning) storeFinalizedResult(
fallback.text,
warning: fallback.polishWarning,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq
)
} }
SpeechHistoryStore.shared.recordUtterance( SpeechHistoryStore.shared.recordUtterance(
@@ -1223,6 +1432,92 @@ final class FlowSessionManager: ObservableObject {
debug("utterance finalized length=\(text.count)") 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 { private static func polishModeLogLabel(_ mode: PolishingService.PolishMode) -> String {
switch mode { switch mode {
case .polish: case .polish:
@@ -1252,35 +1547,12 @@ final class FlowSessionManager: ObservableObject {
engineMode: String, engineMode: String,
chunkWarning: String? chunkWarning: String?
) -> TranscriptionDelivery { ) -> TranscriptionDelivery {
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText) TranscriptionPolishFallback.makeDelivery(
let warning = warningFromPolishError(error, engineMode: engineMode) rawText: rawText,
?? polishDegradedWarning() error: error,
?? chunkWarning engineMode: engineMode,
return TranscriptionDelivery(text: fallbackText, polishWarning: warning) chunkWarning: chunkWarning
} )
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")
} }
private func asrWaitTimeout() -> TimeInterval { private func asrWaitTimeout() -> TimeInterval {
@@ -1293,6 +1565,22 @@ final class FlowSessionManager: ObservableObject {
return FlowSessionKeys.cloudASRWaitTimeout 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) // MARK: - Level publishing (main thread only)
private func startLevelPublishing() { private func startLevelPublishing() {
@@ -1318,8 +1606,8 @@ final class FlowSessionManager: ObservableObject {
// Refresh the Live Activity `staleDate` every N heartbeat ticks // Refresh the Live Activity `staleDate` every N heartbeat ticks
// (1 Hz) well inside `FlowLiveActivityController.staleWindow` so a // (1 Hz) well inside `FlowLiveActivityController.staleWindow` so a
// live session never looks stale, while a force-quit stops these // live session never looks stale, while a force-quit stops these
// refreshes and lets the system reclaim the orphaned island. // refreshes and lets the island go stale within ~30 s.
let liveActivityKeepAliveEveryTicks = 15 let liveActivityKeepAliveEveryTicks = 10
var tick = 0 var tick = 0
while !Task.isCancelled { while !Task.isCancelled {
guard let self else { break } guard let self else { break }
@@ -1351,6 +1639,30 @@ final class FlowSessionManager: ObservableObject {
FlowDiagnostics.log(message) 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) { private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)" let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
guard signature != lastIgnoredCommandSignature else { return } guard signature != lastIgnoredCommandSignature else { return }
@@ -4,12 +4,10 @@
// Single source of truth for third-party open-source components shipped // Single source of truth for third-party open-source components shipped
// with OSGKeyboard. Consumed by Settings About Third-Party Licenses. // 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) // iOS targets remain zero-SPM. macOS local ASR downloads the sherpa-onnx
// and the `aufklarer/Qwen3-ASR-CoreML` runtime artefact. The local // runtime binary on demand; model weights are cached under Application Support.
// engine now ships with iOS 26 `SpeechAnalyzer` + `DictationTranscriber`
// and has no on-device ML dependencies of our own.
import Foundation import Foundation
@@ -26,19 +24,24 @@ enum OpenSourceLicenseCatalog {
let licenseText: String let licenseText: String
} }
/// Bundled libraries referenced by the app. v0.2.0 no longer pulls in /// Bundled libraries and runtime components referenced by the app.
/// `soniqo/speech-swift` (we use iOS 26 `SpeechAnalyzer` instead) and
/// no longer downloads `Qwen3-ASR-CoreML` weights both entries are
/// intentionally absent.
static let entries: [Entry] = [ static let entries: [Entry] = [
.init( .init(
id: "material-icons", id: "material-icons",
name: "Google Material Icons", name: "Google Material Icons",
licenseName: "Apache-2.0", 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"), url: URL(string: "https://github.com/google/material-design-icons"),
licenseText: apache2Text 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 // MARK: - License bodies
@@ -60,30 +63,4 @@ enum OpenSourceLicenseCatalog {
implied. See the License for the specific language governing implied. See the License for the specific language governing
permissions and limitations under the License. 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.
"""
} }
+62 -191
View File
@@ -11,215 +11,86 @@ struct APISettingsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
@State private var showKey: Bool = false /// `false`
@State private var testStatus: TestStatus = .idle var showsSurface: Bool = true
private enum TestStatus: Equatable {
case idle
case running
case success
case failure(String)
}
var body: some View { var body: some View {
VStack(spacing: 0) { 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"), title: AppL10n.string("api.baseUrl"),
placeholder: "https://api.openai.com/v1", placeholder: LLMProvider.provider(id: config.providerId).defaultBaseURL,
text: $config.baseURL, text: $config.baseURL,
autocap: false isMonospaced: true
) )
Divider().background(palette.divider) rowDivider
keyField SettingsModelPickerRow(
Divider().background(palette.divider)
field(
title: AppL10n.string("api.model"), title: AppL10n.string("api.model"),
placeholder: "gpt-4o-mini", placeholder: LLMProvider.provider(id: config.providerId).defaultModel,
text: $config.model, model: $config.model,
autocap: false fetchModels: fetchModels
) )
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { .id(config.providerId)
rowDivider
thinkingRow
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
}
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
private var rowDivider: some View {
Divider().background(palette.divider) 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 /// Two-line thinking toggle opt-in for slower, higher-quality polish.
// TextField, which manifests as "typing jumps to a website". private var thinkingRow: some View {
Button { HStack(alignment: .center, spacing: Spacing.sm) {
UIApplication.shared.open(url) VStack(alignment: .leading, spacing: Spacing.xxs) {
} label: { Text(AppL10n.string("settings.provider.thinking"))
HStack {
Text("api.getKey")
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Spacer() .fixedSize(horizontal: false, vertical: true)
Image(systemName: "arrow.up.right.square") Text(AppL10n.string("settings.provider.thinkingSubtitle"))
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Divider().background(palette.divider)
testConnectionRow
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
// 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)
}
// 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")
.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)
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(testTint) .foregroundStyle(palette.textTertiary)
.lineLimit(3) .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) .settingsListRow()
.padding(.vertical, Spacing.xs)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
} }
private var testButtonLabel: String { private func validateConnection() async throws {
switch testStatus { // Use on-screen config not a fresh AppGroupStore so a just-typed
case .idle: return AppL10n.string("api.test.idle") // key is visible even if Keychain write is still settling.
case .running: return AppL10n.string("api.test.running") let client = LLMClientFactory.make(
case .success: return AppL10n.string("api.test.success") providerId: config.providerId,
case .failure: return AppL10n.string("api.test.failure") baseURL: config.baseURL,
} apiKey: config.apiKey,
} model: config.model,
thinkingEnabled: config.llmThinkingEnabled
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
) )
Task {
do {
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") _ = 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)")
}
} }
private func fetchModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
currentModel: config.model
)
} }
} }
+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? { var sfSymbol: String? {
switch self { switch self {
case .dictionary: return "square.stack.3d.down.right.fill" case .dictionary: return "square.stack.3d.down.right.fill"
@@ -39,6 +39,18 @@ enum AppTab: Int, CaseIterable {
case .settings: return "tab.settings" 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 { 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 @Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
@State private var showCloudAcknowledgment = false
@State private var pendingEngineSelection: String?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
@@ -37,18 +35,8 @@ struct EnginePickerSection: View {
RoundedRectangle(cornerRadius: Radius.large, style: .continuous) RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5) .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 { private var localSubtitle: String {
AppL10n.string("settings.engine.local.legacy") AppL10n.string("settings.engine.local.legacy")
@@ -64,11 +52,6 @@ struct EnginePickerSection: View {
let isSelected = config.engineMode == id let isSelected = config.engineMode == id
return Button { return Button {
guard config.engineMode != id else { return } guard config.engineMode != id else { return }
if id == "cloud", !config.hasAcknowledgedCloudSharing {
pendingEngineSelection = id
showCloudAcknowledgment = true
return
}
selectEngine(id) selectEngine(id)
} label: { } label: {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
@@ -92,8 +75,7 @@ struct EnginePickerSection: View {
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -104,18 +86,9 @@ struct EnginePickerSection: View {
config.engineMode = id config.engineMode = id
if id == "cloud" { if id == "cloud" {
config.modeId = "polish" 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 @ViewBuilder
private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View { private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
+175 -58
View File
@@ -15,6 +15,7 @@ import UIKit
struct HomeView: View { struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ObservedObject private var config = ProviderConfig.shared @ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager @EnvironmentObject private var flowManager: FlowSessionManager
@@ -24,6 +25,10 @@ struct HomeView: View {
@State private var micStatus = AppPermissions.micStatus @State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus @State private var speechStatus = AppPermissions.speechStatus
private var usesWideLayout: Bool {
horizontalSizeClass == .regular
}
private var sessionIsLive: Bool { private var sessionIsLive: Bool {
flowManager.isActive || flowManager.isStarting flowManager.isActive || flowManager.isStarting
} }
@@ -52,50 +57,11 @@ struct HomeView: View {
} }
var body: some View { var body: some View {
GeometryReader { geo in Group {
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top if usesWideLayout {
wideBody
ZStack(alignment: .top) { } else {
sessionHeaderGradient(height: gradientHeight) phoneBody
.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
}
} }
} }
.onAppear { .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() { private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus speechStatus = AppPermissions.speechStatus
@@ -161,12 +247,15 @@ struct HomeView: View {
// MARK: - Header // MARK: - Header
private var logoHeader: some View { // logo 144:41
VStack(spacing: Spacing.xxl) { 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") Image("osglogo")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 144, height: 41) .frame(width: logoWidth, height: logoHeight)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -187,7 +276,18 @@ struct HomeView: View {
.foregroundStyle(palette.warning) .foregroundStyle(palette.warning)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.85) .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, } else if flowManager.isActive,
FlowSessionBridge.isHostReady(),
let expires = flowManager.sessionExpiresAt { let expires = flowManager.sessionExpiresAt {
Text("home.flow.label") Text("home.flow.label")
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
@@ -322,10 +422,14 @@ struct HomeView: View {
private var flowStatusColor: Color { private var flowStatusColor: Color {
if needsCloudSetup { return palette.warning } 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 flowManager.isStarting { return palette.accent }
if needsPermissionSetup { return palette.warning } if needsPermissionSetup { return palette.warning }
if flowManager.sessionWarning != nil { 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 return palette.textTertiary
} }
@@ -337,33 +441,46 @@ struct HomeView: View {
if flowManager.isStarting { if flowManager.isStarting {
return AppL10n.string("home.flow.starting") 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") 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") return AppL10n.string("home.flow.inactive")
} }
// MARK: - Preview field // MARK: - Preview field
private var previewField: some View { private func previewField(minHeight: CGFloat) -> some View {
TextField("home.preview.placeholder", text: $previewText, axis: .vertical) previewFieldContent
.font(TypeStyle.body) .frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading)
.foregroundStyle(palette.textPrimary)
.tint(palette.accent)
.focused($previewFocused)
.lineLimit(1...100)
.frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md) .padding(Spacing.md)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous) RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1) .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)) .contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.onTapGesture { previewFocused = true } .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 { private var engineStatusLine: some View {
Text( Text(
EngineServiceLabel.summary( EngineServiceLabel.summary(
@@ -25,8 +25,10 @@ struct LocalModelsGroup: View {
speechRow speechRow
Divider().background(palette.divider) Divider().background(palette.divider)
polishRow polishRow
#if DEBUG
Divider().background(palette.divider) Divider().background(palette.divider)
customLanguageModelDiagnosticRow customLanguageModelDiagnosticRow
#endif
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay( .overlay(
@@ -45,8 +47,7 @@ struct LocalModelsGroup: View {
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.speechEngine") engineBadge("settings.localModels.speechEngine")
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
// MARK: Polish row // MARK: Polish row
@@ -61,12 +62,12 @@ struct LocalModelsGroup: View {
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.polishEngine") engineBadge("settings.localModels.polishEngine")
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
// MARK: Custom language model diagnostic row // MARK: Custom language model diagnostic row (DEBUG builds only)
#if DEBUG
private var customLanguageModelDiagnosticRow: some View { private var customLanguageModelDiagnosticRow: some View {
Toggle(isOn: $config.localASRCustomLanguageModelEnabled) { Toggle(isOn: $config.localASRCustomLanguageModelEnabled) {
VStack(alignment: .leading, spacing: Spacing.xxs) { VStack(alignment: .leading, spacing: Spacing.xxs) {
@@ -79,10 +80,9 @@ struct LocalModelsGroup: View {
} }
} }
.tint(palette.accent) .tint(palette.accent)
.padding(.horizontal, Spacing.md) .settingsListRow()
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
#endif
// MARK: Helpers // MARK: Helpers
+9 -5
View File
@@ -39,14 +39,18 @@ struct MainAppRoot: View {
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil) .animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
.onAppear { .onAppear {
flowManager.setAppForeground(scenePhase == .active) flowManager.setAppForeground(scenePhase == .active)
flowManager.activateOnForeground() // Register the URL handler BEFORE the foreground auto-start.
AppCloudSync.shared.startObservingExternalChanges() // Registering flushes any URL buffered during a cold launch (the
// Registering here also flushes any URL buffered during a cold // keyboard app `startflow` handoff arrives via the scene
// launch (the keyboard app `startflow` handoff arrives via the // delegate before this view is on screen), so a cold start takes
// scene delegate before this view is on screen). // 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 AppOpenURLRouter.shared.register { url in
handleIncomingURL(url) handleIncomingURL(url)
} }
flowManager.activateOnForeground()
AppCloudSync.shared.startObservingExternalChanges()
Task { Task {
await AppCloudSync.shared.pullAllIfEnabled() 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)
}
}
}
+20 -15
View File
@@ -6,27 +6,35 @@ import OSGKeyboardShared
struct MainTabView: View { struct MainTabView: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var flowManager: FlowSessionManager @EnvironmentObject private var flowManager: FlowSessionManager
@State private var tab: AppTab = .keyboard @State private var tab: AppTab = .keyboard
@State private var isTabBarHidden = false @State private var isTabBarHidden = false
private var usesSplitLayout: Bool {
horizontalSizeClass == .regular
}
var body: some View { 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) { ZStack(alignment: .bottom) {
palette.background.ignoresSafeArea() palette.background.ignoresSafeArea()
Group { MainTabContent(tab: tab)
switch tab {
case .keyboard:
HomeView()
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
case .settings:
SettingsView(presentation: .tab)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(\.isTabBarVisible, !isTabBarHidden) .environment(\.isTabBarVisible, !isTabBarHidden)
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
@@ -45,8 +53,5 @@ struct MainTabView: View {
.transition(.move(edge: .bottom).combined(with: .opacity)) .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)
} }
} }
+66 -33
View File
@@ -1,12 +1,13 @@
// OnboardingView.swift // OnboardingView.swift
// OSGKeyboard · Main App // OSGKeyboard · Main App
// //
// Five-step onboarding: // Six-step onboarding:
// 1) Welcome // 1) Welcome
// 2) Microphone permission // 2) Microphone permission
// 3) Speech recognition permission // 3) Speech recognition permission
// 4) Enable keyboard + Allow Full Access // 4) Enable keyboard + Allow Full Access
// 5) Engine / API setup // 5) Engine / ASR setup
// 6) Polish LLM setup
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
@@ -18,8 +19,9 @@ private enum OnboardingPage: Int, CaseIterable {
case speech case speech
case keyboard case keyboard
case api case api
case polish
static let count = 5 static let count = 6
} }
struct OnboardingView: View { struct OnboardingView: View {
@@ -61,6 +63,8 @@ struct OnboardingView: View {
EnableKeyboardPage() EnableKeyboardPage()
case .api: case .api:
APISetupPage(config: config) APISetupPage(config: config)
case .polish:
PolishSetupPage(config: config)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .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 { private var canAdvance: Bool {
switch currentPage { switch currentPage {
case .welcome, .keyboard: case .welcome, .keyboard, .api:
return !isLastPage || onboardingCompleteReady
case .api:
return !isLastPage || onboardingCompleteReady return !isLastPage || onboardingCompleteReady
case .polish:
return onboardingCompleteReady
case .microphone: case .microphone:
return micStatus != .undetermined return micStatus != .undetermined
case .speech: case .speech:
@@ -250,9 +254,8 @@ struct OnboardingView: View {
} }
private var onboardingCompleteReady: Bool { private var onboardingCompleteReady: Bool {
// v0.2.0: the local engine uses iOS `SpeechAnalyzer`, which is // Local engine: built-in polish satisfies `isPolishConfigured`.
// always available no model download gate. The cloud engine // Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready.
// still requires base URL + API key + model.
if config.isLocalEngine { return true } if config.isLocalEngine { return true }
return config.isConfigured return config.isConfigured
} }
@@ -364,7 +367,6 @@ private struct OnboardingTitleBlock: View {
// MARK: - Welcome // MARK: - Welcome
private struct WelcomePage: View { private struct WelcomePage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var logoAppeared = false @State private var logoAppeared = false
var body: some View { var body: some View {
@@ -386,26 +388,13 @@ private struct WelcomePage: View {
} }
OnboardingTitleBlock( OnboardingTitleBlock(
title: "onboarding.welcome.tagline", title: "onboarding.welcome.tagline"
subtitle: "onboarding.welcome.subtitle",
secondarySubtitle: "onboarding.welcome.subtitle2"
) )
.opacity(logoAppeared ? 1 : 0) .opacity(logoAppeared ? 1 : 0)
.offset(y: logoAppeared ? 0 : 10) .offset(y: logoAppeared ? 0 : 10)
.padding(.top, OnboardingLayoutMetrics.heroTextGap) .padding(.top, OnboardingLayoutMetrics.heroTextGap)
.animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared) .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() Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -708,7 +697,7 @@ private struct EnableKeyboardPage: View {
} }
} }
// MARK: - API setup // MARK: - Engine / ASR setup
private struct APISetupPage: View { private struct APISetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@@ -728,16 +717,60 @@ private struct APISetupPage: View {
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
ProviderPickerSection(config: config) VStack(spacing: 0) {
.padding(.horizontal, Spacing.lg) ProviderPickerSection(config: config, role: .asr, showsSurface: false)
APISettingsCard(config: config) Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
} else { } else {
// Local engine: built-in ASR + built-in polish (no API card). Text("onboarding.api.localModels.hint")
LocalModelsGroup(config: config) .font(TypeStyle.body)
.padding(.horizontal, Spacing.lg) .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) .padding(.bottom, Spacing.xxxl)
} }
@@ -64,8 +64,7 @@ struct OpenSourceLicensesView: View {
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.padding(.vertical, 10)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
} }
@@ -30,8 +30,7 @@ struct PersonalDictionaryEntrySheet: View {
.focused($termFocused) .focused($termFocused)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous) 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)) .font(.caption.weight(.semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
+52 -31
View File
@@ -8,79 +8,100 @@ struct ProviderPickerSection: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig @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 { var body: some View {
// v0.2.1 follow-up: filter out presets marked as // Menu label
// `isUserSelectable == false` (DeepSeek is local-engine only). // label
let visiblePresets = LLMProvider.userSelectablePresets SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) {
VStack(spacing: 0) { Menu {
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in ForEach(visiblePresets, id: \.id) { provider in
Button { Button {
select(provider) select(provider)
} label: { } label: {
row(provider, selected: provider.id == config.providerId) let name = ProviderDisplayName.name(for: provider.id)
if provider.id == selectedProviderId {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
providerChip
} }
.buttonStyle(.plain) .buttonStyle(.plain)
if index < visiblePresets.count - 1 {
Divider().background(palette.divider)
} }
} .modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} }
private func select(_ provider: LLMProvider) { private func select(_ provider: LLMProvider) {
withAnimation(Motion.quick) { withAnimation(Motion.quick) {
switch role {
case .polish:
config.apply(preset: provider) config.apply(preset: provider)
case .asr:
config.applyAsr(preset: provider)
}
} }
} }
@ViewBuilder /// logo + +
private func row(_ provider: LLMProvider, selected: Bool) -> some View { private var providerChip: some View {
HStack(spacing: Spacing.sm) { 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) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
if provider.supportsPersonalDictionaryCloudASR { if selectedProvider.supportsPersonalDictionaryCloudASR {
personalDictionaryBadge personalDictionaryBadge
} }
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
if selected { Image(systemName: "chevron.up.chevron.down")
Image(systemName: "checkmark.circle.fill") .font(.system(size: 12, weight: .semibold))
.font(.system(size: 17, weight: .medium)) .foregroundStyle(palette.textTertiary)
.foregroundStyle(palette.accent)
} }
} .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@ViewBuilder @ViewBuilder
private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View { private func providerMark(_ provider: LLMProvider) -> some View {
ZStack { ZStack {
Circle() Circle()
.fill(selected ? palette.accentMuted : palette.surfaceElevated) .fill(palette.accentMuted)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
if let asset = ProviderLogo.assetName(for: provider.id) { if let asset = ProviderLogo.assetName(for: provider.id) {
Image(asset) Image(asset)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 18, height: 18) .frame(width: 18, height: 18)
.foregroundStyle(selected ? palette.accent : palette.textPrimary) .foregroundStyle(palette.accent)
} else { } else {
Text(String(provider.name.prefix(1))) Text(String(provider.name.prefix(1)))
.font(TypeStyle.caption) .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 syncErrorMessage: String?
@State private var isApplyingToggle = false @State private var isApplyingToggle = false
@State private var isSyncingNow = 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() private let store = AppGroupStore()
@@ -38,13 +41,21 @@ struct SettingsICloudSyncRow: View {
syncNow() syncNow()
} label: { } label: {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
Text(syncButtonTitleKey)
.font(TypeStyle.caption)
Spacer(minLength: 0)
if isSyncingNow { if isSyncingNow {
ProgressView() 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) .buttonStyle(.plain)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
@@ -60,15 +71,18 @@ struct SettingsICloudSyncRow: View {
.padding(.top, Spacing.xxs) .padding(.top, Spacing.xxs)
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow(alignment: .leading)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.onAppear { reloadFromStore() } .onAppear { reloadFromStore() }
.onReceive( .onReceive(
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud) NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
) { _ in ) { _ in
reloadFromStore() reloadFromStore()
} }
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
} }
private var toggleBinding: Binding<Bool> { 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() { private func reloadFromStore() {
isEnabled = store.settingsICloudSyncEnabled isEnabled = store.settingsICloudSyncEnabled
} }
@@ -95,6 +119,15 @@ struct SettingsICloudSyncRow: View {
Task { Task {
do { do {
try await CloudSyncContext.shared.settingsSyncService.enableSync() 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() reloadFromStore()
} catch let error as SettingsCloudSyncError { } catch let error as SettingsCloudSyncError {
isEnabled = false isEnabled = false
@@ -114,15 +147,23 @@ struct SettingsICloudSyncRow: View {
} }
private func syncNow() { private func syncNow() {
guard !isSyncingNow else { return }
isSyncingNow = true isSyncingNow = true
showSyncedConfirmation = false
syncErrorMessage = nil syncErrorMessage = nil
Task { Task {
do { do {
try await CloudSyncContext.shared.syncNow() 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 { } catch {
isSyncingNow = false
syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic") 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") 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 dictionaryAndPolishSection
flowSessionSection flowSessionSection
engineSection 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" { if config.engineMode == "cloud" {
providerSection asrSettingsSection
apiSection
} }
if config.engineMode == "local" { if config.engineMode == "local" {
localEngineSettingsSection localEngineSettingsSection
} }
polishSettingsSection
if presentation == .tab { if presentation == .tab {
footerLinks footerLinks
} }
@@ -120,9 +113,7 @@ struct SettingsView: View {
} }
} }
.tint(palette.accent) .tint(palette.accent)
.padding(.horizontal, Spacing.md) .settingsListRow()
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
Divider().background(palette.divider) Divider().background(palette.divider)
@@ -227,10 +218,6 @@ struct SettingsView: View {
Divider().background(palette.divider) Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible) TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
Divider().background(palette.divider)
PersonalDictionaryICloudSyncRow()
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay( .overlay(
@@ -253,21 +240,37 @@ struct SettingsView: View {
} }
} }
private var providerSection: some View { private var polishSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.provider.title") sectionHeader("settings.polishProvider.title")
ProviderPickerSection(config: config) cloudProviderSettingsCard {
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
Divider().background(palette.divider)
APISettingsCard(config: config, showsSurface: false)
}
} }
} }
// MARK: - API private var asrSettingsSection: some View {
private var apiSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.api.title") sectionHeader("settings.asrProvider.title")
APISettingsCard(config: config) 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 // MARK: - Language helpers
@@ -337,8 +340,7 @@ struct SettingsView: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
} }
.tint(palette.accent) .tint(palette.accent)
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
// MARK: - Footer links (tab settings only) // MARK: - Footer links (tab settings only)
@@ -360,8 +362,7 @@ struct SettingsView: View {
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -417,8 +418,7 @@ struct SettingsView: View {
MaterialIcon(name: .openInNew, size: 18) MaterialIcon(name: .openInNew, size: 18)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -438,8 +438,7 @@ struct SettingsView: View {
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -581,8 +580,7 @@ private struct PickerRow: View {
} }
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
private var currentLabel: String { private var currentLabel: String {
@@ -639,8 +637,7 @@ private struct LocalePickerRow: View {
} }
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
private func label(for localeId: String) -> String { private func label(for localeId: String) -> String {
+1 -4
View File
@@ -69,13 +69,10 @@ struct TranslationPickerRow: View {
} }
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
} }
// MARK: - Selection plumbing
/// Currently selected id the picker always reads /// Currently selected id the picker always reads
/// `translationTargetLocaleId` directly (the previous /// `translationTargetLocaleId` directly (the previous
/// `translationEnabled` boolean is now derived from it). /// `translationEnabled` boolean is now derived from it).
+1 -1
View File
@@ -1,4 +1,4 @@
/* Permission prompts — English */ /* Permission prompts — English */
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running."; "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 */
"onboarding.progress" = "Step %1$d of %2$d"; "onboarding.progress" = "Step %1$d of %2$d";
"onboarding.welcome.tagline" = "Speak instead of type"; "onboarding.welcome.tagline" = "Speak it. Its typed.";
"onboarding.welcome.subtitle" = "Open-source voice keyboard";
"onboarding.welcome.subtitle2" = "We never read or upload anything you type.";
"onboarding.permission.preface" = "iOS will ask for two permissions — please allow both."; "onboarding.permission.preface" = "iOS will ask for two permissions — please allow both.";
"onboarding.permission.mic.title" = "Microphone"; "onboarding.permission.mic.title" = "Microphone";
"onboarding.permission.mic.body" = "Records your voice."; "onboarding.permission.mic.body" = "Records your voice.";
@@ -29,7 +27,10 @@
"onboarding.enable.step3.suffix" = "and select OSGKeyboard"; "onboarding.enable.step3.suffix" = "and select OSGKeyboard";
"onboarding.enable.openSettings" = "Open Settings"; "onboarding.enable.openSettings" = "Open Settings";
"onboarding.api.title" = "Choose Engine"; "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"; "settings.onboarding.replay" = "Restart permission setup";
/* Common navigation */ /* Common navigation */
@@ -44,6 +45,7 @@
"common.delete" = "Delete"; "common.delete" = "Delete";
"common.space" = "Space"; "common.space" = "Space";
"common.newline" = "Return"; "common.newline" = "Return";
"common.send" = "Send";
/* Privacy footnote (onboarding engine page) */ /* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "On-device transcription"; "privacy.audio.title" = "On-device transcription";
@@ -92,17 +94,45 @@
"settings.reset.title" = "Reset all settings?"; "settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared."; "settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.confirm" = "Reset all settings"; "settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Recognition method"; "settings.engine.title" = "Speech transcription method";
"settings.engine.subtitle" = "Local: on-device ASR only. Cloud: ASR + polish via your API."; "settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
"settings.engine.local.title" = "On-device recognition"; "settings.engine.local.title" = "On-device transcription";
"settings.engine.local.ios26" = "Always on-device, no network."; "settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; "settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
"settings.engine.cloud.title" = "Cloud recognition & polish"; "settings.engine.cloud.title" = "Cloud transcription";
"settings.engine.cloud.subtitle" = "Cloud ASR (with your dictionary) + API polish. Audio is sent to your provider."; "settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
"settings.engine.cloud.badge" = "Cloud engine"; "settings.engine.cloud.badge" = "Cloud engine";
"settings.provider.title" = "Provider"; "settings.provider.title" = "Provider";
"settings.provider.personalDictionaryBadge" = "Personal dictionary"; "settings.provider.personalDictionaryBadge" = "Personal dictionary";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; "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.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek"; "provider.deepseek" = "DeepSeek";
"provider.qwen" = "Qwen (DashScope)"; "provider.qwen" = "Qwen (DashScope)";
@@ -161,8 +191,8 @@
"settings.privacy.fullAccess.title" = "About Full Access"; "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.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.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.title" = "Audio leaves your device";
"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.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.support" = "Help & Feedback";
"settings.link.github" = "GitHub"; "settings.link.github" = "GitHub";
"settings.support.footer" = "OSGKeyboard is open source. Report bugs and share ideas on 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.label" = "Ready";
"home.flow.inactive" = "Voice session inactive"; "home.flow.inactive" = "Voice session inactive";
"home.flow.notReady" = "Not ready"; "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.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.mic" = "Microphone access is off — voice input won't work.";
"home.setup.permission.speech" = "Speech recognition 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.flow.startShort" = "Start";
"home.preview.label" = "Try typing"; "home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…"; "home.preview.placeholder" = "Tap to type and test…";
"home.stats.dictationDuration" = "Dictation time"; "home.wide.tagline" = "Voice dictation, anywhere.";
"home.stats.dictationCharacters" = "Dictation chars"; "home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
"home.stats.translationCharacters" = "Translation chars"; "home.wide.mode.cloud" = "Cloud";
"home.stats.dictionaryEntries" = "Dictionary"; "home.wide.mode.local" = "On-device";
"home.wide.devices" = "iPhone & iPad";
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+"; "home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+";
"home.engine.warming" = "Loading ASR model into memory…"; "home.engine.warming" = "Loading ASR model into memory…";
"home.engine.downloading" = "Downloading ASR model…"; "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.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.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
"settings.iCloudSync.title" = "iCloud Sync"; "settings.iCloudSync.title" = "Cross-Device iCloud Sync";
"settings.appSettings.iCloudSync.title" = "iCloud Sync"; "settings.appSettings.iCloudSync.title" = "Cross-Device 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.appSettings.iCloudSync.subtitle" = "Sync settings, history, and API keys across devices via iCloud.";
"settings.appSettings.iCloudSync.syncNow" = "Sync Now"; "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."; "settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
/* v0.3.0: Polish intensity */ /* v0.3.0: Polish intensity */
+1 -1
View File
@@ -1,4 +1,4 @@
/* 权限说明 — 简体中文 */ /* 权限说明 — 简体中文 */
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,并在语音会话运行期间保持后台音频会话。"; "NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,并在语音会话运行期间保持后台音频会话。";
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 使用设备端语音识别将你的语音转为文字。音频仅在设备上处理,不会上传用于转写。"; "NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
+55 -20
View File
@@ -4,9 +4,7 @@
/* Onboarding */ /* Onboarding */
"onboarding.progress" = "第 %1$d / %2$d 步"; "onboarding.progress" = "第 %1$d / %2$d 步";
"onboarding.welcome.tagline" = "能说就不打字"; "onboarding.welcome.tagline" = "开口即文字。";
"onboarding.welcome.subtitle" = "开源语音输入法";
"onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。";
"onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。"; "onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。";
"onboarding.permission.mic.title" = "麦克风"; "onboarding.permission.mic.title" = "麦克风";
"onboarding.permission.mic.body" = "用于录制语音。"; "onboarding.permission.mic.body" = "用于录制语音。";
@@ -29,7 +27,10 @@
"onboarding.enable.step3.suffix" = ",选中 OSGKeyboard"; "onboarding.enable.step3.suffix" = ",选中 OSGKeyboard";
"onboarding.enable.openSettings" = "去设置"; "onboarding.enable.openSettings" = "去设置";
"onboarding.api.title" = "选择语音转文字 AI 引擎"; "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" = "重新开始权限引导"; "settings.onboarding.replay" = "重新开始权限引导";
/* Common navigation */ /* Common navigation */
@@ -43,7 +44,8 @@
"common.clear" = "清空"; "common.clear" = "清空";
"common.delete" = "删除"; "common.delete" = "删除";
"common.space" = "空格"; "common.space" = "空格";
"common.newline" = "换行"; "common.newline" = "回车";
"common.send" = "发送";
/* Privacy footnote (onboarding engine page) */ /* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "支持本地转写"; "privacy.audio.title" = "支持本地转写";
@@ -92,17 +94,45 @@
"settings.reset.title" = "重置所有设置?"; "settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。"; "settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.confirm" = "重置所有设置"; "settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "识别方式"; "settings.engine.title" = "语音转写方式";
"settings.engine.subtitle" = "本地:端侧识别,仅转录;云端:通过 API 润色。"; "settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
"settings.engine.local.title" = "本地识别"; "settings.engine.local.title" = "本地转写";
"settings.engine.local.ios26" = "全程在手机本地,不用联网"; "settings.engine.local.ios26" = "全程在手机本地,不用联网";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.local.legacy" = "通过系统内置服务语音转写";
"settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.title" = "云端转写";
"settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务"; "settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
"settings.engine.cloud.badge" = "云端引擎"; "settings.engine.cloud.badge" = "云端引擎";
"settings.provider.title" = "云端引擎"; "settings.provider.title" = "云端引擎";
"settings.provider.personalDictionaryBadge" = "个性词库"; "settings.provider.personalDictionaryBadge" = "个性词库";
"settings.provider.subtitle" = "选择 LLM 提供商。"; "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.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek"; "provider.deepseek" = "DeepSeek";
"provider.qwen" = "通义千问"; "provider.qwen" = "通义千问";
@@ -161,8 +191,8 @@
"settings.privacy.fullAccess.title" = "关于完全访问"; "settings.privacy.fullAccess.title" = "关于完全访问";
"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。"; "settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。";
"settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。"; "settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。";
"settings.privacy.cloud.alert.title" = "第三方 API"; "settings.privacy.cloud.alert.title" = "音频将离开你的设备";
"settings.privacy.cloud.alert.message" = "云端润色会把转写文字发到你配置的第三方 API。OSGKeyboard 不会在自有服务器上存储数据。是否继续?"; "settings.privacy.cloud.alert.message" = "云端引擎会把你的语音录音上传到你配置的第三方识别服务,并把转写文字发送到其 API 进行润色,适用该服务商的隐私政策。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
"settings.link.support" = "帮助与反馈"; "settings.link.support" = "帮助与反馈";
"settings.link.github" = "GitHub"; "settings.link.github" = "GitHub";
"settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。"; "settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。";
@@ -286,6 +316,8 @@
"home.flow.label" = "就绪"; "home.flow.label" = "就绪";
"home.flow.inactive" = "语音会话未启动"; "home.flow.inactive" = "语音会话未启动";
"home.flow.notReady" = "未就绪"; "home.flow.notReady" = "未就绪";
"home.flow.recording" = "录音中…";
"home.flow.processing" = "处理中…";
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。"; "home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。"; "home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。"; "home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
@@ -302,10 +334,11 @@
"home.flow.startShort" = "开启"; "home.flow.startShort" = "开启";
"home.preview.label" = "输入测试"; "home.preview.label" = "输入测试";
"home.preview.placeholder" = "点这里试试键盘"; "home.preview.placeholder" = "点这里试试键盘";
"home.stats.dictationDuration" = "听写时长"; "home.wide.tagline" = "随处语音听写";
"home.stats.dictationCharacters" = "听写字数"; "home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
"home.stats.translationCharacters" = "翻译字数"; "home.wide.mode.cloud" = "云端";
"home.stats.dictionaryEntries" = "个性词库"; "home.wide.mode.local" = "本机";
"home.wide.devices" = "iPhone 与 iPad";
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本"; "home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本";
"home.engine.warming" = "正在加载语音识别模型…"; "home.engine.warming" = "正在加载语音识别模型…";
"home.engine.downloading" = "正在下载语音识别模型…"; "home.engine.downloading" = "正在下载语音识别模型…";
@@ -367,10 +400,12 @@
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。"; "settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。"; "settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
"settings.iCloudSync.title" = "iCloud 同步"; "settings.iCloudSync.title" = "跨设备iCloud 同步";
"settings.appSettings.iCloudSync.title" = "iCloud 同步"; "settings.appSettings.iCloudSync.title" = "跨设备iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。"; "settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 设备同步设置、历史记录、API Key";
"settings.appSettings.iCloudSync.syncNow" = "立即同步"; "settings.appSettings.iCloudSync.syncNow" = "立即同步";
"settings.appSettings.iCloudSync.syncing" = "同步中…";
"settings.appSettings.iCloudSync.synced" = "已同步";
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。"; "settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
/* v0.3.0: 润色强度 */ /* 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. // Voice-first keyboard hide the misleading "English" subtitle in Settings.
primaryLanguage = "mis" primaryLanguage = "mis"
OSGLog.keyboardExt.info("viewDidLoad — extension booted") 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() setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
installKeyboardHeight() installKeyboardHeight()
configureDictationBehavior() configureDictationBehavior()
@@ -84,6 +88,7 @@ public final class KeyboardViewController: UIInputViewController {
setNeedsUpdateOfScreenEdgesDeferringSystemGestures() setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior() configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess) KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
state.debugHasFullAccess = hasFullAccess
flowCoordinator.refreshSessionState() flowCoordinator.refreshSessionState()
flowCoordinator.startSessionMonitor() flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup() configSync.syncOnboardingStateFromAppGroup()
@@ -100,6 +105,12 @@ public final class KeyboardViewController: UIInputViewController {
super.viewDidAppear(animated) super.viewDidAppear(animated)
disableSystemGestureDelays() disableSystemGestureDelays()
keyboardHeightConstraint?.constant = targetKeyboardHeight keyboardHeightConstraint?.constant = targetKeyboardHeight
refreshReturnKeyRole()
}
public override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput)
refreshReturnKeyRole()
} }
public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
@@ -126,6 +137,7 @@ public final class KeyboardViewController: UIInputViewController {
textInserter = KeyboardTextInserter( textInserter = KeyboardTextInserter(
state: state, state: state,
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) }, insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() } 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 // MARK: - System keyboard chrome
private func configureDictationBehavior() { private func configureDictationBehavior() {
+50 -9
View File
@@ -4,15 +4,17 @@
// Opens the host app from the keyboard extension. // Opens the host app from the keyboard extension.
// //
// Reality check (verified against iOS 1826 behaviour): // 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 // The deprecated `openURL:` selector hack was disabled in iOS 18
// ("BUG IN CLIENT OF UIKIT migrate to open(_:options:completionHandler:)"). // ("BUG IN CLIENT OF UIKIT migrate to open(_:options:completionHandler:)").
// The still-working path is: walk the responder chain to `UIApplication` // Primary path: walk the responder chain to `UIApplication` and call the
// and call the non-deprecated `open(_:options:completionHandler:)`. This // non-deprecated `open(_:options:completionHandler:)`. This requires Full
// requires Full Access and grows less reliable on newer iOS, so we report // Access and grows less reliable on newer iOS, so we report the *real*
// the *real* success from the completion handler instead of assuming it // success from the completion handler instead of assuming it worked.
// worked callers degrade to on-keyboard guidance when it returns false. // 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 import UIKit
@@ -27,13 +29,52 @@ enum HostAppLauncher {
while let current = responder { while let current = responder {
if let application = current as? UIApplication { if let application = current as? UIApplication {
application.open(url, options: [:]) { success in 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 return
} }
responder = current.next responder = current.next
} }
// No `UIApplication` in the responder chain cannot open the host app. // 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) 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 isAwaitingFlowResult = false
private var activeSessionId: UUID? private var activeSessionId: UUID?
private var currentUtteranceId: 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 currentCommandSeq: Int64 = 0
private var lastAvailabilityTraceSignature = "" 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( init(
state: KeyboardState, state: KeyboardState,
@@ -116,6 +133,8 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
startHostReadyWaitIfNeeded() 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* // Only surface "session ended" when the session contract *genuinely*
// dropped (expired / cleared). A transient host-ready flap engine // dropped (expired / cleared). A transient host-ready flap engine
@@ -139,6 +158,12 @@ final class KeyboardFlowCoordinator {
FlowSessionBridge.reloadFromDisk() FlowSessionBridge.reloadFromDisk()
let readySnapshot = FlowSessionBridge.readySnapshot() let readySnapshot = FlowSessionBridge.readySnapshot()
activeSessionId = readySnapshot?.sessionId ?? activeSessionId 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 hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
if hostReady { lastHostReadyAt = now } if hostReady { lastHostReadyAt = now }
@@ -148,10 +173,20 @@ final class KeyboardFlowCoordinator {
// across cross-process read jitter and anchors this smoothing. // across cross-process read jitter and anchors this smoothing.
let withinReadyGrace = lastHostReadyAt > 0 let withinReadyGrace = lastHostReadyAt > 0
&& (now - lastHostReadyAt) <= Self.hostReadyGrace && (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 let hostWarming = !hostReady
&& !hostBusy
&& FlowSessionBridge.isSessionActive() && FlowSessionBridge.isSessionActive()
&& (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace) && (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace)
state.flowSessionActive = FlowSessionBridge.isSessionActive() state.flowSessionActive = FlowSessionBridge.isSessionActive()
state.debugPendingFlowStart = isPendingFlowStart
state.debugFlowRecording = isFlowRecording
state.debugAwaitingFlowResult = isAwaitingFlowResult
state.debugHasFullAccess = hasFullAccess()
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve( state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
phase: state.phase, phase: state.phase,
micDisabled: state.micDisabled, 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 /// Session is live but the ready contract has not landed yet poll
/// quickly instead of sticking on "session inactive" orange. /// quickly instead of sticking on "session inactive" orange.
private func startHostReadyWaitIfNeeded() { private func startHostReadyWaitIfNeeded() {
guard !isPendingFlowStart else { return } guard !isPendingFlowStart else { return }
guard FlowSessionBridge.isSessionActive() else { guard FlowSessionBridge.isSessionActive() else {
stopHostReadyWait() stopHostReadyWait()
if recordWhenHostReady {
// Session gone while waiting escalate to a real cold start.
let shouldRecord = recordWhenHostReady
recordWhenHostReady = false
beginFlowStart(recordAfterHandoff: shouldRecord)
}
return 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() stopHostReadyWait()
recordWhenHostReady = false
return
}
if FlowSessionBridge.isHostReady() {
stopHostReadyWait()
finishHostReadyWaitIfNeeded()
return return
} }
@@ -197,12 +326,48 @@ final class KeyboardFlowCoordinator {
FlowSessionBridge.reloadFromDisk() FlowSessionBridge.reloadFromDisk()
self.recomputeMicVoiceAvailability() self.recomputeMicVoiceAvailability()
if self.state.micVoiceAvailability.isReady { 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 return
} }
try? await Task.sleep(nanoseconds: 150_000_000) 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() { private func stopHostReadyWait() {
hostReadyWaitTask?.cancel() hostReadyWaitTask?.cancel()
@@ -232,9 +397,6 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
switch state.micVoiceAvailability { switch state.micVoiceAvailability {
case .ready:
detectAndStoreAppContext()
startFlowRecording()
case .unavailable(.missingAPIKey): case .unavailable(.missingAPIKey):
return return
case .unavailable(.noFullAccess): case .unavailable(.noFullAccess):
@@ -242,18 +404,43 @@ final class KeyboardFlowCoordinator {
state.phase = .error(.fullAccessRequired, message: msg) state.phase = .error(.fullAccessRequired, message: msg)
scheduleAutoClearError() scheduleAutoClearError()
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
return
case .unavailable(.appGroupUnavailable): case .unavailable(.appGroupUnavailable):
let msg = ExtL10n.string("keyboard.error.appGroupCommunication") let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
state.phase = .error(.appGroupUnavailable, message: msg) state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError() scheduleAutoClearError()
recomputeMicVoiceAvailability() 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() detectAndStoreAppContext()
beginFlowStart() startFlowRecording()
case .unavailable(.hostNotReady): case .waitForHostReady(let recordWhenReady):
detectAndStoreAppContext() detectAndStoreAppContext()
beginFlowStart() recordWhenHostReady = recordWhenReady
case .recording, .processing: coldStartDebouncer.reset()
startHostReadyWaitIfNeeded()
traceState(
"pressBegan.waitForHostReady",
extra: recordWhenReady ? "recordWhenReady=1" : "recordWhenReady=0"
)
case .openHostColdStart:
detectAndStoreAppContext()
beginFlowStart(recordAfterHandoff: true)
case .ignore:
return return
} }
} }
@@ -268,19 +455,23 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false isFlowRecording = false
stopUtteranceCountdown() stopUtteranceCountdown()
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
lastStoppedUtteranceId = currentUtteranceId
writeCommand(.stopRecording) writeCommand(.stopRecording)
debug("pressEnded wrote stop command") debug("pressEnded wrote stop command")
state.phase = .processing state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
recomputeMicVoiceAvailability()
startFlowResultWatchdog() startFlowResultWatchdog()
recomputeMicVoiceAvailability()
} }
func beginFlowStart() { func beginFlowStart(recordAfterHandoff: Bool = false) {
guard !isPendingFlowStart else { guard !isPendingFlowStart else {
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue") traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
return return
} }
self.recordAfterHandoff = recordAfterHandoff
recordWhenHostReady = false
coldStartDebouncer.reset()
isPendingFlowStart = true isPendingFlowStart = true
isFlowRecording = false isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
@@ -288,7 +479,10 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
openHostApp("startflow") openHostApp("startflow")
startFlowStartWatchdog() startFlowStartWatchdog()
traceState("beginFlowStart.started") traceState(
"beginFlowStart.started",
extra: recordAfterHandoff ? "recordAfterHandoff=1" : "recordAfterHandoff=0"
)
} }
func handleHostAppOpenResult(path: String, success: Bool) { func handleHostAppOpenResult(path: String, success: Bool) {
@@ -300,6 +494,7 @@ final class KeyboardFlowCoordinator {
// guide the user to open OSGKeyboard manually. // guide the user to open OSGKeyboard manually.
if path == "startflow", isPendingFlowStart { if path == "startflow", isPendingFlowStart {
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0 flowStartDeadline = 0
stopFlowWatchdog() stopFlowWatchdog()
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1") traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
@@ -319,8 +514,10 @@ final class KeyboardFlowCoordinator {
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
} }
currentUtteranceId = nil currentUtteranceId = nil
lastStoppedUtteranceId = nil
isFlowRecording = false isFlowRecording = false
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
stopUtteranceCountdown() stopUtteranceCountdown()
stopFlowWatchdog() stopFlowWatchdog()
state.level = 0 state.level = 0
@@ -365,6 +562,8 @@ final class KeyboardFlowCoordinator {
) )
) )
FlowSessionBridge.clearResult() FlowSessionBridge.clearResult()
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil currentUtteranceId = nil
textInserter.handleFlowTranscript( textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning) TranscriptionDelivery(text: text, polishWarning: result.warning)
@@ -375,6 +574,8 @@ final class KeyboardFlowCoordinator {
isAwaitingFlowResult = false isAwaitingFlowResult = false
stopFlowWatchdog() stopFlowWatchdog()
FlowSessionBridge.clearResult() FlowSessionBridge.clearResult()
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil currentUtteranceId = nil
let error = FlowTranscriptionError( let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
@@ -420,6 +621,7 @@ final class KeyboardFlowCoordinator {
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
writeCommand(.abort) writeCommand(.abort)
currentUtteranceId = nil currentUtteranceId = nil
lastStoppedUtteranceId = nil
stopFlowWatchdog() stopFlowWatchdog()
state.level = 0 state.level = 0
state.phase = .idle state.phase = .idle
@@ -439,10 +641,12 @@ final class KeyboardFlowCoordinator {
isAwaitingFlowResult = false isAwaitingFlowResult = false
isFlowRecording = false isFlowRecording = false
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
stopUtteranceCountdown() stopUtteranceCountdown()
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
writeCommand(.abort) writeCommand(.abort)
currentUtteranceId = nil currentUtteranceId = nil
lastStoppedUtteranceId = nil
stopFlowWatchdog() stopFlowWatchdog()
state.level = 0 state.level = 0
let message = ExtL10n.string("keyboard.flow.hostDisconnected") let message = ExtL10n.string("keyboard.flow.hostDisconnected")
@@ -477,9 +681,29 @@ final class KeyboardFlowCoordinator {
private func startFlowRecording() { private func startFlowRecording() {
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
guard state.micVoiceAvailability.isReady else { let withinReadyGrace = lastHostReadyAt > 0
traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))") && (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
beginFlowStart() 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 return
} }
isPendingFlowStart = false isPendingFlowStart = false
@@ -488,11 +712,23 @@ final class KeyboardFlowCoordinator {
guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else { guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else {
traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot") 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 return
} }
activeSessionId = sessionId activeSessionId = sessionId
currentUtteranceId = UUID() currentUtteranceId = UUID()
lastStoppedUtteranceId = nil
writeCommand(.startRecording) writeCommand(.startRecording)
isFlowRecording = true isFlowRecording = true
state.lastTranscript = "" state.lastTranscript = ""
@@ -532,8 +768,12 @@ final class KeyboardFlowCoordinator {
private func cancelPendingFlowStart() { private func cancelPendingFlowStart() {
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
recordWhenHostReady = false
flowStartDeadline = 0 flowStartDeadline = 0
coldStartDebouncer.reset()
stopFlowWatchdog() stopFlowWatchdog()
stopHostReadyWait()
state.phase = .idle state.phase = .idle
state.lastTranscript = "" state.lastTranscript = ""
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
@@ -552,6 +792,7 @@ final class KeyboardFlowCoordinator {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
if self.flowStartDeadline > 0, now > self.flowStartDeadline { if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false self.isPendingFlowStart = false
self.recordAfterHandoff = false
self.flowStartDeadline = 0 self.flowStartDeadline = 0
self.traceState("startWatchdog.timeout") self.traceState("startWatchdog.timeout")
self.showManualOpenHint(path: "startflow") self.showManualOpenHint(path: "startflow")
@@ -563,13 +804,20 @@ final class KeyboardFlowCoordinator {
} }
private func completeFlowStartHandoff() { private func completeFlowStartHandoff() {
let shouldRecord = recordAfterHandoff
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0 flowStartDeadline = 0
stopFlowWatchdog() stopFlowWatchdog()
state.lastTranscript = "" state.lastTranscript = ""
refreshSessionState() refreshSessionState()
if shouldRecord {
startFlowRecording() startFlowRecording()
traceState("completeFlowStartHandoff.done") traceState("completeFlowStartHandoff.done", extra: "record=1")
} else {
recomputeMicVoiceAvailability()
traceState("completeFlowStartHandoff.done", extra: "record=0 warmOnly")
}
} }
private func startFlowLevelWatchdog() { private func startFlowLevelWatchdog() {
@@ -626,6 +874,8 @@ final class KeyboardFlowCoordinator {
) )
) )
FlowSessionBridge.clearResult() FlowSessionBridge.clearResult()
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil self.currentUtteranceId = nil
self.debug("resultWatchdog consumed delivery len=\(text.count)") self.debug("resultWatchdog consumed delivery len=\(text.count)")
self.textInserter.handleFlowTranscript( self.textInserter.handleFlowTranscript(
@@ -637,6 +887,8 @@ final class KeyboardFlowCoordinator {
self.isAwaitingFlowResult = false self.isAwaitingFlowResult = false
self.stopFlowWatchdog() self.stopFlowWatchdog()
FlowSessionBridge.clearResult() FlowSessionBridge.clearResult()
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil self.currentUtteranceId = nil
let error = FlowTranscriptionError( let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
@@ -674,6 +926,7 @@ final class KeyboardFlowCoordinator {
self.isAwaitingFlowResult = false self.isAwaitingFlowResult = false
self.stopFlowWatchdog() self.stopFlowWatchdog()
self.currentUtteranceId = nil self.currentUtteranceId = nil
self.lastStoppedUtteranceId = nil
self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host") self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host")
let msg = ExtL10n.string("keyboard.flow.resultTimeout") let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.flowResultTimeout, message: msg) self.state.phase = .error(.flowResultTimeout, message: msg)
@@ -10,15 +10,18 @@ import OSGKeyboardShared
final class KeyboardTextInserter { final class KeyboardTextInserter {
private let state: KeyboardState private let state: KeyboardState
private let insertText: (String) -> Void private let insertText: (String) -> Void
private let contextBeforeInput: () -> String?
private let scheduleAutoClearError: () -> Void private let scheduleAutoClearError: () -> Void
init( init(
state: KeyboardState, state: KeyboardState,
insertText: @escaping (String) -> Void, insertText: @escaping (String) -> Void,
contextBeforeInput: @escaping () -> String?,
scheduleAutoClearError: @escaping () -> Void scheduleAutoClearError: @escaping () -> Void
) { ) {
self.state = state self.state = state
self.insertText = insertText self.insertText = insertText
self.contextBeforeInput = contextBeforeInput
self.scheduleAutoClearError = scheduleAutoClearError self.scheduleAutoClearError = scheduleAutoClearError
} }
@@ -30,7 +33,13 @@ final class KeyboardTextInserter {
return return
} }
// Host app already polished when configured; keyboard only inserts. // 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.lastTranscript = ""
state.level = 0 state.level = 0
if let warning = delivery.polishWarning { if let warning = delivery.polishWarning {
+19 -11
View File
@@ -12,7 +12,7 @@
// (transcript preview) // (transcript preview)
// //
// mic (centred) action cluster: // 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 static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
/// Outer inset for the bottom action row from screen edges (8 pt 24 pt, +200%). /// Outer inset for the bottom action row from screen edges (8 pt 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3 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) // MARK: - Content-driven keyboard height (single source of truth)
static let outerPaddingTop: CGFloat = 2 static let outerPaddingTop: CGFloat = 2
@@ -109,6 +114,8 @@ public struct KeyboardRootView: View {
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom) .padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome // chrome
.background(Color.clear) .background(Color.clear)
.frame(maxWidth: KeyboardLayoutMetrics.contentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight) .frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons. // Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette) .environment(\.themePalette, palette)
@@ -184,7 +191,7 @@ public struct KeyboardRootView: View {
// MARK: - Action cluster // 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 /// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
/// pan recognizers, avoiding SwiftUI gesture delivery issues in /// pan recognizers, avoiding SwiftUI gesture delivery issues in
/// keyboard extensions. /// keyboard extensions.
@@ -218,13 +225,13 @@ public struct KeyboardRootView: View {
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) { HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys { if swapKeys {
bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked) bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
bottomDeleteButton(disabled: editingBlocked) bottomDeleteButton(disabled: editingBlocked)
} else { } else {
bottomDeleteButton(disabled: editingBlocked) bottomDeleteButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked) bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
} }
} }
.opacity(dragging ? 0 : 1) .opacity(dragging ? 0 : 1)
@@ -258,19 +265,20 @@ public struct KeyboardRootView: View {
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) { RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
state.insertSpace() state.insertSpace()
} }
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomReturnButton(disabled: Bool) -> some View {
RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) {
state.insertNewline()
}
.frame( .frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth, width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight 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. /// Option C: block typing keys during the full voice-input pipeline.
private var voiceInputBlocksEditing: Bool { private var voiceInputBlocksEditing: Bool {
switch state.phase { switch state.phase {
@@ -10,6 +10,7 @@ import OSGKeyboardShared
private enum ToolbarButtonMetrics { private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14 static let iconSize: CGFloat = 14
static let titleSize: CGFloat = 16
static let cornerRadius: CGFloat = 12 static let cornerRadius: CGFloat = 12
static let spaceBarCapsuleWidth: CGFloat = 31 static let spaceBarCapsuleWidth: CGFloat = 31
static let pressScale: CGFloat = 0.94 static let pressScale: CGFloat = 0.94
@@ -138,6 +139,7 @@ struct RectangularToolbarButton: View {
let systemName: String? let systemName: String?
let spaceStyle: Bool let spaceStyle: Bool
let title: String?
let label: String let label: String
let disabled: Bool let disabled: Bool
let action: () -> Void let action: () -> Void
@@ -145,14 +147,25 @@ struct RectangularToolbarButton: View {
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) { init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = systemName self.systemName = systemName
self.spaceStyle = false self.spaceStyle = false
self.title = nil
self.label = label self.label = label
self.disabled = disabled self.disabled = disabled
self.action = action 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) { init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = nil self.systemName = nil
self.spaceStyle = spaceStyle self.spaceStyle = spaceStyle
self.title = nil
self.label = label self.label = label
self.disabled = disabled self.disabled = disabled
self.action = action self.action = action
@@ -170,6 +183,10 @@ struct RectangularToolbarButton: View {
Image(systemName: systemName) Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold)) .font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
+7 -6
View File
@@ -25,6 +25,7 @@
"common.delete" = "Delete"; "common.delete" = "Delete";
"common.space" = "Space"; "common.space" = "Space";
"common.newline" = "Return"; "common.newline" = "Return";
"common.send" = "Send";
/* Privacy footnote (onboarding welcome page) */ /* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "On-device transcription"; "privacy.audio.title" = "On-device transcription";
@@ -69,13 +70,13 @@
"settings.reset.title" = "Reset all settings?"; "settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, and base URL will be cleared."; "settings.reset.message" = "API key, model, and base URL will be cleared.";
"settings.reset.confirm" = "Reset all settings"; "settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Recognition method"; "settings.engine.title" = "Speech transcription method";
"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed."; "settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
"settings.engine.local.title" = "On-device recognition"; "settings.engine.local.title" = "On-device transcription";
"settings.engine.local.ios26" = "Always on-device, no network."; "settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; "settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
"settings.engine.cloud.title" = "Cloud recognition & polish"; "settings.engine.cloud.title" = "Cloud transcription";
"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required."; "settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
"settings.provider.title" = "Provider"; "settings.provider.title" = "Provider";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; "settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"settings.api.title" = "API"; "settings.api.title" = "API";
@@ -24,7 +24,8 @@
"common.cancel" = "取消"; "common.cancel" = "取消";
"common.delete" = "删除"; "common.delete" = "删除";
"common.space" = "空格"; "common.space" = "空格";
"common.newline" = "换行"; "common.newline" = "回车";
"common.send" = "发送";
/* Privacy footnote (onboarding welcome page) */ /* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "支持本地转写"; "privacy.audio.title" = "支持本地转写";
@@ -69,13 +70,13 @@
"settings.reset.title" = "重置所有设置?"; "settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "API key、model 和 base URL 都会被清空。"; "settings.reset.message" = "API key、model 和 base URL 都会被清空。";
"settings.reset.confirm" = "重置所有设置"; "settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "识别方式"; "settings.engine.title" = "语音转写方式";
"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。"; "settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
"settings.engine.local.title" = "本地识别"; "settings.engine.local.title" = "本地转写";
"settings.engine.local.ios26" = "始终端侧,无需联网。"; "settings.engine.local.ios26" = "始终端侧,无需联网。";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.local.legacy" = "通过系统内置服务语音转写";
"settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.title" = "云端转写";
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; "settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
"settings.provider.title" = "云端引擎"; "settings.provider.title" = "云端引擎";
"settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.provider.subtitle" = "选择 LLM 提供商。";
"settings.api.title" = "接口"; "settings.api.title" = "接口";
@@ -9,37 +9,50 @@ import SwiftUI
import WidgetKit import WidgetKit
struct FlowLiveActivityWidget: Widget { 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 { var body: some WidgetConfiguration {
ActivityConfiguration(for: FlowActivityAttributes.self) { context in ActivityConfiguration(for: FlowActivityAttributes.self) { context in
FlowLiveActivityLockScreenView(phase: context.state.phase) FlowLiveActivityLockScreenView(
phase: context.state.phase,
isStale: context.isStale
)
.activityBackgroundTint(Color.black.opacity(0.82)) .activityBackgroundTint(Color.black.opacity(0.82))
.activitySystemActionForegroundColor(.white) .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: { context in
DynamicIsland { DynamicIsland {
DynamicIslandExpandedRegion(.leading) { DynamicIslandExpandedRegion(.leading) {
FlowLiveActivityBrandMark(height: 16) FlowLiveActivityBrandMark(height: 16)
} }
DynamicIslandExpandedRegion(.trailing) { DynamicIslandExpandedRegion(.trailing) {
FlowLiveActivityPhaseLabel(phase: context.state.phase) FlowLiveActivityPhaseLabel(phase: context.state.phase, isStale: context.isStale)
} }
DynamicIslandExpandedRegion(.center) { DynamicIslandExpandedRegion(.center) {
Text("OSGKeyboard") Text("OSGKeyboard")
.font(.headline) .font(.headline)
} }
DynamicIslandExpandedRegion(.bottom) { DynamicIslandExpandedRegion(.bottom) {
FlowLiveActivityPhaseCaption(phase: context.state.phase) FlowLiveActivityPhaseCaption(phase: context.state.phase, isStale: context.isStale)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} compactLeading: { } compactLeading: {
FlowLiveActivityBrandMark(height: 12) FlowLiveActivityBrandMark(height: 12)
} compactTrailing: { } compactTrailing: {
FlowLiveActivityTrailingGlyph(phase: context.state.phase) FlowLiveActivityTrailingGlyph(phase: context.state.phase, isStale: context.isStale)
} minimal: { } minimal: {
// The minimal slot is a tiny circle; a short wordmark keeps // The minimal slot is a tiny circle; a short wordmark keeps
// the natural ratio without overflowing its bounds. // the natural ratio without overflowing its bounds.
FlowLiveActivityBrandMark(height: 6) FlowLiveActivityBrandMark(height: 6)
} }
.widgetURL(context.isStale ? Self.reconnectURL : nil)
.keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0)) .keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0))
} }
} }
@@ -49,20 +62,24 @@ struct FlowLiveActivityWidget: Widget {
private struct FlowLiveActivityLockScreenView: View { private struct FlowLiveActivityLockScreenView: View {
let phase: FlowActivityAttributes.ContentState.Phase let phase: FlowActivityAttributes.ContentState.Phase
let isStale: Bool
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
FlowLiveActivityBrandMark(height: 13) FlowLiveActivityBrandMark(height: 10.4)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("OSGKeyboard") Text("OSGKeyboard")
.font(.headline) .font(.headline)
FlowLiveActivityPhaseCaption(phase: phase) FlowLiveActivityPhaseCaption(phase: phase, isStale: isStale)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer(minLength: 0) 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 // iOS Live Activity lock-screen content needs margins so the leading
// logo and trailing glyph don't touch the card edges. // logo and trailing glyph don't touch the card edges.
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -91,8 +108,19 @@ private struct FlowLiveActivityBrandMark: View {
private struct FlowLiveActivityTrailingGlyph: View { private struct FlowLiveActivityTrailingGlyph: View {
let phase: FlowActivityAttributes.ContentState.Phase let phase: FlowActivityAttributes.ContentState.Phase
var isStale: Bool = false
var body: some View { var body: some View {
if isStale {
Image(systemName: "bolt.slash.circle")
.foregroundStyle(.secondary)
} else {
phaseGlyph
}
}
@ViewBuilder
private var phaseGlyph: some View {
switch phase { switch phase {
case .recording: case .recording:
Image(systemName: "waveform") Image(systemName: "waveform")
@@ -114,8 +142,19 @@ private struct FlowLiveActivityTrailingGlyph: View {
private struct FlowLiveActivityPhaseLabel: View { private struct FlowLiveActivityPhaseLabel: View {
let phase: FlowActivityAttributes.ContentState.Phase let phase: FlowActivityAttributes.ContentState.Phase
var isStale: Bool = false
var body: some View { var body: some View {
if isStale {
Image(systemName: "bolt.slash.circle")
.foregroundStyle(.secondary)
} else {
phaseLabel
}
}
@ViewBuilder
private var phaseLabel: some View {
switch phase { switch phase {
case .recording: case .recording:
Text("REC") Text("REC")
@@ -133,8 +172,20 @@ private struct FlowLiveActivityPhaseLabel: View {
private struct FlowLiveActivityPhaseCaption: View { private struct FlowLiveActivityPhaseCaption: View {
let phase: FlowActivityAttributes.ContentState.Phase let phase: FlowActivityAttributes.ContentState.Phase
var isStale: Bool = false
var body: some View { 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 { switch phase {
case .idle: case .idle:
Text("Voice session active") 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" = "会话已断开 · 点按重连";
+111 -83
View File
@@ -1,7 +1,8 @@
// DashboardView.swift // DashboardView.swift
// OSGKeyboard · Mac // 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 import SwiftUI
@@ -20,33 +21,33 @@ struct DashboardView: View {
self._stats = ObservedObject(wrappedValue: viewModel.usageStatistics) 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 { 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 { ScrollView {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: Spacing.lg) { VStack(alignment: .leading, spacing: Spacing.lg) {
if let appName = viewModel.foregroundAppName { heroHeader
Text(MacL10n.format("mac.foregroundApp", language: lang, appName)) statCluster
.font(TypeStyle.caption) dictationStage
.foregroundStyle(palette.textTertiary)
.transition(.opacity)
} }
statGrid .padding(.horizontal, MacMetrics.pageHorizontalInset)
dictationCanvas
}
.animation(Motion.soft, value: viewModel.foregroundAppName)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.sm) .padding(.top, Spacing.sm)
.padding(.bottom, Spacing.lg)
} // 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) BottomDictationBar(viewModel: viewModel)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.sm)
Spacer(minLength: Spacing.xs)
}
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
}
} }
.onAppear { stats.reloadFromDisk() } .onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
@@ -54,47 +55,54 @@ struct DashboardView: View {
} }
} }
private var statGrid: some View { // MARK: - Hero
LazyVGrid(columns: columns, spacing: Spacing.md) {
StatCard( private var heroHeader: some View {
title: MacL10n.string("mac.stat.dictationTime", language: lang), VStack(alignment: .leading, spacing: Spacing.xs) {
value: UsageStatisticsStore.formatDuration( Text(MacL10n.string("mac.brand.tagline", language: lang))
stats.dictationDurationSeconds, .font(TypeStyle.pageTitle)
language: lang .foregroundStyle(palette.textPrimary)
), .lineLimit(2)
caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang), .minimumScaleFactor(0.85)
systemImage: "waveform",
accent: true HStack(spacing: Spacing.sm) {
) Text(MacL10n.string("mac.brand.tagline.subtitle", language: lang))
StatCard( .font(TypeStyle.footnote)
title: MacL10n.string("mac.stat.words", language: lang), .foregroundStyle(palette.textTertiary)
value: UsageStatisticsStore.formatCount(
stats.dictationCharacterCount, if let appName = viewModel.foregroundAppName {
language: lang Text("·")
), .foregroundStyle(palette.textTertiary.opacity(0.45))
caption: MacL10n.string("mac.stat.transcribed", language: lang), Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
systemImage: "text.alignleft" .font(TypeStyle.footnote)
) .foregroundStyle(palette.textTertiary)
StatCard( .transition(.opacity)
title: MacL10n.string("mac.stat.translation", language: lang), .lineLimit(1)
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"
)
} }
} }
.animation(Motion.soft, value: viewModel.foregroundAppName)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var dictationCanvas: some View { // MARK: - Stats (7-day chart + cumulative grid shared cluster)
MacCard(padding: Spacing.lg) {
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) { ZStack(alignment: .topLeading) {
if viewModel.transcript.isEmpty { if viewModel.transcript.isEmpty {
Text( Text(
@@ -102,24 +110,37 @@ struct DashboardView: View {
? MacL10n.string("mac.status.listening", language: lang) ? MacL10n.string("mac.status.listening", language: lang)
: MacL10n.string("mac.status.ready", language: lang) : MacL10n.string("mac.status.ready", language: lang)
) )
.font(.system(size: 26, weight: .light)) .font(.system(size: 22, weight: .light))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
.contentTransition(.opacity) .contentTransition(.opacity)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading) .frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
.transition(.opacity) .transition(.opacity)
} else { } else {
Text(viewModel.transcript) Text(viewModel.transcript)
.font(.system(size: 22, weight: .regular)) .font(.system(size: 20, weight: .regular))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineSpacing(4)
.textSelection(.enabled) .textSelection(.enabled)
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading) .frame(
maxWidth: .infinity,
minHeight: MacMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
.transition(.opacity) .transition(.opacity)
} }
} }
.frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: 160)
} }
.animation(Motion.soft, value: viewModel.transcript.isEmpty) .animation(Motion.soft, value: viewModel.transcript.isEmpty)
.animation(Motion.quick, value: viewModel.isRecording) .animation(Motion.quick, value: viewModel.isRecording)
} }
} }
// MARK: - Floating record bar // MARK: - Floating record bar
@@ -142,12 +163,10 @@ struct BottomDictationBar: View {
recordControl recordControl
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .padding(.vertical, Spacing.xs)
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1) // No surface fill the mic bar sits on the page background so Home
.overlay( // stays flat and the canvas above can stay shorter without a second
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) // floating card competing for height.
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
} }
private var readinessChip: some View { private var readinessChip: some View {
@@ -178,26 +197,36 @@ struct BottomDictationBar: View {
} }
} }
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: Spacing.sm) {
Image(systemName: "translate") Image(systemName: "translate")
Text(currentTranslationLabel)
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .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: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.font(MacSettingsType.control)
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.padding(.vertical, 7) .frame(minHeight: MacMetrics.settingsControlHeight)
.macGlassSurface(in: Capsule(), fillOpacity: 0.66) .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) .menuStyle(.borderlessButton)
.fixedSize() .fixedSize()
} }
// // Mic stays geometrically centred: waveform lives inside the button,
// // press stop floats above neither participates in layout.
// Option
private var recordControl: some View { private var recordControl: some View {
recordButton recordButton
.overlay(alignment: .top) { .overlay(alignment: .top) {
@@ -225,7 +254,6 @@ struct BottomDictationBar: View {
) )
Group { Group {
if viewModel.isRecording { if viewModel.isRecording {
// iOS
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent) MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
} else { } else {
Image(systemName: "mic.fill") Image(systemName: "mic.fill")
+71 -2
View File
@@ -11,11 +11,16 @@
final class MacAudioRecorder: @unchecked Sendable { final class MacAudioRecorder: @unchecked Sendable {
enum RecorderError: Error, LocalizedError { enum RecorderError: Error, LocalizedError {
case converterUnavailable case converterUnavailable
case microphoneAccessDenied
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .converterUnavailable: case .converterUnavailable:
return "无法初始化音频转换器 / Failed to initialize audio converter" 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 var converter: AVAudioConverter?
private let lock = NSLock() private let lock = NSLock()
private var samples: [Float] = [] private var samples: [Float] = []
private var snapshotContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
private var isRunning = false 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 private var smoothedLevel: Float = 0
/// One-shot flag for the converter pull block. Taps are serialized per /// 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. /// 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 } lock.withLock { smoothedLevel }
} }
func start() throws { /// Resolves microphone authorization before capture. Prompts on first
lock.withLock { samples.removeAll(keepingCapacity: true) } /// 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 input = engine.inputNode
let inputFormat = input.outputFormat(forBus: 0) let inputFormat = input.outputFormat(forBus: 0)
@@ -67,6 +128,8 @@ final class MacAudioRecorder: @unchecked Sendable {
engine.stop() engine.stop()
isRunning = false isRunning = false
return lock.withLock { return lock.withLock {
snapshotContinuation?.finish()
snapshotContinuation = nil
let out = samples let out = samples
samples.removeAll(keepingCapacity: false) samples.removeAll(keepingCapacity: false)
return out return out
@@ -105,8 +168,14 @@ final class MacAudioRecorder: @unchecked Sendable {
lock.withLock { lock.withLock {
samples.append(contentsOf: chunk) 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 let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
smoothedLevel += (normalized - smoothedLevel) * factor 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. /// Fixed metrics that keep every desktop surface on the same grid.
enum MacMetrics { enum MacMetrics {
/// Uniform max width for trailing text controls (API key / model field). /// Shared height for credential inputs and icon buttons matches the iOS
static let controlWidth: CGFloat = 240 /// 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. /// Sidebar width and the horizontal inset shared by brand, nav and footer.
static let sidebarWidth: CGFloat = 240 static let sidebarWidth: CGFloat = 240
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo /// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
@@ -25,11 +50,76 @@ enum MacMetrics {
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
/// Reading width for single-column content. /// Reading width for single-column content.
static let contentMaxWidth: CGFloat = 720 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 /// Top inset that clears the window traffic-light buttons now that the
/// title bar is hidden. /// title bar is hidden.
static let trafficLightInset: CGFloat = 28 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 // MARK: - Liquid Glass
private struct MacGlassSurface<S: Shape>: ViewModifier { private struct MacGlassSurface<S: Shape>: ViewModifier {
@@ -59,26 +149,346 @@ extension View {
// MARK: - Text field styling // MARK: - Text field styling
/// Text-field chrome aligned with native macOS Form controls: compact /// Theme-aware credential field chrome comfortable tap target,
/// height, text-background fill, hairline border. /// monospaced-friendly body size, and a fill/border that stays clearly
/// distinct from the section card in both appearances.
private struct MacFieldStyleModifier: ViewModifier { private struct MacFieldStyleModifier: ViewModifier {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
var monospaced: Bool = false
func body(content: Content) -> some View { 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 content
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(TypeStyle.footnote) .font(monospaced ? TypeStyle.mono : TypeStyle.body)
.padding(.horizontal, 8) .foregroundStyle(palette.textPrimary)
.frame(height: 22) .padding(.horizontal, Spacing.sm)
.background(Color(nsColor: .textBackgroundColor), in: shape) .frame(minHeight: MacMetrics.settingsControlHeight)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5)) .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 { extension View {
/// Theme-aware text-field styling for the settings inputs. /// 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 // MARK: - Card container
@@ -87,10 +497,11 @@ extension View {
struct MacCard<Content: View>: View { struct MacCard<Content: View>: View {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md var padding: CGFloat = Spacing.md
var cornerRadius: CGFloat = Radius.medium
@ViewBuilder var content: () -> Content @ViewBuilder var content: () -> Content
var body: some View { var body: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
content() content()
.padding(padding) .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 // MARK: - Live waveform
/// Compact bar visualiser reacting to the input level while recording. /// Compact bar visualiser reacting to the input level while recording.
@@ -152,30 +522,39 @@ struct MiniWaveform: View {
var barCount: Int = 5 var barCount: Int = 5
/// Pass nil to inherit the palette accent automatically. /// Pass nil to inherit the palette accent automatically.
var tint: Color? 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 @State private var phase: CGFloat = 0
var body: some View { var body: some View {
HStack(spacing: 3) { HStack(spacing: barSpacing) {
ForEach(0..<barCount, id: \.self) { index in ForEach(0..<barCount, id: \.self) { index in
Capsule() Capsule()
.fill(tint ?? palette.accent) .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) .animation(Motion.instant, value: level)
.onAppear { .onAppear {
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) { withAnimation(.linear(duration: 0.75).repeatForever(autoreverses: true)) {
phase = 1 phase = 1
} }
} }
} }
private func barHeight(_ index: Int) -> CGFloat { private func barHeight(_ index: Int) -> CGFloat {
let base = CGFloat(level) * 22 // Stronger level coupling + staggered phase so the meter reads as
let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4 // "alive" even at modest mic levels.
return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble)) 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 // MARK: - Status footer
/// Bottom status strip: engine mode (cloud/local), translation target, and /// Bottom status strip: engine mode, translation target, connection
/// the connection state icons and wording mirror the dashboard record bar. /// kept visually quiet so it never competes with the record bar.
struct MacStatusFooter: View { struct MacStatusFooter: View {
@ObservedObject var viewModel: MacDictationViewModel @ObservedObject var viewModel: MacDictationViewModel
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@@ -204,32 +583,129 @@ struct MacStatusFooter: View {
private var lang: AppUILanguage { viewModel.config.uiLanguage } private var lang: AppUILanguage { viewModel.config.uiLanguage }
var body: some View { var body: some View {
HStack(spacing: Spacing.md) { HStack(spacing: Spacing.sm) {
Spacer() 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( Label(
viewModel.isCloudMode viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang) ? MacL10n.string("mac.mode.cloud", language: lang)
: MacL10n.string("mac.mode.local", language: lang), : MacL10n.string("mac.mode.local", language: lang),
systemImage: viewModel.isCloudMode ? "cloud" : "cpu" systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
) )
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity) .contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label( Label(
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang), MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
systemImage: "translate" systemImage: "translate"
) )
.foregroundStyle(palette.textSecondary)
.contentTransition(.opacity) .contentTransition(.opacity)
Text("·")
.foregroundStyle(palette.textTertiary.opacity(0.5))
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link") 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) .labelStyle(.titleAndIcon)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.vertical, Spacing.xs) .padding(.vertical, Spacing.sm)
.animation(Motion.quick, value: viewModel.isCloudMode) .animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId) .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) { VStack(alignment: .leading, spacing: Spacing.sm) {
header header
recordButton recordButton
Text(MacL10n.string("mac.hint.holdOption", language: lang)) Text(MacL10n.string(viewModel.hotkeyTrigger.hintKey, language: lang))
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center) .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))
}
}
+239 -27
View File
@@ -1,9 +1,7 @@
// MacDictationPipeline.swift // MacDictationPipeline.swift
// OSGKeyboard · Mac // OSGKeyboard · Mac
// //
// Dictation pipeline: samples ASR (cloud or local) polish. // Dictation pipeline: samples ASR (cloud or local, chunked when long) polish.
// Cloud path reuses `CloudASRClientFactory`; local path uses Qwen3-ASR (MLX)
// with Apple Speech fallback when weights are missing.
import Foundation 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 { enum MacDictationPipeline {
/// Runs ASR then best-effort polish. Polish failures fall back to raw text. /// First-chunk threshold: longer local utterances use pipelined chunk ASR.
static func run(samples: [Float], store: AppGroupStore) async throws -> String { 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 } 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 let raw: String
var localBias: LocalASRBiasPayload? var localBias: LocalASRBiasPayload?
if store.engineMode == "local" { if store.engineMode == "local" {
MacAppContextService.captureAndPersist(to: store) localBias = resolveLocalBias(store: store, locale: locale)
let capabilities = MacLocalASRService.currentCapabilities() if samples.count > chunkedLocalThresholdSamples {
let bias = LocalASRBiasAdapter.adapt( let chunked = try await transcribeLocalChunked(
LocalASRBiasRequest( samples: samples,
dictionary: store.personalDictionary,
locale: locale, locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(), bias: localBias,
capabilities: capabilities onPartial: onPartial
)
)
localBias = bias
LocalASRBiasDiagnosticsStore.save(
payload: bias,
modelId: MacLocalASRService.selectedModelDefinition()?.id,
backendLabel: MacLocalASRService.currentBackendLabel()
) )
raw = chunked.text
chunkWarning = chunked.chunkWarning
} else {
raw = try await MacLocalASRService.transcribe( raw = try await MacLocalASRService.transcribe(
samples: samples, samples: samples,
locale: locale, locale: locale,
bias: bias bias: localBias
) )
}
} else { } else {
let strategy = CloudASRModelCatalog.strategy(for: store.providerId) let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR } guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
let client = CloudASRClientFactory.make(store: store) 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) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript } guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
@@ -91,14 +190,127 @@ enum MacDictationPipeline {
polishContext = nil polishContext = nil
} }
if let polished = try? await PolishingService(store: store).polish( do {
let polished = try await PolishingService(store: store).polish(
postASR, postASR,
mode: store.polishModeForPipeline, mode: store.polishModeForPipeline,
context: polishContext context: polishContext
), )
!polished.isEmpty { guard !polished.isEmpty else {
return polished 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?
}
+218 -25
View File
@@ -28,7 +28,7 @@ enum MacSection: String, CaseIterable, Identifiable {
var systemImage: String { var systemImage: String {
switch self { switch self {
case .dashboard: return "square.grid.2x2" case .dashboard: return "house"
case .history: return "clock.arrow.circlepath" case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed" case .dictionary: return "character.book.closed"
case .settings: return "gearshape" case .settings: return "gearshape"
@@ -45,7 +45,13 @@ final class MacDictationViewModel: ObservableObject {
@Published var selectedSection: MacSection = .dashboard @Published var selectedSection: MacSection = .dashboard
@Published var isRecording = false @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 @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 transcript = ""
@Published var statusMessage = "" @Published var statusMessage = ""
@Published var audioLevel: Float = 0 @Published var audioLevel: Float = 0
@@ -55,6 +61,7 @@ final class MacDictationViewModel: ObservableObject {
@Published var autoPasteEnabled: Bool @Published var autoPasteEnabled: Bool
@Published var hotkeyEnabled: Bool @Published var hotkeyEnabled: Bool
@Published var hotkeyTrigger: MacHotkeyTrigger
@Published var config: ProviderConfig @Published var config: ProviderConfig
@@ -64,6 +71,12 @@ final class MacDictationViewModel: ObservableObject {
private var levelTimer: Timer? private var levelTimer: Timer?
private var sessionTimer: Timer? private var sessionTimer: Timer?
private var cancellables = Set<AnyCancellable>() 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 usageStatistics: UsageStatisticsStore
let speechHistory = SpeechHistoryStore.shared let speechHistory = SpeechHistoryStore.shared
@@ -71,6 +84,7 @@ final class MacDictationViewModel: ObservableObject {
private enum StoredKeys { private enum StoredKeys {
static let autoPaste = "mac.autoPasteEnabled" static let autoPaste = "mac.autoPasteEnabled"
static let hotkey = "mac.hotkeyEnabled" static let hotkey = "mac.hotkeyEnabled"
static let hotkeyTrigger = MacHotkeyTrigger.storageKey
} }
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
@@ -78,7 +92,12 @@ final class MacDictationViewModel: ObservableObject {
self.config = ProviderConfig(defaults: defaults) self.config = ProviderConfig(defaults: defaults)
self.usageStatistics = UsageStatisticsStore(defaults: defaults) self.usageStatistics = UsageStatisticsStore(defaults: defaults)
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true 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) MacICloudSyncBootstrap.configure(defaults: defaults)
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage) statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
@@ -114,10 +133,16 @@ final class MacDictationViewModel: ObservableObject {
// MARK: - Derived // MARK: - Derived
var selectableProviders: [LLMProvider] { var polishSelectableProviders: [LLMProvider] {
LLMProvider.presets.filter { LLMProvider.userSelectablePresets
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
} }
var asrSelectableProviders: [LLMProvider] {
LLMProvider.asrSelectablePresets
}
var selectableProviders: [LLMProvider] {
asrSelectableProviders
} }
var dictionaryTermCount: Int { var dictionaryTermCount: Int {
@@ -185,6 +210,12 @@ final class MacDictationViewModel: ObservableObject {
if enabled { hotkeyService.start() } else { hotkeyService.stop() } 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) { func setEngineMode(_ mode: String) {
config.engineMode = mode config.engineMode = mode
} }
@@ -192,73 +223,231 @@ final class MacDictationViewModel: ObservableObject {
// MARK: - Recording // MARK: - Recording
func toggleRecording() { func toggleRecording() {
if isRecording { finishRecording() } else { beginRecording() } if isRecording || isPreparingToRecord {
cancelOrFinishRecording()
} else {
Task { await beginRecording() }
}
} }
func beginRecording() { func beginRecording() async {
guard !isProcessing else { return } guard !isProcessing, !isRecording, !isPreparingToRecord else { return }
isPreparingToRecord = true
let store = AppGroupStore(defaults: defaults) let store = AppGroupStore(defaults: defaults)
MacAppContextService.captureAndPersist(to: store) MacAppContextService.captureAndPersist(to: store)
refreshForegroundAppName() refreshForegroundAppName()
do { 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 isRecording = true
transcript = "" transcript = ""
isStreamingPartial = false
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage) statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
startTimers() 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 { } catch {
isPreparingToRecord = false
if !Task.isCancelled {
statusMessage = error.localizedDescription statusMessage = error.localizedDescription
} }
} }
}
func finishRecording() { func finishRecording() {
guard isRecording else { return } guard isRecording else { return }
isRecording = false isRecording = false
isProcessing = true 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() stopTimers()
audioLevel = 0 audioLevel = 0
let samples = recorder.stop() let samples = recorder.stop()
let store = AppGroupStore(defaults: defaults) let store = AppGroupStore(defaults: defaults)
let liveTask = liveCaptureTask
liveCaptureTask = nil
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
do { do {
let text = try await MacDictationPipeline.run(samples: samples, store: store) let result: MacDictationResult
self.transcript = text if let liveTask {
let pasted = try self.deliver(text) let capture = await Self.awaitLiveCapture(liveTask)
self.recordUsage(for: text) let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines)
self.speechHistory.append(text: text) if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty {
self.statusMessage = self.statusAfterDelivery(pasted: pasted) 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 { } catch {
self.statusMessage = error.localizedDescription self.statusMessage = error.localizedDescription
} }
self.isStreamingPartial = false
self.isProcessing = false self.isProcessing = false
} }
} }
private func deliver(_ text: String) throws -> Bool { private func startLiveCaptureIfSupported(store: AppGroupStore) {
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled) 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 lang = config.uiLanguage
let base: String
if autoPasteEnabled, pasted { 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() { private func wireHotkeyService() {
hotkeyService.trigger = hotkeyTrigger
hotkeyService.onPressBegan = { [weak self] in 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 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() } if hotkeyEnabled { hotkeyService.start() }
} }
@@ -304,6 +493,10 @@ final class MacDictationViewModel: ObservableObject {
config.apply(preset: provider) config.apply(preset: provider)
} }
func selectAsrProvider(_ provider: LLMProvider) {
config.applyAsr(preset: provider)
}
func refreshForegroundAppName() { func refreshForegroundAppName() {
foregroundAppName = MacAppContextService.frontmostApplicationName() foregroundAppName = MacAppContextService.frontmostApplicationName()
} }
+70 -48
View File
@@ -1,9 +1,8 @@
// MacDictionaryView.swift // MacDictionaryView.swift
// OSGKeyboard · Mac // OSGKeyboard · Mac
// //
// Personal dictionary synced via iCloud KVS with the iOS app. Read-only on // Personal dictionary synced via iCloud KVS. ScrollView is full-bleed
// the desktop (words are authored on iPhone / iPad): grouped cards (native // (scrollbar on the window edge); title + cards share `pageHorizontalInset`.
// `Form`) with search, matching the Settings and History card style.
import SwiftUI import SwiftUI
@@ -45,16 +44,27 @@ struct MacDictionaryView: View {
} }
var body: some View { var body: some View {
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 { Group {
if entries.isEmpty { if entries.isEmpty {
emptyState emptyState
.transition(.opacity) .transition(.opacity)
} else { } else {
form list
.transition(.opacity) .transition(.opacity)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(palette.background) .background(palette.background)
.animation(Motion.soft, value: entries.isEmpty) .animation(Motion.soft, value: entries.isEmpty)
.task { .task {
@@ -64,33 +74,6 @@ struct MacDictionaryView: View {
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
viewModel.refreshDictionaryFromCloud() 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( .confirmationDialog(
MacL10n.string("mac.dict.deleteTitle", language: lang), MacL10n.string("mac.dict.deleteTitle", language: lang),
isPresented: deletionDialogBinding, 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> { private var deletionDialogBinding: Binding<Bool> {
Binding( Binding(
get: { entryPendingDeletion != nil }, get: { entryPendingDeletion != nil },
@@ -117,25 +145,19 @@ struct MacDictionaryView: View {
) )
} }
private var centeredSearchField: some View { private var searchField: some View {
HStack {
Spacer()
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query) TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(TypeStyle.footnote)
} }
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.padding(.vertical, 7) .padding(.vertical, 6)
.frame(width: 240) .frame(width: 220)
.macGlassSurface(in: Capsule(), fillOpacity: 0.72) .background(palette.surface, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
Spacer()
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
} }
private func row(_ entry: PersonalDictionary.Entry) -> some View { private func row(_ entry: PersonalDictionary.Entry) -> some View {
@@ -165,19 +187,19 @@ struct MacDictionaryView: View {
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: Spacing.sm) { VStack(spacing: Spacing.sm) {
Image(systemName: "character.book.closed") Image(systemName: "character.book.closed")
.font(.system(size: 34)) .font(.system(size: 34, weight: .light))
.foregroundStyle(palette.textTertiary.opacity(0.6)) .foregroundStyle(palette.textTertiary.opacity(0.55))
.symbolRenderingMode(.hierarchical)
Text(MacL10n.string("mac.dict.empty", language: lang)) Text(MacL10n.string("mac.dict.empty", language: lang))
.font(TypeStyle.body) .font(TypeStyle.headline)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
Text(MacL10n.string("mac.dict.emptyBody", language: lang)) Text(MacL10n.string("mac.dict.emptyBody", language: lang))
.font(TypeStyle.caption) .font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(maxWidth: 360) .frame(maxWidth: 320)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, Spacing.xl)
} }
private func delete(_ entry: PersonalDictionary.Entry) { private func delete(_ entry: PersonalDictionary.Entry) {
+63 -37
View File
@@ -1,9 +1,8 @@
// MacHistoryView.swift // MacHistoryView.swift
// OSGKeyboard · Mac // OSGKeyboard · Mac
// //
// Single-column, day-grouped transcript log rendered as grouped cards (the // Day-grouped transcript log. ScrollView is full-bleed (scrollbar on the
// same native `Form` container as Settings). Every entry shows its full text // window edge); title + cards share `pageHorizontalInset` on their content.
// inline no master/detail split, so content never pushes the sidebar out.
import SwiftUI import SwiftUI
@@ -31,36 +30,39 @@ struct MacHistoryView: View {
}() }()
var body: some View { var body: some View {
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 { Group {
if historyStore.entries.isEmpty { if historyStore.entries.isEmpty {
emptyState emptyState
.transition(.opacity) .transition(.opacity)
} else { } else {
form list
.transition(.opacity) .transition(.opacity)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(palette.background) .background(palette.background)
.animation(Motion.soft, value: historyStore.entries.isEmpty) .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)
}
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(palette.background)
.safeAreaInset(edge: .top, spacing: 0) { toolbar }
.confirmationDialog( .confirmationDialog(
MacL10n.string("mac.history.clearTitle", language: lang), MacL10n.string("mac.history.clearTitle", language: lang),
isPresented: $showClearConfirmation, isPresented: $showClearConfirmation,
@@ -75,21 +77,39 @@ struct MacHistoryView: View {
} }
} }
private var toolbar: some View { // MARK: - List
HStack {
Spacer() private var list: some View {
Button { // Full-bleed ScrollView scrollbar on the detail pane's right edge.
showClearConfirmation = true // Horizontal inset lives on the content so cards align with the title.
} label: { ScrollView {
Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash") LazyVStack(alignment: .leading, spacing: Spacing.xl) {
.font(TypeStyle.caption) ForEach(historyStore.groupedByDay, id: \.day) { group in
daySection(group)
} }
.buttonStyle(.borderless) }
.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) .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)
}
}
}
} }
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.xs)
.background(palette.background)
} }
private func row(_ entry: SpeechHistoryEntry) -> some View { private func row(_ entry: SpeechHistoryEntry) -> some View {
@@ -107,11 +127,17 @@ struct MacHistoryView: View {
private var emptyState: some View { private var emptyState: some View {
VStack(spacing: Spacing.sm) { VStack(spacing: Spacing.sm) {
Image(systemName: "text.bubble") Image(systemName: "text.bubble")
.font(.system(size: 34)) .font(.system(size: 34, weight: .light))
.foregroundStyle(palette.textTertiary.opacity(0.6)) .foregroundStyle(palette.textTertiary.opacity(0.55))
.symbolRenderingMode(.hierarchical)
Text(MacL10n.string("mac.history.empty", language: lang)) Text(MacL10n.string("mac.history.empty", language: lang))
.font(TypeStyle.body) .font(TypeStyle.headline)
.foregroundStyle(palette.textSecondary) .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) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
+112 -19
View File
@@ -1,33 +1,93 @@
// MacHotkeyService.swift // MacHotkeyService.swift
// OSGKeyboard · Mac // OSGKeyboard · Mac
// //
// Global hold-to-talk: while Option () is held, dictation runs. Mirrors // Global hold-to-talk: while the configured Option () key is held, dictation
// Typeless / SayIt push-to-talk from any foreground app. // runs. Mirrors Typeless / SayIt push-to-talk from any foreground app.
import AppKit import AppKit
import Foundation 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 @MainActor
final class MacHotkeyService { 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 onPressBegan: (() -> Void)?
var onPressEnded: (() -> Void)? var onPressEnded: (() -> Void)?
var trigger: MacHotkeyTrigger = .rightOption
private var globalFlagsMonitor: Any? private var globalFlagsMonitor: Any?
private var localFlagsMonitor: 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 private var isEnabled = true
func setEnabled(_ enabled: Bool) { func setEnabled(_ enabled: Bool) {
isEnabled = enabled isEnabled = enabled
if !enabled, optionHeld { if !enabled { cancelPress() }
optionHeld = false
onPressEnded?()
}
} }
func start() { func start() {
guard globalFlagsMonitor == nil else { return } guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return }
_ = MacTextInsertionService.requestAccessibilityIfNeeded() // 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 globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
Task { @MainActor in self?.handleFlagsChanged(event) } Task { @MainActor in self?.handleFlagsChanged(event) }
@@ -36,6 +96,12 @@ final class MacHotkeyService {
Task { @MainActor in self?.handleFlagsChanged(event) } Task { @MainActor in self?.handleFlagsChanged(event) }
return event return event
} }
#if DEBUG
if !trusted || globalFlagsMonitor == nil {
NSLog("[OSGKeyboard] Hotkey global monitor unavailable — grant Accessibility in System Settings")
}
#endif
} }
func stop() { func stop() {
@@ -47,20 +113,47 @@ final class MacHotkeyService {
NSEvent.removeMonitor(localFlagsMonitor) NSEvent.removeMonitor(localFlagsMonitor)
self.localFlagsMonitor = nil self.localFlagsMonitor = nil
} }
if optionHeld { cancelPress()
optionHeld = false
onPressEnded?()
}
} }
private func handleFlagsChanged(_ event: NSEvent) { private func handleFlagsChanged(_ event: NSEvent) {
guard isEnabled else { return } guard isEnabled else { return }
let optionDown = event.modifierFlags.contains(.option) let triggerDown = trigger.isPressed(in: event)
if optionDown, !optionHeld { if triggerDown, !triggerKeyDown {
optionHeld = true triggerKeyDown = true
onPressBegan?() scheduleBegin()
} else if !optionDown, optionHeld { } else if !triggerDown, triggerKeyDown {
optionHeld = false 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?() onPressEnded?()
} }
} }
+42 -94
View File
@@ -1,10 +1,8 @@
// MacICloudSyncRows.swift // MacICloudSyncRows.swift
// OSGKeyboard · Mac // OSGKeyboard · Mac
// //
// iCloud sync toggles for settings and personal dictionary same KVS // iCloud sync toggle for settings, history, API keys, and personal
// keys and merge rules as the iOS settings page. Rendered as native Form // dictionary same KVS keys and merge rules as the iOS settings page.
// rows (Toggle + optional action / error) so they sit inside a grouped
// `Form` section and match System Settings exactly.
import SwiftUI import SwiftUI
@@ -19,38 +17,51 @@ struct MacSettingsICloudSyncRow: View {
@State private var isSyncingNow = false @State private var isSyncingNow = false
var body: some View { var body: some View {
Toggle(isOn: toggleBinding) { MacProviderSettingRow(
VStack(alignment: .leading, spacing: 2) { title: MacL10n.string("mac.sync.settingsTitle", language: language),
Text(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)) Text(MacL10n.string("mac.sync.settingsSubtitle", language: language))
.font(TypeStyle.caption) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() } .onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in .onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
reloadFromStore() reloadFromStore()
} }
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
reloadFromStore()
}
if isEnabled { if isEnabled {
Button {
syncNow()
} label: {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
MacSettingsToolButton(
title: MacL10n.string("mac.sync.syncNow", language: language),
disabled: isSyncingNow || isApplyingToggle
) {
syncNow()
}
if isSyncingNow { ProgressView().controlSize(.small) } if isSyncingNow { ProgressView().controlSize(.small) }
Text(MacL10n.string("mac.sync.syncNow", language: language)) Spacer(minLength: 0)
} }
} .padding(.horizontal, MacMetrics.settingsCardInset)
.disabled(isSyncingNow || isApplyingToggle) .padding(.bottom, Spacing.sm)
} }
if let syncErrorMessage { if let syncErrorMessage {
Text(syncErrorMessage) Text(syncErrorMessage)
.font(TypeStyle.caption) .font(MacSettingsType.hint)
.foregroundStyle(palette.danger) .foregroundStyle(palette.danger)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
.padding(.bottom, Spacing.sm)
} }
} }
@@ -74,6 +85,19 @@ struct MacSettingsICloudSyncRow: View {
Task { Task {
do { do {
try await MacICloudSyncBootstrap.settingsSync.enableSync() 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() reloadFromStore()
} catch { } catch {
isEnabled = false 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)
}
}
}
@@ -198,6 +198,9 @@ struct MacLocalASRModelSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel @ObservedObject var viewModel: MacDictationViewModel
@StateObject private var modelVM = MacLocalASRModelSettingsViewModel() @StateObject private var modelVM = MacLocalASRModelSettingsViewModel()
@Environment(\.themePalette) private var palette @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 } private var lang: AppUILanguage { viewModel.config.uiLanguage }
@@ -217,19 +220,24 @@ struct MacLocalASRModelSettingsView: View {
} }
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View { private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
Section { MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
if let runtime = modelVM.currentRuntime(in: catalog) { if let runtime = modelVM.currentRuntime(in: catalog) {
LabeledContent(runtime.displayName) { MacFormSubtitleRow(title: runtime.displayName) {
Text( Text(
modelVM.isRuntimeInstalled(runtime) modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang) ? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang) : MacL10n.string("mac.localASR.notInstalled", language: lang)
) )
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
} }
} }
ForEach(catalog.models) { model in ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in
modelRow(model) modelRow(model)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
} }
if modelVM.isInstalling, if modelVM.isInstalling,
@@ -240,19 +248,26 @@ struct MacLocalASRModelSettingsView: View {
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
.font(TypeStyle.caption) .font(TypeStyle.caption)
} }
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
} }
if !modelVM.statusMessage.isEmpty { if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage) Text(modelVM.statusMessage)
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
} }
Button(MacL10n.string("mac.localASR.openStorage", language: lang)) { HStack(spacing: 0) {
MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot() modelVM.revealStorageRoot()
} }
} header: { Spacer(minLength: 0)
Text(MacL10n.string("mac.localASR.models", language: lang)) }
.padding(.horizontal, MacMetrics.settingsCardInset)
}
} }
} }
@@ -276,14 +291,17 @@ struct MacLocalASRModelSettingsView: View {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
Text(model.displayName) Text(model.displayName)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
if let badgeKey = model.badgeKey {
modelBadge(
MacL10n.string(badgeKey, language: lang),
emphasized: true
)
}
if model.supportsHotwords { if model.supportsHotwords {
Text(MacL10n.string("mac.localASR.personalDictionaryTag", language: lang)) modelBadge(
.font(TypeStyle.caption2) MacL10n.string("mac.localASR.personalDictionaryTag", language: lang),
.padding(.horizontal, 6) emphasized: false
.padding(.vertical, 2) )
.background(palette.accent.opacity(0.15))
.foregroundStyle(palette.accent)
.clipShape(Capsule())
} }
} }
Text(modelSubtitle(model, installed: installed)) Text(modelSubtitle(model, installed: installed))
@@ -302,7 +320,21 @@ struct MacLocalASRModelSettingsView: View {
.animation(Motion.soft, value: installed) .animation(Motion.soft, value: installed)
} }
} }
.padding(.vertical, 0)
}
private func modelBadge(_ title: String, emphasized: Bool) -> some View {
Text(title)
.font(TypeStyle.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(
emphasized
? palette.accent.opacity(0.18)
: palette.textTertiary.opacity(0.12)
)
.foregroundStyle(emphasized ? palette.accent : palette.textSecondary)
.clipShape(Capsule())
} }
@ViewBuilder @ViewBuilder
@@ -312,49 +344,39 @@ struct MacLocalASRModelSettingsView: View {
installing: Bool installing: Bool
) -> some View { ) -> some View {
if installing { if installing {
HStack(spacing: Spacing.sm) { installProgressControl(for: model)
circularInstallProgress(for: model) .animation(Motion.quick, value: isHoveringProgress)
if modelVM.installProgress.phase == .downloading .animation(Motion.quick, value: modelVM.isDownloadPaused)
|| 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)
)
}
}
} else if installed { } 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) modelVM.deleteModel(model)
} }
.buttonStyle(.bordered)
.controlSize(.small)
.tint(palette.danger)
} else { } 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) 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 = { let fraction: Double = {
if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused, if pausable,
let received = modelVM.installProgress.bytesReceived, let received = modelVM.installProgress.bytesReceived,
let total = modelVM.installProgress.bytesTotal, let total = modelVM.installProgress.bytesTotal,
total > 0 { total > 0 {
@@ -362,6 +384,7 @@ struct MacLocalASRModelSettingsView: View {
} }
return modelVM.installProgress.fraction return modelVM.installProgress.fraction
}() }()
return ZStack { return ZStack {
Circle() Circle()
.stroke(palette.textTertiary.opacity(0.25), lineWidth: 3) .stroke(palette.textTertiary.opacity(0.25), lineWidth: 3)
@@ -370,7 +393,36 @@ struct MacLocalASRModelSettingsView: View {
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(Motion.instant, value: fraction) .animation(Motion.instant, value: fraction)
if modelVM.installProgress.phase == .paused {
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") Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
@@ -380,9 +432,6 @@ struct MacLocalASRModelSettingsView: View {
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
} }
.frame(width: 36, height: 36)
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
}
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String { private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
let size = modelVM.formattedSize(model.sizeBytes) let size = modelVM.formattedSize(model.sizeBytes)
+3 -3
View File
@@ -58,7 +58,7 @@ enum MacLocalASRPreferences {
/// Maps removed catalog entries to the current default Sherpa model. /// Maps removed catalog entries to the current default Sherpa model.
static func migratedModelId(_ id: String) -> String { static func migratedModelId(_ id: String) -> String {
switch id { switch id {
case "qwen3-mlx-1.7b": case "qwen3-mlx-1.7b", "sherpa-paraformer-zh-int8":
return "sherpa-qwen3-0.6b-int8" return "sherpa-qwen3-0.6b-int8"
default: default:
return id return id
@@ -120,7 +120,7 @@ enum MacLocalASRService {
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias) 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( private static func transcribeWithModel(
@@ -141,7 +141,7 @@ enum MacLocalASRService {
bias: bias bias: bias
) )
case .appleSpeech: 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(maxWidth: .infinity)
} }
} }
.frame(minWidth: 860, minHeight: 600) .frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
.onAppear { .onAppear {
applyDefaults() applyDefaults()
model.reload() model.reload()
@@ -422,12 +422,15 @@ struct MacOnboardingView: View {
private var cloudAPIFields: some View { private var cloudAPIFields: some View {
VStack(alignment: .leading, spacing: Spacing.md) { VStack(alignment: .leading, spacing: Spacing.md) {
Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) { MacInlinePicker(
ForEach(viewModel.selectableProviders) { provider in selection: providerBinding,
Text(provider.name).tag(provider.id) options: viewModel.selectableProviders.map {
MacInlinePickerOption(
value: $0.id,
label: ProviderDisplayName.name(for: $0.id, language: lang)
)
} }
} )
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) { 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 } 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 { var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) { NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar sidebar
@@ -35,7 +26,7 @@ struct MacRootView: View {
detail detail
} }
.navigationSplitViewStyle(.balanced) .navigationSplitViewStyle(.balanced)
.frame(minWidth: 860, minHeight: 600) .frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
.onAppear { .onAppear {
// Let the AppKit status-bar popover reopen this window on demand. // Let the AppKit status-bar popover reopen this window on demand.
MacWindowBridge.shared.open = { openWindow(id: "main") } MacWindowBridge.shared.open = { openWindow(id: "main") }
@@ -47,7 +38,7 @@ struct MacRootView: View {
private var sidebar: some View { private var sidebar: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
brandHeader brandHeader
VStack(spacing: 4) { VStack(spacing: 6) {
ForEach(MacSection.allCases) { section in ForEach(MacSection.allCases) { section in
MacSidebarRow( MacSidebarRow(
section: section, section: section,
@@ -72,15 +63,15 @@ struct MacRootView: View {
.renderingMode(.template) .renderingMode(.template)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(height: 30) .frame(height: 28)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
.accessibilityLabel("OSGKeyboard") .accessibilityLabel("OSGKeyboard")
Spacer() Spacer()
} }
.padding(.leading, MacMetrics.sidebarContentInset) .padding(.leading, MacMetrics.sidebarContentInset)
.padding(.trailing, MacMetrics.sidebarInset) .padding(.trailing, MacMetrics.sidebarInset)
.padding(.top, Spacing.lg) .padding(.top, 30)
.padding(.bottom, Spacing.lg) .padding(.bottom, 30)
} }
private var devicesFooter: some View { private var devicesFooter: some View {
@@ -88,10 +79,10 @@ struct MacRootView: View {
MacL10n.string("mac.devices", language: uiLanguage), MacL10n.string("mac.devices", language: uiLanguage),
systemImage: "laptopcomputer.and.iphone" systemImage: "laptopcomputer.and.iphone"
) )
.font(TypeStyle.caption) .font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.sidebarInset) .padding(.horizontal, MacMetrics.sidebarInset + Spacing.sm)
.padding(.vertical, Spacing.sm) .padding(.vertical, Spacing.sm)
} }
@@ -118,8 +109,8 @@ struct MacRootView: View {
// MARK: - Sidebar row // MARK: - Sidebar row
/// A navigation row with an animated hover highlight and selection state, /// Navigation row with a restrained selected state: muted accent fill +
/// matching the macOS System Settings feel. /// accent label (not a solid green pill), matching System Settings polish.
private struct MacSidebarRow: View { private struct MacSidebarRow: View {
let section: MacSection let section: MacSection
let isSelected: Bool let isSelected: Bool
@@ -132,14 +123,14 @@ private struct MacSidebarRow: View {
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
Label(section.title(language: language), systemImage: section.systemImage) Label(section.title(language: language), systemImage: section.systemImage)
.font(.system(size: 13)) .font(.system(size: 13, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary) .foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.padding(.vertical, 7) .padding(.vertical, 9)
.background( .background(
rowBackground, rowBackground,
in: RoundedRectangle(cornerRadius: 7, style: .continuous) in: RoundedRectangle(cornerRadius: 8, style: .continuous)
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -150,7 +141,7 @@ private struct MacSidebarRow: View {
} }
private var rowBackground: Color { private var rowBackground: Color {
if isSelected { return palette.accent } if isSelected { return palette.accentMuted }
return isHovering ? palette.textPrimary.opacity(0.06) : .clear 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)
}
}
+271 -152
View File
@@ -1,9 +1,8 @@
// MacSettingsView.swift // MacSettingsView.swift
// OSGKeyboard · Mac // OSGKeyboard · Mac
// //
// Settings built on the native grouped `Form` the same container macOS // Settings uses native grouped `Form`. LLM / ASR provider blocks use a merged
// System Settings uses. This gives system-accurate cards, dividers, insets // `MacSettingsProviderCard` with responsive rows and theme-aware controls.
// and right-aligned controls for free, on both light and dark.
import SwiftUI import SwiftUI
#if os(macOS) #if os(macOS)
@@ -19,7 +18,6 @@ struct MacSettingsView: View {
@AppStorage(MacOnboardingState.storageKey) @AppStorage(MacOnboardingState.storageKey)
private var hasCompletedMacOnboarding = true private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted @State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
private var lang: AppUILanguage { viewModel.config.uiLanguage } private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [ private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
@@ -33,114 +31,218 @@ struct MacSettingsView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { 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 generalSection
recognitionSection recognitionSection
if viewModel.config.engineMode == "cloud" { if viewModel.config.engineMode == "cloud" {
providerSection asrProviderSection
.transition(.opacity) .transition(.opacity)
} }
if viewModel.config.engineMode == "local" { if viewModel.config.engineMode == "local" {
MacLocalASRModelSettingsView(viewModel: viewModel) MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity) .transition(.opacity)
} }
polishProviderSection
inputSection inputSection
legalSection legalSection
} }
.formStyle(.grouped) .padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.bottom, Spacing.md)
}
.tint(palette.accent) .tint(palette.accent)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(palette.background) .background(palette.background)
} }
.background(palette.background)
}
.onAppear { refreshAccessibilityState() } .onAppear { refreshAccessibilityState() }
} }
// MARK: - General // MARK: - General
private var generalSection: some View { private var generalSection: some View {
Section(MacL10n.string("mac.settings.general", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.general", language: lang)) {
Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) { VStack(spacing: MacMetrics.settingsRowGap) {
ForEach(MacAppearancePreference.allCases) { pref in MacProviderSettingRow(title: MacL10n.string("mac.settings.appearance", language: lang)) {
Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue) 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) { MacProviderSettingRow(title: MacL10n.string("mac.settings.interfaceLanguage", language: lang)) {
ForEach(AppUILanguage.allCases, id: \.self) { language in MacInlinePicker(
Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue) 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) { MacProviderSettingRow(title: MacL10n.string("mac.settings.recognitionLanguage", language: lang)) {
ForEach(recognitionLocales, id: \.id) { locale in MacInlinePicker(
Text(localeLabel(locale)).tag(locale.id) selection: recognitionLanguageBinding,
} options: recognitionLocales.map {
MacInlinePickerOption(value: $0.id, label: localeLabel($0))
},
fillsWidth: true
)
} }
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang) MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang) }
} }
} }
// MARK: - Cloud provider // MARK: - Polish LLM
private var providerSection: some View { private var polishProviderSection: some View {
Section(MacL10n.string("mac.settings.cloudProvider", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.polishProvider", language: lang)) {
LabeledContent(MacL10n.string("mac.settings.service", language: lang)) { MacSettingsProviderCard {
Button { MacProviderPickerRow(
showProviderPicker = true title: MacL10n.string("mac.settings.service", language: lang),
} label: { providers: viewModel.polishSelectableProviders,
HStack(spacing: 6) { selection: polishProviderBinding
providerLogo(currentProvider.id) )
Text(currentProvider.name) MacCredentialField(
.foregroundStyle(palette.textPrimary) title: MacL10n.string("mac.settings.apiKey", language: lang),
Image(systemName: "chevron.up.chevron.down") placeholder: "sk-…",
.font(.system(size: 9, weight: .semibold)) text: $viewModel.config.apiKey,
.foregroundStyle(palette.textTertiary) 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
)
} }
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.popover(isPresented: $showProviderPicker, arrowEdge: .bottom) {
providerPickerList
} }
} }
LabeledContent { // MARK: - Cloud ASR
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) {
Text(MacL10n.string("mac.settings.apiKey", language: lang)) 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
)
}
} }
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.apiKey", language: lang))
} }
LabeledContent { @ViewBuilder
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) { private var volcengineAsrRows: some View {
Text(MacL10n.string("mac.settings.model", language: lang)) MacCredentialField(
} title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
.labelsHidden() placeholder: "APP ID",
.macFieldStyle() text: Binding(
.frame(maxWidth: MacMetrics.controlWidth) get: { macVolcengineFields.appID },
} label: { set: { updateMacVolcengine(appID: $0) }
Text(MacL10n.string("mac.settings.model", language: lang)) ),
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 // MARK: - Recognition method
private var recognitionSection: some View { private var recognitionSection: some View {
Section(MacL10n.string("mac.settings.recognition", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.recognition", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
methodRow( methodRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang), title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang), subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud", systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud" selected: viewModel.config.engineMode == "cloud"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } } ) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
methodRow( methodRow(
title: MacL10n.string("mac.settings.localEngine", language: lang), title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang), subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
@@ -149,27 +251,39 @@ struct MacSettingsView: View {
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } } ) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
} }
} }
}
// MARK: - Hotkey / paste // MARK: - Hotkey / paste
private var inputSection: some View { private var inputSection: some View {
Section(MacL10n.string("mac.settings.input", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.input", language: lang)) {
Toggle(isOn: hotkeyBinding) { VStack(spacing: MacMetrics.settingsRowGap) {
rowLabel( MacProviderSettingRow(title: MacL10n.string("mac.settings.hotkeyTrigger", language: lang)) {
MacL10n.string("mac.settings.hotkey", language: lang), MacInlinePicker(
subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang) selection: hotkeyTriggerBinding,
options: MacHotkeyTrigger.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
) )
} }
MacProviderSettingRow(
Toggle(isOn: autoPasteBinding) { title: MacL10n.string("mac.settings.autoPaste", language: lang),
rowLabel( verticalAlignment: .center
MacL10n.string("mac.settings.autoPaste", language: lang), ) {
subtitle: MacL10n.string("mac.settings.autoPasteDesc", language: lang) Toggle("", isOn: autoPasteBinding)
) .labelsHidden()
.toggleStyle(MacToggleStyle())
} }
MacProviderSettingRow(
LabeledContent { title: MacL10n.string("mac.settings.accessibility", language: lang),
verticalAlignment: .center
) {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
MacSettingsToolButton(title: MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
}
Label( Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded, accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle" systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
@@ -178,16 +292,8 @@ struct MacSettingsView: View {
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning) .foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity) .contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted) .animation(Motion.quick, value: accessibilityTrusted)
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
} }
} }
} 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 // MARK: - Legal
private var legalSection: some View { private var legalSection: some View {
Section(MacL10n.string("mac.settings.about", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.about", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
NavigationLink { NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang) MacPrivacyPolicyView(uiLanguage: lang)
} label: { } label: {
Text(MacL10n.string("mac.settings.privacyPolicy", language: lang)) MacFormLinkRow(title: MacL10n.string("mac.settings.privacyPolicy", language: lang))
} }
.buttonStyle(.plain)
NavigationLink { NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang) MacOpenSourceLicensesView(uiLanguage: lang)
} label: { } label: {
Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang)) MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
} }
.buttonStyle(.plain)
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) { Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false hasCompletedMacOnboarding = false
} }
.buttonStyle(.plain)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
} }
} }
// MARK: - Row helpers // 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( private func methodRow(
title: String, title: String,
subtitle: String, subtitle: String,
@@ -252,72 +353,79 @@ struct MacSettingsView: View {
.foregroundStyle(selected ? palette.accent : palette.textTertiary) .foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace)) .contentTransition(.symbolEffect(.replace))
} }
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
.animation(Motion.quick, value: selected) .animation(Motion.quick, value: selected)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private var currentProvider: LLMProvider { private var currentPolishProvider: LLMProvider {
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId } viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.selectableProviders.first ?? viewModel.polishSelectableProviders.first
?? LLMProvider.presets[0] ?? LLMProvider.presets[0]
} }
/// Custom dropdown list shown in a popover. SwiftUI's `Menu` label / items private var currentAsrProvider: LLMProvider {
/// silently drop bundled (non-SF-Symbol) images on macOS, so we render the viewModel.asrSelectableProviders.first { $0.id == viewModel.config.asrProviderId }
/// brand marks in a plain view stack instead. ?? viewModel.asrSelectableProviders.first
private var providerPickerList: some View { ?? LLMProvider.presets[0]
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)
} }
/// Brand mark tinted to the current label colour (black on light / white private var macVolcengineFields: VolcengineASRFields {
/// on dark). Template rendering + an explicit frame make the vector assets VolcengineASRFields.parse(
/// resolve at a text-matched size inside the pop-up menu without a size apiKey: viewModel.config.asrApiKey,
/// hint they collapse to zero and disappear. resourceFallback: viewModel.config.asrModel.isEmpty
@ViewBuilder ? CloudASRModelCatalog.defaultModel(for: "volcengine")
private func providerLogo(_ providerId: String) -> some View { : viewModel.config.asrModel
if let asset = ProviderLogo.assetName(for: providerId) { )
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(palette.textPrimary)
}
} }
@ViewBuilder private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
private func providerLabel(_ provider: LLMProvider) -> some View { var fields = macVolcengineFields
Label { if let appID { fields.appID = appID }
Text(provider.name) if let accessToken { fields.accessToken = accessToken }
} icon: { if let resourceID {
providerLogo(provider.id) 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 // MARK: - Bindings
@@ -329,17 +437,28 @@ struct MacSettingsView: View {
) )
} }
private var providerBinding: Binding<String> { private var polishProviderBinding: Binding<String> {
Binding( Binding(
get: { viewModel.config.providerId }, get: { viewModel.config.providerId },
set: { newId in 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) 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> { private var recognitionLanguageBinding: Binding<String> {
Binding( Binding(
get: { get: {
@@ -352,10 +471,10 @@ struct MacSettingsView: View {
) )
} }
private var hotkeyBinding: Binding<Bool> { private var hotkeyTriggerBinding: Binding<String> {
Binding( Binding(
get: { viewModel.hotkeyEnabled }, get: { viewModel.hotkeyTrigger.rawValue },
set: { viewModel.setHotkeyEnabled($0) } set: { viewModel.setHotkeyTrigger(MacHotkeyTrigger(rawValue: $0) ?? .rightOption) }
) )
} }
+77 -4
View File
@@ -165,16 +165,89 @@ enum MacSherpaONNXRunner {
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
for line in lines.reversed() { for line in lines.reversed() {
if line.hasPrefix("{"), let data = line.data(using: .utf8), 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 object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = object["text"] as? String { let text = object["text"] as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return sanitizeTranscript(text)
if !trimmed.isEmpty { return trimmed }
} }
continue
}
if isMetadataNoiseLine(line) { continue }
if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 { if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 {
return line let cleaned = sanitizeTranscript(line)
if !cleaned.isEmpty { return cleaned }
} }
} }
return "" 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
}
} }
+98 -4
View File
@@ -9,10 +9,65 @@ import Foundation
import Speech import Speech
enum MacSpeechLocalASR { 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() let auth = await requestAuthorization()
guard auth == .authorized else { throw MacLocalASRError.speechDenied } 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) let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
defer { try? FileManager.default.removeItem(at: wavURL) } defer { try? FileManager.default.removeItem(at: wavURL) }
@@ -20,18 +75,43 @@ enum MacSpeechLocalASR {
guard let recognizer, recognizer.isAvailable else { guard let recognizer, recognizer.isAvailable else {
throw MacLocalASRError.speechFailed("Speech recognizer unavailable") 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) let request = SFSpeechURLRecognitionRequest(url: wavURL)
request.shouldReportPartialResults = false 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 { if let error {
if session.claimResume(cancellingTask: true) {
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription)) continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
}
return 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 let result, result.isFinal else { return }
guard session.claimResume(cancellingTask: false) else { return }
let text = result.bestTranscription.formattedString let text = result.bestTranscription.formattedString
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty { if text.isEmpty {
@@ -40,6 +120,16 @@ enum MacSpeechLocalASR {
continuation.resume(returning: text) 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) try wav.write(to: url)
return url return url
} }
private static func isChineseLocale(_ locale: Locale) -> Bool {
locale.identifier(.bcp47).lowercased().hasPrefix("zh")
}
} }
+140 -12
View File
@@ -1,8 +1,10 @@
// MacTextInsertionService.swift // MacTextInsertionService.swift
// OSGKeyboard · Mac // 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. // 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 import AppKit
@preconcurrency import ApplicationServices @preconcurrency import ApplicationServices
@@ -30,32 +32,158 @@ enum MacTextInsertionService {
return AXIsProcessTrustedWithOptions(options) 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( static func insert(
_ text: String, _ text: String,
autoPaste: Bool autoPaste: Bool,
) throws -> Bool { targetApp: NSRunningApplication? = nil
) async throws -> Bool {
guard !text.isEmpty else { return false } guard !text.isEmpty else { return false }
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
let snapshot = snapshotItems(of: pasteboard)
pasteboard.clearContents() pasteboard.clearContents()
pasteboard.setString(text, forType: .string) pasteboard.setString(text, forType: .string)
guard autoPaste else { return false } guard autoPaste else { return false }
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted } guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
Thread.sleep(forTimeInterval: 0.08) // Make sure V lands in the app the user was dictating into, not in
postCommandV() // 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 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 source = CGEventSource(stateID: .combinedSessionState)
let keyCode = CGKeyCode(kVK_ANSI_V) let keyCode = CGKeyCode(kVK_ANSI_V)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true) guard
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
keyDown?.flags = CGEventFlags.maskCommand else { return false }
keyUp?.flags = CGEventFlags.maskCommand keyDown.flags = CGEventFlags.maskCommand
keyDown?.post(tap: CGEventTapLocation.cghidEventTap) keyUp.flags = CGEventFlags.maskCommand
keyUp?.post(tap: CGEventTapLocation.cghidEventTap) 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. // 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, // The brand green is kept only as the accent. Light mode uses a warm,
// iOS-matched surface set (the default `windowBackgroundColor` reads cold // 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 AppKit
import SwiftUI import SwiftUI
@@ -33,8 +34,8 @@ enum MacSystemPalette {
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark), surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
accent: Palette.accent, accent: Palette.accent,
accentMuted: Palette.accent.opacity(0.16), accentMuted: Palette.accent.opacity(dark ? 0.22 : 0.14),
accentGlow: Palette.accent.opacity(0.35), accentGlow: Palette.accent.opacity(dark ? 0.40 : 0.32),
danger: resolved(.systemRed, dark: dark), danger: resolved(.systemRed, dark: dark),
success: Palette.accent, success: Palette.accent,
@@ -45,8 +46,12 @@ enum MacSystemPalette {
textTertiary: resolved(.tertiaryLabelColor, dark: dark), textTertiary: resolved(.tertiaryLabelColor, dark: dark),
textOnAccent: Color.white, textOnAccent: Color.white,
divider: resolved(.separatorColor, dark: dark), divider: dark
dividerStrong: resolved(.separatorColor, dark: 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) recordRed: resolved(.systemRed, dark: dark)
) )
+6 -1
View File
@@ -57,7 +57,9 @@ struct OSGKeyboardMacApp: App {
// very top, matching macOS System Settings. // very top, matching macOS System Settings.
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
.windowResizability(.contentMinSize) .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) { func applicationDidFinishLaunching(_ notification: Notification) {
MacAppearancePreference.applyToApp(.current) MacAppearancePreference.applyToApp(.current)
MacTextInsertionService.beginTrackingFrontmostApp()
configurePopover() configurePopover()
configureStatusItem() configureStatusItem()
MacDictationOverlayController.shared.start(observing: MacDictationViewModel.shared)
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
// The menu bar always follows the *system* appearance, so the status // The menu bar always follows the *system* appearance, so the status
// item must ignore the app's forced light/dark override. Re-pin the // 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. /// Keep this protocol narrow: only what the shared pipeline needs today.
/// Platform-specific settings UI and iCloud sync stay on concrete stores. /// Platform-specific settings UI and iCloud sync stay on concrete stores.
public protocol ConfigurationStore: Sendable { public protocol ConfigurationStore: Sendable {
/// Polish / LLM provider id.
var providerId: String { get } var providerId: String { get }
var baseURL: String { get } var baseURL: String { get }
var apiKey: String { get } var apiKey: String { get }
var model: 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 engineMode: String { get }
var polishIntensity: PolishIntensity { get } var polishIntensity: PolishIntensity { get }
var llmThinkingEnabled: Bool { get }
var personalDictionary: PersonalDictionary { get } var personalDictionary: PersonalDictionary { get }
/// Foreground-app context for polish prompts (keyboard extension publishes this). /// 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