merge: macOS architecture, cloud providers, and 6-step onboarding into main
@@ -73,3 +73,8 @@ __pycache__/
|
||||
|
||||
# Standalone experiment: 灵动岛录音机制验证 demo (throwaway)
|
||||
AudioIntentProbe/
|
||||
|
||||
# Local crash-log pulls and one-off diff/working notes (not for the repo)
|
||||
.crash-pull/
|
||||
all-changes-vs-main.patch
|
||||
CHANGES.md
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
- **6.7"** (1290×2796) — 必需
|
||||
- **6.1"** (1179×2556) — 必需(iPhone 17 Pro)
|
||||
- 5.5" 已被苹果官方文档降级为"可选"(iPhone 8 Plus 等已停产机型)
|
||||
- iPad 截图 — 项目声明 iPhone only,无需提供
|
||||
- iPad 截图 — **现已必需**:项目自 TARGETED_DEVICE_FAMILY "1,2" 起支持 iPad,App Store Connect 要求提供 13″ iPad Pro 截图套组(本条为后续更新覆盖原「iPhone only 无需提供」的结论)
|
||||
- 每套至少 3 张、最多 10 张
|
||||
|
||||
**本审计交付**:
|
||||
|
||||
@@ -7,7 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.3] - 2026-07-11
|
||||
|
||||
### Added
|
||||
- **Shared 7-day usage chart on iOS Home**: iPhone and iPad Home now show the same trailing-7-day dictation chart as macOS, via a shared `UsageStatsCluster`. / **iOS 首页共享近 7 天统计图**:iPhone / iPad 首页与 macOS 共用 `UsageStatsCluster`,展示近 7 天听写字数柱状图。
|
||||
- **macOS dictation overlay**: a bottom-centered floating pill appears for any recording path (global hotkey, menu bar, or main window) — shows listening / transcribing state, a stronger live waveform, a one-line live transcript preview (partials when chunked ASR runs), front-app name, and a stop control, then briefly confirms success before fading out without stealing focus. / **macOS 听写浮层**:任意录音路径(全局热键、菜单栏或主窗口)都会在屏幕底部居中出现胶囊浮层——显示聆听 / 识别状态、更强的实时波形、单行转写预览(分块识别时显示 partial)、前台应用名与停止按钮,成功后短暂确认再淡出,且不抢前台焦点。
|
||||
- **macOS Option-key picker**: Settings → Input lets you choose Left / Right / Either Option as the hold-to-talk key, so the shortcut can avoid conflicts with other apps. / **macOS Option 键选择**:设置 → 输入与快捷键可选择左 / 右 / 任一 Option 作为按住听写键,避免与其他应用冲突。
|
||||
|
||||
### Changed
|
||||
- **Shared home stats UI**: 7-day chart, stat tiles, and surface chrome live in `OSGKeyboardShared` (`UsageStatsCluster` / `UsageStatCard` / `SevenDayUsageChart`); Mac Dashboard and iOS Home (phone stacked / iPad split) both consume them. Localization keys moved from `mac.stat.*` to `stat.*`. / **共享首页统计 UI**:近 7 天图表、统计卡与表面壳迁入 `OSGKeyboardShared`(`UsageStatsCluster` / `UsageStatCard` / `SevenDayUsageChart`);Mac Dashboard 与 iOS 首页(手机上下叠 / iPad 左右分栏)共用。文案 key 由 `mac.stat.*` 改为 `stat.*`。
|
||||
- **Removed temporary Flow DEBUG panels**: the on-screen App Group / session debug text boxes on Home and the keyboard extension are gone now that the orange-mic investigation is closed. / **移除临时 Flow DEBUG 面板**:橙色麦克风排查结束后,首页与键盘扩展上的 App Group / 会话调试文本框已去掉。
|
||||
- **macOS local ASR catalog**: removed offline Paraformer; SenseVoice / Qwen3 0.6B / Qwen3 1.7B now show Fastest / Most balanced / Best quality badges (users still on Paraformer migrate to Qwen3 0.6B). / **macOS 本地 ASR 目录**:移除 offline Paraformer;SenseVoice / Qwen3 0.6B / Qwen3 1.7B 分别标注速度最快 / 最平衡 / 质量最好(仍选 Paraformer 的用户迁移到 Qwen3 0.6B)。
|
||||
- **macOS visual system**: full-app redesign around the brand line “Speak it. It’s 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. It’s typed.” 做全 App 设计升级——侧栏分组、克制的选中态、首页不对称统计(字数主卡)、统一页头、降权状态栏,以及更清晰的暗色卡片层次。
|
||||
- **macOS Home stat cards**: the hero word-count card is now a full-width horizontal bar sized to its content (icon + label + big number) instead of a tall card stretched to match its neighbors, removing the large dead space below the number; the "Recent" list was removed from Home since it duplicated the History page. / **macOS 首页统计卡**:字数主卡改为按内容自适应高度的满宽横条(图标+标题+大数字),不再被拉伸到与相邻卡片等高、留出大片空白;首页底部与历史页重复的「最近」列表已移除。
|
||||
- **macOS page margins**: Home / History / Dictionary / Settings share `pageHorizontalInset` on titles and scroll *content*; ScrollViews / Forms stay full-bleed so the scrollbar sits on the window edge, while cards stay aligned with the page title. Settings keeps native grouped Form for control layout. / **macOS 页边距**:首页 / 历史 / 词库 / 设置在标题与滚动*内容*上共用 `pageHorizontalInset`;ScrollView / Form 通栏使滚动条贴窗口右缘,卡片仍与页标题对齐。设置保留原生分组 Form 以保证控件排版。
|
||||
|
||||
### Fixed
|
||||
- **Spurious "Voice is ready" overlay**: a healthy in-app Flow session no longer flashes the cold-start overlay after dictation. Keyboard mic presses wait when the session is still alive (including `preparingSession` / single-frame `hostNotReady` races); only a truly dead host opens `startflow`. The host silences overlay when already ready or busy. Proactive auto-launch is disabled. / **误弹「语音已就绪」**:App 内健康 Flow 会话在听写后不再闪冷启动浮层。键盘在会话仍存活时(含 `preparingSession` / 单帧 `hostNotReady` 竞态)只等待;仅宿主真正死亡才打开 `startflow`。主 App 在已就绪或忙碌时静默忽略浮层。已关闭被动自动拉起。
|
||||
- **Cold-start overlay recursion crash**: dismissing the ready overlay while an utterance is already recording no longer recurses `refreshHostReady` → `reconcile` → `dismiss` on the main thread until stack overflow (`EXC_BAD_ACCESS`). Handoff flags are cleared before any refresh. / **冷启动浮层递归崩溃**:就绪浮层仍在时若已开始录音,不再在主线程上递归 `refreshHostReady` → `reconcile` → `dismiss` 直至栈溢出(`EXC_BAD_ACCESS`);交接标志会在任何 refresh 之前先清除。
|
||||
- **macOS Qwen3 “language” garbage transcript**: Sherpa Qwen3 results that still include the model scaffold (`language Chinese<asr_text>…`) are now stripped to the spoken text; incomplete outputs that stop at the bare word `language` are treated as empty instead of being inserted. / **macOS Qwen3「language」乱码转写**:Sherpa Qwen3 结果若仍带模型脚手架(`language Chinese<asr_text>…`)会剥到真实口语文案;不完整输出停在单词 `language` 时按空结果处理,不再插入。
|
||||
- **macOS local ASR silence garbage output**: dictating with no speech (silence) on a Sherpa-backed local model (SenseVoice/Qwen3/Paraformer) no longer inserts the raw JSON result line (`{"lang": "", "emotion": "", ...}`) as the transcript — it now correctly reports "no speech recognized". / **macOS 本地识别静音乱码**:使用 Sherpa 本地模型(SenseVoice/Qwen3/Paraformer)听写时若未检测到语音,不再把原始 JSON 结果行(`{"lang": "", "emotion": "", ...}`)当作转写文本插入,现在会正确提示「没有识别到语音」。
|
||||
- **Force-quit mic release**: on termination the host app now synchronously stops `AVAudioEngine`, deactivates `AVAudioSession`, and ends Live Activities (Dynamic Island + Lock Screen) in `applicationWillTerminate`, reducing “microphone in use” errors after reopening. / **强杀麦克风释放**:进程终止时在 `applicationWillTerminate` 内同步停止 `AVAudioEngine`、释放 `AVAudioSession` 并结束 Live Activity(灵动岛 + 锁屏),降低强杀后重开提示麦克风被占用的概率。
|
||||
|
||||
## [0.5.2] - 2026-07-09
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "anthropic.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||
|
After Width: | Height: | Size: 368 B |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "ark.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>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 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "groq.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>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 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "xai.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>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 |
@@ -117,5 +117,12 @@
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -8,6 +8,21 @@
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<!-- Voice recordings: processed on-device by default; uploaded to
|
||||
the user's configured ASR provider only when the cloud engine
|
||||
is explicitly enabled (opt-in with acknowledgment). -->
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeAudioData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
|
||||
|
||||
@@ -97,8 +97,11 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
for item in items {
|
||||
// `sourceApplication` is only non-nil when the caller belongs to
|
||||
// the same Apple Developer Team (our own keyboard extension) —
|
||||
// exactly what the host-return whitelist relies on.
|
||||
if let source = item.source {
|
||||
// exactly what the host-return whitelist relies on. Record it
|
||||
// ONLY for the `startflow` handoff: overwriting it for every
|
||||
// deep link (e.g. `osgkeyboard://settings`) could point a later
|
||||
// cold-start "return to host" at the wrong app.
|
||||
if let source = item.source, item.url.host == "startflow" {
|
||||
FlowSessionBridge.setPendingHostBundleId(source)
|
||||
}
|
||||
AppOpenURLRouter.shared.route(item.url)
|
||||
|
||||
@@ -15,11 +15,17 @@ enum FlowLiveActivityController {
|
||||
nonisolated(unsafe) private static var currentPhase: FlowActivityAttributes.ContentState.Phase = .idle
|
||||
|
||||
/// If the host app is force-quit its `endSession()` never runs, orphaning
|
||||
/// the Live Activity. A short `staleDate` lets the system grey it out and
|
||||
/// reclaim it on its own within ~45s of the process dying. While the host is
|
||||
/// alive the heartbeat calls `keepAlive()` well inside this window, so a
|
||||
/// genuinely active session never looks stale.
|
||||
private static let staleWindow: TimeInterval = 45
|
||||
/// the Live Activity. `staleDate` semantics (verified against ActivityKit
|
||||
/// behaviour, not folklore): the *Dynamic Island* presentation is reliably
|
||||
/// removed shortly after the stale date passes, but the *lock-screen*
|
||||
/// banner may linger greyed-out depending on the iOS version — it is NOT
|
||||
/// guaranteed to be dismissed. Treating staleDate as "auto-cleanup" is
|
||||
/// therefore wrong on its own; the full zombie defence is this short
|
||||
/// window + launch-time reconciliation (`clearOrphanedActivities`) + the
|
||||
/// widget rendering an explicit "disconnected" state via
|
||||
/// `context.isStale`. While the host is alive the heartbeat calls
|
||||
/// `keepAlive()` every ~10 s, well inside this window.
|
||||
private static let staleWindow: TimeInterval = 30
|
||||
|
||||
private static func freshContent(
|
||||
phase: FlowActivityAttributes.ContentState.Phase
|
||||
@@ -118,6 +124,8 @@ enum FlowLiveActivityController {
|
||||
}
|
||||
|
||||
/// `applicationWillTerminate` 专用:阻塞到所有 `end` 完成,避免进程先退出而锁屏卡片残留。
|
||||
/// 等待必须带超时:ActivityKit 的 `end` 走异步 XPC,若在 watchdog 杀进程前
|
||||
/// 没有返回,无限期 `wait()` 会吞掉整个 ~5 秒终止窗口,反而让后续清理全部没跑。
|
||||
nonisolated static func endAllSynchronouslyOnTerminate() {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task.detached(priority: .userInitiated) {
|
||||
@@ -129,7 +137,7 @@ enum FlowLiveActivityController {
|
||||
FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
_ = semaphore.wait(timeout: .now() + 2)
|
||||
currentPhase = .idle
|
||||
currentActivity = nil
|
||||
}
|
||||
|
||||
@@ -54,9 +54,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var currentUtteranceId: UUID?
|
||||
private var currentCommandSeq: Int64 = 0
|
||||
private var lastHandledCommandSeq: Int64 = 0
|
||||
private var isUtteranceRecording = false
|
||||
/// Published so Home / debug UI can show "recording" instead of a false "ready".
|
||||
@Published private(set) var isUtteranceRecording = false
|
||||
/// True from `stopped` until the result/error is written back to App Group.
|
||||
private var isUtteranceProcessing = false
|
||||
@Published private(set) var isUtteranceProcessing = false
|
||||
private var finalizeTask: Task<Void, Never>?
|
||||
private var asrTask: Task<Void, Never>?
|
||||
private var utteranceSafetyTask: Task<Void, Never>?
|
||||
@@ -77,17 +78,50 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var coldStartRecoveryTask: Task<Void, Never>?
|
||||
/// Initial proof window — cold mic sessions often need >2.5s after app switch.
|
||||
private static let coldStartAudioProofTimeout: TimeInterval = 6
|
||||
/// Extra window after the first timeout while the overlay shows a failure hint.
|
||||
private static let coldStartRecoveryProofTimeout: TimeInterval = 12
|
||||
/// Guards the once-per-process launch reconciliation (scene reconnects
|
||||
/// recreate the `@StateObject`-owned manager within the same process).
|
||||
private static var didRunLaunchReconciliation = false
|
||||
|
||||
init() {
|
||||
// Sessions are (re)started explicitly on app foreground via
|
||||
// `activateOnForeground()`. We deliberately do NOT silently reattach a
|
||||
// stored session here — after a force-quit that would resurrect capture
|
||||
// (and keep a stale Live Activity alive) without the user re-opening.
|
||||
//
|
||||
// Launch reconciliation: a brand-new process can never own an
|
||||
// in-flight session, so whatever the previous generation persisted
|
||||
// (force-quit skips `applicationWillTerminate` entirely when the app
|
||||
// was suspended) is void. Rotating the generation token also lets the
|
||||
// keyboard invalidate stale ready snapshots instantly instead of
|
||||
// waiting out the 60 s heartbeat-zombie window.
|
||||
//
|
||||
// Once per PROCESS, not per manager: iOS can disconnect and later
|
||||
// reconnect the sole scene without killing the process, which
|
||||
// recreates the `@StateObject` (and thus this init). Re-rotating then
|
||||
// would wipe live state that belongs to this very process.
|
||||
if AppGroup.isAvailable, !Self.didRunLaunchReconciliation {
|
||||
Self.didRunLaunchReconciliation = true
|
||||
let previous = FlowSessionBridge.rotateHostGeneration()
|
||||
if previous != nil || FlowSessionBridge.isSessionActive() {
|
||||
FlowSessionBridge.clearFlowStateOnHostLaunch()
|
||||
FlowLiveActivityController.clearOrphanedActivities()
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
debug("launch reconciliation: voided previous-generation Flow state")
|
||||
}
|
||||
}
|
||||
|
||||
capture.onEngineLiveChanged = { [weak self] _ in
|
||||
self?.refreshHostReady()
|
||||
}
|
||||
// A system interruption (call / Siri) stops audio frames mid-utterance;
|
||||
// fail fast so the user is not silently recording into a gap.
|
||||
capture.onInterruptionBegan = { [weak self] in
|
||||
guard let self, self.isUtteranceRecording else { return }
|
||||
self.failUtterance(
|
||||
message: AppL10n.string("flow.error.recognitionInterrupted"),
|
||||
kind: .recognitionInterrupted
|
||||
)
|
||||
}
|
||||
FlowTerminationCoordinator.register(self)
|
||||
}
|
||||
|
||||
@@ -104,21 +138,43 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
reconcilePersistedFlowStateBeforeStart()
|
||||
|
||||
// Healthy / busy sessions must not flash the cold-start overlay when a
|
||||
// spurious `startflow` arrives (e.g. keyboard finalize race).
|
||||
if coldStart, isActive {
|
||||
extendSession(duration: duration)
|
||||
refreshHostReady()
|
||||
let decision = FlowHandoffPolicy.coldStartOverlayDecision(
|
||||
sessionIsActive: true,
|
||||
hostIsReady: FlowSessionBridge.isHostReady(),
|
||||
isUtteranceBusy: isUtteranceRecording || isUtteranceProcessing
|
||||
)
|
||||
switch decision {
|
||||
case .silence:
|
||||
traceState(
|
||||
"startSession.coldStart.silenced",
|
||||
extra: FlowSessionBridge.isHostReady() ? "reason=alreadyReady" : "reason=busy"
|
||||
)
|
||||
return
|
||||
case .present:
|
||||
isColdStartHandoff = true
|
||||
showColdStartPreparing()
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.prepareExistingSessionForColdStartReturn()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if coldStart {
|
||||
isColdStartHandoff = true
|
||||
showColdStartPreparing()
|
||||
}
|
||||
|
||||
reconcilePersistedFlowStateBeforeStart()
|
||||
|
||||
if isActive {
|
||||
extendSession(duration: duration)
|
||||
refreshHostReady()
|
||||
if coldStart {
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.prepareExistingSessionForColdStartReturn()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -126,6 +182,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
traceState("startSession.ignored", extra: "reason=alreadyStarting")
|
||||
return
|
||||
}
|
||||
// Claim the flag synchronously: on a cold start the URL router and
|
||||
// `activateOnForeground()` both fire in the same runloop turn, and
|
||||
// setting it inside the async body let two start bodies interleave.
|
||||
isStarting = true
|
||||
|
||||
startTask?.cancel()
|
||||
startTask = Task { @MainActor [weak self] in
|
||||
@@ -190,11 +250,15 @@ final class FlowSessionManager: ObservableObject {
|
||||
func dismissColdStartOverlay() {
|
||||
coldStartRecoveryTask?.cancel()
|
||||
coldStartRecoveryTask = nil
|
||||
// Clear handoff flags BEFORE any refreshHostReady call. Otherwise
|
||||
// refresh → reconcileColdStartOverlayIfRecovered → dismiss → refresh
|
||||
// recurses until the main-thread stack overflows (EXC_BAD_ACCESS,
|
||||
// "Thread stack size exceeded due to excessive recursion").
|
||||
coldStartContext = nil
|
||||
isColdStartHandoff = false
|
||||
if isActive {
|
||||
refreshHostReady()
|
||||
}
|
||||
coldStartContext = nil
|
||||
isColdStartHandoff = false
|
||||
}
|
||||
|
||||
func returnToPendingHostFromColdStart() {
|
||||
@@ -204,6 +268,21 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
func retryColdStartReadiness() {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
// A failed cold start leaves capture in a running-but-dead state on
|
||||
// purpose (the recovery loop keeps probing it). A user-initiated
|
||||
// retry must instead begin from a clean pipeline: tear down capture
|
||||
// and the cached ASR instance so `startSession` rebuilds both —
|
||||
// otherwise the retry reuses the zombie engine and is guaranteed to
|
||||
// hit the same audio-proof timeout.
|
||||
coldStartRecoveryTask?.cancel()
|
||||
coldStartRecoveryTask = nil
|
||||
if capture.running {
|
||||
capture.stop()
|
||||
}
|
||||
sessionASR?.cancel()
|
||||
sessionASR = nil
|
||||
sessionASREngineMode = nil
|
||||
sessionASRWarmedLocaleID = nil
|
||||
startSession(coldStart: true)
|
||||
}
|
||||
|
||||
@@ -241,7 +320,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
if isUtteranceRecording || isUtteranceProcessing {
|
||||
capture.cancelUtterance()
|
||||
asr.cancel()
|
||||
// `asr` is a computed property that ALLOCATES a fresh ASRService
|
||||
// when `sessionASR` is nil — never do that inside the ~5 s
|
||||
// termination window; only cancel an instance that exists.
|
||||
sessionASR?.cancel()
|
||||
}
|
||||
|
||||
capture.cancelUtterance()
|
||||
@@ -407,16 +489,42 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
private func reactivateCaptureIfNeeded() async {
|
||||
guard isActive else { return }
|
||||
// A system interruption (call / Siri) may be in progress. Probe it:
|
||||
// `setActive(true)` inside `reassertIfRunning` fails while the
|
||||
// interruption is live and succeeds once it ends — which also covers
|
||||
// the documented case where iOS never delivers `.ended` (the latch
|
||||
// must not depend on that notification, or the session is dead until
|
||||
// its TTL). While the probe fails we deliberately do NOT stop or
|
||||
// rebuild: tearing the engine down would remove the observers the
|
||||
// `.ended` rebuild relies on and churn the shared session mid-call.
|
||||
if capture.isInterrupted {
|
||||
guard capture.reassertIfRunning(), !capture.isInterrupted else { return }
|
||||
}
|
||||
|
||||
if capture.running {
|
||||
let reasserted = capture.reassertIfRunning()
|
||||
if reasserted, capture.engineHasRecentAudio() {
|
||||
if reasserted, await capture.awaitAudioFlowing(timeout: 2) {
|
||||
sessionWarning = nil
|
||||
} else if !reasserted {
|
||||
sessionWarning = AppL10n.string("flow.error.audioUnavailable")
|
||||
refreshHostReady()
|
||||
return
|
||||
}
|
||||
refreshHostReady()
|
||||
return
|
||||
// The await above is a suspension point: the session may have
|
||||
// ended (expiry, user, teardown) while we waited. Never restart
|
||||
// the microphone for a session that no longer exists.
|
||||
guard isActive, !Task.isCancelled else { return }
|
||||
// Never tear capture down underneath a live utterance either — a
|
||||
// stalled route transition mid-recording must surface through the
|
||||
// utterance pipeline (safety timer / empty-transcript error), not
|
||||
// as a silent stop that truncates the take with no error at all.
|
||||
guard !isUtteranceRecording, !isUtteranceProcessing else {
|
||||
refreshHostReady()
|
||||
return
|
||||
}
|
||||
// Reassert failed, or the engine reports running yet produces no
|
||||
// frames (zombie after suspend / mediaserverd reset) — fall
|
||||
// through to a full rebuild instead of leaving it half-dead.
|
||||
capture.stop()
|
||||
debug("capture zombie after foreground — rebuilding")
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -442,7 +550,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
reason: .noSession,
|
||||
engineMode: store.engineMode,
|
||||
localeId: store.localeId,
|
||||
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt()
|
||||
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration()
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -486,7 +595,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
engineMode: store.engineMode,
|
||||
localeId: store.localeId,
|
||||
busyUtteranceId: isUtteranceRecording || isUtteranceProcessing ? currentUtteranceId : nil,
|
||||
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt()
|
||||
sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(),
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration()
|
||||
)
|
||||
)
|
||||
let signature = [
|
||||
@@ -509,9 +619,19 @@ final class FlowSessionManager: ObservableObject {
|
||||
/// shows a stale preparing/failed snapshot, heal automatically.
|
||||
private func reconcileColdStartOverlayIfRecovered() {
|
||||
guard isColdStartHandoff, isActive else { return }
|
||||
guard FlowSessionBridge.isHostReady() else { return }
|
||||
guard let context = coldStartContext else { return }
|
||||
|
||||
// Mid-utterance is not "ready" — dismiss the ready overlay so Home
|
||||
// does not keep advertising "语音已就绪" while utt.rec=1.
|
||||
if isUtteranceRecording || isUtteranceProcessing {
|
||||
if case .ready = context.state {
|
||||
dismissColdStartOverlay()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard FlowSessionBridge.isHostReady() else { return }
|
||||
|
||||
switch context.state {
|
||||
case .preparing:
|
||||
presentColdStartReadyOverlay()
|
||||
@@ -556,10 +676,11 @@ final class FlowSessionManager: ObservableObject {
|
||||
// MARK: - Session start
|
||||
|
||||
private func startSessionAsync(duration: TimeInterval?) async {
|
||||
traceState("startSessionAsync.begin")
|
||||
isStarting = true
|
||||
sessionWarning = nil
|
||||
// `isStarting` was claimed synchronously in `startSession()`.
|
||||
defer { isStarting = false }
|
||||
guard !Task.isCancelled else { return }
|
||||
traceState("startSessionAsync.begin")
|
||||
sessionWarning = nil
|
||||
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
sessionWarning = permissionWarningMessage()
|
||||
@@ -586,7 +707,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard await waitForAudioProof() else {
|
||||
let audioProved = await waitForAudioProof()
|
||||
guard !Task.isCancelled else { return }
|
||||
guard audioProved else {
|
||||
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
|
||||
sessionWarning = message
|
||||
traceState("startSessionAsync.failed", extra: "reason=audioProofTimeout")
|
||||
@@ -658,6 +781,16 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
refreshHostReady()
|
||||
guard FlowSessionBridge.isHostReady() else {
|
||||
// Busy ≠ broken: a startflow arriving mid-utterance (e.g. tapping
|
||||
// the Live Activity while dictating) finds a healthy session that
|
||||
// is simply recording/processing. Showing the audio-failure
|
||||
// overlay here would be a lie — and its recovery loop could even
|
||||
// stop capture and kill the live utterance.
|
||||
if isUtteranceRecording || isUtteranceProcessing {
|
||||
dismissColdStartOverlay()
|
||||
debug("cold-start handoff ignored: session busy with an utterance")
|
||||
return
|
||||
}
|
||||
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
|
||||
sessionWarning = message
|
||||
showColdStartAudioFailure(message: message)
|
||||
@@ -687,18 +820,54 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps proving mic readiness after the first timeout instead of tearing
|
||||
/// capture down — many handoffs become ready a few seconds later.
|
||||
/// Actively rebuilds the audio pipeline after a failed cold start instead
|
||||
/// of passively waiting for frames that a dead engine will never produce.
|
||||
/// Escalates per attempt: reassert the session → full engine rebuild →
|
||||
/// bounce the audio session and rebuild. Force-quit relaunches routinely
|
||||
/// inherit stale mediaserverd state that only a rebuild clears.
|
||||
private func scheduleColdStartRecovery(duration: TimeInterval?) {
|
||||
coldStartRecoveryTask?.cancel()
|
||||
coldStartRecoveryTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let recovered = await self.capture.awaitAudioFlowing(
|
||||
timeout: Self.coldStartRecoveryProofTimeout
|
||||
)
|
||||
var recovered = false
|
||||
for attempt in 1...3 {
|
||||
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
||||
switch attempt {
|
||||
case 1:
|
||||
_ = self.capture.reassertIfRunning()
|
||||
case 2:
|
||||
self.capture.stop()
|
||||
try? self.capture.start()
|
||||
default:
|
||||
self.capture.stop()
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
try? self.capture.start()
|
||||
}
|
||||
recovered = await self.capture.awaitAudioFlowing(
|
||||
timeout: TimeInterval(attempt + 1)
|
||||
)
|
||||
self.traceState(
|
||||
"coldStartRecovery.attempt",
|
||||
extra: "attempt=\(attempt) recovered=\(recovered)"
|
||||
)
|
||||
if recovered { break }
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
guard self.isColdStartHandoff else { return }
|
||||
guard recovered else { return }
|
||||
guard recovered else {
|
||||
// Out of attempts — leave the failure overlay up; its retry
|
||||
// button now performs a full teardown so the user always has
|
||||
// a working escape hatch (no more force-quit loops). Only
|
||||
// tear capture down when no session owns it: for an active
|
||||
// session the 1 Hz heartbeat keeps self-healing, and a stop
|
||||
// here would just fight it.
|
||||
if !self.isActive {
|
||||
self.capture.stop()
|
||||
}
|
||||
self.traceState("coldStartRecovery.exhausted")
|
||||
return
|
||||
}
|
||||
|
||||
self.sessionWarning = nil
|
||||
self.traceState("coldStartRecovery.recovered")
|
||||
@@ -1027,12 +1196,22 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
// Do NOT cancel `asrTask` or `asr` — drain trailing PCM, then finalize.
|
||||
|
||||
// Capture ids now: a cancelled finalize must still clear *this*
|
||||
// utterance's processing gate even if currentUtteranceId was cleared
|
||||
// by a racing fail/abort path.
|
||||
let drainingSessionId = activeSessionId
|
||||
let drainingUtteranceId = currentUtteranceId
|
||||
let drainingCommandSeq = currentCommandSeq
|
||||
finalizeTask?.cancel()
|
||||
finalizeTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let drainReport = await self.capture.endUtteranceAndDrain()
|
||||
FlowDiagnostics.logDrain(drainReport)
|
||||
await self.finalizeUtterance()
|
||||
await self.finalizeUtterance(
|
||||
sessionId: drainingSessionId,
|
||||
utteranceId: drainingUtteranceId,
|
||||
commandSeq: drainingCommandSeq
|
||||
)
|
||||
}
|
||||
debug("utterance stopped, draining tail")
|
||||
}
|
||||
@@ -1109,20 +1288,22 @@ final class FlowSessionManager: ObservableObject {
|
||||
debug("utterance processing failed: \(message)")
|
||||
}
|
||||
|
||||
private func finalizeUtterance() async {
|
||||
let finalizeSessionId = activeSessionId
|
||||
let finalizeUtteranceId = currentUtteranceId
|
||||
private func finalizeUtterance(
|
||||
sessionId finalizeSessionId: UUID?,
|
||||
utteranceId finalizeUtteranceId: UUID?,
|
||||
commandSeq finalizeCommandSeq: Int64
|
||||
) async {
|
||||
let pipelineStarted = Date()
|
||||
// ALWAYS clear the processing gate for this utterance. The previous
|
||||
// guard required currentUtteranceId to still match; a racing
|
||||
// fail/abort/cancel path could nil the id (or leave processing stuck)
|
||||
// and then skip refreshHostReady — keyboard stayed white forever
|
||||
// while host logs still said "utterance finalized".
|
||||
defer {
|
||||
if activeSessionId == finalizeSessionId,
|
||||
currentUtteranceId == finalizeUtteranceId {
|
||||
isUtteranceProcessing = false
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
touchSessionActivity()
|
||||
currentUtteranceId = nil
|
||||
currentCommandSeq = 0
|
||||
refreshHostReady()
|
||||
}
|
||||
completeFinalizeCleanup(
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId
|
||||
)
|
||||
}
|
||||
|
||||
let asrWait = asrWaitTimeout()
|
||||
@@ -1134,6 +1315,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
while Date() < asrDeadline {
|
||||
if !lastFinal.isEmpty { break }
|
||||
if asrTask?.isCancelled == true { break }
|
||||
// Honour cooperative cancel so a replaced finalize exits promptly,
|
||||
// but still run defer cleanup (unlike an early `return` mid-polish
|
||||
// that used to leave processing=true when ids no longer matched).
|
||||
if Task.isCancelled { break }
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
|
||||
@@ -1151,14 +1336,21 @@ final class FlowSessionManager: ObservableObject {
|
||||
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
guard !text.isEmpty else {
|
||||
let key = (asrTask?.isCancelled == true)
|
||||
let key = (asrTask?.isCancelled == true || Task.isCancelled)
|
||||
? "flow.error.recognitionInterrupted"
|
||||
: "flow.error.noSpeech"
|
||||
let kind: FlowSessionKeys.TranscriptionErrorKind =
|
||||
(asrTask?.isCancelled == true) ? .recognitionInterrupted : .noSpeech
|
||||
(asrTask?.isCancelled == true || Task.isCancelled)
|
||||
? .recognitionInterrupted : .noSpeech
|
||||
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
|
||||
utteranceRecordingStartedAt = nil
|
||||
storeCurrentError(AppL10n.string(key), kind: kind)
|
||||
storeFinalizedError(
|
||||
AppL10n.string(key),
|
||||
kind: kind,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1178,23 +1370,34 @@ final class FlowSessionManager: ObservableObject {
|
||||
"translationTarget=\(pipelineStore.translationTargetLocaleId)"
|
||||
)
|
||||
do {
|
||||
let polished = try await polisher.polish(
|
||||
text,
|
||||
// If the finalize task was cancelled (cold-start churn / abort),
|
||||
// skip the LLM round-trip and deliver the raw transcript so the
|
||||
// keyboard is not left waiting on a result that never arrives.
|
||||
if Task.isCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
let polished = try await Self.polishWithHostTimeout(
|
||||
polisher: polisher,
|
||||
text: text,
|
||||
mode: polishMode,
|
||||
providerIdOverride: pipelineStore.polishProviderIdOverride
|
||||
)
|
||||
delivered = polished
|
||||
storeCurrentFinal(polished, warning: chunkNote)
|
||||
storeFinalizedResult(
|
||||
polished,
|
||||
warning: chunkNote,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq
|
||||
)
|
||||
FlowDiagnostics.log(
|
||||
"polish done in \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s " +
|
||||
"total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s"
|
||||
)
|
||||
} catch {
|
||||
// v0.2.0: local + cloud-polish-on + no API key surfaces
|
||||
// `.missingAPIKey`. We translate it into a polishWarning
|
||||
// so the keyboard can show the "fill in your key" hint
|
||||
// inline rather than a generic failure message. The raw
|
||||
// transcript is still delivered — no data loss.
|
||||
// CancellationError is common when the user jumps back via
|
||||
// startflow mid-polish; still deliver raw text. Other errors
|
||||
// keep the existing polish-warning fallback.
|
||||
let fallback = Self.makeFallbackDelivery(
|
||||
rawText: text,
|
||||
error: error,
|
||||
@@ -1206,7 +1409,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
"\(error.localizedDescription)"
|
||||
)
|
||||
delivered = fallback.text
|
||||
storeCurrentFinal(fallback.text, warning: fallback.polishWarning)
|
||||
storeFinalizedResult(
|
||||
fallback.text,
|
||||
warning: fallback.polishWarning,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
commandSeq: finalizeCommandSeq
|
||||
)
|
||||
}
|
||||
|
||||
SpeechHistoryStore.shared.recordUtterance(
|
||||
@@ -1223,6 +1432,92 @@ final class FlowSessionManager: ObservableObject {
|
||||
debug("utterance finalized length=\(text.count)")
|
||||
}
|
||||
|
||||
/// Drop the processing gate and republish hostReady after finalize.
|
||||
/// Must not depend on a perfect id match — a racing fail/abort/cancel
|
||||
/// used to skip this block and leave the keyboard stuck on white「识别中」
|
||||
/// even after "utterance finalized" was logged.
|
||||
private func completeFinalizeCleanup(sessionId: UUID?, utteranceId: UUID?) {
|
||||
// A newer utterance may have started; never clobber its gate.
|
||||
if let current = currentUtteranceId,
|
||||
let finished = utteranceId,
|
||||
current != finished {
|
||||
debug(
|
||||
"finalize cleanup skipped — newer utterance live " +
|
||||
"finished=\(finished.uuidString.prefix(8)) current=\(current.uuidString.prefix(8))"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let wasProcessing = isUtteranceProcessing
|
||||
isUtteranceProcessing = false
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
if isActive {
|
||||
touchSessionActivity()
|
||||
}
|
||||
if currentUtteranceId == utteranceId || currentUtteranceId == nil {
|
||||
currentUtteranceId = nil
|
||||
currentCommandSeq = 0
|
||||
}
|
||||
refreshHostReady()
|
||||
debug(
|
||||
"finalize cleanup done wasProcessing=\(wasProcessing ? 1 : 0) " +
|
||||
"utterance=\(utteranceId?.uuidString.prefix(8) ?? "nil") " +
|
||||
"session=\(sessionId?.uuidString.prefix(8) ?? "nil")"
|
||||
)
|
||||
}
|
||||
|
||||
private func storeFinalizedResult(
|
||||
_ text: String,
|
||||
warning: String?,
|
||||
sessionId: UUID?,
|
||||
utteranceId: UUID?,
|
||||
commandSeq: Int64
|
||||
) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
storeFinalizedError(
|
||||
AppL10n.string("flow.error.noSpeech"),
|
||||
kind: .noSpeech,
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let sessionId, let utteranceId else { return }
|
||||
FlowSessionBridge.writeResult(
|
||||
FlowResult(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq,
|
||||
status: .final,
|
||||
text: trimmed,
|
||||
warning: warning
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func storeFinalizedError(
|
||||
_ message: String,
|
||||
kind: FlowSessionKeys.TranscriptionErrorKind,
|
||||
sessionId: UUID?,
|
||||
utteranceId: UUID?,
|
||||
commandSeq: Int64,
|
||||
status: FlowResult.Status = .error
|
||||
) {
|
||||
guard let sessionId, let utteranceId else { return }
|
||||
FlowSessionBridge.writeResult(
|
||||
FlowResult(
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq,
|
||||
status: status,
|
||||
text: message,
|
||||
errorKind: kind
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func polishModeLogLabel(_ mode: PolishingService.PolishMode) -> String {
|
||||
switch mode {
|
||||
case .polish:
|
||||
@@ -1252,35 +1547,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
engineMode: String,
|
||||
chunkWarning: String?
|
||||
) -> TranscriptionDelivery {
|
||||
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
|
||||
let warning = warningFromPolishError(error, engineMode: engineMode)
|
||||
?? polishDegradedWarning()
|
||||
?? chunkWarning
|
||||
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
|
||||
}
|
||||
|
||||
private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? {
|
||||
if let polishError = error as? PolishingService.PolishError {
|
||||
switch polishError {
|
||||
case .missingAPIKey:
|
||||
if engineMode == "local" {
|
||||
return SharedL10n.string("flow.warning.localPolishUnavailable")
|
||||
}
|
||||
return SharedL10n.string("flow.warning.cloudPolishMissingKey")
|
||||
case .timeout:
|
||||
return polishDegradedWarning()
|
||||
case .noTranscript:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if error is LLMError {
|
||||
return polishDegradedWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func polishDegradedWarning() -> String? {
|
||||
SharedL10n.string("flow.warning.polishDegraded")
|
||||
TranscriptionPolishFallback.makeDelivery(
|
||||
rawText: rawText,
|
||||
error: error,
|
||||
engineMode: engineMode,
|
||||
chunkWarning: chunkWarning
|
||||
)
|
||||
}
|
||||
|
||||
private func asrWaitTimeout() -> TimeInterval {
|
||||
@@ -1293,6 +1565,22 @@ final class FlowSessionManager: ObservableObject {
|
||||
return FlowSessionKeys.cloudASRWaitTimeout
|
||||
}
|
||||
|
||||
/// Host-level polish cap — does not wait for a cancelled LLM task to unwind.
|
||||
private static func polishWithHostTimeout(
|
||||
polisher: PolishingService,
|
||||
text: String,
|
||||
mode: PolishingService.PolishMode,
|
||||
providerIdOverride: String?
|
||||
) async throws -> String {
|
||||
try await HardTimeout.run(seconds: FlowSessionKeys.maxPolishTimeout) {
|
||||
try await polisher.polish(
|
||||
text,
|
||||
mode: mode,
|
||||
providerIdOverride: providerIdOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Level publishing (main thread only)
|
||||
|
||||
private func startLevelPublishing() {
|
||||
@@ -1318,8 +1606,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
// Refresh the Live Activity `staleDate` every N heartbeat ticks
|
||||
// (1 Hz) — well inside `FlowLiveActivityController.staleWindow` so a
|
||||
// live session never looks stale, while a force-quit stops these
|
||||
// refreshes and lets the system reclaim the orphaned island.
|
||||
let liveActivityKeepAliveEveryTicks = 15
|
||||
// refreshes and lets the island go stale within ~30 s.
|
||||
let liveActivityKeepAliveEveryTicks = 10
|
||||
var tick = 0
|
||||
while !Task.isCancelled {
|
||||
guard let self else { break }
|
||||
@@ -1351,6 +1639,30 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowDiagnostics.log(message)
|
||||
}
|
||||
|
||||
// MARK: - Temporary Flow debug panel (remove after orange-mic investigation)
|
||||
|
||||
/// Snapshot for the on-screen debug panel. Safe to call from the main actor.
|
||||
func makeDebugRows() -> [FlowDebugRow] {
|
||||
let snapshot = FlowSessionBridge.readySnapshot()
|
||||
let hostRows = FlowDebugAppGroupSnapshot.rows()
|
||||
let memRows: [FlowDebugRow] = [
|
||||
FlowDebugRow("isActive", isActive ? "1" : "0"),
|
||||
FlowDebugRow("isStarting", isStarting ? "1" : "0"),
|
||||
FlowDebugRow("coldStart", isColdStartHandoff ? "1" : "0"),
|
||||
FlowDebugRow("engineLive", capture.engineIsLive ? "1" : "0"),
|
||||
FlowDebugRow("audioFresh", capture.engineHasRecentAudio(maxAge: 2) ? "1" : "0"),
|
||||
FlowDebugRow("mem.reason", snapshot?.reason.rawValue ?? "nil"),
|
||||
FlowDebugRow("utt.rec", isUtteranceRecording ? "1" : "0"),
|
||||
FlowDebugRow("utt.proc", isUtteranceProcessing ? "1" : "0"),
|
||||
FlowDebugRow("sessionId", activeSessionId.map { String($0.uuidString.prefix(8)) } ?? "nil"),
|
||||
FlowDebugRow("warning", sessionWarning == nil ? "0" : "1"),
|
||||
FlowDebugRow("overlay", coldStartContext.map { String(describing: $0.state) } ?? "nil"),
|
||||
FlowDebugRow("bridgeReady", FlowSessionBridge.isHostReady() ? "1" : "0")
|
||||
]
|
||||
// Prefer App Group snap.reason near the top of the shared block.
|
||||
return memRows + hostRows
|
||||
}
|
||||
|
||||
private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
|
||||
let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
|
||||
guard signature != lastIgnoredCommandSignature else { return }
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
// Single source of truth for third-party open-source components shipped
|
||||
// with OSGKeyboard. Consumed by Settings → About → Third-Party Licenses.
|
||||
//
|
||||
// Keep this list aligned with `project.yml` package dependencies.
|
||||
// Keep this list aligned with bundled resources and runtime downloads.
|
||||
//
|
||||
// v0.2.0: dropped the `Qwen3Speech` SPM fork (Qwen3 CoreML ASR is gone)
|
||||
// and the `aufklarer/Qwen3-ASR-CoreML` runtime artefact. The local
|
||||
// engine now ships with iOS 26 `SpeechAnalyzer` + `DictationTranscriber`
|
||||
// and has no on-device ML dependencies of our own.
|
||||
// iOS targets remain zero-SPM. macOS local ASR downloads the sherpa-onnx
|
||||
// runtime binary on demand; model weights are cached under Application Support.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -26,19 +24,24 @@ enum OpenSourceLicenseCatalog {
|
||||
let licenseText: String
|
||||
}
|
||||
|
||||
/// Bundled libraries referenced by the app. v0.2.0 no longer pulls in
|
||||
/// `soniqo/speech-swift` (we use iOS 26 `SpeechAnalyzer` instead) and
|
||||
/// no longer downloads `Qwen3-ASR-CoreML` weights — both entries are
|
||||
/// intentionally absent.
|
||||
/// Bundled libraries and runtime components referenced by the app.
|
||||
static let entries: [Entry] = [
|
||||
.init(
|
||||
id: "material-icons",
|
||||
name: "Google Material Icons",
|
||||
licenseName: "Apache-2.0",
|
||||
purpose: "MaterialIcons-Regular.ttf bundled for Settings and navigation iconography.",
|
||||
purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.",
|
||||
url: URL(string: "https://github.com/google/material-design-icons"),
|
||||
licenseText: apache2Text
|
||||
),
|
||||
.init(
|
||||
id: "sherpa-onnx",
|
||||
name: "sherpa-onnx",
|
||||
licenseName: "Apache-2.0",
|
||||
purpose: "macOS local ASR runtime (`sherpa-onnx-offline`) downloaded at install time and cached on device.",
|
||||
url: URL(string: "https://github.com/k2-fsa/sherpa-onnx"),
|
||||
licenseText: apache2Text
|
||||
),
|
||||
]
|
||||
|
||||
// MARK: - License bodies
|
||||
@@ -60,30 +63,4 @@ enum OpenSourceLicenseCatalog {
|
||||
implied. See the License for the specific language governing
|
||||
permissions and limitations under the License.
|
||||
"""
|
||||
|
||||
static let mitText = """
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Apple Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,215 +11,86 @@ struct APISettingsCard: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var showKey: Bool = false
|
||||
@State private var testStatus: TestStatus = .idle
|
||||
|
||||
private enum TestStatus: Equatable {
|
||||
case idle
|
||||
case running
|
||||
case success
|
||||
case failure(String)
|
||||
}
|
||||
/// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。
|
||||
var showsSurface: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
field(
|
||||
SettingsCredentialRow(
|
||||
title: AppL10n.string("api.key"),
|
||||
placeholder: "sk-…",
|
||||
text: $config.apiKey,
|
||||
isSecret: true,
|
||||
isMonospaced: true
|
||||
)
|
||||
rowDivider
|
||||
SettingsCredentialRow(
|
||||
title: AppL10n.string("api.baseUrl"),
|
||||
placeholder: "https://api.openai.com/v1",
|
||||
placeholder: LLMProvider.provider(id: config.providerId).defaultBaseURL,
|
||||
text: $config.baseURL,
|
||||
autocap: false
|
||||
isMonospaced: true
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
keyField
|
||||
Divider().background(palette.divider)
|
||||
field(
|
||||
rowDivider
|
||||
SettingsModelPickerRow(
|
||||
title: AppL10n.string("api.model"),
|
||||
placeholder: "gpt-4o-mini",
|
||||
text: $config.model,
|
||||
autocap: false
|
||||
placeholder: LLMProvider.provider(id: config.providerId).defaultModel,
|
||||
model: $config.model,
|
||||
fetchModels: fetchModels
|
||||
)
|
||||
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
|
||||
Divider().background(palette.divider)
|
||||
// Use a Button + UIApplication.open instead of SwiftUI
|
||||
// `Link`. SwiftUI `Link` can have hit-test quirks on some
|
||||
// makes its tappable area eat gestures from the adjacent
|
||||
// TextField, which manifests as "typing jumps to a website".
|
||||
Button {
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("api.getKey")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Divider().background(palette.divider)
|
||||
testConnectionRow
|
||||
.id(config.providerId)
|
||||
rowDivider
|
||||
thinkingRow
|
||||
rowDivider
|
||||
SettingsProviderToolsRow(validate: validateConnection)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
|
||||
}
|
||||
|
||||
// MARK: - Key
|
||||
|
||||
private var keyField: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("api.key")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
Button(action: { showKey.toggle() }) {
|
||||
Image(systemName: showKey ? "eye.slash.fill" : "eye.fill")
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(showKey
|
||||
? AppL10n.string("api.key.hide")
|
||||
: AppL10n.string("api.key.show")))
|
||||
}
|
||||
Group {
|
||||
if showKey {
|
||||
TextField("sk-…", text: $config.apiKey)
|
||||
} else {
|
||||
SecureField("sk-…", text: $config.apiKey)
|
||||
}
|
||||
}
|
||||
.keyboardType(.asciiCapable)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
|
||||
private var rowDivider: some View {
|
||||
Divider().background(palette.divider)
|
||||
}
|
||||
|
||||
// MARK: - Generic field
|
||||
|
||||
@ViewBuilder
|
||||
private func field(
|
||||
title: String,
|
||||
placeholder: String,
|
||||
text: Binding<String>,
|
||||
autocap: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
TextField(placeholder, text: text)
|
||||
.keyboardType(.asciiCapable)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(autocap ? .sentences : .never)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { /* no-op: prevent the keyboard from "submitting"
|
||||
and dismissing the sheet unexpectedly */ }
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
|
||||
}
|
||||
|
||||
// MARK: - Test connection
|
||||
|
||||
private var testConnectionRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("api.connection")
|
||||
/// Two-line thinking toggle — opt-in for slower, higher-quality polish.
|
||||
private var thinkingRow: some View {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(AppL10n.string("settings.provider.thinking"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
Button(action: runTest) {
|
||||
Group {
|
||||
if testStatus == .running {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Text(testButtonLabel)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(testTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(testStatus == .running)
|
||||
}
|
||||
if let detail = testDetail {
|
||||
Text(detail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(AppL10n.string("settings.provider.thinkingSubtitle"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(testTint)
|
||||
.lineLimit(3)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Toggle("", isOn: $config.llmThinkingEnabled)
|
||||
.labelsHidden()
|
||||
.tint(palette.accent)
|
||||
.accessibilityLabel(AppL10n.string("settings.provider.thinking"))
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
private var testButtonLabel: String {
|
||||
switch testStatus {
|
||||
case .idle: return AppL10n.string("api.test.idle")
|
||||
case .running: return AppL10n.string("api.test.running")
|
||||
case .success: return AppL10n.string("api.test.success")
|
||||
case .failure: return AppL10n.string("api.test.failure")
|
||||
}
|
||||
}
|
||||
|
||||
private var testTint: Color {
|
||||
switch testStatus {
|
||||
case .idle, .running: return palette.accent
|
||||
case .success: return palette.accent
|
||||
case .failure: return palette.danger
|
||||
}
|
||||
}
|
||||
|
||||
private var testDetail: String? {
|
||||
switch testStatus {
|
||||
case .idle, .running, .success: return nil
|
||||
case .failure(let message): return message
|
||||
}
|
||||
}
|
||||
|
||||
private func runTest() {
|
||||
testStatus = .running
|
||||
let store = AppGroupStore()
|
||||
let client = OpenAICompatibleClient(
|
||||
baseURL: store.baseURL,
|
||||
apiKey: store.apiKey,
|
||||
model: store.model
|
||||
private func validateConnection() async throws {
|
||||
// Use on-screen config — not a fresh AppGroupStore — so a just-typed
|
||||
// key is visible even if Keychain write is still settling.
|
||||
let client = LLMClientFactory.make(
|
||||
providerId: config.providerId,
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
thinkingEnabled: config.llmThinkingEnabled
|
||||
)
|
||||
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
|
||||
}
|
||||
|
||||
private func fetchModels() async throws -> [String] {
|
||||
try await ProviderModelService.listLLMModels(
|
||||
providerId: config.providerId,
|
||||
baseURL: config.baseURL,
|
||||
apiKey: config.apiKey,
|
||||
currentModel: config.model
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
|
||||
testStatus = .success
|
||||
} catch LLMError.noAPIKey {
|
||||
testStatus = .failure(AppL10n.string("api.test.missing"))
|
||||
} catch let error as LLMError {
|
||||
switch error {
|
||||
case .http(let status):
|
||||
testStatus = .failure(AppL10n.format("api.test.http", status))
|
||||
case .rateLimited:
|
||||
testStatus = .failure(AppL10n.string("api.test.rateLimited"))
|
||||
case .transport(let msg):
|
||||
testStatus = .failure(AppL10n.format("api.test.transportWith", msg))
|
||||
default:
|
||||
testStatus = .failure(error.errorDescription ?? "\(error)")
|
||||
}
|
||||
} catch {
|
||||
testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// ASRSettingsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Cloud ASR credentials — independent from the polish LLM card.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct ASRSettingsCard: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var config: ProviderConfig
|
||||
/// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。
|
||||
var showsSurface: Bool = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if config.asrProviderId == "volcengine" {
|
||||
volcengineRows
|
||||
} else {
|
||||
genericRows
|
||||
}
|
||||
rowDivider
|
||||
SettingsProviderToolsRow(validate: validateConnection)
|
||||
}
|
||||
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var genericRows: some View {
|
||||
if CloudASRModelCatalog.showsASREndpointField(for: config.asrProviderId) {
|
||||
SettingsCredentialRow(
|
||||
title: AppL10n.string("api.baseUrl"),
|
||||
placeholder: LLMProvider.provider(id: config.asrProviderId).defaultBaseURL,
|
||||
text: $config.asrBaseURL,
|
||||
isMonospaced: true
|
||||
)
|
||||
rowDivider
|
||||
}
|
||||
|
||||
SettingsCredentialRow(
|
||||
title: AppL10n.string("api.key"),
|
||||
placeholder: "sk-…",
|
||||
text: $config.asrApiKey,
|
||||
isSecret: true,
|
||||
isMonospaced: true
|
||||
)
|
||||
rowDivider
|
||||
SettingsModelPickerRow(
|
||||
title: AppL10n.string("settings.asr.model"),
|
||||
placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId),
|
||||
model: $config.asrModel,
|
||||
fetchModels: fetchModels
|
||||
)
|
||||
.id(config.asrProviderId)
|
||||
}
|
||||
|
||||
private var volcengineRows: some View {
|
||||
Group {
|
||||
SettingsCredentialRow(
|
||||
title: AppL10n.string("settings.asr.volcengine.appId"),
|
||||
placeholder: "APP ID",
|
||||
text: Binding(
|
||||
get: { volcengineFields.appID },
|
||||
set: { updateVolcengine(appID: $0) }
|
||||
),
|
||||
isSecret: true,
|
||||
isMonospaced: true
|
||||
)
|
||||
rowDivider
|
||||
SettingsCredentialRow(
|
||||
title: AppL10n.string("settings.asr.volcengine.accessToken"),
|
||||
placeholder: "Access Token",
|
||||
text: Binding(
|
||||
get: { volcengineFields.accessToken },
|
||||
set: { updateVolcengine(accessToken: $0) }
|
||||
),
|
||||
isSecret: true,
|
||||
isMonospaced: true
|
||||
)
|
||||
rowDivider
|
||||
SettingsCredentialRow(
|
||||
title: AppL10n.string("settings.asr.volcengine.resourceId"),
|
||||
placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
|
||||
text: Binding(
|
||||
get: { volcengineFields.resourceID },
|
||||
set: { updateVolcengine(resourceID: $0) }
|
||||
),
|
||||
isMonospaced: true,
|
||||
defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
)
|
||||
rowDivider
|
||||
SettingsProviderRow(title: AppL10n.string("settings.provider.note")) {
|
||||
Text("settings.asr.volcengine.note")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rowDivider: some View {
|
||||
Divider().background(palette.divider)
|
||||
}
|
||||
|
||||
private var volcengineFields: VolcengineASRFields {
|
||||
VolcengineASRFields.parse(
|
||||
apiKey: config.asrApiKey,
|
||||
resourceFallback: config.asrModel.isEmpty
|
||||
? CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
: config.asrModel
|
||||
)
|
||||
}
|
||||
|
||||
private func updateVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
|
||||
var fields = volcengineFields
|
||||
if let appID { fields.appID = appID }
|
||||
if let accessToken { fields.accessToken = accessToken }
|
||||
if let resourceID {
|
||||
fields.resourceID = resourceID
|
||||
config.asrModel = resourceID
|
||||
}
|
||||
config.asrApiKey = fields.encodedAPIKey
|
||||
}
|
||||
|
||||
private func validateConnection() async throws {
|
||||
let persisted = AppGroupStore()
|
||||
let live = LiveConfigurationStore(config: config, fallback: persisted)
|
||||
try await CloudASRConnectionCheck.validate(store: live)
|
||||
}
|
||||
|
||||
private func fetchModels() async throws -> [String] {
|
||||
try await ProviderModelService.listASRModels(
|
||||
providerId: config.asrProviderId,
|
||||
baseURL: config.asrBaseURL,
|
||||
apiKey: config.asrApiKey,
|
||||
currentModel: config.asrModel
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
// HomeStatsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Home screen summary: dictation time, dictation characters,
|
||||
// translation characters, and personal-dictionary entry count.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct HomeStatsCard: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject private var stats = UsageStatisticsStore.shared
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
@State private var dictionaryCount = 0
|
||||
|
||||
private enum Layout {
|
||||
static let fixedHeight: CGFloat = 166
|
||||
static let valueFontSize: CGFloat = 24
|
||||
static let iconSize: CGFloat = 18
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
statCell(
|
||||
systemImage: "waveform",
|
||||
value: UsageStatisticsStore.formatDuration(
|
||||
stats.dictationDurationSeconds,
|
||||
language: config.uiLanguage
|
||||
),
|
||||
label: "home.stats.dictationDuration"
|
||||
)
|
||||
divider
|
||||
statCell(
|
||||
systemImage: "text.alignleft",
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
stats.dictationCharacterCount,
|
||||
language: config.uiLanguage
|
||||
),
|
||||
label: "home.stats.dictationCharacters"
|
||||
)
|
||||
}
|
||||
horizontalDivider
|
||||
HStack(spacing: 0) {
|
||||
statCell(
|
||||
systemImage: "character.bubble",
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
stats.translationCharacterCount,
|
||||
language: config.uiLanguage
|
||||
),
|
||||
label: "home.stats.translationCharacters"
|
||||
)
|
||||
divider
|
||||
statCell(
|
||||
systemImage: "square.stack.3d.down.right.fill",
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
dictionaryCount,
|
||||
language: config.uiLanguage
|
||||
),
|
||||
label: "home.stats.dictionaryEntries"
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: Layout.fixedHeight)
|
||||
.background(cardBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.onAppear(perform: refreshDictionaryCount)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshDictionaryCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
refreshDictionaryCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
stats.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
|
||||
private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View {
|
||||
HStack(alignment: .top, spacing: Spacing.xs) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(value)
|
||||
.font(.system(size: Layout.valueFontSize, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
Text(label)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: Layout.iconSize, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(width: 0.5)
|
||||
}
|
||||
|
||||
private var horizontalDivider: some View {
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
|
||||
private func refreshDictionaryCount() {
|
||||
dictionaryCount = AppGroupStore().personalDictionary.entries.count
|
||||
}
|
||||
|
||||
private var cardBackground: Color {
|
||||
colorScheme == .dark ? palette.surface : .white
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
ThemedRoot {
|
||||
HomeStatsCard()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,61 @@
|
||||
// HomeUsageStatsSection.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Observes usage + dictionary counts and feeds the shared
|
||||
// `UsageStatsCluster` (phone stacked / iPad split).
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct HomeUsageStatsSection: View {
|
||||
let layout: UsageStatsCluster.Layout
|
||||
var compact: Bool = false
|
||||
|
||||
@ObservedObject private var stats = UsageStatisticsStore.shared
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
@State private var dictionaryCount = 0
|
||||
|
||||
var body: some View {
|
||||
UsageStatsCluster(
|
||||
layout: layout,
|
||||
language: config.uiLanguage,
|
||||
points: stats.last7Days,
|
||||
dictationCharacterCount: stats.dictationCharacterCount,
|
||||
dictationDurationSeconds: stats.dictationDurationSeconds,
|
||||
translationCharacterCount: stats.translationCharacterCount,
|
||||
dictionaryTermCount: dictionaryCount,
|
||||
compact: compact
|
||||
)
|
||||
.onAppear(perform: refreshDictionaryCount)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshDictionaryCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
refreshDictionaryCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
stats.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshDictionaryCount() {
|
||||
dictionaryCount = AppGroupStore().personalDictionary.entries.count
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Phone stacked") {
|
||||
ThemedRoot {
|
||||
HomeUsageStatsSection(layout: .stacked)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Wide split") {
|
||||
ThemedRoot {
|
||||
HomeUsageStatsSection(layout: .split)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -23,7 +23,7 @@ enum AppTab: Int, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Matches `HomeStatsCard` dictionary stat cell (filled variant).
|
||||
/// Filled SF Symbol override for the dictionary tab.
|
||||
var sfSymbol: String? {
|
||||
switch self {
|
||||
case .dictionary: return "square.stack.3d.down.right.fill"
|
||||
@@ -39,6 +39,18 @@ enum AppTab: Int, CaseIterable {
|
||||
case .settings: return "tab.settings"
|
||||
}
|
||||
}
|
||||
|
||||
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
|
||||
var sidebarTitle: LocalizedStringKey { accessibilityKey }
|
||||
|
||||
var sidebarSystemImage: String {
|
||||
switch self {
|
||||
case .keyboard: return "house"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
case .dictionary: return "character.book.closed"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MinimalTabBar: View {
|
||||
|
||||
@@ -32,62 +32,3 @@ struct PrivacyInfoCard: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Footnote when Cloud polish is active — text goes to the user's configured API.
|
||||
struct CloudPolishDisclosureBanner: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(palette.accent)
|
||||
Text("settings.privacy.cloud.body")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(palette.accentMuted.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
/// First-time confirmation before enabling Cloud polish (user-configured third-party API).
|
||||
struct CloudSharingAcknowledgmentModifier: ViewModifier {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@Binding var isPresented: Bool
|
||||
let onConfirm: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.alert("settings.privacy.cloud.alert.title", isPresented: $isPresented) {
|
||||
Button("common.continue") {
|
||||
config.hasAcknowledgedCloudSharing = true
|
||||
onConfirm()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
onCancel()
|
||||
}
|
||||
} message: {
|
||||
Text("settings.privacy.cloud.alert.message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cloudSharingAcknowledgment(
|
||||
config: ProviderConfig,
|
||||
isPresented: Binding<Bool>,
|
||||
onConfirm: @escaping () -> Void,
|
||||
onCancel: @escaping () -> Void
|
||||
) -> some View {
|
||||
modifier(CloudSharingAcknowledgmentModifier(
|
||||
config: config,
|
||||
isPresented: isPresented,
|
||||
onConfirm: onConfirm,
|
||||
onCancel: onCancel
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// WideLayoutComponents.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Reusable layout pieces for iPad / regular-width surfaces. Styled with the
|
||||
// shared design tokens so the wide Home dashboard can mirror the macOS shell
|
||||
// without pulling in AppKit-only types from OSGKeyboardMac.
|
||||
//
|
||||
// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Layout metrics
|
||||
|
||||
/// Fixed metrics that keep wide surfaces on the same grid as the macOS app.
|
||||
enum WideLayoutMetrics {
|
||||
static let sidebarWidth: CGFloat = 240
|
||||
static let sidebarInset: CGFloat = Spacing.md
|
||||
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
|
||||
static let pageHorizontalInset: CGFloat = 40
|
||||
static let dictationCanvasMinHeight: CGFloat = 120
|
||||
}
|
||||
|
||||
// MARK: - Card container
|
||||
|
||||
/// Elevated surface used for the dictation canvas on wide Home.
|
||||
struct WideCard<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
var padding: CGFloat = Spacing.md
|
||||
var cornerRadius: CGFloat = Radius.medium
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,6 @@ struct EnginePickerSection: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var showCloudAcknowledgment = false
|
||||
@State private var pendingEngineSelection: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
@@ -37,17 +35,7 @@ struct EnginePickerSection: View {
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
|
||||
if config.engineMode == "cloud" {
|
||||
CloudPolishDisclosureBanner()
|
||||
}
|
||||
}
|
||||
.cloudSharingAcknowledgment(
|
||||
config: config,
|
||||
isPresented: $showCloudAcknowledgment,
|
||||
onConfirm: { applyPendingEngineSelection() },
|
||||
onCancel: { pendingEngineSelection = nil }
|
||||
)
|
||||
}
|
||||
|
||||
private var localSubtitle: String {
|
||||
@@ -64,11 +52,6 @@ struct EnginePickerSection: View {
|
||||
let isSelected = config.engineMode == id
|
||||
return Button {
|
||||
guard config.engineMode != id else { return }
|
||||
if id == "cloud", !config.hasAcknowledgedCloudSharing {
|
||||
pendingEngineSelection = id
|
||||
showCloudAcknowledgment = true
|
||||
return
|
||||
}
|
||||
selectEngine(id)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
@@ -92,8 +75,7 @@ struct EnginePickerSection: View {
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -104,19 +86,10 @@ struct EnginePickerSection: View {
|
||||
config.engineMode = id
|
||||
if id == "cloud" {
|
||||
config.modeId = "polish"
|
||||
if config.providerId == "deepseek" {
|
||||
config.apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingEngineSelection() {
|
||||
guard let id = pendingEngineSelection else { return }
|
||||
pendingEngineSelection = nil
|
||||
selectEngine(id)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
|
||||
ZStack {
|
||||
|
||||
@@ -15,6 +15,7 @@ import UIKit
|
||||
struct HomeView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
@@ -24,6 +25,10 @@ struct HomeView: View {
|
||||
@State private var micStatus = AppPermissions.micStatus
|
||||
@State private var speechStatus = AppPermissions.speechStatus
|
||||
|
||||
private var usesWideLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
private var sessionIsLive: Bool {
|
||||
flowManager.isActive || flowManager.isStarting
|
||||
}
|
||||
@@ -52,50 +57,11 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
sessionHeaderGradient(height: gradientHeight)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
logoHeader
|
||||
.padding(.top, Spacing.xxxl)
|
||||
.padding(.bottom, Spacing.xxl)
|
||||
|
||||
if showsFlowSessionExtras {
|
||||
flowSessionExtras
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
|
||||
HomeStatsCard()
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
previewField
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
engineStatusLine
|
||||
flowStatusFooter
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.xl)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.background(palette.background)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if previewFocused {
|
||||
previewFocused = false
|
||||
}
|
||||
Group {
|
||||
if usesWideLayout {
|
||||
wideBody
|
||||
} else {
|
||||
phoneBody
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -114,6 +80,126 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phone layout
|
||||
|
||||
private var phoneBody: some View {
|
||||
GeometryReader { geo in
|
||||
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
|
||||
// 小屏(如 iPhone SE)压缩顶部留白,把空间让给自适应的输入框,
|
||||
// 避免固定块之和超出视口、底部状态行被 tab 栏遮挡。
|
||||
let isCompact = geo.size.height < 700
|
||||
// logo 上下留白对称,避免视觉上偏下。
|
||||
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
|
||||
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
|
||||
let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg
|
||||
let statusTopPadding = isCompact ? Spacing.sm : Spacing.xl
|
||||
// 输入框最小高度:小屏可压得更矮,让底部状态行始终留在 tab 栏之上。
|
||||
let previewMinHeight: CGFloat = isCompact ? 72 : 160
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
sessionHeaderGradient(height: gradientHeight)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
logoHeader(compact: isCompact)
|
||||
.padding(.top, logoTopPadding)
|
||||
.padding(.bottom, logoBottomPadding)
|
||||
|
||||
if showsFlowSessionExtras {
|
||||
flowSessionExtras
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, extrasBottomPadding)
|
||||
}
|
||||
|
||||
HomeUsageStatsSection(layout: .stacked, compact: isCompact)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
// 唯一的弹性区块:吸收全部剩余空间(大屏铺满、小屏优先让位)。
|
||||
previewField(minHeight: previewMinHeight)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.layoutPriority(-1)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
engineStatusLine
|
||||
flowStatusFooter
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, statusTopPadding)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
.background(palette.background)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if previewFocused {
|
||||
previewFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wide layout (iPad / regular width)
|
||||
|
||||
private var wideBody: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
wideHeroHeader
|
||||
|
||||
HomeUsageStatsSection(layout: .split)
|
||||
|
||||
if showsFlowSessionExtras {
|
||||
flowSessionExtras
|
||||
}
|
||||
|
||||
widePreviewStage
|
||||
}
|
||||
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.bottom, Spacing.md)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background(palette.background)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if previewFocused {
|
||||
previewFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var wideHeroHeader: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text("home.wide.tagline")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
|
||||
Text("home.wide.tagline.subtitle")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var widePreviewStage: some View {
|
||||
WideCard(padding: Spacing.md, cornerRadius: Radius.large) {
|
||||
previewFieldContent
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: WideLayoutMetrics.dictationCanvasMinHeight,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
micStatus = AppPermissions.micStatus
|
||||
speechStatus = AppPermissions.speechStatus
|
||||
@@ -161,12 +247,15 @@ struct HomeView: View {
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var logoHeader: some View {
|
||||
VStack(spacing: Spacing.xxl) {
|
||||
// logo 尺寸保持 144:41 比例;小屏进一步缩小,给下方内容让空间。
|
||||
private func logoHeader(compact: Bool) -> some View {
|
||||
let logoWidth: CGFloat = compact ? 104 : 124
|
||||
let logoHeight = logoWidth * (41.0 / 144.0)
|
||||
return VStack(spacing: Spacing.xxl) {
|
||||
Image("osglogo")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 144, height: 41)
|
||||
.frame(width: logoWidth, height: logoHeight)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -187,7 +276,18 @@ struct HomeView: View {
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
Text("home.flow.recording")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
} else if flowManager.isUtteranceProcessing {
|
||||
Text("home.flow.processing")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
} else if flowManager.isActive,
|
||||
FlowSessionBridge.isHostReady(),
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text("home.flow.label")
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -322,10 +422,14 @@ struct HomeView: View {
|
||||
|
||||
private var flowStatusColor: Color {
|
||||
if needsCloudSetup { return palette.warning }
|
||||
if flowManager.isActive { return palette.accent }
|
||||
if flowManager.isUtteranceRecording { return palette.accent }
|
||||
if flowManager.isUtteranceProcessing { return palette.accent }
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() { return palette.accent }
|
||||
if flowManager.isStarting { return palette.accent }
|
||||
if needsPermissionSetup { return palette.warning }
|
||||
if flowManager.sessionWarning != nil { return palette.warning }
|
||||
// Active but not host-ready (e.g. mid-utterance / audio proof) — amber.
|
||||
if flowManager.isActive { return palette.warning }
|
||||
return palette.textTertiary
|
||||
}
|
||||
|
||||
@@ -337,33 +441,46 @@ struct HomeView: View {
|
||||
if flowManager.isStarting {
|
||||
return AppL10n.string("home.flow.starting")
|
||||
}
|
||||
if flowManager.isActive {
|
||||
if flowManager.isUtteranceRecording {
|
||||
return AppL10n.string("home.flow.recording")
|
||||
}
|
||||
if flowManager.isUtteranceProcessing {
|
||||
return AppL10n.string("home.flow.processing")
|
||||
}
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() {
|
||||
return AppL10n.string("home.flow.label")
|
||||
}
|
||||
if flowManager.isActive {
|
||||
// Session flag is up but the ready contract is not — do not lie.
|
||||
return AppL10n.string("home.flow.notReady")
|
||||
}
|
||||
return AppL10n.string("home.flow.inactive")
|
||||
}
|
||||
|
||||
// MARK: - Preview field
|
||||
|
||||
private var previewField: some View {
|
||||
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.tint(palette.accent)
|
||||
.focused($previewFocused)
|
||||
.lineLimit(1...100)
|
||||
.frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading)
|
||||
private func previewField(minHeight: CGFloat) -> some View {
|
||||
previewFieldContent
|
||||
.frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1)
|
||||
)
|
||||
// TextField only hit-tests the text line(s); expand taps to the full card.
|
||||
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.onTapGesture { previewFocused = true }
|
||||
}
|
||||
|
||||
private var previewFieldContent: some View {
|
||||
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.tint(palette.accent)
|
||||
.focused($previewFocused)
|
||||
.lineLimit(1...100)
|
||||
}
|
||||
|
||||
private var engineStatusLine: some View {
|
||||
Text(
|
||||
EngineServiceLabel.summary(
|
||||
|
||||
@@ -25,8 +25,10 @@ struct LocalModelsGroup: View {
|
||||
speechRow
|
||||
Divider().background(palette.divider)
|
||||
polishRow
|
||||
#if DEBUG
|
||||
Divider().background(palette.divider)
|
||||
customLanguageModelDiagnosticRow
|
||||
#endif
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
@@ -45,8 +47,7 @@ struct LocalModelsGroup: View {
|
||||
Spacer(minLength: Spacing.xs)
|
||||
engineBadge("settings.localModels.speechEngine")
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
// MARK: Polish row
|
||||
@@ -61,12 +62,12 @@ struct LocalModelsGroup: View {
|
||||
Spacer(minLength: Spacing.xs)
|
||||
engineBadge("settings.localModels.polishEngine")
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
// MARK: Custom language model diagnostic row
|
||||
// MARK: Custom language model diagnostic row (DEBUG builds only)
|
||||
|
||||
#if DEBUG
|
||||
private var customLanguageModelDiagnosticRow: some View {
|
||||
Toggle(isOn: $config.localASRCustomLanguageModelEnabled) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
@@ -79,10 +80,9 @@ struct LocalModelsGroup: View {
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
|
||||
@@ -39,14 +39,18 @@ struct MainAppRoot: View {
|
||||
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
flowManager.activateOnForeground()
|
||||
AppCloudSync.shared.startObservingExternalChanges()
|
||||
// Registering here also flushes any URL buffered during a cold
|
||||
// launch (the keyboard → app `startflow` handoff arrives via the
|
||||
// scene delegate before this view is on screen).
|
||||
// Register the URL handler BEFORE the foreground auto-start.
|
||||
// Registering flushes any URL buffered during a cold launch (the
|
||||
// keyboard → app `startflow` handoff arrives via the scene
|
||||
// delegate before this view is on screen), so a cold start takes
|
||||
// the cold-start path first and `activateOnForeground()`'s plain
|
||||
// start then no-ops on the isStarting guard — instead of two
|
||||
// start bodies racing each other on the main actor.
|
||||
AppOpenURLRouter.shared.register { url in
|
||||
handleIncomingURL(url)
|
||||
}
|
||||
flowManager.activateOnForeground()
|
||||
AppCloudSync.shared.startObservingExternalChanges()
|
||||
Task {
|
||||
await AppCloudSync.shared.pullAllIfEnabled()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,47 +6,52 @@ import OSGKeyboardShared
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@EnvironmentObject private var flowManager: FlowSessionManager
|
||||
|
||||
@State private var tab: AppTab = .keyboard
|
||||
@State private var isTabBarHidden = false
|
||||
|
||||
private var usesSplitLayout: Bool {
|
||||
horizontalSizeClass == .regular
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if usesSplitLayout {
|
||||
MainSplitView(selection: $tab)
|
||||
} else {
|
||||
phoneTabLayout
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
}
|
||||
|
||||
// MARK: - Phone layout
|
||||
|
||||
private var phoneTabLayout: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
palette.background.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch tab {
|
||||
case .keyboard:
|
||||
HomeView()
|
||||
case .history:
|
||||
HistoryView()
|
||||
case .dictionary:
|
||||
PersonalDictionaryView()
|
||||
case .settings:
|
||||
SettingsView(presentation: .tab)
|
||||
MainTabContent(tab: tab)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.environment(\.isTabBarVisible, !isTabBarHidden)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !isTabBarHidden {
|
||||
Color.clear.frame(height: 88)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.environment(\.isTabBarVisible, !isTabBarHidden)
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if !isTabBarHidden {
|
||||
Color.clear.frame(height: 88)
|
||||
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
|
||||
withAnimation(Motion.quick) {
|
||||
isTabBarHidden = hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
|
||||
withAnimation(Motion.quick) {
|
||||
isTabBarHidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
if !isTabBarHidden {
|
||||
MinimalTabBar(selection: $tab)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
// Keep home card/input/tab layout fixed when system keyboard appears.
|
||||
// Let the keyboard overlay the content instead of pushing it.
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// OnboardingView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Five-step onboarding:
|
||||
// Six-step onboarding:
|
||||
// 1) Welcome
|
||||
// 2) Microphone permission
|
||||
// 3) Speech recognition permission
|
||||
// 4) Enable keyboard + Allow Full Access
|
||||
// 5) Engine / API setup
|
||||
// 5) Engine / ASR setup
|
||||
// 6) Polish LLM setup
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -18,8 +19,9 @@ private enum OnboardingPage: Int, CaseIterable {
|
||||
case speech
|
||||
case keyboard
|
||||
case api
|
||||
case polish
|
||||
|
||||
static let count = 5
|
||||
static let count = 6
|
||||
}
|
||||
|
||||
struct OnboardingView: View {
|
||||
@@ -61,6 +63,8 @@ struct OnboardingView: View {
|
||||
EnableKeyboardPage()
|
||||
case .api:
|
||||
APISetupPage(config: config)
|
||||
case .polish:
|
||||
PolishSetupPage(config: config)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -234,14 +238,14 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue }
|
||||
private var isLastPage: Bool { config.onboardingPage == OnboardingPage.polish.rawValue }
|
||||
|
||||
private var canAdvance: Bool {
|
||||
switch currentPage {
|
||||
case .welcome, .keyboard:
|
||||
return !isLastPage || onboardingCompleteReady
|
||||
case .api:
|
||||
case .welcome, .keyboard, .api:
|
||||
return !isLastPage || onboardingCompleteReady
|
||||
case .polish:
|
||||
return onboardingCompleteReady
|
||||
case .microphone:
|
||||
return micStatus != .undetermined
|
||||
case .speech:
|
||||
@@ -250,9 +254,8 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private var onboardingCompleteReady: Bool {
|
||||
// v0.2.0: the local engine uses iOS `SpeechAnalyzer`, which is
|
||||
// always available — no model download gate. The cloud engine
|
||||
// still requires base URL + API key + model.
|
||||
// Local engine: built-in polish satisfies `isPolishConfigured`.
|
||||
// Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready.
|
||||
if config.isLocalEngine { return true }
|
||||
return config.isConfigured
|
||||
}
|
||||
@@ -364,7 +367,6 @@ private struct OnboardingTitleBlock: View {
|
||||
// MARK: - Welcome
|
||||
|
||||
private struct WelcomePage: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@State private var logoAppeared = false
|
||||
|
||||
var body: some View {
|
||||
@@ -386,26 +388,13 @@ private struct WelcomePage: View {
|
||||
}
|
||||
|
||||
OnboardingTitleBlock(
|
||||
title: "onboarding.welcome.tagline",
|
||||
subtitle: "onboarding.welcome.subtitle",
|
||||
secondarySubtitle: "onboarding.welcome.subtitle2"
|
||||
title: "onboarding.welcome.tagline"
|
||||
)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.offset(y: logoAppeared ? 0 : 10)
|
||||
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
|
||||
.animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared)
|
||||
|
||||
if let url = LegalLinks.privacyPolicyURL {
|
||||
Link(destination: url) {
|
||||
Text("legal.privacyPolicy")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.padding(.top, Spacing.xxxl)
|
||||
.opacity(logoAppeared ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.45).delay(0.28), value: logoAppeared)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -708,7 +697,7 @@ private struct EnableKeyboardPage: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API setup
|
||||
// MARK: - Engine / ASR setup
|
||||
|
||||
private struct APISetupPage: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@@ -728,16 +717,60 @@ private struct APISetupPage: View {
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
if config.engineMode == "cloud" {
|
||||
ProviderPickerSection(config: config)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
APISettingsCard(config: config)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
VStack(spacing: 0) {
|
||||
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
|
||||
Divider().background(palette.divider)
|
||||
ASRSettingsCard(config: config, showsSurface: false)
|
||||
}
|
||||
.modifier(SettingsSurfaceCardModifier(enabled: true))
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
} else {
|
||||
// Local engine: built-in ASR + built-in polish (no API card).
|
||||
LocalModelsGroup(config: config)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
Text("onboarding.api.localModels.hint")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.bottom, Spacing.xxxl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Polish LLM setup
|
||||
|
||||
private struct PolishSetupPage: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Spacing.lg) {
|
||||
OnboardingHeroIcon(systemName: "wand.and.stars", circleSize: 72, iconSize: 30)
|
||||
.padding(.top, Spacing.xl)
|
||||
|
||||
OnboardingTitleBlock(
|
||||
title: "onboarding.polish.title",
|
||||
subtitle: "onboarding.polish.subtitle"
|
||||
)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
if config.isLocalEngine {
|
||||
Text("onboarding.polish.localHint")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
|
||||
Divider().background(palette.divider)
|
||||
APISettingsCard(config: config, showsSurface: false)
|
||||
}
|
||||
.modifier(SettingsSurfaceCardModifier(enabled: true))
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
}
|
||||
.padding(.bottom, Spacing.xxxl)
|
||||
}
|
||||
|
||||
@@ -64,8 +64,7 @@ struct OpenSourceLicensesView: View {
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, 10)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,7 @@ struct PersonalDictionaryEntrySheet: View {
|
||||
.focused($termFocused)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// PersonalDictionaryICloudSyncRow.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Settings-row toggle for mirroring the personal dictionary through
|
||||
// iCloud Key-Value Store. Lives in Settings → 词库与润色.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
struct PersonalDictionaryICloudSyncRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@State private var isEnabled: Bool = AppGroupStore().personalDictionaryICloudSyncEnabled
|
||||
@State private var syncErrorMessage: String?
|
||||
@State private var isApplyingToggle = false
|
||||
|
||||
private let store = AppGroupStore()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Toggle(isOn: toggleBinding) {
|
||||
Text("settings.personalDictionary.iCloudSync.settingsTitle")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
|
||||
.onAppear { reloadFromStore() }
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
|
||||
) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
}
|
||||
|
||||
private var toggleBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { isEnabled },
|
||||
set: { newValue in
|
||||
guard newValue != isEnabled else { return }
|
||||
if newValue {
|
||||
enableSync()
|
||||
} else {
|
||||
disableSync()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func reloadFromStore() {
|
||||
isEnabled = store.personalDictionaryICloudSyncEnabled
|
||||
}
|
||||
|
||||
private func enableSync() {
|
||||
isApplyingToggle = true
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await PersonalDictionaryCloudSync.shared.enableSync()
|
||||
reloadFromStore()
|
||||
} catch let error as PersonalDictionaryCloudSyncError {
|
||||
isEnabled = false
|
||||
syncErrorMessage = localizedSyncError(error)
|
||||
} catch {
|
||||
isEnabled = false
|
||||
syncErrorMessage = error.localizedDescription
|
||||
}
|
||||
isApplyingToggle = false
|
||||
}
|
||||
}
|
||||
|
||||
private func disableSync() {
|
||||
PersonalDictionaryCloudSync.shared.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = nil
|
||||
}
|
||||
|
||||
private func localizedSyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
|
||||
switch error {
|
||||
case .payloadTooLarge:
|
||||
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
|
||||
case .encodeFailed, .decodeFailed:
|
||||
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,8 +170,7 @@ struct PersonalDictionaryView: View {
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -8,79 +8,100 @@ struct ProviderPickerSection: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var config: ProviderConfig
|
||||
var role: CloudProviderRole = .polish
|
||||
/// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。
|
||||
var showsSurface: Bool = true
|
||||
|
||||
private var selectedProviderId: String {
|
||||
role == .asr ? config.asrProviderId : config.providerId
|
||||
}
|
||||
|
||||
private var visiblePresets: [LLMProvider] {
|
||||
role == .asr
|
||||
? LLMProvider.asrSelectablePresets
|
||||
: LLMProvider.userSelectablePresets
|
||||
}
|
||||
|
||||
private var selectedProvider: LLMProvider {
|
||||
visiblePresets.first(where: { $0.id == selectedProviderId })
|
||||
?? LLMProvider.provider(id: selectedProviderId)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// v0.2.1 follow-up: filter out presets marked as
|
||||
// `isUserSelectable == false` (DeepSeek is local-engine only).
|
||||
let visiblePresets = LLMProvider.userSelectablePresets
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in
|
||||
Button {
|
||||
select(provider)
|
||||
} label: {
|
||||
row(provider, selected: provider.id == config.providerId)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < visiblePresets.count - 1 {
|
||||
Divider().background(palette.divider)
|
||||
// 只有右侧芯片是 Menu 的 label;标题留在行外,避免菜单弹出时
|
||||
// 把整行 label 一起隐藏,导致左侧「供应商」文字消失。
|
||||
SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) {
|
||||
Menu {
|
||||
ForEach(visiblePresets, id: \.id) { provider in
|
||||
Button {
|
||||
select(provider)
|
||||
} label: {
|
||||
let name = ProviderDisplayName.name(for: provider.id)
|
||||
if provider.id == selectedProviderId {
|
||||
Label(name, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
providerChip
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
|
||||
}
|
||||
|
||||
private func select(_ provider: LLMProvider) {
|
||||
withAnimation(Motion.quick) {
|
||||
config.apply(preset: provider)
|
||||
switch role {
|
||||
case .polish:
|
||||
config.apply(preset: provider)
|
||||
case .asr:
|
||||
config.applyAsr(preset: provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
|
||||
/// 收起状态展示:当前所选供应商 logo + 名称 + 展开箭头。
|
||||
private var providerChip: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
providerMark(provider, selected: selected)
|
||||
providerMark(selectedProvider)
|
||||
|
||||
Text(ProviderDisplayName.name(for: provider.id))
|
||||
Text(ProviderDisplayName.name(for: selectedProvider.id))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
if provider.supportsPersonalDictionaryCloudASR {
|
||||
if selectedProvider.supportsPersonalDictionaryCloudASR {
|
||||
personalDictionaryBadge
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
if selected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View {
|
||||
private func providerMark(_ provider: LLMProvider) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(selected ? palette.accentMuted : palette.surfaceElevated)
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 32, height: 32)
|
||||
if let asset = ProviderLogo.assetName(for: provider.id) {
|
||||
Image(asset)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundStyle(selected ? palette.accent : palette.textPrimary)
|
||||
.foregroundStyle(palette.accent)
|
||||
} else {
|
||||
Text(String(provider.name.prefix(1)))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(selected ? palette.accent : palette.textSecondary)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// SettingsCardChrome.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Shared rounded surface chrome for settings list cards.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct SettingsSurfaceCardModifier: ViewModifier {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let enabled: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if enabled {
|
||||
content
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ struct SettingsICloudSyncRow: View {
|
||||
@State private var syncErrorMessage: String?
|
||||
@State private var isApplyingToggle = false
|
||||
@State private var isSyncingNow = false
|
||||
/// Transient success flag: shows a brief "已同步" confirmation so a fast
|
||||
/// sync gives visible feedback instead of a spinner that flashes once.
|
||||
@State private var showSyncedConfirmation = false
|
||||
|
||||
private let store = AppGroupStore()
|
||||
|
||||
@@ -38,13 +41,21 @@ struct SettingsICloudSyncRow: View {
|
||||
syncNow()
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text(syncButtonTitleKey)
|
||||
.font(TypeStyle.caption)
|
||||
Spacer(minLength: 0)
|
||||
if isSyncingNow {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.controlSize(.mini)
|
||||
} else if showSyncedConfirmation {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
Text("settings.appSettings.iCloudSync.syncNow")
|
||||
.font(TypeStyle.caption)
|
||||
}
|
||||
// Fill the row so the whole strip is tappable, not just
|
||||
// the caption glyphs (previously easy to miss).
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
@@ -60,15 +71,18 @@ struct SettingsICloudSyncRow: View {
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
|
||||
.settingsListRow(alignment: .leading)
|
||||
.onAppear { reloadFromStore() }
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
|
||||
) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
|
||||
) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
}
|
||||
|
||||
private var toggleBinding: Binding<Bool> {
|
||||
@@ -85,6 +99,16 @@ struct SettingsICloudSyncRow: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var syncButtonTitleKey: LocalizedStringKey {
|
||||
if isSyncingNow {
|
||||
return "settings.appSettings.iCloudSync.syncing"
|
||||
}
|
||||
if showSyncedConfirmation {
|
||||
return "settings.appSettings.iCloudSync.synced"
|
||||
}
|
||||
return "settings.appSettings.iCloudSync.syncNow"
|
||||
}
|
||||
|
||||
private func reloadFromStore() {
|
||||
isEnabled = store.settingsICloudSyncEnabled
|
||||
}
|
||||
@@ -95,6 +119,15 @@ struct SettingsICloudSyncRow: View {
|
||||
Task {
|
||||
do {
|
||||
try await CloudSyncContext.shared.settingsSyncService.enableSync()
|
||||
do {
|
||||
try await CloudSyncContext.shared.dictionarySyncService.enableSync()
|
||||
} catch let error as PersonalDictionaryCloudSyncError {
|
||||
CloudSyncContext.shared.settingsSyncService.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = localizedDictionarySyncError(error)
|
||||
isApplyingToggle = false
|
||||
return
|
||||
}
|
||||
reloadFromStore()
|
||||
} catch let error as SettingsCloudSyncError {
|
||||
isEnabled = false
|
||||
@@ -114,15 +147,23 @@ struct SettingsICloudSyncRow: View {
|
||||
}
|
||||
|
||||
private func syncNow() {
|
||||
guard !isSyncingNow else { return }
|
||||
isSyncingNow = true
|
||||
showSyncedConfirmation = false
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await CloudSyncContext.shared.syncNow()
|
||||
isSyncingNow = false
|
||||
withAnimation { showSyncedConfirmation = true }
|
||||
// Auto-dismiss the confirmation so the label returns to
|
||||
// its default "立即同步" state.
|
||||
try? await Task.sleep(nanoseconds: 1_800_000_000)
|
||||
withAnimation { showSyncedConfirmation = false }
|
||||
} catch {
|
||||
isSyncingNow = false
|
||||
syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic")
|
||||
}
|
||||
isSyncingNow = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,4 +173,13 @@ struct SettingsICloudSyncRow: View {
|
||||
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedDictionarySyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
|
||||
switch error {
|
||||
case .payloadTooLarge:
|
||||
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
|
||||
case .encodeFailed, .decodeFailed:
|
||||
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
// SettingsProviderControls.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// OpenLess-style setting rows for provider credentials and tools. Compact iOS
|
||||
// stacks label above control; regular-width iPad keeps label/control in one row.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct SettingsProviderRow<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
let title: String
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
if horizontalSizeClass == .compact {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
label
|
||||
content()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.settingsListRow()
|
||||
} else {
|
||||
HStack(alignment: .center, spacing: Spacing.lg) {
|
||||
label
|
||||
.frame(width: 150, alignment: .leading)
|
||||
content()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.settingsListRow()
|
||||
}
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
Text(title)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsCredentialRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let title: String
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
var isSecret: Bool = false
|
||||
var isMonospaced: Bool = false
|
||||
var defaultValue: String?
|
||||
var trailing: AnyView?
|
||||
|
||||
@State private var revealed = false
|
||||
|
||||
var body: some View {
|
||||
SettingsProviderRow(title: title) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
input
|
||||
if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
iconButton(systemName: "checkmark", label: "settings.provider.fillDefault") {
|
||||
text = defaultValue
|
||||
}
|
||||
}
|
||||
if let trailing {
|
||||
trailing
|
||||
}
|
||||
if isSecret {
|
||||
iconButton(systemName: revealed ? "eye.slash" : "eye", label: revealed ? "api.key.hide" : "api.key.show") {
|
||||
revealed.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var input: some View {
|
||||
Group {
|
||||
if isSecret && !revealed {
|
||||
SecureField(placeholder, text: $text)
|
||||
} else {
|
||||
TextField(placeholder, text: $text)
|
||||
}
|
||||
}
|
||||
.keyboardType(.asciiCapable)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(isMonospaced ? TypeStyle.mono : TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 38)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Editable model field + fetch icon
|
||||
|
||||
/// Model id: type freely, or fetch then pick from the trailing dropdown.
|
||||
struct SettingsModelPickerRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let title: String
|
||||
let placeholder: String
|
||||
@Binding var model: String
|
||||
let fetchModels: () async throws -> [String]
|
||||
|
||||
@State private var models: [String] = []
|
||||
@State private var isRunning = false
|
||||
@State private var message: String?
|
||||
@State private var failed = false
|
||||
|
||||
private let controlHeight: CGFloat = 38
|
||||
|
||||
var body: some View {
|
||||
SettingsProviderRow(title: title) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
comboField
|
||||
refreshButton
|
||||
}
|
||||
|
||||
if let message {
|
||||
Text(message)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(failed ? palette.danger : palette.accent)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Editable model id + trailing menu chevron in one well (same chrome as
|
||||
/// Mac `MacPickerFieldBox`). Chevron is overlaid so it stays inside the border.
|
||||
private var comboField: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
let chevronWidth: CGFloat = 28
|
||||
return ZStack(alignment: .trailing) {
|
||||
TextField(placeholder, text: $model)
|
||||
.keyboardType(.asciiCapable)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(TypeStyle.mono)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.leading, Spacing.sm)
|
||||
.padding(.trailing, chevronWidth + Spacing.sm)
|
||||
.frame(maxWidth: .infinity, minHeight: controlHeight, alignment: .leading)
|
||||
.background(palette.surfaceElevated, in: shape)
|
||||
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||
|
||||
Menu {
|
||||
if models.isEmpty {
|
||||
Button(AppL10n.string("settings.provider.modelsEmptyHint")) {}
|
||||
.disabled(true)
|
||||
} else {
|
||||
ForEach(models, id: \.self) { modelId in
|
||||
Button {
|
||||
model = modelId
|
||||
message = AppL10n.format("settings.provider.modelSelected", modelId)
|
||||
failed = false
|
||||
} label: {
|
||||
if modelId == model {
|
||||
Label(modelId, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(modelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(width: chevronWidth, height: controlHeight)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.padding(.trailing, Spacing.sm)
|
||||
.accessibilityLabel(AppL10n.string("settings.provider.selectModel"))
|
||||
}
|
||||
}
|
||||
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
Task { await runFetchModels() }
|
||||
} label: {
|
||||
Group {
|
||||
if isRunning {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
} else {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
.frame(width: controlHeight, height: controlHeight)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isRunning)
|
||||
.accessibilityLabel(AppL10n.string("settings.provider.fetchModels"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runFetchModels() async {
|
||||
isRunning = true
|
||||
failed = false
|
||||
defer { isRunning = false }
|
||||
|
||||
let outcome = await ProviderToolRunner.runFetchModels(
|
||||
runningMessage: AppL10n.string("settings.provider.loadingModels"),
|
||||
loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) },
|
||||
emptyMessage: SharedL10n.string("providerTools.error.empty"),
|
||||
currentModel: model,
|
||||
fetchModels: fetchModels
|
||||
)
|
||||
models = outcome.state.models
|
||||
message = outcome.state.message
|
||||
failed = outcome.state.failed
|
||||
if let selected = outcome.selectedModel {
|
||||
model = selected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection validate only
|
||||
|
||||
struct SettingsProviderToolsRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let validate: () async throws -> Void
|
||||
|
||||
@State private var isRunning = false
|
||||
@State private var message: String?
|
||||
@State private var failed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
Text(AppL10n.string("settings.provider.tools"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
if isRunning {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
} else if let message {
|
||||
Text(message)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(failed ? palette.danger : palette.accent)
|
||||
.lineLimit(3)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button {
|
||||
Task { await runValidate() }
|
||||
} label: {
|
||||
Text(AppL10n.string("settings.provider.validate"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: 34)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isRunning)
|
||||
}
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runValidate() async {
|
||||
isRunning = true
|
||||
failed = false
|
||||
defer { isRunning = false }
|
||||
|
||||
let outcome = await ProviderToolRunner.runValidate(
|
||||
runningMessage: AppL10n.string("api.test.running"),
|
||||
successMessage: AppL10n.string("api.test.success"),
|
||||
validate: validate
|
||||
)
|
||||
message = outcome.message
|
||||
failed = outcome.failed
|
||||
}
|
||||
}
|
||||
@@ -43,20 +43,13 @@ struct SettingsView: View {
|
||||
dictionaryAndPolishSection
|
||||
flowSessionSection
|
||||
engineSection
|
||||
// v0.2.1: hide provider/api card when the
|
||||
// local engine is active regardless of the
|
||||
// cloud-polish toggle. Local mode is
|
||||
// contractually ASR-only, so provider/model/
|
||||
// base URL/API key controls have no use —
|
||||
// and exposing them invites the user to fill
|
||||
// out a DeepSeek key they can't use.
|
||||
if config.engineMode == "cloud" {
|
||||
providerSection
|
||||
apiSection
|
||||
asrSettingsSection
|
||||
}
|
||||
if config.engineMode == "local" {
|
||||
localEngineSettingsSection
|
||||
}
|
||||
polishSettingsSection
|
||||
if presentation == .tab {
|
||||
footerLinks
|
||||
}
|
||||
@@ -120,9 +113,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
@@ -227,10 +218,6 @@ struct SettingsView: View {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
PersonalDictionaryICloudSyncRow()
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||
.overlay(
|
||||
@@ -253,22 +240,38 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var providerSection: some View {
|
||||
private var polishSettingsSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.provider.title")
|
||||
ProviderPickerSection(config: config)
|
||||
sectionHeader("settings.polishProvider.title")
|
||||
cloudProviderSettingsCard {
|
||||
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
|
||||
Divider().background(palette.divider)
|
||||
APISettingsCard(config: config, showsSurface: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
|
||||
private var apiSection: some View {
|
||||
private var asrSettingsSection: some View {
|
||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
sectionHeader("settings.api.title")
|
||||
APISettingsCard(config: config)
|
||||
sectionHeader("settings.asrProvider.title")
|
||||
cloudProviderSettingsCard {
|
||||
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
|
||||
Divider().background(palette.divider)
|
||||
ASRSettingsCard(config: config, showsSurface: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cloudProviderSettingsCard<Content: View>(
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
content()
|
||||
}
|
||||
.modifier(SettingsSurfaceCardModifier(enabled: true))
|
||||
}
|
||||
|
||||
// MARK: - Language helpers
|
||||
|
||||
/// Falls back to a static list while dynamic locales are loading.
|
||||
@@ -337,8 +340,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
// MARK: - Footer links (tab settings only)
|
||||
@@ -360,8 +362,7 @@ struct SettingsView: View {
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -417,8 +418,7 @@ struct SettingsView: View {
|
||||
MaterialIcon(name: .openInNew, size: 18)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -438,8 +438,7 @@ struct SettingsView: View {
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
@@ -581,8 +580,7 @@ private struct PickerRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
@@ -639,8 +637,7 @@ private struct LocalePickerRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
}
|
||||
|
||||
private func label(for localeId: String) -> String {
|
||||
|
||||
@@ -69,13 +69,10 @@ struct TranslationPickerRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
.settingsListRow()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection plumbing
|
||||
|
||||
/// Currently selected id — the picker always reads
|
||||
/// `translationTargetLocaleId` directly (the previous
|
||||
/// `translationEnabled` boolean is now derived from it).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Permission prompts — English */
|
||||
|
||||
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard transcribes your voice with on-device speech recognition by default. If you explicitly switch to a cloud engine in Settings, recordings are sent to the ASR provider you configure.";
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
/* Onboarding */
|
||||
"onboarding.progress" = "Step %1$d of %2$d";
|
||||
"onboarding.welcome.tagline" = "Speak instead of type";
|
||||
"onboarding.welcome.subtitle" = "Open-source voice keyboard";
|
||||
"onboarding.welcome.subtitle2" = "We never read or upload anything you type.";
|
||||
"onboarding.welcome.tagline" = "Speak it. It’s typed.";
|
||||
"onboarding.permission.preface" = "iOS will ask for two permissions — please allow both.";
|
||||
"onboarding.permission.mic.title" = "Microphone";
|
||||
"onboarding.permission.mic.body" = "Records your voice.";
|
||||
@@ -29,7 +27,10 @@
|
||||
"onboarding.enable.step3.suffix" = "and select OSGKeyboard";
|
||||
"onboarding.enable.openSettings" = "Open Settings";
|
||||
"onboarding.api.title" = "Choose Engine";
|
||||
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition and built-in polish — no API key needed.";
|
||||
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition — no API key needed.";
|
||||
"onboarding.polish.title" = "Text polish (LLM)";
|
||||
"onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider.";
|
||||
"onboarding.polish.localHint" = "Built-in polish is included. Add your own API key here to override.";
|
||||
"settings.onboarding.replay" = "Restart permission setup";
|
||||
|
||||
/* Common navigation */
|
||||
@@ -44,6 +45,7 @@
|
||||
"common.delete" = "Delete";
|
||||
"common.space" = "Space";
|
||||
"common.newline" = "Return";
|
||||
"common.send" = "Send";
|
||||
|
||||
/* Privacy footnote (onboarding engine page) */
|
||||
"privacy.audio.title" = "On-device transcription";
|
||||
@@ -92,17 +94,45 @@
|
||||
"settings.reset.title" = "Reset all settings?";
|
||||
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
|
||||
"settings.reset.confirm" = "Reset all settings";
|
||||
"settings.engine.title" = "Recognition method";
|
||||
"settings.engine.subtitle" = "Local: on-device ASR only. Cloud: ASR + polish via your API.";
|
||||
"settings.engine.local.title" = "On-device recognition";
|
||||
"settings.engine.title" = "Speech transcription method";
|
||||
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
|
||||
"settings.engine.local.title" = "On-device transcription";
|
||||
"settings.engine.local.ios26" = "Always on-device, no network.";
|
||||
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
|
||||
"settings.engine.cloud.title" = "Cloud recognition & polish";
|
||||
"settings.engine.cloud.subtitle" = "Cloud ASR (with your dictionary) + API polish. Audio is sent to your provider.";
|
||||
"settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
|
||||
"settings.engine.cloud.title" = "Cloud transcription";
|
||||
"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
|
||||
"settings.engine.cloud.badge" = "Cloud engine";
|
||||
"settings.provider.title" = "Provider";
|
||||
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
|
||||
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
|
||||
"settings.polishProvider.title" = "Text polish (LLM)";
|
||||
"settings.polishProvider.subtitle" = "Cleans up the transcript after recognition. Independent from the ASR provider.";
|
||||
"settings.polishApi.title" = "Polish API";
|
||||
"settings.asrProvider.title" = "Speech recognition (ASR)";
|
||||
"settings.asrProvider.subtitle" = "Transcribes your audio in cloud mode. Can differ from the polish provider.";
|
||||
"settings.asrApi.title" = "ASR API";
|
||||
"settings.asr.model" = "ASR model";
|
||||
"settings.asr.testConnection" = "Test ASR";
|
||||
"settings.provider.supplier" = "Provider";
|
||||
"settings.provider.tools" = "Connection check";
|
||||
"settings.provider.validate" = "Validate";
|
||||
"settings.provider.fetchModels" = "Fetch models";
|
||||
"settings.provider.loadingModels" = "Loading models…";
|
||||
"settings.provider.modelsLoaded" = "%lld models loaded";
|
||||
"settings.provider.selectModel" = "Select model";
|
||||
"settings.provider.modelSelected" = "Selected %@";
|
||||
"settings.provider.modelsEmptyHint" = "Tap refresh to load models";
|
||||
"settings.provider.fillDefault" = "Fill default";
|
||||
"settings.provider.thinking" = "Thinking";
|
||||
"settings.provider.thinkingSubtitle" = "Slower, higher quality — recommended off";
|
||||
"settings.provider.thinkingOn" = "On";
|
||||
"settings.provider.thinkingOff" = "Off";
|
||||
"settings.provider.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning.";
|
||||
"settings.provider.note" = "Note";
|
||||
"settings.asr.volcengine.appId" = "APP ID";
|
||||
"settings.asr.volcengine.accessToken" = "Access Token";
|
||||
"settings.asr.volcengine.resourceId" = "Resource ID";
|
||||
"settings.asr.volcengine.note" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
|
||||
"provider.openai" = "OpenAI";
|
||||
"provider.deepseek" = "DeepSeek";
|
||||
"provider.qwen" = "Qwen (DashScope)";
|
||||
@@ -161,8 +191,8 @@
|
||||
"settings.privacy.fullAccess.title" = "About Full Access";
|
||||
"settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard.";
|
||||
"settings.privacy.cloud.body" = "In Cloud polish mode, transcribed text is sent to the API endpoint you configure (e.g. OpenAI or your own server). OSGKeyboard does not operate servers and does not store transcripts in the cloud.";
|
||||
"settings.privacy.cloud.alert.title" = "Third-party API";
|
||||
"settings.privacy.cloud.alert.message" = "Cloud polish sends transcribed text to the third-party API you configure. OSGKeyboard never stores data on our servers. Continue?";
|
||||
"settings.privacy.cloud.alert.title" = "Audio leaves your device";
|
||||
"settings.privacy.cloud.alert.message" = "The cloud engine uploads your voice recordings to the third-party ASR provider you configure, and sends the transcript to its API for polish. That provider's privacy policy applies. OSGKeyboard never stores data on our servers. Continue?";
|
||||
"settings.link.support" = "Help & Feedback";
|
||||
"settings.link.github" = "GitHub";
|
||||
"settings.support.footer" = "OSGKeyboard is open source. Report bugs and share ideas on GitHub.";
|
||||
@@ -287,6 +317,8 @@
|
||||
"home.flow.label" = "Ready";
|
||||
"home.flow.inactive" = "Voice session inactive";
|
||||
"home.flow.notReady" = "Not ready";
|
||||
"home.flow.recording" = "Recording…";
|
||||
"home.flow.processing" = "Processing…";
|
||||
"home.flow.hint" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.setup.permission.mic" = "Microphone access is off — voice input won't work.";
|
||||
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
|
||||
@@ -303,10 +335,11 @@
|
||||
"home.flow.startShort" = "Start";
|
||||
"home.preview.label" = "Try typing";
|
||||
"home.preview.placeholder" = "Tap to type and test…";
|
||||
"home.stats.dictationDuration" = "Dictation time";
|
||||
"home.stats.dictationCharacters" = "Dictation chars";
|
||||
"home.stats.translationCharacters" = "Translation chars";
|
||||
"home.stats.dictionaryEntries" = "Dictionary";
|
||||
"home.wide.tagline" = "Voice dictation, anywhere.";
|
||||
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
|
||||
"home.wide.mode.cloud" = "Cloud";
|
||||
"home.wide.mode.local" = "On-device";
|
||||
"home.wide.devices" = "iPhone & iPad";
|
||||
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML requires iOS 18+";
|
||||
"home.engine.warming" = "Loading ASR model into memory…";
|
||||
"home.engine.downloading" = "Downloading ASR model…";
|
||||
@@ -368,10 +401,12 @@
|
||||
"settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again.";
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
|
||||
|
||||
"settings.iCloudSync.title" = "iCloud Sync";
|
||||
"settings.appSettings.iCloudSync.title" = "iCloud Sync";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud. API keys use your private iCloud Keychain.";
|
||||
"settings.iCloudSync.title" = "Cross-Device iCloud Sync";
|
||||
"settings.appSettings.iCloudSync.title" = "Cross-Device iCloud Sync";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "Sync settings, history, and API keys across devices via iCloud.";
|
||||
"settings.appSettings.iCloudSync.syncNow" = "Sync Now";
|
||||
"settings.appSettings.iCloudSync.syncing" = "Syncing…";
|
||||
"settings.appSettings.iCloudSync.synced" = "Synced";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
|
||||
|
||||
/* v0.3.0: Polish intensity */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 权限说明 — 简体中文 */
|
||||
|
||||
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,并在语音会话运行期间保持后台音频会话。";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 使用设备端语音识别将你的语音转为文字。音频仅在设备上处理,不会上传用于转写。";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
/* Onboarding */
|
||||
"onboarding.progress" = "第 %1$d / %2$d 步";
|
||||
"onboarding.welcome.tagline" = "能说就不打字";
|
||||
"onboarding.welcome.subtitle" = "开源语音输入法";
|
||||
"onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。";
|
||||
"onboarding.welcome.tagline" = "开口即文字。";
|
||||
"onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。";
|
||||
"onboarding.permission.mic.title" = "麦克风";
|
||||
"onboarding.permission.mic.body" = "用于录制语音。";
|
||||
@@ -29,7 +27,10 @@
|
||||
"onboarding.enable.step3.suffix" = ",选中 OSGKeyboard";
|
||||
"onboarding.enable.openSettings" = "去设置";
|
||||
"onboarding.api.title" = "选择语音转文字 AI 引擎";
|
||||
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别与内置润色,无需填写 API Key。";
|
||||
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别,无需填写 API Key。";
|
||||
"onboarding.polish.title" = "文本润色(LLM)";
|
||||
"onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
|
||||
"onboarding.polish.localHint" = "已包含内置润色。在此填入 API Key 可替换为自定义模型。";
|
||||
"settings.onboarding.replay" = "重新开始权限引导";
|
||||
|
||||
/* Common navigation */
|
||||
@@ -43,7 +44,8 @@
|
||||
"common.clear" = "清空";
|
||||
"common.delete" = "删除";
|
||||
"common.space" = "空格";
|
||||
"common.newline" = "换行";
|
||||
"common.newline" = "回车";
|
||||
"common.send" = "发送";
|
||||
|
||||
/* Privacy footnote (onboarding engine page) */
|
||||
"privacy.audio.title" = "支持本地转写";
|
||||
@@ -92,17 +94,45 @@
|
||||
"settings.reset.title" = "重置所有设置?";
|
||||
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
|
||||
"settings.reset.confirm" = "重置所有设置";
|
||||
"settings.engine.title" = "识别方式";
|
||||
"settings.engine.subtitle" = "本地:端侧识别,仅转录;云端:通过 API 润色。";
|
||||
"settings.engine.local.title" = "本地识别";
|
||||
"settings.engine.title" = "语音转写方式";
|
||||
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
|
||||
"settings.engine.local.title" = "本地转写";
|
||||
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
|
||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||
"settings.engine.cloud.title" = "云端识别与润色";
|
||||
"settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务";
|
||||
"settings.engine.local.legacy" = "通过系统内置服务语音转写";
|
||||
"settings.engine.cloud.title" = "云端转写";
|
||||
"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
|
||||
"settings.engine.cloud.badge" = "云端引擎";
|
||||
"settings.provider.title" = "云端引擎";
|
||||
"settings.provider.personalDictionaryBadge" = "个性词库";
|
||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||
"settings.polishProvider.title" = "文本润色(LLM)";
|
||||
"settings.polishProvider.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。";
|
||||
"settings.polishApi.title" = "润色接口";
|
||||
"settings.asrProvider.title" = "语音转写(ASR)";
|
||||
"settings.asrProvider.subtitle" = "云端模式下负责听写转文字,可与润色模型分开配置。";
|
||||
"settings.asrApi.title" = "转写接口";
|
||||
"settings.asr.model" = "ASR 模型";
|
||||
"settings.asr.testConnection" = "测试转写";
|
||||
"settings.provider.supplier" = "供应商";
|
||||
"settings.provider.tools" = "连接检查";
|
||||
"settings.provider.validate" = "验证";
|
||||
"settings.provider.fetchModels" = "拉取模型";
|
||||
"settings.provider.loadingModels" = "正在拉取模型…";
|
||||
"settings.provider.modelsLoaded" = "已拉取 %lld 个模型";
|
||||
"settings.provider.selectModel" = "选择模型";
|
||||
"settings.provider.modelSelected" = "已选择 %@";
|
||||
"settings.provider.modelsEmptyHint" = "先点右侧刷新拉取模型";
|
||||
"settings.provider.fillDefault" = "填入默认值";
|
||||
"settings.provider.thinking" = "思考";
|
||||
"settings.provider.thinkingSubtitle" = "速度更慢、质量更高,建议关闭";
|
||||
"settings.provider.thinkingOn" = "开启";
|
||||
"settings.provider.thinkingOff" = "关闭";
|
||||
"settings.provider.thinkingHint" = "默认关闭。仅在需要更慢、更深的推理时开启。";
|
||||
"settings.provider.note" = "提示";
|
||||
"settings.asr.volcengine.appId" = "APP ID";
|
||||
"settings.asr.volcengine.accessToken" = "Access Token";
|
||||
"settings.asr.volcengine.resourceId" = "Resource ID";
|
||||
"settings.asr.volcengine.note" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
|
||||
"provider.openai" = "OpenAI";
|
||||
"provider.deepseek" = "DeepSeek";
|
||||
"provider.qwen" = "通义千问";
|
||||
@@ -161,8 +191,8 @@
|
||||
"settings.privacy.fullAccess.title" = "关于完全访问";
|
||||
"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。";
|
||||
"settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。";
|
||||
"settings.privacy.cloud.alert.title" = "第三方 API";
|
||||
"settings.privacy.cloud.alert.message" = "云端润色会把转写文字发到你配置的第三方 API。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
|
||||
"settings.privacy.cloud.alert.title" = "音频将离开你的设备";
|
||||
"settings.privacy.cloud.alert.message" = "云端引擎会把你的语音录音上传到你配置的第三方识别服务,并把转写文字发送到其 API 进行润色,适用该服务商的隐私政策。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
|
||||
"settings.link.support" = "帮助与反馈";
|
||||
"settings.link.github" = "GitHub";
|
||||
"settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。";
|
||||
@@ -286,6 +316,8 @@
|
||||
"home.flow.label" = "就绪";
|
||||
"home.flow.inactive" = "语音会话未启动";
|
||||
"home.flow.notReady" = "未就绪";
|
||||
"home.flow.recording" = "录音中…";
|
||||
"home.flow.processing" = "处理中…";
|
||||
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
|
||||
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
|
||||
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
|
||||
@@ -302,10 +334,11 @@
|
||||
"home.flow.startShort" = "开启";
|
||||
"home.preview.label" = "输入测试";
|
||||
"home.preview.placeholder" = "点这里试试键盘";
|
||||
"home.stats.dictationDuration" = "听写时长";
|
||||
"home.stats.dictationCharacters" = "听写字数";
|
||||
"home.stats.translationCharacters" = "翻译字数";
|
||||
"home.stats.dictionaryEntries" = "个性词库";
|
||||
"home.wide.tagline" = "随处语音听写";
|
||||
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
|
||||
"home.wide.mode.cloud" = "云端";
|
||||
"home.wide.mode.local" = "本机";
|
||||
"home.wide.devices" = "iPhone 与 iPad";
|
||||
"home.engine.unsupportedOS" = "Qwen3-ASR CoreML 需要 iOS 18 或更高版本";
|
||||
"home.engine.warming" = "正在加载语音识别模型…";
|
||||
"home.engine.downloading" = "正在下载语音识别模型…";
|
||||
@@ -367,10 +400,12 @@
|
||||
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
|
||||
|
||||
"settings.iCloudSync.title" = "iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.title" = "iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。";
|
||||
"settings.iCloudSync.title" = "跨设备iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.title" = "跨设备iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 跨设备同步设置、历史记录、API Key";
|
||||
"settings.appSettings.iCloudSync.syncNow" = "立即同步";
|
||||
"settings.appSettings.iCloudSync.syncing" = "同步中…";
|
||||
"settings.appSettings.iCloudSync.synced" = "已同步";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
|
||||
|
||||
/* v0.3.0: 润色强度 */
|
||||
|
||||
@@ -57,7 +57,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||||
primaryLanguage = "mis"
|
||||
OSGLog.keyboardExt.info("viewDidLoad — extension booted")
|
||||
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
|
||||
// Deliberately NO CustomLanguageModelManager prewarm here: the
|
||||
// extension never runs ASR (the host app owns the microphone and
|
||||
// the SpeechAnalyzer pipeline), and compiling/caching an LM inside
|
||||
// the keyboard's ~60 MB jetsam budget risks the system killing the
|
||||
// keyboard outright. The host app prewarms it on session start.
|
||||
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||||
installKeyboardHeight()
|
||||
configureDictationBehavior()
|
||||
@@ -84,6 +88,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||||
configureDictationBehavior()
|
||||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||||
state.debugHasFullAccess = hasFullAccess
|
||||
flowCoordinator.refreshSessionState()
|
||||
flowCoordinator.startSessionMonitor()
|
||||
configSync.syncOnboardingStateFromAppGroup()
|
||||
@@ -100,6 +105,12 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
super.viewDidAppear(animated)
|
||||
disableSystemGestureDelays()
|
||||
keyboardHeightConstraint?.constant = targetKeyboardHeight
|
||||
refreshReturnKeyRole()
|
||||
}
|
||||
|
||||
public override func textDidChange(_ textInput: UITextInput?) {
|
||||
super.textDidChange(textInput)
|
||||
refreshReturnKeyRole()
|
||||
}
|
||||
|
||||
public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||||
@@ -126,6 +137,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
textInserter = KeyboardTextInserter(
|
||||
state: state,
|
||||
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
|
||||
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
||||
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() }
|
||||
)
|
||||
|
||||
@@ -189,6 +201,21 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshReturnKeyRole() {
|
||||
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||
}
|
||||
|
||||
private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole {
|
||||
switch returnKeyType {
|
||||
case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall:
|
||||
return .send
|
||||
case .default, .next, .done:
|
||||
return .newline
|
||||
@unknown default:
|
||||
return .newline
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - System keyboard chrome
|
||||
|
||||
private func configureDictationBehavior() {
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
// Opens the host app from the keyboard extension.
|
||||
//
|
||||
// Reality check (verified against iOS 18–26 behaviour):
|
||||
// • `extensionContext.open` is documented for Today widgets only; for a
|
||||
// keyboard extension it resolves `false`, so we do not use it.
|
||||
// • The deprecated `openURL:` selector hack was disabled in iOS 18
|
||||
// ("BUG IN CLIENT OF UIKIT … migrate to open(_:options:completionHandler:)").
|
||||
// • The still-working path is: walk the responder chain to `UIApplication`
|
||||
// and call the non-deprecated `open(_:options:completionHandler:)`. This
|
||||
// requires Full Access and grows less reliable on newer iOS, so we report
|
||||
// the *real* success from the completion handler instead of assuming it
|
||||
// worked — callers degrade to on-keyboard guidance when it returns false.
|
||||
// • Primary path: walk the responder chain to `UIApplication` and call the
|
||||
// non-deprecated `open(_:options:completionHandler:)`. This requires Full
|
||||
// Access and grows less reliable on newer iOS, so we report the *real*
|
||||
// success from the completion handler instead of assuming it worked.
|
||||
// • Fallback: `extensionContext.open`. Historically documented for Today
|
||||
// widgets only (and it used to resolve `false` for keyboards), but it is
|
||||
// the Apple-documented API for extensions to open URLs and ships in
|
||||
// production keyboards on current iOS — worth trying before giving up.
|
||||
// When both paths fail, callers degrade to on-keyboard guidance.
|
||||
|
||||
import UIKit
|
||||
|
||||
@@ -27,13 +29,52 @@ enum HostAppLauncher {
|
||||
while let current = responder {
|
||||
if let application = current as? UIApplication {
|
||||
application.open(url, options: [:]) { success in
|
||||
Task { @MainActor in completion(success) }
|
||||
Task { @MainActor in
|
||||
if success {
|
||||
completion(true)
|
||||
} else {
|
||||
openViaExtensionContext(url: url, from: controller, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
responder = current.next
|
||||
}
|
||||
// No `UIApplication` in the responder chain — cannot open the host app.
|
||||
completion(false)
|
||||
// No `UIApplication` in the responder chain — try the extension context.
|
||||
openViaExtensionContext(url: url, from: controller, completion: completion)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openViaExtensionContext(
|
||||
url: URL,
|
||||
from controller: KeyboardViewController,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
guard let context = controller.extensionContext else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
// `NSExtensionContext.open` from keyboards has historically been
|
||||
// flaky about ever invoking its completion on some iOS versions.
|
||||
// Callers rely on a real answer to fail fast (instead of spinning
|
||||
// the 30 s start watchdog), so race the callback against a timeout
|
||||
// and report the first result only.
|
||||
var didComplete = false
|
||||
let finish: @MainActor (Bool) -> Void = { success in
|
||||
guard !didComplete else { return }
|
||||
didComplete = true
|
||||
completion(success)
|
||||
}
|
||||
Task { @MainActor in
|
||||
// 1.5 s: long enough for a real open to call back, short enough
|
||||
// that a dead completion degrades to on-keyboard guidance before
|
||||
// the user gives up staring at nothing.
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
finish(false)
|
||||
}
|
||||
context.open(url) { success in
|
||||
Task { @MainActor in finish(success) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,25 @@ final class KeyboardFlowCoordinator {
|
||||
private var isAwaitingFlowResult = false
|
||||
private var activeSessionId: UUID?
|
||||
private var currentUtteranceId: UUID?
|
||||
/// Utterance whose final result we already inserted (or failed). Prevents
|
||||
/// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a
|
||||
/// stale App Group snapshot still says `reason=processing`.
|
||||
private var lastConsumedUtteranceId: UUID?
|
||||
/// Utterance we just asked the host to stop. Until the host publishes
|
||||
/// processing/final state, stale App Group snapshots can still say
|
||||
/// `reason=recording`; do not re-adopt that utterance as locally active.
|
||||
private var lastStoppedUtteranceId: UUID?
|
||||
private var currentCommandSeq: Int64 = 0
|
||||
private var lastAvailabilityTraceSignature = ""
|
||||
/// When true, `completeFlowStartHandoff` starts recording after the host
|
||||
/// publishes ready — set only for an explicit mic press.
|
||||
private var recordAfterHandoff = false
|
||||
/// When true, `startHostReadyWaitIfNeeded` starts recording once ready
|
||||
/// (mic pressed while session was still warming / mid ready-flap).
|
||||
private var recordWhenHostReady = false
|
||||
/// Ignores single-frame "host dead" samples before allowing a cold-start jump
|
||||
/// from non-press recovery paths.
|
||||
private var coldStartDebouncer = FlowColdStartDebouncer()
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
@@ -116,6 +133,8 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
recomputeMicVoiceAvailability()
|
||||
startHostReadyWaitIfNeeded()
|
||||
// Proactive host auto-launch is disabled (FlowHandoffPolicy): a single
|
||||
// stale ready snapshot after finalize must never open startflow.
|
||||
|
||||
// Only surface "session ended" when the session contract *genuinely*
|
||||
// dropped (expired / cleared). A transient host-ready flap — engine
|
||||
@@ -139,6 +158,12 @@ final class KeyboardFlowCoordinator {
|
||||
FlowSessionBridge.reloadFromDisk()
|
||||
let readySnapshot = FlowSessionBridge.readySnapshot()
|
||||
activeSessionId = readySnapshot?.sessionId ?? activeSessionId
|
||||
|
||||
// If the host is mid-utterance but this extension process lost local
|
||||
// ownership (jetsam / recreate after app switch), re-adopt it so we
|
||||
// show red/white instead of a fake orange "starting" state.
|
||||
adoptHostBusyStateIfNeeded(snapshot: readySnapshot)
|
||||
|
||||
let hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
|
||||
let now = Date().timeIntervalSince1970
|
||||
if hostReady { lastHostReadyAt = now }
|
||||
@@ -148,10 +173,20 @@ final class KeyboardFlowCoordinator {
|
||||
// across cross-process read jitter and anchors this smoothing.
|
||||
let withinReadyGrace = lastHostReadyAt > 0
|
||||
&& (now - lastHostReadyAt) <= Self.hostReadyGrace
|
||||
// Host busy (recording/processing) is NOT "still starting". Treating
|
||||
// it as preparingSession was the orange-stuck bug after cold start:
|
||||
// host utt.rec=1 → ready=false → keyboard forever "正在启动…".
|
||||
let hostBusy = readySnapshot?.reason == .recording
|
||||
|| readySnapshot?.reason == .processing
|
||||
let hostWarming = !hostReady
|
||||
&& !hostBusy
|
||||
&& FlowSessionBridge.isSessionActive()
|
||||
&& (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace)
|
||||
state.flowSessionActive = FlowSessionBridge.isSessionActive()
|
||||
state.debugPendingFlowStart = isPendingFlowStart
|
||||
state.debugFlowRecording = isFlowRecording
|
||||
state.debugAwaitingFlowResult = isAwaitingFlowResult
|
||||
state.debugHasFullAccess = hasFullAccess()
|
||||
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
|
||||
phase: state.phase,
|
||||
micDisabled: state.micDisabled,
|
||||
@@ -176,16 +211,110 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-attach to a host utterance this keyboard process no longer owns.
|
||||
private func adoptHostBusyStateIfNeeded(snapshot: FlowReadySnapshot?) {
|
||||
guard let snapshot, let sessionId = snapshot.sessionId else { return }
|
||||
// Ignore snapshots from a dead host generation.
|
||||
if let snapGen = snapshot.hostGeneration,
|
||||
let liveGen = FlowSessionBridge.currentHostGeneration(),
|
||||
snapGen != liveGen {
|
||||
return
|
||||
}
|
||||
|
||||
// Host already finished — never re-adopt a consumed utterance, and
|
||||
// clear sticky local processing left behind by a stale busy snapshot.
|
||||
if snapshot.reason != .recording, snapshot.reason != .processing {
|
||||
clearStickyProcessingIfNeeded(hostReady: snapshot.ready)
|
||||
return
|
||||
}
|
||||
|
||||
switch snapshot.reason {
|
||||
case .recording:
|
||||
guard !isFlowRecording else { return }
|
||||
guard !isAwaitingFlowResult else { return }
|
||||
// Require the host's utterance id — inventing one makes matchingResult
|
||||
// forever miss the real delivery and leaves the mic white forever.
|
||||
guard let busyId = snapshot.busyUtteranceId else { return }
|
||||
guard busyId != lastConsumedUtteranceId else { return }
|
||||
guard busyId != lastStoppedUtteranceId else { return }
|
||||
activeSessionId = sessionId
|
||||
currentUtteranceId = busyId
|
||||
isPendingFlowStart = false
|
||||
flowStartDeadline = 0
|
||||
stopHostReadyWait()
|
||||
isFlowRecording = true
|
||||
state.phase = .recording
|
||||
if state.lastTranscript.isEmpty {
|
||||
state.lastTranscript = ""
|
||||
}
|
||||
if let view = wakeLockView() {
|
||||
ExtensionScreenWakeLock.acquire(from: view)
|
||||
}
|
||||
startUtteranceCountdown()
|
||||
startFlowLevelWatchdog()
|
||||
traceState("adoptHostBusy.recording", extra: "session=\(sessionId)")
|
||||
case .processing:
|
||||
guard !isAwaitingFlowResult else { return }
|
||||
guard let busyId = snapshot.busyUtteranceId else { return }
|
||||
guard busyId != lastConsumedUtteranceId else { return }
|
||||
activeSessionId = sessionId
|
||||
currentUtteranceId = busyId
|
||||
isPendingFlowStart = false
|
||||
flowStartDeadline = 0
|
||||
isFlowRecording = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
state.phase = .processing
|
||||
if state.lastTranscript.isEmpty {
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||
}
|
||||
startFlowResultWatchdog()
|
||||
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// After insert, a stale `reason=processing` snapshot can bounce the mic
|
||||
/// back to white loading. When the host is no longer busy, force idle.
|
||||
private func clearStickyProcessingIfNeeded(hostReady: Bool) {
|
||||
guard !isAwaitingFlowResult, !isFlowRecording else { return }
|
||||
guard case .processing = state.phase else { return }
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
stopFlowWatchdog()
|
||||
currentUtteranceId = nil
|
||||
lastStoppedUtteranceId = nil
|
||||
traceState(
|
||||
"stickyProcessing.cleared",
|
||||
extra: hostReady ? "hostReady=1" : "hostReady=0"
|
||||
)
|
||||
}
|
||||
|
||||
/// Session is live but the ready contract has not landed yet — poll
|
||||
/// quickly instead of sticking on "session inactive" orange.
|
||||
private func startHostReadyWaitIfNeeded() {
|
||||
guard !isPendingFlowStart else { return }
|
||||
guard FlowSessionBridge.isSessionActive() else {
|
||||
stopHostReadyWait()
|
||||
if recordWhenHostReady {
|
||||
// Session gone while waiting — escalate to a real cold start.
|
||||
let shouldRecord = recordWhenHostReady
|
||||
recordWhenHostReady = false
|
||||
beginFlowStart(recordAfterHandoff: shouldRecord)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard !FlowSessionBridge.isHostReady() else {
|
||||
// Host busy ≠ waiting for ready. Do not spin the ready-wait poll.
|
||||
if let reason = FlowSessionBridge.readySnapshot()?.reason,
|
||||
reason == .recording || reason == .processing {
|
||||
stopHostReadyWait()
|
||||
recordWhenHostReady = false
|
||||
return
|
||||
}
|
||||
if FlowSessionBridge.isHostReady() {
|
||||
stopHostReadyWait()
|
||||
finishHostReadyWaitIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -197,13 +326,49 @@ final class KeyboardFlowCoordinator {
|
||||
FlowSessionBridge.reloadFromDisk()
|
||||
self.recomputeMicVoiceAvailability()
|
||||
if self.state.micVoiceAvailability.isReady {
|
||||
self.finishHostReadyWaitIfNeeded()
|
||||
return
|
||||
}
|
||||
if self.state.micVoiceAvailability == .recording
|
||||
|| self.state.micVoiceAvailability == .processing {
|
||||
self.recordWhenHostReady = false
|
||||
return
|
||||
}
|
||||
// Host died mid-wait — only cold-start after debounced dead samples.
|
||||
let dead = FlowHandoffPolicy.shouldOpenHostColdStart(
|
||||
sessionActive: FlowSessionBridge.isSessionActive(),
|
||||
hostReachable: FlowSessionBridge.isHostReachable(),
|
||||
hostStale: FlowSessionBridge.isHostStale(),
|
||||
withinReadyGrace: false
|
||||
)
|
||||
if self.coldStartDebouncer.observe(hostTrulyDead: dead) {
|
||||
let shouldRecord = self.recordWhenHostReady
|
||||
self.recordWhenHostReady = false
|
||||
self.coldStartDebouncer.reset()
|
||||
self.beginFlowStart(recordAfterHandoff: shouldRecord)
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
}
|
||||
// Timed out still not ready — if the user asked to record, cold-start.
|
||||
guard let self else { return }
|
||||
if self.recordWhenHostReady {
|
||||
let shouldRecord = self.recordWhenHostReady
|
||||
self.recordWhenHostReady = false
|
||||
self.beginFlowStart(recordAfterHandoff: shouldRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishHostReadyWaitIfNeeded() {
|
||||
coldStartDebouncer.reset()
|
||||
guard recordWhenHostReady else { return }
|
||||
recordWhenHostReady = false
|
||||
guard state.micVoiceAvailability.isReady else { return }
|
||||
startFlowRecording()
|
||||
traceState("hostReadyWait.recordStarted")
|
||||
}
|
||||
|
||||
private func stopHostReadyWait() {
|
||||
hostReadyWaitTask?.cancel()
|
||||
hostReadyWaitTask = nil
|
||||
@@ -232,9 +397,6 @@ final class KeyboardFlowCoordinator {
|
||||
recomputeMicVoiceAvailability()
|
||||
|
||||
switch state.micVoiceAvailability {
|
||||
case .ready:
|
||||
detectAndStoreAppContext()
|
||||
startFlowRecording()
|
||||
case .unavailable(.missingAPIKey):
|
||||
return
|
||||
case .unavailable(.noFullAccess):
|
||||
@@ -242,18 +404,43 @@ final class KeyboardFlowCoordinator {
|
||||
state.phase = .error(.fullAccessRequired, message: msg)
|
||||
scheduleAutoClearError()
|
||||
recomputeMicVoiceAvailability()
|
||||
return
|
||||
case .unavailable(.appGroupUnavailable):
|
||||
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
|
||||
state.phase = .error(.appGroupUnavailable, message: msg)
|
||||
scheduleAutoClearError()
|
||||
recomputeMicVoiceAvailability()
|
||||
case .unavailable(.preparingSession):
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let withinReadyGrace = lastHostReadyAt > 0
|
||||
&& (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
|
||||
let action = FlowHandoffPolicy.micPressAction(
|
||||
availability: state.micVoiceAvailability,
|
||||
sessionActive: FlowSessionBridge.isSessionActive(),
|
||||
hostReachable: FlowSessionBridge.isHostReachable(),
|
||||
hostStale: FlowSessionBridge.isHostStale(),
|
||||
withinReadyGrace: withinReadyGrace
|
||||
)
|
||||
switch action {
|
||||
case .startRecording:
|
||||
detectAndStoreAppContext()
|
||||
beginFlowStart()
|
||||
case .unavailable(.hostNotReady):
|
||||
startFlowRecording()
|
||||
case .waitForHostReady(let recordWhenReady):
|
||||
detectAndStoreAppContext()
|
||||
beginFlowStart()
|
||||
case .recording, .processing:
|
||||
recordWhenHostReady = recordWhenReady
|
||||
coldStartDebouncer.reset()
|
||||
startHostReadyWaitIfNeeded()
|
||||
traceState(
|
||||
"pressBegan.waitForHostReady",
|
||||
extra: recordWhenReady ? "recordWhenReady=1" : "recordWhenReady=0"
|
||||
)
|
||||
case .openHostColdStart:
|
||||
detectAndStoreAppContext()
|
||||
beginFlowStart(recordAfterHandoff: true)
|
||||
case .ignore:
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -268,19 +455,23 @@ final class KeyboardFlowCoordinator {
|
||||
isFlowRecording = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
lastStoppedUtteranceId = currentUtteranceId
|
||||
writeCommand(.stopRecording)
|
||||
debug("pressEnded wrote stop command")
|
||||
state.phase = .processing
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||
recomputeMicVoiceAvailability()
|
||||
startFlowResultWatchdog()
|
||||
recomputeMicVoiceAvailability()
|
||||
}
|
||||
|
||||
func beginFlowStart() {
|
||||
func beginFlowStart(recordAfterHandoff: Bool = false) {
|
||||
guard !isPendingFlowStart else {
|
||||
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
|
||||
return
|
||||
}
|
||||
self.recordAfterHandoff = recordAfterHandoff
|
||||
recordWhenHostReady = false
|
||||
coldStartDebouncer.reset()
|
||||
isPendingFlowStart = true
|
||||
isFlowRecording = false
|
||||
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
|
||||
@@ -288,7 +479,10 @@ final class KeyboardFlowCoordinator {
|
||||
recomputeMicVoiceAvailability()
|
||||
openHostApp("startflow")
|
||||
startFlowStartWatchdog()
|
||||
traceState("beginFlowStart.started")
|
||||
traceState(
|
||||
"beginFlowStart.started",
|
||||
extra: recordAfterHandoff ? "recordAfterHandoff=1" : "recordAfterHandoff=0"
|
||||
)
|
||||
}
|
||||
|
||||
func handleHostAppOpenResult(path: String, success: Bool) {
|
||||
@@ -300,6 +494,7 @@ final class KeyboardFlowCoordinator {
|
||||
// guide the user to open OSGKeyboard manually.
|
||||
if path == "startflow", isPendingFlowStart {
|
||||
isPendingFlowStart = false
|
||||
recordAfterHandoff = false
|
||||
flowStartDeadline = 0
|
||||
stopFlowWatchdog()
|
||||
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
|
||||
@@ -319,8 +514,10 @@ final class KeyboardFlowCoordinator {
|
||||
ExtensionScreenWakeLock.release()
|
||||
}
|
||||
currentUtteranceId = nil
|
||||
lastStoppedUtteranceId = nil
|
||||
isFlowRecording = false
|
||||
isPendingFlowStart = false
|
||||
recordAfterHandoff = false
|
||||
stopUtteranceCountdown()
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
@@ -365,6 +562,8 @@ final class KeyboardFlowCoordinator {
|
||||
)
|
||||
)
|
||||
FlowSessionBridge.clearResult()
|
||||
lastConsumedUtteranceId = result.utteranceId
|
||||
lastStoppedUtteranceId = nil
|
||||
currentUtteranceId = nil
|
||||
textInserter.handleFlowTranscript(
|
||||
TranscriptionDelivery(text: text, polishWarning: result.warning)
|
||||
@@ -375,6 +574,8 @@ final class KeyboardFlowCoordinator {
|
||||
isAwaitingFlowResult = false
|
||||
stopFlowWatchdog()
|
||||
FlowSessionBridge.clearResult()
|
||||
lastConsumedUtteranceId = result.utteranceId
|
||||
lastStoppedUtteranceId = nil
|
||||
currentUtteranceId = nil
|
||||
let error = FlowTranscriptionError(
|
||||
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
|
||||
@@ -420,6 +621,7 @@ final class KeyboardFlowCoordinator {
|
||||
ExtensionScreenWakeLock.release()
|
||||
writeCommand(.abort)
|
||||
currentUtteranceId = nil
|
||||
lastStoppedUtteranceId = nil
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
state.phase = .idle
|
||||
@@ -439,10 +641,12 @@ final class KeyboardFlowCoordinator {
|
||||
isAwaitingFlowResult = false
|
||||
isFlowRecording = false
|
||||
isPendingFlowStart = false
|
||||
recordAfterHandoff = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
writeCommand(.abort)
|
||||
currentUtteranceId = nil
|
||||
lastStoppedUtteranceId = nil
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
let message = ExtL10n.string("keyboard.flow.hostDisconnected")
|
||||
@@ -477,9 +681,29 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
private func startFlowRecording() {
|
||||
recomputeMicVoiceAvailability()
|
||||
guard state.micVoiceAvailability.isReady else {
|
||||
traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))")
|
||||
beginFlowStart()
|
||||
let withinReadyGrace = lastHostReadyAt > 0
|
||||
&& (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
|
||||
if !state.micVoiceAvailability.isReady {
|
||||
let action = FlowHandoffPolicy.micPressAction(
|
||||
availability: state.micVoiceAvailability,
|
||||
sessionActive: FlowSessionBridge.isSessionActive(),
|
||||
hostReachable: FlowSessionBridge.isHostReachable(),
|
||||
hostStale: FlowSessionBridge.isHostStale(),
|
||||
withinReadyGrace: withinReadyGrace
|
||||
)
|
||||
traceState(
|
||||
"startFlowRecording.blocked",
|
||||
extra: "availability=\(String(describing: state.micVoiceAvailability)) action=\(action)"
|
||||
)
|
||||
switch action {
|
||||
case .waitForHostReady(let recordWhenReady):
|
||||
recordWhenHostReady = recordWhenReady
|
||||
startHostReadyWaitIfNeeded()
|
||||
case .openHostColdStart:
|
||||
beginFlowStart(recordAfterHandoff: true)
|
||||
case .startRecording, .ignore:
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
isPendingFlowStart = false
|
||||
@@ -488,11 +712,23 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else {
|
||||
traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot")
|
||||
beginFlowStart()
|
||||
// Snapshot lag with a live session → wait; only cold-start if host is dead.
|
||||
if FlowHandoffPolicy.shouldOpenHostColdStart(
|
||||
sessionActive: FlowSessionBridge.isSessionActive(),
|
||||
hostReachable: FlowSessionBridge.isHostReachable(),
|
||||
hostStale: FlowSessionBridge.isHostStale(),
|
||||
withinReadyGrace: withinReadyGrace
|
||||
) {
|
||||
beginFlowStart(recordAfterHandoff: true)
|
||||
} else {
|
||||
recordWhenHostReady = true
|
||||
startHostReadyWaitIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
activeSessionId = sessionId
|
||||
currentUtteranceId = UUID()
|
||||
lastStoppedUtteranceId = nil
|
||||
writeCommand(.startRecording)
|
||||
isFlowRecording = true
|
||||
state.lastTranscript = ""
|
||||
@@ -532,8 +768,12 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
private func cancelPendingFlowStart() {
|
||||
isPendingFlowStart = false
|
||||
recordAfterHandoff = false
|
||||
recordWhenHostReady = false
|
||||
flowStartDeadline = 0
|
||||
coldStartDebouncer.reset()
|
||||
stopFlowWatchdog()
|
||||
stopHostReadyWait()
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
recomputeMicVoiceAvailability()
|
||||
@@ -552,6 +792,7 @@ final class KeyboardFlowCoordinator {
|
||||
let now = Date().timeIntervalSince1970
|
||||
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
|
||||
self.isPendingFlowStart = false
|
||||
self.recordAfterHandoff = false
|
||||
self.flowStartDeadline = 0
|
||||
self.traceState("startWatchdog.timeout")
|
||||
self.showManualOpenHint(path: "startflow")
|
||||
@@ -563,13 +804,20 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
|
||||
private func completeFlowStartHandoff() {
|
||||
let shouldRecord = recordAfterHandoff
|
||||
isPendingFlowStart = false
|
||||
recordAfterHandoff = false
|
||||
flowStartDeadline = 0
|
||||
stopFlowWatchdog()
|
||||
state.lastTranscript = ""
|
||||
refreshSessionState()
|
||||
startFlowRecording()
|
||||
traceState("completeFlowStartHandoff.done")
|
||||
if shouldRecord {
|
||||
startFlowRecording()
|
||||
traceState("completeFlowStartHandoff.done", extra: "record=1")
|
||||
} else {
|
||||
recomputeMicVoiceAvailability()
|
||||
traceState("completeFlowStartHandoff.done", extra: "record=0 warmOnly")
|
||||
}
|
||||
}
|
||||
|
||||
private func startFlowLevelWatchdog() {
|
||||
@@ -626,6 +874,8 @@ final class KeyboardFlowCoordinator {
|
||||
)
|
||||
)
|
||||
FlowSessionBridge.clearResult()
|
||||
self.lastConsumedUtteranceId = result.utteranceId
|
||||
self.lastStoppedUtteranceId = nil
|
||||
self.currentUtteranceId = nil
|
||||
self.debug("resultWatchdog consumed delivery len=\(text.count)")
|
||||
self.textInserter.handleFlowTranscript(
|
||||
@@ -637,6 +887,8 @@ final class KeyboardFlowCoordinator {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
FlowSessionBridge.clearResult()
|
||||
self.lastConsumedUtteranceId = result.utteranceId
|
||||
self.lastStoppedUtteranceId = nil
|
||||
self.currentUtteranceId = nil
|
||||
let error = FlowTranscriptionError(
|
||||
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
|
||||
@@ -674,6 +926,7 @@ final class KeyboardFlowCoordinator {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.currentUtteranceId = nil
|
||||
self.lastStoppedUtteranceId = nil
|
||||
self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host")
|
||||
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
|
||||
self.state.phase = .error(.flowResultTimeout, message: msg)
|
||||
|
||||
@@ -10,15 +10,18 @@ import OSGKeyboardShared
|
||||
final class KeyboardTextInserter {
|
||||
private let state: KeyboardState
|
||||
private let insertText: (String) -> Void
|
||||
private let contextBeforeInput: () -> String?
|
||||
private let scheduleAutoClearError: () -> Void
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
insertText: @escaping (String) -> Void,
|
||||
contextBeforeInput: @escaping () -> String?,
|
||||
scheduleAutoClearError: @escaping () -> Void
|
||||
) {
|
||||
self.state = state
|
||||
self.insertText = insertText
|
||||
self.contextBeforeInput = contextBeforeInput
|
||||
self.scheduleAutoClearError = scheduleAutoClearError
|
||||
}
|
||||
|
||||
@@ -30,7 +33,13 @@ final class KeyboardTextInserter {
|
||||
return
|
||||
}
|
||||
// Host app already polished when configured; keyboard only inserts.
|
||||
insertText(trimmed)
|
||||
// Word-boundary hygiene: dictating "world" with the cursor right
|
||||
// after "Hello" must yield "Hello world", not "Helloworld".
|
||||
let separator = DictationTextComposer.insertionSeparator(
|
||||
previousContext: contextBeforeInput(),
|
||||
insertion: trimmed
|
||||
)
|
||||
insertText(separator + trimmed)
|
||||
state.lastTranscript = ""
|
||||
state.level = 0
|
||||
if let warning = delivery.polishWarning {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// │ (transcript preview) │
|
||||
// │ ┊ │
|
||||
// │ ◯ mic (centred) │ ← action cluster:
|
||||
// │ [delete] [ space ] [return] │ mic + bottom row
|
||||
// │ [delete] [ return ] [space] │ mic + bottom row
|
||||
// │ ┊ │
|
||||
// └───────────────────────────────────────────┘
|
||||
|
||||
@@ -33,6 +33,11 @@ private enum KeyboardLayoutMetrics {
|
||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
|
||||
/// Outer inset for the bottom action row from screen edges (8 pt → 24 pt, +200%).
|
||||
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
|
||||
/// iPad: cap the content column. A full-width (~1180 pt) keyboard would
|
||||
/// park delete/return at the far screen edges and turn each cursor-drag
|
||||
/// pad into a ~450 pt runway — capping keeps the reach ergonomics of the
|
||||
/// phone layout. iPhone widths are all below this, so it is a no-op there.
|
||||
static let contentMaxWidth: CGFloat = 700
|
||||
|
||||
// MARK: - Content-driven keyboard height (single source of truth)
|
||||
static let outerPaddingTop: CGFloat = 2
|
||||
@@ -109,6 +114,8 @@ public struct KeyboardRootView: View {
|
||||
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
|
||||
// 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。
|
||||
.background(Color.clear)
|
||||
.frame(maxWidth: KeyboardLayoutMetrics.contentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: Self.totalHeight)
|
||||
// Feed the resolved palette to all nested chips/buttons.
|
||||
.environment(\.themePalette, palette)
|
||||
@@ -184,7 +191,7 @@ public struct KeyboardRootView: View {
|
||||
|
||||
// MARK: - Action cluster
|
||||
|
||||
/// Mic centred above a bottom row: delete · space · return (or swapped).
|
||||
/// Mic centred above a bottom row: delete · smart return · space (or swapped).
|
||||
/// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
|
||||
/// pan recognizers, avoiding SwiftUI gesture delivery issues in
|
||||
/// keyboard extensions.
|
||||
@@ -218,13 +225,13 @@ public struct KeyboardRootView: View {
|
||||
|
||||
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
|
||||
if swapKeys {
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
} else {
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
}
|
||||
}
|
||||
.opacity(dragging ? 0 : 1)
|
||||
@@ -258,19 +265,20 @@ public struct KeyboardRootView: View {
|
||||
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
|
||||
state.insertSpace()
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
private func bottomReturnButton(disabled: Bool) -> some View {
|
||||
RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) {
|
||||
state.insertNewline()
|
||||
}
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
|
||||
height: KeyboardLayoutMetrics.bottomActionRowHeight
|
||||
)
|
||||
}
|
||||
|
||||
private func bottomReturnButton(disabled: Bool) -> some View {
|
||||
let title = ExtL10n.string(state.returnKeyRole.titleKey)
|
||||
return RectangularToolbarButton(title: title, label: title, disabled: disabled) {
|
||||
state.insertNewline()
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
/// Option C: block typing keys during the full voice-input pipeline.
|
||||
private var voiceInputBlocksEditing: Bool {
|
||||
switch state.phase {
|
||||
|
||||
@@ -10,6 +10,7 @@ import OSGKeyboardShared
|
||||
|
||||
private enum ToolbarButtonMetrics {
|
||||
static let iconSize: CGFloat = 14
|
||||
static let titleSize: CGFloat = 16
|
||||
static let cornerRadius: CGFloat = 12
|
||||
static let spaceBarCapsuleWidth: CGFloat = 31
|
||||
static let pressScale: CGFloat = 0.94
|
||||
@@ -138,6 +139,7 @@ struct RectangularToolbarButton: View {
|
||||
|
||||
let systemName: String?
|
||||
let spaceStyle: Bool
|
||||
let title: String?
|
||||
let label: String
|
||||
let disabled: Bool
|
||||
let action: () -> Void
|
||||
@@ -145,14 +147,25 @@ struct RectangularToolbarButton: View {
|
||||
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
|
||||
self.systemName = systemName
|
||||
self.spaceStyle = false
|
||||
self.title = nil
|
||||
self.label = label
|
||||
self.disabled = disabled
|
||||
self.action = action
|
||||
}
|
||||
|
||||
init(title: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
|
||||
self.systemName = nil
|
||||
self.spaceStyle = false
|
||||
self.label = label
|
||||
self.disabled = disabled
|
||||
self.action = action
|
||||
self.title = title
|
||||
}
|
||||
|
||||
init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
|
||||
self.systemName = nil
|
||||
self.spaceStyle = spaceStyle
|
||||
self.title = nil
|
||||
self.label = label
|
||||
self.disabled = disabled
|
||||
self.action = action
|
||||
@@ -170,6 +183,10 @@ struct RectangularToolbarButton: View {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
} else if let title {
|
||||
Text(title)
|
||||
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"common.delete" = "Delete";
|
||||
"common.space" = "Space";
|
||||
"common.newline" = "Return";
|
||||
"common.send" = "Send";
|
||||
|
||||
/* Privacy footnote (onboarding welcome page) */
|
||||
"privacy.audio.title" = "On-device transcription";
|
||||
@@ -69,13 +70,13 @@
|
||||
"settings.reset.title" = "Reset all settings?";
|
||||
"settings.reset.message" = "API key, model, and base URL will be cleared.";
|
||||
"settings.reset.confirm" = "Reset all settings";
|
||||
"settings.engine.title" = "Recognition method";
|
||||
"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed.";
|
||||
"settings.engine.local.title" = "On-device recognition";
|
||||
"settings.engine.title" = "Speech transcription method";
|
||||
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
|
||||
"settings.engine.local.title" = "On-device transcription";
|
||||
"settings.engine.local.ios26" = "Always on-device, no network.";
|
||||
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
|
||||
"settings.engine.cloud.title" = "Cloud recognition & polish";
|
||||
"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required.";
|
||||
"settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
|
||||
"settings.engine.cloud.title" = "Cloud transcription";
|
||||
"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
|
||||
"settings.provider.title" = "Provider";
|
||||
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
|
||||
"settings.api.title" = "API";
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"common.cancel" = "取消";
|
||||
"common.delete" = "删除";
|
||||
"common.space" = "空格";
|
||||
"common.newline" = "换行";
|
||||
"common.newline" = "回车";
|
||||
"common.send" = "发送";
|
||||
|
||||
/* Privacy footnote (onboarding welcome page) */
|
||||
"privacy.audio.title" = "支持本地转写";
|
||||
@@ -69,13 +70,13 @@
|
||||
"settings.reset.title" = "重置所有设置?";
|
||||
"settings.reset.message" = "API key、model 和 base URL 都会被清空。";
|
||||
"settings.reset.confirm" = "重置所有设置";
|
||||
"settings.engine.title" = "识别方式";
|
||||
"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。";
|
||||
"settings.engine.local.title" = "本地识别";
|
||||
"settings.engine.title" = "语音转写方式";
|
||||
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
|
||||
"settings.engine.local.title" = "本地转写";
|
||||
"settings.engine.local.ios26" = "始终端侧,无需联网。";
|
||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||
"settings.engine.cloud.title" = "云端识别与润色";
|
||||
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
|
||||
"settings.engine.local.legacy" = "通过系统内置服务语音转写";
|
||||
"settings.engine.cloud.title" = "云端转写";
|
||||
"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
|
||||
"settings.provider.title" = "云端引擎";
|
||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||
"settings.api.title" = "接口";
|
||||
|
||||
@@ -9,37 +9,50 @@ import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct FlowLiveActivityWidget: Widget {
|
||||
/// Deep link that restarts the Flow session. When the host process is
|
||||
/// dead the activity goes stale; tapping it must take the *cold-start*
|
||||
/// path (same as the keyboard's mic button), not just open the app.
|
||||
private static let reconnectURL = URL(string: "osgkeyboard://startflow")
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: FlowActivityAttributes.self) { context in
|
||||
FlowLiveActivityLockScreenView(phase: context.state.phase)
|
||||
.activityBackgroundTint(Color.black.opacity(0.82))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
FlowLiveActivityLockScreenView(
|
||||
phase: context.state.phase,
|
||||
isStale: context.isStale
|
||||
)
|
||||
.activityBackgroundTint(Color.black.opacity(0.82))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
// Deep-link to a session restart only when the host is dead —
|
||||
// tapping a HEALTHY activity should just open the app, not
|
||||
// force a cold-start handoff into a running session.
|
||||
.widgetURL(context.isStale ? Self.reconnectURL : nil)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
FlowLiveActivityBrandMark(height: 16)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
FlowLiveActivityPhaseLabel(phase: context.state.phase)
|
||||
FlowLiveActivityPhaseLabel(phase: context.state.phase, isStale: context.isStale)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text("OSGKeyboard")
|
||||
.font(.headline)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
FlowLiveActivityPhaseCaption(phase: context.state.phase)
|
||||
FlowLiveActivityPhaseCaption(phase: context.state.phase, isStale: context.isStale)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} compactLeading: {
|
||||
FlowLiveActivityBrandMark(height: 12)
|
||||
} compactTrailing: {
|
||||
FlowLiveActivityTrailingGlyph(phase: context.state.phase)
|
||||
FlowLiveActivityTrailingGlyph(phase: context.state.phase, isStale: context.isStale)
|
||||
} minimal: {
|
||||
// The minimal slot is a tiny circle; a short wordmark keeps
|
||||
// the natural ratio without overflowing its bounds.
|
||||
FlowLiveActivityBrandMark(height: 6)
|
||||
}
|
||||
.widgetURL(context.isStale ? Self.reconnectURL : nil)
|
||||
.keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0))
|
||||
}
|
||||
}
|
||||
@@ -49,20 +62,24 @@ struct FlowLiveActivityWidget: Widget {
|
||||
|
||||
private struct FlowLiveActivityLockScreenView: View {
|
||||
let phase: FlowActivityAttributes.ContentState.Phase
|
||||
let isStale: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
FlowLiveActivityBrandMark(height: 13)
|
||||
FlowLiveActivityBrandMark(height: 10.4)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("OSGKeyboard")
|
||||
.font(.headline)
|
||||
FlowLiveActivityPhaseCaption(phase: phase)
|
||||
FlowLiveActivityPhaseCaption(phase: phase, isStale: isStale)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
FlowLiveActivityTrailingGlyph(phase: phase)
|
||||
FlowLiveActivityTrailingGlyph(phase: phase, isStale: isStale)
|
||||
}
|
||||
// A stale activity means the host process is gone — never advertise
|
||||
// "Voice session active" for a dead session; grey the card instead.
|
||||
.opacity(isStale ? 0.55 : 1)
|
||||
// iOS Live Activity lock-screen content needs margins so the leading
|
||||
// logo and trailing glyph don't touch the card edges.
|
||||
.padding(.horizontal, 16)
|
||||
@@ -91,8 +108,19 @@ private struct FlowLiveActivityBrandMark: View {
|
||||
|
||||
private struct FlowLiveActivityTrailingGlyph: View {
|
||||
let phase: FlowActivityAttributes.ContentState.Phase
|
||||
var isStale: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if isStale {
|
||||
Image(systemName: "bolt.slash.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
phaseGlyph
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var phaseGlyph: some View {
|
||||
switch phase {
|
||||
case .recording:
|
||||
Image(systemName: "waveform")
|
||||
@@ -114,8 +142,19 @@ private struct FlowLiveActivityTrailingGlyph: View {
|
||||
|
||||
private struct FlowLiveActivityPhaseLabel: View {
|
||||
let phase: FlowActivityAttributes.ContentState.Phase
|
||||
var isStale: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if isStale {
|
||||
Image(systemName: "bolt.slash.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
phaseLabel
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var phaseLabel: some View {
|
||||
switch phase {
|
||||
case .recording:
|
||||
Text("REC")
|
||||
@@ -133,8 +172,20 @@ private struct FlowLiveActivityPhaseLabel: View {
|
||||
|
||||
private struct FlowLiveActivityPhaseCaption: View {
|
||||
let phase: FlowActivityAttributes.ContentState.Phase
|
||||
var isStale: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if isStale {
|
||||
// Host process is gone — be honest about it and turn the card
|
||||
// into a recovery entry point (tap deep-links to startflow).
|
||||
Text("Session disconnected · tap to reconnect")
|
||||
} else {
|
||||
phaseCaption
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var phaseCaption: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Text("Voice session active")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Live Activity captions. Keys are the English literals used in
|
||||
FlowLiveActivityWidget — SwiftUI Text(_:) resolves string literals as
|
||||
LocalizedStringKey against this table automatically. */
|
||||
"Voice session active" = "Voice session active";
|
||||
"Listening…" = "Listening…";
|
||||
"Transcribing…" = "Transcribing…";
|
||||
"Session disconnected · tap to reconnect" = "Session disconnected · tap to reconnect";
|
||||
@@ -0,0 +1,6 @@
|
||||
/* Live Activity 文案。键为 FlowLiveActivityWidget 中的英文字面量 —
|
||||
SwiftUI Text(_:) 会把字符串字面量按 LocalizedStringKey 在本表解析。 */
|
||||
"Voice session active" = "语音会话进行中";
|
||||
"Listening…" = "正在聆听…";
|
||||
"Transcribing…" = "正在转写…";
|
||||
"Session disconnected · tap to reconnect" = "会话已断开 · 点按重连";
|
||||
@@ -1,7 +1,8 @@
|
||||
// DashboardView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Primary workspace: session stats, dictation canvas, floating record bar.
|
||||
// Primary workspace: brand voice, asymmetric stats, dictation stage, and
|
||||
// the record bar. History lives on its own page — no duplicate list here.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -20,33 +21,33 @@ struct DashboardView: View {
|
||||
self._stats = ObservedObject(wrappedValue: viewModel.usageStatistics)
|
||||
}
|
||||
|
||||
// Four equal-width columns — same metrics as the iOS home stats card.
|
||||
private let columns = Array(
|
||||
repeating: GridItem(.flexible(minimum: 120), spacing: Spacing.md),
|
||||
count: 4
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Scrollable like the other pages so the shared status footer always
|
||||
// stays pinned to the window's bottom edge. `minHeight: viewport` keeps
|
||||
// the balanced Spacer layout when the window is tall (no scrollbar) and
|
||||
// lets the content scroll only when the window is too short to fit it.
|
||||
GeometryReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
if let appName = viewModel.foregroundAppName {
|
||||
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.transition(.opacity)
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
heroHeader
|
||||
statCluster
|
||||
dictationStage
|
||||
}
|
||||
statGrid
|
||||
dictationCanvas
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
|
||||
// Leftover window height splits evenly above / below the mic
|
||||
// bar so spacing stays balanced at any window size.
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
BottomDictationBar(viewModel: viewModel)
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
}
|
||||
.animation(Motion.soft, value: viewModel.foregroundAppName)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
|
||||
}
|
||||
BottomDictationBar(viewModel: viewModel)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
.onAppear { stats.reloadFromDisk() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
@@ -54,47 +55,54 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var statGrid: some View {
|
||||
LazyVGrid(columns: columns, spacing: Spacing.md) {
|
||||
StatCard(
|
||||
title: MacL10n.string("mac.stat.dictationTime", language: lang),
|
||||
value: UsageStatisticsStore.formatDuration(
|
||||
stats.dictationDurationSeconds,
|
||||
language: lang
|
||||
),
|
||||
caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang),
|
||||
systemImage: "waveform",
|
||||
accent: true
|
||||
)
|
||||
StatCard(
|
||||
title: MacL10n.string("mac.stat.words", language: lang),
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
stats.dictationCharacterCount,
|
||||
language: lang
|
||||
),
|
||||
caption: MacL10n.string("mac.stat.transcribed", language: lang),
|
||||
systemImage: "text.alignleft"
|
||||
)
|
||||
StatCard(
|
||||
title: MacL10n.string("mac.stat.translation", language: lang),
|
||||
value: UsageStatisticsStore.formatCount(
|
||||
stats.translationCharacterCount,
|
||||
language: lang
|
||||
),
|
||||
caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang),
|
||||
systemImage: "character.bubble"
|
||||
)
|
||||
StatCard(
|
||||
title: MacL10n.string("mac.stat.dictionary", language: lang),
|
||||
value: "\(viewModel.dictionaryTermCount)",
|
||||
caption: MacL10n.string("mac.stat.customTerms", language: lang),
|
||||
systemImage: "character.book.closed"
|
||||
)
|
||||
// MARK: - Hero
|
||||
|
||||
private var heroHeader: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(MacL10n.string("mac.brand.tagline", language: lang))
|
||||
.font(TypeStyle.pageTitle)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(MacL10n.string("mac.brand.tagline.subtitle", language: lang))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
if let appName = viewModel.foregroundAppName {
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.45))
|
||||
Text(MacL10n.format("mac.foregroundApp", language: lang, appName))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.transition(.opacity)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.animation(Motion.soft, value: viewModel.foregroundAppName)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var dictationCanvas: some View {
|
||||
MacCard(padding: Spacing.lg) {
|
||||
// MARK: - Stats (7-day chart + cumulative grid — shared cluster)
|
||||
|
||||
private var statCluster: some View {
|
||||
UsageStatsCluster(
|
||||
layout: .split,
|
||||
language: lang,
|
||||
points: stats.last7Days,
|
||||
dictationCharacterCount: stats.dictationCharacterCount,
|
||||
dictationDurationSeconds: stats.dictationDurationSeconds,
|
||||
translationCharacterCount: stats.translationCharacterCount,
|
||||
dictionaryTermCount: viewModel.dictionaryTermCount
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Dictation stage
|
||||
|
||||
private var dictationStage: some View {
|
||||
MacCard(padding: Spacing.md, cornerRadius: Radius.large) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if viewModel.transcript.isEmpty {
|
||||
Text(
|
||||
@@ -102,24 +110,37 @@ struct DashboardView: View {
|
||||
? MacL10n.string("mac.status.listening", language: lang)
|
||||
: MacL10n.string("mac.status.ready", language: lang)
|
||||
)
|
||||
.font(.system(size: 26, weight: .light))
|
||||
.font(.system(size: 22, weight: .light))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.contentTransition(.opacity)
|
||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: MacMetrics.dictationCanvasMinHeight,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
Text(viewModel.transcript)
|
||||
.font(.system(size: 22, weight: .regular))
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineSpacing(4)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: MacMetrics.dictationCanvasMinHeight,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: MacMetrics.dictationCanvasMinHeight, maxHeight: 160)
|
||||
}
|
||||
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Floating record bar
|
||||
@@ -142,12 +163,10 @@ struct BottomDictationBar: View {
|
||||
recordControl
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.macGlassSurface(in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous), fillOpacity: 1)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
// No surface fill — the mic bar sits on the page background so Home
|
||||
// stays flat and the canvas above can stay shorter without a second
|
||||
// floating card competing for height.
|
||||
}
|
||||
|
||||
private var readinessChip: some View {
|
||||
@@ -178,26 +197,36 @@ struct BottomDictationBar: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "translate")
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text(currentTranslationLabel)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.font(MacSettingsType.control)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 7)
|
||||
.macGlassSurface(in: Capsule(), fillOpacity: 0.66)
|
||||
.frame(minHeight: MacMetrics.settingsControlHeight)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
// 麦克风按钮始终居中固定:录音时的波形放进按钮内部,
|
||||
// “按停止”提示作为浮层显示在按钮上方,二者均不参与布局,
|
||||
// 因此按下 Option 触发录音时按钮位置不会发生偏移。
|
||||
// Mic stays geometrically centred: waveform lives inside the button,
|
||||
// “press stop” floats above — neither participates in layout.
|
||||
private var recordControl: some View {
|
||||
recordButton
|
||||
.overlay(alignment: .top) {
|
||||
@@ -225,7 +254,6 @@ struct BottomDictationBar: View {
|
||||
)
|
||||
Group {
|
||||
if viewModel.isRecording {
|
||||
// 与 iOS 一致:录音时在红色按钮内部显示实时波形
|
||||
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
|
||||
} else {
|
||||
Image(systemName: "mic.fill")
|
||||
|
||||
@@ -11,11 +11,16 @@
|
||||
final class MacAudioRecorder: @unchecked Sendable {
|
||||
enum RecorderError: Error, LocalizedError {
|
||||
case converterUnavailable
|
||||
case microphoneAccessDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .converterUnavailable:
|
||||
return "无法初始化音频转换器 / Failed to initialize audio converter"
|
||||
case .microphoneAccessDenied:
|
||||
return "麦克风权限被拒绝——请在「系统设置 → 隐私与安全性 → 麦克风」中启用"
|
||||
+ " / Microphone access denied — enable it in System Settings"
|
||||
+ " → Privacy & Security → Microphone"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +35,20 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
private var converter: AVAudioConverter?
|
||||
private let lock = NSLock()
|
||||
private var samples: [Float] = []
|
||||
private var snapshotContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
|
||||
private var isRunning = false
|
||||
/// Hard cap on accumulated audio: 10 minutes @16 kHz ≈ 38 MB of Float32.
|
||||
/// Recording is push-to-talk, but a stuck hotkey (or a latched Option
|
||||
/// key) would otherwise grow this buffer without bound; past the cap we
|
||||
/// keep the newest audio (drop from the front) so the take still ends
|
||||
/// with what the user last said.
|
||||
private static let maxSampleCount = 10 * 60 * 16_000
|
||||
/// Trim hysteresis: dropping from the front is an O(n) memmove of the
|
||||
/// whole ~38 MB buffer, done under the same lock the UI's level poll
|
||||
/// takes — doing it on EVERY tap callback once capped would stall the
|
||||
/// render thread ~12×/s. Let the buffer overshoot by 30 s and trim the
|
||||
/// whole excess in one move instead.
|
||||
private static let trimHysteresisSamples = 30 * 16_000
|
||||
private var smoothedLevel: Float = 0
|
||||
/// One-shot flag for the converter pull block. Taps are serialized per
|
||||
/// bus, so a plain instance property (not a captured local) is safe here.
|
||||
@@ -42,8 +60,51 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
lock.withLock { smoothedLevel }
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
lock.withLock { samples.removeAll(keepingCapacity: true) }
|
||||
/// Resolves microphone authorization before capture. Prompts on first
|
||||
/// use; throws `microphoneAccessDenied` once the user has declined so
|
||||
/// failures surface as a permission problem, not an empty transcription.
|
||||
private static func ensureMicrophoneAccess() async throws {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
return
|
||||
case .notDetermined:
|
||||
guard await AVCaptureDevice.requestAccess(for: .audio) else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
@unknown default:
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
try await Self.ensureMicrophoneAccess()
|
||||
try startEngine()
|
||||
}
|
||||
|
||||
/// Live 16 kHz mono snapshots for streaming ASR while the mic is open.
|
||||
/// The stream is finished automatically in `stop()`.
|
||||
func makeSnapshotStream() -> AsyncStream<AudioBufferSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
lock.withLock {
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = continuation
|
||||
}
|
||||
continuation.onTermination = { [weak self] _ in
|
||||
self?.lock.withLock {
|
||||
self?.snapshotContinuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startEngine() throws {
|
||||
lock.withLock {
|
||||
samples.removeAll(keepingCapacity: true)
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = nil
|
||||
}
|
||||
|
||||
let input = engine.inputNode
|
||||
let inputFormat = input.outputFormat(forBus: 0)
|
||||
@@ -67,6 +128,8 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
return lock.withLock {
|
||||
snapshotContinuation?.finish()
|
||||
snapshotContinuation = nil
|
||||
let out = samples
|
||||
samples.removeAll(keepingCapacity: false)
|
||||
return out
|
||||
@@ -105,8 +168,14 @@ final class MacAudioRecorder: @unchecked Sendable {
|
||||
|
||||
lock.withLock {
|
||||
samples.append(contentsOf: chunk)
|
||||
if samples.count > Self.maxSampleCount + Self.trimHysteresisSamples {
|
||||
samples.removeFirst(samples.count - Self.maxSampleCount)
|
||||
}
|
||||
let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
|
||||
smoothedLevel += (normalized - smoothedLevel) * factor
|
||||
snapshotContinuation?.yield(
|
||||
AudioBufferSnapshot(samples: chunk, sampleRate: 16_000)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// MacCloudASRChunkAdapter.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Adapts configured cloud ASR clients to the shared chunked utterance pipeline.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
final class MacCloudASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
|
||||
private let store: AppGroupStore
|
||||
private let client: CloudASRTranscribing
|
||||
private let cancelled = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
init(store: AppGroupStore) throws {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
|
||||
guard strategy != .localFallback else {
|
||||
throw MacDictationError.providerHasNoCloudASR
|
||||
}
|
||||
self.store = store
|
||||
self.client = CloudASRClientFactory.make(store: store)
|
||||
}
|
||||
|
||||
func prepare() async throws {
|
||||
try await client.prepare(dictionary: store.personalDictionary)
|
||||
}
|
||||
|
||||
func resetForNewUtterance() {
|
||||
cancelled.withLock { $0 = false }
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancelled.withLock { $0 = true }
|
||||
}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
let isCancelled = cancelled.withLock { $0 }
|
||||
if isCancelled || Task.isCancelled { return .cancelled }
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
|
||||
do {
|
||||
let text = try await client.transcribe(
|
||||
samples: samples,
|
||||
sampleRate: 16_000,
|
||||
locale: locale,
|
||||
dictionary: store.personalDictionary
|
||||
)
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return .success(trimmed)
|
||||
} catch is CancellationError {
|
||||
return .cancelled
|
||||
} catch {
|
||||
return .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,33 @@ import SwiftUI
|
||||
|
||||
/// Fixed metrics that keep every desktop surface on the same grid.
|
||||
enum MacMetrics {
|
||||
/// Uniform max width for trailing text controls (API key / model field).
|
||||
static let controlWidth: CGFloat = 240
|
||||
/// Shared height for credential inputs and icon buttons — matches the iOS
|
||||
/// settings controls (38).
|
||||
static let settingsControlHeight: CGFloat = 38
|
||||
/// Fixed label column for provider rows: wider than iOS (150) so the label
|
||||
/// text takes a larger share of the row and the trailing control narrows.
|
||||
static let settingLabelWidth: CGFloat = 200
|
||||
/// Uniform minimum height for every settings row, so rows read as an even
|
||||
/// list regardless of whether they hold a 38pt control or a single-line
|
||||
/// label. Content is centered within it; taller rows (status text, progress)
|
||||
/// grow past it.
|
||||
static let settingsRowMinHeight: CGFloat = 40
|
||||
/// The single vertical rhythm for settings cards: the gap *between* rows AND
|
||||
/// the padding between a card's top/bottom edge and its first/last row are
|
||||
/// both this value, so the card breathes evenly. Applied as `VStack(spacing:)`
|
||||
/// between rows and `.padding(.vertical:)` on the card body.
|
||||
static let settingsRowGap: CGFloat = Spacing.md
|
||||
/// Provider menu trigger width (legacy).
|
||||
static let selectWidth: CGFloat = 200
|
||||
/// Text-field max width for provider credential rows (narrower than the
|
||||
/// old 360pt so long keys truncate instead of wrapping when the window shrinks).
|
||||
static let fieldWidth: CGFloat = 280
|
||||
/// Legacy alias used by older call sites; prefer `fieldWidth`.
|
||||
static let controlWidth: CGFloat = fieldWidth
|
||||
/// Horizontal inset inside settings cards — matches History / Dictionary rows.
|
||||
static let settingsCardInset: CGFloat = Spacing.md
|
||||
/// Below this row width, provider rows stack label above control.
|
||||
static let settingsCompactBreakpoint: CGFloat = 520
|
||||
/// Sidebar width and the horizontal inset shared by brand, nav and footer.
|
||||
static let sidebarWidth: CGFloat = 240
|
||||
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
|
||||
@@ -25,11 +50,76 @@ enum MacMetrics {
|
||||
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
|
||||
/// Reading width for single-column content.
|
||||
static let contentMaxWidth: CGFloat = 720
|
||||
/// Horizontal inset for page titles and scroll *content* (cards).
|
||||
/// ScrollViews / Forms stay full-bleed so the scrollbar sits on the
|
||||
/// window edge; only the content inside is inset.
|
||||
/// Doubled from `Spacing.lg` so title + cards breathe from the edges.
|
||||
static let pageHorizontalInset: CGFloat = Spacing.lg * 2
|
||||
/// Built-in horizontal inset macOS grouped `Form` adds around its section
|
||||
/// cards, on top of any padding we apply. Subtracted from
|
||||
/// `pageHorizontalInset` on the Settings Form so its card outer edge lands
|
||||
/// on `pageHorizontalInset` — matching the History page and the page title.
|
||||
static let groupedFormSectionInset: CGFloat = Spacing.lg
|
||||
/// Default (= minimum) main-window size. Opening the app uses this size;
|
||||
/// the window cannot shrink below it.
|
||||
static let windowMinWidth: CGFloat = 860
|
||||
static let windowMinHeight: CGFloat = 600
|
||||
/// Compact dictation-canvas height so Home fits the min window without scrolling.
|
||||
static let dictationCanvasMinHeight: CGFloat = 120
|
||||
/// Top inset that clears the window traffic-light buttons now that the
|
||||
/// title bar is hidden.
|
||||
static let trafficLightInset: CGFloat = 28
|
||||
}
|
||||
|
||||
// MARK: - Settings typography (mirrors the iOS settings rows)
|
||||
|
||||
/// Type scale that maps 1:1 to the iOS settings controls so the desktop and
|
||||
/// phone read identically: row label / picker value at body (15), hints at
|
||||
/// caption2 (11), section header at caption2 (uppercased at the call site).
|
||||
enum MacSettingsType {
|
||||
static let sectionTitle = TypeStyle.caption2
|
||||
static let rowLabel = TypeStyle.body
|
||||
static let control = TypeStyle.body
|
||||
static let controlEmph = TypeStyle.bodyEmph
|
||||
static let hint = TypeStyle.caption2
|
||||
static let button = TypeStyle.body
|
||||
}
|
||||
|
||||
// MARK: - iOS-style toggle
|
||||
|
||||
/// Pill switch mirroring the iOS settings toggle: accent track when on, neutral
|
||||
/// when off, 16pt white knob, spring slide. Colours resolve through the active
|
||||
/// OSG palette so it follows the app theme.
|
||||
struct MacToggleStyle: ToggleStyle {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
Button {
|
||||
configuration.isOn.toggle()
|
||||
} label: {
|
||||
ZStack(alignment: configuration.isOn ? .trailing : .leading) {
|
||||
Capsule()
|
||||
.fill(configuration.isOn ? palette.accent : offTrackColor)
|
||||
Circle()
|
||||
.fill(Color.white)
|
||||
.frame(width: 16, height: 16)
|
||||
.shadow(color: .black.opacity(0.25), radius: 1, y: 1)
|
||||
.padding(2)
|
||||
}
|
||||
.frame(width: 36, height: 20)
|
||||
.animation(Motion.quick, value: configuration.isOn)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var offTrackColor: Color {
|
||||
colorScheme == .dark
|
||||
? Color.white.opacity(0.18)
|
||||
: Color.black.opacity(0.15)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Liquid Glass
|
||||
|
||||
private struct MacGlassSurface<S: Shape>: ViewModifier {
|
||||
@@ -59,26 +149,346 @@ extension View {
|
||||
|
||||
// MARK: - Text field styling
|
||||
|
||||
/// Text-field chrome aligned with native macOS Form controls: compact
|
||||
/// height, text-background fill, hairline border.
|
||||
/// Theme-aware credential field chrome — comfortable tap target,
|
||||
/// monospaced-friendly body size, and a fill/border that stays clearly
|
||||
/// distinct from the section card in both appearances.
|
||||
private struct MacFieldStyleModifier: ViewModifier {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
var monospaced: Bool = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
// Mirrors the iOS credential field: recessed surface-elevated fill,
|
||||
// medium radius, hairline border, comfortable 38pt tap target.
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
content
|
||||
.textFieldStyle(.plain)
|
||||
.font(TypeStyle.footnote)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 22)
|
||||
.background(Color(nsColor: .textBackgroundColor), in: shape)
|
||||
.font(monospaced ? TypeStyle.mono : TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: MacMetrics.settingsControlHeight)
|
||||
.background(fieldFill, in: shape)
|
||||
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
/// Light mode: the section card reads near-white, so a recessed `surfaceElevated`
|
||||
/// grey barely differs. Use the system text-input background (true white) so the
|
||||
/// field reads as an editable well. Dark mode keeps the elevated grey, which sits
|
||||
/// *lighter* than the card and already stands out.
|
||||
private var fieldFill: Color {
|
||||
#if os(macOS)
|
||||
if colorScheme != .dark {
|
||||
return Color(nsColor: .textBackgroundColor)
|
||||
}
|
||||
#endif
|
||||
return palette.surfaceElevated
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Theme-aware text-field styling for the settings inputs.
|
||||
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
|
||||
func macFieldStyle(monospaced: Bool = false) -> some View {
|
||||
modifier(MacFieldStyleModifier(monospaced: monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider settings chrome
|
||||
|
||||
/// Section header + `MacCard` shell — same outer alignment as History /
|
||||
/// Dictionary (title and card share `pageHorizontalInset` from the parent).
|
||||
struct MacSettingsSection<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let title: String
|
||||
var footer: String? = nil
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(title)
|
||||
.font(MacSettingsType.sectionTitle)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
MacCard(padding: 0) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
content()
|
||||
}
|
||||
// Card top/bottom breathe on the same rhythm as the gaps between
|
||||
// rows (rows own their own inter-row spacing via their container).
|
||||
.padding(.vertical, MacMetrics.settingsRowGap)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if let footer, !footer.isEmpty {
|
||||
Text(footer)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS-style inline row: title (+ optional subtitle) on the left, control
|
||||
/// right-aligned. Used by simple picker / toggle rows.
|
||||
struct MacInlineRow<Control: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
@ViewBuilder var control: () -> Control
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(title)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(MacSettingsType.hint)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: Spacing.sm)
|
||||
control()
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
}
|
||||
}
|
||||
|
||||
/// Backwards-compatible alias: title + optional subtitle + trailing control.
|
||||
struct MacFormSubtitleRow<Control: View>: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
@ViewBuilder var control: () -> Control
|
||||
|
||||
var body: some View {
|
||||
MacInlineRow(title: title, subtitle: subtitle) {
|
||||
control()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tappable navigation / action row inside a settings card.
|
||||
struct MacFormLinkRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let title: String
|
||||
var showsChevron: Bool = true
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(title)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: 0)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
/// Single merged card body for LLM / ASR provider configuration sections.
|
||||
struct MacSettingsProviderCard<Content: View>: View {
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: MacMetrics.settingsRowGap) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Responsive provider setting row
|
||||
|
||||
/// Label + control row for provider cards. Uses a two-column layout at
|
||||
/// comfortable widths and stacks vertically when horizontal space is tight.
|
||||
struct MacProviderSettingRow<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
/// Retained for source compatibility; the control now fills its column.
|
||||
var controlMaxWidth: CGFloat? = nil
|
||||
/// Cross-axis alignment between the label column and the control. Provider
|
||||
/// rows keep `.top` (model row grows a status line below its field); single
|
||||
/// control rows can pass `.center` to vertically center label and control.
|
||||
var verticalAlignment: VerticalAlignment = .top
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: verticalAlignment, spacing: Spacing.md) {
|
||||
label
|
||||
.frame(width: MacMetrics.settingLabelWidth, alignment: .leading)
|
||||
content()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
// Uniform row height (content centered); inter-row spacing is owned by the
|
||||
// enclosing card, so the row adds no vertical padding of its own.
|
||||
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(title)
|
||||
.font(MacSettingsType.rowLabel)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(MacSettingsType.hint)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
// Align the title with the first 38pt control row, not with any
|
||||
// status/help text that appears below the control.
|
||||
.frame(minHeight: MacMetrics.settingsControlHeight, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// Square icon button matching credential field height.
|
||||
struct MacSettingsIconButton: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let systemName: String
|
||||
var help: String? = nil
|
||||
var disabled: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary)
|
||||
.frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight)
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
.help(help ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact tool button for validate / fetch-models actions.
|
||||
struct MacSettingsToolButton: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let title: String
|
||||
/// Background fill. Defaults to the neutral elevated surface.
|
||||
var fill: Color? = nil
|
||||
/// Text color. Defaults to primary text (tertiary when disabled).
|
||||
var foreground: Color? = nil
|
||||
/// Hairline divider border — drawn for the neutral variant, hidden for
|
||||
/// colored fills (delete / download) so the fill reads as the button.
|
||||
var showsBorder: Bool = true
|
||||
var disabled: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(MacSettingsType.button)
|
||||
.foregroundStyle(resolvedForeground)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(minHeight: 34)
|
||||
.background(fill ?? palette.surfaceElevated, in: shape)
|
||||
.overlay {
|
||||
if showsBorder {
|
||||
shape.stroke(palette.divider, lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
private var resolvedForeground: Color {
|
||||
if disabled { return palette.textTertiary }
|
||||
return foreground ?? palette.textPrimary
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy setting row
|
||||
|
||||
/// Fixed label column + left-aligned control. Prefer `MacProviderSettingRow`
|
||||
/// for provider configuration cards.
|
||||
struct MacSettingRow<Content: View>: View {
|
||||
let title: String
|
||||
var controlMaxWidth: CGFloat? = nil
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
MacProviderSettingRow(title: title, controlMaxWidth: controlMaxWidth) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page header
|
||||
|
||||
/// Page title for History / Dictionary / Settings. Applies the shared
|
||||
/// `pageHorizontalInset` so its left edge matches inset card content below.
|
||||
/// Type size matches Home's brand line (`TypeStyle.pageTitle`).
|
||||
struct MacPageHeader<Trailing: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let title: String
|
||||
var subtitle: String?
|
||||
@ViewBuilder var trailing: () -> Trailing
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
@ViewBuilder trailing: @escaping () -> Trailing
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.trailing = trailing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(title)
|
||||
.font(TypeStyle.pageTitle)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
trailing()
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
}
|
||||
|
||||
extension MacPageHeader where Trailing == EmptyView {
|
||||
init(title: String, subtitle: String? = nil) {
|
||||
self.init(title: title, subtitle: subtitle) { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card container
|
||||
@@ -87,10 +497,11 @@ extension View {
|
||||
struct MacCard<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
var padding: CGFloat = Spacing.md
|
||||
var cornerRadius: CGFloat = Radius.medium
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
content()
|
||||
.padding(padding)
|
||||
@@ -102,47 +513,6 @@ struct MacCard<Content: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat tile
|
||||
|
||||
struct StatCard: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let title: String
|
||||
let value: String
|
||||
let caption: String
|
||||
var systemImage: String?
|
||||
var accent: Bool = false
|
||||
|
||||
var body: some View {
|
||||
MacCard {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack {
|
||||
Text(title.uppercased())
|
||||
.font(TypeStyle.caption2)
|
||||
.tracking(0.6)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer()
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(accent ? palette.accent : palette.textTertiary)
|
||||
}
|
||||
}
|
||||
Text(value)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(accent ? palette.accent : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.contentTransition(.numericText())
|
||||
.animation(Motion.soft, value: value)
|
||||
Text(caption)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live waveform
|
||||
|
||||
/// Compact bar visualiser reacting to the input level while recording.
|
||||
@@ -152,30 +522,39 @@ struct MiniWaveform: View {
|
||||
var barCount: Int = 5
|
||||
/// Pass nil to inherit the palette accent automatically.
|
||||
var tint: Color?
|
||||
/// Peak bar height; overlay HUD uses a taller meter than the mic button.
|
||||
var maxBarHeight: CGFloat = 22
|
||||
var barWidth: CGFloat = 3
|
||||
var barSpacing: CGFloat = 3
|
||||
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
HStack(spacing: barSpacing) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(tint ?? palette.accent)
|
||||
.frame(width: 3, height: barHeight(index))
|
||||
.frame(width: barWidth, height: barHeight(index))
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
.frame(height: maxBarHeight)
|
||||
.animation(Motion.instant, value: level)
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||
withAnimation(.linear(duration: 0.75).repeatForever(autoreverses: true)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func barHeight(_ index: Int) -> CGFloat {
|
||||
let base = CGFloat(level) * 22
|
||||
let wobble = sin((phase * .pi * 2) + CGFloat(index)) * 4 + 4
|
||||
return max(4, min(22, base * (0.6 + CGFloat(index % 2) * 0.4) + wobble))
|
||||
// Stronger level coupling + staggered phase so the meter reads as
|
||||
// "alive" even at modest mic levels.
|
||||
let boosted = min(1, CGFloat(level) * 1.35 + 0.08)
|
||||
let base = boosted * maxBarHeight
|
||||
let wobble = sin((phase * .pi * 2) + CGFloat(index) * 0.85) * (maxBarHeight * 0.22)
|
||||
+ (maxBarHeight * 0.12)
|
||||
let parity = 0.55 + CGFloat(index % 3) * 0.2
|
||||
return max(maxBarHeight * 0.18, min(maxBarHeight, base * parity + wobble))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +574,8 @@ enum MacTranslationDisplay {
|
||||
|
||||
// MARK: - Status footer
|
||||
|
||||
/// Bottom status strip: engine mode (cloud/local), translation target, and
|
||||
/// the connection state — icons and wording mirror the dashboard record bar.
|
||||
/// Bottom status strip: engine mode, translation target, connection —
|
||||
/// kept visually quiet so it never competes with the record bar.
|
||||
struct MacStatusFooter: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
@@ -204,32 +583,129 @@ struct MacStatusFooter: View {
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Spacer()
|
||||
|
||||
// Current models, shown just before the engine mode. Label text is
|
||||
// the model name only; the full "provider · model" lives in the
|
||||
// hover tooltip to keep the strip quiet.
|
||||
Label(asrModel.name, systemImage: "waveform")
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: 150, alignment: .trailing)
|
||||
.help(asrModel.tooltip)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
Label(llmModel.name, systemImage: "sparkles")
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: 150, alignment: .trailing)
|
||||
.help(llmModel.tooltip)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
Label(
|
||||
viewModel.isCloudMode
|
||||
? MacL10n.string("mac.mode.cloud", language: lang)
|
||||
: MacL10n.string("mac.mode.local", language: lang),
|
||||
systemImage: viewModel.isCloudMode ? "cloud" : "cpu"
|
||||
)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
Label(
|
||||
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
|
||||
systemImage: "translate"
|
||||
)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
|
||||
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
|
||||
.foregroundStyle(palette.accent)
|
||||
.foregroundStyle(palette.accent.opacity(0.85))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.animation(Motion.quick, value: viewModel.isCloudMode)
|
||||
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
|
||||
.animation(Motion.quick, value: asrModel.name)
|
||||
.animation(Motion.quick, value: llmModel.name)
|
||||
}
|
||||
|
||||
// MARK: - Current model resolution
|
||||
|
||||
/// ASR: cloud shows the configured provider's model (or its default);
|
||||
/// local shows the selected on-device model's display name.
|
||||
private var asrModel: (name: String, tooltip: String) {
|
||||
let config = viewModel.config
|
||||
if viewModel.isCloudMode {
|
||||
let providerName = ProviderDisplayName.name(for: config.asrProviderId, language: lang)
|
||||
let model = config.asrModel.isEmpty
|
||||
? CloudASRModelCatalog.defaultModel(for: config.asrProviderId)
|
||||
: config.asrModel
|
||||
return (model, "\(providerName) · \(model)")
|
||||
}
|
||||
let providerName = MacL10n.string("mac.mode.local", language: lang)
|
||||
let model = MacLocalASRModelName.displayName(for: MacLocalASRPreferences.selectedModelId, language: lang)
|
||||
return (model, "\(providerName) · \(model)")
|
||||
}
|
||||
|
||||
/// LLM: cloud uses the configured provider; local routes through
|
||||
/// `localModeProviderId` (built-in DeepSeek unless the user supplied a key).
|
||||
private var llmModel: (name: String, tooltip: String) {
|
||||
let config = viewModel.config
|
||||
let providerId = viewModel.isCloudMode ? config.providerId : config.localModeProviderId
|
||||
let providerName = ProviderDisplayName.name(for: providerId, language: lang)
|
||||
let model: String
|
||||
if providerId == config.providerId, !config.model.isEmpty {
|
||||
model = config.model
|
||||
} else {
|
||||
model = LLMProvider.provider(id: providerId).defaultModel
|
||||
}
|
||||
return (model, "\(providerName) · \(model)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached lookup from a local ASR model id to its catalog display name. The
|
||||
/// bundled catalog is small and immutable, so decoding it once is enough.
|
||||
enum MacLocalASRModelName {
|
||||
private static let catalog: LocalASRCatalogDocument? = try? LocalASRModelCatalog.loadBundled()
|
||||
|
||||
static func displayName(for modelId: String, language: AppUILanguage) -> String {
|
||||
if !modelId.isEmpty,
|
||||
let catalog,
|
||||
let model = LocalASRModelCatalog.model(modelId, in: catalog) {
|
||||
return model.displayName
|
||||
}
|
||||
return modelId.isEmpty ? MacL10n.string("mac.mode.local", language: language) : modelId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Form alignment
|
||||
|
||||
private struct MacFormPageAlignModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
// Form stays full-bleed (scrollbar on the window edge). Section
|
||||
// cards are inset to match `MacPageHeader`.
|
||||
content
|
||||
.contentMargins(.horizontal, MacMetrics.pageHorizontalInset, for: .scrollContent)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Insets grouped-`Form` section cards to `pageHorizontalInset`.
|
||||
func macFormPageAligned() -> some View {
|
||||
modifier(MacFormPageAlignModifier())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ struct MacContentView: View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
header
|
||||
recordButton
|
||||
Text(MacL10n.string("mac.hint.holdOption", language: lang))
|
||||
Text(MacL10n.string(viewModel.hotkeyTrigger.hintKey, language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// MacDictationOverlayController.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Owns a borderless, non-activating floating NSPanel that hosts the
|
||||
// dictation HUD. Shown for any recording path (hotkey, menu bar, main
|
||||
// window) and dismissed after a short success beat when processing ends.
|
||||
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class MacDictationOverlayController {
|
||||
static let shared = MacDictationOverlayController()
|
||||
|
||||
private var panel: NSPanel?
|
||||
private var hosting: NSHostingView<AnyView>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var hideWorkItem: DispatchWorkItem?
|
||||
/// Keeps the pill visible briefly after a successful delivery.
|
||||
private var showingCompletion = false
|
||||
private var wasBusy = false
|
||||
|
||||
private let bottomMargin: CGFloat = 36
|
||||
private let fallbackSize = NSSize(width: 400, height: 52)
|
||||
|
||||
private init() {}
|
||||
|
||||
func start(observing viewModel: MacDictationViewModel) {
|
||||
guard cancellables.isEmpty else { return }
|
||||
|
||||
Publishers.CombineLatest3(
|
||||
viewModel.$isRecording,
|
||||
viewModel.$isPreparingToRecord,
|
||||
viewModel.$isProcessing
|
||||
)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] recording, preparing, processing in
|
||||
self?.handleBusyChange(
|
||||
recording: recording,
|
||||
preparing: preparing,
|
||||
processing: processing,
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Keep waveform / app name / copy fresh while visible.
|
||||
viewModel.objectWillChange
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self, self.panel?.isVisible == true else { return }
|
||||
self.refreshContent(viewModel: viewModel)
|
||||
self.resizeToFit()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.reposition() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleBusyChange(
|
||||
recording: Bool,
|
||||
preparing: Bool,
|
||||
processing: Bool,
|
||||
viewModel: MacDictationViewModel
|
||||
) {
|
||||
let busy = recording || preparing || processing
|
||||
|
||||
if busy {
|
||||
hideWorkItem?.cancel()
|
||||
hideWorkItem = nil
|
||||
showingCompletion = false
|
||||
wasBusy = true
|
||||
present(viewModel: viewModel)
|
||||
return
|
||||
}
|
||||
|
||||
// Transition: busy → idle. Flash a short "done" state, then hide.
|
||||
if wasBusy {
|
||||
wasBusy = false
|
||||
showingCompletion = true
|
||||
present(viewModel: viewModel)
|
||||
scheduleHide()
|
||||
return
|
||||
}
|
||||
|
||||
if !showingCompletion {
|
||||
hideImmediately()
|
||||
}
|
||||
}
|
||||
|
||||
private func present(viewModel: MacDictationViewModel) {
|
||||
ensurePanel(viewModel: viewModel)
|
||||
refreshContent(viewModel: viewModel)
|
||||
resizeToFit()
|
||||
reposition()
|
||||
|
||||
guard let panel else { return }
|
||||
if panel.isVisible {
|
||||
// Already up — still bump to front in case another space stole it.
|
||||
panel.orderFrontRegardless()
|
||||
return
|
||||
}
|
||||
|
||||
panel.alphaValue = 0
|
||||
panel.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.22
|
||||
panel.animator().alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePanel(viewModel: MacDictationViewModel) {
|
||||
if panel != nil { return }
|
||||
|
||||
let host = NSHostingView(rootView: makeRoot(viewModel: viewModel))
|
||||
host.frame = NSRect(origin: .zero, size: fallbackSize)
|
||||
hosting = host
|
||||
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(origin: .zero, size: fallbackSize),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.contentView = host
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = false
|
||||
// Above normal floating windows so the HUD stays visible over browsers /
|
||||
// full-screen apps, without going as high as the screen saver.
|
||||
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.floatingWindow)) + 1)
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
panel.isMovableByWindowBackground = false
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.ignoresMouseEvents = false
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
self.panel = panel
|
||||
}
|
||||
|
||||
private func refreshContent(viewModel: MacDictationViewModel) {
|
||||
hosting?.rootView = makeRoot(viewModel: viewModel)
|
||||
}
|
||||
|
||||
private func makeRoot(viewModel: MacDictationViewModel) -> AnyView {
|
||||
AnyView(
|
||||
MacDictationOverlayView(viewModel: viewModel)
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
|
||||
)
|
||||
}
|
||||
|
||||
private func resizeToFit() {
|
||||
guard let panel, let hosting else { return }
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
let fitting = hosting.fittingSize
|
||||
let width = fitting.width.isFinite && fitting.width > 1
|
||||
? min(max(fitting.width, 300), 520)
|
||||
: fallbackSize.width
|
||||
let height = fitting.height.isFinite && fitting.height > 1
|
||||
? max(fitting.height, fallbackSize.height)
|
||||
: fallbackSize.height
|
||||
var frame = panel.frame
|
||||
let midX = frame.midX
|
||||
frame.size = NSSize(width: width, height: height)
|
||||
if midX.isFinite {
|
||||
frame.origin.x = midX - width / 2
|
||||
}
|
||||
panel.setFrame(frame, display: true)
|
||||
hosting.frame = NSRect(origin: .zero, size: frame.size)
|
||||
}
|
||||
|
||||
private func reposition() {
|
||||
guard let panel else { return }
|
||||
let screen = NSScreen.main ?? NSScreen.screens.first
|
||||
guard let visible = screen?.visibleFrame else { return }
|
||||
let size = panel.frame.size
|
||||
let origin = NSPoint(
|
||||
x: visible.midX - size.width / 2,
|
||||
y: visible.minY + bottomMargin
|
||||
)
|
||||
panel.setFrameOrigin(origin)
|
||||
}
|
||||
|
||||
private func scheduleHide() {
|
||||
hideWorkItem?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.showingCompletion = false
|
||||
self?.hideAnimated()
|
||||
}
|
||||
hideWorkItem = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.15, execute: work)
|
||||
}
|
||||
|
||||
private func hideImmediately() {
|
||||
hideWorkItem?.cancel()
|
||||
hideWorkItem = nil
|
||||
showingCompletion = false
|
||||
panel?.orderOut(nil)
|
||||
panel?.alphaValue = 1
|
||||
}
|
||||
|
||||
private func hideAnimated() {
|
||||
guard let panel, panel.isVisible else {
|
||||
hideImmediately()
|
||||
return
|
||||
}
|
||||
NSAnimationContext.runAnimationGroup({ ctx in
|
||||
ctx.duration = 0.2
|
||||
panel.animator().alphaValue = 0
|
||||
}, completionHandler: { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.panel?.orderOut(nil)
|
||||
self?.panel?.alphaValue = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// MacDictationOverlayView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Compact bottom-of-screen HUD shown while dictating. Lives inside a
|
||||
// non-activating NSPanel so it never steals focus from the front app.
|
||||
// One-line layout: status / live transcript preview + waveform + stop.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacDictationOverlayView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
private var isBusy: Bool {
|
||||
viewModel.isRecording || viewModel.isPreparingToRecord || viewModel.isProcessing
|
||||
}
|
||||
|
||||
/// Trimmed live / final transcript for the single-line preview.
|
||||
private var previewText: String {
|
||||
viewModel.transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var hasPreview: Bool { !previewText.isEmpty }
|
||||
|
||||
private var showsLiveBadge: Bool {
|
||||
viewModel.isRecording && viewModel.isStreamingPartial
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
statusDot
|
||||
primaryLine
|
||||
Spacer(minLength: Spacing.xs)
|
||||
trailingControl
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, 11)
|
||||
.frame(minWidth: 300, idealWidth: 400, maxWidth: 520)
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
.background(palette.surface, in: Capsule(style: .continuous))
|
||||
.overlay(
|
||||
Capsule(style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
|
||||
.padding(2)
|
||||
.animation(Motion.soft, value: hasPreview)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
.animation(Motion.quick, value: viewModel.isStreamingPartial)
|
||||
}
|
||||
|
||||
// MARK: - Primary line (status or one-line transcript)
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryLine: some View {
|
||||
if hasPreview {
|
||||
HStack(spacing: 6) {
|
||||
if showsLiveBadge {
|
||||
liveBadge
|
||||
}
|
||||
Text(previewText)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(maxWidth: showsLiveBadge ? 280 : 320, alignment: .leading)
|
||||
.contentTransition(.opacity)
|
||||
.animation(Motion.quick, value: previewText)
|
||||
.accessibilityLabel(previewText)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
Text(statusText)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.contentTransition(.opacity)
|
||||
if let appName = viewModel.foregroundAppName, isBusy {
|
||||
Text("·")
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.45))
|
||||
Text(appName)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.animation(Motion.quick, value: statusText)
|
||||
}
|
||||
}
|
||||
|
||||
private var liveBadge: some View {
|
||||
Text(MacL10n.string("mac.overlay.live", language: lang))
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(palette.recordRed)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(palette.recordRed.opacity(0.12), in: Capsule(style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var statusDot: some View {
|
||||
Circle()
|
||||
.fill(dotColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
.animation(Motion.quick, value: viewModel.isProcessing)
|
||||
.animation(Motion.quick, value: viewModel.isPreparingToRecord)
|
||||
.animation(Motion.quick, value: viewModel.isStreamingPartial)
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
if viewModel.isRecording {
|
||||
return viewModel.isStreamingPartial ? palette.accent : palette.recordRed
|
||||
}
|
||||
if viewModel.isPreparingToRecord || viewModel.isProcessing { return palette.warning }
|
||||
return palette.accent
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if viewModel.isRecording {
|
||||
return MacL10n.string("mac.overlay.listening", language: lang)
|
||||
}
|
||||
if viewModel.isPreparingToRecord {
|
||||
return MacL10n.string("mac.overlay.preparing", language: lang)
|
||||
}
|
||||
if viewModel.isProcessing {
|
||||
if viewModel.isStreamingPartial {
|
||||
return MacL10n.string("mac.overlay.polishing", language: lang)
|
||||
}
|
||||
return MacL10n.string("mac.overlay.transcribing", language: lang)
|
||||
}
|
||||
return MacL10n.string("mac.overlay.done", language: lang)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingControl: some View {
|
||||
if viewModel.isRecording {
|
||||
MiniWaveform(
|
||||
level: viewModel.audioLevel,
|
||||
barCount: 7,
|
||||
tint: (viewModel.isStreamingPartial ? palette.accent : palette.recordRed)
|
||||
.opacity(0.9),
|
||||
maxBarHeight: 28,
|
||||
barWidth: 3.5,
|
||||
barSpacing: 2.5
|
||||
)
|
||||
stopButton
|
||||
} else if viewModel.isPreparingToRecord || viewModel.isProcessing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
|
||||
private var stopButton: some View {
|
||||
Button(action: viewModel.toggleRecording) {
|
||||
Image(systemName: "stop.fill")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(palette.recordRed, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(MacL10n.string("mac.record.stop", language: lang))
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
// MacDictationPipeline.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Dictation pipeline: samples → ASR (cloud or local) → polish.
|
||||
// Cloud path reuses `CloudASRClientFactory`; local path uses Qwen3-ASR (MLX)
|
||||
// with Apple Speech fallback when weights are missing.
|
||||
// Dictation pipeline: samples → ASR (cloud or local, chunked when long) → polish.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -24,39 +22,61 @@ enum MacDictationError: Error, LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of ASR that ran while the microphone was still open.
|
||||
struct MacLiveASRCaptureResult: Sendable {
|
||||
let raw: String
|
||||
let chunkWarning: String?
|
||||
let localBias: LocalASRBiasPayload?
|
||||
/// When true, callers should fall back to batch ASR on the recorded samples.
|
||||
let shouldFallbackToBatch: Bool
|
||||
}
|
||||
|
||||
enum MacDictationPipeline {
|
||||
/// Runs ASR then best-effort polish. Polish failures fall back to raw text.
|
||||
static func run(samples: [Float], store: AppGroupStore) async throws -> String {
|
||||
/// First-chunk threshold: longer local utterances use pipelined chunk ASR.
|
||||
private static let chunkedLocalThresholdSamples = Int(
|
||||
FlowUtteranceChunkConfig.flowDefault.maxChunkDurationSeconds(forChunkIndex: 0) * 16_000
|
||||
)
|
||||
|
||||
/// Whether the active engine can surface `onPartial` text while recording.
|
||||
static func supportsLivePartials(store: AppGroupStore) -> Bool {
|
||||
if store.engineMode == "local" { return true }
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
|
||||
return strategy != .localFallback
|
||||
}
|
||||
|
||||
/// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning.
|
||||
static func run(
|
||||
samples: [Float],
|
||||
store: AppGroupStore,
|
||||
onPartial: (@Sendable (String) -> Void)? = nil
|
||||
) async throws -> MacDictationResult {
|
||||
guard !samples.isEmpty else { throw MacDictationError.noAudio }
|
||||
|
||||
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
|
||||
let locale = resolvedLocale(store: store)
|
||||
var chunkWarning: String?
|
||||
let raw: String
|
||||
var localBias: LocalASRBiasPayload?
|
||||
|
||||
if store.engineMode == "local" {
|
||||
MacAppContextService.captureAndPersist(to: store)
|
||||
let capabilities = MacLocalASRService.currentCapabilities()
|
||||
let bias = LocalASRBiasAdapter.adapt(
|
||||
LocalASRBiasRequest(
|
||||
dictionary: store.personalDictionary,
|
||||
localBias = resolveLocalBias(store: store, locale: locale)
|
||||
if samples.count > chunkedLocalThresholdSamples {
|
||||
let chunked = try await transcribeLocalChunked(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
|
||||
capabilities: capabilities
|
||||
bias: localBias,
|
||||
onPartial: onPartial
|
||||
)
|
||||
)
|
||||
localBias = bias
|
||||
LocalASRBiasDiagnosticsStore.save(
|
||||
payload: bias,
|
||||
modelId: MacLocalASRService.selectedModelDefinition()?.id,
|
||||
backendLabel: MacLocalASRService.currentBackendLabel()
|
||||
)
|
||||
raw = try await MacLocalASRService.transcribe(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
bias: bias
|
||||
)
|
||||
raw = chunked.text
|
||||
chunkWarning = chunked.chunkWarning
|
||||
} else {
|
||||
raw = try await MacLocalASRService.transcribe(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
bias: localBias
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId)
|
||||
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
|
||||
|
||||
let client = CloudASRClientFactory.make(store: store)
|
||||
@@ -69,6 +89,85 @@ enum MacDictationPipeline {
|
||||
)
|
||||
}
|
||||
|
||||
return try await polishCapturedASR(
|
||||
raw: raw,
|
||||
store: store,
|
||||
localBias: localBias,
|
||||
chunkWarning: chunkWarning
|
||||
)
|
||||
}
|
||||
|
||||
/// Consumes a live mic snapshot stream until finished; yields stitched partials.
|
||||
static func captureLive(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
store: AppGroupStore,
|
||||
onPartial: @escaping @Sendable (String) -> Void
|
||||
) async -> MacLiveASRCaptureResult {
|
||||
let locale = resolvedLocale(store: store)
|
||||
let localBias: LocalASRBiasPayload?
|
||||
if store.engineMode == "local" {
|
||||
localBias = resolveLocalBias(store: store, locale: locale)
|
||||
} else {
|
||||
localBias = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let adapter = try makeChunkASRAdapter(
|
||||
store: store,
|
||||
locale: locale,
|
||||
bias: localBias
|
||||
)
|
||||
if let cloudAdapter = adapter as? MacCloudASRChunkAdapter {
|
||||
try? await cloudAdapter.prepare()
|
||||
}
|
||||
|
||||
let pipeline = ChunkedUtterancePipeline(
|
||||
asr: adapter,
|
||||
locale: locale,
|
||||
config: .flowDefault
|
||||
)
|
||||
let outcome = await pipeline.transcribe(stream: stream, onPartial: onPartial)
|
||||
|
||||
switch outcome {
|
||||
case .success(let success):
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: success.text,
|
||||
chunkWarning: success.chunkWarnings.first,
|
||||
localBias: localBias,
|
||||
shouldFallbackToBatch: false
|
||||
)
|
||||
case .failure:
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: "",
|
||||
chunkWarning: nil,
|
||||
localBias: localBias,
|
||||
shouldFallbackToBatch: true
|
||||
)
|
||||
case .cancelled:
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: "",
|
||||
chunkWarning: nil,
|
||||
localBias: localBias,
|
||||
shouldFallbackToBatch: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: "",
|
||||
chunkWarning: nil,
|
||||
localBias: localBias,
|
||||
shouldFallbackToBatch: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Polish-only step after live or batch ASR has produced raw text.
|
||||
static func polishCapturedASR(
|
||||
raw: String,
|
||||
store: AppGroupStore,
|
||||
localBias: LocalASRBiasPayload?,
|
||||
chunkWarning: String?
|
||||
) async throws -> MacDictationResult {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
|
||||
|
||||
@@ -91,14 +190,127 @@ enum MacDictationPipeline {
|
||||
polishContext = nil
|
||||
}
|
||||
|
||||
if let polished = try? await PolishingService(store: store).polish(
|
||||
postASR,
|
||||
mode: store.polishModeForPipeline,
|
||||
context: polishContext
|
||||
),
|
||||
!polished.isEmpty {
|
||||
return polished
|
||||
do {
|
||||
let polished = try await PolishingService(store: store).polish(
|
||||
postASR,
|
||||
mode: store.polishModeForPipeline,
|
||||
context: polishContext
|
||||
)
|
||||
guard !polished.isEmpty else {
|
||||
throw PolishingService.PolishError.noTranscript
|
||||
}
|
||||
return MacDictationResult(
|
||||
text: polished,
|
||||
polishWarning: nil,
|
||||
chunkWarning: chunkWarning
|
||||
)
|
||||
} catch {
|
||||
let delivery = TranscriptionPolishFallback.makeDelivery(
|
||||
rawText: postASR,
|
||||
error: error,
|
||||
engineMode: store.engineMode,
|
||||
chunkWarning: chunkWarning
|
||||
)
|
||||
return MacDictationResult(
|
||||
text: delivery.text,
|
||||
polishWarning: delivery.polishWarning,
|
||||
chunkWarning: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chunked local ASR
|
||||
|
||||
private struct ChunkedLocalResult {
|
||||
let text: String
|
||||
let chunkWarning: String?
|
||||
}
|
||||
|
||||
private static func resolvedLocale(store: AppGroupStore) -> Locale {
|
||||
Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
|
||||
}
|
||||
|
||||
private static func resolveLocalBias(
|
||||
store: AppGroupStore,
|
||||
locale: Locale
|
||||
) -> LocalASRBiasPayload? {
|
||||
MacAppContextService.captureAndPersist(to: store)
|
||||
let capabilities = MacLocalASRService.currentCapabilities()
|
||||
let bias = LocalASRBiasAdapter.adapt(
|
||||
LocalASRBiasRequest(
|
||||
dictionary: store.personalDictionary,
|
||||
locale: locale,
|
||||
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
|
||||
capabilities: capabilities
|
||||
)
|
||||
)
|
||||
LocalASRBiasDiagnosticsStore.save(
|
||||
payload: bias,
|
||||
modelId: MacLocalASRService.selectedModelDefinition()?.id,
|
||||
backendLabel: MacLocalASRService.currentBackendLabel()
|
||||
)
|
||||
return bias
|
||||
}
|
||||
|
||||
private static func makeChunkASRAdapter(
|
||||
store: AppGroupStore,
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload?
|
||||
) throws -> any ASRChunkTranscribing {
|
||||
if store.engineMode == "local" {
|
||||
return MacLocalASRChunkAdapter(locale: locale, bias: bias)
|
||||
}
|
||||
return try MacCloudASRChunkAdapter(store: store)
|
||||
}
|
||||
|
||||
private static func transcribeLocalChunked(
|
||||
samples: [Float],
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload?,
|
||||
onPartial: (@Sendable (String) -> Void)?
|
||||
) async throws -> ChunkedLocalResult {
|
||||
let adapter = MacLocalASRChunkAdapter(locale: locale, bias: bias)
|
||||
let pipeline = ChunkedUtterancePipeline(
|
||||
asr: adapter,
|
||||
locale: locale,
|
||||
config: .flowDefault
|
||||
)
|
||||
let outcome = await pipeline.transcribe(
|
||||
stream: audioStream(from: samples),
|
||||
onPartial: { partial in
|
||||
onPartial?(partial)
|
||||
}
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
case .success(let success):
|
||||
let warning = success.chunkWarnings.first
|
||||
return ChunkedLocalResult(text: success.text, chunkWarning: warning)
|
||||
case .failure(let message):
|
||||
throw MacLocalASRError.qwen3InferenceFailed(message)
|
||||
case .cancelled:
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds recorded PCM into the chunker as if it arrived incrementally.
|
||||
private static func audioStream(
|
||||
from samples: [Float],
|
||||
sliceSamples: Int = 8_000
|
||||
) -> AsyncStream<AudioBufferSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
var offset = 0
|
||||
while offset < samples.count {
|
||||
let end = min(offset + sliceSamples, samples.count)
|
||||
continuation.yield(
|
||||
AudioBufferSnapshot(
|
||||
samples: Array(samples[offset..<end]),
|
||||
sampleRate: 16_000
|
||||
)
|
||||
)
|
||||
offset = end
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
return postASR
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
@@ -28,7 +28,7 @@ enum MacSection: String, CaseIterable, Identifiable {
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .dashboard: return "house"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
case .dictionary: return "character.book.closed"
|
||||
case .settings: return "gearshape"
|
||||
@@ -45,7 +45,13 @@ final class MacDictationViewModel: ObservableObject {
|
||||
@Published var selectedSection: MacSection = .dashboard
|
||||
|
||||
@Published var isRecording = false
|
||||
/// True while microphone permission / engine start is in flight.
|
||||
/// Drives the overlay so the HUD appears on Option-down immediately,
|
||||
/// instead of waiting for the async `beginRecording` to finish.
|
||||
@Published private(set) var isPreparingToRecord = false
|
||||
@Published var isProcessing = false
|
||||
/// True once live ASR has surfaced at least one partial during this take.
|
||||
@Published private(set) var isStreamingPartial = false
|
||||
@Published var transcript = ""
|
||||
@Published var statusMessage = ""
|
||||
@Published var audioLevel: Float = 0
|
||||
@@ -55,6 +61,7 @@ final class MacDictationViewModel: ObservableObject {
|
||||
|
||||
@Published var autoPasteEnabled: Bool
|
||||
@Published var hotkeyEnabled: Bool
|
||||
@Published var hotkeyTrigger: MacHotkeyTrigger
|
||||
|
||||
@Published var config: ProviderConfig
|
||||
|
||||
@@ -64,6 +71,12 @@ final class MacDictationViewModel: ObservableObject {
|
||||
private var levelTimer: Timer?
|
||||
private var sessionTimer: Timer?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// In-flight `beginRecording` started by the hotkey — cancelled if the
|
||||
/// key is released before the engine is ready (avoids a stuck session).
|
||||
private var hotkeyBeginTask: Task<Void, Never>?
|
||||
/// Live chunked ASR while recording (cloud / supported local paths).
|
||||
/// Finished in `finishRecording` so partials can become the final draft.
|
||||
private var liveCaptureTask: Task<MacLiveASRCaptureResult, Never>?
|
||||
|
||||
let usageStatistics: UsageStatisticsStore
|
||||
let speechHistory = SpeechHistoryStore.shared
|
||||
@@ -71,6 +84,7 @@ final class MacDictationViewModel: ObservableObject {
|
||||
private enum StoredKeys {
|
||||
static let autoPaste = "mac.autoPasteEnabled"
|
||||
static let hotkey = "mac.hotkeyEnabled"
|
||||
static let hotkeyTrigger = MacHotkeyTrigger.storageKey
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@@ -78,7 +92,12 @@ final class MacDictationViewModel: ObservableObject {
|
||||
self.config = ProviderConfig(defaults: defaults)
|
||||
self.usageStatistics = UsageStatisticsStore(defaults: defaults)
|
||||
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true
|
||||
self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true
|
||||
// Global hotkey is always on — the settings toggle was removed, so any
|
||||
// previously-stored "off" value is intentionally ignored.
|
||||
self.hotkeyEnabled = true
|
||||
self.hotkeyTrigger = MacHotkeyTrigger(
|
||||
rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? ""
|
||||
) ?? .rightOption
|
||||
|
||||
MacICloudSyncBootstrap.configure(defaults: defaults)
|
||||
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
|
||||
@@ -114,10 +133,16 @@ final class MacDictationViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
var polishSelectableProviders: [LLMProvider] {
|
||||
LLMProvider.userSelectablePresets
|
||||
}
|
||||
|
||||
var asrSelectableProviders: [LLMProvider] {
|
||||
LLMProvider.asrSelectablePresets
|
||||
}
|
||||
|
||||
var selectableProviders: [LLMProvider] {
|
||||
LLMProvider.presets.filter {
|
||||
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
|
||||
}
|
||||
asrSelectableProviders
|
||||
}
|
||||
|
||||
var dictionaryTermCount: Int {
|
||||
@@ -185,6 +210,12 @@ final class MacDictationViewModel: ObservableObject {
|
||||
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
|
||||
}
|
||||
|
||||
func setHotkeyTrigger(_ trigger: MacHotkeyTrigger) {
|
||||
hotkeyTrigger = trigger
|
||||
defaults.set(trigger.rawValue, forKey: StoredKeys.hotkeyTrigger)
|
||||
hotkeyService.trigger = trigger
|
||||
}
|
||||
|
||||
func setEngineMode(_ mode: String) {
|
||||
config.engineMode = mode
|
||||
}
|
||||
@@ -192,23 +223,45 @@ final class MacDictationViewModel: ObservableObject {
|
||||
// MARK: - Recording
|
||||
|
||||
func toggleRecording() {
|
||||
if isRecording { finishRecording() } else { beginRecording() }
|
||||
if isRecording || isPreparingToRecord {
|
||||
cancelOrFinishRecording()
|
||||
} else {
|
||||
Task { await beginRecording() }
|
||||
}
|
||||
}
|
||||
|
||||
func beginRecording() {
|
||||
guard !isProcessing else { return }
|
||||
func beginRecording() async {
|
||||
guard !isProcessing, !isRecording, !isPreparingToRecord else { return }
|
||||
isPreparingToRecord = true
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
MacAppContextService.captureAndPersist(to: store)
|
||||
refreshForegroundAppName()
|
||||
|
||||
do {
|
||||
try recorder.start()
|
||||
try await recorder.start()
|
||||
// Hotkey may have been released while we awaited mic permission /
|
||||
// engine start — abandon cleanly instead of latching a stuck session.
|
||||
isPreparingToRecord = false
|
||||
if Task.isCancelled {
|
||||
_ = recorder.stop()
|
||||
return
|
||||
}
|
||||
isRecording = true
|
||||
transcript = ""
|
||||
isStreamingPartial = false
|
||||
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
|
||||
startTimers()
|
||||
startLiveCaptureIfSupported(store: store)
|
||||
// Tiny race: Option released between the cancel check and
|
||||
// `isRecording = true`. Treat it as end-of-hold and finish.
|
||||
if Task.isCancelled {
|
||||
finishRecording()
|
||||
}
|
||||
} catch {
|
||||
statusMessage = error.localizedDescription
|
||||
isPreparingToRecord = false
|
||||
if !Task.isCancelled {
|
||||
statusMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,49 +269,185 @@ final class MacDictationViewModel: ObservableObject {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
isProcessing = true
|
||||
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
|
||||
let hadLivePartial = isStreamingPartial
|
||||
statusMessage = MacL10n.string(
|
||||
hadLivePartial ? "mac.status.polishing" : "mac.status.transcribing",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
stopTimers()
|
||||
audioLevel = 0
|
||||
let samples = recorder.stop()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let liveTask = liveCaptureTask
|
||||
liveCaptureTask = nil
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let text = try await MacDictationPipeline.run(samples: samples, store: store)
|
||||
self.transcript = text
|
||||
let pasted = try self.deliver(text)
|
||||
self.recordUsage(for: text)
|
||||
self.speechHistory.append(text: text)
|
||||
self.statusMessage = self.statusAfterDelivery(pasted: pasted)
|
||||
let result: MacDictationResult
|
||||
if let liveTask {
|
||||
let capture = await Self.awaitLiveCapture(liveTask)
|
||||
let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty {
|
||||
if self.transcript.isEmpty {
|
||||
self.transcript = trimmedLive
|
||||
}
|
||||
result = try await MacDictationPipeline.polishCapturedASR(
|
||||
raw: capture.raw,
|
||||
store: store,
|
||||
localBias: capture.localBias,
|
||||
chunkWarning: capture.chunkWarning
|
||||
)
|
||||
} else {
|
||||
result = try await MacDictationPipeline.run(
|
||||
samples: samples,
|
||||
store: store,
|
||||
onPartial: { [weak self] partial in
|
||||
Task { @MainActor in
|
||||
self?.transcript = partial
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result = try await MacDictationPipeline.run(
|
||||
samples: samples,
|
||||
store: store,
|
||||
onPartial: { [weak self] partial in
|
||||
Task { @MainActor in
|
||||
self?.transcript = partial
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
self.transcript = result.text
|
||||
let pasted = try await self.deliver(result.text)
|
||||
self.recordUsage(for: result.text)
|
||||
self.speechHistory.append(text: result.text)
|
||||
self.statusMessage = self.statusAfterDelivery(
|
||||
pasted: pasted,
|
||||
polishWarning: result.polishWarning,
|
||||
chunkWarning: result.chunkWarning
|
||||
)
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
self.isStreamingPartial = false
|
||||
self.isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ text: String) throws -> Bool {
|
||||
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
|
||||
private func startLiveCaptureIfSupported(store: AppGroupStore) {
|
||||
guard MacDictationPipeline.supportsLivePartials(store: store) else { return }
|
||||
let stream = recorder.makeSnapshotStream()
|
||||
liveCaptureTask = Task { [weak self] in
|
||||
await MacDictationPipeline.captureLive(
|
||||
stream: stream,
|
||||
store: store,
|
||||
onPartial: { [weak self] partial in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
guard self.isRecording || self.isProcessing else { return }
|
||||
let trimmed = partial.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.transcript = partial
|
||||
self.isStreamingPartial = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusAfterDelivery(pasted: Bool) -> String {
|
||||
private func cancelLiveCapture() {
|
||||
liveCaptureTask?.cancel()
|
||||
liveCaptureTask = nil
|
||||
isStreamingPartial = false
|
||||
}
|
||||
|
||||
/// Hard timeout so a hung realtime ASR drain cannot leave `isProcessing` stuck.
|
||||
private static func awaitLiveCapture(
|
||||
_ task: Task<MacLiveASRCaptureResult, Never>
|
||||
) async -> MacLiveASRCaptureResult {
|
||||
await HardTimeout.value(
|
||||
seconds: FlowSessionKeys.cloudASRWaitTimeout,
|
||||
operation: { await task.value },
|
||||
onTimeout: {
|
||||
task.cancel()
|
||||
return MacLiveASRCaptureResult(
|
||||
raw: "",
|
||||
chunkWarning: nil,
|
||||
localBias: nil,
|
||||
shouldFallbackToBatch: true
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Stops an in-flight prepare, or finishes an active recording.
|
||||
private func cancelOrFinishRecording() {
|
||||
if isRecording {
|
||||
finishRecording()
|
||||
return
|
||||
}
|
||||
if isPreparingToRecord {
|
||||
hotkeyBeginTask?.cancel()
|
||||
hotkeyBeginTask = nil
|
||||
// If the button-triggered prepare wasn't tracked by hotkeyBeginTask,
|
||||
// still clear the preparing flag and stop any engine that raced in.
|
||||
isPreparingToRecord = false
|
||||
cancelLiveCapture()
|
||||
_ = recorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ text: String) async throws -> Bool {
|
||||
try await MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
|
||||
}
|
||||
|
||||
private func statusAfterDelivery(
|
||||
pasted: Bool,
|
||||
polishWarning: String? = nil,
|
||||
chunkWarning: String? = nil
|
||||
) -> String {
|
||||
let lang = config.uiLanguage
|
||||
let base: String
|
||||
if autoPasteEnabled, pasted {
|
||||
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
|
||||
base = MacL10n.string("mac.status.copiedAndPasted", language: lang)
|
||||
} else if autoPasteEnabled, !pasted {
|
||||
base = MacL10n.string("mac.status.copied", language: lang)
|
||||
} else {
|
||||
base = MacL10n.string("mac.status.copied", language: lang)
|
||||
}
|
||||
if autoPasteEnabled, !pasted {
|
||||
return MacL10n.string("mac.status.copied", language: lang)
|
||||
|
||||
if let polishWarning, !polishWarning.isEmpty {
|
||||
return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, polishWarning)
|
||||
}
|
||||
return MacL10n.string("mac.status.copied", language: lang)
|
||||
if let chunkWarning, !chunkWarning.isEmpty {
|
||||
return MacL10n.format("mac.status.deliveryWithNote", language: lang, base, chunkWarning)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private func wireHotkeyService() {
|
||||
hotkeyService.trigger = hotkeyTrigger
|
||||
hotkeyService.onPressBegan = { [weak self] in
|
||||
self?.beginRecording()
|
||||
guard let self else { return }
|
||||
self.hotkeyBeginTask?.cancel()
|
||||
self.hotkeyBeginTask = Task { [weak self] in
|
||||
await self?.beginRecording()
|
||||
}
|
||||
}
|
||||
hotkeyService.onPressEnded = { [weak self] in
|
||||
self?.finishRecording()
|
||||
guard let self else { return }
|
||||
// Cancel a still-preparing start so a quick Option tap never
|
||||
// latches recording. If recording already began, finish it.
|
||||
if self.isRecording {
|
||||
self.hotkeyBeginTask = nil
|
||||
self.finishRecording()
|
||||
} else {
|
||||
self.hotkeyBeginTask?.cancel()
|
||||
self.hotkeyBeginTask = nil
|
||||
}
|
||||
}
|
||||
if hotkeyEnabled { hotkeyService.start() }
|
||||
}
|
||||
@@ -304,6 +493,10 @@ final class MacDictationViewModel: ObservableObject {
|
||||
config.apply(preset: provider)
|
||||
}
|
||||
|
||||
func selectAsrProvider(_ provider: LLMProvider) {
|
||||
config.applyAsr(preset: provider)
|
||||
}
|
||||
|
||||
func refreshForegroundAppName() {
|
||||
foregroundAppName = MacAppContextService.frontmostApplicationName()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// MacDictionaryView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Personal dictionary synced via iCloud KVS with the iOS app. Read-only on
|
||||
// the desktop (words are authored on iPhone / iPad): grouped cards (native
|
||||
// `Form`) with search, matching the Settings and History card style.
|
||||
// Personal dictionary synced via iCloud KVS. ScrollView is full-bleed
|
||||
// (scrollbar on the window edge); title + cards share `pageHorizontalInset`.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -45,16 +44,27 @@ struct MacDictionaryView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
form
|
||||
.transition(.opacity)
|
||||
VStack(spacing: 0) {
|
||||
MacPageHeader(
|
||||
title: MacL10n.string("mac.section.dictionary", language: lang),
|
||||
subtitle: MacL10n.string("mac.page.dictionary.subtitle", language: lang)
|
||||
) {
|
||||
if !entries.isEmpty {
|
||||
searchField
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
list
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(palette.background)
|
||||
.animation(Motion.soft, value: entries.isEmpty)
|
||||
.task {
|
||||
@@ -64,33 +74,6 @@ struct MacDictionaryView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
viewModel.refreshDictionaryFromCloud()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grouped cards
|
||||
|
||||
private var form: some View {
|
||||
Form {
|
||||
if sections.isEmpty {
|
||||
Section {
|
||||
Text(MacL10n.string("mac.dict.noMatch", language: lang))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
} else {
|
||||
ForEach(sections, id: \.category) { section in
|
||||
Section(MacL10n.string(section.category.labelKey, language: lang)) {
|
||||
ForEach(section.items) { entry in
|
||||
row(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.animation(Motion.soft, value: query)
|
||||
.safeAreaInset(edge: .top, spacing: 0) { centeredSearchField }
|
||||
.confirmationDialog(
|
||||
MacL10n.string("mac.dict.deleteTitle", language: lang),
|
||||
isPresented: deletionDialogBinding,
|
||||
@@ -110,6 +93,51 @@ struct MacDictionaryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
// Full-bleed ScrollView → scrollbar on the detail pane's right edge.
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
if sections.isEmpty {
|
||||
MacCard {
|
||||
Text(MacL10n.string("mac.dict.noMatch", language: lang))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
} else {
|
||||
ForEach(sections, id: \.category) { section in
|
||||
categorySection(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.bottom, Spacing.md)
|
||||
.animation(Motion.soft, value: query)
|
||||
}
|
||||
}
|
||||
|
||||
private func categorySection(
|
||||
_ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(MacL10n.string(section.category.labelKey, language: lang))
|
||||
.font(MacSettingsType.sectionTitle)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
MacCard(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(section.items, id: \.id) { entry in
|
||||
row(entry)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var deletionDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { entryPendingDeletion != nil },
|
||||
@@ -117,25 +145,19 @@ struct MacDictionaryView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var centeredSearchField: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 7)
|
||||
.frame(width: 240)
|
||||
.macGlassSurface(in: Capsule(), fillOpacity: 0.72)
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
Spacer()
|
||||
private var searchField: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
TextField(MacL10n.string("mac.dict.search", language: lang), text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.font(TypeStyle.footnote)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.background)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 6)
|
||||
.frame(width: 220)
|
||||
.background(palette.surface, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private func row(_ entry: PersonalDictionary.Entry) -> some View {
|
||||
@@ -165,19 +187,19 @@ struct MacDictionaryView: View {
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "character.book.closed")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.6))
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.55))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(MacL10n.string("mac.dict.empty", language: lang))
|
||||
.font(TypeStyle.body)
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text(MacL10n.string("mac.dict.emptyBody", language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 360)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
}
|
||||
|
||||
private func delete(_ entry: PersonalDictionary.Entry) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// MacHistoryView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Single-column, day-grouped transcript log rendered as grouped cards (the
|
||||
// same native `Form` container as Settings). Every entry shows its full text
|
||||
// inline — no master/detail split, so content never pushes the sidebar out.
|
||||
// Day-grouped transcript log. ScrollView is full-bleed (scrollbar on the
|
||||
// window edge); title + cards share `pageHorizontalInset` on their content.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -31,36 +30,39 @@ struct MacHistoryView: View {
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if historyStore.entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
form
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(palette.background)
|
||||
.animation(Motion.soft, value: historyStore.entries.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Grouped cards
|
||||
|
||||
private var form: some View {
|
||||
Form {
|
||||
ForEach(historyStore.groupedByDay, id: \.day) { group in
|
||||
Section(Self.dayFormatter.string(from: group.day)) {
|
||||
ForEach(group.items) { entry in
|
||||
row(entry)
|
||||
VStack(spacing: 0) {
|
||||
MacPageHeader(
|
||||
title: MacL10n.string("mac.section.history", language: lang),
|
||||
subtitle: MacL10n.string("mac.page.history.subtitle", language: lang)
|
||||
) {
|
||||
if !historyStore.entries.isEmpty {
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Label(
|
||||
MacL10n.string("mac.history.clearConfirm", language: lang),
|
||||
systemImage: "trash"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Group {
|
||||
if historyStore.entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
list
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.safeAreaInset(edge: .top, spacing: 0) { toolbar }
|
||||
.animation(Motion.soft, value: historyStore.entries.isEmpty)
|
||||
.confirmationDialog(
|
||||
MacL10n.string("mac.history.clearTitle", language: lang),
|
||||
isPresented: $showClearConfirmation,
|
||||
@@ -75,21 +77,39 @@ struct MacHistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash")
|
||||
.font(TypeStyle.caption)
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
// Full-bleed ScrollView → scrollbar on the detail pane's right edge.
|
||||
// Horizontal inset lives on the content so cards align with the title.
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
ForEach(historyStore.groupedByDay, id: \.day) { group in
|
||||
daySection(group)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.bottom, Spacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(Self.dayFormatter.string(from: group.day))
|
||||
.font(MacSettingsType.sectionTitle)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
MacCard(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(group.items, id: \.id) { entry in
|
||||
row(entry)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private func row(_ entry: SpeechHistoryEntry) -> some View {
|
||||
@@ -107,11 +127,17 @@ struct MacHistoryView: View {
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "text.bubble")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.6))
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.55))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text(MacL10n.string("mac.history.empty", language: lang))
|
||||
.font(TypeStyle.body)
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text(MacL10n.string("mac.history.emptyBody", language: lang))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,93 @@
|
||||
// MacHotkeyService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Global hold-to-talk: while Option (⌥) is held, dictation runs. Mirrors
|
||||
// Typeless / SayIt push-to-talk from any foreground app.
|
||||
// Global hold-to-talk: while the configured Option (⌥) key is held, dictation
|
||||
// runs. Mirrors Typeless / SayIt push-to-talk from any foreground app.
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Which physical Option (⌥) key triggers global hold-to-talk.
|
||||
///
|
||||
/// Right Option is the default: the left key is a routine typing modifier
|
||||
/// (special characters, app shortcuts), so firing on any Option press
|
||||
/// constantly misfires during normal typing.
|
||||
enum MacHotkeyTrigger: String, CaseIterable, Identifiable {
|
||||
case rightOption
|
||||
case leftOption
|
||||
case eitherOption
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var labelKey: String {
|
||||
switch self {
|
||||
case .rightOption: return "mac.hotkeyTrigger.rightOption"
|
||||
case .leftOption: return "mac.hotkeyTrigger.leftOption"
|
||||
case .eitherOption: return "mac.hotkeyTrigger.eitherOption"
|
||||
}
|
||||
}
|
||||
|
||||
/// Main-window hint under the record button — must follow the picker,
|
||||
/// or the UI tells left-Option users to hold the right key.
|
||||
var hintKey: String {
|
||||
switch self {
|
||||
case .rightOption: return "mac.hint.hold.rightOption"
|
||||
case .leftOption: return "mac.hint.hold.leftOption"
|
||||
case .eitherOption: return "mac.hint.hold.eitherOption"
|
||||
}
|
||||
}
|
||||
|
||||
/// `@AppStorage`-compatible key; persisted via the view model's defaults.
|
||||
static let storageKey = "mac.hotkeyTrigger"
|
||||
|
||||
/// Device-dependent modifier bits (IOKit `NX_DEVICELALTKEYMASK` /
|
||||
/// `NX_DEVICERALTKEYMASK`) that `.flagsChanged` events carry alongside the
|
||||
/// device-independent `.option` flag, telling left and right apart.
|
||||
private static let leftOptionMask: UInt = 0x20
|
||||
private static let rightOptionMask: UInt = 0x40
|
||||
|
||||
/// Whether this trigger's key is currently down in a `.flagsChanged` event.
|
||||
func isPressed(in event: NSEvent) -> Bool {
|
||||
guard event.modifierFlags.contains(.option) else { return false }
|
||||
let raw = event.modifierFlags.rawValue
|
||||
switch self {
|
||||
case .rightOption: return raw & Self.rightOptionMask != 0
|
||||
case .leftOption: return raw & Self.leftOptionMask != 0
|
||||
case .eitherOption: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MacHotkeyService {
|
||||
/// How long the trigger key must stay held before recording begins.
|
||||
/// Filters out quick ⌥-taps and ⌥+key combos (special characters, app
|
||||
/// shortcuts) that would otherwise start and immediately abort dictation.
|
||||
private static let holdDebounce: Duration = .milliseconds(150)
|
||||
|
||||
var onPressBegan: (() -> Void)?
|
||||
var onPressEnded: (() -> Void)?
|
||||
var trigger: MacHotkeyTrigger = .rightOption
|
||||
|
||||
private var globalFlagsMonitor: Any?
|
||||
private var localFlagsMonitor: Any?
|
||||
private var optionHeld = false
|
||||
/// The trigger key is physically down (debounce may still be pending).
|
||||
private var triggerKeyDown = false
|
||||
/// `onPressBegan` has fired and `onPressEnded` is owed.
|
||||
private var pressActive = false
|
||||
private var pendingBegin: Task<Void, Never>?
|
||||
private var isEnabled = true
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
isEnabled = enabled
|
||||
if !enabled, optionHeld {
|
||||
optionHeld = false
|
||||
onPressEnded?()
|
||||
}
|
||||
if !enabled { cancelPress() }
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard globalFlagsMonitor == nil else { return }
|
||||
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return }
|
||||
// Global monitors require Accessibility; without it the call returns
|
||||
// nil and Option-hold never fires outside our own windows.
|
||||
let trusted = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
|
||||
globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
Task { @MainActor in self?.handleFlagsChanged(event) }
|
||||
@@ -36,6 +96,12 @@ final class MacHotkeyService {
|
||||
Task { @MainActor in self?.handleFlagsChanged(event) }
|
||||
return event
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if !trusted || globalFlagsMonitor == nil {
|
||||
NSLog("[OSGKeyboard] Hotkey global monitor unavailable — grant Accessibility in System Settings")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -47,20 +113,47 @@ final class MacHotkeyService {
|
||||
NSEvent.removeMonitor(localFlagsMonitor)
|
||||
self.localFlagsMonitor = nil
|
||||
}
|
||||
if optionHeld {
|
||||
optionHeld = false
|
||||
onPressEnded?()
|
||||
}
|
||||
cancelPress()
|
||||
}
|
||||
|
||||
private func handleFlagsChanged(_ event: NSEvent) {
|
||||
guard isEnabled else { return }
|
||||
let optionDown = event.modifierFlags.contains(.option)
|
||||
if optionDown, !optionHeld {
|
||||
optionHeld = true
|
||||
onPressBegan?()
|
||||
} else if !optionDown, optionHeld {
|
||||
optionHeld = false
|
||||
let triggerDown = trigger.isPressed(in: event)
|
||||
if triggerDown, !triggerKeyDown {
|
||||
triggerKeyDown = true
|
||||
scheduleBegin()
|
||||
} else if !triggerDown, triggerKeyDown {
|
||||
triggerKeyDown = false
|
||||
pendingBegin?.cancel()
|
||||
pendingBegin = nil
|
||||
if pressActive {
|
||||
pressActive = false
|
||||
onPressEnded?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounce: begin only after the key has stayed held for `holdDebounce`.
|
||||
/// Releasing the key first cancels the pending start, so a quick
|
||||
/// Option+key combo never triggers recording.
|
||||
private func scheduleBegin() {
|
||||
pendingBegin?.cancel()
|
||||
pendingBegin = Task { [weak self] in
|
||||
try? await Task.sleep(for: Self.holdDebounce)
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
self.pendingBegin = nil
|
||||
guard self.isEnabled, self.triggerKeyDown, !self.pressActive else { return }
|
||||
self.pressActive = true
|
||||
self.onPressBegan?()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPress() {
|
||||
pendingBegin?.cancel()
|
||||
pendingBegin = nil
|
||||
triggerKeyDown = false
|
||||
if pressActive {
|
||||
pressActive = false
|
||||
onPressEnded?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// MacICloudSyncRows.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// iCloud sync toggles for settings and personal dictionary — same KVS
|
||||
// keys and merge rules as the iOS settings page. Rendered as native Form
|
||||
// rows (Toggle + optional action / error) so they sit inside a grouped
|
||||
// `Form` section and match System Settings exactly.
|
||||
// iCloud sync toggle for settings, history, API keys, and personal
|
||||
// dictionary — same KVS keys and merge rules as the iOS settings page.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@@ -19,38 +17,51 @@ struct MacSettingsICloudSyncRow: View {
|
||||
@State private var isSyncingNow = false
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: toggleBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(MacL10n.string("mac.sync.settingsTitle", language: language))
|
||||
MacProviderSettingRow(
|
||||
title: MacL10n.string("mac.sync.settingsTitle", language: language),
|
||||
verticalAlignment: .center
|
||||
) {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
Toggle("", isOn: toggleBinding)
|
||||
.labelsHidden()
|
||||
.toggleStyle(MacToggleStyle())
|
||||
.disabled(isApplyingToggle)
|
||||
Text(MacL10n.string("mac.sync.settingsSubtitle", language: language))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
.onAppear { reloadFromStore() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
|
||||
if isEnabled {
|
||||
Button {
|
||||
syncNow()
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if isSyncingNow { ProgressView().controlSize(.small) }
|
||||
Text(MacL10n.string("mac.sync.syncNow", language: language))
|
||||
HStack(spacing: Spacing.xs) {
|
||||
MacSettingsToolButton(
|
||||
title: MacL10n.string("mac.sync.syncNow", language: language),
|
||||
disabled: isSyncingNow || isApplyingToggle
|
||||
) {
|
||||
syncNow()
|
||||
}
|
||||
if isSyncingNow { ProgressView().controlSize(.small) }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.disabled(isSyncingNow || isApplyingToggle)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.font(MacSettingsType.hint)
|
||||
.foregroundStyle(palette.danger)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +85,19 @@ struct MacSettingsICloudSyncRow: View {
|
||||
Task {
|
||||
do {
|
||||
try await MacICloudSyncBootstrap.settingsSync.enableSync()
|
||||
do {
|
||||
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
|
||||
} catch let error as PersonalDictionaryCloudSyncError {
|
||||
MacICloudSyncBootstrap.settingsSync.disableSync()
|
||||
isEnabled = false
|
||||
if case .payloadTooLarge = error {
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
|
||||
} else {
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
isApplyingToggle = false
|
||||
return
|
||||
}
|
||||
reloadFromStore()
|
||||
} catch {
|
||||
isEnabled = false
|
||||
@@ -102,79 +126,3 @@ struct MacSettingsICloudSyncRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MacDictionaryICloudSyncRow: View {
|
||||
let defaults: UserDefaults
|
||||
let language: AppUILanguage
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@State private var isEnabled = false
|
||||
@State private var syncErrorMessage: String?
|
||||
@State private var isApplyingToggle = false
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: toggleBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(MacL10n.string("mac.sync.dictTitle", language: language))
|
||||
Text(MacL10n.string("mac.sync.dictSubtitle", language: language))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
.onAppear { reloadFromStore() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.danger)
|
||||
}
|
||||
}
|
||||
|
||||
private var toggleBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { isEnabled },
|
||||
set: { newValue in
|
||||
guard newValue != isEnabled else { return }
|
||||
newValue ? enableSync() : disableSync()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func reloadFromStore() {
|
||||
isEnabled = AppGroupStore(defaults: defaults).personalDictionaryICloudSyncEnabled
|
||||
}
|
||||
|
||||
private func enableSync() {
|
||||
isApplyingToggle = true
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
|
||||
reloadFromStore()
|
||||
} catch let error as PersonalDictionaryCloudSyncError {
|
||||
isEnabled = false
|
||||
if case .payloadTooLarge = error {
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
|
||||
} else {
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
} catch {
|
||||
isEnabled = false
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
isApplyingToggle = false
|
||||
}
|
||||
}
|
||||
|
||||
private func disableSync() {
|
||||
MacICloudSyncBootstrap.dictionarySync.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// MacLocalASRChunkAdapter.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Adapts macOS local ASR to the shared chunked utterance pipeline.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
final class MacLocalASRChunkAdapter: ASRChunkTranscribing, @unchecked Sendable {
|
||||
private let locale: Locale
|
||||
private let bias: LocalASRBiasPayload?
|
||||
private let cancelled = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
init(locale: Locale, bias: LocalASRBiasPayload?) {
|
||||
self.locale = locale
|
||||
self.bias = bias
|
||||
}
|
||||
|
||||
func resetForNewUtterance() {
|
||||
cancelled.withLock { $0 = false }
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancelled.withLock { $0 = true }
|
||||
}
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
let isCancelled = cancelled.withLock { $0 }
|
||||
if isCancelled || Task.isCancelled { return .cancelled }
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
|
||||
do {
|
||||
let text = try await MacLocalASRService.transcribe(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
bias: bias
|
||||
)
|
||||
return .success(text)
|
||||
} catch {
|
||||
return .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,9 @@ struct MacLocalASRModelSettingsView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@StateObject private var modelVM = MacLocalASRModelSettingsViewModel()
|
||||
@Environment(\.themePalette) private var palette
|
||||
/// Only one model installs at a time, so a single hover flag drives the
|
||||
/// active download ring's pause/resume affordance.
|
||||
@State private var isHoveringProgress = false
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
@@ -217,42 +220,54 @@ struct MacLocalASRModelSettingsView: View {
|
||||
}
|
||||
|
||||
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
|
||||
Section {
|
||||
if let runtime = modelVM.currentRuntime(in: catalog) {
|
||||
LabeledContent(runtime.displayName) {
|
||||
Text(
|
||||
modelVM.isRuntimeInstalled(runtime)
|
||||
? MacL10n.string("mac.localASR.installed", language: lang)
|
||||
: MacL10n.string("mac.localASR.notInstalled", language: lang)
|
||||
)
|
||||
MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) {
|
||||
VStack(spacing: MacMetrics.settingsRowGap) {
|
||||
if let runtime = modelVM.currentRuntime(in: catalog) {
|
||||
MacFormSubtitleRow(title: runtime.displayName) {
|
||||
Text(
|
||||
modelVM.isRuntimeInstalled(runtime)
|
||||
? MacL10n.string("mac.localASR.installed", language: lang)
|
||||
: MacL10n.string("mac.localASR.notInstalled", language: lang)
|
||||
)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(catalog.models) { model in
|
||||
modelRow(model)
|
||||
}
|
||||
ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in
|
||||
modelRow(model)
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
|
||||
if modelVM.isInstalling,
|
||||
modelVM.installProgress.phase == .extracting
|
||||
|| modelVM.installProgress.phase == .validating
|
||||
|| modelVM.installProgress.phase == .finalizing {
|
||||
ProgressView(value: modelVM.installProgress.fraction) {
|
||||
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
|
||||
if modelVM.isInstalling,
|
||||
modelVM.installProgress.phase == .extracting
|
||||
|| modelVM.installProgress.phase == .validating
|
||||
|| modelVM.installProgress.phase == .finalizing {
|
||||
ProgressView(value: modelVM.installProgress.fraction) {
|
||||
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
}
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
|
||||
if !modelVM.statusMessage.isEmpty {
|
||||
Text(modelVM.statusMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
}
|
||||
|
||||
if !modelVM.statusMessage.isEmpty {
|
||||
Text(modelVM.statusMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
HStack(spacing: 0) {
|
||||
MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) {
|
||||
modelVM.revealStorageRoot()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
|
||||
Button(MacL10n.string("mac.localASR.openStorage", language: lang)) {
|
||||
modelVM.revealStorageRoot()
|
||||
}
|
||||
} header: {
|
||||
Text(MacL10n.string("mac.localASR.models", language: lang))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,14 +291,17 @@ struct MacLocalASRModelSettingsView: View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text(model.displayName)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
if let badgeKey = model.badgeKey {
|
||||
modelBadge(
|
||||
MacL10n.string(badgeKey, language: lang),
|
||||
emphasized: true
|
||||
)
|
||||
}
|
||||
if model.supportsHotwords {
|
||||
Text(MacL10n.string("mac.localASR.personalDictionaryTag", language: lang))
|
||||
.font(TypeStyle.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(palette.accent.opacity(0.15))
|
||||
.foregroundStyle(palette.accent)
|
||||
.clipShape(Capsule())
|
||||
modelBadge(
|
||||
MacL10n.string("mac.localASR.personalDictionaryTag", language: lang),
|
||||
emphasized: false
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(modelSubtitle(model, installed: installed))
|
||||
@@ -302,7 +320,21 @@ struct MacLocalASRModelSettingsView: View {
|
||||
.animation(Motion.soft, value: installed)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.padding(.vertical, 0)
|
||||
}
|
||||
|
||||
private func modelBadge(_ title: String, emphasized: Bool) -> some View {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
emphasized
|
||||
? palette.accent.opacity(0.18)
|
||||
: palette.textTertiary.opacity(0.12)
|
||||
)
|
||||
.foregroundStyle(emphasized ? palette.accent : palette.textSecondary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -312,49 +344,39 @@ struct MacLocalASRModelSettingsView: View {
|
||||
installing: Bool
|
||||
) -> some View {
|
||||
if installing {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
circularInstallProgress(for: model)
|
||||
if modelVM.installProgress.phase == .downloading
|
||||
|| modelVM.installProgress.phase == .paused {
|
||||
Button {
|
||||
if modelVM.isDownloadPaused {
|
||||
modelVM.resumeDownload()
|
||||
} else {
|
||||
modelVM.pauseDownload()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: modelVM.isDownloadPaused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help(
|
||||
modelVM.isDownloadPaused
|
||||
? MacL10n.string("mac.localASR.resume", language: lang)
|
||||
: MacL10n.string("mac.localASR.pause", language: lang)
|
||||
)
|
||||
}
|
||||
}
|
||||
installProgressControl(for: model)
|
||||
.animation(Motion.quick, value: isHoveringProgress)
|
||||
.animation(Motion.quick, value: modelVM.isDownloadPaused)
|
||||
} else if installed {
|
||||
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
|
||||
MacSettingsToolButton(
|
||||
title: MacL10n.string("mac.localASR.delete", language: lang),
|
||||
fill: palette.danger.opacity(0.15),
|
||||
foreground: palette.danger,
|
||||
showsBorder: false
|
||||
) {
|
||||
modelVM.deleteModel(model)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.tint(palette.danger)
|
||||
} else {
|
||||
Button(MacL10n.string("mac.localASR.download", language: lang)) {
|
||||
MacSettingsToolButton(
|
||||
title: MacL10n.string("mac.localASR.download", language: lang),
|
||||
fill: palette.accent,
|
||||
foreground: .white,
|
||||
showsBorder: false
|
||||
) {
|
||||
modelVM.installModel(model)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View {
|
||||
/// Single download ring. Shows the percentage while downloading; on hover it
|
||||
/// reveals a clickable pause icon, and once paused it keeps a resume (play)
|
||||
/// icon so the state stays clear without a second button.
|
||||
private func installProgressControl(for model: LocalASRModelDefinition) -> some View {
|
||||
let phase = modelVM.installProgress.phase
|
||||
let pausable = phase == .downloading || phase == .paused
|
||||
let paused = modelVM.isDownloadPaused
|
||||
let fraction: Double = {
|
||||
if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused,
|
||||
if pausable,
|
||||
let received = modelVM.installProgress.bytesReceived,
|
||||
let total = modelVM.installProgress.bytesTotal,
|
||||
total > 0 {
|
||||
@@ -362,6 +384,7 @@ struct MacLocalASRModelSettingsView: View {
|
||||
}
|
||||
return modelVM.installProgress.fraction
|
||||
}()
|
||||
|
||||
return ZStack {
|
||||
Circle()
|
||||
.stroke(palette.textTertiary.opacity(0.25), lineWidth: 3)
|
||||
@@ -370,20 +393,46 @@ struct MacLocalASRModelSettingsView: View {
|
||||
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(Motion.instant, value: fraction)
|
||||
if modelVM.installProgress.phase == .paused {
|
||||
Image(systemName: "pause.fill")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
} else {
|
||||
Text("\(Int(fraction * 100))%")
|
||||
.font(.system(size: 9, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
progressCenter(pausable: pausable, paused: paused, fraction: fraction)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.onHover { hovering in
|
||||
guard pausable else { return }
|
||||
isHoveringProgress = hovering
|
||||
}
|
||||
.onTapGesture {
|
||||
guard pausable else { return }
|
||||
if paused { modelVM.resumeDownload() } else { modelVM.pauseDownload() }
|
||||
}
|
||||
.help(
|
||||
pausable
|
||||
? (paused
|
||||
? MacL10n.string("mac.localASR.resume", language: lang)
|
||||
: MacL10n.string("mac.localASR.pause", language: lang))
|
||||
: modelVM.progressLabel(for: modelVM.installProgress, language: lang)
|
||||
)
|
||||
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func progressCenter(pausable: Bool, paused: Bool, fraction: Double) -> some View {
|
||||
if pausable, paused {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(palette.accent)
|
||||
} else if pausable, isHoveringProgress {
|
||||
Image(systemName: "pause.fill")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
} else {
|
||||
Text("\(Int(fraction * 100))%")
|
||||
.font(.system(size: 9, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
|
||||
let size = modelVM.formattedSize(model.sizeBytes)
|
||||
if let usage = modelVM.installedDiskUsage(model) {
|
||||
|
||||
@@ -58,7 +58,7 @@ enum MacLocalASRPreferences {
|
||||
/// Maps removed catalog entries to the current default Sherpa model.
|
||||
static func migratedModelId(_ id: String) -> String {
|
||||
switch id {
|
||||
case "qwen3-mlx-1.7b":
|
||||
case "qwen3-mlx-1.7b", "sherpa-paraformer-zh-int8":
|
||||
return "sherpa-qwen3-0.6b-int8"
|
||||
default:
|
||||
return id
|
||||
@@ -120,7 +120,7 @@ enum MacLocalASRService {
|
||||
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
|
||||
}
|
||||
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
|
||||
}
|
||||
|
||||
private static func transcribeWithModel(
|
||||
@@ -141,7 +141,7 @@ enum MacLocalASRService {
|
||||
bias: bias
|
||||
)
|
||||
case .appleSpeech:
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale, bias: bias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ struct MacOnboardingView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 860, minHeight: 600)
|
||||
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
|
||||
.onAppear {
|
||||
applyDefaults()
|
||||
model.reload()
|
||||
@@ -422,12 +422,15 @@ struct MacOnboardingView: View {
|
||||
|
||||
private var cloudAPIFields: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) {
|
||||
ForEach(viewModel.selectableProviders) { provider in
|
||||
Text(provider.name).tag(provider.id)
|
||||
MacInlinePicker(
|
||||
selection: providerBinding,
|
||||
options: viewModel.selectableProviders.map {
|
||||
MacInlinePickerOption(
|
||||
value: $0.id,
|
||||
label: ProviderDisplayName.name(for: $0.id, language: lang)
|
||||
)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,6 @@ struct MacRootView: View {
|
||||
|
||||
private var uiLanguage: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
/// `List` selection is optional; keep the view model's non-optional section
|
||||
/// in sync without letting a nil selection blank the detail pane.
|
||||
private var selection: Binding<MacSection?> {
|
||||
Binding(
|
||||
get: { viewModel.selectedSection },
|
||||
set: { if let new = $0 { viewModel.selectedSection = new } }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
sidebar
|
||||
@@ -35,7 +26,7 @@ struct MacRootView: View {
|
||||
detail
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.frame(minWidth: 860, minHeight: 600)
|
||||
.frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight)
|
||||
.onAppear {
|
||||
// Let the AppKit status-bar popover reopen this window on demand.
|
||||
MacWindowBridge.shared.open = { openWindow(id: "main") }
|
||||
@@ -47,7 +38,7 @@ struct MacRootView: View {
|
||||
private var sidebar: some View {
|
||||
VStack(spacing: 0) {
|
||||
brandHeader
|
||||
VStack(spacing: 4) {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(MacSection.allCases) { section in
|
||||
MacSidebarRow(
|
||||
section: section,
|
||||
@@ -72,15 +63,15 @@ struct MacRootView: View {
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 30)
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, MacMetrics.sidebarContentInset)
|
||||
.padding(.trailing, MacMetrics.sidebarInset)
|
||||
.padding(.top, Spacing.lg)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
.padding(.top, 30)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
|
||||
private var devicesFooter: some View {
|
||||
@@ -88,10 +79,10 @@ struct MacRootView: View {
|
||||
MacL10n.string("mac.devices", language: uiLanguage),
|
||||
systemImage: "laptopcomputer.and.iphone"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, MacMetrics.sidebarInset)
|
||||
.padding(.horizontal, MacMetrics.sidebarInset + Spacing.sm)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
@@ -118,8 +109,8 @@ struct MacRootView: View {
|
||||
|
||||
// MARK: - Sidebar row
|
||||
|
||||
/// A navigation row with an animated hover highlight and selection state,
|
||||
/// matching the macOS System Settings feel.
|
||||
/// Navigation row with a restrained selected state: muted accent fill +
|
||||
/// accent label (not a solid green pill), matching System Settings polish.
|
||||
private struct MacSidebarRow: View {
|
||||
let section: MacSection
|
||||
let isSelected: Bool
|
||||
@@ -132,14 +123,14 @@ private struct MacSidebarRow: View {
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(section.title(language: language), systemImage: section.systemImage)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(isSelected ? palette.textOnAccent : palette.textPrimary)
|
||||
.font(.system(size: 13, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 7)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
rowBackground,
|
||||
in: RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
@@ -150,7 +141,7 @@ private struct MacSidebarRow: View {
|
||||
}
|
||||
|
||||
private var rowBackground: Color {
|
||||
if isSelected { return palette.accent }
|
||||
return isHovering ? palette.textPrimary.opacity(0.06) : .clear
|
||||
if isSelected { return palette.accentMuted }
|
||||
return isHovering ? palette.textPrimary.opacity(0.05) : .clear
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// MacSettingsView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Settings built on the native grouped `Form` — the same container macOS
|
||||
// System Settings uses. This gives system-accurate cards, dividers, insets
|
||||
// and right-aligned controls for free, on both light and dark.
|
||||
// Settings uses native grouped `Form`. LLM / ASR provider blocks use a merged
|
||||
// `MacSettingsProviderCard` with responsive rows and theme-aware controls.
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
@@ -19,7 +18,6 @@ struct MacSettingsView: View {
|
||||
@AppStorage(MacOnboardingState.storageKey)
|
||||
private var hasCompletedMacOnboarding = true
|
||||
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
@State private var showProviderPicker = false
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
|
||||
@@ -33,23 +31,35 @@ struct MacSettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
generalSection
|
||||
recognitionSection
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
providerSection
|
||||
.transition(.opacity)
|
||||
VStack(spacing: 0) {
|
||||
MacPageHeader(
|
||||
title: MacL10n.string("mac.section.settings", language: lang),
|
||||
subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang)
|
||||
)
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
|
||||
generalSection
|
||||
recognitionSection
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
asrProviderSection
|
||||
.transition(.opacity)
|
||||
}
|
||||
if viewModel.config.engineMode == "local" {
|
||||
MacLocalASRModelSettingsView(viewModel: viewModel)
|
||||
.transition(.opacity)
|
||||
}
|
||||
polishProviderSection
|
||||
inputSection
|
||||
legalSection
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.pageHorizontalInset)
|
||||
.padding(.bottom, Spacing.md)
|
||||
}
|
||||
if viewModel.config.engineMode == "local" {
|
||||
MacLocalASRModelSettingsView(viewModel: viewModel)
|
||||
.transition(.opacity)
|
||||
}
|
||||
inputSection
|
||||
legalSection
|
||||
.tint(palette.accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tint(palette.accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
}
|
||||
.onAppear { refreshAccessibilityState() }
|
||||
@@ -58,136 +68,232 @@ struct MacSettingsView: View {
|
||||
// MARK: - General
|
||||
|
||||
private var generalSection: some View {
|
||||
Section(MacL10n.string("mac.settings.general", language: lang)) {
|
||||
Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) {
|
||||
ForEach(MacAppearancePreference.allCases) { pref in
|
||||
Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue)
|
||||
MacSettingsSection(title: MacL10n.string("mac.settings.general", language: lang)) {
|
||||
VStack(spacing: MacMetrics.settingsRowGap) {
|
||||
MacProviderSettingRow(title: MacL10n.string("mac.settings.appearance", language: lang)) {
|
||||
MacInlinePicker(
|
||||
selection: $appearanceRaw,
|
||||
options: MacAppearancePreference.allCases.map {
|
||||
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
|
||||
},
|
||||
fillsWidth: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Picker(MacL10n.string("mac.settings.interfaceLanguage", language: lang), selection: interfaceLanguageBinding) {
|
||||
ForEach(AppUILanguage.allCases, id: \.self) { language in
|
||||
Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue)
|
||||
MacProviderSettingRow(title: MacL10n.string("mac.settings.interfaceLanguage", language: lang)) {
|
||||
MacInlinePicker(
|
||||
selection: interfaceLanguageBinding,
|
||||
options: AppUILanguage.allCases.map {
|
||||
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
|
||||
},
|
||||
fillsWidth: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Picker(MacL10n.string("mac.settings.recognitionLanguage", language: lang), selection: recognitionLanguageBinding) {
|
||||
ForEach(recognitionLocales, id: \.id) { locale in
|
||||
Text(localeLabel(locale)).tag(locale.id)
|
||||
MacProviderSettingRow(title: MacL10n.string("mac.settings.recognitionLanguage", language: lang)) {
|
||||
MacInlinePicker(
|
||||
selection: recognitionLanguageBinding,
|
||||
options: recognitionLocales.map {
|
||||
MacInlinePickerOption(value: $0.id, label: localeLabel($0))
|
||||
},
|
||||
fillsWidth: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
|
||||
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang)
|
||||
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cloud provider
|
||||
// MARK: - Polish LLM
|
||||
|
||||
private var providerSection: some View {
|
||||
Section(MacL10n.string("mac.settings.cloudProvider", language: lang)) {
|
||||
LabeledContent(MacL10n.string("mac.settings.service", language: lang)) {
|
||||
Button {
|
||||
showProviderPicker = true
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
providerLogo(currentProvider.id)
|
||||
Text(currentProvider.name)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(isPresented: $showProviderPicker, arrowEdge: .bottom) {
|
||||
providerPickerList
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent {
|
||||
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) {
|
||||
Text(MacL10n.string("mac.settings.apiKey", language: lang))
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
.frame(maxWidth: MacMetrics.controlWidth)
|
||||
} label: {
|
||||
Text(MacL10n.string("mac.settings.apiKey", language: lang))
|
||||
}
|
||||
|
||||
LabeledContent {
|
||||
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
|
||||
Text(MacL10n.string("mac.settings.model", language: lang))
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
.frame(maxWidth: MacMetrics.controlWidth)
|
||||
} label: {
|
||||
Text(MacL10n.string("mac.settings.model", language: lang))
|
||||
private var polishProviderSection: some View {
|
||||
MacSettingsSection(title: MacL10n.string("mac.settings.polishProvider", language: lang)) {
|
||||
MacSettingsProviderCard {
|
||||
MacProviderPickerRow(
|
||||
title: MacL10n.string("mac.settings.service", language: lang),
|
||||
providers: viewModel.polishSelectableProviders,
|
||||
selection: polishProviderBinding
|
||||
)
|
||||
MacCredentialField(
|
||||
title: MacL10n.string("mac.settings.apiKey", language: lang),
|
||||
placeholder: "sk-…",
|
||||
text: $viewModel.config.apiKey,
|
||||
isSecret: true
|
||||
)
|
||||
MacCredentialField(
|
||||
title: MacL10n.string("mac.settings.baseURL", language: lang),
|
||||
placeholder: currentPolishProvider.defaultBaseURL,
|
||||
text: $viewModel.config.baseURL
|
||||
)
|
||||
MacProviderModelRow(
|
||||
title: MacL10n.string("mac.settings.model", language: lang),
|
||||
placeholder: currentPolishProvider.defaultModel,
|
||||
model: $viewModel.config.model,
|
||||
apiKey: viewModel.config.apiKey,
|
||||
fetchModels: fetchMacLLMModels,
|
||||
language: lang
|
||||
)
|
||||
.id(viewModel.config.providerId)
|
||||
MacProviderThinkingRow(
|
||||
title: MacL10n.string("mac.settings.thinking", language: lang),
|
||||
subtitle: MacL10n.string("mac.settings.thinkingSubtitle", language: lang),
|
||||
isOn: $viewModel.config.llmThinkingEnabled
|
||||
)
|
||||
MacProviderToolsRow(
|
||||
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
|
||||
validate: validateMacLLM,
|
||||
language: lang
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cloud ASR
|
||||
|
||||
private var asrProviderSection: some View {
|
||||
MacSettingsSection(title: MacL10n.string("mac.settings.asrProvider", language: lang)) {
|
||||
MacSettingsProviderCard {
|
||||
MacProviderPickerRow(
|
||||
title: MacL10n.string("mac.settings.asrService", language: lang),
|
||||
providers: viewModel.asrSelectableProviders,
|
||||
selection: asrProviderBinding
|
||||
)
|
||||
if viewModel.config.asrProviderId == "volcengine" {
|
||||
volcengineAsrRows
|
||||
} else {
|
||||
genericAsrRows
|
||||
}
|
||||
MacProviderToolsRow(
|
||||
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
|
||||
validate: validateMacASR,
|
||||
language: lang
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var volcengineAsrRows: some View {
|
||||
MacCredentialField(
|
||||
title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
|
||||
placeholder: "APP ID",
|
||||
text: Binding(
|
||||
get: { macVolcengineFields.appID },
|
||||
set: { updateMacVolcengine(appID: $0) }
|
||||
),
|
||||
isSecret: true
|
||||
)
|
||||
MacCredentialField(
|
||||
title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
|
||||
placeholder: "Access Token",
|
||||
text: Binding(
|
||||
get: { macVolcengineFields.accessToken },
|
||||
set: { updateMacVolcengine(accessToken: $0) }
|
||||
),
|
||||
isSecret: true
|
||||
)
|
||||
MacCredentialField(
|
||||
title: MacL10n.string("mac.settings.volcengineResourceId", language: lang),
|
||||
placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
|
||||
text: Binding(
|
||||
get: { macVolcengineFields.resourceID },
|
||||
set: { updateMacVolcengine(resourceID: $0) }
|
||||
),
|
||||
defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
)
|
||||
MacProviderNoteRow(text: MacL10n.string("mac.settings.volcengineNote", language: lang))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var genericAsrRows: some View {
|
||||
if CloudASRModelCatalog.showsASREndpointField(for: viewModel.config.asrProviderId) {
|
||||
MacCredentialField(
|
||||
title: MacL10n.string("mac.settings.baseURL", language: lang),
|
||||
placeholder: currentAsrProvider.defaultBaseURL,
|
||||
text: $viewModel.config.asrBaseURL
|
||||
)
|
||||
}
|
||||
|
||||
MacCredentialField(
|
||||
title: MacL10n.string("mac.settings.asrApiKey", language: lang),
|
||||
placeholder: "sk-…",
|
||||
text: $viewModel.config.asrApiKey,
|
||||
isSecret: true
|
||||
)
|
||||
MacProviderModelRow(
|
||||
title: MacL10n.string("mac.settings.asrModel", language: lang),
|
||||
placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId),
|
||||
model: $viewModel.config.asrModel,
|
||||
apiKey: viewModel.config.asrApiKey,
|
||||
fetchModels: fetchMacASRModels,
|
||||
language: lang
|
||||
)
|
||||
.id(viewModel.config.asrProviderId)
|
||||
}
|
||||
|
||||
// MARK: - Recognition method
|
||||
|
||||
private var recognitionSection: some View {
|
||||
Section(MacL10n.string("mac.settings.recognition", language: lang)) {
|
||||
methodRow(
|
||||
MacSettingsSection(title: MacL10n.string("mac.settings.recognition", language: lang)) {
|
||||
VStack(spacing: MacMetrics.settingsRowGap) {
|
||||
methodRow(
|
||||
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
|
||||
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
|
||||
systemImage: "cloud",
|
||||
selected: viewModel.config.engineMode == "cloud"
|
||||
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
|
||||
|
||||
methodRow(
|
||||
methodRow(
|
||||
title: MacL10n.string("mac.settings.localEngine", language: lang),
|
||||
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
|
||||
systemImage: "cpu",
|
||||
selected: viewModel.config.engineMode == "local"
|
||||
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hotkey / paste
|
||||
|
||||
private var inputSection: some View {
|
||||
Section(MacL10n.string("mac.settings.input", language: lang)) {
|
||||
Toggle(isOn: hotkeyBinding) {
|
||||
rowLabel(
|
||||
MacL10n.string("mac.settings.hotkey", language: lang),
|
||||
subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang)
|
||||
)
|
||||
}
|
||||
|
||||
Toggle(isOn: autoPasteBinding) {
|
||||
rowLabel(
|
||||
MacL10n.string("mac.settings.autoPaste", language: lang),
|
||||
subtitle: MacL10n.string("mac.settings.autoPasteDesc", language: lang)
|
||||
)
|
||||
}
|
||||
|
||||
LabeledContent {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(
|
||||
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
|
||||
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
|
||||
MacSettingsSection(title: MacL10n.string("mac.settings.input", language: lang)) {
|
||||
VStack(spacing: MacMetrics.settingsRowGap) {
|
||||
MacProviderSettingRow(title: MacL10n.string("mac.settings.hotkeyTrigger", language: lang)) {
|
||||
MacInlinePicker(
|
||||
selection: hotkeyTriggerBinding,
|
||||
options: MacHotkeyTrigger.allCases.map {
|
||||
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
|
||||
},
|
||||
fillsWidth: true
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
|
||||
.contentTransition(.opacity)
|
||||
.animation(Motion.quick, value: accessibilityTrusted)
|
||||
}
|
||||
MacProviderSettingRow(
|
||||
title: MacL10n.string("mac.settings.autoPaste", language: lang),
|
||||
verticalAlignment: .center
|
||||
) {
|
||||
Toggle("", isOn: autoPasteBinding)
|
||||
.labelsHidden()
|
||||
.toggleStyle(MacToggleStyle())
|
||||
}
|
||||
MacProviderSettingRow(
|
||||
title: MacL10n.string("mac.settings.accessibility", language: lang),
|
||||
verticalAlignment: .center
|
||||
) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
MacSettingsToolButton(title: MacL10n.string("mac.settings.openAccessibility", language: lang)) {
|
||||
openAccessibilitySettings()
|
||||
}
|
||||
|
||||
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
|
||||
openAccessibilitySettings()
|
||||
Label(
|
||||
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
|
||||
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
|
||||
.contentTransition(.opacity)
|
||||
.animation(Motion.quick, value: accessibilityTrusted)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
rowLabel(
|
||||
MacL10n.string("mac.settings.accessibility", language: lang),
|
||||
subtitle: MacL10n.string("mac.settings.accessibilityDesc", language: lang)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,37 +301,32 @@ struct MacSettingsView: View {
|
||||
// MARK: - Legal
|
||||
|
||||
private var legalSection: some View {
|
||||
Section(MacL10n.string("mac.settings.about", language: lang)) {
|
||||
NavigationLink {
|
||||
MacPrivacyPolicyView(uiLanguage: lang)
|
||||
} label: {
|
||||
Text(MacL10n.string("mac.settings.privacyPolicy", language: lang))
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
MacOpenSourceLicensesView(uiLanguage: lang)
|
||||
} label: {
|
||||
Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
|
||||
}
|
||||
|
||||
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
|
||||
hasCompletedMacOnboarding = false
|
||||
MacSettingsSection(title: MacL10n.string("mac.settings.about", language: lang)) {
|
||||
VStack(spacing: MacMetrics.settingsRowGap) {
|
||||
NavigationLink {
|
||||
MacPrivacyPolicyView(uiLanguage: lang)
|
||||
} label: {
|
||||
MacFormLinkRow(title: MacL10n.string("mac.settings.privacyPolicy", language: lang))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
NavigationLink {
|
||||
MacOpenSourceLicensesView(uiLanguage: lang)
|
||||
} label: {
|
||||
MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
|
||||
hasCompletedMacOnboarding = false
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row helpers
|
||||
|
||||
private func rowLabel(_ title: String, subtitle: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func methodRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
@@ -252,72 +353,79 @@ struct MacSettingsView: View {
|
||||
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.frame(minHeight: MacMetrics.settingsRowMinHeight)
|
||||
.padding(.horizontal, MacMetrics.settingsCardInset)
|
||||
.animation(Motion.quick, value: selected)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var currentProvider: LLMProvider {
|
||||
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId }
|
||||
?? viewModel.selectableProviders.first
|
||||
private var currentPolishProvider: LLMProvider {
|
||||
viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
|
||||
?? viewModel.polishSelectableProviders.first
|
||||
?? LLMProvider.presets[0]
|
||||
}
|
||||
|
||||
/// Custom dropdown list shown in a popover. SwiftUI's `Menu` label / items
|
||||
/// silently drop bundled (non-SF-Symbol) images on macOS, so we render the
|
||||
/// brand marks in a plain view stack instead.
|
||||
private var providerPickerList: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(viewModel.selectableProviders) { provider in
|
||||
Button {
|
||||
viewModel.selectProvider(provider)
|
||||
showProviderPicker = false
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
providerLogo(provider.id)
|
||||
Text(provider.name)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.md)
|
||||
if provider.id == currentProvider.id {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(height: 34)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.frame(width: 260)
|
||||
private var currentAsrProvider: LLMProvider {
|
||||
viewModel.asrSelectableProviders.first { $0.id == viewModel.config.asrProviderId }
|
||||
?? viewModel.asrSelectableProviders.first
|
||||
?? LLMProvider.presets[0]
|
||||
}
|
||||
|
||||
/// Brand mark tinted to the current label colour (black on light / white
|
||||
/// on dark). Template rendering + an explicit frame make the vector assets
|
||||
/// resolve at a text-matched size inside the pop-up menu — without a size
|
||||
/// hint they collapse to zero and disappear.
|
||||
@ViewBuilder
|
||||
private func providerLogo(_ providerId: String) -> some View {
|
||||
if let asset = ProviderLogo.assetName(for: providerId) {
|
||||
Image(asset)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
private var macVolcengineFields: VolcengineASRFields {
|
||||
VolcengineASRFields.parse(
|
||||
apiKey: viewModel.config.asrApiKey,
|
||||
resourceFallback: viewModel.config.asrModel.isEmpty
|
||||
? CloudASRModelCatalog.defaultModel(for: "volcengine")
|
||||
: viewModel.config.asrModel
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func providerLabel(_ provider: LLMProvider) -> some View {
|
||||
Label {
|
||||
Text(provider.name)
|
||||
} icon: {
|
||||
providerLogo(provider.id)
|
||||
private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
|
||||
var fields = macVolcengineFields
|
||||
if let appID { fields.appID = appID }
|
||||
if let accessToken { fields.accessToken = accessToken }
|
||||
if let resourceID {
|
||||
fields.resourceID = resourceID
|
||||
viewModel.config.asrModel = resourceID
|
||||
}
|
||||
viewModel.config.asrApiKey = fields.encodedAPIKey
|
||||
}
|
||||
|
||||
private func validateMacLLM() async throws {
|
||||
let client = LLMClientFactory.make(
|
||||
providerId: viewModel.config.providerId,
|
||||
baseURL: viewModel.config.baseURL,
|
||||
apiKey: viewModel.config.apiKey,
|
||||
model: viewModel.config.model,
|
||||
thinkingEnabled: viewModel.config.llmThinkingEnabled
|
||||
)
|
||||
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
|
||||
}
|
||||
|
||||
private func fetchMacLLMModels() async throws -> [String] {
|
||||
try await ProviderModelService.listLLMModels(
|
||||
providerId: viewModel.config.providerId,
|
||||
baseURL: viewModel.config.baseURL,
|
||||
apiKey: viewModel.config.apiKey,
|
||||
currentModel: viewModel.config.model
|
||||
)
|
||||
}
|
||||
|
||||
private func validateMacASR() async throws {
|
||||
let persisted = AppGroupStore(defaults: viewModel.defaults)
|
||||
let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted)
|
||||
try await CloudASRConnectionCheck.validate(store: store)
|
||||
}
|
||||
|
||||
private func fetchMacASRModels() async throws -> [String] {
|
||||
try await ProviderModelService.listASRModels(
|
||||
providerId: viewModel.config.asrProviderId,
|
||||
baseURL: viewModel.config.asrBaseURL,
|
||||
apiKey: viewModel.config.asrApiKey,
|
||||
currentModel: viewModel.config.asrModel
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Bindings
|
||||
@@ -329,17 +437,28 @@ struct MacSettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var providerBinding: Binding<String> {
|
||||
private var polishProviderBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.config.providerId },
|
||||
set: { newId in
|
||||
if let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) {
|
||||
if let provider = viewModel.polishSelectableProviders.first(where: { $0.id == newId }) {
|
||||
viewModel.selectProvider(provider)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var asrProviderBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.config.asrProviderId },
|
||||
set: { newId in
|
||||
if let provider = viewModel.asrSelectableProviders.first(where: { $0.id == newId }) {
|
||||
viewModel.selectAsrProvider(provider)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var recognitionLanguageBinding: Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
@@ -352,10 +471,10 @@ struct MacSettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var hotkeyBinding: Binding<Bool> {
|
||||
private var hotkeyTriggerBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.hotkeyEnabled },
|
||||
set: { viewModel.setHotkeyEnabled($0) }
|
||||
get: { viewModel.hotkeyTrigger.rawValue },
|
||||
set: { viewModel.setHotkeyTrigger(MacHotkeyTrigger(rawValue: $0) ?? .rightOption) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -165,16 +165,89 @@ enum MacSherpaONNXRunner {
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
for line in lines.reversed() {
|
||||
if line.hasPrefix("{"), let data = line.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let text = object["text"] as? String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
if line.hasPrefix("{") {
|
||||
// Sherpa's JSON result line (`{"text": ..., "lang": ..., ...}`).
|
||||
// Trust only its `text` field — including when it's empty
|
||||
// (silence/no-speech) — and never fall through to the raw
|
||||
// JSON below, or the JSON blob itself gets inserted as text.
|
||||
if let data = line.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let text = object["text"] as? String {
|
||||
return sanitizeTranscript(text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isMetadataNoiseLine(line) { continue }
|
||||
if !line.hasPrefix("/"), !line.hasPrefix("--"), line.count > 1 {
|
||||
return line
|
||||
let cleaned = sanitizeTranscript(line)
|
||||
if !cleaned.isEmpty { return cleaned }
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// Qwen3-ASR (via sherpa-onnx) often prefixes the transcript with a
|
||||
/// scaffold such as `language Chinese<asr_text>…`. Older runtimes leave
|
||||
/// that intact in `result.text`; incomplete generations can even stop at
|
||||
/// the bare word `language`. Strip the scaffold so only spoken text remains.
|
||||
private static func sanitizeTranscript(_ raw: String) -> String {
|
||||
var text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.isEmpty { return "" }
|
||||
|
||||
// Prefer the payload after the last `<asr_text>` marker.
|
||||
if let marker = text.range(of: "<asr_text>", options: .backwards) {
|
||||
text = String(text[marker.upperBound...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else if let match = text.range(
|
||||
of: #"^language\s+\S+\s*"#,
|
||||
options: [.regularExpression, .caseInsensitive]
|
||||
) {
|
||||
// Fallback when the marker token was lost but the language prefix remains.
|
||||
text = String(text[match.upperBound...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// Drop leftover control tokens / bare scaffold words.
|
||||
if isMetadataNoiseLine(text) { return "" }
|
||||
return text
|
||||
}
|
||||
|
||||
/// Lines that are sherpa/Qwen metadata rather than spoken content.
|
||||
private static func isMetadataNoiseLine(_ line: String) -> Bool {
|
||||
let lowered = line.lowercased()
|
||||
switch lowered {
|
||||
case "language", "emotion", "event", "text",
|
||||
"<asr_text>", "</asr_text>", "<|im_end|>":
|
||||
return true
|
||||
default:
|
||||
// Exact scaffold with no spoken payload, e.g. "language Chinese".
|
||||
if lowered.range(
|
||||
of: #"^language(\s+\S+)?$"#,
|
||||
options: .regularExpression
|
||||
) != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MacQwen3LanguageHint {
|
||||
/// Map persisted BCP-47 locale ids to Qwen3 prompt language names.
|
||||
/// Returns `nil` for auto-detect.
|
||||
static func from(locale: Locale) -> String? {
|
||||
let raw = locale.identifier.lowercased()
|
||||
if raw.isEmpty || raw == "auto" { return nil }
|
||||
if raw.hasPrefix("zh") { return "Chinese" }
|
||||
if raw.hasPrefix("en") { return "English" }
|
||||
if raw.hasPrefix("ja") { return "Japanese" }
|
||||
if raw.hasPrefix("ko") { return "Korean" }
|
||||
if raw.hasPrefix("fr") { return "French" }
|
||||
if raw.hasPrefix("de") { return "German" }
|
||||
if raw.hasPrefix("es") { return "Spanish" }
|
||||
if raw.hasPrefix("pt") { return "Portuguese" }
|
||||
if raw.hasPrefix("ru") { return "Russian" }
|
||||
if raw.hasPrefix("ar") { return "Arabic" }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,65 @@ import Foundation
|
||||
import Speech
|
||||
|
||||
enum MacSpeechLocalASR {
|
||||
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
|
||||
/// Shared resume-once state for one recognition run. The recognizer
|
||||
/// callback (delivered on an arbitrary Speech queue) and the timeout task
|
||||
/// race to finish, and a `CheckedContinuation` must resume exactly once,
|
||||
/// so both go through this lock-guarded gate. It also retains the
|
||||
/// `SFSpeechRecognitionTask` so the losing/failing path can cancel it.
|
||||
private final class RecognitionSession: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var isResumed = false
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
func retain(_ task: SFSpeechRecognitionTask) {
|
||||
lock.lock()
|
||||
self.task = task
|
||||
let alreadyResumed = isResumed
|
||||
lock.unlock()
|
||||
// Timeout won the race before the task handle was stored.
|
||||
if alreadyResumed { task.cancel() }
|
||||
}
|
||||
|
||||
func retainTimeout(_ task: Task<Void, Never>) {
|
||||
lock.lock()
|
||||
timeoutTask = task
|
||||
let alreadyResumed = isResumed
|
||||
lock.unlock()
|
||||
// Recognition finished before the handle landed — stop the timer.
|
||||
if alreadyResumed { task.cancel() }
|
||||
}
|
||||
|
||||
/// Returns `true` exactly once across all callers; the winner may
|
||||
/// resume the continuation. Pass `cancellingTask: true` on failure
|
||||
/// paths so the in-flight recognition stops doing work. The winner
|
||||
/// also cancels the timeout task so it doesn't keep the session (and
|
||||
/// continuation captures) alive for the rest of its sleep.
|
||||
func claimResume(cancellingTask: Bool) -> Bool {
|
||||
lock.lock()
|
||||
guard !isResumed else {
|
||||
lock.unlock()
|
||||
return false
|
||||
}
|
||||
isResumed = true
|
||||
let task = self.task
|
||||
let timeout = timeoutTask
|
||||
lock.unlock()
|
||||
if cancellingTask { task?.cancel() }
|
||||
timeout?.cancel()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func transcribe(samples: [Float], locale: Locale, bias: LocalASRBiasPayload? = nil) async throws -> String {
|
||||
let auth = await requestAuthorization()
|
||||
guard auth == .authorized else { throw MacLocalASRError.speechDenied }
|
||||
|
||||
if Self.isChineseLocale(locale) {
|
||||
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
|
||||
_ = try? await CustomLanguageModelManager.shared.prepareIfNeeded()
|
||||
}
|
||||
|
||||
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
|
||||
defer { try? FileManager.default.removeItem(at: wavURL) }
|
||||
|
||||
@@ -20,18 +75,43 @@ enum MacSpeechLocalASR {
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
throw MacLocalASRError.speechFailed("Speech recognizer unavailable")
|
||||
}
|
||||
// The request below sets `requiresOnDeviceRecognition = true`, which
|
||||
// fails (or worse, never produces a final result) when the on-device
|
||||
// model for the locale is missing — fail fast with a clear error.
|
||||
guard recognizer.supportsOnDeviceRecognition else {
|
||||
throw MacLocalASRError.speechFailed(
|
||||
"On-device speech recognition is not available for \(recognizer.locale.identifier). Download the language in System Settings → Keyboard → Dictation."
|
||||
)
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
// Overall deadline: recognition of a file is normally much faster than
|
||||
// realtime, so 2× audio length with a 30 s floor is generous. Without
|
||||
// it, empty audio / cancellation / a missing model can leave the
|
||||
// callback silent forever and the continuation never resumes.
|
||||
let audioSeconds = Double(samples.count) / 16_000
|
||||
let timeoutSeconds = max(30.0, audioSeconds * 2)
|
||||
let session = RecognitionSession()
|
||||
|
||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in
|
||||
let request = SFSpeechURLRecognitionRequest(url: wavURL)
|
||||
request.shouldReportPartialResults = false
|
||||
request.requiresOnDeviceRecognition = true
|
||||
CustomLanguageModelManager.applyCustomLanguageModel(
|
||||
to: request,
|
||||
locale: locale,
|
||||
bias: bias
|
||||
)
|
||||
|
||||
recognizer.recognitionTask(with: request) { result, error in
|
||||
let task = recognizer.recognitionTask(with: request) { result, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
|
||||
if session.claimResume(cancellingTask: true) {
|
||||
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non-final callbacks carry no usable transcript yet; if a
|
||||
// final result never arrives, the timeout below resumes us.
|
||||
guard let result, result.isFinal else { return }
|
||||
guard session.claimResume(cancellingTask: false) else { return }
|
||||
let text = result.bestTranscription.formattedString
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.isEmpty {
|
||||
@@ -40,6 +120,16 @@ enum MacSpeechLocalASR {
|
||||
continuation.resume(returning: text)
|
||||
}
|
||||
}
|
||||
session.retain(task)
|
||||
|
||||
let timeout = Task {
|
||||
try? await Task.sleep(for: .seconds(timeoutSeconds))
|
||||
guard !Task.isCancelled else { return }
|
||||
if session.claimResume(cancellingTask: true) {
|
||||
continuation.resume(throwing: MacLocalASRError.speechFailed("Speech recognition timed out"))
|
||||
}
|
||||
}
|
||||
session.retainTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +148,8 @@ enum MacSpeechLocalASR {
|
||||
try wav.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
private static func isChineseLocale(_ locale: Locale) -> Bool {
|
||||
locale.identifier(.bcp47).lowercased().hasPrefix("zh")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// MacTextInsertionService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Inserts transcribed text into the frontmost app: clipboard first, then
|
||||
// Inserts transcribed text into the target app: clipboard first, then
|
||||
// a synthetic ⌘V (SayIt / Typeless-style). Requires Accessibility trust.
|
||||
// Re-activates the app the user was dictating into (the popover steals
|
||||
// focus) and restores the original clipboard once the paste has landed.
|
||||
|
||||
import AppKit
|
||||
@preconcurrency import ApplicationServices
|
||||
@@ -30,32 +32,158 @@ enum MacTextInsertionService {
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
/// Copy to pasteboard and optionally simulate ⌘V in the front app.
|
||||
// MARK: - Paste-target tracking
|
||||
|
||||
/// Start observing app activations early (app launch) so a paste target
|
||||
/// can still be recovered while OSGKeyboard itself is frontmost — e.g.
|
||||
/// when a recording is started from the menu-bar popover.
|
||||
@MainActor
|
||||
static func beginTrackingFrontmostApp() {
|
||||
_ = FrontmostAppTracker.shared
|
||||
}
|
||||
|
||||
/// The app a synthesized ⌘V should land in: the current frontmost app,
|
||||
/// or — when OSGKeyboard is frontmost because the popover has key
|
||||
/// focus — the app that was active immediately before it.
|
||||
@MainActor
|
||||
static func captureTargetApplication() -> NSRunningApplication? {
|
||||
let selfPid = NSRunningApplication.current.processIdentifier
|
||||
if let front = NSWorkspace.shared.frontmostApplication,
|
||||
front.processIdentifier != selfPid {
|
||||
return front
|
||||
}
|
||||
return FrontmostAppTracker.shared.lastExternalApp
|
||||
}
|
||||
|
||||
// MARK: - Insertion
|
||||
|
||||
/// Copy to pasteboard and optionally simulate ⌘V in the target app.
|
||||
/// Returns `true` only if the ⌘V event was actually synthesized. After a
|
||||
/// successful paste the user's original clipboard is put back, so
|
||||
/// dictation never permanently clobbers it.
|
||||
@MainActor
|
||||
static func insert(
|
||||
_ text: String,
|
||||
autoPaste: Bool
|
||||
) throws -> Bool {
|
||||
autoPaste: Bool,
|
||||
targetApp: NSRunningApplication? = nil
|
||||
) async throws -> Bool {
|
||||
guard !text.isEmpty else { return false }
|
||||
let pasteboard = NSPasteboard.general
|
||||
let snapshot = snapshotItems(of: pasteboard)
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
|
||||
guard autoPaste else { return false }
|
||||
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
|
||||
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
postCommandV()
|
||||
// Make sure ⌘V lands in the app the user was dictating into, not in
|
||||
// OSGKeyboard's own popover / window.
|
||||
if let targetApp { await activate(targetApp) }
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
guard postCommandV() else { return false }
|
||||
|
||||
// Give the target app time to read the transcript off the
|
||||
// pasteboard, then restore whatever the user had on it.
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
restoreItems(snapshot, to: pasteboard)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func postCommandV() {
|
||||
/// Brings `app` forward and waits (up to ~1 s) until it is frontmost so
|
||||
/// the synthesized keystroke isn't swallowed mid-switch.
|
||||
@MainActor
|
||||
private static func activate(_ app: NSRunningApplication) async {
|
||||
func isFront() -> Bool {
|
||||
NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier
|
||||
}
|
||||
guard !isFront() else { return }
|
||||
app.activate()
|
||||
var attempts = 0
|
||||
while !isFront(), attempts < 20 {
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
attempts += 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pasteboard preservation
|
||||
|
||||
/// Every representation of every pasteboard item, so restore round-trips
|
||||
/// rich content (images, files, multiple flavours) losslessly.
|
||||
private static func snapshotItems(
|
||||
of pasteboard: NSPasteboard
|
||||
) -> [[NSPasteboard.PasteboardType: Data]] {
|
||||
(pasteboard.pasteboardItems ?? []).map { item in
|
||||
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { flavours, type in
|
||||
flavours[type] = item.data(forType: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func restoreItems(
|
||||
_ items: [[NSPasteboard.PasteboardType: Data]],
|
||||
to pasteboard: NSPasteboard
|
||||
) {
|
||||
guard !items.isEmpty else { return }
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects(items.map { flavours in
|
||||
let item = NSPasteboardItem()
|
||||
for (type, data) in flavours { item.setData(data, forType: type) }
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `false` when the CGEvents could not be created — in that case
|
||||
/// nothing was pasted and callers must not report success.
|
||||
private static func postCommandV() -> Bool {
|
||||
let source = CGEventSource(stateID: .combinedSessionState)
|
||||
let keyCode = CGKeyCode(kVK_ANSI_V)
|
||||
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
|
||||
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
||||
keyDown?.flags = CGEventFlags.maskCommand
|
||||
keyUp?.flags = CGEventFlags.maskCommand
|
||||
keyDown?.post(tap: CGEventTapLocation.cghidEventTap)
|
||||
keyUp?.post(tap: CGEventTapLocation.cghidEventTap)
|
||||
guard
|
||||
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
|
||||
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
||||
else { return false }
|
||||
keyDown.flags = CGEventFlags.maskCommand
|
||||
keyUp.flags = CGEventFlags.maskCommand
|
||||
keyDown.post(tap: CGEventTapLocation.cghidEventTap)
|
||||
keyUp.post(tap: CGEventTapLocation.cghidEventTap)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Frontmost-app tracker
|
||||
|
||||
/// Remembers the most recent non-OSGKeyboard frontmost app. Needed because
|
||||
/// the menu-bar popover activates OSGKeyboard, hiding the real paste target
|
||||
/// from `NSWorkspace.frontmostApplication`.
|
||||
@MainActor
|
||||
private final class FrontmostAppTracker: NSObject {
|
||||
static let shared = FrontmostAppTracker()
|
||||
|
||||
private(set) var lastExternalApp: NSRunningApplication?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
// Seed with whatever is frontmost now (usually not us at launch).
|
||||
if let front = NSWorkspace.shared.frontmostApplication,
|
||||
front.processIdentifier != NSRunningApplication.current.processIdentifier {
|
||||
lastExternalApp = front
|
||||
}
|
||||
NSWorkspace.shared.notificationCenter.addObserver(
|
||||
self,
|
||||
selector: #selector(appDidActivate(_:)),
|
||||
name: NSWorkspace.didActivateApplicationNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(self)
|
||||
}
|
||||
|
||||
@objc private func appDidActivate(_ notification: Notification) {
|
||||
guard
|
||||
let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||
app.processIdentifier != NSRunningApplication.current.processIdentifier
|
||||
else { return }
|
||||
lastExternalApp = app
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// semantic colours, resolved to a concrete value for the *active* appearance.
|
||||
// The brand green is kept only as the accent. Light mode uses a warm,
|
||||
// iOS-matched surface set (the default `windowBackgroundColor` reads cold
|
||||
// grey on macOS); Dark mode keeps the native AppKit semantic colours.
|
||||
// grey on macOS); Dark mode uses stepped elevated greys so cards stay
|
||||
// readable against the page background.
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
@@ -33,8 +34,8 @@ enum MacSystemPalette {
|
||||
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
|
||||
|
||||
accent: Palette.accent,
|
||||
accentMuted: Palette.accent.opacity(0.16),
|
||||
accentGlow: Palette.accent.opacity(0.35),
|
||||
accentMuted: Palette.accent.opacity(dark ? 0.22 : 0.14),
|
||||
accentGlow: Palette.accent.opacity(dark ? 0.40 : 0.32),
|
||||
|
||||
danger: resolved(.systemRed, dark: dark),
|
||||
success: Palette.accent,
|
||||
@@ -45,8 +46,12 @@ enum MacSystemPalette {
|
||||
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
|
||||
textOnAccent: Color.white,
|
||||
|
||||
divider: resolved(.separatorColor, dark: dark),
|
||||
dividerStrong: resolved(.separatorColor, dark: dark),
|
||||
divider: dark
|
||||
? Color.white.opacity(0.08)
|
||||
: Color.black.opacity(0.06),
|
||||
dividerStrong: dark
|
||||
? Color.white.opacity(0.12)
|
||||
: Color.black.opacity(0.10),
|
||||
|
||||
recordRed: resolved(.systemRed, dark: dark)
|
||||
)
|
||||
|
||||
@@ -57,7 +57,9 @@ struct OSGKeyboardMacApp: App {
|
||||
// very top, matching macOS System Settings.
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentMinSize)
|
||||
.defaultSize(width: 1_024, height: 720)
|
||||
// Open at the minimum size — same as `MacMetrics.windowMin*`, so the
|
||||
// first launch already matches the smallest allowed window.
|
||||
.defaultSize(width: MacMetrics.windowMinWidth, height: MacMetrics.windowMinHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +96,11 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
MacAppearancePreference.applyToApp(.current)
|
||||
MacTextInsertionService.beginTrackingFrontmostApp()
|
||||
configurePopover()
|
||||
configureStatusItem()
|
||||
MacDictationOverlayController.shared.start(observing: MacDictationViewModel.shared)
|
||||
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
|
||||
|
||||
// The menu bar always follows the *system* appearance, so the status
|
||||
// item must ignore the app's forced light/dark override. Re-pin the
|
||||
|
||||
@@ -13,12 +13,21 @@ import Foundation
|
||||
/// Keep this protocol narrow: only what the shared pipeline needs today.
|
||||
/// Platform-specific settings UI and iCloud sync stay on concrete stores.
|
||||
public protocol ConfigurationStore: Sendable {
|
||||
/// Polish / LLM provider id.
|
||||
var providerId: String { get }
|
||||
var baseURL: String { get }
|
||||
var apiKey: String { get }
|
||||
var model: String { get }
|
||||
|
||||
/// Cloud ASR provider id — independent from polish when `engineMode == "cloud"`.
|
||||
var asrProviderId: String { get }
|
||||
var asrBaseURL: String { get }
|
||||
var asrApiKey: String { get }
|
||||
var asrModel: String { get }
|
||||
|
||||
var engineMode: String { get }
|
||||
var polishIntensity: PolishIntensity { get }
|
||||
var llmThinkingEnabled: Bool { get }
|
||||
var personalDictionary: PersonalDictionary { get }
|
||||
|
||||
/// Foreground-app context for polish prompts (keyboard extension publishes this).
|
||||
|
||||