Merge pull request #31 from hkgood/feature/macos-architecture
Feature/macos architecture
This commit is contained in:
@@ -64,3 +64,12 @@ OSGKeyboard/Resources/CustomLanguageModel/v1/compiled/
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local debug capture logs and screenshots
|
||||
.flow-capture.log
|
||||
.flow-oslog-capture.log
|
||||
.flow-*.log
|
||||
.osg_*.png
|
||||
|
||||
# Standalone experiment: 灵动岛录音机制验证 demo (throwaway)
|
||||
AudioIntentProbe/
|
||||
|
||||
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **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
|
||||
|
||||
### Added
|
||||
- **macOS local ASR models**: the desktop app ships a bundled model catalog with one-click download of Sherpa Qwen3 (hotwords) and SenseVoice models, plus a shared model storage directory; downloads show a circular progress ring with pause / resume, and each row has inline Download / Delete actions. / **macOS 本地 ASR 模型**:桌面 App 内置模型目录,可一键下载 Sherpa Qwen3(热词)与 SenseVoice 模型,并共用同一模型存储目录;下载显示带暂停 / 继续的环形进度,每行提供内联的下载 / 删除操作。
|
||||
- **Shared model directory for MLX**: Qwen3-ASR MLX now uses a fixed subfolder inside the shared model storage — drop converted weights into the folder opened by "Open folder"; no per-model directory picker. / **MLX 共用模型目录**:Qwen3-ASR MLX 改用共享模型存储中的固定子目录——把转换好的权重放入「打开目录」指向的文件夹即可,不再逐模型选目录。
|
||||
- **iCloud sync hardening**: per-field settings merge (`appSettings.v2`), per-device usage statistics (G-Counter), tombstoned dictionary/history merge, and a low-risk **Sync Now** action in Settings. / **iCloud 同步加固**:设置按字段合并(`appSettings.v2`)、统计按设备 G-Counter 累计、词库/历史带墓碑合并,并在设置中新增低风险的**立即同步**操作。
|
||||
|
||||
### Changed
|
||||
- **API key sync**: cloud provider API keys now replicate through **iCloud Keychain** when settings sync is on — never through iCloud KVS JSON. / **API 密钥同步**:开启设置同步后,云端服务商 API 密钥改由 **iCloud 钥匙串**复制,不再写入 iCloud KVS JSON。
|
||||
- **Speech history cap**: synced history limit is **300** entries (aligned with the sync payload). / **语音历史上限**:可同步历史上限为 **300** 条(与同步载荷一致)。
|
||||
- **macOS app name**: the built product is now `OSGKeyboard.app` (was `OSGKeyboardMac.app`); Dock, About, and Finder all read **OSGKeyboard**. / **macOS 应用名称**:编译产物改为 `OSGKeyboard.app`(原 `OSGKeyboardMac.app`);Dock、关于窗口与 Finder 均显示 **OSGKeyboard**。
|
||||
- **macOS local recognition label**: the Settings entry is now simply "Local Recognition" and no longer names a specific model. / **macOS 本地识别标签**:设置项改为「本地识别」,不再绑定具体模型名称。
|
||||
|
||||
### Fixed
|
||||
- **macOS menu-bar icon in light mode**: the status-bar icon now follows the *system* menu-bar appearance, so forcing the app into Light while the system is Dark no longer renders an unreadable dark icon; a refreshed status mark is used. / **macOS 菜单栏图标(浅色模式)**:状态栏图标改为跟随*系统*菜单栏外观,App 强制浅色而系统为深色时不再出现看不清的深色图标;并更新了状态栏图标。
|
||||
- **macOS light-mode sidebar**: restored the native translucent sidebar material so the light appearance matches system apps (e.g. System Settings, Notes) instead of a flat grey fill. / **macOS 浅色侧边栏**:恢复原生半透明侧栏材质,浅色外观与系统应用(如系统设置、备忘录)一致,不再是扁平灰底。
|
||||
- **Settings sync wiping API keys**: pulling a legacy settings blob without API key fields no longer deletes local Keychain entries. / **设置同步清空 API 密钥**:拉取不含 API 密钥字段的旧版设置包时,不再删除本地 Keychain 项。
|
||||
- **Cross-device settings conflicts**: changing different settings on two devices no longer lets one device's full blob overwrite the other's unrelated fields. / **跨设备设置冲突**:两台设备分别修改不同设置项时,不再因整包覆盖而冲掉对方未改动的字段。
|
||||
- **Usage statistics under-counting**: offline usage on multiple devices now sums correctly instead of taking per-field `max()`. / **使用统计少计**:多设备离线各自累计后合并为求和,不再对总量取 `max()`。
|
||||
- **Dictionary/history resurrection**: deletes and "clear all" on one device propagate via tombstones so older remote entries cannot come back. / **词库/历史复活**:单设备删除或清空会通过墓碑传播,远端旧条目无法复活。
|
||||
- **Flow false-ready mic state**: the keyboard mic now stays orange until the host app publishes a real ready contract (capture engine live + polling idle), not merely a fresh heartbeat; green tap-to-talk and jump-to-host behavior share the same `MicVoiceAvailability` gate, and orphaned `stopped` signals self-heal instead of hanging until timeout. / **Flow 伪就绪麦克风状态**:键盘麦克风在主 App 发布真实就绪合约(音频引擎在跑且轮询空闲)之前保持橙色,不再仅凭心跳误判;绿色「点按说话」与跳转主 App 共用同一 `MicVoiceAvailability` 闸门,孤立的 `stopped` 信号会自愈而不再长时间卡住。
|
||||
- **Flow mic stuck orange after ready**: a single stale cross-process heartbeat read no longer flips a healthy session into a sticky "session ended" error that forced the mic orange. The "session ended" hint now fires only when the (heartbeat-independent) session contract truly drops; a brief read jitter is smoothed by a ready grace window, and a lingering expired hint auto-recovers to green once the host is ready again. / **就绪后麦克风卡橙色**:单次跨进程心跳读数抖动不再把健康会话打成粘滞的「会话已结束」错误、强制麦克风变橙。「会话已结束」提示现仅在(不依赖心跳的)会话合约真正失效时触发;短暂读数抖动由就绪宽限期平滑,遗留的过期提示会在宿主重新就绪后自动恢复为绿色。
|
||||
- **Orphaned Live Activity after force-quit**: force-quitting the app no longer leaves a stale OSGKeyboard status stuck on the Lock Screen / Dynamic Island. The `staleDate` is now ~45s (refreshed by the heartbeat while the session is alive) so the system reclaims a dead session's island on its own, and every app foreground now sweeps leftover Live Activities *before* trying to (re)start a session — so even a start that later fails (e.g. mic proof timeout) still clears the zombie island. / **强杀后遗留 Live Activity**:强制退出 App 不再在锁屏 / 灵动岛留下无法消失的 OSGKeyboard 状态。`staleDate` 缩短为约 45 秒(会话存活期间由心跳持续刷新),系统会自动回收已死会话的灵动岛;且每次 App 回到前台都会**先**清扫遗留的 Live Activity 再尝试(重新)启动会话——即便本次启动随后失败(如麦克风就绪超时),也不会留下僵尸灵动岛。
|
||||
|
||||
## [0.5.0] - 2026-07-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -31,8 +31,6 @@
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : [
|
||||
"iOS"
|
||||
]
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "OSGLogoWide.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg width="952" height="291" viewBox="36 367 952 291" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
|
||||
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
|
||||
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
|
||||
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
|
||||
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "OSGStatusMark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -22,15 +22,15 @@
|
||||
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
||||
<div id="top">
|
||||
<h1>OSGKeyboard Privacy Policy</h1>
|
||||
<p><strong>Last updated:</strong> June 19, 2026</p>
|
||||
<p><strong>Last updated:</strong> July 8, 2026</p>
|
||||
<p>OSGKeyboard is a custom iOS keyboard that turns your voice into text. This policy explains what data the app processes and how it is used.</p>
|
||||
|
||||
<h2>What we collect</h2>
|
||||
<ul>
|
||||
<li><strong>Voice audio</strong> — captured only while you actively record. On-device mode transcribes locally with Apple’s speech APIs; raw audio is not uploaded by OSGKeyboard.</li>
|
||||
<li><strong>Transcribed text</strong> — in Cloud polish mode, the final text (not audio) may be sent to the LLM provider you configure (e.g. OpenAI) for punctuation and formatting.</li>
|
||||
<li><strong>API credentials</strong> — stored in the iOS Keychain on your device and shared only between the main app and keyboard extension via an App Group.</li>
|
||||
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults on your device.</li>
|
||||
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
||||
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors preferences, usage statistics, and voice history through your private iCloud account.</li>
|
||||
</ul>
|
||||
|
||||
<h2>What we do not collect</h2>
|
||||
@@ -51,8 +51,8 @@
|
||||
<p>When you choose Cloud polish mode, transcribed text is sent to the API endpoint you configure. That provider’s privacy policy applies to those requests.</p>
|
||||
|
||||
<h2>Data retention</h2>
|
||||
<p>Settings and API keys remain on your device until you delete the app or reset settings. Transcription results are passed to the host app you are typing in and are not stored long-term by OSGKeyboard.</p>
|
||||
<p><strong>Voice history</strong> — successful transcripts may be saved locally in the main app’s History tab for your convenience. This history stays on your device only, is never uploaded, and can be cleared at any time from History or by resetting settings.</p>
|
||||
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; preferences, statistics, and history may sync via your private iCloud account.</p>
|
||||
<p><strong>Voice history</strong> — successful transcripts may be saved in the main app’s History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
|
||||
@@ -60,15 +60,15 @@
|
||||
|
||||
<hr id="zh">
|
||||
<h1>OSGKeyboard 隐私政策</h1>
|
||||
<p><strong>更新日期:</strong>2026 年 6 月 19 日</p>
|
||||
<p><strong>更新日期:</strong>2026 年 7 月 8 日</p>
|
||||
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。</p>
|
||||
|
||||
<h2>我们处理的数据</h2>
|
||||
<ul>
|
||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。本地模式在设备端通过 Apple 语音识别转写,OSGKeyboard 不会上传原始录音。</li>
|
||||
<li><strong>转写文字</strong> — 云端润色模式下,最终文字(非音频)可能发送到你配置的 LLM 服务商以整理标点和格式。</li>
|
||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,仅通过 App Group 在主 App 与键盘扩展间共享。</li>
|
||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在设备 App Group 中。</li>
|
||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像偏好、统计与语音历史。</li>
|
||||
</ul>
|
||||
|
||||
<h2>我们不收集的内容</h2>
|
||||
@@ -89,8 +89,8 @@
|
||||
<p>选择云端润色时,转写文字会发往你配置的 API,该服务商的隐私政策适用于相关请求。</p>
|
||||
|
||||
<h2>数据保留</h2>
|
||||
<p>设置与 API Key 保留在设备上,直至卸载或重置。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。</p>
|
||||
<p><strong>语音历史</strong> — 成功的转写可能保存在主 App「历史」页,仅供本机查看,不会上传,可随时在历史页清空或通过重置设置清除。</p>
|
||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;偏好、统计与历史可能经私有 iCloud 账户同步。</p>
|
||||
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
||||
|
||||
<h2>联系</h2>
|
||||
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
|
||||
|
||||
@@ -59,6 +59,14 @@ final class AppURLHandler: NSObject, UIApplicationDelegate {
|
||||
configuration.delegateClass = AppSceneDelegate.self
|
||||
return configuration
|
||||
}
|
||||
|
||||
/// 后台音频会话仍 running 时,用户强杀常会进入此回调(约 5 秒清理窗口)。
|
||||
/// 同步释放麦克风 + 结束 Live Activity,避免重开后「麦克风被占用」。
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
MainActor.assumeIsolated {
|
||||
FlowTerminationCoordinator.performSynchronousTerminationCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
|
||||
@@ -10,12 +10,16 @@ import OSGKeyboardShared
|
||||
|
||||
enum FlowLiveActivityController {
|
||||
nonisolated(unsafe) private static var currentActivity: Activity<FlowActivityAttributes>?
|
||||
/// Last phase pushed to the Live Activity so `keepAlive()` can refresh the
|
||||
/// `staleDate` without changing what the user sees.
|
||||
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 `staleDate` lets the system grey it out and become
|
||||
/// willing to reclaim it without our process — refreshed on every update
|
||||
/// so a genuinely active, in-use session never looks stale.
|
||||
private static let staleWindow: TimeInterval = 60 * 60
|
||||
/// 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
|
||||
|
||||
private static func freshContent(
|
||||
phase: FlowActivityAttributes.ContentState.Phase
|
||||
@@ -41,6 +45,7 @@ enum FlowLiveActivityController {
|
||||
}
|
||||
|
||||
do {
|
||||
currentPhase = .idle
|
||||
currentActivity = try Activity.request(
|
||||
attributes: FlowActivityAttributes(),
|
||||
content: freshContent(phase: .idle),
|
||||
@@ -54,14 +59,28 @@ enum FlowLiveActivityController {
|
||||
|
||||
static func update(phase: FlowActivityAttributes.ContentState.Phase) {
|
||||
guard let activity = currentActivity else { return }
|
||||
currentPhase = phase
|
||||
let content = freshContent(phase: phase)
|
||||
Task {
|
||||
await activity.update(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a fresh `staleDate` without changing the visible phase. The host
|
||||
/// heartbeat calls this well inside `staleWindow` so an in-use session
|
||||
/// never looks stale; once the process dies the refreshes stop and the
|
||||
/// system reclaims the orphaned Live Activity on its own.
|
||||
static func keepAlive() {
|
||||
guard let activity = currentActivity else { return }
|
||||
let content = freshContent(phase: currentPhase)
|
||||
Task {
|
||||
await activity.update(content)
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss the island presentation when the Flow session ends.
|
||||
static func endSession() {
|
||||
currentPhase = .idle
|
||||
guard let activity = currentActivity else {
|
||||
endStaleActivities()
|
||||
return
|
||||
@@ -74,6 +93,18 @@ enum FlowLiveActivityController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear Live Activities orphaned by a previous (force-quit) host process.
|
||||
///
|
||||
/// Safe to call on every app foreground: when this process already owns a
|
||||
/// Live Activity (`currentActivity != nil`) we leave it alone so a healthy
|
||||
/// running session is never torn down; we only sweep leftovers that belong
|
||||
/// to a dead process. Call this *before* attempting to (re)start a session
|
||||
/// so a failed start (e.g. mic timeout) still clears the stale island.
|
||||
static func clearOrphanedActivities() {
|
||||
guard currentActivity == nil else { return }
|
||||
endStaleActivities()
|
||||
}
|
||||
|
||||
/// Host relaunch can leave orphan activities; clear them before starting anew.
|
||||
private static func endStaleActivities() {
|
||||
let staleActivities = Activity<FlowActivityAttributes>.activities
|
||||
@@ -85,4 +116,21 @@ enum FlowLiveActivityController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `applicationWillTerminate` 专用:阻塞到所有 `end` 完成,避免进程先退出而锁屏卡片残留。
|
||||
nonisolated static func endAllSynchronouslyOnTerminate() {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task.detached(priority: .userInitiated) {
|
||||
let activities = Activity<FlowActivityAttributes>.activities
|
||||
let count = activities.count
|
||||
for activity in activities {
|
||||
await activity.end(activity.content, dismissalPolicy: .immediate)
|
||||
}
|
||||
FlowDiagnostics.log("Live Activity ended synchronously on terminate (count=\(count))")
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
currentPhase = .idle
|
||||
currentActivity = nil
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
// FlowTerminationCoordinator.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// 桥接 `UIApplicationDelegate.applicationWillTerminate` 与 `FlowSessionManager`。
|
||||
// SwiftUI 里 `FlowSessionManager` 是 `@StateObject`,AppDelegate 无法直接持有;
|
||||
// 此处用弱引用在进程退出窗口(约 5 秒)内同步释放麦克风与 Live Activity。
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
enum FlowTerminationCoordinator {
|
||||
private static weak var sessionManager: FlowSessionManager?
|
||||
|
||||
/// `FlowSessionManager.init()` 注册当前实例。
|
||||
static func register(_ manager: FlowSessionManager) {
|
||||
sessionManager = manager
|
||||
}
|
||||
|
||||
/// 强杀 / 系统终止时调用。必须在主线程执行(`applicationWillTerminate` 保证)。
|
||||
static func performSynchronousTerminationCleanup() {
|
||||
sessionManager?.prepareForProcessTermination()
|
||||
FlowLiveActivityController.endAllSynchronouslyOnTerminate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// SpeechHistoryStore+iOS.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// iOS-only helper that records history and home-screen usage stats together.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
extension SpeechHistoryStore {
|
||||
/// Append history and update cumulative home-screen usage stats.
|
||||
func recordUtterance(
|
||||
text: String,
|
||||
engineMode: String,
|
||||
duration: TimeInterval,
|
||||
wasTranslation: Bool
|
||||
) {
|
||||
append(text: text, engineMode: engineMode)
|
||||
UsageStatisticsStore.shared.recordUtterance(
|
||||
text: text,
|
||||
duration: duration,
|
||||
wasTranslation: wasTranslation
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// SpeechHistoryStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Local-only log of successful voice transcriptions (Flow + future paths).
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
struct SpeechHistoryEntry: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let text: String
|
||||
let createdAt: Date
|
||||
let engineMode: String
|
||||
|
||||
init(id: UUID = UUID(), text: String, createdAt: Date = Date(), engineMode: String) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.createdAt = createdAt
|
||||
self.engineMode = engineMode
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SpeechHistoryStore: ObservableObject {
|
||||
static let shared = SpeechHistoryStore()
|
||||
|
||||
@Published private(set) var entries: [SpeechHistoryEntry] = []
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey = "speechHistory.entries.v1"
|
||||
private let maxEntries = 500
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
load()
|
||||
}
|
||||
|
||||
func append(text: String, engineMode: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
|
||||
entries.insert(entry, at: 0)
|
||||
if entries.count > maxEntries {
|
||||
entries = Array(entries.prefix(maxEntries))
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
/// Append history and update cumulative home-screen usage stats.
|
||||
func recordUtterance(
|
||||
text: String,
|
||||
engineMode: String,
|
||||
duration: TimeInterval,
|
||||
wasTranslation: Bool
|
||||
) {
|
||||
append(text: text, engineMode: engineMode)
|
||||
UsageStatisticsStore.shared.recordUtterance(
|
||||
text: text,
|
||||
duration: duration,
|
||||
wasTranslation: wasTranslation
|
||||
)
|
||||
}
|
||||
|
||||
func delete(id: UUID) {
|
||||
guard entries.contains(where: { $0.id == id }) else { return }
|
||||
entries.removeAll { $0.id == id }
|
||||
persist()
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
entries.removeAll()
|
||||
persist()
|
||||
}
|
||||
|
||||
/// Entries grouped by calendar day (newest day first).
|
||||
var groupedByDay: [(day: Date, items: [SpeechHistoryEntry])] {
|
||||
let calendar = Calendar.current
|
||||
var buckets: [Date: [SpeechHistoryEntry]] = [:]
|
||||
for entry in entries {
|
||||
let day = calendar.startOfDay(for: entry.createdAt)
|
||||
buckets[day, default: []].append(entry)
|
||||
}
|
||||
return buckets.keys.sorted(by: >).map { day in
|
||||
(day, buckets[day]!.sorted { $0.createdAt > $1.createdAt })
|
||||
}
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = defaults.data(forKey: storageKey) else { return }
|
||||
do {
|
||||
entries = try JSONDecoder().decode([SpeechHistoryEntry].self, from: data)
|
||||
} catch {
|
||||
entries = []
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// UsageStatisticsStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Cumulative usage metrics shown on the home screen stats card.
|
||||
// Updated when Flow finalizes a successful utterance.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct UsageStatistics: Codable, Equatable {
|
||||
var dictationDurationSeconds: TimeInterval
|
||||
var dictationCharacterCount: Int
|
||||
var translationCharacterCount: Int
|
||||
|
||||
static let zero = UsageStatistics(
|
||||
dictationDurationSeconds: 0,
|
||||
dictationCharacterCount: 0,
|
||||
translationCharacterCount: 0
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class UsageStatisticsStore: ObservableObject {
|
||||
static let shared = UsageStatisticsStore()
|
||||
|
||||
@Published private(set) var dictationDurationSeconds: TimeInterval = 0
|
||||
@Published private(set) var dictationCharacterCount: Int = 0
|
||||
@Published private(set) var translationCharacterCount: Int = 0
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey = "usageStatistics.v1"
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
load()
|
||||
}
|
||||
|
||||
func recordUtterance(text: String, duration: TimeInterval, wasTranslation: Bool) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let count = Self.characterCount(for: trimmed)
|
||||
if wasTranslation {
|
||||
translationCharacterCount += count
|
||||
} else {
|
||||
dictationCharacterCount += count
|
||||
}
|
||||
dictationDurationSeconds += max(0, duration)
|
||||
persist()
|
||||
}
|
||||
|
||||
static func characterCount(for text: String) -> Int {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).count
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
static func formatDuration(_ seconds: TimeInterval, language: AppUILanguage) -> String {
|
||||
let total = max(0, Int(seconds.rounded()))
|
||||
if total < 60 {
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(total)秒"
|
||||
: "\(total)s"
|
||||
}
|
||||
let hours = total / 3600
|
||||
let minutes = (total % 3600) / 60
|
||||
if hours > 0 {
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(hours)小时\(minutes)分"
|
||||
: "\(hours)h \(minutes)m"
|
||||
}
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(minutes)分"
|
||||
: "\(minutes)m"
|
||||
}
|
||||
|
||||
static func formatCount(_ value: Int, language: AppUILanguage) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: language.resolvedLanguageCode())
|
||||
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = defaults.data(forKey: storageKey),
|
||||
let stats = try? JSONDecoder().decode(UsageStatistics.self, from: data)
|
||||
else { return }
|
||||
dictationDurationSeconds = stats.dictationDurationSeconds
|
||||
dictationCharacterCount = stats.dictationCharacterCount
|
||||
translationCharacterCount = stats.translationCharacterCount
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let stats = UsageStatistics(
|
||||
dictationDurationSeconds: dictationDurationSeconds,
|
||||
dictationCharacterCount: dictationCharacterCount,
|
||||
translationCharacterCount: translationCharacterCount
|
||||
)
|
||||
guard let data = try? JSONEncoder().encode(stats) else { return }
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ struct HomeStatsCard: View {
|
||||
.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 {
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
// FlowColdStartOverlay.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Minimal cold-start handoff UI: bottom-bar swipe guidance and optional return link.
|
||||
// Cold-start handoff hint: a bottom-anchored, full-width gradient that keeps
|
||||
// the current app visible while Flow proves that voice input is actually
|
||||
// ready. Failure states reuse the same minimal layout and only change the
|
||||
// text — permission issues are handled with a single "open Settings" link,
|
||||
// never a second in-app permission flow.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct FlowColdStartContext: Equatable {
|
||||
let hostEntry: HostAppEntry?
|
||||
var state: FlowColdStartState
|
||||
}
|
||||
|
||||
enum FlowColdStartState: Equatable {
|
||||
case preparing
|
||||
case ready
|
||||
case failed(FlowColdStartFailure)
|
||||
}
|
||||
|
||||
enum FlowColdStartFailure: Equatable {
|
||||
case permission(message: String)
|
||||
case audio(message: String)
|
||||
}
|
||||
|
||||
struct FlowColdStartOverlay: View {
|
||||
@@ -16,105 +32,168 @@ struct FlowColdStartOverlay: View {
|
||||
let context: FlowColdStartContext
|
||||
let onReturnToHost: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
let onRetry: () -> Void
|
||||
let onOpenSettings: () -> Void
|
||||
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
/// Fraction of the screen height the bottom gradient occupies.
|
||||
private let gradientHeightFraction: CGFloat = 0.50
|
||||
|
||||
private let homeBarWidth: CGFloat = 134
|
||||
/// Ready and failure states dismiss on blank tap; preparing stays
|
||||
/// informational only (no accidental dismiss while proving audio).
|
||||
private var allowsBlankTapDismiss: Bool {
|
||||
switch context.state {
|
||||
case .ready, .failed:
|
||||
return true
|
||||
case .preparing:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
palette.background.opacity(0.96)
|
||||
.ignoresSafeArea()
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .bottom) {
|
||||
// Full-width bottom gradient: transparent at the top of the
|
||||
// band, nearly opaque at the bottom so hint text stays readable.
|
||||
LinearGradient(
|
||||
colors: [
|
||||
palette.background.opacity(0.35),
|
||||
palette.background.opacity(0.72),
|
||||
palette.background.opacity(0.97)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: geo.size.height * gradientHeightFraction)
|
||||
.frame(maxWidth: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Image("OSGBrandMark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("flow.coldStart.title")
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("flow.coldStart.swipeHint")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
if context.hostEntry != nil {
|
||||
Button(action: onReturnToHost) {
|
||||
Text(returnButtonTitle)
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if allowsBlankTapDismiss {
|
||||
// Captures taps on empty overlay space (dismiss) and blocks
|
||||
// pass-through to the host shell underneath. Action buttons in
|
||||
// `content` sit above this layer and remain tappable.
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onDismiss)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
|
||||
Spacer()
|
||||
VStack(spacing: Spacing.lg) {
|
||||
content
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
|
||||
bottomSwipeGuide
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
Text("flow.coldStart.tapToDismiss")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.padding(.bottom, Spacing.xl)
|
||||
homeIndicator
|
||||
.padding(.bottom, max(geo.safeAreaInsets.bottom, Spacing.sm))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onDismiss)
|
||||
.animation(.easeInOut(duration: 0.2), value: context.state)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var returnButtonTitle: String {
|
||||
guard let entry = context.hostEntry else {
|
||||
return AppL10n.string("flow.coldStart.return.generic")
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
statusIcon
|
||||
|
||||
Text(title)
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(message)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
actionLink
|
||||
}
|
||||
let appName = AppL10n.string(entry.displayNameKey)
|
||||
return AppL10n.format("flow.coldStart.return.named", appName)
|
||||
}
|
||||
|
||||
private var bottomSwipeGuide: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2.5, style: .continuous)
|
||||
.fill(palette.textTertiary.opacity(0.35))
|
||||
.frame(width: homeBarWidth, height: 5)
|
||||
|
||||
Circle()
|
||||
.fill(palette.accent)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: swipeOffset)
|
||||
}
|
||||
.frame(width: homeBarWidth, height: 16)
|
||||
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "arrow.left")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
@ViewBuilder
|
||||
private var statusIcon: some View {
|
||||
switch context.state {
|
||||
case .preparing:
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
.scaleEffect(1.1)
|
||||
.accessibilityLabel(AppL10n.string("flow.coldStart.preparing"))
|
||||
case .ready:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityHidden(true)
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(palette.warning)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility"))
|
||||
.onAppear {
|
||||
swipeOffset = 0
|
||||
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
|
||||
swipeOffset = homeBarWidth - 8
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionLink: some View {
|
||||
switch context.state {
|
||||
case .preparing, .ready:
|
||||
EmptyView()
|
||||
case .failed(let failure):
|
||||
switch failure {
|
||||
case .permission:
|
||||
linkButton(AppL10n.string("flow.coldStart.action.settings"), action: onOpenSettings)
|
||||
case .audio:
|
||||
linkButton(AppL10n.string("flow.coldStart.action.retry"), action: onRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func linkButton(_ title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch context.state {
|
||||
case .preparing:
|
||||
return AppL10n.string("flow.coldStart.preparing")
|
||||
case .ready:
|
||||
return AppL10n.string("flow.coldStart.title")
|
||||
case .failed(let failure):
|
||||
switch failure {
|
||||
case .permission:
|
||||
return AppL10n.string("flow.coldStart.permission.title")
|
||||
case .audio:
|
||||
return AppL10n.string("flow.coldStart.audio.title")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch context.state {
|
||||
case .preparing:
|
||||
return AppL10n.string("flow.coldStart.preparingHint")
|
||||
case .ready:
|
||||
return AppL10n.string("flow.coldStart.swipeHint")
|
||||
case .failed(let failure):
|
||||
switch failure {
|
||||
case .permission(let message):
|
||||
return message
|
||||
case .audio(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System-style home indicator — anchors the swipe-to-return gesture.
|
||||
private var homeIndicator: some View {
|
||||
Capsule()
|
||||
.fill(palette.textTertiary.opacity(context.state == .ready ? 0.55 : 0.35))
|
||||
.frame(width: 134, height: 5)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@ struct HomeView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
.onChange(of: previewFocused) { _, focused in
|
||||
guard focused else { return }
|
||||
Task { await flowManager.refreshForInlineKeyboardFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
|
||||
@@ -29,7 +29,9 @@ struct MainAppRoot: View {
|
||||
FlowColdStartOverlay(
|
||||
context: context,
|
||||
onReturnToHost: { flowManager.returnToPendingHostFromColdStart() },
|
||||
onDismiss: { flowManager.dismissColdStartOverlay() }
|
||||
onDismiss: { flowManager.dismissColdStartOverlay() },
|
||||
onRetry: { flowManager.retryColdStartReadiness() },
|
||||
onOpenSettings: { flowManager.openColdStartPermissionSettings() }
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
|
||||
private func delete(_ entry: PersonalDictionary.Entry) {
|
||||
dictionary.entries.removeAll { $0.id == entry.id }
|
||||
dictionary.recordDeletion(of: entry.id)
|
||||
generatingAliasEntryIDs.remove(entry.id)
|
||||
persist()
|
||||
}
|
||||
@@ -275,7 +275,7 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
|
||||
private func clearAll() {
|
||||
dictionary = .empty
|
||||
dictionary.recordClearAll()
|
||||
generatingAliasEntryIDs = []
|
||||
persist()
|
||||
}
|
||||
|
||||
@@ -95,18 +95,3 @@ struct ProviderPickerSection: View {
|
||||
.background(palette.accentMuted, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
enum ProviderLogo {
|
||||
static func assetName(for providerId: String) -> String? {
|
||||
switch providerId {
|
||||
case "openai": return "openai"
|
||||
case "deepseek": return "deepseek"
|
||||
case "qwen": return "qwen"
|
||||
case "moonshot": return "moonshot"
|
||||
case "zhipu": return "zhipu"
|
||||
case "mimo": return "mimo"
|
||||
case "custom": return "custom"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Settings-row toggle for mirroring user preferences through iCloud KVS.
|
||||
// API keys remain in Keychain and are never uploaded.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -14,6 +13,7 @@ struct SettingsICloudSyncRow: View {
|
||||
@State private var isEnabled: Bool = AppGroupStore().settingsICloudSyncEnabled
|
||||
@State private var syncErrorMessage: String?
|
||||
@State private var isApplyingToggle = false
|
||||
@State private var isSyncingNow = false
|
||||
|
||||
private let store = AppGroupStore()
|
||||
|
||||
@@ -33,6 +33,25 @@ struct SettingsICloudSyncRow: View {
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
|
||||
if isEnabled {
|
||||
Button {
|
||||
syncNow()
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if isSyncingNow {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text("settings.appSettings.iCloudSync.syncNow")
|
||||
.font(TypeStyle.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
.disabled(isSyncingNow || isApplyingToggle)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -75,7 +94,7 @@ struct SettingsICloudSyncRow: View {
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await SettingsCloudSync.shared.enableSync()
|
||||
try await CloudSyncContext.shared.settingsSyncService.enableSync()
|
||||
reloadFromStore()
|
||||
} catch let error as SettingsCloudSyncError {
|
||||
isEnabled = false
|
||||
@@ -89,11 +108,24 @@ struct SettingsICloudSyncRow: View {
|
||||
}
|
||||
|
||||
private func disableSync() {
|
||||
SettingsCloudSync.shared.disableSync()
|
||||
CloudSyncContext.shared.settingsSyncService.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = nil
|
||||
}
|
||||
|
||||
private func syncNow() {
|
||||
isSyncingNow = true
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await CloudSyncContext.shared.syncNow()
|
||||
} catch {
|
||||
syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic")
|
||||
}
|
||||
isSyncingNow = false
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedSyncError(_ error: SettingsCloudSyncError) -> String {
|
||||
switch error {
|
||||
case .encodeFailed, .decodeFailed:
|
||||
|
||||
@@ -369,8 +369,9 @@
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
|
||||
|
||||
"settings.iCloudSync.title" = "iCloud Sync";
|
||||
"settings.appSettings.iCloudSync.title" = "Sync settings via iCloud";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "Keep engine, language, polish, and Flow preferences in sync across your devices. API keys stay on each device.";
|
||||
"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.appSettings.iCloudSync.syncNow" = "Sync Now";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
|
||||
|
||||
/* v0.3.0: Polish intensity */
|
||||
@@ -391,7 +392,14 @@
|
||||
|
||||
/* Cold-start handoff (scheme B) */
|
||||
"flow.coldStart.title" = "Voice is ready";
|
||||
"flow.coldStart.swipeHint" = "Swipe right along the bar at the bottom to return to your previous app.";
|
||||
"flow.coldStart.preparing" = "Getting voice ready";
|
||||
"flow.coldStart.preparingHint" = "Keep OSGKeyboard open for a moment while we start the microphone session.";
|
||||
"flow.coldStart.permission.title" = "Permission required";
|
||||
"flow.coldStart.audio.title" = "Voice could not start";
|
||||
"flow.coldStart.error.audioTimeout" = "The microphone did not become ready in time. It may be busy in another app. Please try again.";
|
||||
"flow.coldStart.action.settings" = "Open Settings";
|
||||
"flow.coldStart.action.retry" = "Try Again";
|
||||
"flow.coldStart.swipeHint" = "Swipe right along the bottom bar\nto return to your previous app.";
|
||||
"flow.coldStart.swipeAccessibility" = "Swipe right along the bottom bar to return";
|
||||
"flow.coldStart.tapToDismiss" = "Tap anywhere to close";
|
||||
"flow.coldStart.return.named" = "Return to %@";
|
||||
|
||||
@@ -368,8 +368,9 @@
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
|
||||
|
||||
"settings.iCloudSync.title" = "iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.title" = "设置 iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "在多台设备间同步引擎、语言、润色和 Flow 偏好。API 密钥仍保留在各设备本地。";
|
||||
"settings.appSettings.iCloudSync.title" = "iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。";
|
||||
"settings.appSettings.iCloudSync.syncNow" = "立即同步";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
|
||||
|
||||
/* v0.3.0: 润色强度 */
|
||||
@@ -390,7 +391,14 @@
|
||||
|
||||
/* 冷启动兜底(方案 B) */
|
||||
"flow.coldStart.title" = "语音已就绪";
|
||||
"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动,返回上一个 App。";
|
||||
"flow.coldStart.preparing" = "正在就绪";
|
||||
"flow.coldStart.preparingHint" = "请先停留片刻,我们正在启动麦克风会话。";
|
||||
"flow.coldStart.permission.title" = "需要权限";
|
||||
"flow.coldStart.audio.title" = "语音暂时无法启动";
|
||||
"flow.coldStart.error.audioTimeout" = "麦克风未能及时就绪,可能正被通话或其他 App 占用。请稍后重试。";
|
||||
"flow.coldStart.action.settings" = "前往设置";
|
||||
"flow.coldStart.action.retry" = "重试";
|
||||
"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动\n返回上一个 App。";
|
||||
"flow.coldStart.swipeAccessibility" = "沿底部横条从左向右滑动返回";
|
||||
"flow.coldStart.tapToDismiss" = "点按屏幕关闭";
|
||||
"flow.coldStart.return.named" = "返回%@";
|
||||
|
||||
@@ -313,6 +313,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
switch self.state.phase {
|
||||
case .error:
|
||||
self.state.phase = .idle
|
||||
// Re-derive mic availability right away so a now-ready host
|
||||
// turns the mic green immediately instead of lingering orange
|
||||
// until the next monitor tick.
|
||||
self.flowCoordinator.refreshSessionState()
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ final class KeyboardConfigSync {
|
||||
|
||||
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
||||
private var transcriptionDarwinObserver: FlowSessionDarwinObserver?
|
||||
private var hostReadyDarwinObserver: FlowSessionDarwinObserver?
|
||||
private var configDarwinObserver: FlowSessionDarwinObserver?
|
||||
|
||||
init(
|
||||
@@ -39,6 +40,11 @@ final class KeyboardConfigSync {
|
||||
) { [weak self] in
|
||||
self?.onFlowSessionChanged()
|
||||
}
|
||||
hostReadyDarwinObserver = FlowSessionDarwinObserver(
|
||||
notificationName: FlowSessionDarwin.hostReadyNotificationName
|
||||
) { [weak self] in
|
||||
self?.onFlowSessionChanged()
|
||||
}
|
||||
configDarwinObserver = FlowSessionDarwinObserver(
|
||||
notificationName: AppGroupConfigDarwin.notificationName
|
||||
) { [weak self] in
|
||||
|
||||
@@ -32,12 +32,20 @@ final class KeyboardFlowCoordinator {
|
||||
private var isFlowRecording = false
|
||||
private var flowWatchdogTask: Task<Void, Never>?
|
||||
private var utteranceTimerTask: Task<Void, Never>?
|
||||
private var hostReadyWaitTask: Task<Void, Never>?
|
||||
private var utteranceStartedAt: TimeInterval = 0
|
||||
private var wasFlowSessionActive = false
|
||||
private var wasSessionActive = false
|
||||
/// Last wall-clock time the host published a fresh ready contract. Used to
|
||||
/// smooth over transient cross-process heartbeat read jitter so a single
|
||||
/// stale sample never flashes the mic orange while the session is healthy.
|
||||
private var lastHostReadyAt: TimeInterval = 0
|
||||
private static let hostReadyGrace: TimeInterval = 4
|
||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||
private var isAwaitingFlowResult = false
|
||||
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
||||
private static let flowAutoStartCooldown: TimeInterval = 20
|
||||
private var activeSessionId: UUID?
|
||||
private var currentUtteranceId: UUID?
|
||||
private var currentCommandSeq: Int64 = 0
|
||||
private var lastAvailabilityTraceSignature = ""
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
@@ -82,9 +90,11 @@ final class KeyboardFlowCoordinator {
|
||||
func stopSessionMonitor() {
|
||||
flowSessionMonitorTask?.cancel()
|
||||
flowSessionMonitorTask = nil
|
||||
stopHostReadyWait()
|
||||
}
|
||||
|
||||
func refreshSessionState() {
|
||||
FlowSessionBridge.reloadFromDisk()
|
||||
refreshConfigFromAppGroup()
|
||||
refreshFlowPartialIfNeeded()
|
||||
consumePendingFlowDeliveryIfNeeded()
|
||||
@@ -95,10 +105,26 @@ final class KeyboardFlowCoordinator {
|
||||
debug("cleared zombie Flow session from App Group")
|
||||
}
|
||||
|
||||
let reachable = FlowSessionBridge.isHostReachable()
|
||||
state.flowSessionActive = reachable
|
||||
// A stale "session ended" hint may linger from an earlier drop. If the
|
||||
// host is provably ready again, recover to idle now so the mic can go
|
||||
// green immediately instead of waiting out the auto-clear timer.
|
||||
if case .error(.flowSessionExpired, _) = state.phase,
|
||||
FlowSessionBridge.isHostReady() {
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
}
|
||||
|
||||
if wasFlowSessionActive && !reachable && !isFlowRecording && !isPendingFlowStart {
|
||||
recomputeMicVoiceAvailability()
|
||||
startHostReadyWaitIfNeeded()
|
||||
|
||||
// Only surface "session ended" when the session contract *genuinely*
|
||||
// dropped (expired / cleared). A transient host-ready flap — engine
|
||||
// hiccup or a stale cross-process read while the session is still
|
||||
// valid — must never nuke a healthy ready state into a sticky error,
|
||||
// otherwise the error phase forces the mic orange and defeats the
|
||||
// ready-wait poll until the auto-clear fires.
|
||||
let sessionActive = FlowSessionBridge.isSessionActive()
|
||||
if wasSessionActive && !sessionActive && !isFlowRecording && !isPendingFlowStart {
|
||||
switch state.phase {
|
||||
case .recording, .processing:
|
||||
break
|
||||
@@ -106,9 +132,81 @@ final class KeyboardFlowCoordinator {
|
||||
showFlowSessionExpiredHint()
|
||||
}
|
||||
}
|
||||
wasFlowSessionActive = reachable
|
||||
wasSessionActive = sessionActive
|
||||
}
|
||||
|
||||
maybeAutoStartFlowSession()
|
||||
private func recomputeMicVoiceAvailability() {
|
||||
FlowSessionBridge.reloadFromDisk()
|
||||
let readySnapshot = FlowSessionBridge.readySnapshot()
|
||||
activeSessionId = readySnapshot?.sessionId ?? activeSessionId
|
||||
let hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
|
||||
let now = Date().timeIntervalSince1970
|
||||
if hostReady { lastHostReadyAt = now }
|
||||
// Grace window: the host was ready very recently, so treat a momentary
|
||||
// stale heartbeat read as "still warming" rather than an outright
|
||||
// failure. `isSessionActive` is heartbeat-independent, so it stays true
|
||||
// across cross-process read jitter and anchors this smoothing.
|
||||
let withinReadyGrace = lastHostReadyAt > 0
|
||||
&& (now - lastHostReadyAt) <= Self.hostReadyGrace
|
||||
let hostWarming = !hostReady
|
||||
&& FlowSessionBridge.isSessionActive()
|
||||
&& (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace)
|
||||
state.flowSessionActive = FlowSessionBridge.isSessionActive()
|
||||
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
|
||||
phase: state.phase,
|
||||
micDisabled: state.micDisabled,
|
||||
hasFullAccess: hasFullAccess(),
|
||||
appGroupAvailable: AppGroup.isAvailable,
|
||||
hostReady: hostReady,
|
||||
isPreparingSession: isPendingFlowStart || hostWarming
|
||||
)
|
||||
let signature = [
|
||||
"phase=\(String(describing: state.phase))",
|
||||
"availability=\(String(describing: state.micVoiceAvailability))",
|
||||
hostReady ? "hostReady=1" : "hostReady=0",
|
||||
state.flowSessionActive ? "sessionActive=1" : "sessionActive=0",
|
||||
isPendingFlowStart ? "pending=1" : "pending=0",
|
||||
isFlowRecording ? "recording=1" : "recording=0",
|
||||
isAwaitingFlowResult ? "awaiting=1" : "awaiting=0",
|
||||
readySnapshot?.reason.rawValue ?? "snapshot=nil"
|
||||
].joined(separator: "|")
|
||||
if signature != lastAvailabilityTraceSignature {
|
||||
lastAvailabilityTraceSignature = signature
|
||||
traceState("availability.update", extra: signature)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
return
|
||||
}
|
||||
guard !FlowSessionBridge.isHostReady() else {
|
||||
stopHostReadyWait()
|
||||
return
|
||||
}
|
||||
|
||||
guard hostReadyWaitTask == nil else { return }
|
||||
hostReadyWaitTask = Task { @MainActor [weak self] in
|
||||
defer { self?.hostReadyWaitTask = nil }
|
||||
for _ in 0..<20 {
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
FlowSessionBridge.reloadFromDisk()
|
||||
self.recomputeMicVoiceAvailability()
|
||||
if self.state.micVoiceAvailability.isReady {
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopHostReadyWait() {
|
||||
hostReadyWaitTask?.cancel()
|
||||
hostReadyWaitTask = nil
|
||||
}
|
||||
|
||||
func toggleRecording() {
|
||||
@@ -129,32 +227,34 @@ final class KeyboardFlowCoordinator {
|
||||
default:
|
||||
return
|
||||
}
|
||||
guard !state.micDisabled else { return }
|
||||
guard hasFullAccess() else {
|
||||
guard !isPendingFlowStart else { return }
|
||||
|
||||
recomputeMicVoiceAvailability()
|
||||
|
||||
switch state.micVoiceAvailability {
|
||||
case .ready:
|
||||
detectAndStoreAppContext()
|
||||
startFlowRecording()
|
||||
case .unavailable(.missingAPIKey):
|
||||
return
|
||||
case .unavailable(.noFullAccess):
|
||||
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
|
||||
state.phase = .error(.fullAccessRequired, message: msg)
|
||||
scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
guard AppGroup.isAvailable else {
|
||||
recomputeMicVoiceAvailability()
|
||||
case .unavailable(.appGroupUnavailable):
|
||||
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
|
||||
state.phase = .error(.appGroupUnavailable, message: msg)
|
||||
scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
|
||||
detectAndStoreAppContext()
|
||||
|
||||
let reachable = FlowSessionBridge.isHostReachable()
|
||||
debug(
|
||||
"pressBegan hostReachable=\(reachable) " +
|
||||
"staleness=\(FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil") " +
|
||||
"container=\(AppGroup.containerPathForDiagnostics)"
|
||||
)
|
||||
if reachable {
|
||||
startFlowRecording()
|
||||
} else {
|
||||
recomputeMicVoiceAvailability()
|
||||
case .unavailable(.preparingSession):
|
||||
detectAndStoreAppContext()
|
||||
beginFlowStart()
|
||||
case .unavailable(.hostNotReady):
|
||||
detectAndStoreAppContext()
|
||||
beginFlowStart()
|
||||
case .recording, .processing:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,27 +268,31 @@ final class KeyboardFlowCoordinator {
|
||||
isFlowRecording = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
FlowSessionBridge.setRecordingState(.stopped)
|
||||
debug("pressEnded wrote .stopped (readback=\(FlowSessionBridge.recordingState().rawValue))")
|
||||
writeCommand(.stopRecording)
|
||||
debug("pressEnded wrote stop command")
|
||||
state.phase = .processing
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||
recomputeMicVoiceAvailability()
|
||||
startFlowResultWatchdog()
|
||||
}
|
||||
|
||||
func beginFlowStart() {
|
||||
guard !isPendingFlowStart else { return }
|
||||
guard !isPendingFlowStart else {
|
||||
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
|
||||
return
|
||||
}
|
||||
isPendingFlowStart = true
|
||||
isFlowRecording = false
|
||||
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.startingSession")
|
||||
state.phase = .processing
|
||||
recomputeMicVoiceAvailability()
|
||||
openHostApp("startflow")
|
||||
startFlowStartWatchdog()
|
||||
debug("beginFlowStart")
|
||||
traceState("beginFlowStart.started")
|
||||
}
|
||||
|
||||
func handleHostAppOpenResult(path: String, success: Bool) {
|
||||
debug("openHostApp path=\(path) success=\(success)")
|
||||
traceState("openHostApp.result", extra: "path=\(path) success=\(success)")
|
||||
guard !success else { return }
|
||||
|
||||
// The open genuinely failed (iOS blocked it / no Full Access). Don't
|
||||
@@ -198,7 +302,9 @@ final class KeyboardFlowCoordinator {
|
||||
isPendingFlowStart = false
|
||||
flowStartDeadline = 0
|
||||
stopFlowWatchdog()
|
||||
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
|
||||
showManualOpenHint(path: "startflow")
|
||||
recomputeMicVoiceAvailability()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,44 +315,100 @@ final class KeyboardFlowCoordinator {
|
||||
guard !isAwaitingFlowResult else { return }
|
||||
if isFlowRecording || isPendingFlowStart {
|
||||
if isFlowRecording {
|
||||
FlowSessionBridge.setRecordingState(.aborted)
|
||||
writeCommand(.abort)
|
||||
ExtensionScreenWakeLock.release()
|
||||
}
|
||||
currentUtteranceId = nil
|
||||
isFlowRecording = false
|
||||
isPendingFlowStart = false
|
||||
stopUtteranceCountdown()
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
recomputeMicVoiceAvailability()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func nextCommandSeq() -> Int64 {
|
||||
let millis = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
currentCommandSeq = max(currentCommandSeq + 1, millis)
|
||||
return currentCommandSeq
|
||||
}
|
||||
|
||||
private func writeCommand(_ action: FlowCommand.Action) {
|
||||
guard let activeSessionId, let currentUtteranceId else { return }
|
||||
let command = FlowCommand(
|
||||
sessionId: activeSessionId,
|
||||
utteranceId: currentUtteranceId,
|
||||
commandSeq: nextCommandSeq(),
|
||||
action: action,
|
||||
localeId: state.localeId
|
||||
)
|
||||
FlowSessionBridge.writeCommand(command)
|
||||
debug(
|
||||
"command \(action.rawValue) seq=\(command.commandSeq) " +
|
||||
"utterance=\(currentUtteranceId.uuidString)"
|
||||
)
|
||||
}
|
||||
|
||||
private func consumePendingFlowDeliveryIfNeeded() {
|
||||
if isAwaitingFlowResult {
|
||||
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
|
||||
if let result = matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
|
||||
isAwaitingFlowResult = false
|
||||
stopFlowWatchdog()
|
||||
textInserter.handleFlowTranscript(delivery)
|
||||
FlowSessionBridge.writeAck(
|
||||
FlowAck(
|
||||
sessionId: result.sessionId,
|
||||
utteranceId: result.utteranceId,
|
||||
commandSeq: result.commandSeq
|
||||
)
|
||||
)
|
||||
FlowSessionBridge.clearResult()
|
||||
currentUtteranceId = nil
|
||||
textInserter.handleFlowTranscript(
|
||||
TranscriptionDelivery(text: text, polishWarning: result.warning)
|
||||
)
|
||||
return
|
||||
}
|
||||
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||||
if let result = matchingResult(), isTerminalFailure(result) {
|
||||
isAwaitingFlowResult = false
|
||||
stopFlowWatchdog()
|
||||
FlowSessionBridge.clearResult()
|
||||
currentUtteranceId = nil
|
||||
let error = FlowTranscriptionError(
|
||||
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
|
||||
kind: result.errorKind ?? .generic
|
||||
)
|
||||
state.phase = .error(
|
||||
.fromFlowTranscription(error),
|
||||
message: error.message
|
||||
)
|
||||
scheduleAutoClearError()
|
||||
recomputeMicVoiceAvailability()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if isPendingFlowStart, FlowSessionBridge.isHostReachable() {
|
||||
if isPendingFlowStart, FlowSessionBridge.isHostReady() {
|
||||
completeFlowStartHandoff()
|
||||
}
|
||||
}
|
||||
|
||||
private func matchingResult() -> FlowResult? {
|
||||
guard let result = FlowSessionBridge.latestResult() else { return nil }
|
||||
guard let activeSessionId, let currentUtteranceId else { return nil }
|
||||
guard result.sessionId == activeSessionId,
|
||||
result.utteranceId == currentUtteranceId else {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func isTerminalFailure(_ result: FlowResult) -> Bool {
|
||||
result.status == .error || result.status == .timeout || result.status == .aborted
|
||||
}
|
||||
|
||||
/// When the host process died mid-utterance, abort local recording / waiting
|
||||
/// so the user is not stuck until the long result watchdog fires.
|
||||
private func recoverFromDeadHostIfNeeded() {
|
||||
@@ -256,11 +418,13 @@ final class KeyboardFlowCoordinator {
|
||||
isFlowRecording = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
FlowSessionBridge.setRecordingState(.aborted)
|
||||
writeCommand(.abort)
|
||||
currentUtteranceId = nil
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
recomputeMicVoiceAvailability()
|
||||
debug("aborted recording — host heartbeat zombie")
|
||||
return
|
||||
}
|
||||
@@ -270,32 +434,21 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores the pre-ABCD behaviour: when the keyboard appears and the host
|
||||
/// is not reachable, automatically jump to the main app to start Flow.
|
||||
private func maybeAutoStartFlowSession() {
|
||||
guard !FlowSessionBridge.isHostReachable() else { return }
|
||||
guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
|
||||
guard hasFullAccess(), AppGroup.isAvailable else { return }
|
||||
guard case .idle = state.phase else { return }
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
|
||||
lastFlowAutoStartAttempt = now
|
||||
beginFlowStart()
|
||||
}
|
||||
|
||||
private func failHostDisconnected() {
|
||||
traceState("hostDisconnected.fail")
|
||||
isAwaitingFlowResult = false
|
||||
isFlowRecording = false
|
||||
isPendingFlowStart = false
|
||||
stopUtteranceCountdown()
|
||||
ExtensionScreenWakeLock.release()
|
||||
FlowSessionBridge.setRecordingState(.aborted)
|
||||
writeCommand(.abort)
|
||||
currentUtteranceId = nil
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
let message = ExtL10n.string("keyboard.flow.hostDisconnected")
|
||||
state.phase = .error(.flowSessionExpired, message: message)
|
||||
scheduleAutoClearError()
|
||||
recomputeMicVoiceAvailability()
|
||||
debug("host disconnected while awaiting Flow result")
|
||||
}
|
||||
|
||||
@@ -303,6 +456,7 @@ final class KeyboardFlowCoordinator {
|
||||
let message = ExtL10n.string("keyboard.flow.sessionExpired")
|
||||
state.phase = .error(.flowSessionExpired, message: message)
|
||||
scheduleAutoClearError()
|
||||
recomputeMicVoiceAvailability()
|
||||
}
|
||||
|
||||
private func showManualOpenHint(path: String) {
|
||||
@@ -318,10 +472,13 @@ final class KeyboardFlowCoordinator {
|
||||
}
|
||||
state.phase = .error(.manualOpenRequired, message: msg)
|
||||
scheduleAutoClearError()
|
||||
recomputeMicVoiceAvailability()
|
||||
}
|
||||
|
||||
private func startFlowRecording() {
|
||||
guard FlowSessionBridge.isHostReachable() else {
|
||||
recomputeMicVoiceAvailability()
|
||||
guard state.micVoiceAvailability.isReady else {
|
||||
traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))")
|
||||
beginFlowStart()
|
||||
return
|
||||
}
|
||||
@@ -329,19 +486,24 @@ final class KeyboardFlowCoordinator {
|
||||
flowStartDeadline = 0
|
||||
stopFlowWatchdog()
|
||||
|
||||
FlowSessionBridge.setTranscriptionLanguage(state.localeId)
|
||||
FlowSessionBridge.setRecordingState(.recording)
|
||||
guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else {
|
||||
traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot")
|
||||
beginFlowStart()
|
||||
return
|
||||
}
|
||||
activeSessionId = sessionId
|
||||
currentUtteranceId = UUID()
|
||||
writeCommand(.startRecording)
|
||||
isFlowRecording = true
|
||||
state.lastTranscript = ""
|
||||
state.phase = .recording
|
||||
recomputeMicVoiceAvailability()
|
||||
if let view = wakeLockView() {
|
||||
ExtensionScreenWakeLock.acquire(from: view)
|
||||
}
|
||||
startUtteranceCountdown()
|
||||
startFlowLevelWatchdog()
|
||||
// Read back in-process to confirm the write landed before we rely on
|
||||
// the host polling it out cross-process.
|
||||
debug("startFlowRecording wrote .recording (readback=\(FlowSessionBridge.recordingState().rawValue))")
|
||||
traceState("startFlowRecording.started")
|
||||
}
|
||||
|
||||
private func startUtteranceCountdown() {
|
||||
@@ -374,13 +536,16 @@ final class KeyboardFlowCoordinator {
|
||||
stopFlowWatchdog()
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
recomputeMicVoiceAvailability()
|
||||
traceState("pendingStart.cancelledByUser")
|
||||
}
|
||||
|
||||
private func startFlowStartWatchdog() {
|
||||
stopFlowWatchdog()
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled, self.isPendingFlowStart {
|
||||
if FlowSessionBridge.isHostReachable() {
|
||||
self.recomputeMicVoiceAvailability()
|
||||
if FlowSessionBridge.isHostReady() {
|
||||
self.completeFlowStartHandoff()
|
||||
return
|
||||
}
|
||||
@@ -388,6 +553,7 @@ final class KeyboardFlowCoordinator {
|
||||
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
|
||||
self.isPendingFlowStart = false
|
||||
self.flowStartDeadline = 0
|
||||
self.traceState("startWatchdog.timeout")
|
||||
self.showManualOpenHint(path: "startflow")
|
||||
return
|
||||
}
|
||||
@@ -403,7 +569,7 @@ final class KeyboardFlowCoordinator {
|
||||
state.lastTranscript = ""
|
||||
refreshSessionState()
|
||||
startFlowRecording()
|
||||
debug("completeFlowStartHandoff → auto startFlowRecording")
|
||||
traceState("completeFlowStartHandoff.done")
|
||||
}
|
||||
|
||||
private func startFlowLevelWatchdog() {
|
||||
@@ -415,6 +581,12 @@ final class KeyboardFlowCoordinator {
|
||||
self.state.level = Double(peak)
|
||||
}
|
||||
self.refreshFlowPartialIfNeeded()
|
||||
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
|
||||
if staleness > 5 {
|
||||
self.debug("levelWatchdog: host heartbeat stale while recording")
|
||||
self.failHostDisconnected()
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||||
}
|
||||
}
|
||||
@@ -424,7 +596,10 @@ final class KeyboardFlowCoordinator {
|
||||
guard isFlowRecording || isAwaitingFlowResult else { return }
|
||||
switch state.phase {
|
||||
case .recording, .processing:
|
||||
if let partial = FlowSessionBridge.transcriptionPartial() {
|
||||
if let result = matchingResult(),
|
||||
result.status == .partial,
|
||||
let partial = result.text,
|
||||
!partial.isEmpty {
|
||||
state.lastTranscript = partial
|
||||
}
|
||||
default:
|
||||
@@ -440,27 +615,50 @@ final class KeyboardFlowCoordinator {
|
||||
debug("resultWatchdog started timeout=\(Int(resultTimeout))s engine=\(state.engineMode)")
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled {
|
||||
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
|
||||
if let result = self.matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.debug("resultWatchdog consumed delivery len=\(delivery.text.count)")
|
||||
self.textInserter.handleFlowTranscript(delivery)
|
||||
FlowSessionBridge.writeAck(
|
||||
FlowAck(
|
||||
sessionId: result.sessionId,
|
||||
utteranceId: result.utteranceId,
|
||||
commandSeq: result.commandSeq
|
||||
)
|
||||
)
|
||||
FlowSessionBridge.clearResult()
|
||||
self.currentUtteranceId = nil
|
||||
self.debug("resultWatchdog consumed delivery len=\(text.count)")
|
||||
self.textInserter.handleFlowTranscript(
|
||||
TranscriptionDelivery(text: text, polishWarning: result.warning)
|
||||
)
|
||||
return
|
||||
}
|
||||
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||||
if let result = self.matchingResult(), self.isTerminalFailure(result) {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
FlowSessionBridge.clearResult()
|
||||
self.currentUtteranceId = nil
|
||||
let error = FlowTranscriptionError(
|
||||
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
|
||||
kind: result.errorKind ?? .generic
|
||||
)
|
||||
self.debug("resultWatchdog consumed error kind=\(error.kind.rawValue)")
|
||||
self.state.phase = .error(
|
||||
.fromFlowTranscription(error),
|
||||
message: error.message
|
||||
)
|
||||
self.scheduleAutoClearError()
|
||||
self.recomputeMicVoiceAvailability()
|
||||
return
|
||||
}
|
||||
self.refreshFlowPartialIfNeeded()
|
||||
let now = Date().timeIntervalSince1970
|
||||
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
|
||||
if self.isFlowRecording, staleness > 5 {
|
||||
self.debug("level/result watchdog: host heartbeat stale while recording")
|
||||
self.failHostDisconnected()
|
||||
return
|
||||
}
|
||||
if staleness > FlowSessionKeys.heartbeatZombieInterval {
|
||||
self.debug("resultWatchdog: host heartbeat zombie (staleness=\(String(format: "%.1f", staleness))s)")
|
||||
self.failHostDisconnected()
|
||||
@@ -475,10 +673,12 @@ final class KeyboardFlowCoordinator {
|
||||
if now - startedAt > resultTimeout {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.currentUtteranceId = 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)
|
||||
self.scheduleAutoClearError()
|
||||
self.recomputeMicVoiceAvailability()
|
||||
return
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||||
@@ -494,4 +694,29 @@ final class KeyboardFlowCoordinator {
|
||||
private func debug(_ message: String) {
|
||||
OSGLog.keyboardExt.info("\(message, privacy: .public)")
|
||||
}
|
||||
|
||||
private func traceState(_ event: String, extra: String? = nil) {
|
||||
let staleness = FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil"
|
||||
let sessionId = activeSessionId?.uuidString ?? "nil"
|
||||
let utteranceId = currentUtteranceId?.uuidString ?? "nil"
|
||||
let summary = [
|
||||
"event=\(event)",
|
||||
"phase=\(String(describing: state.phase))",
|
||||
"availability=\(String(describing: state.micVoiceAvailability))",
|
||||
"pending=\(isPendingFlowStart)",
|
||||
"recording=\(isFlowRecording)",
|
||||
"awaiting=\(isAwaitingFlowResult)",
|
||||
"sessionId=\(sessionId)",
|
||||
"utteranceId=\(utteranceId)",
|
||||
"cmdSeq=\(currentCommandSeq)",
|
||||
"sessionActive=\(FlowSessionBridge.isSessionActive())",
|
||||
"hostReady=\(FlowSessionBridge.isHostReady())",
|
||||
"heartbeatStaleness=\(staleness)"
|
||||
].joined(separator: " ")
|
||||
if let extra, !extra.isEmpty {
|
||||
debug("[trace] \(summary) \(extra)")
|
||||
} else {
|
||||
debug("[trace] \(summary)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +138,7 @@ public struct KeyboardRootView: View {
|
||||
TranscriptLine(
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
flowSessionActive: state.flowSessionActive,
|
||||
micDisabled: state.micDisabled,
|
||||
micVoiceAvailability: state.micVoiceAvailability,
|
||||
micDisabledHint: state.micDisabledHint,
|
||||
cursorDragHintActive: state.cursorDragActive,
|
||||
openSettings: state.openSettings
|
||||
@@ -192,7 +191,6 @@ public struct KeyboardRootView: View {
|
||||
private var micActionRow: some View {
|
||||
let editingBlocked = voiceInputBlocksEditing
|
||||
let swapKeys = state.handednessPreference.swapsActionKeys
|
||||
let micDisabled = state.micDisabled
|
||||
let cursorPadsEnabled = state.cursorDragNavigationEnabled && !editingBlocked
|
||||
|
||||
// Dragging hides the mic + bottom keys (kept in the layout via
|
||||
@@ -208,7 +206,7 @@ public struct KeyboardRootView: View {
|
||||
phase: buttonPhase,
|
||||
level: state.level,
|
||||
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
|
||||
isEnabled: !micDisabled,
|
||||
isEnabled: !state.micDisabled,
|
||||
onToggle: state.tapMic
|
||||
)
|
||||
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
||||
@@ -284,13 +282,17 @@ public struct KeyboardRootView: View {
|
||||
}
|
||||
|
||||
private var buttonPhase: RecordButton.Phase {
|
||||
switch state.phase {
|
||||
case .idle: return .idle
|
||||
case .requestingPermissions: return .idle
|
||||
case .recording: return .recording
|
||||
case .processing: return .processing
|
||||
case .error: return .error
|
||||
case .denied: return .error
|
||||
if case .error = state.phase { return .error }
|
||||
if case .denied = state.phase { return .error }
|
||||
switch state.micVoiceAvailability {
|
||||
case .ready:
|
||||
return .idleReady
|
||||
case .unavailable:
|
||||
return .idleUnavailable
|
||||
case .recording:
|
||||
return .recording
|
||||
case .processing:
|
||||
return .processing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,8 +332,7 @@ private struct TranscriptLine: View {
|
||||
|
||||
let phase: KeyboardViewController.State.Phase
|
||||
let transcript: String
|
||||
let flowSessionActive: Bool
|
||||
let micDisabled: Bool
|
||||
let micVoiceAvailability: MicVoiceAvailability
|
||||
let micDisabledHint: String
|
||||
let cursorDragHintActive: Bool
|
||||
let openSettings: () -> Void
|
||||
@@ -353,66 +354,79 @@ private struct TranscriptLine: View {
|
||||
private var phaseContent: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
if micDisabled {
|
||||
Text(micDisabledHint)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else if flowSessionActive {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else {
|
||||
ExtL10n.text("keyboard.flow.sessionInactive")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
case .requestingPermissions:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
||||
ExtL10n.text("keyboard.placeholder.preparing")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
case .recording:
|
||||
Text(transcript.isEmpty ? " " : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(maxWidth: .infinity)
|
||||
case .processing:
|
||||
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
|
||||
idleHint
|
||||
case .requestingPermissions:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
||||
ExtL10n.text("keyboard.placeholder.preparing")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .error(_, let msg):
|
||||
Text(msg ?? "")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .denied(let reason):
|
||||
Button(action: openSettings) {
|
||||
HStack(spacing: 4) {
|
||||
Text(deniedMessage(for: reason))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
case .recording:
|
||||
Text(transcript.isEmpty ? " " : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(maxWidth: .infinity)
|
||||
case .processing:
|
||||
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .error(_, let msg):
|
||||
Text(msg ?? "")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .denied(let reason):
|
||||
Button(action: openSettings) {
|
||||
HStack(spacing: 4) {
|
||||
Text(deniedMessage(for: reason))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var idleHint: some View {
|
||||
let isWarning = micVoiceAvailability.isUnavailable
|
||||
Group {
|
||||
switch micVoiceAvailability {
|
||||
case .ready:
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
case .unavailable(.missingAPIKey):
|
||||
Text(micDisabledHint)
|
||||
case .unavailable(.hostNotReady):
|
||||
ExtL10n.text("keyboard.flow.sessionInactive")
|
||||
case .unavailable(.preparingSession):
|
||||
ExtL10n.text("keyboard.flow.startingSession")
|
||||
case .unavailable(.noFullAccess):
|
||||
ExtL10n.text("keyboard.error.fullAccessRequired")
|
||||
case .unavailable(.appGroupUnavailable):
|
||||
ExtL10n.text("keyboard.error.appGroupCommunication")
|
||||
case .recording, .processing:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(isWarning ? palette.warning : palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||
switch reason {
|
||||
case .mic: return ExtL10n.string("keyboard.denied.mic")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="912" height="251" viewBox="56 387 912 251" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
|
||||
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
|
||||
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -17,7 +17,7 @@ struct FlowLiveActivityWidget: Widget {
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
FlowLiveActivityBrandMark(size: 28)
|
||||
FlowLiveActivityBrandMark(height: 16)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
FlowLiveActivityPhaseLabel(phase: context.state.phase)
|
||||
@@ -32,11 +32,13 @@ struct FlowLiveActivityWidget: Widget {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} compactLeading: {
|
||||
FlowLiveActivityBrandMark(size: 22)
|
||||
FlowLiveActivityBrandMark(height: 12)
|
||||
} compactTrailing: {
|
||||
FlowLiveActivityTrailingGlyph(phase: context.state.phase)
|
||||
} minimal: {
|
||||
FlowLiveActivityBrandMark(size: 18)
|
||||
// The minimal slot is a tiny circle; a short wordmark keeps
|
||||
// the natural ratio without overflowing its bounds.
|
||||
FlowLiveActivityBrandMark(height: 6)
|
||||
}
|
||||
.keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0))
|
||||
}
|
||||
@@ -50,7 +52,7 @@ private struct FlowLiveActivityLockScreenView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
FlowLiveActivityBrandMark(size: 32)
|
||||
FlowLiveActivityBrandMark(height: 13)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("OSGKeyboard")
|
||||
.font(.headline)
|
||||
@@ -71,13 +73,18 @@ private struct FlowLiveActivityLockScreenView: View {
|
||||
/// Branded mark used in compactLeading so users see OSGKeyboard, not the system mic icon.
|
||||
/// Transparent white OSG glyphs render directly on the black Dynamic Island.
|
||||
private struct FlowLiveActivityBrandMark: View {
|
||||
let size: CGFloat
|
||||
/// The OSG wordmark is wide and short; pin the width to its true aspect
|
||||
/// ratio so it never collapses into a thin sliver inside a square frame.
|
||||
private static let aspectRatio: CGFloat = 912.0 / 251.0
|
||||
|
||||
/// Rendered glyph height; width follows the wordmark's natural ratio.
|
||||
let height: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Image("OSGLogo")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: size, height: size)
|
||||
.frame(width: height * Self.aspectRatio, height: height)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
}
|
||||
}
|
||||
@@ -92,9 +99,10 @@ private struct FlowLiveActivityTrailingGlyph: View {
|
||||
.foregroundStyle(.red)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
case .processing:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||
case .idle:
|
||||
// Session ready but NOT listening — avoid a mic glyph so users
|
||||
// don't think the keyboard is recording in the background.
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// DashboardView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Primary workspace: session stats, dictation canvas, floating record bar.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@ObservedObject private var stats: UsageStatisticsStore
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
init(viewModel: MacDictationViewModel) {
|
||||
self.viewModel = viewModel
|
||||
// Observe through the view model's store instance — a bare
|
||||
// `UsageStatisticsStore.shared` on @ObservedObject often misses
|
||||
// post-sync @Published updates on macOS.
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
statGrid
|
||||
dictationCanvas
|
||||
}
|
||||
.animation(Motion.soft, value: viewModel.foregroundAppName)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.top, Spacing.sm)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
BottomDictationBar(viewModel: viewModel)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.bottom, Spacing.sm)
|
||||
}
|
||||
.onAppear { stats.reloadFromDisk() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
stats.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var dictationCanvas: some View {
|
||||
MacCard(padding: Spacing.lg) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if viewModel.transcript.isEmpty {
|
||||
Text(
|
||||
viewModel.isRecording
|
||||
? MacL10n.string("mac.status.listening", language: lang)
|
||||
: MacL10n.string("mac.status.ready", language: lang)
|
||||
)
|
||||
.font(.system(size: 26, weight: .light))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.contentTransition(.opacity)
|
||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
Text(viewModel.transcript)
|
||||
.font(.system(size: 22, weight: .regular))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, minHeight: 220, alignment: .topLeading)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Floating record bar
|
||||
|
||||
struct BottomDictationBar: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
@State private var pulse = false
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
HStack {
|
||||
translationPicker
|
||||
Spacer()
|
||||
readinessChip
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
private var readinessChip: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(viewModel.isProcessing ? palette.warning : palette.accent)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(
|
||||
viewModel.isProcessing
|
||||
? MacL10n.string("mac.status.chipProcessing", language: lang)
|
||||
: MacL10n.string("mac.status.chipReady", language: lang)
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.contentTransition(.opacity)
|
||||
}
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 5)
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
.animation(Motion.quick, value: viewModel.isProcessing)
|
||||
}
|
||||
|
||||
private var translationPicker: some View {
|
||||
Menu {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button(translationLabel(for: language)) {
|
||||
viewModel.config.translationTargetLocaleId = language.id
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "translate")
|
||||
Text(currentTranslationLabel)
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 7)
|
||||
.macGlassSurface(in: Capsule(), fillOpacity: 0.66)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
// 麦克风按钮始终居中固定:录音时的波形放进按钮内部,
|
||||
// “按停止”提示作为浮层显示在按钮上方,二者均不参与布局,
|
||||
// 因此按下 Option 触发录音时按钮位置不会发生偏移。
|
||||
private var recordControl: some View {
|
||||
recordButton
|
||||
.overlay(alignment: .top) {
|
||||
if viewModel.isRecording {
|
||||
Text(MacL10n.string("mac.record.pressStop", language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize()
|
||||
.offset(y: -22)
|
||||
.transition(.opacity.combined(with: .offset(y: 6)))
|
||||
}
|
||||
}
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
}
|
||||
|
||||
private var recordButton: some View {
|
||||
Button(action: viewModel.toggleRecording) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(viewModel.isRecording ? palette.recordRed : palette.accent)
|
||||
.frame(width: 52, height: 52)
|
||||
.shadow(
|
||||
color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35),
|
||||
radius: pulse ? 10 : 5
|
||||
)
|
||||
Group {
|
||||
if viewModel.isRecording {
|
||||
// 与 iOS 一致:录音时在红色按钮内部显示实时波形
|
||||
MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent)
|
||||
} else {
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.7)))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isProcessing)
|
||||
.opacity(viewModel.isProcessing ? 0.55 : 1)
|
||||
.scaleEffect(viewModel.isRecording ? 1.06 : 1)
|
||||
.animation(Motion.soft, value: viewModel.isRecording)
|
||||
.animation(Motion.quick, value: viewModel.isProcessing)
|
||||
.onAppear {
|
||||
withAnimation(Motion.breath) {
|
||||
pulse = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentTranslationLabel: String {
|
||||
let current = TranslationLanguageCatalog.resolve(viewModel.config.translationTargetLocaleId)
|
||||
return translationLabel(for: current)
|
||||
}
|
||||
|
||||
private func translationLabel(for language: TranslationLanguage) -> String {
|
||||
if TranslationLanguageCatalog.isOff(language.id) {
|
||||
return MacL10n.string("keyboard.translation.offMenu", language: lang)
|
||||
}
|
||||
return language.nativeName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OSGKeyboard</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>zh-Hans</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>OSGKeyboard</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OSGKeyboard uses on-device speech recognition for local dictation mode.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,86 @@
|
||||
// MacAppContextService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Unlike the iOS keyboard extension, macOS can read the frontmost app's
|
||||
// bundle ID via NSWorkspace and map it to `AppContext` for polish prompts.
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum MacAppContextService {
|
||||
/// Bundle IDs → coarse polish context (macOS + cross-platform).
|
||||
private static let contextByBundleId: [String: AppContext] = [
|
||||
// Code / dev
|
||||
"com.apple.dt.Xcode": .code,
|
||||
"com.microsoft.VSCode": .code,
|
||||
"com.google.android.studio": .code,
|
||||
"com.jetbrains.intellij": .code,
|
||||
"com.jetbrains.AppCode": .code,
|
||||
"com.sublimetext.4": .code,
|
||||
"com.github.GitHubClient": .code,
|
||||
"com.apple.Terminal": .code,
|
||||
"com.googlecode.iterm2": .code,
|
||||
"dev.warp.Warp-Stable": .code,
|
||||
// Email
|
||||
"com.apple.mail": .email,
|
||||
"com.microsoft.Outlook": .email,
|
||||
"com.google.Gmail": .email,
|
||||
"com.readdle.smartemail": .email,
|
||||
// Chat / IM
|
||||
"com.tencent.xinWeChat": .chat,
|
||||
"com.tencent.qq": .chat,
|
||||
"com.tencent.wework": .chat,
|
||||
"com.tinyspeck.slackmacgap": .chat,
|
||||
"com.hnc.Discord": .chat,
|
||||
"com.microsoft.teams": .chat,
|
||||
"com.microsoft.teams2": .chat,
|
||||
"ru.keepcoder.Telegram": .chat,
|
||||
"net.whatsapp.WhatsApp": .chat,
|
||||
"com.apple.MobileSMS": .chat,
|
||||
"com.facebook.archon": .chat,
|
||||
"com.laiwang.DingTalk": .chat,
|
||||
"com.bytedance.feishu": .chat,
|
||||
// Documents / notes
|
||||
"com.apple.Notes": .document,
|
||||
"com.apple.iWork.Pages": .document,
|
||||
"notion.id": .document,
|
||||
"md.obsidian": .document,
|
||||
"net.shinyfrog.bear": .document,
|
||||
"com.agiletortoise.Drafts-OSX": .document,
|
||||
"com.microsoft.Word": .document,
|
||||
"com.google.GoogleDocs": .document,
|
||||
"com.evernote.Evernote": .document,
|
||||
"com.microsoft.onenote.mac": .document,
|
||||
]
|
||||
|
||||
/// Chat-style apps from the shared host registry (iOS bundle IDs often
|
||||
/// match Mac counterparts for cross-platform IM).
|
||||
private static let chatBundleIdsFromRegistry: Set<String> = {
|
||||
Set(HostAppURLRegistry.entries.map(\.bundleId))
|
||||
}()
|
||||
|
||||
static func frontmostApplicationName() -> String? {
|
||||
NSWorkspace.shared.frontmostApplication?.localizedName
|
||||
}
|
||||
|
||||
static func frontmostBundleIdentifier() -> String? {
|
||||
NSWorkspace.shared.frontmostApplication?.bundleIdentifier
|
||||
}
|
||||
|
||||
static func detectContext() -> AppContext {
|
||||
guard let bundleId = frontmostBundleIdentifier() else { return .unknown }
|
||||
if let mapped = contextByBundleId[bundleId] { return mapped }
|
||||
if chatBundleIdsFromRegistry.contains(bundleId) { return .chat }
|
||||
if bundleId.hasPrefix("com.apple.Safari") || bundleId.contains("chrome") {
|
||||
return .document
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
|
||||
/// Persist detected context into the shared configuration store so
|
||||
/// `PolishingService` reads the same signal as on iOS.
|
||||
static func captureAndPersist(to store: AppGroupStore) {
|
||||
let context = detectContext()
|
||||
store.setDetectedAppContext(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// MacAppearance.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// User-facing light / dark preference for the desktop app. Stored locally
|
||||
// (Mac-only switch for now); can be promoted into `SyncedAppSettings` later
|
||||
// if iPad / cross-device appearance sync is wanted.
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// How the macOS app resolves its colour scheme.
|
||||
enum MacAppearancePreference: String, CaseIterable, Identifiable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// `nil` means "follow the system", matching SwiftUI's convention where
|
||||
/// `preferredColorScheme(nil)` defers to the environment.
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return .light
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
|
||||
/// The matching AppKit appearance so window chrome, traffic lights and
|
||||
/// the menu-bar popover follow the same choice as the SwiftUI content.
|
||||
var nsAppearance: NSAppearance? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return NSAppearance(named: .aqua)
|
||||
case .dark: return NSAppearance(named: .darkAqua)
|
||||
}
|
||||
}
|
||||
|
||||
var labelKey: String {
|
||||
switch self {
|
||||
case .system: return "mac.appearance.system"
|
||||
case .light: return "mac.appearance.light"
|
||||
case .dark: return "mac.appearance.dark"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .system: return "circle.lefthalf.filled"
|
||||
case .light: return "sun.max"
|
||||
case .dark: return "moon"
|
||||
}
|
||||
}
|
||||
|
||||
/// `@AppStorage` key shared by the app shell and the settings switch.
|
||||
static let storageKey = "mac.appearancePreference"
|
||||
|
||||
static var current: MacAppearancePreference {
|
||||
MacAppearancePreference(
|
||||
rawValue: UserDefaults.standard.string(forKey: storageKey) ?? ""
|
||||
) ?? .system
|
||||
}
|
||||
|
||||
/// Push the preference into AppKit so non-SwiftUI chrome (title bar,
|
||||
/// popover) tracks it too. Safe to call on the main actor at any time.
|
||||
@MainActor
|
||||
static func applyToApp(_ preference: MacAppearancePreference) {
|
||||
NSApp?.appearance = preference.nsAppearance
|
||||
NSApp?.windows.forEach { window in
|
||||
window.appearance = preference.nsAppearance
|
||||
window.contentView?.needsDisplay = true
|
||||
window.contentView?.subviews.forEach { $0.needsDisplay = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// MacAudioRecorder.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Captures microphone audio via AVAudioEngine and resamples it to the
|
||||
// 16 kHz mono Float32 buffer the cloud ASR clients expect. The tap
|
||||
// callback runs on the audio render thread, so sample accumulation is
|
||||
// guarded by a lock and the type is `@unchecked Sendable`.
|
||||
|
||||
@preconcurrency import AVFoundation
|
||||
|
||||
final class MacAudioRecorder: @unchecked Sendable {
|
||||
enum RecorderError: Error, LocalizedError {
|
||||
case converterUnavailable
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .converterUnavailable:
|
||||
return "无法初始化音频转换器 / Failed to initialize audio converter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let engine = AVAudioEngine()
|
||||
private let targetFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
private var converter: AVAudioConverter?
|
||||
private let lock = NSLock()
|
||||
private var samples: [Float] = []
|
||||
private var isRunning = false
|
||||
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.
|
||||
private var didProvideInput = false
|
||||
|
||||
/// Normalised input level (0…1), smoothed for a calm waveform.
|
||||
/// Read from the main thread by a polling timer while recording.
|
||||
func level() -> Float {
|
||||
lock.withLock { smoothedLevel }
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
lock.withLock { samples.removeAll(keepingCapacity: true) }
|
||||
|
||||
let input = engine.inputNode
|
||||
let inputFormat = input.outputFormat(forBus: 0)
|
||||
guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else {
|
||||
throw RecorderError.converterUnavailable
|
||||
}
|
||||
self.converter = converter
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 4_096, format: inputFormat) { [weak self] buffer, _ in
|
||||
self?.appendResampled(buffer)
|
||||
}
|
||||
engine.prepare()
|
||||
try engine.start()
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
/// Stops capture and returns the accumulated 16 kHz mono samples.
|
||||
func stop() -> [Float] {
|
||||
guard isRunning else { return [] }
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
return lock.withLock {
|
||||
let out = samples
|
||||
samples.removeAll(keepingCapacity: false)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
private func appendResampled(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let converter else { return }
|
||||
let ratio = targetFormat.sampleRate / buffer.format.sampleRate
|
||||
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1_024
|
||||
guard let output = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: capacity) else { return }
|
||||
|
||||
didProvideInput = false
|
||||
var conversionError: NSError?
|
||||
converter.convert(to: output, error: &conversionError) { [self] _, statusPointer in
|
||||
if didProvideInput {
|
||||
statusPointer.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
didProvideInput = true
|
||||
statusPointer.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard conversionError == nil, let channel = output.floatChannelData else { return }
|
||||
|
||||
let frameCount = Int(output.frameLength)
|
||||
guard frameCount > 0 else { return }
|
||||
let chunk = Array(UnsafeBufferPointer(start: channel[0], count: frameCount))
|
||||
|
||||
// RMS → rough 0…1 level with an attack/decay smoothing so the UI
|
||||
// waveform breathes rather than jitters.
|
||||
var sumSquares: Float = 0
|
||||
for sample in chunk { sumSquares += sample * sample }
|
||||
let rms = (sumSquares / Float(frameCount)).squareRoot()
|
||||
let normalized = min(1, max(0, rms * 12))
|
||||
|
||||
lock.withLock {
|
||||
samples.append(contentsOf: chunk)
|
||||
let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15
|
||||
smoothedLevel += (normalized - smoothedLevel) * factor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// MacComponents.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Reusable macOS UI pieces styled with the shared design tokens and the
|
||||
// system-native palette (see MacTheme.swift). Kept as plain SwiftUI (no
|
||||
// AppKit) so they can be reused on iPadOS. Settings / History / Dictionary
|
||||
// now use the native grouped `Form`, so the old hand-rolled card/row types
|
||||
// were removed — what remains here is used by the Dashboard, the status
|
||||
// footer and the menu-bar popover.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Shared layout metrics
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// adds `Spacing.sm` on top of this so its left edge lines up with the
|
||||
/// SF Symbol in each nav `Label`.
|
||||
static let sidebarInset: CGFloat = Spacing.md
|
||||
static let sidebarContentInset: CGFloat = sidebarInset + Spacing.sm
|
||||
/// Reading width for single-column content.
|
||||
static let contentMaxWidth: CGFloat = 720
|
||||
/// Top inset that clears the window traffic-light buttons now that the
|
||||
/// title bar is hidden.
|
||||
static let trafficLightInset: CGFloat = 28
|
||||
}
|
||||
|
||||
// MARK: - Liquid Glass
|
||||
|
||||
private struct MacGlassSurface<S: Shape>: ViewModifier {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let shape: S
|
||||
let fillOpacity: Double
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
// Flat, shadowless surface fill. We deliberately avoid `glassEffect`
|
||||
// here: on macOS 26 Liquid Glass adds a raised drop shadow to every
|
||||
// card, which reads as visual noise for content containers. Hierarchy
|
||||
// is carried by the surface colour + hairline border instead.
|
||||
content
|
||||
.background(palette.surface.opacity(fillOpacity), in: shape)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Applies a flat semantic surface fill (no drop shadow) behind `content`.
|
||||
func macGlassSurface<S: Shape>(
|
||||
in shape: S,
|
||||
fillOpacity: Double = 0.72
|
||||
) -> some View {
|
||||
modifier(MacGlassSurface(shape: shape, fillOpacity: fillOpacity))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Text field styling
|
||||
|
||||
/// Text-field chrome aligned with native macOS Form controls: compact
|
||||
/// height, text-background fill, hairline border.
|
||||
private struct MacFieldStyleModifier: ViewModifier {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
content
|
||||
.textFieldStyle(.plain)
|
||||
.font(TypeStyle.footnote)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 22)
|
||||
.background(Color(nsColor: .textBackgroundColor), in: shape)
|
||||
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Theme-aware text-field styling for the settings inputs.
|
||||
func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) }
|
||||
}
|
||||
|
||||
// MARK: - Card container
|
||||
|
||||
/// Elevated surface used for stat tiles and the dictation canvas.
|
||||
struct MacCard<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
var padding: CGFloat = Spacing.md
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
|
||||
content()
|
||||
.padding(padding)
|
||||
.macGlassSurface(in: shape, fillOpacity: 1)
|
||||
.overlay(
|
||||
shape
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
struct MiniWaveform: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let level: Float
|
||||
var barCount: Int = 5
|
||||
/// Pass nil to inherit the palette accent automatically.
|
||||
var tint: Color?
|
||||
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
Capsule()
|
||||
.fill(tint ?? palette.accent)
|
||||
.frame(width: 3, height: barHeight(index))
|
||||
}
|
||||
}
|
||||
.frame(height: 22)
|
||||
.animation(Motion.instant, value: level)
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 0.9).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))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Translation display helper
|
||||
|
||||
/// Shared label logic for the translation control so the dashboard chip,
|
||||
/// the status footer and the menu-bar popover all read identically.
|
||||
enum MacTranslationDisplay {
|
||||
static func label(for targetLocaleId: String, language: AppUILanguage) -> String {
|
||||
let resolved = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
if TranslationLanguageCatalog.isOff(resolved.id) {
|
||||
return MacL10n.string("keyboard.translation.offMenu", language: language)
|
||||
}
|
||||
return resolved.nativeName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status footer
|
||||
|
||||
/// Bottom status strip: engine mode (cloud/local), translation target, and
|
||||
/// the connection state — icons and wording mirror the dashboard record bar.
|
||||
struct MacStatusFooter: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Spacer()
|
||||
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)
|
||||
|
||||
Label(
|
||||
MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang),
|
||||
systemImage: "translate"
|
||||
)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.animation(Motion.quick, value: viewModel.isCloudMode)
|
||||
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// MacContentView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Compact menu-bar popover for quick dictation.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacContentView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
header
|
||||
recordButton
|
||||
Text(MacL10n.string("mac.hint.holdOption", language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Text(viewModel.statusMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.contentTransition(.opacity)
|
||||
|
||||
if !viewModel.transcript.isEmpty {
|
||||
ScrollView {
|
||||
Text(viewModel.transcript)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 120)
|
||||
.padding(Spacing.xs)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous))
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
|
||||
Divider().overlay(palette.divider)
|
||||
statusRow
|
||||
Divider().overlay(palette.divider)
|
||||
footer
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(palette.background)
|
||||
.animation(Motion.soft, value: viewModel.transcript.isEmpty)
|
||||
.animation(Motion.quick, value: viewModel.statusMessage)
|
||||
}
|
||||
|
||||
private var statusRow: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
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)
|
||||
|
||||
Spacer()
|
||||
|
||||
translationMenu
|
||||
|
||||
Spacer()
|
||||
|
||||
Label(MacL10n.string("mac.connected", language: lang), systemImage: "link")
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
|
||||
private var translationMenu: some View {
|
||||
Menu {
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button(MacTranslationDisplay.label(for: language.id, language: lang)) {
|
||||
viewModel.config.translationTargetLocaleId = language.id
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "translate")
|
||||
Text(MacTranslationDisplay.label(for: viewModel.config.translationTargetLocaleId, language: lang))
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image("OSGBrandMark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundStyle(palette.accent)
|
||||
Text("OSGKeyboard")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
if viewModel.isRecording {
|
||||
MiniWaveform(level: viewModel.audioLevel, barCount: 4)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.7)))
|
||||
}
|
||||
}
|
||||
.animation(Motion.quick, value: viewModel.isRecording)
|
||||
}
|
||||
|
||||
private var recordButton: some View {
|
||||
Button(action: viewModel.toggleRecording) {
|
||||
HStack {
|
||||
Image(systemName: viewModel.isRecording ? "stop.fill" : "mic.fill")
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
Text(
|
||||
viewModel.isRecording
|
||||
? MacL10n.string("mac.record.stop", language: lang)
|
||||
: MacL10n.string("mac.record.start", language: lang)
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.contentTransition(.opacity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 40)
|
||||
.background(
|
||||
(viewModel.isRecording ? palette.recordRed : palette.accent),
|
||||
in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isProcessing)
|
||||
.opacity(viewModel.isProcessing ? 0.55 : 1)
|
||||
.animation(Motion.soft, value: viewModel.isRecording)
|
||||
.animation(Motion.quick, value: viewModel.isProcessing)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Button(MacL10n.string("mac.openWindow", language: lang)) { MacMainWindow.open() }
|
||||
.buttonStyle(.plain)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
Spacer()
|
||||
Button(MacL10n.string("mac.quit", language: lang)) { NSApplication.shared.terminate(nil) }
|
||||
.buttonStyle(.plain)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MacDictationError: Error, LocalizedError {
|
||||
case noAudio
|
||||
case providerHasNoCloudASR
|
||||
case emptyTranscript
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAudio:
|
||||
return MacL10n.string("mac.error.noAudio")
|
||||
case .providerHasNoCloudASR:
|
||||
return MacL10n.string("mac.error.noCloudASR")
|
||||
case .emptyTranscript:
|
||||
return MacL10n.string("mac.error.emptyTranscript")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
guard !samples.isEmpty else { throw MacDictationError.noAudio }
|
||||
|
||||
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
|
||||
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,
|
||||
locale: locale,
|
||||
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
|
||||
capabilities: capabilities
|
||||
)
|
||||
)
|
||||
localBias = bias
|
||||
LocalASRBiasDiagnosticsStore.save(
|
||||
payload: bias,
|
||||
modelId: MacLocalASRService.selectedModelDefinition()?.id,
|
||||
backendLabel: MacLocalASRService.currentBackendLabel()
|
||||
)
|
||||
raw = try await MacLocalASRService.transcribe(
|
||||
samples: samples,
|
||||
locale: locale,
|
||||
bias: bias
|
||||
)
|
||||
} else {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
||||
guard strategy != .localFallback else { throw MacDictationError.providerHasNoCloudASR }
|
||||
|
||||
let client = CloudASRClientFactory.make(store: store)
|
||||
try? await client.prepare(dictionary: store.personalDictionary)
|
||||
raw = try await client.transcribe(
|
||||
samples: samples,
|
||||
sampleRate: 16_000,
|
||||
locale: locale,
|
||||
dictionary: store.personalDictionary
|
||||
)
|
||||
}
|
||||
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw MacDictationError.emptyTranscript }
|
||||
|
||||
let postASR: String
|
||||
if let localBias, !localBias.correctionPairs.isEmpty {
|
||||
postASR = LocalASRTranscriptCorrector.apply(trimmed, pairs: localBias.correctionPairs)
|
||||
} else {
|
||||
postASR = trimmed
|
||||
}
|
||||
|
||||
let polishContext: PolishContext?
|
||||
if let supplement = localBias?.polishFragment.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!supplement.isEmpty {
|
||||
polishContext = PolishContext(
|
||||
appContext: store.detectedAppContext?.context ?? .unknown,
|
||||
intensity: store.polishIntensity,
|
||||
dictionarySupplement: supplement
|
||||
)
|
||||
} else {
|
||||
polishContext = nil
|
||||
}
|
||||
|
||||
if let polished = try? await PolishingService(store: store).polish(
|
||||
postASR,
|
||||
mode: store.polishModeForPipeline,
|
||||
context: polishContext
|
||||
),
|
||||
!polished.isEmpty {
|
||||
return polished
|
||||
}
|
||||
return postASR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// MacDictationViewModel.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Drives the whole macOS window: navigation, recording, hotkey, iCloud-backed
|
||||
// settings, foreground-app context, and text insertion.
|
||||
|
||||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// Top-level navigation destinations, mirroring the iOS app's tabs.
|
||||
enum MacSection: String, CaseIterable, Identifiable {
|
||||
case dashboard
|
||||
case history
|
||||
case dictionary
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
func title(language: AppUILanguage) -> String {
|
||||
switch self {
|
||||
case .dashboard: return MacL10n.string("mac.section.dashboard", language: language)
|
||||
case .history: return MacL10n.string("mac.section.history", language: language)
|
||||
case .dictionary: return MacL10n.string("mac.section.dictionary", language: language)
|
||||
case .settings: return MacL10n.string("mac.section.settings", language: language)
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
case .dictionary: return "character.book.closed"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MacDictationViewModel: ObservableObject {
|
||||
/// Shared instance so the SwiftUI window and the AppKit menu-bar popover
|
||||
/// (see `MacAppDelegate`) drive the exact same recording / settings state.
|
||||
static let shared = MacDictationViewModel()
|
||||
|
||||
@Published var selectedSection: MacSection = .dashboard
|
||||
|
||||
@Published var isRecording = false
|
||||
@Published var isProcessing = false
|
||||
@Published var transcript = ""
|
||||
@Published var statusMessage = ""
|
||||
@Published var audioLevel: Float = 0
|
||||
@Published var sessionSeconds: Int = 0
|
||||
@Published var foregroundAppName: String?
|
||||
@Published var dictionaryRevision = 0
|
||||
|
||||
@Published var autoPasteEnabled: Bool
|
||||
@Published var hotkeyEnabled: Bool
|
||||
|
||||
@Published var config: ProviderConfig
|
||||
|
||||
let defaults: UserDefaults
|
||||
private let recorder = MacAudioRecorder()
|
||||
private let hotkeyService = MacHotkeyService()
|
||||
private var levelTimer: Timer?
|
||||
private var sessionTimer: Timer?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
let usageStatistics: UsageStatisticsStore
|
||||
let speechHistory = SpeechHistoryStore.shared
|
||||
|
||||
private enum StoredKeys {
|
||||
static let autoPaste = "mac.autoPasteEnabled"
|
||||
static let hotkey = "mac.hotkeyEnabled"
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
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
|
||||
|
||||
MacICloudSyncBootstrap.configure(defaults: defaults)
|
||||
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
|
||||
wireHotkeyService()
|
||||
forwardNestedObjectChanges()
|
||||
}
|
||||
|
||||
/// `config` is a nested `ObservableObject`; without forwarding its
|
||||
/// `objectWillChange`, SwiftUI views that observe only the view model
|
||||
/// won't refresh when settings (e.g. engine mode / provider) change.
|
||||
private func forwardNestedObjectChanges() {
|
||||
config.objectWillChange
|
||||
.sink { [weak self] in self?.objectWillChange.send() }
|
||||
.store(in: &cancellables)
|
||||
usageStatistics.objectWillChange
|
||||
.sink { [weak self] in self?.objectWillChange.send() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func onAppear() async {
|
||||
await MacICloudSyncBootstrap.pullIfEnabled()
|
||||
refreshForegroundAppName()
|
||||
}
|
||||
|
||||
func reloadConfigFromCloud() {
|
||||
config.reloadFromPersistedStorage()
|
||||
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
|
||||
}
|
||||
|
||||
func refreshDictionaryFromCloud() {
|
||||
dictionaryRevision += 1
|
||||
}
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
var selectableProviders: [LLMProvider] {
|
||||
LLMProvider.presets.filter {
|
||||
$0.isUserSelectable && $0.cloudASRStrategy != .localFallback
|
||||
}
|
||||
}
|
||||
|
||||
var dictionaryTermCount: Int {
|
||||
_ = dictionaryRevision
|
||||
return AppGroupStore(defaults: defaults).personalDictionary.entries.count
|
||||
}
|
||||
|
||||
var currentWordCount: Int {
|
||||
transcript.split { $0 == " " || $0 == "\n" || $0 == "\t" }.count
|
||||
}
|
||||
|
||||
var isCloudMode: Bool { config.engineMode == "cloud" }
|
||||
|
||||
var languageLabel: String {
|
||||
let id = config.localeId.isEmpty ? "zh-CN" : config.localeId
|
||||
return Locale.current.localizedString(forIdentifier: id) ?? id
|
||||
}
|
||||
|
||||
var sessionTimeLabel: String {
|
||||
let minutes = sessionSeconds / 60
|
||||
let seconds = sessionSeconds % 60
|
||||
if minutes > 0 { return "\(minutes)m \(seconds)s" }
|
||||
return "\(seconds)s"
|
||||
}
|
||||
|
||||
var localModelReady: Bool {
|
||||
_ = localModelRevision
|
||||
guard let model = MacLocalASRService.selectedModelDefinition() else { return false }
|
||||
return MacLocalASRService.isModelInstalled(model)
|
||||
}
|
||||
|
||||
/// Context-aware warning when local engine is selected but the active model is not ready.
|
||||
var localModelWarningMessage: String? {
|
||||
_ = localModelRevision
|
||||
guard config.engineMode == "local" else { return nil }
|
||||
if localModelReady { return nil }
|
||||
guard let model = MacLocalASRService.selectedModelDefinition() else {
|
||||
return MacL10n.string("mac.settings.localModelFallbackApple", language: config.uiLanguage)
|
||||
}
|
||||
return MacL10n.format(
|
||||
"mac.settings.selectedModelMissing",
|
||||
language: config.uiLanguage,
|
||||
model.displayName
|
||||
)
|
||||
}
|
||||
|
||||
@Published private(set) var localModelRevision = 0
|
||||
|
||||
func bumpLocalModelRevision() {
|
||||
localModelRevision += 1
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
// MARK: - Preferences
|
||||
|
||||
func setAutoPasteEnabled(_ enabled: Bool) {
|
||||
autoPasteEnabled = enabled
|
||||
defaults.set(enabled, forKey: StoredKeys.autoPaste)
|
||||
}
|
||||
|
||||
func setHotkeyEnabled(_ enabled: Bool) {
|
||||
hotkeyEnabled = enabled
|
||||
defaults.set(enabled, forKey: StoredKeys.hotkey)
|
||||
hotkeyService.setEnabled(enabled)
|
||||
if enabled { hotkeyService.start() } else { hotkeyService.stop() }
|
||||
}
|
||||
|
||||
func setEngineMode(_ mode: String) {
|
||||
config.engineMode = mode
|
||||
}
|
||||
|
||||
// MARK: - Recording
|
||||
|
||||
func toggleRecording() {
|
||||
if isRecording { finishRecording() } else { beginRecording() }
|
||||
}
|
||||
|
||||
func beginRecording() {
|
||||
guard !isProcessing else { return }
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
MacAppContextService.captureAndPersist(to: store)
|
||||
refreshForegroundAppName()
|
||||
|
||||
do {
|
||||
try recorder.start()
|
||||
isRecording = true
|
||||
transcript = ""
|
||||
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
|
||||
startTimers()
|
||||
} catch {
|
||||
statusMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func finishRecording() {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
isProcessing = true
|
||||
statusMessage = MacL10n.string("mac.status.transcribing", language: config.uiLanguage)
|
||||
stopTimers()
|
||||
audioLevel = 0
|
||||
let samples = recorder.stop()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
|
||||
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)
|
||||
} catch {
|
||||
self.statusMessage = error.localizedDescription
|
||||
}
|
||||
self.isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ text: String) throws -> Bool {
|
||||
try MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
|
||||
}
|
||||
|
||||
private func statusAfterDelivery(pasted: Bool) -> String {
|
||||
let lang = config.uiLanguage
|
||||
if autoPasteEnabled, pasted {
|
||||
return MacL10n.string("mac.status.copiedAndPasted", language: lang)
|
||||
}
|
||||
if autoPasteEnabled, !pasted {
|
||||
return MacL10n.string("mac.status.copied", language: lang)
|
||||
}
|
||||
return MacL10n.string("mac.status.copied", language: lang)
|
||||
}
|
||||
|
||||
private func wireHotkeyService() {
|
||||
hotkeyService.onPressBegan = { [weak self] in
|
||||
self?.beginRecording()
|
||||
}
|
||||
hotkeyService.onPressEnded = { [weak self] in
|
||||
self?.finishRecording()
|
||||
}
|
||||
if hotkeyEnabled { hotkeyService.start() }
|
||||
}
|
||||
|
||||
private func startTimers() {
|
||||
sessionSeconds = 0
|
||||
let levelTimer = Timer(timeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in self.audioLevel = self.recorder.level() }
|
||||
}
|
||||
let sessionTimer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in self.sessionSeconds += 1 }
|
||||
}
|
||||
RunLoop.main.add(levelTimer, forMode: .common)
|
||||
RunLoop.main.add(sessionTimer, forMode: .common)
|
||||
self.levelTimer = levelTimer
|
||||
self.sessionTimer = sessionTimer
|
||||
}
|
||||
|
||||
private func stopTimers() {
|
||||
levelTimer?.invalidate()
|
||||
sessionTimer?.invalidate()
|
||||
levelTimer = nil
|
||||
sessionTimer = nil
|
||||
}
|
||||
|
||||
private func recordUsage(for text: String) {
|
||||
usageStatistics.recordUtterance(
|
||||
text: text,
|
||||
duration: TimeInterval(sessionSeconds),
|
||||
wasTranslation: config.isTranslationEffective
|
||||
)
|
||||
}
|
||||
|
||||
func copyToClipboard(_ text: String) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
}
|
||||
|
||||
func selectProvider(_ provider: LLMProvider) {
|
||||
config.apply(preset: provider)
|
||||
}
|
||||
|
||||
func refreshForegroundAppName() {
|
||||
foregroundAppName = MacAppContextService.frontmostApplicationName()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// 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.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacDictionaryView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
@State private var query = ""
|
||||
@State private var entryPendingDeletion: PersonalDictionary.Entry?
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
private var entries: [PersonalDictionary.Entry] {
|
||||
_ = viewModel.dictionaryRevision
|
||||
return AppGroupStore(defaults: viewModel.defaults).personalDictionary.entries
|
||||
}
|
||||
|
||||
/// Entries filtered by the search field, grouped by category and sorted
|
||||
/// (most-used first) — mirrors the iOS Personal Dictionary tab.
|
||||
private var sections: [(category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let filtered: [PersonalDictionary.Entry]
|
||||
if trimmed.isEmpty {
|
||||
filtered = entries
|
||||
} else {
|
||||
filtered = entries.filter { entry in
|
||||
entry.term.lowercased().contains(trimmed)
|
||||
|| entry.aliases.contains { $0.lowercased().contains(trimmed) }
|
||||
}
|
||||
}
|
||||
let grouped = Dictionary(grouping: filtered, by: { $0.category })
|
||||
return PersonalDictionary.Entry.Category.allCases.compactMap { category in
|
||||
guard let bucket = grouped[category], !bucket.isEmpty else { return nil }
|
||||
let sorted = bucket.sorted {
|
||||
if $0.usageCount != $1.usageCount { return $0.usageCount > $1.usageCount }
|
||||
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
|
||||
}
|
||||
return (category, sorted)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if entries.isEmpty {
|
||||
emptyState
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
form
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(palette.background)
|
||||
.animation(Motion.soft, value: entries.isEmpty)
|
||||
.task {
|
||||
await MacICloudSyncBootstrap.dictionarySync.pullAndMergeIfEnabled()
|
||||
viewModel.refreshDictionaryFromCloud()
|
||||
}
|
||||
.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,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(MacL10n.string("mac.delete", language: lang), role: .destructive) {
|
||||
if let entry = entryPendingDeletion {
|
||||
withAnimation(Motion.soft) { delete(entry) }
|
||||
}
|
||||
entryPendingDeletion = nil
|
||||
}
|
||||
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {
|
||||
entryPendingDeletion = nil
|
||||
}
|
||||
} message: {
|
||||
Text(MacL10n.string("mac.dict.deleteMessage", language: lang))
|
||||
}
|
||||
}
|
||||
|
||||
private var deletionDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { entryPendingDeletion != nil },
|
||||
set: { if !$0 { entryPendingDeletion = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private func row(_ entry: PersonalDictionary.Entry) -> some View {
|
||||
MacDictionaryRow(
|
||||
entry: entry,
|
||||
subtitle: subtitle(for: entry),
|
||||
language: lang,
|
||||
copy: { viewModel.copyToClipboard(entry.term) },
|
||||
delete: { entryPendingDeletion = entry }
|
||||
)
|
||||
}
|
||||
|
||||
/// "Manual · ×3 · k8s / kube" — same metadata line as iOS.
|
||||
private func subtitle(for entry: PersonalDictionary.Entry) -> String? {
|
||||
var parts: [String] = [MacL10n.string(entry.source.labelKey, language: lang)]
|
||||
if entry.usageCount > 1 {
|
||||
parts.append("×\(entry.usageCount)")
|
||||
}
|
||||
if !entry.aliases.isEmpty {
|
||||
parts.append(entry.aliases.joined(separator: " / "))
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
// MARK: - Empty state
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "character.book.closed")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.6))
|
||||
Text(MacL10n.string("mac.dict.empty", language: lang))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text(MacL10n.string("mac.dict.emptyBody", language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
}
|
||||
|
||||
private func delete(_ entry: PersonalDictionary.Entry) {
|
||||
let store = AppGroupStore(defaults: viewModel.defaults)
|
||||
store.deletePersonalDictionaryEntry(id: entry.id)
|
||||
viewModel.refreshDictionaryFromCloud()
|
||||
Task {
|
||||
try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(store.personalDictionary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MacDictionaryRow: View {
|
||||
let entry: PersonalDictionary.Entry
|
||||
let subtitle: String?
|
||||
let language: AppUILanguage
|
||||
let copy: () -> Void
|
||||
let delete: () -> Void
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(entry.term)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Button(action: delete) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(isHovering ? palette.danger : palette.textTertiary)
|
||||
.opacity(isHovering ? 1 : 0)
|
||||
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.animation(Motion.quick, value: isHovering)
|
||||
.onHover { isHovering = $0 }
|
||||
.contextMenu {
|
||||
Button(action: copy) {
|
||||
Label(MacL10n.string("mac.copy", language: language), systemImage: "doc.on.doc")
|
||||
}
|
||||
Button(role: .destructive, action: delete) {
|
||||
Label(MacL10n.string("mac.delete", language: language), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacHistoryView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@ObservedObject private var historyStore = SpeechHistoryStore.shared
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
@State private var showClearConfirmation = false
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .none
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .none
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.safeAreaInset(edge: .top, spacing: 0) { toolbar }
|
||||
.confirmationDialog(
|
||||
MacL10n.string("mac.history.clearTitle", language: lang),
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(MacL10n.string("mac.history.clearConfirm", language: lang), role: .destructive) {
|
||||
withAnimation(Motion.soft) { historyStore.clearAll() }
|
||||
}
|
||||
Button(MacL10n.string("mac.cancel", language: lang), role: .cancel) {}
|
||||
} message: {
|
||||
Text(MacL10n.string("mac.history.clearMessage", language: lang))
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
Label(MacL10n.string("mac.history.clearConfirm", language: lang), systemImage: "trash")
|
||||
.font(TypeStyle.caption)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.background(palette.background)
|
||||
}
|
||||
|
||||
private func row(_ entry: SpeechHistoryEntry) -> some View {
|
||||
MacHistoryRow(
|
||||
entry: entry,
|
||||
time: Self.timeFormatter.string(from: entry.createdAt),
|
||||
language: lang,
|
||||
copy: { viewModel.copyToClipboard(entry.text) },
|
||||
delete: { withAnimation(Motion.soft) { historyStore.delete(id: entry.id) } }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Empty state
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "text.bubble")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.6))
|
||||
Text(MacL10n.string("mac.history.empty", language: lang))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MacHistoryRow: View {
|
||||
let entry: SpeechHistoryEntry
|
||||
let time: String
|
||||
let language: AppUILanguage
|
||||
let copy: () -> Void
|
||||
let delete: () -> Void
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(time)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.monospacedDigit()
|
||||
Text(entry.text)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Button(action: delete) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(isHovering ? palette.danger : palette.textTertiary)
|
||||
.opacity(isHovering ? 1 : 0)
|
||||
.accessibilityLabel(MacL10n.string("mac.delete", language: language))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.animation(Motion.quick, value: isHovering)
|
||||
.onHover { isHovering = $0 }
|
||||
.contextMenu {
|
||||
Button(action: copy) {
|
||||
Label(MacL10n.string("mac.copy", language: language), systemImage: "doc.on.doc")
|
||||
}
|
||||
Button(role: .destructive, action: delete) {
|
||||
Label(MacL10n.string("mac.delete", language: language), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// MacHotkeyService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Global hold-to-talk: while Option (⌥) is held, dictation runs. Mirrors
|
||||
// Typeless / SayIt push-to-talk from any foreground app.
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class MacHotkeyService {
|
||||
var onPressBegan: (() -> Void)?
|
||||
var onPressEnded: (() -> Void)?
|
||||
|
||||
private var globalFlagsMonitor: Any?
|
||||
private var localFlagsMonitor: Any?
|
||||
private var optionHeld = false
|
||||
private var isEnabled = true
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
isEnabled = enabled
|
||||
if !enabled, optionHeld {
|
||||
optionHeld = false
|
||||
onPressEnded?()
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard globalFlagsMonitor == nil else { return }
|
||||
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
|
||||
globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
Task { @MainActor in self?.handleFlagsChanged(event) }
|
||||
}
|
||||
localFlagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
Task { @MainActor in self?.handleFlagsChanged(event) }
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let globalFlagsMonitor {
|
||||
NSEvent.removeMonitor(globalFlagsMonitor)
|
||||
self.globalFlagsMonitor = nil
|
||||
}
|
||||
if let localFlagsMonitor {
|
||||
NSEvent.removeMonitor(localFlagsMonitor)
|
||||
self.localFlagsMonitor = nil
|
||||
}
|
||||
if optionHeld {
|
||||
optionHeld = false
|
||||
onPressEnded?()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
onPressEnded?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// MacICloudSyncBootstrap.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Wires the shared iCloud KVS sync layer to macOS UserDefaults so settings
|
||||
// and personal dictionary stay aligned with the iOS app.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
enum MacICloudSyncBootstrap {
|
||||
private static var configured = false
|
||||
private static var cloudSync: AppCloudSync?
|
||||
|
||||
static func configure(defaults: UserDefaults) {
|
||||
guard !configured else { return }
|
||||
configured = true
|
||||
let makeStore = { AppGroupStore(defaults: defaults) }
|
||||
cloudSync = AppCloudSync(makeStore: makeStore, historyDefaults: { defaults })
|
||||
cloudSync?.startObservingExternalChanges()
|
||||
}
|
||||
|
||||
static func pullIfEnabled() async {
|
||||
await cloudSync?.pullAllIfEnabled()
|
||||
}
|
||||
|
||||
static var settingsSync: SettingsCloudSync {
|
||||
if let cloudSync {
|
||||
return cloudSync.settingsSyncService
|
||||
}
|
||||
return SettingsCloudSync(makeStore: { AppGroupStore(defaults: .standard) })
|
||||
}
|
||||
|
||||
static var dictionarySync: PersonalDictionaryCloudSync {
|
||||
cloudSync?.dictionarySyncService ?? PersonalDictionaryCloudSync(makeStore: { AppGroupStore(defaults: .standard) })
|
||||
}
|
||||
|
||||
static var appCloudSync: AppCloudSync {
|
||||
cloudSync ?? AppCloudSync.shared
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacSettingsICloudSyncRow: 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
|
||||
@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))
|
||||
Text(MacL10n.string("mac.sync.settingsSubtitle", 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: .settingsDidSyncFromCloud)) { _ in
|
||||
reloadFromStore()
|
||||
}
|
||||
|
||||
if isEnabled {
|
||||
Button {
|
||||
syncNow()
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if isSyncingNow { ProgressView().controlSize(.small) }
|
||||
Text(MacL10n.string("mac.sync.syncNow", language: language))
|
||||
}
|
||||
}
|
||||
.disabled(isSyncingNow || isApplyingToggle)
|
||||
}
|
||||
|
||||
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).settingsICloudSyncEnabled
|
||||
}
|
||||
|
||||
private func enableSync() {
|
||||
isApplyingToggle = true
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await MacICloudSyncBootstrap.settingsSync.enableSync()
|
||||
reloadFromStore()
|
||||
} catch {
|
||||
isEnabled = false
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
isApplyingToggle = false
|
||||
}
|
||||
}
|
||||
|
||||
private func disableSync() {
|
||||
MacICloudSyncBootstrap.settingsSync.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = nil
|
||||
}
|
||||
|
||||
private func syncNow() {
|
||||
isSyncingNow = true
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await MacICloudSyncBootstrap.appCloudSync.syncNow()
|
||||
} catch {
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
isSyncingNow = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,17 @@
|
||||
// MacL10n.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Bilingual UI strings for the macOS app. Reuses Shared.strings and
|
||||
// respects the same `AppUILanguage` override as the iOS settings page.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MacL10n {
|
||||
static func string(_ key: String, language: AppUILanguage? = nil) -> String {
|
||||
SharedL10n.string(key, language: language)
|
||||
}
|
||||
|
||||
static func format(_ key: String, language: AppUILanguage? = nil, _ args: CVarArg...) -> String {
|
||||
SharedL10n.format(key, language: language, args)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// MacLegalSettingsViews.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Privacy policy and third-party license screens (mirrors iOS Settings footer).
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacPrivacyPolicyView: View {
|
||||
let uiLanguage: AppUILanguage
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
var body: some View {
|
||||
MacLegalWebView(
|
||||
resourceName: "PrivacyPolicy",
|
||||
scrollToAnchor: privacyScrollAnchor
|
||||
)
|
||||
.background(palette.background)
|
||||
.navigationTitle(MacL10n.string("mac.settings.privacyPolicy", language: uiLanguage))
|
||||
}
|
||||
|
||||
private var privacyScrollAnchor: String? {
|
||||
switch uiLanguage {
|
||||
case .chinese:
|
||||
return "zh"
|
||||
case .english:
|
||||
return "top"
|
||||
case .auto:
|
||||
return uiLanguage.resolvedLanguageCode().hasPrefix("zh") ? "zh" : "top"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MacOpenSourceLicensesView: View {
|
||||
let uiLanguage: AppUILanguage
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Text(MacL10n.string("mac.settings.licenses.footer", language: uiLanguage))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(OpenSourceLicenseCatalog.entries) { entry in
|
||||
NavigationLink {
|
||||
MacOpenSourceLicenseDetailView(entry: entry, uiLanguage: uiLanguage)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(entry.name)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Text(entry.licenseName)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.navigationTitle(MacL10n.string("mac.settings.thirdPartyLicenses", language: uiLanguage))
|
||||
}
|
||||
}
|
||||
|
||||
private struct MacOpenSourceLicenseDetailView: View {
|
||||
let entry: OpenSourceLicenseCatalog.Entry
|
||||
let uiLanguage: AppUILanguage
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
if let url = entry.url {
|
||||
Link(destination: url) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text(url.absoluteString)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(entry.purpose)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(entry.licenseText)
|
||||
.font(TypeStyle.monoSmall)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle(entry.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// MacLegalWebView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// In-app HTML viewer for bundled legal documents (privacy policy).
|
||||
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct MacLegalWebView: NSViewRepresentable {
|
||||
let resourceName: String
|
||||
var scrollToAnchor: String?
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(scrollToAnchor: scrollToAnchor)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let webView = WKWebView(frame: .zero)
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.webView = webView
|
||||
|
||||
guard let url = Bundle.main.url(forResource: resourceName, withExtension: "html") else {
|
||||
return webView
|
||||
}
|
||||
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: WKWebView, context: Context) {
|
||||
context.coordinator.scrollToAnchor = scrollToAnchor
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
var scrollToAnchor: String?
|
||||
weak var webView: WKWebView?
|
||||
|
||||
init(scrollToAnchor: String?) {
|
||||
self.scrollToAnchor = scrollToAnchor
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
guard let anchor = scrollToAnchor, !anchor.isEmpty else { return }
|
||||
let escaped = anchor.replacingOccurrences(of: "'", with: "\\'")
|
||||
webView.evaluateJavaScript("location.hash = '#\(escaped)';") { _, _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
// MacLocalASRModelSettingsView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Local ASR model catalog, download progress, and bias diagnostics.
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class MacLocalASRModelSettingsViewModel: ObservableObject {
|
||||
@Published var catalog: LocalASRCatalogDocument?
|
||||
@Published var selectedModelId: String = MacLocalASRPreferences.selectedModelId
|
||||
@Published var installProgress = LocalASRModelInstallProgress.idle
|
||||
@Published var diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
|
||||
@Published var statusMessage = ""
|
||||
@Published var isInstalling = false
|
||||
@Published var isDownloadPaused = false
|
||||
|
||||
var onLocalModelStateChanged: (() -> Void)?
|
||||
|
||||
private let manager = LocalASRModelManager.shared
|
||||
private var progressPollTask: Task<Void, Never>?
|
||||
|
||||
deinit {
|
||||
progressPollTask?.cancel()
|
||||
}
|
||||
|
||||
func reload() {
|
||||
catalog = try? LocalASRModelCatalog.loadBundled()
|
||||
if let catalog {
|
||||
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
||||
selectedModelId = MacLocalASRPreferences.migratedModelId(
|
||||
manifest.selectedModelId.isEmpty
|
||||
? MacLocalASRPreferences.selectedModelId
|
||||
: manifest.selectedModelId
|
||||
)
|
||||
}
|
||||
diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load()
|
||||
onLocalModelStateChanged?()
|
||||
}
|
||||
|
||||
func isInstalled(_ model: LocalASRModelDefinition) -> Bool {
|
||||
MacLocalASRService.isModelInstalled(model)
|
||||
}
|
||||
|
||||
func isInstallingModel(_ model: LocalASRModelDefinition) -> Bool {
|
||||
isInstalling && installProgress.activeItemId == model.id
|
||||
}
|
||||
|
||||
func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? {
|
||||
guard model.installKind == .archive || model.installKind == .repository,
|
||||
let relative = model.installRelativePath,
|
||||
isInstalled(model) else { return nil }
|
||||
let dir = LocalASRModelInstallState.installDirectory(for: relative)
|
||||
let bytes = LocalASRModelInstallState.directoryByteCount(at: dir)
|
||||
guard bytes > 0 else { return nil }
|
||||
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||
}
|
||||
|
||||
func currentRuntime(in catalog: LocalASRCatalogDocument) -> LocalASRRuntimeDefinition? {
|
||||
LocalASRModelCatalog.runtime(for: LocalASRModelCatalog.currentRuntimePlatform(), in: catalog)
|
||||
}
|
||||
|
||||
func isRuntimeInstalled(_ runtime: LocalASRRuntimeDefinition) -> Bool {
|
||||
LocalASRModelInstallState.isRuntimeInstalled(runtime)
|
||||
}
|
||||
|
||||
func selectModel(_ modelId: String) {
|
||||
guard let catalog, !isInstalling else { return }
|
||||
selectedModelId = modelId
|
||||
MacLocalASRPreferences.selectedModelId = modelId
|
||||
var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
||||
manifest.selectedModelId = modelId
|
||||
manifest.updatedAt = Date()
|
||||
try? LocalASRInstalledManifestIO.save(manifest)
|
||||
onLocalModelStateChanged?()
|
||||
}
|
||||
|
||||
func installModel(_ model: LocalASRModelDefinition) {
|
||||
guard let catalog, !isInstalling else { return }
|
||||
statusMessage = ""
|
||||
isInstalling = true
|
||||
isDownloadPaused = false
|
||||
startProgressPolling()
|
||||
Task {
|
||||
do {
|
||||
try await manager.installModel(model, catalog: catalog)
|
||||
installProgress = await manager.currentProgress()
|
||||
selectModel(model.id)
|
||||
statusMessage = MacL10n.string("mac.localASR.installDone")
|
||||
} catch {
|
||||
installProgress = await manager.currentProgress()
|
||||
statusMessage = error.localizedDescription
|
||||
}
|
||||
isInstalling = false
|
||||
isDownloadPaused = false
|
||||
stopProgressPolling()
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
func pauseDownload() {
|
||||
Task {
|
||||
do {
|
||||
try await manager.pauseDownload()
|
||||
isDownloadPaused = true
|
||||
installProgress = await manager.currentProgress()
|
||||
} catch {
|
||||
statusMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resumeDownload() {
|
||||
Task {
|
||||
do {
|
||||
try await manager.resumeDownload()
|
||||
isDownloadPaused = false
|
||||
installProgress = await manager.currentProgress()
|
||||
} catch {
|
||||
statusMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteModel(_ model: LocalASRModelDefinition) {
|
||||
guard let catalog, !isInstalling else { return }
|
||||
Task {
|
||||
do {
|
||||
try await manager.deleteModel(model, catalog: catalog)
|
||||
statusMessage = MacL10n.string("mac.localASR.deleteDone")
|
||||
reload()
|
||||
} catch {
|
||||
statusMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func revealModelInFinder(_ model: LocalASRModelDefinition) {
|
||||
guard let relative = model.installRelativePath else { return }
|
||||
let url = LocalASRModelInstallState.installDirectory(for: relative)
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
func revealStorageRoot() {
|
||||
let url = LocalASRModelInstallState.rootDirectory()
|
||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
func progressLabel(for progress: LocalASRModelInstallProgress, language: AppUILanguage) -> String {
|
||||
let phaseKey: String
|
||||
switch progress.phase {
|
||||
case .downloading: phaseKey = "mac.localASR.phase.downloading"
|
||||
case .paused: phaseKey = "mac.localASR.phase.paused"
|
||||
case .extracting: phaseKey = "mac.localASR.phase.extracting"
|
||||
case .validating: phaseKey = "mac.localASR.phase.validating"
|
||||
case .finalizing: phaseKey = "mac.localASR.phase.finalizing"
|
||||
case .failed: phaseKey = "mac.localASR.phase.failed"
|
||||
case .completed: phaseKey = "mac.localASR.phase.completed"
|
||||
case .idle: return progress.message
|
||||
}
|
||||
let phase = MacL10n.string(phaseKey, language: language)
|
||||
if let received = progress.bytesReceived, let total = progress.bytesTotal, total > 0 {
|
||||
let recv = ByteCountFormatter.string(fromByteCount: received, countStyle: .file)
|
||||
let tot = ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
|
||||
return "\(phase) · \(progress.message) (\(recv) / \(tot))"
|
||||
}
|
||||
return "\(phase) · \(progress.message)"
|
||||
}
|
||||
|
||||
private func startProgressPolling() {
|
||||
progressPollTask?.cancel()
|
||||
progressPollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let current = await manager.currentProgress()
|
||||
await MainActor.run {
|
||||
self.installProgress = current
|
||||
self.isDownloadPaused = current.phase == .paused
|
||||
}
|
||||
try? await Task.sleep(for: .milliseconds(120))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopProgressPolling() {
|
||||
progressPollTask?.cancel()
|
||||
progressPollTask = nil
|
||||
}
|
||||
|
||||
func formattedSize(_ bytes: Int) -> String {
|
||||
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
struct MacLocalASRModelSettingsView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@StateObject private var modelVM = MacLocalASRModelSettingsViewModel()
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let catalog = modelVM.catalog {
|
||||
modelPickerSection(catalog: catalog)
|
||||
} else {
|
||||
Text(MacL10n.string("mac.localASR.catalogMissing", language: lang))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
modelVM.onLocalModelStateChanged = { viewModel.bumpLocalModelRevision() }
|
||||
modelVM.reload()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(catalog.models) { model in
|
||||
modelRow(model)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if !modelVM.statusMessage.isEmpty {
|
||||
Text(modelVM.statusMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Button(MacL10n.string("mac.localASR.openStorage", language: lang)) {
|
||||
modelVM.revealStorageRoot()
|
||||
}
|
||||
} header: {
|
||||
Text(MacL10n.string("mac.localASR.models", language: lang))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func modelRow(_ model: LocalASRModelDefinition) -> some View {
|
||||
let installed = modelVM.isInstalled(model)
|
||||
let selected = modelVM.selectedModelId == model.id
|
||||
let installing = modelVM.isInstallingModel(model)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack(alignment: .top) {
|
||||
Button {
|
||||
modelVM.selectModel(model.id)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: selected ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
.animation(Motion.quick, value: selected)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text(model.displayName)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
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())
|
||||
}
|
||||
}
|
||||
Text(modelSubtitle(model, installed: installed))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(modelVM.isInstalling)
|
||||
|
||||
Spacer()
|
||||
|
||||
modelRowActions(model: model, installed: installed, installing: installing)
|
||||
.animation(Motion.soft, value: installing)
|
||||
.animation(Motion.soft, value: installed)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func modelRowActions(
|
||||
model: LocalASRModelDefinition,
|
||||
installed: Bool,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if installed {
|
||||
Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) {
|
||||
modelVM.deleteModel(model)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.tint(palette.danger)
|
||||
} else {
|
||||
Button(MacL10n.string("mac.localASR.download", language: lang)) {
|
||||
modelVM.installModel(model)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View {
|
||||
let fraction: Double = {
|
||||
if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused,
|
||||
let received = modelVM.installProgress.bytesReceived,
|
||||
let total = modelVM.installProgress.bytesTotal,
|
||||
total > 0 {
|
||||
return min(1, max(0, Double(received) / Double(total)))
|
||||
}
|
||||
return modelVM.installProgress.fraction
|
||||
}()
|
||||
return ZStack {
|
||||
Circle()
|
||||
.stroke(palette.textTertiary.opacity(0.25), lineWidth: 3)
|
||||
Circle()
|
||||
.trim(from: 0, to: fraction)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
|
||||
}
|
||||
|
||||
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
|
||||
let size = modelVM.formattedSize(model.sizeBytes)
|
||||
if let usage = modelVM.installedDiskUsage(model) {
|
||||
return "\(size) · \(usage)"
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
private var diagnosticsSection: some View {
|
||||
Section {
|
||||
if let snapshot = modelVM.diagnosticsSnapshot {
|
||||
LabeledContent(MacL10n.string("mac.localASR.diagBackend", language: lang)) {
|
||||
Text(snapshot.backendLabel ?? "—")
|
||||
}
|
||||
LabeledContent(MacL10n.string("mac.localASR.diagUserTerms", language: lang)) {
|
||||
Text("\(snapshot.diagnostics.userTermCount)")
|
||||
}
|
||||
LabeledContent(MacL10n.string("mac.localASR.diagBuiltinTerms", language: lang)) {
|
||||
Text("\(snapshot.diagnostics.builtinTermCount)")
|
||||
}
|
||||
LabeledContent(MacL10n.string("mac.localASR.diagHotwords", language: lang)) {
|
||||
Text("\(snapshot.hotwordCount)")
|
||||
}
|
||||
LabeledContent(MacL10n.string("mac.localASR.diagPrompt", language: lang)) {
|
||||
Text("\(snapshot.promptBiasLength)")
|
||||
}
|
||||
if snapshot.diagnostics.truncated {
|
||||
Label(
|
||||
snapshot.diagnostics.truncationReason ?? MacL10n.string("mac.localASR.diagTruncated", language: lang),
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
Text(snapshot.diagnostics.selectedSources.joined(separator: ", "))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
} else {
|
||||
Text(MacL10n.string("mac.localASR.diagEmpty", language: lang))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
} header: {
|
||||
Text(MacL10n.string("mac.localASR.diagnostics", language: lang))
|
||||
} footer: {
|
||||
Text(MacL10n.string("mac.localASR.diagnosticsDesc", language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// MacLocalASRService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// On-device ASR for macOS. Routes through the bundled local ASR catalog:
|
||||
// Sherpa Qwen3 (default), Paraformer, SenseVoice, Apple Speech fallback.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MacLocalASRBackend: String, Sendable, CaseIterable {
|
||||
case sherpaQwen3
|
||||
case sherpaParaformer
|
||||
case sherpaSenseVoice
|
||||
case appleSpeech
|
||||
}
|
||||
|
||||
enum MacLocalASRError: Error, LocalizedError {
|
||||
case qwen3ModelMissing
|
||||
case qwen3LoadFailed(String)
|
||||
case qwen3InferenceFailed(String)
|
||||
case speechDenied
|
||||
case speechFailed(String)
|
||||
case emptyTranscript
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .qwen3ModelMissing:
|
||||
return MacL10n.string("mac.error.qwen3ModelMissing")
|
||||
case .qwen3LoadFailed(let detail):
|
||||
return MacL10n.format("mac.error.qwen3LoadFailed", detail)
|
||||
case .qwen3InferenceFailed(let detail):
|
||||
return MacL10n.format("mac.error.qwen3InferenceFailed", detail)
|
||||
case .speechDenied:
|
||||
return "Speech recognition permission denied"
|
||||
case .speechFailed(let detail):
|
||||
return detail
|
||||
case .emptyTranscript:
|
||||
return MacL10n.string("mac.error.emptyTranscript")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum MacLocalASRPreferences {
|
||||
static let backendKey = "mac.localASR.backend"
|
||||
static let selectedModelIdKey = LocalASRPreferenceKeys.selectedModelId
|
||||
/// Legacy MLX path key — retained for migration only.
|
||||
static let qwen3ModelRelativePath = "models/qwen3-asr-1.7b-mlx"
|
||||
|
||||
static var selectedModelId: String {
|
||||
get {
|
||||
if let raw = UserDefaults.standard.string(forKey: selectedModelIdKey), !raw.isEmpty {
|
||||
return migratedModelId(raw)
|
||||
}
|
||||
return legacyBackend == .appleSpeech ? "apple-speech-fallback" : "sherpa-qwen3-0.6b-int8"
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue, forKey: selectedModelIdKey) }
|
||||
}
|
||||
|
||||
/// Maps removed catalog entries to the current default Sherpa model.
|
||||
static func migratedModelId(_ id: String) -> String {
|
||||
switch id {
|
||||
case "qwen3-mlx-1.7b":
|
||||
return "sherpa-qwen3-0.6b-int8"
|
||||
default:
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
static var legacyBackend: MacLocalASRBackend {
|
||||
guard let raw = UserDefaults.standard.string(forKey: backendKey),
|
||||
let value = MacLocalASRBackend(rawValue: raw) else {
|
||||
return .sherpaQwen3
|
||||
}
|
||||
if raw == "qwen3MLX" { return .sherpaQwen3 }
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
enum MacLocalASRService {
|
||||
|
||||
static func loadCatalog() -> LocalASRCatalogDocument? {
|
||||
try? LocalASRModelCatalog.loadBundled()
|
||||
}
|
||||
|
||||
static func selectedModelDefinition() -> LocalASRModelDefinition? {
|
||||
guard let catalog = loadCatalog() else { return nil }
|
||||
let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
||||
let selectedId = manifest.selectedModelId.isEmpty
|
||||
? MacLocalASRPreferences.selectedModelId
|
||||
: MacLocalASRPreferences.migratedModelId(manifest.selectedModelId)
|
||||
if selectedId == "apple-speech-fallback" { return nil }
|
||||
return LocalASRModelCatalog.model(selectedId, in: catalog)
|
||||
?? LocalASRModelCatalog.model(catalog.defaultModelId, in: catalog)
|
||||
}
|
||||
|
||||
static func currentCapabilities() -> LocalASRCapabilities {
|
||||
guard let model = selectedModelDefinition() else { return .appleSpeech }
|
||||
return LocalASRModelCatalog.capabilities(for: model)
|
||||
}
|
||||
|
||||
static func currentBackendLabel() -> String {
|
||||
guard let model = selectedModelDefinition() else { return "Apple Speech" }
|
||||
return model.displayName
|
||||
}
|
||||
|
||||
static func isModelInstalled(_ model: LocalASRModelDefinition) -> Bool {
|
||||
LocalASRModelInstallState.isInstalled(
|
||||
model,
|
||||
manualMLXPath: nil,
|
||||
fileManager: FileManager.default
|
||||
)
|
||||
}
|
||||
|
||||
/// Transcribe using the selected catalog model, falling back to Apple Speech.
|
||||
static func transcribe(
|
||||
samples: [Float],
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload? = nil
|
||||
) async throws -> String {
|
||||
if let model = selectedModelDefinition(), isModelInstalled(model) {
|
||||
return try await transcribeWithModel(model, samples: samples, locale: locale, bias: bias)
|
||||
}
|
||||
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
||||
}
|
||||
|
||||
private static func transcribeWithModel(
|
||||
_ model: LocalASRModelDefinition,
|
||||
samples: [Float],
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload?
|
||||
) async throws -> String {
|
||||
switch model.backend {
|
||||
case .mlx:
|
||||
throw MacLocalASRError.qwen3ModelMissing
|
||||
case .sherpaQwen3, .sherpaSenseVoice, .sherpaParaformer:
|
||||
return try await MacSherpaLocalASR.transcribe(
|
||||
samples: samples,
|
||||
sampleRate: 16_000,
|
||||
locale: locale,
|
||||
model: model,
|
||||
bias: bias
|
||||
)
|
||||
case .appleSpeech:
|
||||
return try await MacSpeechLocalASR.transcribe(samples: samples, locale: locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
// MacOnboardingView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// A short first-run setup for the macOS app. It is intentionally separate
|
||||
// from iOS onboarding because Mac needs Accessibility and optional Sherpa setup.
|
||||
//
|
||||
// Visual language mirrors the iOS onboarding: an ambient top gradient, a
|
||||
// glowing hero icon, a large title block, and elongated capsule progress
|
||||
// dots — all carried by whitespace and a single accent colour.
|
||||
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
enum MacOnboardingState {
|
||||
static let storageKey = "mac.hasCompletedOnboarding"
|
||||
}
|
||||
|
||||
private enum MacOnboardingStep: Int, CaseIterable {
|
||||
case welcome
|
||||
case microphone
|
||||
case accessibility
|
||||
case engine
|
||||
case cloudAPI
|
||||
case localModel
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .welcome: return "sparkles"
|
||||
case .microphone: return "mic.fill"
|
||||
case .accessibility: return "accessibility"
|
||||
case .engine: return "switch.2"
|
||||
case .cloudAPI: return "key.fill"
|
||||
case .localModel: return "arrow.down.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MacOnboardingViewModel: ObservableObject {
|
||||
@Published var step: MacOnboardingStep = .welcome
|
||||
@Published var micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
@Published var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
@Published var catalog: LocalASRCatalogDocument?
|
||||
@Published var installProgress = LocalASRModelInstallProgress.idle
|
||||
@Published var isInstalling = false
|
||||
@Published var statusMessage = ""
|
||||
|
||||
private let manager = LocalASRModelManager.shared
|
||||
private var progressPollTask: Task<Void, Never>?
|
||||
|
||||
deinit {
|
||||
progressPollTask?.cancel()
|
||||
}
|
||||
|
||||
var defaultModel: LocalASRModelDefinition? {
|
||||
guard let catalog else { return nil }
|
||||
return catalog.models.first { $0.id == catalog.defaultModelId }
|
||||
}
|
||||
|
||||
var isDefaultModelInstalled: Bool {
|
||||
guard let defaultModel else { return false }
|
||||
return MacLocalASRService.isModelInstalled(defaultModel)
|
||||
}
|
||||
|
||||
func reload() {
|
||||
catalog = try? LocalASRModelCatalog.loadBundled()
|
||||
micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
}
|
||||
|
||||
func requestMicrophone() {
|
||||
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openAccessibilitySettings() {
|
||||
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
refreshAccessibilitySoon()
|
||||
}
|
||||
|
||||
func refreshAccessibilitySoon() {
|
||||
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
}
|
||||
}
|
||||
|
||||
func installDefaultModel() {
|
||||
guard let catalog, let model = defaultModel, !isInstalling else { return }
|
||||
statusMessage = ""
|
||||
isInstalling = true
|
||||
startProgressPolling()
|
||||
Task {
|
||||
do {
|
||||
try await manager.installModel(model, catalog: catalog)
|
||||
installProgress = await manager.currentProgress()
|
||||
selectInstalledModel(model.id, catalog: catalog)
|
||||
statusMessage = MacL10n.string("mac.onboarding.model.done")
|
||||
} catch {
|
||||
installProgress = await manager.currentProgress()
|
||||
statusMessage = error.localizedDescription
|
||||
}
|
||||
isInstalling = false
|
||||
stopProgressPolling()
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
func progressLabel(language: AppUILanguage) -> String {
|
||||
let phase: String
|
||||
switch installProgress.phase {
|
||||
case .idle: return installProgress.message
|
||||
case .downloading: phase = MacL10n.string("mac.localASR.phase.downloading", language: language)
|
||||
case .paused: phase = MacL10n.string("mac.localASR.phase.paused", language: language)
|
||||
case .extracting: phase = MacL10n.string("mac.localASR.phase.extracting", language: language)
|
||||
case .validating: phase = MacL10n.string("mac.localASR.phase.validating", language: language)
|
||||
case .finalizing: phase = MacL10n.string("mac.localASR.phase.finalizing", language: language)
|
||||
case .failed: phase = MacL10n.string("mac.localASR.phase.failed", language: language)
|
||||
case .completed: phase = MacL10n.string("mac.localASR.phase.completed", language: language)
|
||||
}
|
||||
guard !installProgress.message.isEmpty else { return phase }
|
||||
return "\(phase) · \(installProgress.message)"
|
||||
}
|
||||
|
||||
private func selectInstalledModel(_ modelId: String, catalog: LocalASRCatalogDocument) {
|
||||
MacLocalASRPreferences.selectedModelId = modelId
|
||||
var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId)
|
||||
manifest.selectedModelId = modelId
|
||||
manifest.updatedAt = Date()
|
||||
try? LocalASRInstalledManifestIO.save(manifest)
|
||||
}
|
||||
|
||||
private func startProgressPolling() {
|
||||
progressPollTask?.cancel()
|
||||
progressPollTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
let current = await LocalASRModelManager.shared.currentProgress()
|
||||
await MainActor.run { self?.installProgress = current }
|
||||
try? await Task.sleep(nanoseconds: 120_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopProgressPolling() {
|
||||
progressPollTask?.cancel()
|
||||
progressPollTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Root
|
||||
|
||||
struct MacOnboardingView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Binding var hasCompletedOnboarding: Bool
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@StateObject private var model = MacOnboardingViewModel()
|
||||
@State private var contentAppeared = false
|
||||
|
||||
private var lang: AppUILanguage { viewModel.config.uiLanguage }
|
||||
|
||||
private var visibleSteps: [MacOnboardingStep] {
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
return [.welcome, .microphone, .accessibility, .engine, .cloudAPI]
|
||||
}
|
||||
return [.welcome, .microphone, .accessibility, .engine, .localModel]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .top) {
|
||||
background(height: geo.size.height)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: Spacing.xl)
|
||||
|
||||
hero
|
||||
.id(model.step)
|
||||
.transition(stepTransition)
|
||||
|
||||
Spacer(minLength: Spacing.lg)
|
||||
|
||||
progressDots
|
||||
.padding(.bottom, Spacing.lg)
|
||||
|
||||
bottomBar
|
||||
.padding(.horizontal, Spacing.xxxl)
|
||||
.padding(.bottom, Spacing.xxl)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 860, minHeight: 600)
|
||||
.onAppear {
|
||||
applyDefaults()
|
||||
model.reload()
|
||||
withAnimation(.spring(response: 0.7, dampingFraction: 0.85)) {
|
||||
contentAppeared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Background
|
||||
|
||||
private func background(height: CGFloat) -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
palette.background.ignoresSafeArea()
|
||||
|
||||
LinearGradient(
|
||||
colors: [
|
||||
palette.accent.opacity(0.12),
|
||||
palette.accent.opacity(0.03),
|
||||
palette.background.opacity(0)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: height * 0.42)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Hero + content
|
||||
|
||||
private var hero: some View {
|
||||
VStack(spacing: Spacing.lg) {
|
||||
heroIcon
|
||||
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Text(title)
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: 460)
|
||||
}
|
||||
|
||||
stepContent
|
||||
.frame(maxWidth: 460)
|
||||
.padding(.top, Spacing.xs)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xxl)
|
||||
.opacity(contentAppeared ? 1 : 0)
|
||||
.offset(y: contentAppeared ? 0 : 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var heroIcon: some View {
|
||||
if model.step == .welcome {
|
||||
Image("OSGBrandMark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 128, height: 128)
|
||||
.foregroundStyle(colorScheme == .dark ? Color.white : palette.accent)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
} else {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(palette.accentGlow)
|
||||
.frame(width: 116, height: 116)
|
||||
.blur(radius: 26)
|
||||
|
||||
Circle()
|
||||
.fill(palette.accentMuted)
|
||||
.frame(width: 92, height: 92)
|
||||
.overlay(Circle().stroke(palette.accent.opacity(0.25), lineWidth: 1))
|
||||
|
||||
Image(systemName: model.step.systemImage)
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var stepContent: some View {
|
||||
switch model.step {
|
||||
case .welcome:
|
||||
featureList
|
||||
case .microphone:
|
||||
permissionCard(
|
||||
isGranted: model.micStatus == .authorized,
|
||||
grantedText: MacL10n.string("mac.onboarding.microphone.granted", language: lang),
|
||||
neededText: MacL10n.string("mac.onboarding.microphone.needed", language: lang)
|
||||
)
|
||||
case .accessibility:
|
||||
permissionCard(
|
||||
isGranted: model.accessibilityTrusted,
|
||||
grantedText: MacL10n.string("mac.onboarding.accessibility.granted", language: lang),
|
||||
neededText: MacL10n.string("mac.onboarding.accessibility.needed", language: lang)
|
||||
)
|
||||
case .engine:
|
||||
enginePicker
|
||||
case .cloudAPI:
|
||||
cloudAPIFields
|
||||
case .localModel:
|
||||
localModelPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var featureList: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
featureRow("lock.shield.fill", MacL10n.string("mac.onboarding.welcome.privacy", language: lang))
|
||||
featureRow("option", MacL10n.string("mac.onboarding.welcome.hotkey", language: lang))
|
||||
featureRow("cpu", MacL10n.string("mac.onboarding.welcome.local", language: lang))
|
||||
}
|
||||
}
|
||||
|
||||
private func featureRow(_ icon: String, _ text: String) -> some View {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 26, height: 26)
|
||||
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
|
||||
Text(text)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: isGranted ? "checkmark.seal.fill" : "exclamationmark.circle.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(isGranted ? palette.accent : palette.warning)
|
||||
|
||||
Text(isGranted ? grantedText : neededText)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.overlay(cardShape.stroke((isGranted ? palette.accent : palette.warning).opacity(0.25), lineWidth: 1))
|
||||
}
|
||||
|
||||
private var enginePicker: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
engineRow(
|
||||
title: MacL10n.string("mac.settings.localEngine", language: lang),
|
||||
subtitle: MacL10n.string("mac.onboarding.engine.localDesc", language: lang),
|
||||
systemImage: "cpu",
|
||||
selected: viewModel.config.engineMode == "local"
|
||||
) { setEngine("local") }
|
||||
|
||||
engineRow(
|
||||
title: MacL10n.string("mac.settings.cloudEngine", language: lang),
|
||||
subtitle: MacL10n.string("mac.onboarding.engine.cloudDesc", language: lang),
|
||||
systemImage: "cloud.fill",
|
||||
selected: viewModel.config.engineMode == "cloud"
|
||||
) { setEngine("cloud") }
|
||||
}
|
||||
}
|
||||
|
||||
private func engineRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
selected: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
||||
.frame(width: 30)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.sm)
|
||||
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(selected ? palette.accent : palette.textTertiary.opacity(0.6))
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(selected ? palette.accentMuted : palette.surface))
|
||||
.overlay(cardShape.stroke(selected ? palette.accent.opacity(0.5) : palette.divider, lineWidth: selected ? 1 : 0.5))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
|
||||
Text(MacL10n.string("mac.settings.apiKey", language: lang))
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
|
||||
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) {
|
||||
Text(MacL10n.string("mac.settings.model", language: lang))
|
||||
}
|
||||
.labelsHidden()
|
||||
.macFieldStyle()
|
||||
|
||||
Label(MacL10n.string("mac.onboarding.cloud.skipHint", language: lang), systemImage: "info.circle")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private var localModelPanel: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: model.isDefaultModelInstalled ? "checkmark.circle.fill" : "shippingbox.fill")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.textTertiary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.defaultModel?.displayName ?? MacL10n.string("mac.localASR.catalogMissing", language: lang))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(localModelSubtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.sm)
|
||||
|
||||
if !model.isDefaultModelInstalled, !model.isInstalling {
|
||||
Button(MacL10n.string("mac.onboarding.model.download", language: lang)) {
|
||||
model.installDefaultModel()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
.disabled(model.defaultModel == nil)
|
||||
}
|
||||
}
|
||||
|
||||
if model.isInstalling || model.installProgress.phase != .idle {
|
||||
ProgressView(value: model.installProgress.fraction)
|
||||
.tint(palette.accent)
|
||||
Text(model.progressLabel(language: lang))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
if !model.statusMessage.isEmpty {
|
||||
Text(model.statusMessage)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(model.isDefaultModelInstalled ? palette.accent : palette.warning)
|
||||
}
|
||||
|
||||
Label(MacL10n.string("mac.onboarding.model.skipHint", language: lang), systemImage: "info.circle")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(cardShape.fill(palette.surface))
|
||||
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
// MARK: Progress dots
|
||||
|
||||
private var progressDots: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(visibleSteps.enumerated()), id: \.offset) { index, _ in
|
||||
Capsule()
|
||||
.fill(index == currentStepIndex ? palette.accent : palette.textTertiary.opacity(0.28))
|
||||
.frame(width: index == currentStepIndex ? 22 : 6, height: 6)
|
||||
}
|
||||
}
|
||||
.animation(Motion.quick, value: currentStepIndex)
|
||||
}
|
||||
|
||||
// MARK: Bottom bar
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if canGoBack {
|
||||
secondaryButton(MacL10n.string("mac.onboarding.back", language: lang)) { goBack() }
|
||||
}
|
||||
|
||||
if canSkipCurrentStep {
|
||||
secondaryButton(MacL10n.string("mac.onboarding.skipForNow", language: lang)) {
|
||||
if isLastStep { finish() } else { goForward() }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
primaryButton(primaryButtonTitle, disabled: model.isInstalling && model.step == .localModel) {
|
||||
primaryAction()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 520)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func primaryButton(_ titleText: String, disabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(titleText)
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(disabled ? palette.textSecondary : palette.textOnAccent)
|
||||
.padding(.horizontal, Spacing.xxl)
|
||||
.frame(minWidth: 150, minHeight: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.fill(disabled ? palette.surfaceElevated : palette.accent)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
private func secondaryButton(_ titleText: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(titleText)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
.frame(minHeight: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: Copy
|
||||
|
||||
private var title: String {
|
||||
switch model.step {
|
||||
case .welcome: return MacL10n.string("mac.onboarding.welcome.title", language: lang)
|
||||
case .microphone: return MacL10n.string("mac.onboarding.microphone.title", language: lang)
|
||||
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.title", language: lang)
|
||||
case .engine: return MacL10n.string("mac.onboarding.engine.title", language: lang)
|
||||
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.title", language: lang)
|
||||
case .localModel: return MacL10n.string("mac.onboarding.model.title", language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
switch model.step {
|
||||
case .welcome: return MacL10n.string("mac.onboarding.welcome.subtitle", language: lang)
|
||||
case .microphone: return MacL10n.string("mac.onboarding.microphone.subtitle", language: lang)
|
||||
case .accessibility: return MacL10n.string("mac.onboarding.accessibility.subtitle", language: lang)
|
||||
case .engine: return MacL10n.string("mac.onboarding.engine.subtitle", language: lang)
|
||||
case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang)
|
||||
case .localModel: return MacL10n.string("mac.onboarding.model.subtitle", language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
private var primaryButtonTitle: String {
|
||||
switch model.step {
|
||||
case .microphone where model.micStatus != .authorized:
|
||||
return MacL10n.string("mac.onboarding.microphone.allow", language: lang)
|
||||
case .accessibility where !model.accessibilityTrusted:
|
||||
return MacL10n.string("mac.onboarding.accessibility.open", language: lang)
|
||||
case .cloudAPI:
|
||||
return MacL10n.string("mac.onboarding.finish", language: lang)
|
||||
case .localModel:
|
||||
return MacL10n.string(model.isDefaultModelInstalled ? "mac.onboarding.finish" : "mac.onboarding.skipForNow", language: lang)
|
||||
default:
|
||||
return isLastStep ? MacL10n.string("mac.onboarding.finish", language: lang) : MacL10n.string("mac.onboarding.next", language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
private var localModelSubtitle: String {
|
||||
if model.isDefaultModelInstalled {
|
||||
return MacL10n.string("mac.localASR.installed", language: lang)
|
||||
}
|
||||
guard let model = model.defaultModel else { return "" }
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(model.sizeBytes), countStyle: .file)
|
||||
}
|
||||
|
||||
private var providerBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.config.providerId },
|
||||
set: { newId in
|
||||
guard let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) else { return }
|
||||
viewModel.selectProvider(provider)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Derived
|
||||
|
||||
private var cardShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
}
|
||||
|
||||
private var stepTransition: AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .opacity.combined(with: .offset(y: 10)),
|
||||
removal: .opacity.combined(with: .offset(y: -10))
|
||||
)
|
||||
}
|
||||
|
||||
private var currentStepIndex: Int {
|
||||
visibleSteps.firstIndex(of: model.step) ?? 0
|
||||
}
|
||||
|
||||
private var canGoBack: Bool {
|
||||
currentStepIndex > 0 && !model.isInstalling
|
||||
}
|
||||
|
||||
private var canSkipCurrentStep: Bool {
|
||||
model.step != .welcome && !model.isInstalling
|
||||
}
|
||||
|
||||
private var isLastStep: Bool {
|
||||
currentStepIndex == visibleSteps.count - 1
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
private func setEngine(_ mode: String) {
|
||||
withAnimation(Motion.quick) { viewModel.setEngineMode(mode) }
|
||||
}
|
||||
|
||||
private func primaryAction() {
|
||||
switch model.step {
|
||||
case .microphone where model.micStatus != .authorized:
|
||||
model.requestMicrophone()
|
||||
case .accessibility where !model.accessibilityTrusted:
|
||||
model.openAccessibilitySettings()
|
||||
default:
|
||||
if isLastStep { finish() } else { goForward() }
|
||||
}
|
||||
}
|
||||
|
||||
private func goForward() {
|
||||
let nextIndex = min(currentStepIndex + 1, visibleSteps.count - 1)
|
||||
withAnimation(Motion.soft) { model.step = visibleSteps[nextIndex] }
|
||||
}
|
||||
|
||||
private func goBack() {
|
||||
let previousIndex = max(currentStepIndex - 1, 0)
|
||||
withAnimation(Motion.soft) { model.step = visibleSteps[previousIndex] }
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
viewModel.selectedSection = .dashboard
|
||||
hasCompletedOnboarding = true
|
||||
}
|
||||
|
||||
private func applyDefaults() {
|
||||
guard !hasCompletedOnboarding else { return }
|
||||
if viewModel.config.apiKey.isEmpty, viewModel.config.engineMode == "cloud" {
|
||||
viewModel.setEngineMode("local")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// MacRootView.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Window shell built on the native `NavigationSplitView` so the desktop app
|
||||
// reads like macOS System Settings: traffic lights float over a borderless
|
||||
// sidebar, no separate title bar. The same structure lifts cleanly onto
|
||||
// iPadOS later (NavigationSplitView is cross-platform).
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MacRootView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
|
||||
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
|
||||
.navigationSplitViewColumnWidth(MacMetrics.sidebarWidth)
|
||||
} detail: {
|
||||
detail
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.frame(minWidth: 860, minHeight: 600)
|
||||
.onAppear {
|
||||
// Let the AppKit status-bar popover reopen this window on demand.
|
||||
MacWindowBridge.shared.open = { openWindow(id: "main") }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sidebar
|
||||
|
||||
private var sidebar: some View {
|
||||
VStack(spacing: 0) {
|
||||
brandHeader
|
||||
VStack(spacing: 4) {
|
||||
ForEach(MacSection.allCases) { section in
|
||||
MacSidebarRow(
|
||||
section: section,
|
||||
isSelected: viewModel.selectedSection == section,
|
||||
language: uiLanguage
|
||||
) {
|
||||
withAnimation(Motion.soft) { viewModel.selectedSection = section }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, MacMetrics.sidebarInset)
|
||||
Spacer()
|
||||
devicesFooter
|
||||
}
|
||||
}
|
||||
|
||||
/// Brand mark pinned above the nav list. Top padding clears the traffic
|
||||
/// lights that now float over the borderless sidebar.
|
||||
private var brandHeader: some View {
|
||||
HStack {
|
||||
Image("OSGLogoWide")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 30)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityLabel("OSGKeyboard")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, MacMetrics.sidebarContentInset)
|
||||
.padding(.trailing, MacMetrics.sidebarInset)
|
||||
.padding(.top, Spacing.lg)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
|
||||
private var devicesFooter: some View {
|
||||
Label(
|
||||
MacL10n.string("mac.devices", language: uiLanguage),
|
||||
systemImage: "laptopcomputer.and.iphone"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, MacMetrics.sidebarInset)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
// MARK: - Detail
|
||||
|
||||
private var detail: some View {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
switch viewModel.selectedSection {
|
||||
case .dashboard: DashboardView(viewModel: viewModel)
|
||||
case .history: MacHistoryView(viewModel: viewModel)
|
||||
case .dictionary: MacDictionaryView(viewModel: viewModel)
|
||||
case .settings: MacSettingsView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.id(viewModel.selectedSection)
|
||||
.transition(.opacity)
|
||||
MacStatusFooter(viewModel: viewModel)
|
||||
}
|
||||
.background(palette.background)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sidebar row
|
||||
|
||||
/// A navigation row with an animated hover highlight and selection state,
|
||||
/// matching the macOS System Settings feel.
|
||||
private struct MacSidebarRow: View {
|
||||
let section: MacSection
|
||||
let isSelected: Bool
|
||||
let language: AppUILanguage
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.themePalette) private var palette
|
||||
@State private var isHovering = false
|
||||
|
||||
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)
|
||||
.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)
|
||||
.animation(Motion.quick, value: isHovering)
|
||||
.onHover { isHovering = $0 }
|
||||
}
|
||||
|
||||
private var rowBackground: Color {
|
||||
if isSelected { return palette.accent }
|
||||
return isHovering ? palette.textPrimary.opacity(0.06) : .clear
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// 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.
|
||||
|
||||
import SwiftUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct MacSettingsView: View {
|
||||
@ObservedObject var viewModel: MacDictationViewModel
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
@AppStorage(MacAppearancePreference.storageKey)
|
||||
private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||
@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)] = [
|
||||
("auto", "locale.auto", "Auto"),
|
||||
("zh-Hans", "locale.zh-Hans", "Chinese (Simplified)"),
|
||||
("zh-Hant", "locale.zh-Hant", "Chinese (Traditional)"),
|
||||
("en-US", "locale.en-US", "English (US)"),
|
||||
("ja-JP", "locale.ja-JP", "Japanese"),
|
||||
("ko-KR", "locale.ko-KR", "Korean")
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
generalSection
|
||||
recognitionSection
|
||||
if viewModel.config.engineMode == "cloud" {
|
||||
providerSection
|
||||
.transition(.opacity)
|
||||
}
|
||||
if viewModel.config.engineMode == "local" {
|
||||
MacLocalASRModelSettingsView(viewModel: viewModel)
|
||||
.transition(.opacity)
|
||||
}
|
||||
inputSection
|
||||
legalSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.tint(palette.accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
}
|
||||
.onAppear { refreshAccessibilityState() }
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Picker(MacL10n.string("mac.settings.recognitionLanguage", language: lang), selection: recognitionLanguageBinding) {
|
||||
ForEach(recognitionLocales, id: \.id) { locale in
|
||||
Text(localeLabel(locale)).tag(locale.id)
|
||||
}
|
||||
}
|
||||
|
||||
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
|
||||
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cloud provider
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recognition method
|
||||
|
||||
private var recognitionSection: some View {
|
||||
Section(MacL10n.string("mac.settings.recognition", language: lang)) {
|
||||
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(
|
||||
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"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
|
||||
.contentTransition(.opacity)
|
||||
.animation(Motion.quick, value: accessibilityTrusted)
|
||||
|
||||
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
|
||||
openAccessibilitySettings()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
rowLabel(
|
||||
MacL10n.string("mac.settings.accessibility", language: lang),
|
||||
subtitle: MacL10n.string("mac.settings.accessibilityDesc", language: lang)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
systemImage: String,
|
||||
selected: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
||||
.frame(width: 26)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(selected ? palette.accent : palette.textTertiary)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.animation(Motion.quick, value: selected)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var currentProvider: LLMProvider {
|
||||
viewModel.selectableProviders.first { $0.id == viewModel.config.providerId }
|
||||
?? viewModel.selectableProviders.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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func providerLabel(_ provider: LLMProvider) -> some View {
|
||||
Label {
|
||||
Text(provider.name)
|
||||
} icon: {
|
||||
providerLogo(provider.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
private var interfaceLanguageBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.config.uiLanguage.rawValue },
|
||||
set: { viewModel.config.uiLanguage = AppUILanguage(rawValue: $0) ?? .auto }
|
||||
)
|
||||
}
|
||||
|
||||
private var providerBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.config.providerId },
|
||||
set: { newId in
|
||||
if let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) {
|
||||
viewModel.selectProvider(provider)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var recognitionLanguageBinding: Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
let current = viewModel.config.localeId
|
||||
return current.isEmpty ? "auto" : current
|
||||
},
|
||||
set: { newValue in
|
||||
viewModel.config.localeId = newValue == "auto" ? "" : newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var hotkeyBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.hotkeyEnabled },
|
||||
set: { viewModel.setHotkeyEnabled($0) }
|
||||
)
|
||||
}
|
||||
|
||||
private var autoPasteBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.autoPasteEnabled },
|
||||
set: { viewModel.setAutoPasteEnabled($0) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - AppKit actions (macOS only)
|
||||
|
||||
private func openAccessibilitySettings() {
|
||||
#if os(macOS)
|
||||
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
refreshAccessibilityState()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
refreshAccessibilityState()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func refreshAccessibilityState() {
|
||||
accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
||||
}
|
||||
|
||||
private func localeLabel(_ locale: (id: String, key: String, fallback: String)) -> String {
|
||||
let resolved = AppUILanguage.localizedString(
|
||||
locale.key,
|
||||
tableName: nil,
|
||||
bundle: .main,
|
||||
language: lang
|
||||
)
|
||||
return resolved == locale.key ? locale.fallback : resolved
|
||||
}
|
||||
|
||||
private var accessibilityStatusGranted: String {
|
||||
lang.resolvedLanguageCode().hasPrefix("zh") ? "已授权" : "Granted"
|
||||
}
|
||||
|
||||
private var accessibilityStatusNeeded: String {
|
||||
lang.resolvedLanguageCode().hasPrefix("zh") ? "未授权" : "Needed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// MacSherpaLocalASR.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Sherpa-onnx backed local ASR (Qwen3 hotwords POC + SenseVoice baseline).
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MacSherpaLocalASR {
|
||||
|
||||
static func transcribe(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
model: LocalASRModelDefinition,
|
||||
bias: LocalASRBiasPayload?
|
||||
) async throws -> String {
|
||||
let catalog = try LocalASRModelCatalog.loadBundled()
|
||||
let manager = LocalASRModelManager.shared
|
||||
guard let layout = model.layout,
|
||||
let modelRoot = LocalASRModelInstallState.modelRootURL(model) else {
|
||||
throw MacLocalASRError.qwen3ModelMissing
|
||||
}
|
||||
|
||||
try await manager.ensureRuntimeInstalled(catalog: catalog)
|
||||
guard let runtime = LocalASRModelCatalog.runtime(
|
||||
for: LocalASRModelCatalog.currentRuntimePlatform(),
|
||||
in: catalog
|
||||
),
|
||||
let binary = LocalASRModelInstallState.resolveRuntimeBinary(runtime: runtime) else {
|
||||
throw MacLocalASRError.qwen3LoadFailed("Sherpa runtime binary missing")
|
||||
}
|
||||
|
||||
switch model.backend {
|
||||
case .sherpaQwen3:
|
||||
return try await MacSherpaONNXRunner.transcribeQwen3(
|
||||
samples: samples,
|
||||
sampleRate: sampleRate,
|
||||
locale: locale,
|
||||
modelRoot: modelRoot,
|
||||
layout: layout,
|
||||
runtimeBinary: binary,
|
||||
bias: bias
|
||||
)
|
||||
case .sherpaSenseVoice:
|
||||
return try await MacSherpaONNXRunner.transcribeSenseVoice(
|
||||
samples: samples,
|
||||
sampleRate: sampleRate,
|
||||
modelRoot: modelRoot,
|
||||
layout: layout,
|
||||
runtimeBinary: binary
|
||||
)
|
||||
case .sherpaParaformer:
|
||||
return try await MacSherpaONNXRunner.transcribeParaformer(
|
||||
samples: samples,
|
||||
sampleRate: sampleRate,
|
||||
modelRoot: modelRoot,
|
||||
layout: layout,
|
||||
runtimeBinary: binary
|
||||
)
|
||||
default:
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Unsupported Sherpa backend")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// MacSherpaONNXRunner.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Invokes the downloaded `sherpa-onnx-offline` binary for Sherpa-backed POC models.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MacSherpaONNXRunner {
|
||||
|
||||
static func transcribeQwen3(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
locale: Locale,
|
||||
modelRoot: URL,
|
||||
layout: LocalASRModelLayout,
|
||||
runtimeBinary: URL,
|
||||
bias: LocalASRBiasPayload?
|
||||
) async throws -> String {
|
||||
guard sampleRate == 16_000 else {
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
|
||||
}
|
||||
guard let conv = layout.convFrontend,
|
||||
let encoder = layout.encoder,
|
||||
let decoder = layout.decoder,
|
||||
let tokenizer = layout.tokenizer else {
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Incomplete Sherpa Qwen3 layout")
|
||||
}
|
||||
|
||||
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
|
||||
defer { try? FileManager.default.removeItem(at: wavURL) }
|
||||
|
||||
var arguments = [
|
||||
"--qwen3-asr-conv-frontend=\(modelRoot.appendingPathComponent(conv).path)",
|
||||
"--qwen3-asr-encoder=\(modelRoot.appendingPathComponent(encoder).path)",
|
||||
"--qwen3-asr-decoder=\(modelRoot.appendingPathComponent(decoder).path)",
|
||||
"--qwen3-asr-tokenizer=\(modelRoot.appendingPathComponent(tokenizer).path)",
|
||||
"--qwen3-asr-max-new-tokens=512",
|
||||
"--num-threads=2",
|
||||
]
|
||||
|
||||
if let language = MacQwen3LanguageHint.from(locale: locale) {
|
||||
arguments.append("--qwen3-asr-language=\(language)")
|
||||
}
|
||||
|
||||
if let hotwords = bias?.hardHotwords, !hotwords.isEmpty {
|
||||
arguments.append("--qwen3-asr-hotwords=\(hotwords.joined(separator: ","))")
|
||||
}
|
||||
|
||||
arguments.append(wavURL.path)
|
||||
return try await run(binary: runtimeBinary, arguments: arguments)
|
||||
}
|
||||
|
||||
static func transcribeSenseVoice(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
modelRoot: URL,
|
||||
layout: LocalASRModelLayout,
|
||||
runtimeBinary: URL
|
||||
) async throws -> String {
|
||||
guard sampleRate == 16_000 else {
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
|
||||
}
|
||||
guard let model = layout.senseVoiceModel,
|
||||
let tokens = layout.tokens else {
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Incomplete SenseVoice layout")
|
||||
}
|
||||
|
||||
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
|
||||
defer { try? FileManager.default.removeItem(at: wavURL) }
|
||||
|
||||
let arguments = [
|
||||
"--tokens=\(modelRoot.appendingPathComponent(tokens).path)",
|
||||
"--sense-voice-model=\(modelRoot.appendingPathComponent(model).path)",
|
||||
"--num-threads=2",
|
||||
wavURL.path,
|
||||
]
|
||||
return try await run(binary: runtimeBinary, arguments: arguments)
|
||||
}
|
||||
|
||||
static func transcribeParaformer(
|
||||
samples: [Float],
|
||||
sampleRate: Int,
|
||||
modelRoot: URL,
|
||||
layout: LocalASRModelLayout,
|
||||
runtimeBinary: URL
|
||||
) async throws -> String {
|
||||
guard sampleRate == 16_000 else {
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Sherpa expects 16 kHz audio")
|
||||
}
|
||||
guard let paraformer = layout.paraformerModel,
|
||||
let tokens = layout.tokens else {
|
||||
throw MacLocalASRError.qwen3InferenceFailed("Incomplete Paraformer layout")
|
||||
}
|
||||
|
||||
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: sampleRate)
|
||||
defer { try? FileManager.default.removeItem(at: wavURL) }
|
||||
|
||||
let arguments = [
|
||||
"--tokens=\(modelRoot.appendingPathComponent(tokens).path)",
|
||||
"--paraformer=\(modelRoot.appendingPathComponent(paraformer).path)",
|
||||
"--num-threads=2",
|
||||
wavURL.path,
|
||||
]
|
||||
return try await run(binary: runtimeBinary, arguments: arguments)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
|
||||
let data = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("osg-sherpa-\(UUID().uuidString).wav")
|
||||
try data.write(to: url, options: .atomic)
|
||||
return url
|
||||
}
|
||||
|
||||
private static func run(binary: URL, arguments: [String]) async throws -> String {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let process = Process()
|
||||
process.executableURL = binary
|
||||
process.arguments = arguments
|
||||
process.currentDirectoryURL = binary.deletingLastPathComponent()
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
|
||||
process.terminationHandler = { proc in
|
||||
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stdout = String(data: outputData, encoding: .utf8) ?? ""
|
||||
let stderr = String(data: errorData, encoding: .utf8) ?? ""
|
||||
|
||||
guard proc.terminationStatus == 0 else {
|
||||
let detail = stderr.isEmpty ? stdout : stderr
|
||||
continuation.resume(
|
||||
throwing: MacLocalASRError.qwen3InferenceFailed(
|
||||
detail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let text = parseTranscript(stdout: stdout)
|
||||
if text.isEmpty {
|
||||
continuation.resume(throwing: MacLocalASRError.emptyTranscript)
|
||||
} else {
|
||||
continuation.resume(returning: text)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
continuation.resume(throwing: MacLocalASRError.qwen3InferenceFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseTranscript(stdout: String) -> String {
|
||||
let lines = stdout
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.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("/"), !line.hasPrefix("--"), line.count > 1 {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// MacSpeechLocalASR.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Apple Speech framework fallback for local engine mode. Writes PCM to a
|
||||
// temp WAV and runs `SFSpeechURLRecognitionRequest`.
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
enum MacSpeechLocalASR {
|
||||
static func transcribe(samples: [Float], locale: Locale) async throws -> String {
|
||||
let auth = await requestAuthorization()
|
||||
guard auth == .authorized else { throw MacLocalASRError.speechDenied }
|
||||
|
||||
let wavURL = try writeTemporaryWAV(samples: samples, sampleRate: 16_000)
|
||||
defer { try? FileManager.default.removeItem(at: wavURL) }
|
||||
|
||||
let recognizer = SFSpeechRecognizer(locale: locale) ?? SFSpeechRecognizer()
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
throw MacLocalASRError.speechFailed("Speech recognizer unavailable")
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let request = SFSpeechURLRecognitionRequest(url: wavURL)
|
||||
request.shouldReportPartialResults = false
|
||||
request.requiresOnDeviceRecognition = true
|
||||
|
||||
recognizer.recognitionTask(with: request) { result, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: MacLocalASRError.speechFailed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
guard let result, result.isFinal else { return }
|
||||
let text = result.bestTranscription.formattedString
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.isEmpty {
|
||||
continuation.resume(throwing: MacLocalASRError.emptyTranscript)
|
||||
} else {
|
||||
continuation.resume(returning: text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestAuthorization() async -> SFSpeechRecognizerAuthorizationStatus {
|
||||
await withCheckedContinuation { continuation in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func writeTemporaryWAV(samples: [Float], sampleRate: Int) throws -> URL {
|
||||
let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("osg-mac-asr-\(UUID().uuidString).wav")
|
||||
try wav.write(to: url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// MacTextInsertionService.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Inserts transcribed text into the frontmost app: clipboard first, then
|
||||
// a synthetic ⌘V (SayIt / Typeless-style). Requires Accessibility trust.
|
||||
|
||||
import AppKit
|
||||
@preconcurrency import ApplicationServices
|
||||
import Carbon
|
||||
import Foundation
|
||||
|
||||
enum MacTextInsertionService {
|
||||
enum InsertionError: Error, LocalizedError {
|
||||
case accessibilityNotGranted
|
||||
|
||||
var errorDescription: String? {
|
||||
MacL10n.string("mac.error.accessibilityRequired")
|
||||
}
|
||||
}
|
||||
|
||||
static var isAccessibilityTrusted: Bool {
|
||||
AXIsProcessTrusted()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func requestAccessibilityIfNeeded() -> Bool {
|
||||
if AXIsProcessTrusted() { return true }
|
||||
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
let options = [promptKey: true] as CFDictionary
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
/// Copy to pasteboard and optionally simulate ⌘V in the front app.
|
||||
static func insert(
|
||||
_ text: String,
|
||||
autoPaste: Bool
|
||||
) throws -> Bool {
|
||||
guard !text.isEmpty else { return false }
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
|
||||
guard autoPaste else { return false }
|
||||
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
|
||||
|
||||
Thread.sleep(forTimeInterval: 0.08)
|
||||
postCommandV()
|
||||
return true
|
||||
}
|
||||
|
||||
private static func postCommandV() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// MacTheme.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// System-native colour palette for the desktop app. Instead of the custom
|
||||
// near-black brand palette, the Mac app maps every design token onto AppKit
|
||||
// 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.
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
enum MacSystemPalette {
|
||||
/// Returns the palette for the given colour scheme. Because the two
|
||||
/// palettes hold *concrete* (already-resolved) colours, the value changes
|
||||
/// identity when the scheme flips — so injecting it via `@Environment`
|
||||
/// (see `macSystemPalette()`) reliably re-renders every dependent view the
|
||||
/// instant the appearance changes, instead of lagging until the next
|
||||
/// view rebuild.
|
||||
static func palette(for scheme: ColorScheme) -> ThemePalette {
|
||||
scheme == .dark ? darkPalette : lightPalette
|
||||
}
|
||||
|
||||
private static let lightPalette = makePalette(dark: false)
|
||||
private static let darkPalette = makePalette(dark: true)
|
||||
|
||||
private static func makePalette(dark: Bool) -> ThemePalette {
|
||||
ThemePalette(
|
||||
background: resolved(dark ? darkBackground : warmBackground, dark: dark),
|
||||
surface: resolved(dark ? darkSurface : warmSurface, dark: dark),
|
||||
surfaceElevated: resolved(dark ? darkElevated : warmElevated, dark: dark),
|
||||
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
|
||||
|
||||
accent: Palette.accent,
|
||||
accentMuted: Palette.accent.opacity(0.16),
|
||||
accentGlow: Palette.accent.opacity(0.35),
|
||||
|
||||
danger: resolved(.systemRed, dark: dark),
|
||||
success: Palette.accent,
|
||||
warning: resolved(.systemOrange, dark: dark),
|
||||
|
||||
textPrimary: resolved(.labelColor, dark: dark),
|
||||
textSecondary: resolved(.secondaryLabelColor, dark: dark),
|
||||
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
|
||||
textOnAccent: Color.white,
|
||||
|
||||
divider: resolved(.separatorColor, dark: dark),
|
||||
dividerStrong: resolved(.separatorColor, dark: dark),
|
||||
|
||||
recordRed: resolved(.systemRed, dark: dark)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Warm Light-mode surfaces (matched to iOS `Palette.light`)
|
||||
|
||||
/// #F2F1EE — warm gray page background.
|
||||
private static let warmBackground = NSColor(srgbRed: 0.949, green: 0.945, blue: 0.933, alpha: 1)
|
||||
/// #FCFBF9 — warm off-white card/control surface.
|
||||
private static let warmSurface = NSColor(srgbRed: 0.988, green: 0.984, blue: 0.976, alpha: 1)
|
||||
/// #EBEAE7 — slightly recessed elevated surface.
|
||||
private static let warmElevated = NSColor(srgbRed: 0.922, green: 0.918, blue: 0.906, alpha: 1)
|
||||
/// #EEEDE9 — muted fill between background and surface.
|
||||
private static let warmMuted = NSColor(srgbRed: 0.933, green: 0.929, blue: 0.918, alpha: 1)
|
||||
|
||||
// MARK: - Dark-mode surfaces (Apple standard elevated grays)
|
||||
//
|
||||
// AppKit's `controlBackgroundColor` is *darker* than `windowBackgroundColor`
|
||||
// in Dark Aqua, so cards using it recede into the page. Instead we step the
|
||||
// surfaces explicitly (systemGray6→4 equivalents) so every card reads as
|
||||
// clearly elevated above the background — mirroring the iOS dark palette.
|
||||
|
||||
/// #1C1C1E — page background.
|
||||
private static let darkBackground = NSColor(srgbRed: 0.110, green: 0.110, blue: 0.118, alpha: 1)
|
||||
/// #2C2C2E — card / control surface, clearly lighter than the background.
|
||||
private static let darkSurface = NSColor(srgbRed: 0.173, green: 0.173, blue: 0.180, alpha: 1)
|
||||
/// #3A3A3C — elevated fill for selected / raised chrome.
|
||||
private static let darkElevated = NSColor(srgbRed: 0.227, green: 0.227, blue: 0.235, alpha: 1)
|
||||
/// #242426 — muted fill between background and surface.
|
||||
private static let darkMuted = NSColor(srgbRed: 0.141, green: 0.141, blue: 0.149, alpha: 1)
|
||||
|
||||
/// Resolves a (possibly dynamic) AppKit colour to its concrete value under
|
||||
/// the requested appearance, so the two static palettes differ by value.
|
||||
private static func resolved(_ nsColor: NSColor, dark: Bool) -> Color {
|
||||
guard let appearance = NSAppearance(named: dark ? .darkAqua : .aqua) else {
|
||||
return Color(nsColor: nsColor)
|
||||
}
|
||||
var result = nsColor
|
||||
appearance.performAsCurrentDrawingAppearance {
|
||||
result = nsColor.usingColorSpace(.sRGB) ?? nsColor
|
||||
}
|
||||
return Color(nsColor: result)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MacSystemPaletteModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.environment(\.themePalette, MacSystemPalette.palette(for: colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Injects the system-native palette used across the macOS app, refreshed
|
||||
/// automatically whenever the effective colour scheme changes.
|
||||
func macSystemPalette() -> some View {
|
||||
modifier(MacSystemPaletteModifier())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,227 @@
|
||||
// OSGKeyboardMacApp.swift
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// Entry point. A borderless, System-Settings-style main window plus a
|
||||
// rock-solid AppKit status-bar item (NSStatusItem) with a dictation popover.
|
||||
// Light / dark follows the user's Appearance preference (Settings ▸ General).
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OSGKeyboardMacApp: App {
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate
|
||||
@StateObject private var viewModel = MacDictationViewModel.shared
|
||||
|
||||
// Mac-local appearance preference. Drives both the SwiftUI colour scheme
|
||||
// and — via `applyToApp` — the AppKit window chrome / popover.
|
||||
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
|
||||
|
||||
private var appearance: MacAppearancePreference {
|
||||
MacAppearancePreference(rawValue: appearanceRaw) ?? .system
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
Window("OSGKeyboard", id: "main") {
|
||||
Group {
|
||||
if hasCompletedMacOnboarding {
|
||||
MacRootView(viewModel: viewModel)
|
||||
} else {
|
||||
MacOnboardingView(
|
||||
viewModel: viewModel,
|
||||
hasCompletedOnboarding: $hasCompletedMacOnboarding
|
||||
)
|
||||
}
|
||||
}
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
.task { await viewModel.onAppear() }
|
||||
.onAppear { MacAppearancePreference.applyToApp(appearance) }
|
||||
.onChange(of: appearanceRaw) { MacAppearancePreference.applyToApp(appearance) }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
viewModel.reloadConfigFromCloud()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
viewModel.refreshDictionaryFromCloud()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
viewModel.usageStatistics.reloadFromDisk()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .speechHistoryDidSyncFromCloud)) { _ in
|
||||
viewModel.speechHistory.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
// Borderless titlebar → content (sidebar + traffic lights) runs to the
|
||||
// very top, matching macOS System Settings.
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentMinSize)
|
||||
.defaultSize(width: 1_024, height: 720)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reopening the main window from AppKit
|
||||
|
||||
/// Bridges SwiftUI's `openWindow` action out to AppKit code (the status-bar
|
||||
/// popover) that has no access to the scene environment.
|
||||
@MainActor
|
||||
final class MacWindowBridge {
|
||||
static let shared = MacWindowBridge()
|
||||
var open: (() -> Void)?
|
||||
private init() {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum MacMainWindow {
|
||||
/// Bring the app forward and show the main window, recreating it if the
|
||||
/// user had closed it.
|
||||
static func open() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
MacWindowBridge.shared.open?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status-bar item (AppKit)
|
||||
|
||||
/// Owns the menu-bar `NSStatusItem` and its dictation popover. Implemented in
|
||||
/// AppKit rather than SwiftUI's `MenuBarExtra` because the latter is flaky
|
||||
/// when combined with a primary `Window` scene (the icon can silently vanish).
|
||||
@MainActor
|
||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var statusItem: NSStatusItem?
|
||||
private let popover = NSPopover()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
MacAppearancePreference.applyToApp(.current)
|
||||
configurePopover()
|
||||
configureStatusItem()
|
||||
|
||||
// The menu bar always follows the *system* appearance, so the status
|
||||
// item must ignore the app's forced light/dark override. Re-pin the
|
||||
// button appearance whenever the system theme flips.
|
||||
DistributedNotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(systemAppearanceDidChange),
|
||||
name: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
DistributedNotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
/// Keep the app alive after the last window closes — it lives in the menu bar.
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
private func configureStatusItem() {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
if let button = item.button {
|
||||
button.image = Self.makeStatusBarImage()
|
||||
button.image?.accessibilityDescription = "OSGKeyboard"
|
||||
button.action = #selector(togglePopover(_:))
|
||||
button.target = self
|
||||
}
|
||||
statusItem = item
|
||||
applyStatusItemAppearance()
|
||||
}
|
||||
|
||||
/// Builds the menu-bar glyph from the dedicated horizontal status mark.
|
||||
/// Height is pinned to the tallest practical menu-bar slot so the logo reads
|
||||
/// clearly; width follows the asset's aspect ratio.
|
||||
private static func makeStatusBarImage() -> NSImage? {
|
||||
guard let image = NSImage(named: "OSGStatusMark")
|
||||
?? NSImage(named: "OSGBrandMark")
|
||||
?? NSImage(systemSymbolName: "mic.circle.fill", accessibilityDescription: "OSGKeyboard") else {
|
||||
return nil
|
||||
}
|
||||
let height: CGFloat = 9
|
||||
let aspect = max(image.size.width / max(image.size.height, 1), 1)
|
||||
image.size = NSSize(width: height * aspect, height: height)
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
|
||||
/// Pins the status-bar button to the current *system* appearance so its
|
||||
/// template image tint matches the real menu-bar background — regardless of
|
||||
/// the in-app light/dark preference forced on `NSApp.appearance`.
|
||||
private func applyStatusItemAppearance() {
|
||||
guard let button = statusItem?.button else { return }
|
||||
let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle")?
|
||||
.lowercased().contains("dark") ?? false
|
||||
button.appearance = NSAppearance(named: isDark ? .darkAqua : .aqua)
|
||||
}
|
||||
|
||||
@objc private func systemAppearanceDidChange() {
|
||||
// The global-domain default lags the notification by a hair; hop to the
|
||||
// next runloop tick so `AppleInterfaceStyle` reflects the new value.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.applyStatusItemAppearance()
|
||||
}
|
||||
}
|
||||
|
||||
private func configurePopover() {
|
||||
popover.behavior = .transient
|
||||
popover.animates = true
|
||||
popover.contentSize = NSSize(width: 340, height: 420)
|
||||
popover.contentViewController = NSHostingController(rootView: MacMenuBarPopover())
|
||||
}
|
||||
|
||||
@objc private func togglePopover(_ sender: Any?) {
|
||||
guard let button = statusItem?.button else { return }
|
||||
if popover.isShown {
|
||||
popover.performClose(sender)
|
||||
} else {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
popover.contentViewController?.view.window?.makeKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI content hosted inside the status-bar popover. Shares the single
|
||||
/// view model and follows the same appearance preference as the main window.
|
||||
private struct MacMenuBarPopover: View {
|
||||
@ObservedObject private var viewModel = MacDictationViewModel.shared
|
||||
@AppStorage(MacAppearancePreference.storageKey) private var appearanceRaw = MacAppearancePreference.system.rawValue
|
||||
@AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if hasCompletedMacOnboarding {
|
||||
MacContentView(viewModel: viewModel)
|
||||
} else {
|
||||
onboardingPrompt
|
||||
}
|
||||
}
|
||||
.frame(width: 340)
|
||||
.macSystemPalette()
|
||||
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
|
||||
.preferredColorScheme(MacAppearancePreference(rawValue: appearanceRaw)?.colorScheme ?? nil)
|
||||
}
|
||||
|
||||
private var onboardingPrompt: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(.accent)
|
||||
|
||||
Text(MacL10n.string("mac.onboarding.popover.title", language: viewModel.config.uiLanguage))
|
||||
.font(TypeStyle.headline)
|
||||
|
||||
Text(MacL10n.string("mac.onboarding.popover.subtitle", language: viewModel.config.uiLanguage))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Button(MacL10n.string("mac.openWindow", language: viewModel.config.uiLanguage)) {
|
||||
MacMainWindow.open()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// AppGroupStore+ConfigurationStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// iOS / keyboard-extension configuration backed by the App Group suite.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension AppGroupStore: ConfigurationStore {
|
||||
public var cloudASRPersistence: UserDefaults { defaults }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// ConfigurationStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cross-platform read facade for the dictation pipeline (ASR → polish).
|
||||
// iOS implements this via `AppGroupStore` (App Group UserDefaults + Keychain).
|
||||
// macOS will gain a separate implementation (standard UserDefaults + Keychain)
|
||||
// without pulling in keyboard-extension-only APIs.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Read-only configuration surface consumed by ASR, cloud ASR, and polish services.
|
||||
///
|
||||
/// 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 {
|
||||
var providerId: String { get }
|
||||
var baseURL: String { get }
|
||||
var apiKey: String { get }
|
||||
var model: String { get }
|
||||
var engineMode: String { get }
|
||||
var polishIntensity: PolishIntensity { get }
|
||||
var personalDictionary: PersonalDictionary { get }
|
||||
|
||||
/// Foreground-app context for polish prompts (keyboard extension publishes this).
|
||||
var detectedAppContext: (context: AppContext, observedAt: Date)? { get }
|
||||
|
||||
/// Provider-specific ASR caches (e.g. Alibaba Fun-ASR vocabulary IDs).
|
||||
var cloudASRPersistence: UserDefaults { get }
|
||||
|
||||
func makeClient() -> LLMClient
|
||||
}
|
||||
@@ -10,7 +10,10 @@ public struct RecordButton: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
public enum Phase: Equatable {
|
||||
case idle
|
||||
/// Green — host ready; tap records immediately.
|
||||
case idleReady
|
||||
/// Orange — voice input unavailable (missing key, session not ready, etc.).
|
||||
case idleUnavailable
|
||||
case recording
|
||||
case processing
|
||||
case error
|
||||
@@ -75,7 +78,7 @@ public struct RecordButton: View {
|
||||
.animation(Motion.soft, value: level)
|
||||
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(phase == .idle ? 0.08 : 0.12), lineWidth: 0.5)
|
||||
.stroke(Color.white.opacity(isIdle ? 0.08 : 0.12), lineWidth: 0.5)
|
||||
.frame(width: Layout.outerRing, height: Layout.outerRing)
|
||||
|
||||
ZStack {
|
||||
@@ -87,7 +90,7 @@ public struct RecordButton: View {
|
||||
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
case .idleReady, .idleUnavailable:
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.system(size: 36, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
@@ -128,9 +131,9 @@ public struct RecordButton: View {
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
}
|
||||
.contentShape(Circle())
|
||||
.opacity(isEnabled ? 1 : 0.45)
|
||||
.onTapGesture {
|
||||
guard isEnabled, phase != .processing else { return }
|
||||
guard phase != .processing else { return }
|
||||
guard isEnabled || phase == .idleUnavailable else { return }
|
||||
onToggle()
|
||||
}
|
||||
.onAppear { breath = (phase == .recording) }
|
||||
@@ -140,6 +143,15 @@ public struct RecordButton: View {
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
|
||||
}
|
||||
|
||||
private var isIdle: Bool {
|
||||
switch phase {
|
||||
case .idleReady, .idleUnavailable:
|
||||
return true
|
||||
case .recording, .processing, .error:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func formatRemaining(_ seconds: Int) -> String {
|
||||
let minutes = seconds / 60
|
||||
let remainder = seconds % 60
|
||||
@@ -159,13 +171,13 @@ public struct RecordButton: View {
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
case .error:
|
||||
case .error, .idleUnavailable:
|
||||
return LinearGradient(
|
||||
colors: [palette.warning.opacity(0.85), palette.warning.opacity(0.55)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
case .idle:
|
||||
case .idleReady:
|
||||
return LinearGradient(
|
||||
colors: [palette.accent.opacity(0.95), palette.accent.opacity(0.75)],
|
||||
startPoint: .top,
|
||||
|
||||
@@ -36,6 +36,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let settingsICloudSyncEnabled = "config.settings.iCloudSyncEnabled"
|
||||
/// Wall-clock stamp of the last settings blob applied from iCloud KVS.
|
||||
public static let settingsCloudUpdatedAt = "config.settings.cloudUpdatedAt"
|
||||
/// Cached per-field settings merge payload (`SyncedAppSettingsV2`).
|
||||
public static let settingsCloudPayloadV2 = "config.settings.cloudPayload.v2"
|
||||
/// When true, the host app auto-returns to the source app after a cold-start handoff.
|
||||
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
||||
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
||||
@@ -104,8 +106,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
||||
/// When settings iCloud sync is on, reads synchronizable Keychain items first.
|
||||
public var apiKey: String {
|
||||
Keychain.apiKey(for: providerId) ?? ""
|
||||
Self.resolveAPIKey(
|
||||
defaults: nil,
|
||||
providerId: providerId,
|
||||
preferICloudSync: settingsICloudSyncEnabled
|
||||
)
|
||||
}
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
@@ -206,7 +213,11 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
|
||||
_ = resolveAPIKey(defaults: defaults, providerId: config.providerId)
|
||||
_ = resolveAPIKey(
|
||||
defaults: defaults,
|
||||
providerId: config.providerId,
|
||||
preferICloudSync: config.settingsICloudSyncEnabled
|
||||
)
|
||||
|
||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||
if config.engineMode == "cloud", config.modeId != "polish" {
|
||||
@@ -295,19 +306,23 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
|
||||
static func resolveAPIKey(defaults: UserDefaults?, providerId: String) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
||||
static func resolveAPIKey(
|
||||
defaults: UserDefaults?,
|
||||
providerId: String,
|
||||
preferICloudSync: Bool = false
|
||||
) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId, useICloudSync: preferICloudSync)
|
||||
try? Keychain.deleteLegacyAPIKey()
|
||||
return legacyKeychain
|
||||
}
|
||||
if let defaults,
|
||||
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
|
||||
!legacy.isEmpty {
|
||||
try? Keychain.setAPIKey(legacy, for: providerId)
|
||||
try? Keychain.setAPIKey(legacy, for: providerId, useICloudSync: preferICloudSync)
|
||||
defaults.removeObject(forKey: Keys.apiKeyLegacy)
|
||||
return legacy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// LocalASRBiasPayload.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Output of `LocalASRBiasAdapter` — vocabulary signals for each pipeline layer.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct LocalASRCorrectionPair: Sendable, Equatable {
|
||||
public let alias: String
|
||||
public let term: String
|
||||
|
||||
public init(alias: String, term: String) {
|
||||
self.alias = alias
|
||||
self.term = term
|
||||
}
|
||||
}
|
||||
|
||||
public struct LocalASRBiasDiagnostics: Sendable, Equatable, Codable {
|
||||
public var userTermCount: Int
|
||||
public var builtinTermCount: Int
|
||||
public var truncated: Bool
|
||||
public var truncationReason: String?
|
||||
public var selectedSources: [String]
|
||||
|
||||
public init(
|
||||
userTermCount: Int = 0,
|
||||
builtinTermCount: Int = 0,
|
||||
truncated: Bool = false,
|
||||
truncationReason: String? = nil,
|
||||
selectedSources: [String] = []
|
||||
) {
|
||||
self.userTermCount = userTermCount
|
||||
self.builtinTermCount = builtinTermCount
|
||||
self.truncated = truncated
|
||||
self.truncationReason = truncationReason
|
||||
self.selectedSources = selectedSources
|
||||
}
|
||||
}
|
||||
|
||||
public struct LocalASRBiasPayload: Sendable, Equatable {
|
||||
public var hardHotwords: [String]
|
||||
public var promptBias: String?
|
||||
public var corpusContext: String?
|
||||
public var polishFragment: String
|
||||
public var correctionPairs: [LocalASRCorrectionPair]
|
||||
public var diagnostics: LocalASRBiasDiagnostics
|
||||
|
||||
public static let empty = LocalASRBiasPayload(
|
||||
hardHotwords: [],
|
||||
promptBias: nil,
|
||||
corpusContext: nil,
|
||||
polishFragment: "",
|
||||
correctionPairs: [],
|
||||
diagnostics: LocalASRBiasDiagnostics()
|
||||
)
|
||||
|
||||
public init(
|
||||
hardHotwords: [String],
|
||||
promptBias: String?,
|
||||
corpusContext: String?,
|
||||
polishFragment: String,
|
||||
correctionPairs: [LocalASRCorrectionPair],
|
||||
diagnostics: LocalASRBiasDiagnostics
|
||||
) {
|
||||
self.hardHotwords = hardHotwords
|
||||
self.promptBias = promptBias
|
||||
self.corpusContext = corpusContext
|
||||
self.polishFragment = polishFragment
|
||||
self.correctionPairs = correctionPairs
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
public struct LocalASRBiasRequest: Sendable {
|
||||
public var dictionary: PersonalDictionary
|
||||
public var locale: Locale
|
||||
public var frontAppBundleId: String?
|
||||
public var capabilities: LocalASRCapabilities
|
||||
/// Max builtin `phrases.tsv` terms considered for ASR bias (not polish-only).
|
||||
public var builtinASRLimit: Int
|
||||
/// Max builtin terms referenced in the polish supplement block.
|
||||
public var builtinPolishLimit: Int
|
||||
|
||||
public init(
|
||||
dictionary: PersonalDictionary,
|
||||
locale: Locale,
|
||||
frontAppBundleId: String? = nil,
|
||||
capabilities: LocalASRCapabilities,
|
||||
builtinASRLimit: Int = 300,
|
||||
builtinPolishLimit: Int = 40
|
||||
) {
|
||||
self.dictionary = dictionary
|
||||
self.locale = locale
|
||||
self.frontAppBundleId = frontAppBundleId
|
||||
self.capabilities = capabilities
|
||||
self.builtinASRLimit = builtinASRLimit
|
||||
self.builtinPolishLimit = builtinPolishLimit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// LocalASRCapabilities.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Declares what each on-device ASR backend can accept for vocabulary bias.
|
||||
// Callers must consult capabilities before building a `LocalASRBiasPayload`.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// How a backend accepts vocabulary hints (honest matrix — not every model
|
||||
/// supports hard hotwords).
|
||||
public enum LocalASRHotwordMode: String, Sendable, Codable, Equatable {
|
||||
case none
|
||||
case promptOnly
|
||||
case perRequest
|
||||
case recognizerScoped
|
||||
case cloudVocabulary
|
||||
}
|
||||
|
||||
/// Cost of refreshing hotwords on a backend (e.g. Sherpa Qwen3 reloads recognizer).
|
||||
public enum LocalASRHotwordReloadCost: String, Sendable, Codable, Equatable {
|
||||
case none
|
||||
case recognizerReload
|
||||
case modelReload
|
||||
}
|
||||
|
||||
public struct LocalASRCapabilities: Sendable, Equatable {
|
||||
public let hotwordMode: LocalASRHotwordMode
|
||||
public let maxHotwordCount: Int
|
||||
public let maxPromptCharacters: Int
|
||||
public let supportsStreaming: Bool
|
||||
public let hotwordReloadCost: LocalASRHotwordReloadCost
|
||||
|
||||
public init(
|
||||
hotwordMode: LocalASRHotwordMode,
|
||||
maxHotwordCount: Int,
|
||||
maxPromptCharacters: Int,
|
||||
supportsStreaming: Bool,
|
||||
hotwordReloadCost: LocalASRHotwordReloadCost
|
||||
) {
|
||||
self.hotwordMode = hotwordMode
|
||||
self.maxHotwordCount = maxHotwordCount
|
||||
self.maxPromptCharacters = maxPromptCharacters
|
||||
self.supportsStreaming = supportsStreaming
|
||||
self.hotwordReloadCost = hotwordReloadCost
|
||||
}
|
||||
|
||||
/// Qwen3 MLX via mlx-swift-asr — `context` soft prompt on `transcribe`.
|
||||
public static let qwen3MLX = LocalASRCapabilities(
|
||||
hotwordMode: .promptOnly,
|
||||
maxHotwordCount: 0,
|
||||
maxPromptCharacters: 800,
|
||||
supportsStreaming: false,
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
|
||||
/// Apple Speech on macOS — no project-controlled hotword API today.
|
||||
public static let appleSpeech = LocalASRCapabilities(
|
||||
hotwordMode: .none,
|
||||
maxHotwordCount: 0,
|
||||
maxPromptCharacters: 0,
|
||||
supportsStreaming: false,
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
|
||||
/// Sherpa Qwen3 — hard hotwords via `--qwen3-asr-hotwords`.
|
||||
public static let sherpaQwen3 = LocalASRCapabilities(
|
||||
hotwordMode: .recognizerScoped,
|
||||
maxHotwordCount: 100,
|
||||
maxPromptCharacters: 0,
|
||||
supportsStreaming: false,
|
||||
hotwordReloadCost: .recognizerReload
|
||||
)
|
||||
|
||||
/// Sherpa SenseVoice — fast Chinese baseline without hotwords.
|
||||
public static let sherpaSenseVoice = LocalASRCapabilities(
|
||||
hotwordMode: .none,
|
||||
maxHotwordCount: 0,
|
||||
maxPromptCharacters: 0,
|
||||
supportsStreaming: false,
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
|
||||
/// FunASR Paraformer (Sherpa offline) — no project hotword API.
|
||||
public static let sherpaParaformer = LocalASRCapabilities(
|
||||
hotwordMode: .none,
|
||||
maxHotwordCount: 0,
|
||||
maxPromptCharacters: 0,
|
||||
supportsStreaming: false,
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// LocalASRModelCatalog.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Bundled catalog of downloadable / manual local ASR models and Sherpa runtimes.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum LocalASRModelBackend: String, Codable, Sendable, Equatable {
|
||||
case mlx
|
||||
case sherpaQwen3
|
||||
case sherpaSenseVoice
|
||||
case sherpaParaformer
|
||||
case appleSpeech
|
||||
}
|
||||
|
||||
public enum LocalASRInstallKind: String, Codable, Sendable, Equatable {
|
||||
case manual
|
||||
case archive
|
||||
/// Multi-file install from a remote repository (ModelScope / HuggingFace file API).
|
||||
case repository
|
||||
case runtime
|
||||
}
|
||||
|
||||
public struct LocalASRDownloadFile: Codable, Sendable, Equatable {
|
||||
public let remotePath: String
|
||||
public let localPath: String
|
||||
public let sizeBytes: Int?
|
||||
}
|
||||
|
||||
public struct LocalASRDownloadSource: Codable, Sendable, Equatable {
|
||||
public let type: String
|
||||
public let priority: Int
|
||||
/// Full URL for a single archive download.
|
||||
public let url: String
|
||||
/// Base URL template for repository installs; must contain `{path}`.
|
||||
public let baseURL: String?
|
||||
public let files: [LocalASRDownloadFile]?
|
||||
|
||||
public var isRepository: Bool {
|
||||
guard let files, !files.isEmpty else { return false }
|
||||
return baseURL?.contains("{path}") == true
|
||||
}
|
||||
|
||||
public var isArchive: Bool {
|
||||
!url.isEmpty && !isRepository
|
||||
}
|
||||
}
|
||||
|
||||
public struct LocalASRModelLayout: Codable, Sendable, Equatable {
|
||||
public var convFrontend: String?
|
||||
public var encoder: String?
|
||||
public var decoder: String?
|
||||
public var tokenizer: String?
|
||||
public var senseVoiceModel: String?
|
||||
public var paraformerModel: String?
|
||||
public var tokens: String?
|
||||
}
|
||||
|
||||
public struct LocalASRRuntimeDefinition: Codable, Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let displayName: String
|
||||
public let installRelativePath: String
|
||||
public let binaryCandidates: [String]
|
||||
public let archiveFileName: String
|
||||
public let sizeBytes: Int
|
||||
public let platform: String
|
||||
public let sources: [LocalASRDownloadSource]
|
||||
}
|
||||
|
||||
public struct LocalASRModelDefinition: Codable, Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let displayName: String
|
||||
public let backend: LocalASRModelBackend
|
||||
public let sizeBytes: Int
|
||||
public let recommendedLocales: [String]
|
||||
public let supportsHotwords: Bool
|
||||
public let hotwordMode: LocalASRHotwordMode
|
||||
public let installKind: LocalASRInstallKind
|
||||
public let installRelativePath: String?
|
||||
public let archiveBaseName: String?
|
||||
public let layout: LocalASRModelLayout?
|
||||
public let requiredRelativeFiles: [String]?
|
||||
public let runtimePlatform: String?
|
||||
public let sources: [LocalASRDownloadSource]?
|
||||
}
|
||||
|
||||
public struct LocalASRCatalogDocument: Codable, Sendable, Equatable {
|
||||
public let schemaVersion: Int
|
||||
public let defaultModelId: String
|
||||
public let runtimes: [LocalASRRuntimeDefinition]
|
||||
public let models: [LocalASRModelDefinition]
|
||||
}
|
||||
|
||||
public enum LocalASRModelCatalog {
|
||||
|
||||
public static func loadBundled() throws -> LocalASRCatalogDocument {
|
||||
let bundle = Bundle(for: LocalASRCatalogBundleToken.self)
|
||||
guard let url = bundle.url(forResource: "local-asr-catalog", withExtension: "json") else {
|
||||
throw LocalASRModelCatalogError.missingBundledCatalog
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
return try JSONDecoder().decode(LocalASRCatalogDocument.self, from: data)
|
||||
}
|
||||
|
||||
public static func model(_ id: String, in catalog: LocalASRCatalogDocument) -> LocalASRModelDefinition? {
|
||||
catalog.models.first { $0.id == id }
|
||||
}
|
||||
|
||||
public static func capabilities(for model: LocalASRModelDefinition) -> LocalASRCapabilities {
|
||||
switch model.backend {
|
||||
case .mlx:
|
||||
return .qwen3MLX
|
||||
case .sherpaQwen3:
|
||||
return .sherpaQwen3
|
||||
case .sherpaSenseVoice:
|
||||
return .sherpaSenseVoice
|
||||
case .sherpaParaformer:
|
||||
return .sherpaParaformer
|
||||
case .appleSpeech:
|
||||
return .appleSpeech
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
public static func runtime(for platform: String, in catalog: LocalASRCatalogDocument) -> LocalASRRuntimeDefinition? {
|
||||
if platform == "macos-arm64" {
|
||||
return catalog.runtimes.first { $0.platform == "macos-arm64" }
|
||||
}
|
||||
if platform == "macos-x64" {
|
||||
return catalog.runtimes.first { $0.platform == "macos-x64" }
|
||||
}
|
||||
return catalog.runtimes.first
|
||||
}
|
||||
|
||||
public static func currentRuntimePlatform() -> String {
|
||||
#if arch(arm64)
|
||||
return "macos-arm64"
|
||||
#else
|
||||
return "macos-x64"
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Region-aware ordering for local ASR model download mirrors.
|
||||
public enum LocalASRDownloadSourceSorter {
|
||||
|
||||
/// `true` when the system region is mainland China (`CN`).
|
||||
public static func isChinaMainland(region: Locale.Region? = Locale.current.region) -> Bool {
|
||||
region?.identifier == "CN"
|
||||
}
|
||||
|
||||
/// Lower rank = tried earlier. CN: ModelScope → HuggingFace → GitHub; elsewhere: HF → GitHub → ModelScope.
|
||||
public static func typeRank(_ type: String, chinaFirst: Bool) -> Int {
|
||||
switch type.lowercased() {
|
||||
case "modelscope":
|
||||
return chinaFirst ? 0 : 2
|
||||
case "huggingface":
|
||||
return chinaFirst ? 1 : 0
|
||||
case "github":
|
||||
return 1
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
public static func sorted(
|
||||
_ sources: [LocalASRDownloadSource],
|
||||
region: Locale.Region? = Locale.current.region
|
||||
) -> [LocalASRDownloadSource] {
|
||||
let chinaFirst = isChinaMainland(region: region)
|
||||
return sources.sorted { lhs, rhs in
|
||||
let leftRank = typeRank(lhs.type, chinaFirst: chinaFirst)
|
||||
let rightRank = typeRank(rhs.type, chinaFirst: chinaFirst)
|
||||
if leftRank != rightRank { return leftRank < rightRank }
|
||||
return lhs.priority < rhs.priority
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum LocalASRModelCatalogError: Error, LocalizedError {
|
||||
case missingBundledCatalog
|
||||
case modelNotFound(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingBundledCatalog:
|
||||
return "Missing bundled local ASR catalog."
|
||||
case .modelNotFound(let id):
|
||||
return "Local ASR model not found: \(id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class LocalASRCatalogBundleToken {}
|
||||
@@ -0,0 +1,45 @@
|
||||
// MicVoiceAvailability+Keyboard.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Derives keyboard mic availability from pipeline phase and host readiness.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum MicVoiceAvailabilityResolver {
|
||||
public static func resolve(
|
||||
phase: KeyboardState.Phase,
|
||||
micDisabled: Bool,
|
||||
hasFullAccess: Bool,
|
||||
appGroupAvailable: Bool,
|
||||
hostReady: Bool,
|
||||
isPreparingSession: Bool
|
||||
) -> MicVoiceAvailability {
|
||||
switch phase {
|
||||
case .recording:
|
||||
return .recording
|
||||
case .processing, .requestingPermissions:
|
||||
return .processing
|
||||
case .error, .denied:
|
||||
return .unavailable(.hostNotReady)
|
||||
case .idle:
|
||||
break
|
||||
}
|
||||
|
||||
if !appGroupAvailable {
|
||||
return .unavailable(.appGroupUnavailable)
|
||||
}
|
||||
if !hasFullAccess {
|
||||
return .unavailable(.noFullAccess)
|
||||
}
|
||||
if micDisabled {
|
||||
return .unavailable(.missingAPIKey)
|
||||
}
|
||||
if isPreparingSession {
|
||||
return .unavailable(.preparingSession)
|
||||
}
|
||||
if hostReady {
|
||||
return .ready
|
||||
}
|
||||
return .unavailable(.hostNotReady)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// MicVoiceAvailability.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single source of truth for keyboard mic color, hint text, and tap behavior.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Whether the keyboard mic can start a Flow utterance right now.
|
||||
public enum MicVoiceAvailability: Equatable, Sendable {
|
||||
/// Green — tap records immediately without opening the host app.
|
||||
case ready
|
||||
/// Orange — voice input blocked; see `Reason` for hint copy.
|
||||
case unavailable(Reason)
|
||||
/// Red — user is actively recording.
|
||||
case recording
|
||||
/// White — waiting for ASR / cloud polish after stop.
|
||||
case processing
|
||||
|
||||
public enum Reason: Equatable, Sendable {
|
||||
case missingAPIKey
|
||||
case hostNotReady
|
||||
case noFullAccess
|
||||
case appGroupUnavailable
|
||||
/// User tapped mic; host app jump in progress, awaiting ready contract.
|
||||
case preparingSession
|
||||
}
|
||||
|
||||
public var isReady: Bool {
|
||||
if case .ready = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
public var isUnavailable: Bool {
|
||||
if case .unavailable = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -111,4 +111,29 @@ extension PersonalDictionary {
|
||||
if hasNonASCII { return "zh" }
|
||||
return "en"
|
||||
}
|
||||
|
||||
/// Alias → canonical term pairs for deterministic post-ASR correction.
|
||||
/// Sorted longest-alias-first by the caller (`LocalASRTranscriptCorrector`).
|
||||
public func localCorrectionPairs() -> [LocalASRCorrectionPair] {
|
||||
var seen = Set<String>()
|
||||
var pairs: [LocalASRCorrectionPair] = []
|
||||
for entry in effectiveEntries {
|
||||
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !term.isEmpty else { continue }
|
||||
for alias in entry.aliases {
|
||||
let trimmed = alias.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
guard trimmed.caseInsensitiveCompare(term) != .orderedSame else { continue }
|
||||
let key = "\(trimmed.lowercased())|\(term.lowercased())"
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
pairs.append(LocalASRCorrectionPair(alias: trimmed, term: term))
|
||||
}
|
||||
}
|
||||
return pairs.sorted { lhs, rhs in
|
||||
if lhs.alias.count != rhs.alias.count {
|
||||
return lhs.alias.count > rhs.alias.count
|
||||
}
|
||||
return lhs.alias.localizedCaseInsensitiveCompare(rhs.alias) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,36 @@
|
||||
import Foundation
|
||||
|
||||
extension PersonalDictionary {
|
||||
public static let kvsKeyV2 = "personalDictionary.v2"
|
||||
public static let legacyKVSKey = "personalDictionary.v1"
|
||||
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
|
||||
|
||||
/// Merges two dictionary snapshots for cross-device sync.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Apply `clearedAt` and deletion tombstones before entry union.
|
||||
/// - Same `id`: keep the entry with the newer `updatedAt`.
|
||||
/// - Same canonical term (case-insensitive) but different `id`: union
|
||||
/// aliases, take max `usageCount`, keep the newer entry's fields.
|
||||
public static func merge(local: PersonalDictionary, remote: PersonalDictionary) -> PersonalDictionary {
|
||||
let clearedAt = later(of: local.clearedAt, and: remote.clearedAt)
|
||||
var deletedIDs = local.deletedEntryIDs
|
||||
for (id, date) in remote.deletedEntryIDs {
|
||||
if let existing = deletedIDs[id] {
|
||||
deletedIDs[id] = max(existing, date)
|
||||
} else {
|
||||
deletedIDs[id] = date
|
||||
}
|
||||
}
|
||||
deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt)
|
||||
|
||||
var mergedByID: [UUID: Entry] = [:]
|
||||
var canonicalOwner: [String: UUID] = [:]
|
||||
|
||||
func insertOrMerge(_ candidate: Entry) {
|
||||
if deletedIDs[candidate.id] != nil { return }
|
||||
if let clearedAt, candidate.createdAt <= clearedAt { return }
|
||||
|
||||
let key = candidate.term.lowercased()
|
||||
if let existingID = canonicalOwner[key], var existing = mergedByID[existingID] {
|
||||
if candidate.id == existingID {
|
||||
@@ -56,10 +75,51 @@ extension PersonalDictionary {
|
||||
return PersonalDictionary(
|
||||
entries: mergedEntries,
|
||||
version: max(local.version, remote.version) + 1,
|
||||
lastSyncedAt: lastSyncedAt
|
||||
lastSyncedAt: lastSyncedAt,
|
||||
deletedEntryIDs: deletedIDs,
|
||||
clearedAt: clearedAt
|
||||
)
|
||||
}
|
||||
|
||||
public mutating func recordDeletion(of entryID: UUID, at date: Date = Date()) {
|
||||
deletedEntryIDs[entryID] = date
|
||||
entries.removeAll { $0.id == entryID }
|
||||
}
|
||||
|
||||
public mutating func recordClearAll(at date: Date = Date()) {
|
||||
entries.removeAll()
|
||||
clearedAt = date
|
||||
}
|
||||
|
||||
public mutating func pruneTombstonesIfNeeded() {
|
||||
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
|
||||
}
|
||||
|
||||
private static func pruneTombstones(
|
||||
_ tombstones: [UUID: Date],
|
||||
clearedAt: Date?
|
||||
) -> [UUID: Date] {
|
||||
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
|
||||
return tombstones.filter { _, deletedAt in
|
||||
if deletedAt < cutoff { return false }
|
||||
if let clearedAt, deletedAt <= clearedAt { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
|
||||
switch (lhs, rhs) {
|
||||
case let (left?, right?):
|
||||
return max(left, right)
|
||||
case (nil, let right?):
|
||||
return right
|
||||
case (let left?, nil):
|
||||
return left
|
||||
case (nil, nil):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveEntryConflict(existing: Entry, incoming: Entry) -> Entry {
|
||||
incoming.updatedAt >= existing.updatedAt ? incoming : existing
|
||||
}
|
||||
|
||||
@@ -23,17 +23,31 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
public var version: Int
|
||||
/// When this dictionary blob was last successfully pushed to iCloud KVS.
|
||||
public var lastSyncedAt: Date?
|
||||
/// Tombstones for deleted entries — prevents remote resurrections.
|
||||
public var deletedEntryIDs: [UUID: Date]
|
||||
/// When set, entries created at or before this instant are excluded from merge.
|
||||
public var clearedAt: Date?
|
||||
|
||||
public init(entries: [Entry] = [], version: Int = 1, lastSyncedAt: Date? = nil) {
|
||||
public init(
|
||||
entries: [Entry] = [],
|
||||
version: Int = 1,
|
||||
lastSyncedAt: Date? = nil,
|
||||
deletedEntryIDs: [UUID: Date] = [:],
|
||||
clearedAt: Date? = nil
|
||||
) {
|
||||
self.entries = entries
|
||||
self.version = version
|
||||
self.lastSyncedAt = lastSyncedAt
|
||||
self.deletedEntryIDs = deletedEntryIDs
|
||||
self.clearedAt = clearedAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case entries
|
||||
case version
|
||||
case lastSyncedAt
|
||||
case deletedEntryIDs
|
||||
case clearedAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
@@ -41,6 +55,8 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
entries = try container.decodeIfPresent([Entry].self, forKey: .entries) ?? []
|
||||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||
lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt)
|
||||
deletedEntryIDs = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) ?? [:]
|
||||
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
@@ -48,6 +64,10 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
try container.encode(entries, forKey: .entries)
|
||||
try container.encode(version, forKey: .version)
|
||||
try container.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt)
|
||||
if !deletedEntryIDs.isEmpty {
|
||||
try container.encode(deletedEntryIDs, forKey: .deletedEntryIDs)
|
||||
}
|
||||
try container.encodeIfPresent(clearedAt, forKey: .clearedAt)
|
||||
}
|
||||
|
||||
public struct Entry: Codable, Sendable, Equatable, Identifiable {
|
||||
|
||||
@@ -24,6 +24,10 @@ public struct PolishContext: Sendable {
|
||||
/// bias terminology choices.
|
||||
public let precedingText: String?
|
||||
|
||||
/// Extra dictionary block appended after `PersonalDictionary.promptFragment()`
|
||||
/// (e.g. builtin `phrases.tsv` terms on macOS local ASR).
|
||||
public let dictionarySupplement: String?
|
||||
|
||||
/// Cap on how many characters of `precedingText` we actually
|
||||
/// include in the prompt. The full preceding text is often
|
||||
/// hundreds of KB in a long note — we only need the tail.
|
||||
@@ -33,11 +37,13 @@ public struct PolishContext: Sendable {
|
||||
appContext: AppContext = .unknown,
|
||||
intensity: PolishIntensity = .default,
|
||||
precedingText: String? = nil,
|
||||
dictionarySupplement: String? = nil,
|
||||
maxPrecedingChars: Int = 500
|
||||
) {
|
||||
self.appContext = appContext
|
||||
self.intensity = intensity
|
||||
self.precedingText = precedingText
|
||||
self.dictionarySupplement = dictionarySupplement
|
||||
self.maxPrecedingChars = maxPrecedingChars
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,11 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
didSet {
|
||||
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
|
||||
do {
|
||||
try Keychain.setAPIKey(apiKey, for: providerId)
|
||||
try Keychain.setAPIKey(
|
||||
apiKey,
|
||||
for: providerId,
|
||||
useICloudSync: configuration.settingsICloudSyncEnabled
|
||||
)
|
||||
} catch {
|
||||
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -70,7 +74,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||
configuration.engineMode = engineMode
|
||||
applyEngineModeSideEffects()
|
||||
persistConfiguration()
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
@Published public var hasCompletedOnboarding: Bool {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// ProviderLogo.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Maps a provider id to its asset-catalog logo name. Shared by the iOS
|
||||
// app and the macOS menu-bar app so both show identical brand marks.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ProviderLogo {
|
||||
/// Asset name for the provider's logo, or `nil` when there is no bundled logo.
|
||||
public static func assetName(for providerId: String) -> String? {
|
||||
switch providerId {
|
||||
case "openai": return "openai"
|
||||
case "deepseek": return "deepseek"
|
||||
case "qwen": return "qwen"
|
||||
case "moonshot": return "moonshot"
|
||||
case "zhipu": return "zhipu"
|
||||
case "mimo": return "mimo"
|
||||
case "apple": return "apple"
|
||||
case "custom": return "custom"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SpeechHistoryEntry.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// A single voice transcription in the cross-device history log.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
public let id: UUID
|
||||
public let text: String
|
||||
public let createdAt: Date
|
||||
/// iOS Flow engine mode; nil on macOS captures.
|
||||
public let engineMode: String?
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
text: String,
|
||||
createdAt: Date = Date(),
|
||||
engineMode: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.createdAt = createdAt
|
||||
self.engineMode = engineMode
|
||||
}
|
||||
|
||||
/// First-line preview for compact list rows (macOS history sidebar).
|
||||
public var previewTitle: String {
|
||||
let firstLine = text.split(separator: "\n").first.map(String.init) ?? text
|
||||
return firstLine.count > 36 ? String(firstLine.prefix(36)) + "…" : firstLine
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// SyncedAppSettings.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// User-facing app settings mirrored through iCloud KVS. Excludes
|
||||
// device-local state (onboarding progress, detected app context,
|
||||
// personal dictionary blob, and API keys in Keychain).
|
||||
// Legacy v1 settings blob (read-only migration input). New sync uses
|
||||
// `SyncedAppSettingsV2`. API keys never belong in KVS payloads.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -23,6 +22,8 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
|
||||
public var polishIntensity: PolishIntensity
|
||||
public var flowSkipAppSwitch: Bool
|
||||
public var flowInactivityDuration: FlowInactivityDuration
|
||||
/// Deprecated — decoded for backward compatibility only; never applied.
|
||||
public var providerAPIKeys: [String: String]
|
||||
|
||||
public init(
|
||||
updatedAt: Date = Date(),
|
||||
@@ -39,7 +40,8 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
|
||||
cursorDragNavigationEnabled: Bool,
|
||||
polishIntensity: PolishIntensity,
|
||||
flowSkipAppSwitch: Bool,
|
||||
flowInactivityDuration: FlowInactivityDuration
|
||||
flowInactivityDuration: FlowInactivityDuration,
|
||||
providerAPIKeys: [String: String] = [:]
|
||||
) {
|
||||
self.updatedAt = updatedAt
|
||||
self.providerId = providerId
|
||||
@@ -56,11 +58,51 @@ public struct SyncedAppSettings: Codable, Sendable, Equatable {
|
||||
self.polishIntensity = polishIntensity
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
self.providerAPIKeys = providerAPIKeys
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
providerId = try container.decode(String.self, forKey: .providerId)
|
||||
baseURL = try container.decode(String.self, forKey: .baseURL)
|
||||
model = try container.decode(String.self, forKey: .model)
|
||||
modeId = try container.decode(String.self, forKey: .modeId)
|
||||
localeId = try container.decode(String.self, forKey: .localeId)
|
||||
engineMode = try container.decode(String.self, forKey: .engineMode)
|
||||
hasAcknowledgedCloudSharing = try container.decode(Bool.self, forKey: .hasAcknowledgedCloudSharing)
|
||||
uiLanguage = try container.decode(AppUILanguage.self, forKey: .uiLanguage)
|
||||
translationTargetLocaleId = try container.decode(String.self, forKey: .translationTargetLocaleId)
|
||||
handednessPreference = try container.decode(HandednessPreference.self, forKey: .handednessPreference)
|
||||
cursorDragNavigationEnabled = try container.decode(Bool.self, forKey: .cursorDragNavigationEnabled)
|
||||
polishIntensity = try container.decode(PolishIntensity.self, forKey: .polishIntensity)
|
||||
flowSkipAppSwitch = try container.decode(Bool.self, forKey: .flowSkipAppSwitch)
|
||||
flowInactivityDuration = try container.decode(FlowInactivityDuration.self, forKey: .flowInactivityDuration)
|
||||
providerAPIKeys = try container.decodeIfPresent([String: String].self, forKey: .providerAPIKeys) ?? [:]
|
||||
}
|
||||
|
||||
/// Apply legacy scalar fields only — never touches Keychain.
|
||||
func applyingScalars(to configuration: inout AppGroupConfiguration) {
|
||||
configuration.providerId = providerId
|
||||
configuration.baseURL = baseURL
|
||||
configuration.model = model
|
||||
configuration.modeId = modeId
|
||||
configuration.localeId = localeId
|
||||
configuration.engineMode = engineMode
|
||||
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||
configuration.uiLanguage = uiLanguage
|
||||
configuration.translationTargetLocaleId = translationTargetLocaleId
|
||||
configuration.handednessPreference = handednessPreference
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
configuration.polishIntensity = polishIntensity
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
configuration.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
}
|
||||
|
||||
public extension SyncedAppSettings {
|
||||
/// Build a cloud payload from the current App Group configuration.
|
||||
static let legacyKVSKey = "appSettings.v1"
|
||||
|
||||
static func from(configuration: AppGroupConfiguration, updatedAt: Date = Date()) -> SyncedAppSettings {
|
||||
SyncedAppSettings(
|
||||
updatedAt: updatedAt,
|
||||
@@ -80,28 +122,4 @@ public extension SyncedAppSettings {
|
||||
flowInactivityDuration: configuration.flowInactivityDuration
|
||||
)
|
||||
}
|
||||
|
||||
/// Apply syncable fields onto a configuration, preserving device-local
|
||||
/// fields such as onboarding progress and the personal dictionary.
|
||||
func applying(to configuration: inout AppGroupConfiguration) {
|
||||
configuration.providerId = providerId
|
||||
configuration.baseURL = baseURL
|
||||
configuration.model = model
|
||||
configuration.modeId = modeId
|
||||
configuration.localeId = localeId
|
||||
configuration.engineMode = engineMode
|
||||
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||
configuration.uiLanguage = uiLanguage
|
||||
configuration.translationTargetLocaleId = translationTargetLocaleId
|
||||
configuration.handednessPreference = handednessPreference
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
configuration.polishIntensity = polishIntensity
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
configuration.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
|
||||
/// Last-write-wins merge for whole settings blobs.
|
||||
static func merge(local: SyncedAppSettings, remote: SyncedAppSettings) -> SyncedAppSettings {
|
||||
remote.updatedAt >= local.updatedAt ? remote : local
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// SyncedAppSettingsV2.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Versioned settings payload with per-field merge metadata. API keys are
|
||||
// intentionally excluded — they sync through iCloud Keychain.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 2
|
||||
public static let kvsKey = "appSettings.v2"
|
||||
|
||||
public var schemaVersion: Int
|
||||
public var providerId: SyncedField<String>
|
||||
public var baseURL: SyncedField<String>
|
||||
public var model: SyncedField<String>
|
||||
public var modeId: SyncedField<String>
|
||||
public var localeId: SyncedField<String>
|
||||
public var engineMode: SyncedField<String>
|
||||
public var hasAcknowledgedCloudSharing: SyncedField<Bool>
|
||||
public var uiLanguage: SyncedField<AppUILanguage>
|
||||
public var translationTargetLocaleId: SyncedField<String>
|
||||
public var handednessPreference: SyncedField<HandednessPreference>
|
||||
public var cursorDragNavigationEnabled: SyncedField<Bool>
|
||||
public var polishIntensity: SyncedField<PolishIntensity>
|
||||
public var flowSkipAppSwitch: SyncedField<Bool>
|
||||
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = Self.schemaVersion,
|
||||
providerId: SyncedField<String>,
|
||||
baseURL: SyncedField<String>,
|
||||
model: SyncedField<String>,
|
||||
modeId: SyncedField<String>,
|
||||
localeId: SyncedField<String>,
|
||||
engineMode: SyncedField<String>,
|
||||
hasAcknowledgedCloudSharing: SyncedField<Bool>,
|
||||
uiLanguage: SyncedField<AppUILanguage>,
|
||||
translationTargetLocaleId: SyncedField<String>,
|
||||
handednessPreference: SyncedField<HandednessPreference>,
|
||||
cursorDragNavigationEnabled: SyncedField<Bool>,
|
||||
polishIntensity: SyncedField<PolishIntensity>,
|
||||
flowSkipAppSwitch: SyncedField<Bool>,
|
||||
flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.providerId = providerId
|
||||
self.baseURL = baseURL
|
||||
self.model = model
|
||||
self.modeId = modeId
|
||||
self.localeId = localeId
|
||||
self.engineMode = engineMode
|
||||
self.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||
self.uiLanguage = uiLanguage
|
||||
self.translationTargetLocaleId = translationTargetLocaleId
|
||||
self.handednessPreference = handednessPreference
|
||||
self.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
self.polishIntensity = polishIntensity
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
|
||||
/// Monotonic stamp used for `settingsCloudUpdatedAt` bookkeeping.
|
||||
public var latestUpdatedAt: Date {
|
||||
[
|
||||
providerId.updatedAt,
|
||||
baseURL.updatedAt,
|
||||
model.updatedAt,
|
||||
modeId.updatedAt,
|
||||
localeId.updatedAt,
|
||||
engineMode.updatedAt,
|
||||
hasAcknowledgedCloudSharing.updatedAt,
|
||||
uiLanguage.updatedAt,
|
||||
translationTargetLocaleId.updatedAt,
|
||||
handednessPreference.updatedAt,
|
||||
cursorDragNavigationEnabled.updatedAt,
|
||||
polishIntensity.updatedAt,
|
||||
flowSkipAppSwitch.updatedAt,
|
||||
flowInactivityDuration.updatedAt,
|
||||
].max() ?? .distantPast
|
||||
}
|
||||
}
|
||||
|
||||
public extension SyncedAppSettingsV2 {
|
||||
static func from(configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
|
||||
seeded(from: configuration, deviceID: deviceID, updatedAt: Date())
|
||||
}
|
||||
|
||||
/// Build a payload from configuration using one shared timestamp (for merge bookkeeping).
|
||||
static func seeded(
|
||||
from configuration: AppGroupConfiguration,
|
||||
deviceID: String,
|
||||
updatedAt: Date
|
||||
) -> SyncedAppSettingsV2 {
|
||||
func field<T>(_ value: T) -> SyncedField<T> {
|
||||
SyncedField(value: value, updatedAt: updatedAt, deviceID: deviceID)
|
||||
}
|
||||
return SyncedAppSettingsV2(
|
||||
providerId: field(configuration.providerId),
|
||||
baseURL: field(configuration.baseURL),
|
||||
model: field(configuration.model),
|
||||
modeId: field(configuration.modeId),
|
||||
localeId: field(configuration.localeId),
|
||||
engineMode: field(configuration.engineMode),
|
||||
hasAcknowledgedCloudSharing: field(configuration.hasAcknowledgedCloudSharing),
|
||||
uiLanguage: field(configuration.uiLanguage),
|
||||
translationTargetLocaleId: field(configuration.translationTargetLocaleId),
|
||||
handednessPreference: field(configuration.handednessPreference),
|
||||
cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled),
|
||||
polishIntensity: field(configuration.polishIntensity),
|
||||
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(configuration.flowInactivityDuration)
|
||||
)
|
||||
}
|
||||
|
||||
/// Upgrade a legacy v1 blob into per-field metadata on this device.
|
||||
static func migrated(from legacy: SyncedAppSettings, deviceID: String) -> SyncedAppSettingsV2 {
|
||||
let stamp = legacy.updatedAt
|
||||
func field<T>(_ value: T) -> SyncedField<T> {
|
||||
SyncedField(value: value, updatedAt: stamp, deviceID: deviceID)
|
||||
}
|
||||
return SyncedAppSettingsV2(
|
||||
providerId: field(legacy.providerId),
|
||||
baseURL: field(legacy.baseURL),
|
||||
model: field(legacy.model),
|
||||
modeId: field(legacy.modeId),
|
||||
localeId: field(legacy.localeId),
|
||||
engineMode: field(legacy.engineMode),
|
||||
hasAcknowledgedCloudSharing: field(legacy.hasAcknowledgedCloudSharing),
|
||||
uiLanguage: field(legacy.uiLanguage),
|
||||
translationTargetLocaleId: field(legacy.translationTargetLocaleId),
|
||||
handednessPreference: field(legacy.handednessPreference),
|
||||
cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled),
|
||||
polishIntensity: field(legacy.polishIntensity),
|
||||
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(legacy.flowInactivityDuration)
|
||||
)
|
||||
}
|
||||
|
||||
static func merge(local: SyncedAppSettingsV2, remote: SyncedAppSettingsV2) -> SyncedAppSettingsV2 {
|
||||
SyncedAppSettingsV2(
|
||||
providerId: .merge(local: local.providerId, remote: remote.providerId),
|
||||
baseURL: .merge(local: local.baseURL, remote: remote.baseURL),
|
||||
model: .merge(local: local.model, remote: remote.model),
|
||||
modeId: .merge(local: local.modeId, remote: remote.modeId),
|
||||
localeId: .merge(local: local.localeId, remote: remote.localeId),
|
||||
engineMode: .merge(local: local.engineMode, remote: remote.engineMode),
|
||||
hasAcknowledgedCloudSharing: .merge(
|
||||
local: local.hasAcknowledgedCloudSharing,
|
||||
remote: remote.hasAcknowledgedCloudSharing
|
||||
),
|
||||
uiLanguage: .merge(local: local.uiLanguage, remote: remote.uiLanguage),
|
||||
translationTargetLocaleId: .merge(
|
||||
local: local.translationTargetLocaleId,
|
||||
remote: remote.translationTargetLocaleId
|
||||
),
|
||||
handednessPreference: .merge(local: local.handednessPreference, remote: remote.handednessPreference),
|
||||
cursorDragNavigationEnabled: .merge(
|
||||
local: local.cursorDragNavigationEnabled,
|
||||
remote: remote.cursorDragNavigationEnabled
|
||||
),
|
||||
polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity),
|
||||
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
|
||||
flowInactivityDuration: .merge(
|
||||
local: local.flowInactivityDuration,
|
||||
remote: remote.flowInactivityDuration
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func applying(to configuration: inout AppGroupConfiguration) {
|
||||
configuration.providerId = providerId.value
|
||||
configuration.baseURL = baseURL.value
|
||||
configuration.model = model.value
|
||||
configuration.modeId = modeId.value
|
||||
configuration.localeId = localeId.value
|
||||
configuration.engineMode = engineMode.value
|
||||
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing.value
|
||||
configuration.uiLanguage = uiLanguage.value
|
||||
configuration.translationTargetLocaleId = translationTargetLocaleId.value
|
||||
configuration.handednessPreference = handednessPreference.value
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value
|
||||
configuration.polishIntensity = polishIntensity.value
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
|
||||
configuration.flowInactivityDuration = flowInactivityDuration.value
|
||||
}
|
||||
|
||||
/// Stamp fields whose values differ from `configuration` with this device id.
|
||||
func patchLocalChanges(from configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
|
||||
var copy = self
|
||||
func patch<T: Equatable>(_ field: inout SyncedField<T>, value: T) {
|
||||
guard field.value != value else { return }
|
||||
field = .make(value: value, deviceID: deviceID)
|
||||
}
|
||||
patch(©.providerId, value: configuration.providerId)
|
||||
patch(©.baseURL, value: configuration.baseURL)
|
||||
patch(©.model, value: configuration.model)
|
||||
patch(©.modeId, value: configuration.modeId)
|
||||
patch(©.localeId, value: configuration.localeId)
|
||||
patch(©.engineMode, value: configuration.engineMode)
|
||||
patch(©.hasAcknowledgedCloudSharing, value: configuration.hasAcknowledgedCloudSharing)
|
||||
patch(©.uiLanguage, value: configuration.uiLanguage)
|
||||
patch(©.translationTargetLocaleId, value: configuration.translationTargetLocaleId)
|
||||
patch(©.handednessPreference, value: configuration.handednessPreference)
|
||||
patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
patch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
}
|
||||
|
||||
/// Refresh only fields owned by `deviceID` from the current local configuration.
|
||||
func refreshedLocalFields(from configuration: AppGroupConfiguration, deviceID: String) -> SyncedAppSettingsV2 {
|
||||
var copy = self
|
||||
let now = Date()
|
||||
func touch<T>(_ field: inout SyncedField<T>, value: T) {
|
||||
guard field.deviceID == deviceID else { return }
|
||||
field.value = value
|
||||
field.updatedAt = now
|
||||
}
|
||||
touch(©.providerId, value: configuration.providerId)
|
||||
touch(©.baseURL, value: configuration.baseURL)
|
||||
touch(©.model, value: configuration.model)
|
||||
touch(©.modeId, value: configuration.modeId)
|
||||
touch(©.localeId, value: configuration.localeId)
|
||||
touch(©.engineMode, value: configuration.engineMode)
|
||||
touch(©.hasAcknowledgedCloudSharing, value: configuration.hasAcknowledgedCloudSharing)
|
||||
touch(©.uiLanguage, value: configuration.uiLanguage)
|
||||
touch(©.translationTargetLocaleId, value: configuration.translationTargetLocaleId)
|
||||
touch(©.handednessPreference, value: configuration.handednessPreference)
|
||||
touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||
touch(©.polishIntensity, value: configuration.polishIntensity)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SyncedField.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Per-field metadata for conflict-free settings merge across devices.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SyncedField<T: Codable & Equatable & Sendable>: Codable, Equatable, Sendable {
|
||||
public var value: T
|
||||
public var updatedAt: Date
|
||||
public var deviceID: String
|
||||
|
||||
public init(value: T, updatedAt: Date = Date(), deviceID: String) {
|
||||
self.value = value
|
||||
self.updatedAt = updatedAt
|
||||
self.deviceID = deviceID
|
||||
}
|
||||
|
||||
/// Pick the field with the newer `updatedAt`; ties break lexicographically on `deviceID`.
|
||||
public static func merge(local: SyncedField<T>, remote: SyncedField<T>) -> SyncedField<T> {
|
||||
if remote.updatedAt > local.updatedAt { return remote }
|
||||
if local.updatedAt > remote.updatedAt { return local }
|
||||
return remote.deviceID >= local.deviceID ? remote : local
|
||||
}
|
||||
|
||||
public static func make(value: T, deviceID: String) -> SyncedField<T> {
|
||||
SyncedField(value: value, updatedAt: Date(), deviceID: deviceID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// SyncedSpeechHistory.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// iCloud KVS payload for speech history. Tombstones and `clearedAt`
|
||||
// propagate single-entry deletes and "clear all" across devices.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 2
|
||||
public static let kvsKey = "speechHistory.v2"
|
||||
public static let legacyKVSKey = "speechHistory.v1"
|
||||
public static let maxEntries = 300
|
||||
/// Tombstones older than this window may be pruned during merge.
|
||||
public static let tombstoneRetention: TimeInterval = 90 * 24 * 60 * 60
|
||||
|
||||
public var schemaVersion: Int
|
||||
public var updatedAt: Date
|
||||
public var entries: [SpeechHistoryEntry]
|
||||
/// Entry IDs deleted on any device, with deletion timestamps.
|
||||
public var deletedEntryIDs: [UUID: Date]
|
||||
/// When set, entries created at or before this instant are excluded.
|
||||
public var clearedAt: Date?
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = Self.schemaVersion,
|
||||
updatedAt: Date = Date(),
|
||||
entries: [SpeechHistoryEntry] = [],
|
||||
deletedEntryIDs: [UUID: Date] = [:],
|
||||
clearedAt: Date? = nil
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.updatedAt = updatedAt
|
||||
self.entries = entries
|
||||
self.deletedEntryIDs = deletedEntryIDs
|
||||
self.clearedAt = clearedAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
|
||||
entries = try container.decodeIfPresent([SpeechHistoryEntry].self, forKey: .entries) ?? []
|
||||
if let map = try container.decodeIfPresent([UUID: Date].self, forKey: .deletedEntryIDs) {
|
||||
deletedEntryIDs = map
|
||||
} else if let legacyIDs = try container.decodeIfPresent([UUID].self, forKey: .deletedEntryIDs) {
|
||||
let stamp = Date()
|
||||
deletedEntryIDs = Dictionary(uniqueKeysWithValues: legacyIDs.map { ($0, stamp) })
|
||||
} else {
|
||||
deletedEntryIDs = [:]
|
||||
}
|
||||
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
|
||||
}
|
||||
|
||||
public static let empty = SyncedSpeechHistory(updatedAt: .distantPast)
|
||||
|
||||
/// Union entries by id (newer `createdAt` wins), apply tombstones and clear.
|
||||
public static func merge(local: SyncedSpeechHistory, remote: SyncedSpeechHistory) -> SyncedSpeechHistory {
|
||||
let clearedAt = later(of: local.clearedAt, and: remote.clearedAt)
|
||||
var deletedIDs = local.deletedEntryIDs
|
||||
for (id, date) in remote.deletedEntryIDs {
|
||||
if let existing = deletedIDs[id] {
|
||||
deletedIDs[id] = max(existing, date)
|
||||
} else {
|
||||
deletedIDs[id] = date
|
||||
}
|
||||
}
|
||||
deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt)
|
||||
|
||||
var byID: [UUID: SpeechHistoryEntry] = [:]
|
||||
for entry in local.entries + remote.entries {
|
||||
if deletedIDs[entry.id] != nil { continue }
|
||||
if let clearedAt, entry.createdAt <= clearedAt { continue }
|
||||
if let existing = byID[entry.id] {
|
||||
byID[entry.id] = entry.createdAt >= existing.createdAt ? entry : existing
|
||||
} else {
|
||||
byID[entry.id] = entry
|
||||
}
|
||||
}
|
||||
|
||||
var entries = Array(byID.values).sorted { $0.createdAt > $1.createdAt }
|
||||
if entries.count > maxEntries {
|
||||
entries = Array(entries.prefix(maxEntries))
|
||||
}
|
||||
|
||||
return SyncedSpeechHistory(
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
entries: entries,
|
||||
deletedEntryIDs: deletedIDs,
|
||||
clearedAt: clearedAt
|
||||
)
|
||||
}
|
||||
|
||||
/// Trim to the newest `maxEntries` rows (call after local-only appends).
|
||||
public mutating func trimEntries() {
|
||||
guard entries.count > Self.maxEntries else { return }
|
||||
entries = Array(entries.sorted { $0.createdAt > $1.createdAt }.prefix(Self.maxEntries))
|
||||
updatedAt = Date()
|
||||
}
|
||||
|
||||
public mutating func pruneTombstonesIfNeeded() {
|
||||
deletedEntryIDs = Self.pruneTombstones(deletedEntryIDs, clearedAt: clearedAt)
|
||||
}
|
||||
|
||||
private static func pruneTombstones(
|
||||
_ tombstones: [UUID: Date],
|
||||
clearedAt: Date?
|
||||
) -> [UUID: Date] {
|
||||
let cutoff = Date().addingTimeInterval(-tombstoneRetention)
|
||||
return tombstones.filter { _, deletedAt in
|
||||
if deletedAt < cutoff {
|
||||
return false
|
||||
}
|
||||
if let clearedAt, deletedAt <= clearedAt {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func later(of lhs: Date?, and rhs: Date?) -> Date? {
|
||||
switch (lhs, rhs) {
|
||||
case let (left?, right?):
|
||||
return max(left, right)
|
||||
case (nil, let right?):
|
||||
return right
|
||||
case (let left?, nil):
|
||||
return left
|
||||
case (nil, nil):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SyncedSpeechHistory {
|
||||
mutating func recordClearAll(at date: Date = Date()) {
|
||||
entries.removeAll()
|
||||
clearedAt = date
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// SyncedUsageStatisticsV2.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Per-device grow-only counters (G-Counter) for cumulative usage stats.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
|
||||
public var updatedAt: Date
|
||||
public var dictationDurationSeconds: TimeInterval
|
||||
public var dictationCharacterCount: Int
|
||||
public var translationCharacterCount: Int
|
||||
|
||||
public init(
|
||||
updatedAt: Date = Date(),
|
||||
dictationDurationSeconds: TimeInterval = 0,
|
||||
dictationCharacterCount: Int = 0,
|
||||
translationCharacterCount: Int = 0
|
||||
) {
|
||||
self.updatedAt = updatedAt
|
||||
self.dictationDurationSeconds = dictationDurationSeconds
|
||||
self.dictationCharacterCount = dictationCharacterCount
|
||||
self.translationCharacterCount = translationCharacterCount
|
||||
}
|
||||
|
||||
public static func merge(local: UsageStatisticsDeviceSlice, remote: UsageStatisticsDeviceSlice) -> UsageStatisticsDeviceSlice {
|
||||
UsageStatisticsDeviceSlice(
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
|
||||
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount),
|
||||
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount)
|
||||
)
|
||||
}
|
||||
|
||||
public var totals: UsageStatistics {
|
||||
UsageStatistics(
|
||||
updatedAt: updatedAt,
|
||||
dictationDurationSeconds: dictationDurationSeconds,
|
||||
dictationCharacterCount: dictationCharacterCount,
|
||||
translationCharacterCount: translationCharacterCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 2
|
||||
public static let kvsKey = "usageStatistics.v2"
|
||||
|
||||
public var schemaVersion: Int
|
||||
public var devices: [String: UsageStatisticsDeviceSlice]
|
||||
|
||||
public init(schemaVersion: Int = Self.schemaVersion, devices: [String: UsageStatisticsDeviceSlice] = [:]) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.devices = devices
|
||||
}
|
||||
|
||||
public static let empty = SyncedUsageStatisticsV2()
|
||||
|
||||
public var aggregated: UsageStatistics {
|
||||
var duration: TimeInterval = 0
|
||||
var dictation = 0
|
||||
var translation = 0
|
||||
var latest = Date.distantPast
|
||||
for slice in devices.values {
|
||||
duration += slice.dictationDurationSeconds
|
||||
dictation += slice.dictationCharacterCount
|
||||
translation += slice.translationCharacterCount
|
||||
latest = max(latest, slice.updatedAt)
|
||||
}
|
||||
return UsageStatistics(
|
||||
updatedAt: latest,
|
||||
dictationDurationSeconds: duration,
|
||||
dictationCharacterCount: dictation,
|
||||
translationCharacterCount: translation
|
||||
)
|
||||
}
|
||||
|
||||
public static func merge(local: SyncedUsageStatisticsV2, remote: SyncedUsageStatisticsV2) -> SyncedUsageStatisticsV2 {
|
||||
var mergedDevices = local.devices
|
||||
for (deviceID, remoteSlice) in remote.devices {
|
||||
if let localSlice = mergedDevices[deviceID] {
|
||||
mergedDevices[deviceID] = .merge(local: localSlice, remote: remoteSlice)
|
||||
} else {
|
||||
mergedDevices[deviceID] = remoteSlice
|
||||
}
|
||||
}
|
||||
return SyncedUsageStatisticsV2(devices: mergedDevices)
|
||||
}
|
||||
|
||||
public static func migrated(from legacy: UsageStatistics, deviceID: String) -> SyncedUsageStatisticsV2 {
|
||||
guard legacy != .zero else { return .empty }
|
||||
return SyncedUsageStatisticsV2(devices: [
|
||||
deviceID: UsageStatisticsDeviceSlice(
|
||||
updatedAt: legacy.updatedAt,
|
||||
dictationDurationSeconds: legacy.dictationDurationSeconds,
|
||||
dictationCharacterCount: legacy.dictationCharacterCount,
|
||||
translationCharacterCount: legacy.translationCharacterCount
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
public enum SyncedUsageStatisticsStorage {
|
||||
public static let storageKey = SyncedUsageStatisticsV2.kvsKey
|
||||
public static let legacyStorageKey = "usageStatistics.v1"
|
||||
|
||||
public static func load(from defaults: UserDefaults) -> SyncedUsageStatisticsV2 {
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let payload = try? JSONDecoder().decode(SyncedUsageStatisticsV2.self, from: data) {
|
||||
return payload
|
||||
}
|
||||
return migrateLegacyIfNeeded(into: defaults)
|
||||
}
|
||||
|
||||
public static func save(_ payload: SyncedUsageStatisticsV2, to defaults: UserDefaults) {
|
||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> SyncedUsageStatisticsV2 {
|
||||
let deviceID = SyncDeviceID.current(defaults: defaults)
|
||||
let legacy = UsageStatisticsStorage.migrateLegacyIfNeeded(into: defaults)
|
||||
let migrated = SyncedUsageStatisticsV2.migrated(from: legacy, deviceID: deviceID)
|
||||
if migrated != .empty {
|
||||
save(migrated, to: defaults)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
public static func currentDeviceSlice(
|
||||
from defaults: UserDefaults,
|
||||
deviceID: String? = nil
|
||||
) -> UsageStatisticsDeviceSlice {
|
||||
let id = deviceID ?? SyncDeviceID.current(defaults: defaults)
|
||||
return load(from: defaults).devices[id] ?? UsageStatisticsDeviceSlice()
|
||||
}
|
||||
|
||||
public static func upsertCurrentDeviceSlice(
|
||||
_ slice: UsageStatisticsDeviceSlice,
|
||||
defaults: UserDefaults,
|
||||
deviceID: String? = nil
|
||||
) {
|
||||
let id = deviceID ?? SyncDeviceID.current(defaults: defaults)
|
||||
var payload = load(from: defaults)
|
||||
payload.devices[id] = slice
|
||||
save(payload, to: defaults)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// UsageStatistics.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cumulative dictation metrics shown on the home / dashboard stats cards.
|
||||
// Mirrored through iCloud KVS when settings sync is enabled.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct UsageStatistics: Codable, Equatable, Sendable {
|
||||
public var updatedAt: Date
|
||||
public var dictationDurationSeconds: TimeInterval
|
||||
public var dictationCharacterCount: Int
|
||||
public var translationCharacterCount: Int
|
||||
|
||||
public init(
|
||||
updatedAt: Date = Date(),
|
||||
dictationDurationSeconds: TimeInterval = 0,
|
||||
dictationCharacterCount: Int = 0,
|
||||
translationCharacterCount: Int = 0
|
||||
) {
|
||||
self.updatedAt = updatedAt
|
||||
self.dictationDurationSeconds = dictationDurationSeconds
|
||||
self.dictationCharacterCount = dictationCharacterCount
|
||||
self.translationCharacterCount = translationCharacterCount
|
||||
}
|
||||
|
||||
public static let zero = UsageStatistics(updatedAt: .distantPast)
|
||||
|
||||
/// Combine lifetime totals from two devices. After merge, each device
|
||||
/// continues accumulating locally so `max` converges to the union.
|
||||
public static func merge(local: UsageStatistics, remote: UsageStatistics) -> UsageStatistics {
|
||||
UsageStatistics(
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
|
||||
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount),
|
||||
translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum UsageStatisticsStorage {
|
||||
public static let storageKey = "usageStatistics.v1"
|
||||
/// Legacy macOS dashboard counter (word split); migrated on first load.
|
||||
public static let legacyMacTotalWordsKey = "mac.totalWords"
|
||||
/// Pre–App Group iOS storage in `UserDefaults.standard`.
|
||||
public static let legacyStandardDefaultsKey = "usageStatistics.v1"
|
||||
|
||||
public static func load(from defaults: UserDefaults) -> UsageStatistics {
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let stats = try? JSONDecoder().decode(UsageStatistics.self, from: data) {
|
||||
return stats
|
||||
}
|
||||
return .zero
|
||||
}
|
||||
|
||||
public static func save(_ stats: UsageStatistics, to defaults: UserDefaults) {
|
||||
guard let data = try? JSONEncoder().encode(stats) else { return }
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
/// One-time imports from older per-platform keys.
|
||||
public static func migrateLegacyIfNeeded(into defaults: UserDefaults) -> UsageStatistics {
|
||||
var stats = load(from: defaults)
|
||||
guard stats == .zero else { return stats }
|
||||
|
||||
let legacyWords = defaults.integer(forKey: legacyMacTotalWordsKey)
|
||||
if legacyWords > 0 {
|
||||
stats.dictationCharacterCount = legacyWords
|
||||
stats.updatedAt = Date()
|
||||
save(stats, to: defaults)
|
||||
return stats
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if let data = UserDefaults.standard.data(forKey: legacyStandardDefaultsKey),
|
||||
let legacy = try? JSONDecoder().decode(UsageStatistics.self, from: data),
|
||||
legacy != .zero {
|
||||
stats = legacy
|
||||
stats.updatedAt = Date()
|
||||
save(stats, to: defaults)
|
||||
}
|
||||
#endif
|
||||
|
||||
return stats
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"defaultModelId": "sherpa-qwen3-0.6b-int8",
|
||||
"runtimes": [
|
||||
{
|
||||
"id": "sherpa-onnx-1.13.4-macos-arm64",
|
||||
"displayName": "sherpa-onnx 1.13.4 (Apple Silicon)",
|
||||
"installRelativePath": "runtimes/sherpa-onnx-1.13.4-macos-arm64",
|
||||
"binaryCandidates": ["bin/sherpa-onnx-offline", "sherpa-onnx-offline"],
|
||||
"archiveFileName": "sherpa-onnx-v1.13.4-osx-arm64-static-no-tts.tar.bz2",
|
||||
"sizeBytes": 120000000,
|
||||
"platform": "macos-arm64",
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/sherpa-onnx-v1.13.4-osx-arm64-static-no-tts.tar.bz2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-onnx-1.13.4-macos-x64",
|
||||
"displayName": "sherpa-onnx 1.13.4 (Intel)",
|
||||
"installRelativePath": "runtimes/sherpa-onnx-1.13.4-macos-x64",
|
||||
"binaryCandidates": ["bin/sherpa-onnx-offline", "sherpa-onnx-offline"],
|
||||
"archiveFileName": "sherpa-onnx-v1.13.4-osx-x64-static-no-tts.tar.bz2",
|
||||
"sizeBytes": 130000000,
|
||||
"platform": "macos-x64",
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/v1.13.4/sherpa-onnx-v1.13.4-osx-x64-static-no-tts.tar.bz2"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "sherpa-qwen3-0.6b-int8",
|
||||
"displayName": "Qwen3-ASR 0.6B",
|
||||
"backend": "sherpaQwen3",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 650000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": true,
|
||||
"hotwordMode": "recognizerScoped",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-qwen3-0.6b-int8",
|
||||
"archiveBaseName": "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25",
|
||||
"layout": {
|
||||
"convFrontend": "conv_frontend.onnx",
|
||||
"encoder": "encoder.int8.onnx",
|
||||
"decoder": "decoder.int8.onnx",
|
||||
"tokenizer": "tokenizer"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-qwen3-1.7b-int8",
|
||||
"displayName": "Qwen3-ASR 1.7B",
|
||||
"backend": "sherpaQwen3",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 1900000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": true,
|
||||
"hotwordMode": "recognizerScoped",
|
||||
"installKind": "repository",
|
||||
"installRelativePath": "models/sherpa-qwen3-1.7b-int8",
|
||||
"archiveBaseName": "sherpa-onnx-qwen3-asr-1.7B-int8",
|
||||
"layout": {
|
||||
"convFrontend": "conv_frontend.onnx",
|
||||
"encoder": "encoder.int8.onnx",
|
||||
"decoder": "decoder.int8.onnx",
|
||||
"tokenizer": "tokenizer"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "modelscope",
|
||||
"priority": 1,
|
||||
"url": "",
|
||||
"baseURL": "https://www.modelscope.cn/models/zengshuishui/Qwen3-ASR-onnx/resolve/master/{path}",
|
||||
"files": [
|
||||
{ "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 },
|
||||
{ "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 },
|
||||
{ "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "huggingface",
|
||||
"priority": 1,
|
||||
"url": "",
|
||||
"baseURL": "https://huggingface.co/zengshuishui/Qwen3-ASR-onnx/resolve/main/{path}",
|
||||
"files": [
|
||||
{ "remotePath": "model_1.7B/conv_frontend.onnx", "localPath": "conv_frontend.onnx", "sizeBytes": 12000000 },
|
||||
{ "remotePath": "model_1.7B/encoder.int8.onnx", "localPath": "encoder.int8.onnx", "sizeBytes": 900000000 },
|
||||
{ "remotePath": "model_1.7B/decoder.int8.onnx", "localPath": "decoder.int8.onnx", "sizeBytes": 700000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/merges.txt", "localPath": "tokenizer/merges.txt", "sizeBytes": 500000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/vocab.json", "localPath": "tokenizer/vocab.json", "sizeBytes": 3000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/tokenizer.json", "localPath": "tokenizer/tokenizer.json", "sizeBytes": 7000000 },
|
||||
{ "remotePath": "model_1.7B/tokenizer/tokenizer_config.json", "localPath": "tokenizer/tokenizer_config.json", "sizeBytes": 10000 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-paraformer-zh-int8",
|
||||
"displayName": "Paraformer Large",
|
||||
"backend": "sherpaParaformer",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 220000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US"],
|
||||
"supportsHotwords": false,
|
||||
"hotwordMode": "none",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-paraformer-zh-int8",
|
||||
"archiveBaseName": "sherpa-onnx-paraformer-zh-int8-2025-10-07",
|
||||
"layout": {
|
||||
"paraformerModel": "model.int8.onnx",
|
||||
"tokens": "tokens.txt"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-paraformer-zh-int8-2025-10-07.tar.bz2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "sherpa-sensevoice-small-int8",
|
||||
"displayName": "SenseVoice Small",
|
||||
"backend": "sherpaSenseVoice",
|
||||
"runtimePlatform": "macos",
|
||||
"sizeBytes": 250000000,
|
||||
"recommendedLocales": ["zh-CN", "en-US", "ja-JP", "ko-KR"],
|
||||
"supportsHotwords": false,
|
||||
"hotwordMode": "none",
|
||||
"installKind": "archive",
|
||||
"installRelativePath": "models/sherpa-sensevoice-small-int8",
|
||||
"archiveBaseName": "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17",
|
||||
"layout": {
|
||||
"senseVoiceModel": "model.int8.onnx",
|
||||
"tokens": "tokens.txt"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"type": "github",
|
||||
"priority": 1,
|
||||
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -123,7 +123,7 @@ public enum ASREvent: Sendable, Equatable {
|
||||
public enum ASRServiceFactory {
|
||||
/// Returns on-device SpeechAnalyzer for `local`, or the user's cloud
|
||||
/// ASR provider when `engineMode == "cloud"`.
|
||||
public static func make(store: AppGroupStore = AppGroupStore()) -> ASRService {
|
||||
public static func make(store: any ConfigurationStore = AppGroupStore()) -> ASRService {
|
||||
if store.engineMode == "cloud" {
|
||||
return CloudASRService(store: store)
|
||||
}
|
||||
|
||||
@@ -16,15 +16,25 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
self.defaults = defaults
|
||||
return
|
||||
}
|
||||
guard let available = AppGroup.defaultsIfAvailable else {
|
||||
#if DEBUG
|
||||
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
|
||||
#else
|
||||
// Callers must check `AppGroup.isAvailable` before constructing.
|
||||
fatalError("App Group unavailable.")
|
||||
#endif
|
||||
if let available = AppGroup.defaultsIfAvailable {
|
||||
self.defaults = available
|
||||
return
|
||||
}
|
||||
self.defaults = available
|
||||
#if os(iOS)
|
||||
// iOS app + keyboard extension MUST share the App Group suite; a
|
||||
// silent `.standard` fallback would desync them. Keep this a hard
|
||||
// failure so a provisioning mistake is impossible to miss.
|
||||
#if DEBUG
|
||||
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
|
||||
#else
|
||||
fatalError("App Group unavailable.")
|
||||
#endif
|
||||
#else
|
||||
// macOS is a standalone menu-bar app with no keyboard extension to
|
||||
// stay in sync with, so a missing App Group container is expected;
|
||||
// fall back to the app's standard defaults.
|
||||
self.defaults = .standard
|
||||
#endif
|
||||
}
|
||||
|
||||
private var configuration: AppGroupConfiguration {
|
||||
@@ -82,6 +92,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
config.model = openAI.defaultModel
|
||||
}
|
||||
}
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setUILanguage(_ language: AppUILanguage) {
|
||||
@@ -165,6 +176,14 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) {
|
||||
mutateConfiguration { config in
|
||||
config.personalDictionary.entries.removeAll { $0.id == id }
|
||||
config.personalDictionary.deletedEntryIDs[id] = date
|
||||
}
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public var personalDictionaryICloudSyncEnabled: Bool {
|
||||
get { configuration.personalDictionaryICloudSyncEnabled }
|
||||
set { setPersonalDictionaryICloudSyncEnabled(newValue) }
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// BuiltinLexiconIndex.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// In-memory index over bundled `phrases.tsv` (~10k computer terms).
|
||||
// macOS local ASR consumes a Top-N subset; the full index also backs
|
||||
// polish supplements and future retrieval.
|
||||
|
||||
import Foundation
|
||||
|
||||
public final class BuiltinLexiconIndex: @unchecked Sendable {
|
||||
|
||||
public struct Term: Sendable, Equatable {
|
||||
public let word: String
|
||||
public let pinyin: String
|
||||
public let source: String
|
||||
public let weight: Int
|
||||
}
|
||||
|
||||
public static let shared = BuiltinLexiconIndex()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var cachedTerms: [Term]?
|
||||
private let injectedURL: URL?
|
||||
|
||||
/// Production singleton loads from the app bundle.
|
||||
private init() {
|
||||
injectedURL = nil
|
||||
}
|
||||
|
||||
/// Test / preview hook with an explicit TSV file or inline fixture.
|
||||
init(fixtureURL: URL) {
|
||||
injectedURL = fixtureURL
|
||||
}
|
||||
|
||||
/// Parse TSV content without touching the bundle (unit tests).
|
||||
public static func parseTSV(_ content: String) -> [Term] {
|
||||
var terms: [Term] = []
|
||||
terms.reserveCapacity(256)
|
||||
|
||||
for (lineIndex, line) in content.split(whereSeparator: \.isNewline).enumerated() {
|
||||
if lineIndex == 0, line.hasPrefix("word\t") { continue }
|
||||
let columns = line.split(separator: "\t", omittingEmptySubsequences: false)
|
||||
guard columns.count >= 4 else { continue }
|
||||
let word = String(columns[0]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !word.isEmpty else { continue }
|
||||
let pinyin = String(columns[1])
|
||||
let source = String(columns[2])
|
||||
let weight = Int(columns[3]) ?? 1
|
||||
terms.append(Term(word: word, pinyin: pinyin, source: source, weight: weight))
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
public func termCount() -> Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return loadTermsLocked().count
|
||||
}
|
||||
|
||||
/// Returns canonical words ranked for ASR bias injection.
|
||||
public func topTerms(
|
||||
limit: Int,
|
||||
minimumWeight: Int = 4,
|
||||
preferredSources: Set<String>? = nil
|
||||
) -> [String] {
|
||||
guard limit > 0 else { return [] }
|
||||
|
||||
lock.lock()
|
||||
let all = loadTermsLocked()
|
||||
lock.unlock()
|
||||
|
||||
let filtered = all.filter { term in
|
||||
guard term.weight >= minimumWeight else { return false }
|
||||
if let preferredSources, !preferredSources.isEmpty {
|
||||
return preferredSources.contains(term.source)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
let ranked = filtered.sorted { lhs, rhs in
|
||||
let leftScore = Self.rankingScore(lhs)
|
||||
let rightScore = Self.rankingScore(rhs)
|
||||
if leftScore != rightScore { return leftScore > rightScore }
|
||||
return lhs.word.localizedCaseInsensitiveCompare(rhs.word) == .orderedAscending
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
var words: [String] = []
|
||||
words.reserveCapacity(min(limit, ranked.count))
|
||||
for term in ranked {
|
||||
let key = term.word.lowercased()
|
||||
guard seen.insert(key).inserted else { continue }
|
||||
words.append(term.word)
|
||||
if words.count >= limit { break }
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func loadTermsLocked() -> [Term] {
|
||||
if let cachedTerms { return cachedTerms }
|
||||
let loaded: [Term]
|
||||
if let injectedURL {
|
||||
loaded = Self.load(from: injectedURL)
|
||||
} else if let url = Self.locateBundledPhrasesURL() {
|
||||
loaded = Self.load(from: url)
|
||||
} else {
|
||||
loaded = []
|
||||
}
|
||||
cachedTerms = loaded
|
||||
return loaded
|
||||
}
|
||||
|
||||
private static func load(from url: URL) -> [Term] {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let content = String(data: data, encoding: .utf8) else {
|
||||
return []
|
||||
}
|
||||
return parseTSV(content)
|
||||
}
|
||||
|
||||
private static func locateBundledPhrasesURL() -> URL? {
|
||||
let candidates: [Bundle] = [Bundle.main, Bundle(for: BuiltinLexiconIndex.self)]
|
||||
for bundle in candidates {
|
||||
if let url = bundle.url(
|
||||
forResource: "phrases",
|
||||
withExtension: "tsv",
|
||||
subdirectory: "CustomLanguageModel/v1"
|
||||
) {
|
||||
return url
|
||||
}
|
||||
if let url = bundle.url(forResource: "phrases", withExtension: "tsv") {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func rankingScore(_ term: Term) -> Int {
|
||||
var score = term.weight * 100
|
||||
if containsLatinLetters(term.word) { score += 50 }
|
||||
if term.word.count <= 8 { score += 10 }
|
||||
return score
|
||||
}
|
||||
|
||||
private static func containsLatinLetters(_ text: String) -> Bool {
|
||||
text.unicodeScalars.contains { scalar in
|
||||
scalar.isASCII && CharacterSet.letters.contains(scalar)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public protocol CloudASRTranscribing: Sendable {
|
||||
}
|
||||
|
||||
public enum CloudASRClientFactory {
|
||||
public static func make(store: AppGroupStore, session: URLSession = .shared) -> CloudASRTranscribing {
|
||||
public static func make(store: any ConfigurationStore, session: URLSession = .shared) -> CloudASRTranscribing {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
||||
switch strategy {
|
||||
case .zhipuHotwords:
|
||||
@@ -29,7 +29,7 @@ public enum CloudASRClientFactory {
|
||||
return AlibabaFunASRClient(
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
store: store,
|
||||
persistence: store.cloudASRPersistence,
|
||||
session: session
|
||||
)
|
||||
case .prompt:
|
||||
@@ -149,12 +149,12 @@ struct ZhipuCloudASRClient: CloudASRTranscribing {
|
||||
|
||||
// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context)
|
||||
|
||||
struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
/// `UserDefaults` is not `Sendable`; we only touch `persistence` on the
|
||||
/// actor-isolated cloud ASR path, same as the previous `AppGroupStore` holder.
|
||||
struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
|
||||
let apiKey: String
|
||||
let model: String
|
||||
// Hold the (@unchecked Sendable) AppGroupStore rather than a raw
|
||||
// UserDefaults so this struct stays Sendable under strict concurrency.
|
||||
let store: AppGroupStore
|
||||
let persistence: UserDefaults
|
||||
let session: URLSession
|
||||
|
||||
func prepare(dictionary: PersonalDictionary) async throws {
|
||||
@@ -162,7 +162,7 @@ struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
dictionary: dictionary,
|
||||
apiKey: apiKey,
|
||||
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: store.defaults,
|
||||
defaults: persistence,
|
||||
session: session
|
||||
)
|
||||
}
|
||||
@@ -179,7 +179,7 @@ struct AlibabaFunASRClient: CloudASRTranscribing {
|
||||
dictionary: dictionary,
|
||||
apiKey: apiKey,
|
||||
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
|
||||
defaults: store.defaults,
|
||||
defaults: persistence,
|
||||
session: session
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import Foundation
|
||||
import os
|
||||
|
||||
public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
private let store: AppGroupStore
|
||||
private let store: any ConfigurationStore
|
||||
private let session: URLSession
|
||||
private let localFallback: ASRService
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
@@ -18,7 +18,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
private var cancelled = false
|
||||
|
||||
public init(
|
||||
store: AppGroupStore = AppGroupStore(),
|
||||
store: any ConfigurationStore = AppGroupStore(),
|
||||
session: URLSession = .shared,
|
||||
localFallback: ASRService? = nil
|
||||
) {
|
||||
|
||||
@@ -123,6 +123,26 @@ private final class FlowLevelStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Last observed audio tap timestamp. This lets the host publish "ready"
|
||||
/// only after the microphone pipeline has produced real frames.
|
||||
private final class FlowAudioProofStore: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock(initialState: TimeInterval(0))
|
||||
|
||||
func markFrameReceived() {
|
||||
lock.withLock { $0 = Date().timeIntervalSince1970 }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
lock.withLock { $0 = 0 }
|
||||
}
|
||||
|
||||
func hasRecentFrame(maxAge: TimeInterval) -> Bool {
|
||||
let timestamp = lock.withLock { $0 }
|
||||
guard timestamp > 0 else { return false }
|
||||
return Date().timeIntervalSince1970 - timestamp <= maxAge
|
||||
}
|
||||
}
|
||||
|
||||
/// Route-adaptive downsampling converter, safe to call from the realtime tap.
|
||||
///
|
||||
/// `AVAudioEngine.installTap(format:)` traps with an **uncatchable** NSException
|
||||
@@ -207,6 +227,7 @@ public final class FlowContinuousCapture {
|
||||
private let streamRelay = FlowCaptureStreamRelay()
|
||||
private let prerollStore = FlowPrerollStore()
|
||||
private let levelStore = FlowLevelStore(barCount: FlowCaptureConstants.levelBarCount)
|
||||
private let audioProofStore = FlowAudioProofStore()
|
||||
private let gate = OSAllocatedUnfairLock(initialState: UtteranceGatePhase.idle)
|
||||
private let drainTracker = FlowCaptureDrainTracker()
|
||||
private let tailSampleCounter = OSAllocatedUnfairLock(initialState: 0)
|
||||
@@ -229,12 +250,28 @@ public final class FlowContinuousCapture {
|
||||
|
||||
public var running: Bool { isRunning }
|
||||
|
||||
/// True when the capture session flag, tap, and audio engine are all live.
|
||||
public var engineIsLive: Bool {
|
||||
isRunning && didInstallTap && audioEngine.isRunning
|
||||
}
|
||||
|
||||
/// True only when the engine is live and the input tap has recently
|
||||
/// delivered an actual audio frame.
|
||||
public func engineHasRecentAudio(maxAge: TimeInterval = 1) -> Bool {
|
||||
engineIsLive && audioProofStore.hasRecentFrame(maxAge: maxAge)
|
||||
}
|
||||
|
||||
/// Called on the main actor when `engineIsLive` may have changed.
|
||||
public var onEngineLiveChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||
public func start() throws {
|
||||
guard !isRunning else { return }
|
||||
audioProofStore.reset()
|
||||
try activateEngine()
|
||||
isRunning = true
|
||||
installSessionObservers()
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
/// Bring up the audio session + engine for the *current* hardware route.
|
||||
@@ -289,6 +326,7 @@ public final class FlowContinuousCapture {
|
||||
let relay = streamRelay
|
||||
let preroll = prerollStore
|
||||
let levels = levelStore
|
||||
let proof = audioProofStore
|
||||
let tracker = drainTracker
|
||||
let tailCounter = tailSampleCounter
|
||||
let policy = drainPolicy
|
||||
@@ -296,6 +334,7 @@ public final class FlowContinuousCapture {
|
||||
downsampler: downsampler,
|
||||
gate: gateLock,
|
||||
levelStore: levels,
|
||||
audioProofStore: proof,
|
||||
prerollStore: preroll,
|
||||
streamRelay: relay,
|
||||
drainTracker: tracker,
|
||||
@@ -332,6 +371,7 @@ public final class FlowContinuousCapture {
|
||||
audioEngine.stop()
|
||||
}
|
||||
isRunning = false
|
||||
audioProofStore.reset()
|
||||
downsampler = nil
|
||||
targetFormat = nil
|
||||
hwFormat = nil
|
||||
@@ -339,24 +379,47 @@ public final class FlowContinuousCapture {
|
||||
false,
|
||||
options: .notifyOthersOnDeactivation
|
||||
)
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
|
||||
/// Re-activate capture after returning from background without
|
||||
/// reinstalling the tap (iOS may deactivate the audio session).
|
||||
public func reassertIfRunning() {
|
||||
guard isRunning else { return }
|
||||
@discardableResult
|
||||
public func reassertIfRunning() -> Bool {
|
||||
guard isRunning else { return false }
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .measurement,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
try? session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
if !audioEngine.isRunning {
|
||||
try? audioEngine.start()
|
||||
do {
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .measurement,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
if !audioEngine.isRunning {
|
||||
try audioEngine.start()
|
||||
}
|
||||
notifyEngineLiveChanged()
|
||||
return engineIsLive
|
||||
} catch {
|
||||
notifyEngineLiveChanged()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func awaitAudioFlowing(
|
||||
timeout: TimeInterval,
|
||||
recentFrameMaxAge: TimeInterval = 1
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if engineHasRecentAudio(maxAge: recentFrameMaxAge) {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
return engineHasRecentAudio(maxAge: recentFrameMaxAge)
|
||||
}
|
||||
|
||||
// MARK: - Route / interruption recovery
|
||||
|
||||
private func installSessionObservers() {
|
||||
@@ -434,6 +497,7 @@ public final class FlowContinuousCapture {
|
||||
switch type {
|
||||
case .began:
|
||||
log.info("Audio interruption began")
|
||||
notifyEngineLiveChanged()
|
||||
case .ended:
|
||||
guard isRunning else { return }
|
||||
let shouldResume: Bool
|
||||
@@ -462,11 +526,17 @@ public final class FlowContinuousCapture {
|
||||
}
|
||||
do {
|
||||
try activateEngine()
|
||||
notifyEngineLiveChanged()
|
||||
} catch {
|
||||
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
|
||||
notifyEngineLiveChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyEngineLiveChanged() {
|
||||
onEngineLiveChanged?(engineIsLive)
|
||||
}
|
||||
|
||||
/// Begin forwarding downsampled buffers to ASR for one utterance.
|
||||
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
|
||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
@@ -546,6 +616,7 @@ public final class FlowContinuousCapture {
|
||||
downsampler: AdaptiveDownsampler,
|
||||
gate: OSAllocatedUnfairLock<UtteranceGatePhase>,
|
||||
levelStore: FlowLevelStore,
|
||||
audioProofStore: FlowAudioProofStore,
|
||||
prerollStore: FlowPrerollStore,
|
||||
streamRelay: FlowCaptureStreamRelay,
|
||||
drainTracker: FlowCaptureDrainTracker,
|
||||
@@ -553,6 +624,7 @@ public final class FlowContinuousCapture {
|
||||
drainPolicy: FlowCaptureTailDrainPolicy
|
||||
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
||||
return { buffer, _ in
|
||||
audioProofStore.markFrameReceived()
|
||||
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
|
||||
|
||||
// Derive the converter from the *live* buffer format so a mid-session
|
||||
|
||||
@@ -6,6 +6,158 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
public enum Action: String, Codable, Sendable {
|
||||
case startRecording
|
||||
case stopRecording
|
||||
case abort
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let action: Action
|
||||
public let localeId: String
|
||||
public let createdAt: TimeInterval
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
action: Action,
|
||||
localeId: String,
|
||||
createdAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.action = action
|
||||
self.localeId = localeId
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct FlowResult: Codable, Equatable, Sendable {
|
||||
public enum Status: String, Codable, Sendable {
|
||||
case partial
|
||||
case final
|
||||
case error
|
||||
case aborted
|
||||
case timeout
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let status: Status
|
||||
public let text: String?
|
||||
public let warning: String?
|
||||
public let errorKind: FlowSessionKeys.TranscriptionErrorKind?
|
||||
public let createdAt: TimeInterval
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
status: Status,
|
||||
text: String? = nil,
|
||||
warning: String? = nil,
|
||||
errorKind: FlowSessionKeys.TranscriptionErrorKind? = nil,
|
||||
createdAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.status = status
|
||||
self.text = text
|
||||
self.warning = warning
|
||||
self.errorKind = errorKind
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct FlowAck: Codable, Equatable, Sendable {
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let consumedAt: TimeInterval
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
consumedAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.consumedAt = consumedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
public enum Reason: String, Codable, Sendable {
|
||||
case ready
|
||||
case noSession
|
||||
case starting
|
||||
case audioEngineNotLive
|
||||
case waitingForAudioProof
|
||||
case recording
|
||||
case processing
|
||||
case permissionMissing
|
||||
case appGroupUnavailable
|
||||
case hostLost
|
||||
case error
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID?
|
||||
public let ready: Bool
|
||||
public let reason: Reason
|
||||
public let heartbeatAt: TimeInterval
|
||||
public let readyAt: TimeInterval?
|
||||
public let audioProofAt: TimeInterval?
|
||||
public let engineMode: String
|
||||
public let localeId: String
|
||||
public let busyUtteranceId: UUID?
|
||||
public let sessionExpiresAt: TimeInterval?
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID?,
|
||||
ready: Bool,
|
||||
reason: Reason,
|
||||
heartbeatAt: TimeInterval = Date().timeIntervalSince1970,
|
||||
readyAt: TimeInterval? = nil,
|
||||
audioProofAt: TimeInterval? = nil,
|
||||
engineMode: String,
|
||||
localeId: String,
|
||||
busyUtteranceId: UUID? = nil,
|
||||
sessionExpiresAt: TimeInterval? = nil
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.ready = ready
|
||||
self.reason = reason
|
||||
self.heartbeatAt = heartbeatAt
|
||||
self.readyAt = readyAt
|
||||
self.audioProofAt = audioProofAt
|
||||
self.engineMode = engineMode
|
||||
self.localeId = localeId
|
||||
self.busyUtteranceId = busyUtteranceId
|
||||
self.sessionExpiresAt = sessionExpiresAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct FlowTranscriptionError: Equatable, Sendable {
|
||||
public let message: String
|
||||
public let kind: FlowSessionKeys.TranscriptionErrorKind
|
||||
@@ -36,10 +188,105 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
}
|
||||
|
||||
private static func encode<T: Encodable>(_ value: T) -> Data? {
|
||||
try? JSONEncoder().encode(value)
|
||||
}
|
||||
|
||||
private static func decode<T: Decodable>(_ type: T.Type, from data: Data?) -> T? {
|
||||
guard let data else { return nil }
|
||||
return try? JSONDecoder().decode(type, from: data)
|
||||
}
|
||||
|
||||
/// Keyboard/read side: refresh App Group defaults after the extension was
|
||||
/// suspended so decisions are not based on stale in-process caches.
|
||||
public static func reloadFromDisk(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if Thread.isMainThread {
|
||||
store.synchronize()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Typed Flow protocol
|
||||
|
||||
public static func writeCommand(_ command: FlowCommand, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let data = encode(command) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowCommandPayload)
|
||||
}
|
||||
flush(store)
|
||||
FlowSessionDarwin.postCommandChanged()
|
||||
}
|
||||
|
||||
public static func latestCommand(defaults: UserDefaults? = nil) -> FlowCommand? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return decode(FlowCommand.self, from: store.data(forKey: FlowSessionKeys.flowCommandPayload))
|
||||
}
|
||||
|
||||
public static func writeResult(_ result: FlowResult, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let data = encode(result) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowResultPayload)
|
||||
}
|
||||
flush(store)
|
||||
FlowSessionDarwin.postTranscriptionChanged()
|
||||
}
|
||||
|
||||
public static func latestResult(defaults: UserDefaults? = nil) -> FlowResult? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return decode(FlowResult.self, from: store.data(forKey: FlowSessionKeys.flowResultPayload))
|
||||
}
|
||||
|
||||
public static func clearResult(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func writeAck(_ ack: FlowAck, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let data = encode(ack) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowAckPayload)
|
||||
}
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func latestAck(defaults: UserDefaults? = nil) -> FlowAck? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return decode(FlowAck.self, from: store.data(forKey: FlowSessionKeys.flowAckPayload))
|
||||
}
|
||||
|
||||
public static func writeReadySnapshot(_ snapshot: FlowReadySnapshot, defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let data = encode(snapshot) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
if snapshot.ready {
|
||||
store.set(true, forKey: FlowSessionKeys.flowHostReady)
|
||||
if let readyAt = snapshot.readyAt {
|
||||
store.set(readyAt, forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
}
|
||||
} else {
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
if let expires = snapshot.sessionExpiresAt {
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
}
|
||||
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
flush(store)
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
|
||||
public static func readySnapshot(defaults: UserDefaults? = nil) -> FlowReadySnapshot? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return decode(FlowReadySnapshot.self, from: store.data(forKey: FlowSessionKeys.flowReadyPayload))
|
||||
}
|
||||
|
||||
// MARK: - Session lifecycle (host app)
|
||||
|
||||
public static func markSessionActive(
|
||||
duration: TimeInterval? = nil,
|
||||
sessionId: UUID? = nil,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
@@ -50,8 +297,26 @@ public enum FlowSessionBridge {
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
|
||||
writeHeartbeat(defaults: store)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
|
||||
if let sessionId {
|
||||
let snapshot = FlowReadySnapshot(
|
||||
sessionId: sessionId,
|
||||
ready: false,
|
||||
reason: .starting,
|
||||
heartbeatAt: now,
|
||||
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
|
||||
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
|
||||
sessionExpiresAt: expires
|
||||
)
|
||||
if let data = encode(snapshot) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
} else {
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
flush(store)
|
||||
}
|
||||
|
||||
@@ -60,14 +325,22 @@ public enum FlowSessionBridge {
|
||||
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
public static func writeHeartbeat(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
let now = Date().timeIntervalSince1970
|
||||
store.set(now, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
if store.bool(forKey: FlowSessionKeys.flowHostReady) {
|
||||
store.set(now, forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
}
|
||||
flush(store)
|
||||
}
|
||||
|
||||
@@ -118,8 +391,7 @@ public enum FlowSessionBridge {
|
||||
// MARK: - Session validity (keyboard)
|
||||
|
||||
/// True when the App Group session contract is still valid (not expired).
|
||||
/// Does **not** mean the host process is alive — use `isHostReachable()` for
|
||||
/// recording gates and "session ready" UI.
|
||||
/// Does **not** mean the host can accept utterances — use `isHostReady()`.
|
||||
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
|
||||
@@ -137,8 +409,8 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
|
||||
/// True when the host app recently wrote a heartbeat (foreground or
|
||||
/// actively processing). Gating record / "session ready" UI must use this,
|
||||
/// not `isSessionActive()` alone.
|
||||
/// actively processing). Use for zombie / disconnect detection — **not**
|
||||
/// for mic-ready UI; prefer `isHostReady()`.
|
||||
public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
guard isSessionActive(defaults: store) else { return false }
|
||||
@@ -146,6 +418,53 @@ public enum FlowSessionBridge {
|
||||
return staleness <= FlowSessionKeys.heartbeatStaleInterval
|
||||
}
|
||||
|
||||
// MARK: - Host ready contract (host app → keyboard)
|
||||
|
||||
/// Host app: publish whether Flow can accept a new utterance right now.
|
||||
public static func setHostReady(
|
||||
_ ready: Bool,
|
||||
defaults: UserDefaults? = nil,
|
||||
notify: Bool = true
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if ready {
|
||||
let now = Date().timeIntervalSince1970
|
||||
store.set(true, forKey: FlowSessionKeys.flowHostReady)
|
||||
store.set(now, forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
writeHeartbeat(defaults: store)
|
||||
} else {
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
}
|
||||
flush(store)
|
||||
if notify {
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
|
||||
public static func isHostReady(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let snapshot = readySnapshot(defaults: store) {
|
||||
guard snapshot.ready else { return false }
|
||||
guard isHostReachable(defaults: store) else { return false }
|
||||
if let readyAt = snapshot.readyAt {
|
||||
let skew = abs(snapshot.heartbeatAt - readyAt)
|
||||
guard skew <= FlowSessionKeys.hostReadyMaxHeartbeatSkew else { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard isHostReachable(defaults: store) else { return false }
|
||||
return store.bool(forKey: FlowSessionKeys.flowHostReady)
|
||||
}
|
||||
|
||||
private static func clearHostReady(defaults: UserDefaults, notify: Bool) {
|
||||
defaults.removeObject(forKey: FlowSessionKeys.flowHostReady)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
if notify {
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the session contract flag is still set but the host heartbeat
|
||||
/// proves the process is gone (reboot, force-quit, long suspend).
|
||||
public static func isHostStale(
|
||||
@@ -342,10 +661,15 @@ public enum FlowSessionBridge {
|
||||
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ import Foundation
|
||||
|
||||
public enum FlowSessionDarwin {
|
||||
public static let notificationName = "com.osgkeyboard.flow.session.changed"
|
||||
/// Posted when the keyboard writes a command for the host app.
|
||||
public static let commandNotificationName = "com.osgkeyboard.flow.command.changed"
|
||||
/// Posted when the host app writes a transcription result or error.
|
||||
public static let transcriptionNotificationName = "com.osgkeyboard.flow.transcription.changed"
|
||||
/// Posted when the host app publishes or clears the ready contract.
|
||||
public static let hostReadyNotificationName = "com.osgkeyboard.flow.host.ready.changed"
|
||||
|
||||
public static func postSessionChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
@@ -21,6 +25,16 @@ public enum FlowSessionDarwin {
|
||||
)
|
||||
}
|
||||
|
||||
public static func postCommandChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(commandNotificationName as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
public static func postTranscriptionChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
@@ -30,6 +44,16 @@ public enum FlowSessionDarwin {
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
public static func postHostReadyChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(hostReadyNotificationName as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes Darwin notifications on a background thread; invokes
|
||||
|
||||
@@ -7,9 +7,17 @@
|
||||
import Foundation
|
||||
|
||||
public enum FlowSessionKeys {
|
||||
public static let flowCommandPayload = "flow.commandPayload.v1"
|
||||
public static let flowResultPayload = "flow.resultPayload.v1"
|
||||
public static let flowAckPayload = "flow.ackPayload.v1"
|
||||
public static let flowReadyPayload = "flow.readyPayload.v1"
|
||||
public static let flowSessionActive = "flow.flowSessionActive"
|
||||
public static let flowSessionExpires = "flow.flowSessionExpires"
|
||||
public static let flowHeartbeat = "flow.flowHeartbeat"
|
||||
/// Host-published contract: capture + polling idle and able to accept utterances.
|
||||
public static let flowHostReady = "flow.flowHostReady"
|
||||
/// Wall-clock timestamp paired with `flowHostReady` (seconds since 1970).
|
||||
public static let flowHostReadyAt = "flow.flowHostReadyAt"
|
||||
public static let keyboardRecordingState = "flow.keyboardRecordingState"
|
||||
public static let transcriptionLanguage = "flow.transcriptionLanguage"
|
||||
public static let transcriptionResult = "flow.transcriptionResult"
|
||||
@@ -29,6 +37,9 @@ public enum FlowSessionKeys {
|
||||
/// Heartbeat older than this → host is not actively reachable for recording.
|
||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||
|
||||
/// `flowHostReadyAt` must be within this window of the latest heartbeat.
|
||||
public static let hostReadyMaxHeartbeatSkew: TimeInterval = 5
|
||||
|
||||
/// Session flag still set but heartbeat older than this → host process is
|
||||
/// dead (force-quit, reboot). Keyboard / host should clear persisted state.
|
||||
public static let heartbeatZombieInterval: TimeInterval = 60
|
||||
@@ -67,7 +78,7 @@ public enum FlowSessionKeys {
|
||||
}
|
||||
|
||||
/// Structured host → keyboard transcription failure kind.
|
||||
public enum TranscriptionErrorKind: String, Sendable, Equatable {
|
||||
public enum TranscriptionErrorKind: String, Sendable, Equatable, Codable {
|
||||
case noSpeech
|
||||
case recognitionInterrupted
|
||||
case audioUnavailable
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single entry point for iCloud KVS sync in the main app: preferences
|
||||
// toggles, settings payload, and personal dictionary.
|
||||
// toggles, usage statistics, settings payload, speech history, and
|
||||
// personal dictionary.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -14,18 +15,28 @@ public final class AppCloudSync {
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let settingsSync: SettingsCloudSync
|
||||
private let dictionarySync: PersonalDictionaryCloudSync
|
||||
private let usageStatisticsSync: UsageStatisticsCloudSync
|
||||
private let speechHistorySync: SpeechHistoryCloudSync
|
||||
private var externalChangeObserver: NSObjectProtocol?
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard },
|
||||
settingsSync: SettingsCloudSync? = nil,
|
||||
dictionarySync: PersonalDictionaryCloudSync? = nil
|
||||
dictionarySync: PersonalDictionaryCloudSync? = nil,
|
||||
usageStatisticsSync: UsageStatisticsCloudSync? = nil,
|
||||
speechHistorySync: SpeechHistoryCloudSync? = nil
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.settingsSync = settingsSync ?? SettingsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.settingsSync = settingsSync
|
||||
?? SettingsCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
self.dictionarySync = dictionarySync ?? PersonalDictionaryCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.usageStatisticsSync = usageStatisticsSync
|
||||
?? UsageStatisticsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.speechHistorySync = speechHistorySync
|
||||
?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
}
|
||||
|
||||
public func startObservingExternalChanges() {
|
||||
@@ -62,9 +73,28 @@ public final class AppCloudSync {
|
||||
)
|
||||
|
||||
await settingsSync.pullAndMergeIfEnabled()
|
||||
await usageStatisticsSync.pullAndMergeIfEnabled()
|
||||
await speechHistorySync.pullAndMergeIfEnabled()
|
||||
await dictionarySync.pullAndMergeIfEnabled()
|
||||
}
|
||||
|
||||
/// Low-risk manual sync: pull remote changes, merge, then push local state.
|
||||
public func syncNow() async throws {
|
||||
let store = makeStore()
|
||||
await pullAllIfEnabled()
|
||||
|
||||
if store.settingsICloudSyncEnabled {
|
||||
try await settingsSync.pushLocalIfEnabled()
|
||||
try await usageStatisticsSync.pushLocalIfEnabled()
|
||||
try await speechHistorySync.pushLocalIfEnabled()
|
||||
}
|
||||
if store.personalDictionaryICloudSyncEnabled {
|
||||
try await dictionarySync.pushLocalIfEnabled(store.personalDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
public var settingsSyncService: SettingsCloudSync { settingsSync }
|
||||
public var dictionarySyncService: PersonalDictionaryCloudSync { dictionarySync }
|
||||
public var usageStatisticsSyncService: UsageStatisticsCloudSync { usageStatisticsSync }
|
||||
public var speechHistorySyncService: SpeechHistoryCloudSync { speechHistorySync }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// CloudSyncContext.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Injectable AppCloudSync instance so iOS and Mac share one sync graph.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public enum CloudSyncContext {
|
||||
private static var configured: AppCloudSync?
|
||||
|
||||
public static var shared: AppCloudSync {
|
||||
configured ?? AppCloudSync.shared
|
||||
}
|
||||
|
||||
public static func configure(_ sync: AppCloudSync) {
|
||||
configured = sync
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// SettingsCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors user-facing app settings through iCloud KVS. API keys stay
|
||||
// in Keychain and are never uploaded.
|
||||
// Mirrors user-facing app settings through iCloud KVS (`appSettings.v2`)
|
||||
// with per-field merge. API keys sync via iCloud Keychain — never KVS.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -22,17 +22,21 @@ public enum SettingsCloudSyncError: Error, Equatable, Sendable {
|
||||
public final class SettingsCloudSync {
|
||||
public static let shared = SettingsCloudSync()
|
||||
|
||||
public static let kvsKey = "appSettings.v1"
|
||||
public static let kvsKey = SyncedAppSettingsV2.kvsKey
|
||||
public static let legacyKVSKey = SyncedAppSettings.legacyKVSKey
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let historyDefaults: () -> UserDefaults
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.historyDefaults = historyDefaults
|
||||
}
|
||||
|
||||
public func pullAndMergeIfEnabled() async {
|
||||
@@ -44,8 +48,15 @@ public final class SettingsCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
|
||||
try push(local)
|
||||
let deviceID = SyncDeviceID.current(defaults: store.defaults)
|
||||
let config = store.configurationSnapshot()
|
||||
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
|
||||
local = local.patchLocalChanges(from: config, deviceID: deviceID)
|
||||
saveLocalPayload(local, to: store.defaults)
|
||||
|
||||
let remote = loadRemote()
|
||||
let toPush = remote.map { SyncedAppSettingsV2.merge(local: local, remote: $0) } ?? local
|
||||
try push(toPush)
|
||||
}
|
||||
|
||||
public func enableSync() async throws {
|
||||
@@ -57,12 +68,26 @@ public final class SettingsCloudSync {
|
||||
store: store
|
||||
)
|
||||
|
||||
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
|
||||
Keychain.migrateLocalKeysToICloud()
|
||||
|
||||
let deviceID = SyncDeviceID.current(defaults: store.defaults)
|
||||
let config = store.configurationSnapshot()
|
||||
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
|
||||
local = local.patchLocalChanges(from: config, deviceID: deviceID)
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedAppSettings.merge(local: local, remote: remote)
|
||||
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
|
||||
apply(merged, to: store, postNotification: false)
|
||||
try push(merged)
|
||||
|
||||
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
|
||||
let statisticsSync = UsageStatisticsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
try await statisticsSync.mergeAndPushIfEnabled()
|
||||
let historySync = SpeechHistoryCloudSync(
|
||||
kvs: kvs,
|
||||
makeStore: makeStore,
|
||||
historyDefaults: historyDefaults
|
||||
)
|
||||
try await historySync.mergeAndPushIfEnabled()
|
||||
}
|
||||
|
||||
public func disableSync() {
|
||||
@@ -75,17 +100,19 @@ public final class SettingsCloudSync {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let local = SyncedAppSettings.from(
|
||||
configuration: store.configurationSnapshot(),
|
||||
updatedAt: store.settingsCloudUpdatedAt ?? .distantPast
|
||||
)
|
||||
let merged = SyncedAppSettings.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
let deviceID = SyncDeviceID.current(defaults: store.defaults)
|
||||
let config = store.configurationSnapshot()
|
||||
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
|
||||
local = local.patchLocalChanges(from: config, deviceID: deviceID)
|
||||
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
|
||||
var trial = config
|
||||
merged.applying(to: &trial)
|
||||
guard trial != config else { return }
|
||||
|
||||
apply(merged, to: store, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ settings: SyncedAppSettings) throws {
|
||||
public func push(_ settings: SyncedAppSettingsV2) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(settings) else {
|
||||
@@ -95,12 +122,28 @@ public final class SettingsCloudSync {
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedAppSettings? {
|
||||
guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
|
||||
return try? decode(data)
|
||||
public func loadRemote() -> SyncedAppSettingsV2? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decodeV2(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey),
|
||||
let legacy = try? decodeLegacy(legacyData) else {
|
||||
return nil
|
||||
}
|
||||
let deviceID = SyncDeviceID.current()
|
||||
return SyncedAppSettingsV2.migrated(from: legacy, deviceID: deviceID)
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> SyncedAppSettings {
|
||||
public func decodeV2(_ data: Data) throws -> SyncedAppSettingsV2 {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let settings = try? decoder.decode(SyncedAppSettingsV2.self, from: data) else {
|
||||
throw SettingsCloudSyncError.decodeFailed
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
public func decodeLegacy(_ data: Data) throws -> SyncedAppSettings {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let settings = try? decoder.decode(SyncedAppSettings.self, from: data) else {
|
||||
@@ -110,18 +153,42 @@ public final class SettingsCloudSync {
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ settings: SyncedAppSettings,
|
||||
_ settings: SyncedAppSettingsV2,
|
||||
to store: AppGroupStore,
|
||||
postNotification: Bool
|
||||
) {
|
||||
var config = store.configurationSnapshot()
|
||||
settings.applying(to: &config)
|
||||
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.updatedAt)
|
||||
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.latestUpdatedAt)
|
||||
saveLocalPayload(settings, to: store.defaults)
|
||||
if postNotification {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLocalPayload(
|
||||
from defaults: UserDefaults,
|
||||
configuration: AppGroupConfiguration,
|
||||
deviceID: String
|
||||
) -> SyncedAppSettingsV2 {
|
||||
if let data = defaults.data(forKey: AppGroupConfiguration.Keys.settingsCloudPayloadV2),
|
||||
let payload = try? JSONDecoder().decode(SyncedAppSettingsV2.self, from: data) {
|
||||
return payload
|
||||
}
|
||||
let stamp = defaults.object(forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt) as? TimeInterval
|
||||
let updatedAt = stamp.map { Date(timeIntervalSince1970: $0) } ?? .distantPast
|
||||
return SyncedAppSettingsV2.seeded(
|
||||
from: configuration,
|
||||
deviceID: deviceID,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
private func saveLocalPayload(_ payload: SyncedAppSettingsV2, to defaults: UserDefaults) {
|
||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
||||
defaults.set(data, forKey: AppGroupConfiguration.Keys.settingsCloudPayloadV2)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppGroupStore {
|
||||
@@ -132,6 +199,9 @@ private extension AppGroupStore {
|
||||
func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) {
|
||||
let config = configuration
|
||||
config.save(to: defaults)
|
||||
defaults.set(settingsCloudUpdatedAt.timeIntervalSince1970, forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt)
|
||||
defaults.set(
|
||||
settingsCloudUpdatedAt.timeIntervalSince1970,
|
||||
forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// SpeechHistoryCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors speech history through iCloud KVS when settings sync is enabled.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
/// Posted after remote speech history is applied locally.
|
||||
static let speechHistoryDidSyncFromCloud = Notification.Name(
|
||||
"com.osgkeyboard.speechHistory.didSyncFromCloud"
|
||||
)
|
||||
}
|
||||
|
||||
public enum SpeechHistoryCloudSyncError: Error, Equatable, Sendable {
|
||||
case payloadTooLarge(byteCount: Int)
|
||||
case encodeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class SpeechHistoryCloudSync {
|
||||
public static let shared = SpeechHistoryCloudSync()
|
||||
|
||||
public static let kvsKey = SyncedSpeechHistory.kvsKey
|
||||
public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let historyDefaults: () -> UserDefaults
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.historyDefaults = historyDefaults
|
||||
}
|
||||
|
||||
public func pullAndMergeIfEnabled() async {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
await pullAndMerge(store: store)
|
||||
}
|
||||
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SpeechHistoryStorage.load(from: historyDefaults())
|
||||
try push(local)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote history.
|
||||
public func mergeAndPushIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
||||
apply(merged, to: defaults, postNotification: false)
|
||||
try push(merged)
|
||||
NotificationCenter.default.post(name: .speechHistoryDidSyncFromCloud, object: nil)
|
||||
}
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
|
||||
apply(merged, to: defaults, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ history: SyncedSpeechHistory) throws {
|
||||
let data = try encode(history)
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedSpeechHistory? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decode(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
|
||||
return try? decode(legacyData)
|
||||
}
|
||||
|
||||
public func encode(_ history: SyncedSpeechHistory) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(history) else {
|
||||
throw SpeechHistoryCloudSyncError.encodeFailed
|
||||
}
|
||||
guard data.count <= Self.maxPayloadBytes else {
|
||||
throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: data.count)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> SyncedSpeechHistory {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let history = try? decoder.decode(SyncedSpeechHistory.self, from: data) else {
|
||||
throw SpeechHistoryCloudSyncError.decodeFailed
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ history: SyncedSpeechHistory,
|
||||
to defaults: UserDefaults,
|
||||
postNotification: Bool
|
||||
) {
|
||||
SpeechHistoryStorage.save(history, to: defaults)
|
||||
if postNotification {
|
||||
NotificationCenter.default.post(name: .speechHistoryDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SyncDeviceID.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Stable per-install identifier for per-field / per-device iCloud merge.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SyncDeviceID {
|
||||
private static let defaultsKey = "sync.deviceID.v1"
|
||||
|
||||
/// Returns a stable device id stored in the active defaults suite.
|
||||
public static func current(defaults: UserDefaults = AppGroupStore().defaults) -> String {
|
||||
if let existing = defaults.string(forKey: defaultsKey), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let created = UUID().uuidString
|
||||
defaults.set(created, forKey: defaultsKey)
|
||||
return created
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// UsageStatisticsCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors cumulative usage statistics through iCloud KVS (`usageStatistics.v2`)
|
||||
// using per-device G-Counter merge when settings sync is enabled.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
/// Posted after remote usage statistics are applied locally.
|
||||
static let usageStatisticsDidSyncFromCloud = Notification.Name(
|
||||
"com.osgkeyboard.usageStatistics.didSyncFromCloud"
|
||||
)
|
||||
}
|
||||
|
||||
public enum UsageStatisticsCloudSyncError: Error, Equatable, Sendable {
|
||||
case encodeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class UsageStatisticsCloudSync {
|
||||
public static let shared = UsageStatisticsCloudSync()
|
||||
|
||||
public static let kvsKey = SyncedUsageStatisticsV2.kvsKey
|
||||
public static let legacyKVSKey = SyncedUsageStatisticsStorage.legacyStorageKey
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
}
|
||||
|
||||
public func pullAndMergeIfEnabled() async {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
await pullAndMerge(store: store)
|
||||
}
|
||||
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
try push(local)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote totals.
|
||||
public func mergeAndPushIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote)
|
||||
apply(merged, to: store.defaults, postNotification: false)
|
||||
try push(merged)
|
||||
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
|
||||
apply(merged, to: store.defaults, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ stats: SyncedUsageStatisticsV2) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(stats) else {
|
||||
throw UsageStatisticsCloudSyncError.encodeFailed
|
||||
}
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
/// Removes the cumulative-stats payload from iCloud KVS. Used by the
|
||||
/// one-time cleanup that clears data corrupted by the pre-fix
|
||||
/// double-counting bug so it can't be pulled back onto other devices.
|
||||
public func purgeRemote() {
|
||||
kvs.set(Data?.none, forKey: Self.kvsKey)
|
||||
kvs.set(Data?.none, forKey: Self.legacyKVSKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedUsageStatisticsV2? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decodeV2(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
|
||||
let deviceID = SyncDeviceID.current()
|
||||
guard let legacy = try? decodeLegacy(legacyData) else { return nil }
|
||||
return SyncedUsageStatisticsV2.migrated(from: legacy, deviceID: deviceID)
|
||||
}
|
||||
|
||||
public func decodeV2(_ data: Data) throws -> SyncedUsageStatisticsV2 {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let stats = try? decoder.decode(SyncedUsageStatisticsV2.self, from: data) else {
|
||||
throw UsageStatisticsCloudSyncError.decodeFailed
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
public func decodeLegacy(_ data: Data) throws -> UsageStatistics {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let stats = try? decoder.decode(UsageStatistics.self, from: data) else {
|
||||
throw UsageStatisticsCloudSyncError.decodeFailed
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ stats: SyncedUsageStatisticsV2,
|
||||
to defaults: UserDefaults,
|
||||
postNotification: Bool
|
||||
) {
|
||||
SyncedUsageStatisticsStorage.save(stats, to: defaults)
|
||||
if postNotification {
|
||||
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,10 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||
/// Whether the host app's Flow voice session is live and reachable (fresh
|
||||
/// heartbeat). Do not use the App Group session flag alone for UI gating.
|
||||
/// Prefer `micVoiceAvailability` for mic color and tap behavior.
|
||||
@Published public var flowSessionActive: Bool = false
|
||||
/// Unified mic color / tap / hint source for the keyboard extension.
|
||||
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
|
||||
/// When true, the mic is intentionally disabled (e.g. cloud engine
|
||||
/// selected but the provider-specific API key is missing).
|
||||
@Published public var micDisabled: Bool = false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user